mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 20:00:51 +02:00
408 lines
17 KiB
Lua
408 lines
17 KiB
Lua
-- Voxel world mode: the third-person camera -- the 3RD rung.
|
|
--
|
|
-- 3RD is 1ST with the eye pulled off the back of the head. Everything that
|
|
-- makes the first-person rung work -- the steered attitude, the placed
|
|
-- camera on Voxel3D's seam, the cards that turn to face the eye, the
|
|
-- continuous camera-relative walk -- is already general over WHERE the eye
|
|
-- stands, so this module adds exactly one thing to it: a BOOM.
|
|
--
|
|
-- What the boom owns:
|
|
--
|
|
-- the LENGTH how far behind the pivot the eye sits, eased in and out
|
|
-- so stepping between 1ST and 3RD slides rather than cuts,
|
|
-- and clamped every frame by what the world will allow.
|
|
--
|
|
-- the COLLISION a march back along the boom line through the terrain
|
|
-- height field and the map's own walkability, so backing
|
|
-- into a wall walks the camera in toward the player's
|
|
-- shoulders instead of through the wall into the void.
|
|
-- The recovery is deliberately slower than the intrusion:
|
|
-- a camera must never be a frame late leaving geometry,
|
|
-- and must never snap back out the instant a corner clears.
|
|
--
|
|
-- the SHOULDER the small lateral rail offset that keeps the character
|
|
-- off dead centre, faded out with the boom so a camera
|
|
-- jammed against a wall does not also slide sideways into
|
|
-- it.
|
|
--
|
|
-- Deliberately NOT here: the attitude, the look inputs, the blend, the
|
|
-- move intent (all lib/FirstPerson.lua, which drives this module and reads
|
|
-- its answer while building the frame's rig), and movement itself
|
|
-- (lib/FreeMove.lua, unchanged -- the walk is camera-relative either way,
|
|
-- and the camera's yaw is the same number on both rungs).
|
|
--
|
|
-- Nothing here is required for the rung to draw: with no overworld to ask
|
|
-- (a headless run, the test suite) every query answers "clear" and the boom
|
|
-- extends to its full length over an empty world.
|
|
|
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
|
local V = ...
|
|
|
|
local Voxel = V.require("VoxelState")
|
|
|
|
local ThirdPerson = {}
|
|
|
|
-- ------- the boom's numbers
|
|
--
|
|
-- BOOM is world pixels behind the pivot at full extension. A cell is 16 and
|
|
-- a character card is 16 tall, so 48 stands the camera three cells back:
|
|
-- with the first-person lens (65 degrees vertical) that frames the player
|
|
-- at roughly a quarter of the frame height -- the modern action-game
|
|
-- middle ground, close enough to read the four-frame sprite and far enough
|
|
-- to see the cell you are about to walk into.
|
|
--
|
|
-- PIVOT_LIFT raises the orbit point above the first-person eye, so the
|
|
-- boom looks slightly DOWN across the player's shoulder rather than
|
|
-- straight through the back of their head.
|
|
--
|
|
-- SHOULDER is the lateral rail offset, in world pixels, positive to the
|
|
-- camera's right -- which puts the player left of centre, leaving the
|
|
-- larger half of the frame in front of them.
|
|
ThirdPerson.BOOM = 48
|
|
ThirdPerson.PIVOT_LIFT = 4
|
|
ThirdPerson.SHOULDER = 4
|
|
|
|
-- how long the eye takes to slide out to the boom (and back into the head
|
|
-- when 1ST is picked), in seconds -- the same order as FirstPerson's own
|
|
-- dive so stepping 75 -> 1ST -> 3RD reads as one continuous camera
|
|
ThirdPerson.BOOM_TIME = 0.35
|
|
|
|
-- ------- the player's own zoom
|
|
--
|
|
-- A multiplier on BOOM, stepped by the wheel, Q/E or a pinch (see
|
|
-- CamControl, which owns every one of those and decides which camera a
|
|
-- given input is aimed at). The range is deliberately wider IN than OUT:
|
|
-- close is the shot people reach for, and far enough out the character is
|
|
-- a few pixels and the rung may as well be an orbit rung.
|
|
--
|
|
-- Stepped in fractions rather than world pixels so a notch feels the same
|
|
-- at both ends -- the near end of a linear step would crawl and the far
|
|
-- end would leap.
|
|
ThirdPerson.ZOOM_MIN = 0.45 -- ~22px: over the shoulder, close
|
|
ThirdPerson.ZOOM_MAX = 2.4 -- ~115px: the character in a landscape
|
|
ThirdPerson.ZOOM_STEP = 1.18 -- one wheel notch / key press
|
|
ThirdPerson.ZOOM_TIME = 0.18 -- how fast the eye eases to a new one
|
|
|
|
ThirdPerson.zoom = 1 -- eased, what place() actually uses
|
|
ThirdPerson.zoomGoal = 1 -- what the input asked for
|
|
|
|
-- Step the zoom by `notches` (positive pulls the camera OUT). Returns true
|
|
-- when the goal actually moved, so a caller can tell "zoomed" from "already
|
|
-- at the stop" and let the input fall through.
|
|
function ThirdPerson.stepZoom(notches)
|
|
local was = ThirdPerson.zoomGoal
|
|
local goal = was * (ThirdPerson.ZOOM_STEP ^ (notches or 0))
|
|
ThirdPerson.zoomGoal = math.max(ThirdPerson.ZOOM_MIN,
|
|
math.min(ThirdPerson.ZOOM_MAX, goal))
|
|
return ThirdPerson.zoomGoal ~= was
|
|
end
|
|
|
|
-- Scale the zoom by a continuous factor -- what a pinch hands over, where
|
|
-- the gesture's own scale IS the answer and there are no notches.
|
|
function ThirdPerson.scaleZoom(factor)
|
|
if not (factor and factor > 0) then return false end
|
|
return ThirdPerson.stepZoom(math.log(factor) / math.log(ThirdPerson.ZOOM_STEP))
|
|
end
|
|
|
|
-- ------- the collision's numbers
|
|
--
|
|
-- STEP is how far apart the samples along the boom line are, in world
|
|
-- pixels, and REFINE how many bisections narrow the first blocked one --
|
|
-- four halvings of a 4px step lands the eye within a quarter pixel of the
|
|
-- face, which is finer than the boom ever needs to be.
|
|
--
|
|
-- PAD is the clearance kept between the eye and whatever stopped it. It
|
|
-- has to beat the placed camera's near plane (|eye - focus| * 0.05, which
|
|
-- at full extension is about 3.6 world pixels -- see Voxel3D) or the near
|
|
-- plane clips a hole in the very wall the boom stopped at.
|
|
--
|
|
-- CLEAR is how high above a cell's ground the eye must be to pass OVER
|
|
-- something unwalkable rather than being stopped by it: a fence, a kerb or
|
|
-- a plant pot should not shove the camera in, a building should. Roughly
|
|
-- head height, so the eye clears the props and never the walls.
|
|
ThirdPerson.STEP = 4
|
|
ThirdPerson.REFINE = 4
|
|
ThirdPerson.PAD = 5
|
|
ThirdPerson.CLEAR = 20
|
|
|
|
-- How fast the boom is allowed to grow BACK once whatever shortened it is
|
|
-- out of the way, in world pixels per second. Shortening is instant (a
|
|
-- camera inside a wall is a hole in the frame); lengthening is rationed,
|
|
-- so rounding a corner eases the eye back out instead of snapping it.
|
|
ThirdPerson.RETURN = 150
|
|
|
|
-- ------- state
|
|
--
|
|
-- `out` is the eased extension, 0 in the head and 1 fully boomed -- the
|
|
-- number that carries 1ST into 3RD. `len` is the boom's actual length in
|
|
-- world pixels after the world has had its say, which is what place()
|
|
-- stands the eye at and update() eases back toward `want`.
|
|
ThirdPerson.out = 0
|
|
ThirdPerson.len = 0
|
|
ThirdPerson.want = 0
|
|
|
|
local function ease(t)
|
|
return t * t * (3 - 2 * t)
|
|
end
|
|
|
|
-- ------- gates
|
|
|
|
-- Whether the 3RD rung is the one selected. Not "is the boom out" -- that
|
|
-- is extended() below, which stays true through the ease after the rung is
|
|
-- left, the same way FirstPerson.blend outlives its own rung.
|
|
--
|
|
-- A live headset declines the boom outright: VR builds its own eye cameras
|
|
-- from the tracked pose and never asks place() where to stand, and a
|
|
-- headset that seats its wearer three cells behind their own body is a
|
|
-- well-known way to make people ill. Answering false here is what keeps
|
|
-- everything ELSE the extension decides -- the player's own card, the body
|
|
-- that turns as it walks -- honest about the head VR actually puts you in.
|
|
-- Required lazily and guarded: VR reaches this module through FirstPerson,
|
|
-- and a headless run has no VR module worth loading at all.
|
|
local function headset()
|
|
local ok, on = pcall(function() return V.require("VR").active() end)
|
|
return ok and on or false
|
|
end
|
|
|
|
function ThirdPerson.selected()
|
|
return Voxel.isThirdPerson(Voxel.level) and not headset()
|
|
end
|
|
|
|
-- The eased extension, 0 at the head and 1 at the full boom.
|
|
function ThirdPerson.extension()
|
|
return ease(ThirdPerson.out)
|
|
end
|
|
|
|
-- Whether the boom is out far enough to be a third-person camera at all --
|
|
-- read off the TARGET extension rather than the live length, so it is
|
|
-- steady while the world shoves the eye about. What the body reads to
|
|
-- decide whether it turns along its own travel.
|
|
function ThirdPerson.extended()
|
|
return ThirdPerson.extension() > 0.5
|
|
end
|
|
|
|
-- How far back the eye must ACTUALLY be, in world pixels, for the player's
|
|
-- own card to be worth drawing: a shade under a cell, which is the point
|
|
-- where a 16-pixel card stops being a character and starts being a wall of
|
|
-- pixels across the lens.
|
|
ThirdPerson.SHOW_AT = 14
|
|
|
|
-- Whether the player's own card belongs in the frame. Not the same
|
|
-- question as extended(): back into a fence and the boom collapses into
|
|
-- the head whatever the rung says, and a card drawn there fills the lens
|
|
-- from inside exactly as it would in first person -- so it comes out, and
|
|
-- the rung reads as first person for as long as the world insists on it.
|
|
function ThirdPerson.showsPlayer()
|
|
return ThirdPerson.extension() > 0 and ThirdPerson.len >= ThirdPerson.SHOW_AT
|
|
end
|
|
|
|
-- ------- the world the boom has to fit through
|
|
--
|
|
-- Everything below asks the live overworld and pcall-guards the asking:
|
|
-- with no map (headless, the suite, a frame mid-warp) the boom simply
|
|
-- extends to its full length, which is the right answer for a world with
|
|
-- nothing in it.
|
|
|
|
local function overworld()
|
|
local ok, ow = pcall(function()
|
|
return require("src.core.Game").overworld
|
|
end)
|
|
if not ok or not ow or not ow.map then return nil end
|
|
return ow
|
|
end
|
|
|
|
-- Which map, and which of its cells, covers a world point. The player's own
|
|
-- map first, then the neighbours the scene streams in around it (same ox/oy
|
|
-- offsets VoxelScene draws them at) -- without that pass the boom would
|
|
-- shorten against "off the map" every time the player walked within three
|
|
-- cells of a route connection, which is most of the time.
|
|
--
|
|
-- nil means no map covers it: genuinely off the world, where the border
|
|
-- ring is drawn and the camera has no business going.
|
|
local function cellAt(ow, wx, wz)
|
|
local map = ow.map
|
|
local cx, cy = math.floor(wx / 16), math.floor(wz / 16)
|
|
if map:inBounds(cx, cy) then return map, cx, cy end
|
|
for _, nb in ipairs(ow.neighbors or {}) do
|
|
if nb.map then
|
|
local nx = math.floor((wx - (nb.ox or 0)) / 16)
|
|
local ny = math.floor((wz - (nb.oy or 0)) / 16)
|
|
if nb.map:inBounds(nx, ny) then return nb.map, nx, ny end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Whether the eye may not stand at this world point. Two refusals, and
|
|
-- they are different questions:
|
|
--
|
|
-- the GROUND is the terrain height field the mesh is actually built from
|
|
-- (VoxelScene.groundAt -- the same answer a character stands on), so a
|
|
-- ledge, a raised bank or a cliff stops the boom exactly where it stops
|
|
-- the geometry, at any pitch.
|
|
--
|
|
-- the WALKABILITY is the map's own, and stands in for everything built
|
|
-- ON the ground that the height field does not describe: house walls,
|
|
-- trees, signs, counters. Held to CLEAR above that cell's ground so the
|
|
-- short furniture of the world is passed over rather than bumped into.
|
|
local function occupied(ow, wx, y, wz)
|
|
local map, cx, cy = cellAt(ow, wx, wz)
|
|
if not map then return true end
|
|
local VoxelScene = V.require("VoxelScene")
|
|
local okG, gh = pcall(VoxelScene.groundAt, map, cx, cy)
|
|
gh = (okG and gh) or 0
|
|
if y < gh + ThirdPerson.PAD then return true end
|
|
local okW, walkable = pcall(function() return map:isWalkableCell(cx, cy) end)
|
|
if okW and not walkable and y < gh + ThirdPerson.CLEAR then return true end
|
|
return false
|
|
end
|
|
|
|
ThirdPerson._occupied = occupied -- named for the suite
|
|
|
|
-- How far back along (bx, by, bz) from `pivot` the eye can stand, up to
|
|
-- `want`. March at STEP, and when a sample refuses, bisect back into the
|
|
-- gap between it and the last clear one -- so the answer is the face's own
|
|
-- position rather than the sampling grid's, and walking toward a wall
|
|
-- draws the camera in smoothly instead of in four-pixel jerks. PAD comes
|
|
-- off whatever survives.
|
|
function ThirdPerson.reach(ow, pivot, bx, by, bz, want)
|
|
if not ow or want <= 0 then return math.max(0, want) end
|
|
local function clear(t)
|
|
return not occupied(ow, pivot[1] + bx * t, pivot[2] + by * t,
|
|
pivot[3] + bz * t)
|
|
end
|
|
local lo = 0
|
|
local steps = math.ceil(want / ThirdPerson.STEP)
|
|
local hi = nil
|
|
for i = 1, steps do
|
|
local t = math.min(want, i * ThirdPerson.STEP)
|
|
if clear(t) then
|
|
lo = t
|
|
else
|
|
hi = t
|
|
break
|
|
end
|
|
end
|
|
if not hi then return want end
|
|
for _ = 1, ThirdPerson.REFINE do
|
|
local mid = (lo + hi) / 2
|
|
if clear(mid) then lo = mid else hi = mid end
|
|
end
|
|
return math.max(0, lo - ThirdPerson.PAD)
|
|
end
|
|
|
|
-- ------- the tick
|
|
--
|
|
-- Rides FirstPerson.update, which is itself on the pipeline's own update
|
|
-- hook, so this runs every frame whatever the rung -- the extension has to
|
|
-- keep easing back in after 3RD is left. `blend` is FirstPerson's dive into
|
|
-- the head: while it is fully out (the diorama), the extension SNAPS to its
|
|
-- target rather than easing, so picking 3RD from an orbit rung is one
|
|
-- motion (the dive) rather than two (a dive, then a slide backwards).
|
|
function ThirdPerson.update(dt, blend)
|
|
-- the player's own zoom FIRST, so everything below measures itself
|
|
-- against the boom length this frame actually wants. A step is a request
|
|
-- rather than a jump: three notches of wheel should read as one glide.
|
|
local zg = ThirdPerson.zoomGoal
|
|
if ThirdPerson.zoom ~= zg then
|
|
local k = math.min(1, dt / ThirdPerson.ZOOM_TIME)
|
|
local z = ThirdPerson.zoom + (zg - ThirdPerson.zoom) * k
|
|
ThirdPerson.zoom = (math.abs(zg - z) < 1e-4) and zg or z
|
|
end
|
|
|
|
local target = ThirdPerson.selected() and 1 or 0
|
|
if (blend or 0) <= 0 then
|
|
ThirdPerson.out = target
|
|
ThirdPerson.len = ThirdPerson.reachFor() * target
|
|
-- and the wanted length with it: place() is what normally maintains it
|
|
-- and it does not run at all while the rig is out of the frame, so a
|
|
-- stale want left here would have the recovery below creeping the boom
|
|
-- back out over a camera that is not on screen
|
|
ThirdPerson.want = ThirdPerson.len
|
|
else
|
|
local step = dt / ThirdPerson.BOOM_TIME
|
|
if ThirdPerson.out < target then
|
|
ThirdPerson.out = math.min(target, ThirdPerson.out + step)
|
|
elseif ThirdPerson.out > target then
|
|
ThirdPerson.out = math.max(target, ThirdPerson.out - step)
|
|
end
|
|
end
|
|
|
|
-- the rationed recovery: place() already pulled `len` in to whatever the
|
|
-- world allowed this frame, and this is the only thing that lets it back
|
|
-- out again
|
|
if ThirdPerson.len < ThirdPerson.want then
|
|
ThirdPerson.len = math.min(ThirdPerson.want,
|
|
ThirdPerson.len + ThirdPerson.RETURN * dt)
|
|
end
|
|
end
|
|
|
|
-- The boom's full length right now, before the world has its say: BOOM at
|
|
-- the player's own zoom. Named so the collision march and the shoulder
|
|
-- fade measure themselves against the same number.
|
|
function ThirdPerson.reachFor()
|
|
return ThirdPerson.BOOM * ThirdPerson.zoom
|
|
end
|
|
|
|
-- ------- the eye
|
|
--
|
|
-- Where the camera stands, given the pivot the first-person rig would have
|
|
-- put the eye at and the unit look direction it would have looked along.
|
|
-- Returns the eye and the focus: both slide by the shoulder offset, so the
|
|
-- view direction is untouched and only the frame's contents shift.
|
|
--
|
|
-- With the boom fully in this is exactly the first-person answer, to the
|
|
-- pixel -- which is what makes 1ST and 3RD one rig with a number between
|
|
-- them rather than two cameras to keep in sync.
|
|
function ThirdPerson.place(pivot, lx, ly, lz, focus)
|
|
local e = ThirdPerson.extension()
|
|
if e <= 0 then
|
|
ThirdPerson.want, ThirdPerson.len = 0, 0
|
|
return pivot, focus
|
|
end
|
|
|
|
local up = ThirdPerson.PIVOT_LIFT * e
|
|
local orbit = { pivot[1], pivot[2] + up, pivot[3] }
|
|
|
|
local want = ThirdPerson.reachFor() * e
|
|
ThirdPerson.want = want
|
|
local room = ThirdPerson.reach(overworld(), orbit, -lx, -ly, -lz, want)
|
|
-- in instantly, out only as fast as update() allows
|
|
ThirdPerson.len = math.min(ThirdPerson.len, room)
|
|
local len = ThirdPerson.len
|
|
|
|
-- the rail offset, faded with how much boom actually survived: a camera
|
|
-- squeezed against a wall gives up its shoulder before it gives up its
|
|
-- distance. Right of the look, flat: cross(look, worldUp) normalized,
|
|
-- which for a look of (sin y, *, cos y) is (-cos y, 0, sin y) -- the same
|
|
-- right hand FirstPerson.moveWorld strafes along.
|
|
-- The rail rides the ZOOM as well, so it stays the same fraction of the
|
|
-- frame at every distance: a fixed four pixels would swamp the close shot
|
|
-- and vanish from the wide one.
|
|
local flat = math.sqrt(lx * lx + lz * lz)
|
|
local sx, sz = 0, 0
|
|
if flat > 1e-6 then
|
|
local s = ThirdPerson.SHOULDER * ThirdPerson.zoom * e
|
|
* (len / math.max(want, 1e-6))
|
|
sx, sz = -lz / flat * s, lx / flat * s
|
|
end
|
|
|
|
local eye = { orbit[1] - lx * len + sx,
|
|
orbit[2] - ly * len,
|
|
orbit[3] - lz * len + sz }
|
|
local aim = focus and { focus[1] + sx, focus[2] + up, focus[3] + sz }
|
|
or nil
|
|
return eye, aim
|
|
end
|
|
|
|
-- What a shadow signature has to include about the boom: the sun's box is
|
|
-- fitted around this camera, so sliding the eye back (or having a wall
|
|
-- shove it in) re-fits it even standing still.
|
|
function ThirdPerson.signature()
|
|
if ThirdPerson.extension() <= 0 then return "" end
|
|
return math.floor(ThirdPerson.len) .. "/" ..
|
|
math.floor(ThirdPerson.extension() * 64)
|
|
end
|
|
|
|
return ThirdPerson
|