DramaticShapeVoxelMod archival mirror · read-only
f245e8808ff6bb285e9746463d582528f878c8c1

add camcontrol module

DramaticShape · 2026-08-03 · parent c79ecbb7ac

add camcontrol module

Changed files

lib/CamControl.lua | 353 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 353 insertions(+)

Diff

diff --git a/lib/CamControl.lua b/lib/CamControl.lua
new file mode 100644
index 0000000..9061645
--- /dev/null
+++ b/lib/CamControl.lua
@@ -0,0 +1,353 @@
+-- The player's own camera controls: zoom everywhere, and the battle's orbit.
+--
+-- This mod has four cameras, and by the time a wheel notch arrives they all
+-- want it. So one module owns the INPUTS and answers the only question that
+-- matters -- which camera is this aimed at -- rather than each camera
+-- growing its own wheel handler and racing the others for the event:
+--
+--   a staged battle      the camera the fight is shot with (BattleCam): the
+--                        wheel and Q/E work its lens, and the right stick,
+--                        a drag or the mouse walk it around the arena.
+--
+--   the 3RD rung         the boom behind the player's shoulder
+--                        (ThirdPerson): the wheel, Q/E and a pinch let it
+--                        out and pull it in.
+--
+--   an orbit rung        the engine's own survey zoom, which the wheel has
+--                        always driven -- so here the module mostly gets
+--                        out of the way, and only ADDS the two keys and the
+--                        pinch that the engine has no handler for.
+--
+--   the 1ST rung         nothing. The eye is in the player's head; there is
+--                        no distance to change, and a pinch there would
+--                        silently wind the survey zoom for whenever they
+--                        stepped back out. Inputs pass through untouched.
+--
+-- Every claim is answered by a GATE rather than by a mode flag, and every
+-- wrap forwards whatever it does not claim -- so with voxel mode off, and
+-- on every screen that is not the overworld or a battle, each byte flows
+-- exactly where it always did.
+--
+-- Installed AFTER FirstPerson (see main.lua), which makes these wraps the
+-- outer ones: a battle's controls get first refusal on the mouse and the
+-- touch screen, which is right, because while a fight is staged the
+-- free-roam look is not driving anyway.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local Voxel = V.require("VoxelState")
+local Voxel3D = V.require("Voxel3D")
+local FirstPerson = V.require("FirstPerson")
+local ThirdPerson = V.require("ThirdPerson")
+local BattleCam = V.require("BattleCam")
+
+local CamControl = {}
+
+-- ------- tuning
+--
+-- PINCH_SLACK is how far apart two fingers must travel, as a ratio of
+-- their starting gap, before the gesture counts as a pinch at all -- below
+-- it a two-finger tap wobbles rather than zooms.
+--
+-- SURVEY_PINCH is how many of the engine's integer survey steps one
+-- doubling of the finger gap is worth. The survey ladder is coarse (whole
+-- pixels per world pixel), so a pinch has to be geared down or the first
+-- centimetre of travel crosses the whole range.
+CamControl.PINCH_SLACK = 0.02
+CamControl.SURVEY_PINCH = 2.2
+
+-- ------- gates
+
+-- A fight staged on the map, drawn and on screen. Asked of the shot rather
+-- than of the battle state, because the shot is exactly "there is a 3D
+-- battle in front of the player right now" -- with 3D-BTL off, or on a map
+-- with no arena, the engine's own flat battle screen is up and its camera
+-- is not ours to steer.
+local function battleLive()
+  local ok, shot = pcall(function()
+    return V.require("OverworldBattle").shot()
+  end)
+  return (ok and shot) and true or false
+end
+
+CamControl.battleLive = battleLive
+
+-- The free-roam overworld, with the 3D pass carrying it: the gate every
+-- zoom that is not a battle's answers to.
+local function roaming()
+  return Voxel.active() and Voxel3D.available() and FirstPerson.onTop()
+end
+
+-- Which camera a zoom is aimed at: "battle", "boom", "survey", or nil for
+-- nothing that zooms (1ST, or a screen with no camera of ours behind it).
+function CamControl.zoomTarget()
+  if battleLive() then return "battle" end
+  if not roaming() then return nil end
+  if Voxel.isThirdPerson(Voxel.level) then return "boom" end
+  if Voxel.isFirstPerson(Voxel.level) then return nil end
+  return "survey"
+end
+
+-- ------- zoom
+--
+-- `notches` is signed the way every zoom in this file is: POSITIVE pulls
+-- the camera OUT. The engine's own survey step runs the other way, and is
+-- negated at the one place it is called rather than everywhere else being
+-- bent to match it.
+--
+-- Returns true when the input was ours, which is what tells a wrap to stop
+-- rather than forward.
+
+local function surveyStep(notches)
+  local ok = pcall(function()
+    local Game = require("src.core.Game")
+    Game:zoomStep(notches > 0 and -1 or 1)
+  end)
+  return ok
+end
+
+function CamControl.zoomBy(notches)
+  if not notches or notches == 0 then return false end
+  local target = CamControl.zoomTarget()
+  if target == "battle" then
+    BattleCam.stepZoom(notches)
+    return true
+  elseif target == "boom" then
+    ThirdPerson.stepZoom(notches)
+    return true
+  elseif target == "survey" then
+    -- one call per notch: the engine's ladder is integer rungs, and a
+    -- wheel spun hard should climb them all rather than one
+    for _ = 1, math.min(8, math.abs(notches)) do surveyStep(notches) end
+    return true
+  end
+  return false
+end
+
+-- A pinch's own scale: > 1 is fingers spreading, which means zoom IN
+-- (pull the world closer), which is a NEGATIVE notch count.
+function CamControl.pinchBy(factor)
+  if not (factor and factor > 0) then return false end
+  local target = CamControl.zoomTarget()
+  if target == "boom" then
+    return ThirdPerson.scaleZoom(1 / factor)
+  elseif target == "battle" then
+    -- battles take a pinch too: the wheel and the keys reach this camera
+    -- and a phone has neither, so without it the lens would be the one
+    -- control a touch screen could not work
+    return BattleCam.stepZoom(math.log(1 / factor)
+                              / math.log(BattleCam.ZOOM_STEP))
+  elseif target == "survey" then
+    CamControl.surveyAccum = (CamControl.surveyAccum or 0)
+      + math.log(factor) / math.log(2) * CamControl.SURVEY_PINCH
+    local moved = false
+    while CamControl.surveyAccum >= 1 do
+      CamControl.surveyAccum = CamControl.surveyAccum - 1
+      surveyStep(-1)
+      moved = true
+    end
+    while CamControl.surveyAccum <= -1 do
+      CamControl.surveyAccum = CamControl.surveyAccum + 1
+      surveyStep(1)
+      moved = true
+    end
+    return moved
+  end
+  return false
+end
+
+CamControl.surveyAccum = 0
+
+-- ------- the battle's orbit
+--
+-- Only ever the battle's: the free-roam rungs already steer their own look
+-- through FirstPerson, and these wraps sit outside it precisely so a fight
+-- can borrow the same devices without either of them growing a mode check.
+
+-- The right stick, read as a rate off the axes FirstPerson's own wrap is
+-- already recording (it records whatever the rung, so a battle can read
+-- them without a second wrap on the same seam). Ticked from
+-- OverworldBattle.update, which runs whatever is on top of the stack.
+function CamControl.tick(dt)
+  if not battleLive() then return end
+  local x = FirstPerson.stickX()
+  if x ~= 0 then BattleCam.stickOrbit(x, dt) end
+end
+
+-- ------- the wraps
+
+local installed = false
+
+function CamControl.install()
+  if installed then return end
+  installed = true
+
+  local Game = require("src.core.Game")
+
+  -- ------- the wheel
+  --
+  -- The engine's own handler is the survey zoom, so the wrap only has to
+  -- take the notch away when some OTHER camera wants it; "survey" falls
+  -- through to exactly the code that always ran.
+  do
+    local inner = Game.wheelmoved
+    function Game:wheelmoved(dx, dy)
+      local target = CamControl.zoomTarget()
+      if (target == "battle" or target == "boom") and dy and dy ~= 0 then
+        CamControl.zoomBy(dy > 0 and -1 or 1)
+        return
+      end
+      return inner(self, dx, dy)
+    end
+  end
+
+  -- ------- the mouse
+  --
+  -- Battle only. The free-roam look already owns relative motion through
+  -- FirstPerson's own wrap (this one is outside it, so what is claimed here
+  -- never reaches it) and a fight is exactly when that look is not driving.
+  do
+    local inner = love.mousemoved
+    love.mousemoved = function(x, y, dx, dy, istouch)
+      if battleLive() and not istouch and dx and dx ~= 0 then
+        BattleCam.mouseOrbit(dx)
+        -- forwarded anyway: the cursor still has UI to point at, and the
+        -- orbit is a read of the motion rather than a claim on it
+      end
+      if inner then return inner(x, y, dx, dy, istouch) end
+    end
+  end
+
+  -- ------- the touch screen
+  --
+  -- Two gestures, told apart by how many fingers are down on OPEN screen
+  -- (the overlay's own d-pad and buttons are never either):
+  --
+  --   one finger, in a battle    drags the shot around the arena
+  --   two fingers               pinch to zoom, wherever zooming means
+  --                             something -- and while they are down the
+  --                             free-roam look stands aside, so a pinch in
+  --                             3RD does not also spin the view
+  local TouchControls = require("src.core.TouchControls")
+
+  local free = {}          -- id -> {x, y} for every finger on open screen
+  local pinch = nil        -- { a, b, gap } while two of them are pinching
+
+  local function freeCount()
+    local n = 0
+    for _ in pairs(free) do n = n + 1 end
+    return n
+  end
+
+  local function gapOf(a, b)
+    local dx, dy = free[a].x - free[b].x, free[a].y - free[b].y
+    return math.sqrt(dx * dx + dy * dy)
+  end
+
+  -- Two free fingers and a camera that zooms: start measuring. The look
+  -- drag is dropped for the duration -- FirstPerson never sees the moves
+  -- below -- and re-seated on whichever finger survives, so the view does
+  -- not jump by however far the pinch travelled.
+  local function startPinch()
+    if pinch or freeCount() < 2 then return end
+    local ids = {}
+    for id in pairs(free) do ids[#ids + 1] = id end
+    local gap = gapOf(ids[1], ids[2])
+    if gap < 16 then return end
+    pinch = { a = ids[1], b = ids[2], gap = gap }
+    CamControl.surveyAccum = 0
+    pcall(FirstPerson.dropLook)
+  end
+
+  local function endPinch(lifted)
+    if not pinch then return end
+    local survivor = nil
+    for id in pairs(free) do
+      if id ~= lifted then survivor = id break end
+    end
+    pinch = nil
+    if survivor and free[survivor] then
+      pcall(FirstPerson.reseatLook, survivor,
+            free[survivor].x, free[survivor].y)
+    end
+  end
+
+  local function onControl(x, y)
+    local hit = nil
+    pcall(function() hit = TouchControls:hitTest(x, y) end)
+    return hit
+  end
+
+  -- Whether this module has any interest in touches at all this frame.
+  -- Kept deliberately wide -- a battle, or anything that zooms -- because
+  -- the wrap forwards everything it does not claim regardless.
+  local function wantsTouch()
+    return battleLive() or CamControl.zoomTarget() ~= nil
+  end
+
+  do
+    local inner = Game.touchpressed
+    function Game:touchpressed(id, x, y)
+      if wantsTouch() and not onControl(x, y) then
+        free[id] = { x = x, y = y }
+        if CamControl.zoomTarget() then startPinch() end
+        -- forwarded even so: a single free finger is the free-roam look's
+        -- to claim (FirstPerson's wrap is inside this one), and in a
+        -- battle it is nobody's until it MOVES
+      end
+      return inner(self, id, x, y)
+    end
+  end
+
+  do
+    local inner = Game.touchmoved
+    function Game:touchmoved(id, x, y)
+      local f = free[id]
+      if f then
+        local px, py = f.x, f.y
+        f.x, f.y = x, y
+        if pinch and (id == pinch.a or id == pinch.b) then
+          local gap = gapOf(pinch.a, pinch.b)
+          local factor = gap / math.max(1, pinch.gap)
+          if math.abs(factor - 1) > CamControl.PINCH_SLACK then
+            CamControl.pinchBy(factor)
+            pinch.gap = gap
+          end
+          return                       -- claimed: never a look drag too
+        end
+        if battleLive() and not pinch then
+          local w = 1280
+          pcall(function() w = love.graphics.getWidth() end)
+          BattleCam.dragOrbit((x - px) / math.max(320, w))
+          return
+        end
+      end
+      return inner(self, id, x, y)
+    end
+  end
+
+  do
+    local inner = Game.touchreleased
+    function Game:touchreleased(id, x, y)
+      if free[id] then
+        if pinch and (id == pinch.a or id == pinch.b) then endPinch(id) end
+        free[id] = nil
+      end
+      return inner(self, id, x, y)
+    end
+  end
+
+  -- a reset that drops held input state drops ours with it, exactly as the
+  -- free-roam look's does
+  do
+    local inner = Game.focus
+    function Game:focus(f)
+      free, pinch = {}, nil
+      CamControl.surveyAccum = 0
+      return inner(self, f)
+    end
+  end
+end
+
+return CamControl