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,