From c79ecbb7ac04b7c3689b94ad581d4df9fe3eebea Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Mon, 3 Aug 2026 17:09:03 -0400 Subject: [PATCH] add camera zoom controls in overworld --- lib/BattleArena.lua | 5 +- lib/BattleCam.lua | 153 ++++++++++++++++++++++++++++++++++++++-- lib/BattleScene.lua | 5 +- lib/FirstPerson.lua | 44 ++++++++++-- lib/OverworldBattle.lua | 56 ++++++++++++++- lib/ThirdPerson.lua | 64 ++++++++++++++++- main.lua | 23 ++++++ 7 files changed, 332 insertions(+), 18 deletions(-) diff --git a/lib/BattleArena.lua b/lib/BattleArena.lua index 20f0e04..025fe8f 100644 --- a/lib/BattleArena.lua +++ b/lib/BattleArena.lua @@ -242,7 +242,10 @@ end -- Whether both mons would be in plain view from the battle camera. function BattleArena.clearance(map, arena) local BattleCam = V.require("BattleCam") - local ok, rig = pcall(BattleCam.rig, arena, 0) + -- the CANONICAL shot: whether a fight fits somewhere is a fact about the + -- ground, so it must not depend on the drift's phase or on where the + -- player last swung the camera (see BattleCam.rig's third argument) + local ok, rig = pcall(BattleCam.rig, arena, 0, true) if not (ok and rig and rig.eye) then return true end local eye = rig.eye local H = BattleArena.MON_H diff --git a/lib/BattleCam.lua b/lib/BattleCam.lua index 09448a5..51ea4b3 100644 --- a/lib/BattleCam.lua +++ b/lib/BattleCam.lua @@ -124,6 +124,50 @@ BattleCam.PAN_PERIOD = 26 -- seconds for one there-and-back BattleCam.PAN_DOLLY = 0.02 -- how far the eye breathes, as a fraction BattleCam.DOLLY_PERIOD = 37 +-- ------- the player's own orbit +-- +-- The drift above is the shot breathing. THIS is the player steering it: +-- a right stick, a drag across the screen or the mouse walks the eye +-- around the arena's axis, and it stops at both ends. +-- +-- 0 is the shot the rig was solved for and the LEFT stop, because there is +-- nothing to the left of it -- the composition below is what the whole +-- module exists to land, and past it the two mons start swapping sides. +-- +-- 1 is SIDE-ON: the eye swung round until it is square to the arena's +-- north-south axis, where the two mons stand at the same distance instead +-- of one behind the other. That is as far as the picture stays a battle +-- rather than a diorama with two Pokemon in it, and it is a different angle +-- for each rig -- the tele lens starts 28 degrees off the axis and the wide +-- one 45 -- so the stop is COMPUTED from the rig rather than written down, +-- and retuning either moves its own stop with it. +-- +-- The input is deliberately not 1:1 with the pixels: it accumulates into +-- `orbitGoal` and the live angle eases after it, so a flick reads as the +-- camera being pushed rather than as the camera being dragged. +BattleCam.ORBIT_TIME = 0.22 -- seconds for the eye to catch its goal +BattleCam.ORBIT_DRAG = 1.15 -- fraction of the range per screen width +BattleCam.ORBIT_STICK = 0.9 -- fraction of the range per second, full tilt +BattleCam.ORBIT_MOUSE = 0.0011 -- fraction of the range per mouse count +BattleCam.STICK_DEAD = 0.2 + +-- ------- and the player's own zoom +-- +-- How much world the frame holds, as a multiple of the rig's own frameH: +-- BELOW one is zoomed in. It has to be the LENS rather than the distance, +-- because the rig derives its field of view from frameH and the distance +-- together -- so moving the eye alone changes the perspective and not the +-- framing, which is exactly what the dolly breath above is for. +BattleCam.ZOOM_MIN = 0.45 -- the pair filling the frame +BattleCam.ZOOM_MAX = 2.0 -- the fight in its own landscape +BattleCam.ZOOM_STEP = 1.15 +BattleCam.ZOOM_TIME = 0.18 + +BattleCam.orbit = 0 +BattleCam.orbitGoal = 0 +BattleCam.zoom = 1 +BattleCam.zoomGoal = 1 + -- Hold the rig perfectly still (VR sets this while a session runs). The -- drift exists to give a FLAT screen the depth cue the picture cannot -- have; a headset gets real parallax from the player's own head, and a @@ -134,8 +178,79 @@ BattleCam.still = false BattleCam.t = 0 +-- Every fight opens on the shot the rig was solved for: the orbit and the +-- zoom are a way of LOOKING at this battle, not a preference carried into +-- the next one, and a player who left the camera side-on an hour ago should +-- not have the next encounter open there. function BattleCam.reset() BattleCam.t = 0 + BattleCam.orbit, BattleCam.orbitGoal = 0, 0 + BattleCam.zoom, BattleCam.zoomGoal = 1, 1 +end + +-- How far the eye may swing, in radians, before it is square to the arena's +-- axis. The rig's own stance decides it: `side` and `back` are the offset +-- it starts at, so the bearing it starts on is atan2(side, back) and what +-- is left to a quarter turn is the room the player has. +function BattleCam.orbitRange(arena) + local R = BattleCam.rigFor(arena) + return math.max(0, math.pi / 2 - math.atan2(R.side, R.back)) +end + +-- ------- what the player's inputs reach +-- +-- All four take a signed amount and clamp; positive is RIGHTWARD, toward +-- the side-on stop. Returning whether the goal actually moved lets a +-- caller tell "steered" from "already against the stop". + +local function setOrbit(goal) + local was = BattleCam.orbitGoal + BattleCam.orbitGoal = math.max(0, math.min(1, goal)) + return BattleCam.orbitGoal ~= was +end + +-- A drag, in fractions of the screen's width. +function BattleCam.dragOrbit(fraction) + return setOrbit(BattleCam.orbitGoal + (fraction or 0) * BattleCam.ORBIT_DRAG) +end + +-- Relative mouse motion, in counts. +function BattleCam.mouseOrbit(dx) + return setOrbit(BattleCam.orbitGoal + (dx or 0) * BattleCam.ORBIT_MOUSE) +end + +-- A stick held for `dt` seconds, as a rate with a squared response -- the +-- first half of the throw aims and the rest travels, the same curve the +-- free-roam look uses. +function BattleCam.stickOrbit(x, dt) + local a = math.abs(x or 0) + if a < BattleCam.STICK_DEAD then return false end + a = (a - BattleCam.STICK_DEAD) / (1 - BattleCam.STICK_DEAD) + local v = ((x < 0) and -1 or 1) * a * a + return setOrbit(BattleCam.orbitGoal + + v * BattleCam.ORBIT_STICK * (dt or 0)) +end + +-- The zoom, in notches (positive pulls OUT, like every other zoom here). +function BattleCam.stepZoom(notches) + local was = BattleCam.zoomGoal + BattleCam.zoomGoal = math.max(BattleCam.ZOOM_MIN, + math.min(BattleCam.ZOOM_MAX, + was * (BattleCam.ZOOM_STEP ^ (notches or 0)))) + return BattleCam.zoomGoal ~= was +end + +-- How much world the frame holds right now: the rig's own reach at the +-- player's zoom. The sun's box is fitted to this too, so a zoomed shot +-- lights exactly the ground it shows. +function BattleCam.frameH(arena) + return BattleCam.rigFor(arena).frameH * BattleCam.zoom +end + +local function chase(now, goal, dt, time) + if now == goal then return goal end + local v = now + (goal - now) * math.min(1, (dt or 0) / time) + return (math.abs(goal - v) < 1e-4) and goal or v end -- Real frame time, like every other presentational tween in this mod: a @@ -146,6 +261,12 @@ function BattleCam.update(dt) -- float precision in the sines below local wrap = BattleCam.PAN_PERIOD * BattleCam.DOLLY_PERIOD if BattleCam.t > wrap then BattleCam.t = BattleCam.t - wrap end + -- and the steered pair easing after whatever the player last asked for, + -- which is what keeps a flick of the stick from being a cut + BattleCam.orbit = chase(BattleCam.orbit, BattleCam.orbitGoal, dt, + BattleCam.ORBIT_TIME) + BattleCam.zoom = chase(BattleCam.zoom, BattleCam.zoomGoal, dt, + BattleCam.ZOOM_TIME) end local function phase(t, period) @@ -163,17 +284,32 @@ end -- -- `groundY` is the height of the arena floor, so a fight staged on a ledge -- or a raised walkway is shot from above THAT rather than from inside it. -function BattleCam.rig(arena, groundY) +-- `canonical` asks for the shot the rig was SOLVED for -- no drift, no +-- breath, no steer, no zoom -- from a caller that is reasoning about the +-- arena rather than drawing it. BattleArena's clearance test is the one +-- that needs it: whether a fight can be staged somewhere is a fact about +-- the ground, and answering it through whatever angle the player happened +-- to leave the last battle on would pick a different arena depending on +-- where they had swung the camera an hour ago. +function BattleCam.rig(arena, groundY, canonical) groundY = groundY or 0 local R = BattleCam.rigFor(arena) local mx, mz = arena.mid[1], arena.mid[2] + -- VR asks for the same stillness for its own reason (see BattleCam.still) + local fixed = BattleCam.still or canonical - local yaw = BattleCam.still and 0 - or BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD) + -- The drift, plus wherever the player has steered to. The steer is + -- NEGATIVE because the rotation below runs the other way from the bearing + -- it turns: rotating (side, back) by +yaw carries the eye back toward the + -- arena's own axis, and the room the player has is all on the far side of + -- that -- out toward square-on. (orbitRange measures exactly that room.) + local steer = fixed and 0 or -BattleCam.orbit * BattleCam.orbitRange(arena) + local yaw = steer + (fixed and 0 + or BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD)) local c, s = math.cos(yaw), math.sin(yaw) -- the breath scales the whole offset, height included, so the eye moves -- along its own line to the arena and the pitch of the shot never changes - local k = BattleCam.still and 1 + local k = fixed and 1 or 1 + BattleCam.PAN_DOLLY * phase(BattleCam.t, BattleCam.DOLLY_PERIOD) local dx = (R.side * c - R.back * s) * k @@ -188,10 +324,17 @@ function BattleCam.rig(arena, groundY) local dist = math.max(1, math.sqrt(ex * ex + ey * ey + ez * ez)) local horiz = math.sqrt(ex * ex + ez * ez) + -- The lens carries the player's zoom: how much world the frame holds is + -- the one thing that actually changes the framing here, because the field + -- of view is DERIVED from that reach and the distance. Moving the eye + -- instead would leave the picture the same size and only change its + -- perspective -- which is what the dolly breath above is deliberately + -- for, and is not what "zoom" means to anyone holding a wheel. + local frameH = fixed and R.frameH or BattleCam.frameH(arena) return { eye = eye, focus = focus, - fov = 2 * math.atan((R.frameH / 2) / dist), + fov = 2 * math.atan((frameH / 2) / dist), -- the world curve is a free-roam flourish that bends the horizon away -- from the player; a fixed camera on a staged shot has no player to bend -- around, and the bend would tip the arena floor out from under the mons diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 19adad0..66f13ca 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -472,7 +472,10 @@ function BattleScene.render(state, arena, textures, token) local cx, cy = arena.mid[1], arena.mid[2] -- the world extents the sun frustum is fitted to; the camera itself is -- framed by cam.fov, so these only have to describe the ground in shot - local vh = BattleCam.rigFor(arena).frameH * ph / (BattleScene.GB_H * s) + -- the player's zoom is part of this: the sun's box is fitted to what the + -- frame holds, so a shot pulled wide has to light the ground it just + -- brought into view rather than the ground the rig alone would have + local vh = BattleCam.frameH(arena) * ph / (BattleScene.GB_H * s) local vw = vh * pw / ph -- the cards need the camera's eye to face it, so the rig has to be live diff --git a/lib/FirstPerson.lua b/lib/FirstPerson.lua index ab30de5..9409332 100644 --- a/lib/FirstPerson.lua +++ b/lib/FirstPerson.lua @@ -149,11 +149,11 @@ function FirstPerson.engaged() return Voxel.isFreeCam(Voxel.level) and Voxel3D.available() end --- Whether first person should be READING the player's inputs right now: --- engaged, with the overworld on top of the stack (a menu, a dialog or a --- battle above it owns the buttons, exactly as it does for grid walking). -function FirstPerson.driving() - if not FirstPerson.engaged() then return false end +-- Whether the overworld is what the player is looking at: nothing pushed +-- over it, so the buttons are free-roam's. Shared with everything else in +-- the mod that asks the same question of the same stack (CamControl's +-- zooms above all), rather than each restating the pcall. +function FirstPerson.onTop() local ok, top, ow = pcall(function() local Game = require("src.core.Game") return Game.stack and Game.stack:top(), Game.overworld @@ -161,6 +161,40 @@ function FirstPerson.driving() return ok and top ~= nil and top == ow end +-- Whether first person should be READING the player's inputs right now: +-- engaged, with the overworld on top of the stack (a menu, a dialog or a +-- battle above it owns the buttons, exactly as it does for grid walking). +function FirstPerson.driving() + return FirstPerson.engaged() and FirstPerson.onTop() +end + +-- The right stick's live X, for a camera that is not this one: while a +-- battle is staged the free-roam look is not driving, but the axes are +-- still arriving on the wrap below (which records whatever the rung), and +-- the battle's orbit wants them. Reading them here rather than wrapping +-- the same seam twice. +function FirstPerson.stickX() + return stick.x or 0 +end + +-- ------- lending the look finger out +-- +-- A pinch needs both fingers on the screen, and one of them is very likely +-- the finger this module claimed as the look drag. Rather than have the +-- pinch fight for it, CamControl asks for it: dropLook while the gesture +-- runs, reseatLook on whichever finger survives it. Re-seating rather than +-- simply releasing is what stops the view snapping by however far the +-- pinch travelled -- the finger carries on as the look drag from where it +-- now is, which is what it looks like it should do. +function FirstPerson.dropLook() + lookTouch = nil +end + +function FirstPerson.reseatLook(id, x, y) + if id == nil then lookTouch = nil return end + lookTouch = { id = id, x = x, y = y } +end + -- The eased blend, 0 at the orbit and 1 in the head. function FirstPerson.blendEased() return ease(FirstPerson.blend) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index f5a1aae..f4055dd 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -249,6 +249,31 @@ OverworldBattle.TEXT_RECT = { mimic = { 0, 56, 128, 40 }, } +-- How far apart the two anchors are: the spacing every move animation was +-- authored against, and so the yardstick the live pair is measured with. +OverworldBattle.ANCHOR_SPAN = math.sqrt( + (OverworldBattle.ANCHOR.enemy[1] - OverworldBattle.ANCHOR.player[1]) ^ 2 + + (OverworldBattle.ANCHOR.enemy[2] - OverworldBattle.ANCHOR.player[2]) ^ 2) + +-- The effects layer's scale for this shot: how far apart the two mons +-- actually are on screen, over how far apart the slots they were authored +-- for were. Clamped hard at both ends -- an effect is pixel art and a wild +-- factor is worse than a slightly wrong one -- and held at exactly 1 when +-- the marks coincide, which is a projection about to degenerate rather +-- than a pair that has genuinely closed up. +OverworldBattle.ANIM_SCALE_MIN = 0.5 +OverworldBattle.ANIM_SCALE_MAX = 2.0 + +function OverworldBattle.animScale(shot, px, py) + if not (shot and shot.enemy and px and py) then return 1 end + local dx, dy = shot.enemy[1] - px, shot.enemy[2] - py + local span = math.sqrt(dx * dx + dy * dy) + if not (span > 1) then return 1 end + local k = span / OverworldBattle.ANCHOR_SPAN + return math.max(OverworldBattle.ANIM_SCALE_MIN, + math.min(OverworldBattle.ANIM_SCALE_MAX, k)) +end + function OverworldBattle.textRects(battle) if not battle or battle.blankForAskName then return {} end local r = OverworldBattle.TEXT_RECT @@ -478,6 +503,11 @@ function OverworldBattle.update(dt) return end + -- the right stick, read as a rate before the rig is built from it: the + -- wheel, the keys, the mouse and a drag all arrive as events and have + -- already landed, but a stick is a HELD position and only a tick can + -- turn it into travel (CamControl, which owns every one of those inputs) + pcall(V.require("CamControl").tick, dt) BattleCam.update(dt) -- the battle only exists once it has been pushed; a session opened at -- pushBattle time has it, one opened from battle.started was handed it @@ -1120,16 +1150,36 @@ function OverworldBattle.install() -- give them. They ride to where the PAIR went: the midpoint of the two -- mons' projected positions, less the midpoint of the slots they used to -- sit in. A hit still lands on the mon it is aimed at. + -- + -- And they ride the pair's SEPARATION as well, because the mons + -- themselves do. Both are geometry standing on the map, so the camera + -- sizes them: zoom in and they grow, swing round to side-on and the two + -- marks close up as the axis foreshortens. A layer that only slid would + -- have held the authored 106-pixel spacing through all of it -- a beam + -- fired between two mons that are no longer that far apart, ending in + -- the air beside the one it was aimed at. Scaling about the same + -- midpoint keeps every authored offset the same fraction of the gap it + -- was authored as. local a = OverworldBattle.ANCHOR -- BACK SPRITES leaves the player's mon exactly where the GB put it, so that side -- contributes no movement at all and the pair's centre has gone half as -- far as the foe's mark did. local px, py = shot.player[1], shot.player[2] if OverworldBattle.backPinned() then px, py = a.player[1], a.player[2] end - local dx = (shot.enemy[1] + px) / 2 - (a.enemy[1] + a.player[1]) / 2 - local dy = (shot.enemy[2] + py) / 2 - (a.enemy[2] + a.player[2]) / 2 + local cx, cy = (shot.enemy[1] + px) / 2, (shot.enemy[2] + py) / 2 + local ax = (a.enemy[1] + a.player[1]) / 2 + local ay = (a.enemy[2] + a.player[2]) / 2 love.graphics.push() - love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5)) + love.graphics.translate(cx - ax, cy - ay) + -- Clamped, and skipped outright if the marks ever coincide: a + -- degenerate projection must leave the effects the size they were + -- rather than collapse them to nothing or blow them across the screen. + local k = OverworldBattle.animScale(shot, px, py) + if k ~= 1 then + love.graphics.translate(ax, ay) + love.graphics.scale(k, k) + love.graphics.translate(-ax, -ay) + end local ok, err = pcall(innerAnim, self, colorized) love.graphics.pop() if not ok then error(err, 0) end diff --git a/lib/ThirdPerson.lua b/lib/ThirdPerson.lua index d146326..2b0c69d 100644 --- a/lib/ThirdPerson.lua +++ b/lib/ThirdPerson.lua @@ -67,6 +67,43 @@ ThirdPerson.SHOULDER = 4 -- dive so stepping 75 -> 1ST -> 3RD reads as one continuous camera ThirdPerson.BOOM_TIME = 0.35 +-- ------- the player's own zoom +-- +-- A multiplier on BOOM, stepped by the wheel, Q/E or a pinch (see +-- CamControl, which owns every one of those and decides which camera a +-- given input is aimed at). The range is deliberately wider IN than OUT: +-- close is the shot people reach for, and far enough out the character is +-- a few pixels and the rung may as well be an orbit rung. +-- +-- Stepped in fractions rather than world pixels so a notch feels the same +-- at both ends -- the near end of a linear step would crawl and the far +-- end would leap. +ThirdPerson.ZOOM_MIN = 0.45 -- ~22px: over the shoulder, close +ThirdPerson.ZOOM_MAX = 2.4 -- ~115px: the character in a landscape +ThirdPerson.ZOOM_STEP = 1.18 -- one wheel notch / key press +ThirdPerson.ZOOM_TIME = 0.18 -- how fast the eye eases to a new one + +ThirdPerson.zoom = 1 -- eased, what place() actually uses +ThirdPerson.zoomGoal = 1 -- what the input asked for + +-- Step the zoom by `notches` (positive pulls the camera OUT). Returns true +-- when the goal actually moved, so a caller can tell "zoomed" from "already +-- at the stop" and let the input fall through. +function ThirdPerson.stepZoom(notches) + local was = ThirdPerson.zoomGoal + local goal = was * (ThirdPerson.ZOOM_STEP ^ (notches or 0)) + ThirdPerson.zoomGoal = math.max(ThirdPerson.ZOOM_MIN, + math.min(ThirdPerson.ZOOM_MAX, goal)) + return ThirdPerson.zoomGoal ~= was +end + +-- Scale the zoom by a continuous factor -- what a pinch hands over, where +-- the gesture's own scale IS the answer and there are no notches. +function ThirdPerson.scaleZoom(factor) + if not (factor and factor > 0) then return false end + return ThirdPerson.stepZoom(math.log(factor) / math.log(ThirdPerson.ZOOM_STEP)) +end + -- ------- the collision's numbers -- -- STEP is how far apart the samples along the boom line are, in world @@ -263,10 +300,20 @@ end -- target rather than easing, so picking 3RD from an orbit rung is one -- motion (the dive) rather than two (a dive, then a slide backwards). function ThirdPerson.update(dt, blend) + -- the player's own zoom FIRST, so everything below measures itself + -- against the boom length this frame actually wants. A step is a request + -- rather than a jump: three notches of wheel should read as one glide. + local zg = ThirdPerson.zoomGoal + if ThirdPerson.zoom ~= zg then + local k = math.min(1, dt / ThirdPerson.ZOOM_TIME) + local z = ThirdPerson.zoom + (zg - ThirdPerson.zoom) * k + ThirdPerson.zoom = (math.abs(zg - z) < 1e-4) and zg or z + end + local target = ThirdPerson.selected() and 1 or 0 if (blend or 0) <= 0 then ThirdPerson.out = target - ThirdPerson.len = ThirdPerson.BOOM * target + ThirdPerson.len = ThirdPerson.reachFor() * target -- and the wanted length with it: place() is what normally maintains it -- and it does not run at all while the rig is out of the frame, so a -- stale want left here would have the recovery below creeping the boom @@ -290,6 +337,13 @@ function ThirdPerson.update(dt, blend) end end +-- The boom's full length right now, before the world has its say: BOOM at +-- the player's own zoom. Named so the collision march and the shoulder +-- fade measure themselves against the same number. +function ThirdPerson.reachFor() + return ThirdPerson.BOOM * ThirdPerson.zoom +end + -- ------- the eye -- -- Where the camera stands, given the pivot the first-person rig would have @@ -310,7 +364,7 @@ function ThirdPerson.place(pivot, lx, ly, lz, focus) local up = ThirdPerson.PIVOT_LIFT * e local orbit = { pivot[1], pivot[2] + up, pivot[3] } - local want = ThirdPerson.BOOM * e + local want = ThirdPerson.reachFor() * e ThirdPerson.want = want local room = ThirdPerson.reach(overworld(), orbit, -lx, -ly, -lz, want) -- in instantly, out only as fast as update() allows @@ -322,10 +376,14 @@ function ThirdPerson.place(pivot, lx, ly, lz, focus) -- distance. Right of the look, flat: cross(look, worldUp) normalized, -- which for a look of (sin y, *, cos y) is (-cos y, 0, sin y) -- the same -- right hand FirstPerson.moveWorld strafes along. + -- The rail rides the ZOOM as well, so it stays the same fraction of the + -- frame at every distance: a fixed four pixels would swamp the close shot + -- and vanish from the wide one. local flat = math.sqrt(lx * lx + lz * lz) local sx, sz = 0, 0 if flat > 1e-6 then - local s = ThirdPerson.SHOULDER * e * (len / math.max(want, 1e-6)) + local s = ThirdPerson.SHOULDER * ThirdPerson.zoom * e + * (len / math.max(want, 1e-6)) sx, sz = -lz / flat * s, lx / flat * s end diff --git a/main.lua b/main.lua index 8f2d776..df02cee 100644 --- a/main.lua +++ b/main.lua @@ -93,6 +93,7 @@ local Water = V.require("Water") local AntiAlias = V.require("AntiAlias") local FirstPerson = V.require("FirstPerson") local FreeMove = V.require("FreeMove") +local CamControl = V.require("CamControl") local VR = V.require("VR") -- Forward declaration: the voxel pipeline's update hook (registered below) @@ -538,6 +539,17 @@ do function Game:keypressed(key) local claim = HOTKEYS[key] local top = self.stack and self.stack:top() + -- Q and E work whichever camera is in front of the player -- the + -- battle's lens, the third-person boom, or the engine's own survey + -- zoom on an orbit rung. CamControl answers which, and answers "none" + -- for 1ST and for every screen with no camera of ours behind it, in + -- which case the key falls through untouched. Ahead of the hotkey + -- table because unlike those it is NOT free-roam only: a staged battle + -- is exactly where the zoom is most wanted. + if (key == "q" or key == "e") + and not (top and top.onKeyPressed) then + if CamControl.zoomBy(key == "q" and 1 or -1) then return end + end -- A screen with its own key handler gets the key first, exactly as the -- engine's first branch does: typing a nickname must not toggle a -- render mode. Only free-roam presses are ours to take. @@ -889,6 +901,17 @@ OverworldBattle.install() FirstPerson.install() FreeMove.install() +-- ------- the zooms, and the battle camera the player can steer +-- +-- CamControl claims the wheel, Q/E, the mouse and the touch screen for +-- whichever camera is actually in front of the player -- the staged +-- battle's, the third-person boom, or the engine's own survey zoom -- and +-- forwards everything else. Installed AFTER the two above deliberately: a +-- wrap installed later is the OUTER one, so a fight gets first refusal on +-- the mouse and the fingers, which is right, because while one is staged +-- the free-roam look is not driving. +CamControl.install() + -- ------- SELECT walks the angle ladder -- -- The same step the "3" key makes, on the pad's own button: a phone (and