DramaticShapeVoxelMod archival mirror · read-only
9542ba94b1f6059dfb13fd6f80b0f42a0732be9e

cam control rotation in battle

DramaticShape · 2026-08-03 · parent f245e8808f

cam control rotation in battle

Changed files

lib/BattleCam.lua             | 193 ++++++++++++++++++++++++++++++++++++++++++++++------
 lib/CamControl.lua            |  76 +++++++++++++++++++--
 lib/FirstPerson.lua           |   4 ++
 lib/FreeMove.lua              |  19 ++++--
 lib/OverworldBattle.lua       |   8 +++
 lib/Structures.lua            |  88 ++++++++++++++++++++++++
 tests/dramatic_shape_test.lua | 227 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 7 files changed, 579 insertions(+), 36 deletions(-)

Diff

diff --git a/lib/BattleCam.lua b/lib/BattleCam.lua
index 51ea4b3..3bd3170 100644
--- a/lib/BattleCam.lua
+++ b/lib/BattleCam.lua
@@ -151,6 +151,24 @@ 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 height it is watched from
+--
+-- The same steering on the other axis, with the same shape of stop at each
+-- end: 0 is the rig's own stance -- the low, near-floor seat the whole
+-- composition is solved around, and the DOWN stop, because below it the
+-- camera starts looking up the arena's nose -- and 1 is 45 degrees above
+-- it, which is high enough to read the ground the fight is standing on
+-- without becoming the diorama's own top-down.
+--
+-- Raised about the FOCUS rather than about the eye, so the aim stays on
+-- the two mons and only the seat climbs; and at a constant radius, so
+-- climbing never changes how big anything is -- that is the zoom's job.
+BattleCam.PITCH_RANGE = math.rad(45)
+BattleCam.PITCH_TIME = 0.22
+BattleCam.PITCH_DRAG = 1.6        -- fraction of the range per screen HEIGHT
+BattleCam.PITCH_STICK = 0.9
+BattleCam.PITCH_MOUSE = 0.0016
+
 -- ------- and the player's own zoom
 --
 -- How much world the frame holds, as a multiple of the rig's own frameH:
@@ -165,9 +183,24 @@ BattleCam.ZOOM_TIME = 0.18
 
 BattleCam.orbit = 0
 BattleCam.orbitGoal = 0
+BattleCam.pitch = 0
+BattleCam.pitchGoal = 0
 BattleCam.zoom = 1
 BattleCam.zoomGoal = 1
 
+-- Whether the player may steer at all. BACK SPRITES clears it: that
+-- setting pins the player's own mon to the GB's own slot on the menu
+-- (OverworldBattle.backPinned) instead of standing it out on the map, so
+-- half the picture is nailed to the frame and half of it is geometry. Swing
+-- the camera under that and the two halves come apart -- the foe walks
+-- around an arena its opponent is not standing in, and the move animations
+-- that reach between them stretch across the gap. There is no angle that
+-- composition survives, so the answer is not to allow one.
+--
+-- Only the STEER is withheld: the slow drift stays, because it was always
+-- there under BACK SPRITES and two degrees is not a composition problem.
+BattleCam.steerable = true
+
 -- 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
@@ -178,13 +211,21 @@ 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.
+-- Only the DRIFT's phase, so every fight opens on the same breath. Where
+-- the player last put the camera is deliberately NOT reset: an angle and a
+-- lens they chose are how they want to watch battles, not a thing about
+-- this battle, and having to re-find them every encounter would make them
+-- not worth setting. They are session state -- a fresh run opens on the
+-- rig's own shot, which is the one the composition is solved for.
 function BattleCam.reset()
   BattleCam.t = 0
+end
+
+-- Back to the solved shot, for anything that wants the composition as
+-- authored rather than as steered.
+function BattleCam.recentre()
   BattleCam.orbit, BattleCam.orbitGoal = 0, 0
+  BattleCam.pitch, BattleCam.pitchGoal = 0, 0
   BattleCam.zoom, BattleCam.zoomGoal = 1, 1
 end
 
@@ -203,36 +244,64 @@ end
 -- 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
+-- Both axes go through here, so the "nothing while BACK SPRITES holds the
+-- composition" rule and the two stops live in one place each.
+local function setAxis(key, goal)
+  if not BattleCam.steerable then return false end
+  local was = BattleCam[key]
+  BattleCam[key] = math.max(0, math.min(1, goal))
+  return BattleCam[key] ~= was
 end
 
--- A drag, in fractions of the screen's width.
+-- A drag, in fractions of the screen's width (orbit) or height (pitch).
 function BattleCam.dragOrbit(fraction)
