From f245e8808ff6bb285e9746463d582528f878c8c1 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Mon, 3 Aug 2026 17:09:26 -0400 Subject: [PATCH] add camcontrol module --- lib/CamControl.lua | 353 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 lib/CamControl.lua 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