mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 10:40:50 +02:00
add third person mode
This commit is contained in:
+137
-18
@@ -1,4 +1,4 @@
|
||||
-- Voxel world mode: the first-person camera -- the 1ST rung.
|
||||
-- Voxel world mode: the free-roam camera -- the 1ST and 3RD rungs.
|
||||
--
|
||||
-- Every other rung is the same camera at a different pitch: an orbit over
|
||||
-- the view centre, described by one number. 1ST is a different rig
|
||||
@@ -9,6 +9,13 @@
|
||||
-- uniforms, project(), the sky's vanishing line, the water's lean -- reads
|
||||
-- eye and focus the same way it always has.
|
||||
--
|
||||
-- 3RD is that same rig with the eye pulled back onto a boom behind the
|
||||
-- player's shoulder (lib/ThirdPerson.lua). Everything in this file is
|
||||
-- already general over where the eye stands -- the attitude, the look
|
||||
-- inputs, the move intent, the cards that turn to face the eye -- so the
|
||||
-- third-person rung is one number applied at the very end of frame(),
|
||||
-- rather than a second camera to keep in step with this one.
|
||||
--
|
||||
-- What this module owns:
|
||||
--
|
||||
-- the ATTITUDE yaw and pitch, fed by whichever look input speaks:
|
||||
@@ -47,6 +54,7 @@ local Mat4 = V.require("Mat4")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local WorldCurve = V.require("WorldCurve")
|
||||
local ThirdPerson = V.require("ThirdPerson")
|
||||
|
||||
local FirstPerson = {}
|
||||
|
||||
@@ -133,9 +141,12 @@ end
|
||||
|
||||
-- ------- gates
|
||||
|
||||
-- Whether the 1ST rung is selected and the 3D pass can carry it.
|
||||
-- Whether a free-roam rung -- 1ST or 3RD -- is selected and the 3D pass can
|
||||
-- carry it. Both stand the camera with the player, so both read the look
|
||||
-- inputs, both walk free, and both turn the cards; how far behind the head
|
||||
-- the eye ends up is ThirdPerson's business alone.
|
||||
function FirstPerson.engaged()
|
||||
return Voxel.isFirstPerson(Voxel.level) and Voxel3D.available()
|
||||
return Voxel.isFreeCam(Voxel.level) and Voxel3D.available()
|
||||
end
|
||||
|
||||
-- Whether first person should be READING the player's inputs right now:
|
||||
@@ -178,7 +189,16 @@ end
|
||||
-- deep enough into the blend that the card would fill the lens from
|
||||
-- inside. The sun pass keeps drawing it either way -- a first-person
|
||||
-- player still throws a shadow on the ground ahead.
|
||||
--
|
||||
-- Never while 3RD's boom is genuinely out, whatever the blend: the whole
|
||||
-- point of a boom is that the character it is booming away from is on
|
||||
-- screen. (Nor the silhouette that rides the same answer -- seeing your own
|
||||
-- outline through the building you just walked behind is what a
|
||||
-- third-person camera owes the player.) A boom SQUEEZED into the head by a
|
||||
-- wall answers false there and the card comes out again, because at that
|
||||
-- range it is the first-person problem word for word.
|
||||
function FirstPerson.hidePlayer()
|
||||
if ThirdPerson.showsPlayer() then return false end
|
||||
return FirstPerson.cardBlend() > 0.9
|
||||
end
|
||||
|
||||
@@ -193,17 +213,79 @@ function FirstPerson.lookBy(dyaw, dpitch)
|
||||
FirstPerson.pitch + dpitch))
|
||||
end
|
||||
|
||||
-- The view direction's flat compass facing, for everything that still
|
||||
-- thinks in the grid's four directions: the cell A interacts with, the
|
||||
-- sprite the sun sees, the direction a blocked slide bonks in.
|
||||
function FirstPerson.compassFacing()
|
||||
local s, c = math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw)
|
||||
-- A bearing as one of the grid's four directions -- the 45-degree
|
||||
-- quantisation every facing in this file is made with, in one place so the
|
||||
-- compass, the body and the card frames can never disagree about where a
|
||||
-- boundary is.
|
||||
local function facingOf(a)
|
||||
local s, c = math.sin(a), math.cos(a)
|
||||
if math.abs(s) > math.abs(c) then
|
||||
return s > 0 and "right" or "left"
|
||||
end
|
||||
return c > 0 and "down" or "up"
|
||||
end
|
||||
|
||||
-- The view direction's flat compass facing, for everything that still
|
||||
-- thinks in the grid's four directions: the cell A interacts with, the
|
||||
-- sprite the sun sees, the direction a blocked slide bonks in.
|
||||
function FirstPerson.compassFacing()
|
||||
return facingOf(FirstPerson.yaw)
|
||||
end
|
||||
|
||||
-- Which way the BODY points, as a continuous world bearing, given the
|
||||
-- world-space direction it is walking (0, 0 while standing). In the head,
|
||||
-- the body is the head: you face what you look at. On the boom you can see
|
||||
-- yourself, and a character sliding sideways while facing the lens reads as
|
||||
-- a bug rather than as a strafe -- so a walking body turns to face its own
|
||||
-- travel, and a standing one comes back round to the camera's bearing,
|
||||
-- which is the one A talks along.
|
||||
function FirstPerson.bodyBearing(wx, wz)
|
||||
if ThirdPerson.extended() and wx and wz and (wx ~= 0 or wz ~= 0) then
|
||||
return math.atan2(wx, wz)
|
||||
end
|
||||
return FirstPerson.yaw
|
||||
end
|
||||
|
||||
-- The same answer as one of the four facings, which is what the grid game
|
||||
-- (and the sprite sheet) reasons in.
|
||||
function FirstPerson.bodyFacing(wx, wz)
|
||||
return facingOf(FirstPerson.bodyBearing(wx, wz))
|
||||
end
|
||||
|
||||
-- ------- the body's live bearing
|
||||
--
|
||||
-- The bearing the player's own body is actually pointing along RIGHT NOW,
|
||||
-- or nil whenever the free walk is not the thing pointing it (a scripted
|
||||
-- move, a cutscene, the grid walk with the rung off). FreeMove maintains
|
||||
-- it; only the player's own card reads it.
|
||||
--
|
||||
-- It exists because the card's frame is chosen by the angle BETWEEN the
|
||||
-- body and the eye, and quantising the body to a compass direction first
|
||||
-- throws away exactly the precision that choice needs. A standing body is
|
||||
-- pointed along the camera's own yaw, so the true angle between them is a
|
||||
-- flat 180 degrees and the card should show its back and nothing else --
|
||||
-- but snap the body to one of four directions on the game tick, then
|
||||
-- measure it against an eye that has kept turning since, and the pair can
|
||||
-- read as 135 degrees and pick the PROFILE frame instead. Spin the camera
|
||||
-- fast and the character flicks to a mirrored side view for a frame or
|
||||
-- two. Keeping the bearing continuous gives the measurement a full 45
|
||||
-- degrees of slack before it can cross a boundary, which no frame's worth
|
||||
-- of turning comes close to spending.
|
||||
FirstPerson.bodyYaw = nil
|
||||
|
||||
-- Point the body along the direction it is walking (or, standing, along
|
||||
-- the camera): records the continuous bearing and hands back the compass
|
||||
-- facing the caller wants for p.facing.
|
||||
function FirstPerson.pointBody(wx, wz)
|
||||
FirstPerson.bodyYaw = FirstPerson.bodyBearing(wx, wz)
|
||||
return facingOf(FirstPerson.bodyYaw)
|
||||
end
|
||||
|
||||
-- Hand the body back to whatever else is turning it.
|
||||
function FirstPerson.releaseBody()
|
||||
FirstPerson.bodyYaw = nil
|
||||
end
|
||||
|
||||
-- The unit look direction, and its flat (ground-plane) part.
|
||||
local function lookDir()
|
||||
local cp = math.cos(FirstPerson.pitch)
|
||||
@@ -234,21 +316,43 @@ function FirstPerson.cardYaw(wx, wz)
|
||||
return math.atan2(dx, dz)
|
||||
end
|
||||
|
||||
-- Which of the four sprite frames a body at world bearing `phi` shows an
|
||||
-- eye looking at (wx, wz): the bearing rotated into the viewer's own frame,
|
||||
-- quantised. nil when there is no rig to be seen from.
|
||||
local function frameFor(phi, wx, wz)
|
||||
local eye = rig and rig.eye
|
||||
if not (eye and phi) then return nil end
|
||||
local dx, dz = eye[1] - wx, eye[3] - wz
|
||||
if dx * dx + dz * dz < 1e-9 then return nil end
|
||||
local rel = wrapPi(phi - math.atan2(dx, dz))
|
||||
local idx = math.floor((rel + math.pi / 4) / (math.pi / 2)) % 4
|
||||
return FACING_ORDER[idx + 1]
|
||||
end
|
||||
|
||||
-- Which of the four sprite frames an entity shows THIS eye: its facing
|
||||
-- rotated into the viewer's own frame, quantised. The flat game's frames
|
||||
-- are "how this pose looks from the south", so the apparent facing is the
|
||||
-- pose rotated by where the viewer actually stands -- walk behind an NPC
|
||||
-- and you see their back, circle to their flank and you see the profile,
|
||||
-- exactly as the four frames Gen 1 drew intend.
|
||||
--
|
||||
-- An NPC's facing IS one of the four and nothing finer, so this is the
|
||||
-- whole story for everyone in the world except the one body the camera is
|
||||
-- attached to -- see playerFacing.
|
||||
function FirstPerson.apparentFacing(facing, wx, wz)
|
||||
local eye = rig and rig.eye
|
||||
local phi = FACING_ANGLE[facing]
|
||||
if not (eye and phi) then return facing end
|
||||
local dx, dz = eye[1] - wx, eye[3] - wz
|
||||
if dx * dx + dz * dz < 1e-9 then return facing end
|
||||
local rel = wrapPi(phi - math.atan2(dx, dz))
|
||||
local idx = math.floor((rel + math.pi / 4) / (math.pi / 2)) % 4
|
||||
return FACING_ORDER[idx + 1]
|
||||
return frameFor(FACING_ANGLE[facing], wx, wz) or facing
|
||||
end
|
||||
|
||||
-- The PLAYER's own card, which is the one case where the body's bearing is
|
||||
-- known to better than a compass point (bodyYaw, above) -- and the one case
|
||||
-- where it matters, because the eye is derived FROM that bearing rather
|
||||
-- than independent of it. Measured continuously, a standing body reads as
|
||||
-- a flat 180 degrees from its own camera and shows its back, steadily,
|
||||
-- however fast the camera is spun. Falls back to the four-direction answer
|
||||
-- whenever something other than the free walk is turning the body.
|
||||
function FirstPerson.playerFacing(facing, wx, wz)
|
||||
return frameFor(FirstPerson.bodyYaw, wx, wz)
|
||||
or FirstPerson.apparentFacing(facing, wx, wz)
|
||||
end
|
||||
|
||||
-- ------- the move intent
|
||||
@@ -344,6 +448,12 @@ function FirstPerson.update(dt)
|
||||
rig = nil
|
||||
end
|
||||
|
||||
-- the boom, on the same tick and for the same reason: it has to keep
|
||||
-- easing after 3RD is left, and it needs the blend to know whether a
|
||||
-- change of rung is a SLIDE (already inside the world, 1ST <-> 3RD) or
|
||||
-- part of the dive in from the orbit, which carries the eye anyway
|
||||
ThirdPerson.update(dt, FirstPerson.blend)
|
||||
|
||||
-- mouse capture follows engagement: captured whenever the rung is on and
|
||||
-- the window has focus, released the moment either ends. Checked against
|
||||
-- the live mode rather than toggled on edges, so a capture lost to the
|
||||
@@ -444,6 +554,12 @@ function FirstPerson.frame(me, cx, cy, vw, vh)
|
||||
head[2] + ly * FirstPerson.FOCUS_DIST,
|
||||
head[3] + lz * FirstPerson.FOCUS_DIST }
|
||||
|
||||
-- 3RD: the eye walks back off the head along the very direction it looks,
|
||||
-- as far as the world allows. Fully in (1ST, and every frame of the
|
||||
-- diorama) this hands back the head and the focus untouched, so the two
|
||||
-- rungs are one rig with one number between them.
|
||||
local camEye, camFocus = ThirdPerson.place(head, lx, ly, lz, fpFocus)
|
||||
|
||||
local oEye, oFocus, oFov, oUp = orbitRig(cx, cy, vh)
|
||||
local function mix(p, q)
|
||||
return { p[1] + (q[1] - p[1]) * e,
|
||||
@@ -462,8 +578,8 @@ function FirstPerson.frame(me, cx, cy, vw, vh)
|
||||
local k = WorldCurve.k(vh) * (1 - e)
|
||||
|
||||
rig = {
|
||||
eye = mix(oEye, head),
|
||||
focus = mix(oFocus, fpFocus),
|
||||
eye = mix(oEye, camEye),
|
||||
focus = mix(oFocus, camFocus),
|
||||
fov = oFov + (FirstPerson.FOV - oFov) * e,
|
||||
up = up,
|
||||
curve = k,
|
||||
@@ -503,6 +619,9 @@ function FirstPerson.signature()
|
||||
math.floor(b * 64),
|
||||
math.floor(FirstPerson.yaw * 64),
|
||||
math.floor(FirstPerson.pitch * 64),
|
||||
-- and how far back the boom stands the eye: a wall shortening it moves
|
||||
-- the camera the sun's box is fitted around, standing still or not
|
||||
ThirdPerson.signature(),
|
||||
}, ",")
|
||||
end
|
||||
|
||||
|
||||
+30
-10
@@ -1,11 +1,16 @@
|
||||
-- Voxel world mode: free movement for the first-person rung.
|
||||
-- Voxel world mode: free movement for the free-roam rungs.
|
||||
--
|
||||
-- The engine walks a grid: sixteen frames per cell, four directions,
|
||||
-- input locked mid-step. Inside a first-person camera that gait reads as
|
||||
-- riding a rail, so while 1ST drives, this module replaces the WALK and
|
||||
-- nothing else: the player's position becomes continuous, steered by the
|
||||
-- camera's own yaw -- push forward and you go where you look, at any
|
||||
-- angle, sliding along whatever you graze.
|
||||
-- input locked mid-step. Inside a camera that stands with the player that
|
||||
-- gait reads as riding a rail, so while 1ST or 3RD drives, this module
|
||||
-- replaces the WALK and nothing else: the player's position becomes
|
||||
-- continuous, steered by the camera's own yaw -- push forward and you go
|
||||
-- where you look, at any angle, sliding along whatever you graze.
|
||||
--
|
||||
-- Both rungs walk identically: the boom behind the shoulder (3RD) changes
|
||||
-- where the eye stands, not which way it points, and the walk was always
|
||||
-- rotated by the YAW. The one thing it does change is which way the body
|
||||
-- POINTS while it moves -- see bodyFacing in the tick.
|
||||
--
|
||||
-- THE GRID IS STILL THE GAME. Every fact the world cares about is a fact
|
||||
-- about cells -- what blocks, what warps, what rustles, what bites -- and
|
||||
@@ -73,6 +78,11 @@ end
|
||||
|
||||
function FreeMove.drop()
|
||||
pos = nil
|
||||
-- and the body with it: while something else is walking the player --
|
||||
-- a scripted move, a ledge hop, the grid walk off the rung -- the
|
||||
-- engine's own four-direction facing is the whole truth about which way
|
||||
-- they point, so the card must stop reading our finer one
|
||||
FirstPerson.releaseBody()
|
||||
end
|
||||
|
||||
-- named for the suite: the module's live position, nil while dropped
|
||||
@@ -231,8 +241,12 @@ function FreeMove.tick(state)
|
||||
local input = Game.input
|
||||
|
||||
-- the head is the facing: what A talks to, what the sun's card shows,
|
||||
-- which way a bonk points
|
||||
p.facing = FirstPerson.compassFacing()
|
||||
-- which way a bonk points. (A body that is WALKING may turn along its
|
||||
-- travel instead -- see below, once there is a travel to turn along; a
|
||||
-- standing one always faces where the camera looks, which is what makes
|
||||
-- A predictable.) pointBody rather than compassFacing, so the card also
|
||||
-- gets the CONTINUOUS bearing behind that compass point.
|
||||
p.facing = FirstPerson.pointBody(0, 0)
|
||||
|
||||
if input:wasPressed("a") then
|
||||
state:interact()
|
||||
@@ -266,6 +280,12 @@ function FreeMove.tick(state)
|
||||
|
||||
if not moving then return end
|
||||
|
||||
-- and once there IS a direction of travel, the body may point along it
|
||||
-- rather than along the head: on the boom (3RD) you can see yourself, so
|
||||
-- a strafe has to look like walking sideways. In the head it is the head
|
||||
-- either way -- bodyBearing says so.
|
||||
p.facing = FirstPerson.pointBody(wx, wz)
|
||||
|
||||
state.bumpCooldown = math.max(0, (state.bumpCooldown or 0) - 1)
|
||||
|
||||
local speed = (Game.save and Game.save.onBike) and FreeMove.BIKE
|
||||
@@ -307,8 +327,8 @@ function FreeMove.tick(state)
|
||||
FreeMove.drop()
|
||||
return
|
||||
end
|
||||
-- the push handlers may have turned the facing; the head still rules
|
||||
p.facing = FirstPerson.compassFacing()
|
||||
-- the push handlers may have turned the facing; the walk still rules
|
||||
p.facing = FirstPerson.pointBody(wx, wz)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
-- 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 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)
|
||||
local target = ThirdPerson.selected() and 1 or 0
|
||||
if (blend or 0) <= 0 then
|
||||
ThirdPerson.out = target
|
||||
ThirdPerson.len = ThirdPerson.BOOM * 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 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.BOOM * 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.
|
||||
local flat = math.sqrt(lx * lx + lz * lz)
|
||||
local sx, sz = 0, 0
|
||||
if flat > 1e-6 then
|
||||
local s = ThirdPerson.SHOULDER * e * (len / math.max(want, 1e-6))
|
||||
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
|
||||
@@ -257,6 +257,11 @@ local function renderWorld(views, ctl)
|
||||
VoxelScene.spriteLean = math.rad(75)
|
||||
|
||||
local pivot, anchor, scale, mountYaw
|
||||
-- Either free-roam rung puts the headset in the player's head: 3RD's boom
|
||||
-- is a FLAT-SCREEN framing device, and a headset that stands its wearer
|
||||
-- three cells behind their own body is a well-known way to make people
|
||||
-- ill. The rung still changes the walk and the cards the same way; only
|
||||
-- the eye stays where a head belongs.
|
||||
local fp = FirstPerson.engaged()
|
||||
local battle, battleFloor
|
||||
if camMode == "battle" then battle, battleFloor = battleStage() end
|
||||
|
||||
@@ -226,8 +226,16 @@ end
|
||||
-- reads its own shadowing with must describe the same frame, or the
|
||||
-- mirror-flip half of the pair asks the map about texels the sun filed
|
||||
-- under the other cheek.
|
||||
-- The player's own card asks a different function for the same answer:
|
||||
-- their body's bearing is what the camera is derived FROM, so it is known
|
||||
-- continuously rather than as one of four directions, and measuring
|
||||
-- against the compass point instead flicks the card to a profile for a
|
||||
-- frame or two when the camera is spun fast (see playerFacing).
|
||||
local function viewFacing(p)
|
||||
if FirstPerson.cardBlend() > 0.5 then
|
||||
if p.isPlayer then
|
||||
return FirstPerson.playerFacing(p.facing, p.px + 8, p.py + 8)
|
||||
end
|
||||
return FirstPerson.apparentFacing(p.facing, p.px + 8, p.py + 8)
|
||||
end
|
||||
return p.facing
|
||||
|
||||
+32
-14
@@ -33,17 +33,18 @@ local Voxel = {}
|
||||
-- in the table is deliberate: the ladder is a list of what each rung LOOKS
|
||||
-- like, and two rungs may look the same while meaning different things.
|
||||
--
|
||||
-- 1ST is the other rung that is more than an angle: the camera steps off its
|
||||
-- orbit entirely and stands in the player's own eyes (lib/FirstPerson.lua),
|
||||
-- with free look and free movement. Its ANGLE entry is 75 -- the orbit rung
|
||||
-- it hands over from -- because the tween in and out of first person starts
|
||||
-- from whatever the orbit shows, and the lowest rung is the one a dive into
|
||||
-- a head should start from. Everything angle-derived (the sky's fade, the
|
||||
-- billboard lean the blend eases away) reads that 75 while the first-person
|
||||
-- rig owns the actual camera.
|
||||
Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75 }
|
||||
-- 1ST and 3RD are the other rungs that are more than an angle: the camera
|
||||
-- steps off its orbit entirely and stands with the player -- in their eyes
|
||||
-- (lib/FirstPerson.lua), or on a boom behind their shoulder
|
||||
-- (lib/ThirdPerson.lua) -- with free look and free movement on both. Their
|
||||
-- ANGLE entries are 75 -- the orbit rung they hand over from -- because the
|
||||
-- tween in and out starts from whatever the orbit shows, and the lowest rung
|
||||
-- is the one a dive into a head should start from. Everything angle-derived
|
||||
-- (the sky's fade, the billboard lean the blend eases away) reads that 75
|
||||
-- while the free-roam rig owns the actual camera.
|
||||
Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75, 75 }
|
||||
Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75",
|
||||
"1ST (EXPERIMENTAL)" }
|
||||
"1ST (EXPERIMENTAL)", "3RD (EXPERIMENTAL)" }
|
||||
Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1
|
||||
|
||||
-- the rung FULL sits on, so nothing has to hunt for it by label
|
||||
@@ -60,6 +61,23 @@ function Voxel.isFirstPerson(level)
|
||||
return (level or Voxel.level) == Voxel.FP_LEVEL
|
||||
end
|
||||
|
||||
-- and the third-person one, which is the same rig with the eye boomed off
|
||||
-- the back of the head (lib/ThirdPerson.lua)
|
||||
Voxel.TP_LEVEL = 7
|
||||
|
||||
function Voxel.isThirdPerson(level)
|
||||
return (level or Voxel.level) == Voxel.TP_LEVEL
|
||||
end
|
||||
|
||||
-- The two of them together: the rungs where the camera stands WITH the
|
||||
-- player rather than orbiting the view centre, which is what decides that
|
||||
-- the look inputs are read, the walk goes free and the cards turn to face
|
||||
-- the eye. Everything that used to ask isFirstPerson for those asks this.
|
||||
function Voxel.isFreeCam(level)
|
||||
level = level or Voxel.level
|
||||
return Voxel.isFirstPerson(level) or Voxel.isThirdPerson(level)
|
||||
end
|
||||
|
||||
-- ------- what the hotkey walks
|
||||
--
|
||||
-- The ANGLE rungs only, with FULL left out. The key is a display-mode
|
||||
@@ -69,11 +87,11 @@ end
|
||||
-- with no indication that a keypress had done so. FULL stays on the OPTIONS
|
||||
-- row, which is where a preset that changes other rows belongs.
|
||||
--
|
||||
-- 1ST is on the path: it changes the camera and only the camera, which is
|
||||
-- exactly what the key promises -- and the key is also the way back OUT of
|
||||
-- first person on a keyboard, where the mouse is captured and the OPTIONS
|
||||
-- 1ST and 3RD are on the path: they change the camera and only the camera,
|
||||
-- which is exactly what the key promises -- and the key is also the way back
|
||||
-- OUT of them on a keyboard, where the mouse is captured and the OPTIONS
|
||||
-- menu is a trip.
|
||||
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6 } -- OFF, 15, 35, 50, 75, 1ST
|
||||
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6, 7 } -- OFF,15,35,50,75,1ST,3RD
|
||||
|
||||
-- The rung a press moves to from `level`.
|
||||
--
|
||||
|
||||
Reference in New Issue
Block a user