-  return setOrbit(BattleCam.orbitGoal + (fraction or 0) * BattleCam.ORBIT_DRAG)
+  return setAxis("orbitGoal",
+                 BattleCam.orbitGoal + (fraction or 0) * BattleCam.ORBIT_DRAG)
+end
+
+function BattleCam.dragPitch(fraction)
+  return setAxis("pitchGoal",
+                 BattleCam.pitchGoal + (fraction or 0) * BattleCam.PITCH_DRAG)
 end
 
 -- Relative mouse motion, in counts.
 function BattleCam.mouseOrbit(dx)
-  return setOrbit(BattleCam.orbitGoal + (dx or 0) * BattleCam.ORBIT_MOUSE)
+  return setAxis("orbitGoal",
+                 BattleCam.orbitGoal + (dx or 0) * BattleCam.ORBIT_MOUSE)
+end
+
+function BattleCam.mousePitch(dy)
+  return setAxis("pitchGoal",
+                 BattleCam.pitchGoal + (dy or 0) * BattleCam.PITCH_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
+local function curve(v)
+  local a = math.abs(v or 0)
+  if a < BattleCam.STICK_DEAD then return 0 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))
+  return ((v < 0) and -1 or 1) * a * a
+end
+
+function BattleCam.stickOrbit(x, dt)
+  local v = curve(x)
+  if v == 0 then return false end
+  return setAxis("orbitGoal",
+                 BattleCam.orbitGoal + v * BattleCam.ORBIT_STICK * (dt or 0))
+end
+
+function BattleCam.stickPitch(y, dt)
+  local v = curve(y)
+  if v == 0 then return false end
+  return setAxis("pitchGoal",
+                 BattleCam.pitchGoal + v * BattleCam.PITCH_STICK * (dt or 0))
 end
 
 -- The zoom, in notches (positive pulls OUT, like every other zoom here).
 function BattleCam.stepZoom(notches)
+  if not BattleCam.steerable then return false end
   local was = BattleCam.zoomGoal
   BattleCam.zoomGoal = math.max(BattleCam.ZOOM_MIN,
                         math.min(BattleCam.ZOOM_MAX,
@@ -240,11 +309,64 @@ function BattleCam.stepZoom(notches)
   return BattleCam.zoomGoal ~= was
 end
 
+-- How far apart the two mons READ from the current orbit, as a multiple of
+-- how far apart they read from the solved shot.
+--
+-- The arena's axis runs from one mon to the other, and the solved shot
+-- looks along it at a shallow 28 degrees, which foreshortens that gap to
+-- less than half its length. Swing round to square-on and the
+-- foreshortening is gone: the same two cells now read at their full
+-- separation, better than twice as wide. Left alone, that threw the pair
+-- out to the edges of the frame -- half of each mon off-screen at the
+-- side-on stop, which made the whole far end of the range unusable.
+--
+-- Climbing does the same thing on the other axis -- a raised camera looks
+-- less along the ground and more across it, which un-foreshortens the gap
+-- again -- so the correction has to answer to both.
+--
+-- What it measures is how much of the arena's axis survives projection:
+-- the axis runs due north-south, the view line points back at the arena at
+-- plan bearing `beta` and elevation `elev`, and the part of a unit axis
+-- that lands across the frame rather than along the view is the sine of
+-- the angle between them. The ratio of that to the solved shot's own is
+-- the factor the lens opens by -- 1 at the solved shot by construction,
+-- about 1.9 at side-on, about 1.7 fully raised.
+--
+-- Analytic rather than measured off the built rig, so nothing has to
+-- reason about a camera to ask the question, and so the sun's box (which
+-- asks through frameH) gets the identical number the lens does.
+--
+-- Measured off the STEER alone, deliberately: the drift's own two degrees
+-- moved this before and must keep moving it by exactly as much, or every
+-- battle shot that has ever been taken shifts.
+local function axisSpan(beta, elev)
+  local c = math.cos(elev)
+  local s = math.sin(beta) * c
+  local v = math.sin(elev)
+  return math.sqrt(s * s + v * v)
+end
+
+function BattleCam.spread(arena)
+  local R = BattleCam.rigFor(arena)
+  local beta = math.atan2(R.side, R.back)
+  local elev = math.atan2(R.height - R.lookY,
+                          math.sqrt((R.side - R.lookX) ^ 2 + R.back ^ 2))
+  local home = axisSpan(beta, elev)
+  if home < 1e-6 then return 1 end
+  return axisSpan(beta + BattleCam.orbit * BattleCam.orbitRange(arena),
+                  elev + BattleCam.pitch * BattleCam.PITCH_RANGE) / home
+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.
+-- player's zoom and at whatever the orbit has done to the pair's spacing,
+-- or the rig's own alone whenever both are being withheld (VR's fixed
+-- seat, BACK SPRITES' pinned composition). The sun's box is fitted to this
+-- too, so a zoomed shot lights exactly the ground it shows -- which is why
+-- BattleScene asks this rather than multiplying for itself.
 function BattleCam.frameH(arena)
-  return BattleCam.rigFor(arena).frameH * BattleCam.zoom
+  local base = BattleCam.rigFor(arena).frameH
+  if BattleCam.still or not BattleCam.steerable then return base end
+  return base * BattleCam.zoom * BattleCam.spread(arena)
 end
 
 local function chase(now, goal, dt, time)
@@ -261,10 +383,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,
+  -- and the steered three 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.pitch = chase(BattleCam.pitch, BattleCam.pitchGoal, dt,
+                          BattleCam.PITCH_TIME)
   BattleCam.zoom = chase(BattleCam.zoom, BattleCam.zoomGoal, dt,
                          BattleCam.ZOOM_TIME)
 end
@@ -297,13 +421,16 @@ function BattleCam.rig(arena, groundY, canonical)
   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
+  -- and the steer is withheld a second way, on its own: BACK SPRITES holds
+  -- the composition and the DRIFT still runs under it (see steerable)
+  local steered = (not fixed) and BattleCam.steerable
 
   -- 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 steer = steered and -BattleCam.orbit * BattleCam.orbitRange(arena) or 0
   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)
