cam control rotation in battle

This commit is contained in:
DramaticShape
2026-08-03 17:32:17 -04:00
parent f245e8808f
commit 9542ba94b1
7 changed files with 579 additions and 36 deletions
+171 -22
View File
@@ -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]
+69 -7
View File
@@ -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
+4
View File
@@ -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
+12 -7
View File
@@ -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
+8
View File
@@ -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
+88
View File
@@ -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
+227
View File
@@ -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,