@@ -318,6 +445,28 @@ function BattleCam.rig(arena, groundY, canonical)
   local eye = { mx + dx, groundY + R.height * k, mz + dz }
   local focus = { mx + R.lookX, groundY + R.lookY, mz }
 
+  -- and the climb: the eye swung UP about the focus, at a constant radius.
+  -- About the focus so the aim stays nailed to the two mons and only the
+  -- seat moves, and at a constant radius so climbing never changes how big
+  -- anything is -- that is the lens's job below, and a rig that did both at
+  -- once would have no way to do either on purpose.
+  local lift = steered and BattleCam.pitch * BattleCam.PITCH_RANGE or 0
+  if lift > 0 then
+    local vx, vy, vz = eye[1] - focus[1], eye[2] - focus[2], eye[3] - focus[3]
+    local flat = math.sqrt(vx * vx + vz * vz)
+    local r = math.sqrt(flat * flat + vy * vy)
+    if flat > 1e-6 and r > 1e-6 then
+      local a = math.atan2(vy, flat) + lift
+      -- short of straight down, always: the placed camera's up vector is
+      -- world up, which degenerates against a view looking exactly along it
+      a = math.min(a, math.rad(85))
+      local nf = r * math.cos(a)
+      eye[1] = focus[1] + vx / flat * nf
+      eye[3] = focus[3] + vz / flat * nf
+      eye[2] = focus[2] + r * math.sin(a)
+    end
+  end
+
   local ex = eye[1] - focus[1]
   local ey = eye[2] - focus[2]
   local ez = eye[3] - focus[3]
diff --git a/lib/CamControl.lua b/lib/CamControl.lua
index 9061645..235958e 100644
--- a/lib/CamControl.lua
+++ b/lib/CamControl.lua
@@ -64,11 +64,17 @@ CamControl.SURVEY_PINCH = 2.2
 -- 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.
+-- BACK SPRITES also closes it, through BattleCam.steerable: that setting
+-- nails the player's own mon to the GB's slot on the menu while the foe
+-- stands out on the map, and no camera angle holds a composition that is
+-- half frame and half world (see BattleCam.steerable, which is where the
+-- reasoning lives and which the RIG answers to as well -- so a stored
+-- angle from before the setting was switched on stands down with it).
 local function battleLive()
   local ok, shot = pcall(function()
     return V.require("OverworldBattle").shot()
   end)
-  return (ok and shot) and true or false
+  return (ok and shot and BattleCam.steerable) and true or false
 end
 
 CamControl.battleLive = battleLive
@@ -169,10 +175,16 @@ CamControl.surveyAccum = 0
 -- 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.
+--
+-- X walks the shot round the arena, Y raises the seat. The Y is NEGATED:
+-- a stick pushed forward reads as negative on SDL's axis, and pushing
+-- forward should send the camera UP and over -- the same "push the camera
+-- where you want it" the drag and the mouse below use.
 function CamControl.tick(dt)
   if not battleLive() then return end
-  local x = FirstPerson.stickX()
+  local x, y = FirstPerson.stickX(), FirstPerson.stickY()
   if x ~= 0 then BattleCam.stickOrbit(x, dt) end
+  if y ~= 0 then BattleCam.stickPitch(-y, dt) end
 end
 
 -- ------- the wraps
@@ -202,18 +214,63 @@ function CamControl.install()
     end
   end
 
+  -- ------- the stick clicks
+  --
+  -- Q and E, on the pad: the left stick's click pulls the camera out and the
+  -- right stick's pulls it in. A controller has no wheel and no number row,
+  -- and the two clicks are the only buttons a Gen 1 pad layout leaves free
+  -- (SELECT already walks the angle ladder).
+  --
+  -- Claimed for the two cameras a pad player can actually be looking at
+  -- while pressing them -- the third-person boom and a staged battle's lens
+  -- -- and forwarded untouched everywhere else, so a player who has rebound
+  -- either click keeps it on every other screen, a rebind capture included.
+  -- Not on the orbit rungs: the survey zoom has the OPTIONS row and the
+  -- wheel already, and taking a pad button for it would be taking one from
+  -- a player who never asked.
+  local CLICK_ZOOMS = { boom = true, battle = true }
+  do
+    local inner = Game.gamepadpressed
+    function Game:gamepadpressed(joystick, button)
+      if (button == "leftstick" or button == "rightstick")
+         and CLICK_ZOOMS[CamControl.zoomTarget() or ""] then
+        CamControl.zoomBy(button == "leftstick" and 1 or -1)
+        return
+      end
+      return inner(self, joystick, button)
+    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.
+  --
+  -- Bare motion, no button held: moving the mouse moves the shot.
+  --
+  -- Each event's contribution is CLAMPED, though, because not every motion
+  -- event is a hand moving. The pointer entering the window, a warp back to
+  -- centre, an alt-tab -- each arrives as ONE event carrying the whole
+  -- distance from wherever the cursor was last seen, and in testing that
+  -- was a couple of hundred counts: enough to swing the shot a quarter of
+  -- the way to side-on before the player had touched anything. A real hand
+  -- delivers its travel as a stream of small events and is unaffected; a
+  -- teleport delivers it as one and is cut down to the size of a flick.
+  local MOUSE_STEP = 40
+  local function clamp(v)
+    return math.max(-MOUSE_STEP, math.min(MOUSE_STEP, v or 0))
+  end
   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)
+      if battleLive() and not istouch then
+        -- dy is NEGATED for the same reason the stick's is: moving the
+        -- mouse away from you sends the camera up and over
+        if dx and dx ~= 0 then BattleCam.mouseOrbit(clamp(dx)) end
+        if dy and dy ~= 0 then BattleCam.mousePitch(-clamp(dy)) end
         -- 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
+        -- steer 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
@@ -317,9 +374,14 @@ function CamControl.install()
           return                       -- claimed: never a look drag too
         end
         if battleLive() and not pinch then
-          local w = 1280
-          pcall(function() w = love.graphics.getWidth() end)
+          local w, h = 1280, 720
+          pcall(function()
+            w, h = love.graphics.getWidth(), love.graphics.getHeight()
+          end)
           BattleCam.dragOrbit((x - px) / math.max(320, w))
+          -- dragged UP sends the camera up and over, the same way the
+          -- stick and the mouse do
+          BattleCam.dragPitch(-(y - py) / math.max(240, h))
           return
         end
       end
diff --git a/lib/FirstPerson.lua b/lib/FirstPerson.lua
index 9409332..5511d99 100644
--- a/lib/FirstPerson.lua
+++ b/lib/FirstPerson.lua
@@ -177,6 +177,10 @@ function FirstPerson.stickX()
   return stick.x or 0
 end
 
+function FirstPerson.stickY()
+  return stick.y or 0
+end
+
 -- ------- lending the look finger out
 --
 -- A pinch needs both fingers on the screen, and one of them is very likely
diff --git a/lib/FreeMove.lua b/lib/FreeMove.lua
index 3c7182a..cb9f2e8 100644
--- a/lib/FreeMove.lua
+++ b/lib/FreeMove.lua
@@ -209,13 +209,14 @@ local function pushSpecials(state, dir, why)
       return true
     end
   end
-  if why ~= "entity" then
-    if (state.bumpCooldown or 0) <= 0 then
-      local Game = require("src.core.Game")
-      require("src.core.Sound").play(Game.data, "Collision")
-      state.bumpCooldown = 16
-    end
-  end
+  -- and NO bonk. The grid walk's collision sound marks a discrete event:
+  -- you pressed a direction, the step was refused, nothing happened. A
+  -- free walk has no such moment -- the body slides along every wall it
+  -- grazes, continuously, and a corridor taken at a slight angle is a
+  -- steady graze from end to end. Rate-limited or not, that came out as a
+  -- machine-gun of bonks for walking normally down a hallway. The wall
+  -- stopping you is the feedback; the sound only ever said so twice a
+  -- second whether or not anything had changed.
   return false
 end
 
@@ -286,6 +287,10 @@ function FreeMove.tick(state)
   -- either way -- bodyBearing says so.
   p.facing = FirstPerson.pointBody(wx, wz)
 
+  -- the engine's own bonk clock, kept draining while the free walk has the
+  -- wheel: nothing here rings it (see pushSpecials), but stepping back onto
+  -- the grid must not inherit a cooldown frozen at whatever it held when
+  -- the rung was picked
   state.bumpCooldown = math.max(0, (state.bumpCooldown or 0) - 1)
 
   local speed = (Game.save and Game.save.onBike) and FreeMove.BIKE
diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua
index f4055dd..2114264 100644
--- a/lib/OverworldBattle.lua
+++ b/lib/OverworldBattle.lua
@@ -503,6 +503,14 @@ function OverworldBattle.update(dt)
     return
   end
 
+  -- Whether the shot is the player's to steer at all. BACK SPRITES pins
+  -- their own mon to the GB's slot on the menu while the foe stands out on
+  -- the map, and there is no angle that half-framed, half-solid
+  -- composition survives -- so under it the camera holds the shot the rig
+  -- was solved for (the slow drift aside, which was always there). Polled
+  -- per frame rather than latched at battle start: the row is reachable
+  -- from the mod manager's page mid-session.
+  BattleCam.steerable = not OverworldBattle.backPinned()
   -- 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
diff --git a/lib/Structures.lua b/lib/Structures.lua
index ad3d5ec..be4aac2 100644
--- a/lib/Structures.lua
+++ b/lib/Structures.lua
@@ -3289,6 +3289,56 @@ end
 
 -- ---- tall grass ----
 
+-- ---- closing a standee's sides ----
+--
+-- The grass tufts and the flowers are both built the same way: each row of
+-- the 8x8 drawing becomes a horizontal RUN of lit pixels, stood up as a
+-- front face and a back face one voxel apart, with a lid on top. What that
+-- leaves open is the two ENDS of every run -- so the slab was a pair of
+-- billboards rather than a solid, and from any angle off square you looked
+-- in through the edge and straight out the other side. At the low cameras
+-- this mod has grown (1ST, 3RD, the battle's floor-level seat) that is
+-- most of the time.
+--
+-- A wall goes on an end only where the pixel beyond it is actually clear,
+-- which for a run's end it is by construction -- except where two runs on
+-- the same row meet across a gap of nothing, which cannot happen, and at
+-- the tile's border, where the neighbouring tile's own standee may or may
+-- not continue the shape. The border is closed anyway: tufts sit on their
+-- own half-cells with a gap between them, so an open border edge is a hole
+-- in the open, not a seam with anything.
+--
+-- Each wall samples ONE texel at its centre -- the end pixel it is closing
+-- off -- so it wears that pixel's own colour, which is the nearest coloured
+-- pixel to the surface being filled. Sampling a single texel is also what
+-- carries the animation: when a frame keys that pixel out, the wall's own
+-- fragments discard with the faces either side of it, so a swaying tuft
+-- never leaves a wall standing where its blade no longer is.
+local function sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
+                         ax0, ay0, atlasW, atlasH, py, lit)
+  local function texel(px)
+    return (ax0 + px + 0.5) / atlasW, (ay0 + py + 0.5) / atlasH
+  end
+  if not lit(ix - 1, py) then
+    local u, v = texel(ix)
+    quads[#quads + 1] = {                 -- the run's left wall, facing -X
+      { ix, yBot, zB }, { ix, yBot, zF },
+      { ix, yTop, zF }, { ix, yTop, zB },
+      uv = { { u, v }, { u, v }, { u, v }, { u, v } },
+      shade = OBJ_SHADE.side,
+    }
+  end
+  if not lit(ix2 + 1, py) then
+    local u, v = texel(ix2)
+    quads[#quads + 1] = {                 -- and its right wall, facing +X
+      { ix2 + 1, yBot, zF }, { ix2 + 1, yBot, zB },
+      { ix2 + 1, yTop, zB }, { ix2 + 1, yTop, zF },
+      uv = { { u, v }, { u, v }, { u, v }, { u, v } },
+      shade = OBJ_SHADE.side,
+    }
+  end
+end
+
 -- A tall-grass CELL is four tufts: 2x2 tiles, and each 8x8 tile is one
 -- whole clump of grass. Each tile stands as its own thin per-pixel slab
 -- at ITS OWN depth -- the cell's north tile row in the north half of the
@@ -3359,6 +3409,20 @@ local function grassTemplate(map, data, tileId)
             shade = 1,
           }
         end
+        -- and underneath, where a blade ends in mid-air over the ground
+        if not opaque(ix, iy + 1) then
+          quads[#quads + 1] = {
+            { ix, yBot, zF }, { ix2 + 1, yBot, zF },
+            { ix2 + 1, yBot, zB }, { ix, yBot, zB },
+            uv = { { u0, v1 }, { u1, v1 }, { u1, v1 }, { u0, v1 } },
+            shade = OBJ_SHADE.bottom,
+          }
+        end
+        -- and the run's two end walls, which is what makes a blade a solid
+        -- thing rather than two billboards you can see between (sideQuads
+        -- above argues it, and why each wall wears its end pixel's colour)
+        sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
+                  ax0, ay0, atlasW, atlasH, iy, opaque)
         ix = ix2 + 1
       else
         ix = ix + 1
@@ -3537,6 +3601,30 @@ local function flowerTemplate(map, data, tileId)
             shade = OBJ_SHADE.top,
           }
         end
+        -- and the same strip on the bottom, where the row below is clear:
+        -- a petal that ends mid-air is a solid thing seen from underneath,
+        -- and a low camera (1ST, 3RD, the battle's floor-level seat) is
+        -- looking straight up at it
+        if not on(ix, py + 1) then
+          quads[#quads + 1] = {
+            { ix, yBot, zF }, { ix2 + 1, yBot, zF },
+            { ix2 + 1, yBot, zB }, { ix, yBot, zB },
+            uv = { { u0, v1 }, { u1, v1 }, { u1, v1 }, { u0, v1 } },
+            shade = OBJ_SHADE.bottom,
+          }
+        end
+        -- The ENDS of the run, which close the slab off sideways. Without
+        -- them the flower is two faces and a lid: from anywhere but square
+        -- on you look straight through its open edge and out the far side,
+        -- which is what stopped it reading as a solid thing.
+        --
+        -- Each wall samples ONE texel -- the run's own end pixel, at its
+        -- centre -- so the side wears the colour of the pixel it is closing
+        -- off (the nearest coloured pixel there is) rather than a keyed
+        -- hole, and discards with that pixel when the animation frame it
+        -- belongs to is not the one on screen.
+        sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
+                  ax0, ay0, atlasW, atlasH, py, on)
         ix = ix2 + 1
       else
         ix = ix + 1
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 5bf4401..9ccce89 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -3885,6 +3885,233 @@ Voxel3D.camera = nil
 VoxelState.reset()
 end
 
+-- ------- the cameras the player steers
+--
+-- Three cameras take the same four inputs -- a wheel, Q/E, a pinch, a
+-- stick -- and the whole of CamControl is the answer to "which one is this
+-- aimed at". So the suite pins that routing table, then each camera's own
+-- stops: the boom's zoom, and the battle's orbit, climb and lens.
+
+do
+local CamControl = run.loader.exports.DRAMATIC_SHAPE.lib.require("CamControl")
+local ThirdPerson =
+  run.loader.exports.DRAMATIC_SHAPE.lib.require("ThirdPerson")
+local BattleCam = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleCam")
+local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
+local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D")
+
+-- ------- the boom's own zoom
+ThirdPerson.zoom, ThirdPerson.zoomGoal = 1, 1
+T.eq(ThirdPerson.reachFor(), ThirdPerson.BOOM,
+  "at zoom 1 the boom reaches exactly its own length")
+T.check(ThirdPerson.stepZoom(1), "a notch out moves the goal")
+T.check(ThirdPerson.zoomGoal > 1, "outward, which is what positive means")
+T.check(ThirdPerson.stepZoom(-2), "and back in past where it started")
+T.check(ThirdPerson.zoomGoal < 1, "inward")
+for _ = 1, 40 do ThirdPerson.stepZoom(-1) end
+T.eq(ThirdPerson.zoomGoal, ThirdPerson.ZOOM_MIN, "it stops coming in")
+T.check(not ThirdPerson.stepZoom(-1),
+  "and says so, so the input can fall through instead of being eaten")
+for _ = 1, 60 do ThirdPerson.stepZoom(1) end
+T.eq(ThirdPerson.zoomGoal, ThirdPerson.ZOOM_MAX, "and stops going out")
+
+-- the ease: a step is a request, and the eye takes ZOOM_TIME to answer it
+ThirdPerson.zoom, ThirdPerson.zoomGoal = 1, 1
+ThirdPerson.stepZoom(2)
+ThirdPerson.update(1 / 60, 1)
+T.check(ThirdPerson.zoom > 1 and ThirdPerson.zoom < ThirdPerson.zoomGoal,
+  "one frame later the eye is on its way but not there")
+for _ = 1, 120 do ThirdPerson.update(1 / 60, 1) end
+T.eq(ThirdPerson.zoom, ThirdPerson.zoomGoal, "and it arrives")
+T.check(math.abs(ThirdPerson.reachFor()
+                 - ThirdPerson.BOOM * ThirdPerson.zoom) < 1e-9,
+  "the boom it reaches for is the length at that zoom")
+ThirdPerson.zoom, ThirdPerson.zoomGoal = 1, 1
+
+-- the same control with no notches, for a gesture whose own scale IS the
+-- answer. It scales the BOOM, so the inversion a pinch needs (spread the
+-- fingers, pull the camera in) belongs to the gesture, not to this
+T.check(ThirdPerson.scaleZoom(2), "a continuous factor moves it too")
+T.check(math.abs(ThirdPerson.zoomGoal - 2) < 1e-6,
+  "and scales the boom by exactly that factor")
+ThirdPerson.zoom, ThirdPerson.zoomGoal = 1, 1
+
+-- ------- which camera an input is aimed at
+--
+-- Needs a 3D pass and a free-roam stack, neither of which a headless run
+-- has; both are lent for the length of the check and handed back.
+;(function()
+  local Game = require("src.core.Game")
+  local hadAvail = Voxel3D.available
+  local hadStack, hadOw = Game.stack, Game.overworld
+  local ow = {}
+  Voxel3D.available = function() return true end
+  Game.overworld = ow
+  Game.stack = { top = function() return ow end }
+
+  VoxelState.setLevel(0)
+  T.eq(CamControl.zoomTarget(), nil, "with the mode off, no camera of ours")
+  VoxelState.setLevel(3)
+  T.eq(CamControl.zoomTarget(), "survey",
+    "on an orbit rung a zoom is the engine's own survey zoom")
+  VoxelState.setLevel(VoxelState.FP_LEVEL)
+  T.eq(CamControl.zoomTarget(), nil,
+    "in 1ST nothing zooms -- the eye is in the player's head")
+  VoxelState.setLevel(VoxelState.TP_LEVEL)
+  T.eq(CamControl.zoomTarget(), "boom", "and in 3RD it is the boom")
+
+  ThirdPerson.zoomGoal = 1
+  T.check(CamControl.zoomBy(1) and ThirdPerson.zoomGoal > 1,
+    "so a wheel notch on that rung lets the boom out")
+  ThirdPerson.zoomGoal = 1
+  T.check(CamControl.pinchBy(2) and ThirdPerson.zoomGoal < 1,
+    "and spreading two fingers pulls it IN -- the gesture is the inversion")
+  ThirdPerson.zoomGoal = 1
+  T.check(CamControl.pinchBy(0.5) and ThirdPerson.zoomGoal > 1,
+    "pinching them together pushes it out again")
+  ThirdPerson.zoom, ThirdPerson.zoomGoal = 1, 1
+
+  -- 1ST is the rung that deliberately swallows nothing: a pinch there
+  -- would silently wind the survey zoom for whenever the player stepped
+  -- back out to an orbit rung
+  VoxelState.setLevel(VoxelState.FP_LEVEL)
+  T.check(not CamControl.zoomBy(1), "1ST claims no wheel notch")
+  T.check(not CamControl.pinchBy(2), "and no pinch")
+  VoxelState.setLevel(VoxelState.TP_LEVEL)
+
+  -- a screen over the overworld takes every one of them back
+  Game.stack = { top = function() return {} end }
+  T.eq(CamControl.zoomTarget(), nil,
+    "with anything pushed over the overworld, nothing is ours to zoom")
+
+  Voxel3D.available = hadAvail
+  Game.stack, Game.overworld = hadStack, hadOw
+  VoxelState.reset()
+end)()
+
+-- ------- the battle's orbit
+--
+-- The stop that matters is the far one: swung fully right, the eye must be
+-- SQUARE to the arena's axis -- the side-on shot -- and not a degree past
+-- it. Measured off the rig rather than off the constant, because the
+-- constant is computed from the rig's own stance.
+;(function()
+  local arena = { mid = { 100, 200 }, player = { 100, 216 },
+                  enemy = { 100, 184 } }
+  local function bearing()
+    local rig = BattleCam.rig(arena, 0)
+    return math.atan2(rig.eye[1] - arena.mid[1], rig.eye[3] - arena.mid[2])
+  end
+  BattleCam.recentre()
+  BattleCam.reset()
+  BattleCam.steerable = true
+
+  local home = bearing()
+  T.check(home > 0.4 and home < 0.6,
+    "the solved shot stands about 28 degrees off the arena's axis")
+  BattleCam.orbit = 1
+  T.check(math.abs(bearing() - math.pi / 2) < 1e-9,
+    "swung fully right, the eye is exactly square to the axis: side-on")
+  T.check(math.abs(BattleCam.orbitRange(arena) - (math.pi / 2 - home)) < 1e-9,
+    "which is precisely the room orbitRange said it had")
+
+  -- and the near one: there is nothing to the left of the solved shot
+  BattleCam.recentre()
+  T.check(not BattleCam.dragOrbit(-1), "a drag left of home does nothing")
+  T.eq(BattleCam.orbitGoal, 0, "the shot the composition was solved for IS "
+    .. "the left stop")
+  T.check(BattleCam.dragOrbit(0.2), "a drag right steers")
+  BattleCam.dragOrbit(10)
+  T.eq(BattleCam.orbitGoal, 1, "and stops at side-on however hard it is pushed")
+
+  -- ------- the climb
+  BattleCam.recentre()
+  local function elevation()
+    local rig = BattleCam.rig(arena, 0)
+    local vx = rig.eye[1] - rig.focus[1]
+    local vy = rig.eye[2] - rig.focus[2]
+    local vz = rig.eye[3] - rig.focus[3]
+    return math.atan2(vy, math.sqrt(vx * vx + vz * vz)),
+           math.sqrt(vx * vx + vy * vy + vz * vz)
+  end
+  local low, radius = elevation()
+  BattleCam.pitch = 1
+  local high, radius2 = elevation()
+  T.check(math.abs((high - low) - BattleCam.PITCH_RANGE) < 1e-6,
+    "raised fully, the seat is exactly 45 degrees above the solved one")
+  T.check(math.abs(radius2 - radius) < 1e-6,
+    "at the same distance -- climbing is not zooming")
+  BattleCam.recentre()
+  T.check(not BattleCam.dragPitch(-1), "and it will not tilt below home")
+  T.eq(BattleCam.pitchGoal, 0, "the rig's own low stance is the down stop")
+  BattleCam.dragPitch(10)
+  T.eq(BattleCam.pitchGoal, 1, "45 degrees is the up stop")
+
+  -- ------- the lens opening to keep the pair framed
+  --
+  -- Swinging round or climbing un-foreshortens the arena's axis, so the two
+  -- mons read further apart; left alone that threw them off the edges of
+  -- the frame at the far end of both ranges.
+  BattleCam.recentre()
+  T.check(math.abs(BattleCam.spread(arena) - 1) < 1e-9,
+    "at the solved shot the lens is the rig's own, exactly")
+  BattleCam.orbit = 1
+  T.check(BattleCam.spread(arena) > 1.8,
+    "side-on the pair reads nearly twice as far apart, and the lens opens "
+    .. "by the same amount")
+  BattleCam.orbit = 0
+  BattleCam.pitch = 1
+  T.check(BattleCam.spread(arena) > 1.5,
+    "and climbing spreads them too, on the other axis")
+  BattleCam.recentre()
+
+  local wide = BattleCam.rig(arena, 0).fov
+  T.check(BattleCam.stepZoom(-3), "three notches in")
+  BattleCam.zoom = BattleCam.zoomGoal
+  T.check(BattleCam.rig(arena, 0).fov < wide,
+    "and the lens is longer -- zoom is the FRAME, not the distance")
+  T.check(math.abs(BattleCam.frameH(arena)
+                   - BattleCam.rigFor(arena).frameH * BattleCam.zoom) < 1e-9,
+    "which is what the sun's box is fitted to as well")
+  for _ = 1, 40 do BattleCam.stepZoom(-1) end
+  T.eq(BattleCam.zoomGoal, BattleCam.ZOOM_MIN, "the lens has a near stop")
+  for _ = 1, 60 do BattleCam.stepZoom(1) end
+  T.eq(BattleCam.zoomGoal, BattleCam.ZOOM_MAX, "and a far one")
+
+  -- ------- what BACK SPRITES takes away
+  --
+  -- Not just the input: the RIG stands down too, so an angle stored from
+  -- before the row was switched on cannot leave the pinned composition
+  -- steered anyway.
+  BattleCam.recentre()
+  BattleCam.orbit, BattleCam.orbitGoal = 1, 1
+  BattleCam.pitch, BattleCam.pitchGoal = 1, 1
+  BattleCam.zoom, BattleCam.zoomGoal = 0.5, 0.5
+  BattleCam.steerable = false
+  T.check(math.abs(bearing() - home) < 1e-9,
+    "with the player's mon pinned to the menu, the shot holds its own angle")
+  T.eq(BattleCam.frameH(arena), BattleCam.rigFor(arena).frameH,
+    "and its own lens")
+  T.check(not BattleCam.dragOrbit(0.5), "and refuses to be steered")
+  T.check(not BattleCam.dragPitch(0.5), "on either axis")
+  T.check(not BattleCam.stepZoom(-1), "or zoomed")
+  BattleCam.steerable = true
+
+  -- ------- and what a new battle remembers
+  BattleCam.recentre()
+  BattleCam.dragOrbit(0.5)
+  BattleCam.dragPitch(0.5)
+  BattleCam.stepZoom(-1)
+  BattleCam.reset()
+  T.check(BattleCam.orbitGoal > 0 and BattleCam.pitchGoal > 0
+          and BattleCam.zoomGoal < 1,
+    "a new fight opens where the player left the camera, not where the rig "
+    .. "was solved -- an angle they chose is how they watch battles")
+  T.eq(BattleCam.t, 0, "only the drift's own phase starts over")
+  BattleCam.recentre()
+end)()
+end
+
 -- ------- the VR rig's arithmetic
 --
 -- VRRig is the deliberately pure half of the VR stack: headset poses in,