add third person mode

This commit is contained in:
DramaticShape
2026-08-03 16:52:22 -04:00
parent 399a10a124
commit 70243a407b
13 changed files with 1076 additions and 66 deletions
+45
View File
@@ -1,5 +1,50 @@
# Changelog
## 1.6.0
### Added
- **A third-person camera: the 3RD rung.** The VOXEL ladder's eighth rung
(`3` / SELECT walk onto it after 1ST, and it is on the OPTIONS row)
stands the camera on a boom behind the player's shoulder. It is the
first-person rig with one number added, so everything 1ST already did
it does: the look steers on a mouse, the right stick or a touch drag,
and the grid walk is replaced by continuous camera-relative movement --
push in any direction and you go there, at any angle, with collision,
warps, ledges, encounters and scripts still running through the
engine's own machinery.
The boom **collides**: it marches back through the terrain height field
and the map's own walkability, so backing into a wall walks the camera
in to your shoulders instead of through it, and rounding a corner eases
it back out rather than snapping. Squeezed all the way into the head it
simply draws as 1ST until you step clear. It also carries a small
over-the-shoulder rail offset, which fades out as the boom shortens.
Every sprite in the world **turns to face it** -- yours, the NPCs', the
figures drawn into the furniture -- and shows the frame it would look
like from where the camera actually stands, so walking behind someone
shows you their back. Your own character is drawn (with the
through-the-wall silhouette 1ST had no use for) and **turns to face
where they are walking** rather than where the camera looks, so a strafe
reads as one; standing still they come back round to the camera's
bearing, which is the one A talks along.
Your card's frame is chosen from your body's **continuous** bearing
rather than from the compass direction the grid game stores. An NPC's
facing really is one of four directions, but yours is the angle the
camera itself is hung off, and quantising it before measuring it against
the eye leaves no margin for the shoulder rail's few degrees of offset:
in a band just short of each 45-degree boundary the pair read as 135
degrees apart and picked the mirrored PROFILE frame. Standing still.
Spinning the camera swept four of those bands a revolution, which showed
up as the character flicking sideways for a split second.
In VR the boom is declined outright and 3RD presents as 1ST does: a
headset that seats 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 sprites the same way.
## 1.5.2
### Added
+33 -2
View File
@@ -3,7 +3,8 @@
A mod for the [Pokémon Gen 1 Recompilation
Project](https://github.com/bryanthaboi/pokemon-gen1-recomp-project).
The overworld as a voxelized 3D diorama. Also supports experimental first-person and VR.
The overworld as a voxelized 3D diorama. Also supports experimental
first-person, third-person and VR.
## Controls
@@ -12,7 +13,7 @@ menu.
| control | does |
| --- | --- |
| `3`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → 1ST → OFF (camera pitch) |
| `3`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → 1ST → 3RD → OFF (camera pitch) |
| `SELECT` (pad / touch) | the same step as `3` — for the machines with no number row |
| `5`, or the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel |
| `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) |
@@ -23,11 +24,41 @@ menu.
| the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone |
| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL |
## Free-roam cameras (1ST / 3RD)
The last two rungs of the **VOXEL** ladder are experimental, and they are
the same camera: **1ST** stands it in the player's own eyes, **3RD** pulls
it back onto a boom behind their shoulder. Both steer, and on both the grid
walk is replaced by continuous camera-relative movement — push in any
direction and you go there, at any angle, not just along the four compass
lines. Collision, warps, ledges, encounters and scripts all still run
through the engine's own machinery.
| control | does |
| --- | --- |
| mouse | look (the cursor is captured; left click is A, right click is B) |
| right stick | look |
| a touch drag off the overlay's controls | look |
| left stick / touch d-pad / arrow keys | walk, relative to where the camera looks |
On **3RD** the boom shortens against whatever is behind you, so backing into
a wall walks the camera in to your shoulders rather than through it — squeeze
it all the way in and the view is 1ST until you step clear. The character
turns to face where they are walking, and every sprite in the world — yours,
the NPCs', the figures drawn into the furniture — turns to face the camera
and shows the frame it would look like from where the camera actually
stands, so walking behind someone shows you their back.
## VR
The **VR** options row (OFF / ON, off by default) drives a PCVR headset
through OpenXR on Windows — SteamVR, Oculus or WMR.
Both free-roam rungs put the headset in the player's *head*: a boom that
seats its wearer three cells behind their own body is a reliable way to make
people ill, so **3RD** in VR is **1ST** in VR. The rung still changes the
walk and the sprites the same way.
### VR controls
Suggested onto Touch, Index and WMR controllers (rebindable in the
+137 -18
View File
@@ -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
View File
@@ -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
+349
View File
@@ -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
+5
View File
@@ -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
+8
View File
@@ -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
View File
@@ -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`.
--
+11 -8
View File
@@ -24,10 +24,11 @@
-- below. This file declares; lib/ draws.
--
-- Voxel mode is presentational: it changes what the world LOOKS like and
-- nothing about what it IS. ONE rung is the deliberate exception. 1ST --
-- the first-person camera -- replaces the grid WALK with a free,
-- camera-relative one while it is selected (lib/FreeMove.lua), because a
-- head you can steer with a mouse demands feet that go where it looks.
-- nothing about what it IS. TWO rungs are the deliberate exception. 1ST
-- (the camera in the player's own eyes) and 3RD (the same rig, boomed back
-- behind their shoulder) replace the grid WALK with a free,
-- camera-relative one while either is selected (lib/FreeMove.lua), because
-- a camera you can steer with a mouse demands feet that go where it looks.
-- Even there the game is untouched: the walk asks the engine's own
-- collision the same questions a grid step asks, keeps the player's
-- logical cell synced, and fires the engine's own landing pipeline per
@@ -860,9 +861,11 @@ end
-- so this file keeps naming every engine seam the mod touches.
OverworldBattle.install()
-- ------- the first-person rung's inputs and its walk
-- ------- the free-roam rungs' inputs and their walk
--
-- 1ST needs two things no other rung does, and each is a named seam:
-- 1ST and 3RD need two things no other rung does, and each is a named seam.
-- Both rungs are one rig -- the boom behind the shoulder is a number inside
-- it (lib/ThirdPerson.lua) -- so both are installed by the same two calls:
--
-- FirstPerson.install claims the LOOK inputs the engine ignores: the right
-- stick's axes (Game:gamepadaxis passes them to Input, which returns early
@@ -874,11 +877,11 @@ OverworldBattle.install()
-- open screen is the look; the d-pad and buttons still go to
-- TouchControls, whose own d-pad finger is also read back analog as the
-- move vector). Every wrap forwards whatever it does not claim, and claims
-- only while 1ST is actually driving.
-- only while one of the two rungs is actually driving.
--
-- FreeMove.install wraps OverworldState:handleInput -- the one choke point
-- where the grid walk reads the pad, and the same seam the engine's own
-- Cycling Road pull lives behind. While 1ST drives, the walk is continuous
-- Cycling Road pull lives behind. While either drives, the walk is continuous
-- and camera-relative; the player's logical cell stays synced and every
-- per-cell consequence still runs through the engine's own machinery
-- (onStepComplete, checkEdgeExit, checkLedgeHop, checkBoulderPush). The
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
"version": "1.5.2",
"version": "1.6.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
+8 -5
View File
@@ -17,11 +17,12 @@ return {
"the engine's TILT and GBC FX rows are taken OFF the OPTIONS menu and held at off for as long as this mod is installed -- TILT is the flat fake of what this mode does for real, GBC FX is a full-screen pass over the top of it; uninstalling puts both rows back",
"hotkeys 3 and 5 are taken over from those two, which have no key and no row while this is loaded",
"SELECT in free roam steps the VOXEL ladder exactly as hotkey 3 does -- the button has no overworld job in Gen 1, and phones and pads have no number row; menus keep it untouched",
"on the 1ST rung ONLY, the grid walk is replaced by free camera-relative movement: collision, warps, ledges, encounters and scripts still run through the engine's own machinery, and every other rung leaves movement untouched",
"on the 1ST rung the mouse cursor is captured for free look; left click is A, right click is B, and any touch off the overlay's controls drags the view",
"on the 1ST and 3RD rungs ONLY, the grid walk is replaced by free camera-relative movement: collision, warps, ledges, encounters and scripts still run through the engine's own machinery, and every other rung leaves movement untouched",
"on the 1ST and 3RD rungs the mouse cursor is captured for free look; left click is A, right click is B, and any touch off the overlay's controls drags the view",
"on the 3RD rung the character turns to face where they are walking rather than where the camera looks, so a strafe reads as one; standing still they come back round to the camera's bearing, which is the one A talks along",
},
added = {
"VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees / 1ST, a first-person camera with free look and free movement)",
"VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees / 1ST / 3RD -- a first-person camera and a third-person one, both with free look and free movement)",
"T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur",
"V-GRID on hotkey 5 and V-CURVE on hotkey 7",
"WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march",
@@ -44,8 +45,10 @@ return {
"the battle backdrop renders at the GB's 160x144 to match the pics composited over it, so it is chunkier than the free-roam pass",
"menus and cutscenes are unaffected -- outside a battle the mode only draws the free-roam overworld",
"terrain meshes are cached per map, so the first frame after entering a large map costs a build",
"1ST needs the 3D pass like every rung; without it the level still persists but the world stays 2D and the grid walk stays in charge",
"in 1ST, scripted walks, ledge hops and spinner slides play out as the grid moves they are, with the camera riding along; free control resumes when they land",
"1ST and 3RD need the 3D pass like every rung; without it the level still persists but the world stays 2D and the grid walk stays in charge",
"in 1ST and 3RD, scripted walks, ledge hops and spinner slides play out as the grid moves they are, with the camera riding along; free control resumes when they land",
"3RD's boom shortens against whatever stands behind the player, so backing into a wall walks the camera in to their shoulders; squeezed all the way in it draws as 1ST until they step clear",
"3RD in VR is 1ST in VR: a headset that seats its wearer three cells behind their own body is a well-known way to make people ill, so the boom is declined while a headset is live",
"rooms have no ceilings, so a first-person look over an interior wall shows the void the diorama always had behind it",
"VR is Windows x64 only (the shipped loader and the Win32 GL binding): on any other platform -- mobile above all -- the VR row is absent from the OPTIONS menu and the manager's page both, and a stored vr=true carried over in a save is ignored. On Windows it renders the scene once per eye (heavy with WATER FULL or AA up); pad/keyboard/mouse keep working alongside the XR controllers. The loader DLL is found wherever the mod was put -- the dev tree, an installed release's save directory, or an imported archive, from which it is copied once into the save directory so the FFI has a real disk path",
"VR on and off are both the VR row's job (options menu or manager); no controller button does either",
+219 -8
View File
@@ -49,14 +49,16 @@ T.eq(defs._owners and defs._owners.voxel, "DRAMATIC_SHAPE",
-- ------- the ladders the engine drives
T.eq(#defs.voxel.levels, 7, "voxel exposes a seven-rung ladder")
T.eq(#defs.voxel.levels, 8, "voxel exposes an eight-rung ladder")
T.eq(defs.voxel.levels[1], "OFF", "rung 0 is OFF")
T.eq(defs.voxel.levels[2], "FULL",
"FULL is the first rung after OFF -- the order those two get used in")
T.eq(defs.voxel.levels[6], "75", "rung 5 is the 75-degree camera")
T.eq(defs.voxel.levels[7], "1ST (EXPERIMENTAL)",
"the top rung is the first-person camera, labelled as the experiment it is")
T.eq(Pipelines.maxLevel("voxel"), 6, "the engine reads the ladder height")
"rung 6 is the first-person camera, labelled as the experiment it is")
T.eq(defs.voxel.levels[8], "3RD (EXPERIMENTAL)",
"and the top rung is the third-person one, labelled the same way")
T.eq(Pipelines.maxLevel("voxel"), 7, "the engine reads the ladder height")
T.eq(Pipelines.levelLabel("voxel", 3), "35", "the engine reads the rung labels")
-- ------- gating: inert until switched on, and inert without a GPU
@@ -1084,12 +1086,14 @@ local Curve = run.loader.exports.DRAMATIC_SHAPE.lib.require("WorldCurve")
-- with nothing on screen saying a keypress had done it.
Pipelines.setLevel("voxel", 0)
local walk = {}
for _ = 1, 7 do
for _ = 1, 8 do
Game.keypressed(keyGame, "3")
walk[#walk + 1] = Pipelines.levelLabel("voxel")
end
T.eq(table.concat(walk, ","), "15,35,50,75,1ST (EXPERIMENTAL),OFF,15",
"3 walks OFF -> 15 -> 35 -> 50 -> 75 -> 1ST and wraps, never touching FULL")
T.eq(table.concat(walk, ","),
"15,35,50,75,1ST (EXPERIMENTAL),3RD (EXPERIMENTAL),OFF,15",
"3 walks OFF -> 15 -> 35 -> 50 -> 75 -> 1ST -> 3RD and wraps, never "
.. "touching FULL")
-- FULL is 35 degrees, so a press from it goes ON to 50 rather than back to
-- the rung that shows the same camera -- the key never appears to do nothing.
@@ -1176,8 +1180,8 @@ T.eq(GBCFX.level, 0, "and on the live renderer")
-- TILT with or without us. Park the ladder on its top rung and turn both
-- back on, so the single press under test is the one that wraps to OFF --
-- where nothing else is going to clear them.
-- 6 is the "1ST" rung, the last one the key walks before it wraps to OFF
Pipelines.setLevel("voxel", 6)
-- 3RD is the last rung the key walks before it wraps to OFF
Pipelines.setLevel("voxel", VoxelState.TP_LEVEL)
Tilt.setLevel(3)
GBCFX.setLevel(4)
keyGame.save.options.tilt = 3
@@ -3674,6 +3678,213 @@ FirstPerson.blend = 0
VoxelState.reset()
end
-- ------- the third-person rung
--
-- 3RD is 1ST with the eye on a boom, so what the suite has to hold still is
-- the boom: where it stands the eye behind a pivot, the march through the
-- world that shortens it when something is in the way, and the two things
-- its extension decides that 1ST decides the other way -- the player's own
-- card being drawn, and the body turning to face where it walks.
do
local FirstPerson =
run.loader.exports.DRAMATIC_SHAPE.lib.require("FirstPerson")
local ThirdPerson =
run.loader.exports.DRAMATIC_SHAPE.lib.require("ThirdPerson")
local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D")
T.eq(VoxelState.TP_LEVEL, 7, "3RD is the eighth rung")
T.check(VoxelState.isThirdPerson(7), "and isThirdPerson answers for it")
T.check(not VoxelState.isThirdPerson(6), "but not for 1ST")
T.check(VoxelState.isFreeCam(6) and VoxelState.isFreeCam(7),
"both rungs that stand the camera with the player answer isFreeCam")
T.check(not VoxelState.isFreeCam(5), "the 75-degree orbit does not")
T.eq(VoxelState.ANGLE_LABELS[VoxelState.TP_LEVEL + 1], "3RD (EXPERIMENTAL)",
"the rung wears the experimental label")
T.eq(VoxelState.ANGLES_DEG[VoxelState.TP_LEVEL + 1], 75,
"and hands the blend the same 75-degree orbit 1ST does")
T.eq(VoxelState.HOTKEY_ORDER[#VoxelState.HOTKEY_ORDER], VoxelState.TP_LEVEL,
"the 3 key walks onto it, and off it back to OFF")
-- the boom's own tween: picked from an orbit rung (blend fully out) the
-- extension SNAPS, so the dive into the world is one motion rather than a
-- dive followed by a slide; picked from inside the head it eases
VoxelState.setLevel(VoxelState.TP_LEVEL)
ThirdPerson.out, ThirdPerson.len = 0, 0
ThirdPerson.update(1 / 60, 0)
T.eq(ThirdPerson.out, 1, "picked from the diorama, the boom starts extended")
ThirdPerson.out, ThirdPerson.len = 0, 0
ThirdPerson.update(1 / 60, 1)
T.check(ThirdPerson.out > 0 and ThirdPerson.out < 1,
"picked from inside the head, it slides out over the boom's own time")
-- ------- the world the march asks
--
-- A stub map that refuses everything from x = 4 rightward, with no tileset
-- for the height field to read -- so the ground answers 0 (the lookup is
-- guarded for exactly this) and only the walkability speaks.
local wall = { map = {
inBounds = function(_, cx, cy) return cx >= 0 and cy >= 0 end,
isWalkableCell = function(_, cx) return cx < 4 end,
cellTile = function() return 0 end,
} }
T.check(ThirdPerson._occupied(wall, 70, 10, 8),
"an unwalkable cell refuses the eye at head height")
T.check(not ThirdPerson._occupied(wall, 70, 30, 8),
"but not one the eye stands well above -- a fence is not a wall")
T.check(ThirdPerson._occupied(wall, 70, 2, 8),
"and the ground refuses it from below")
T.check(ThirdPerson._occupied(wall, -20, 30, 8),
"off the world entirely there is nowhere to stand: the border ring")
-- the march itself: the wall's face is at x = 64, the pivot at x = 40, so
-- the eye may travel 24 east of it less the clearance pad -- the FACE's own
-- position rather than the four-pixel sampling grid's
local room = ThirdPerson.reach(wall, { 40, 10, 8 }, 1, 0, 0, ThirdPerson.BOOM)
T.check(math.abs(room - (24 - ThirdPerson.PAD)) < 0.5,
"the boom stops a pad short of the face that blocked it")
T.eq(ThirdPerson.reach(wall, { 40, 30, 8 }, 1, 0, 0, ThirdPerson.BOOM),
ThirdPerson.BOOM, "with nothing tall enough in the way it runs out full")
T.eq(ThirdPerson.reach(nil, { 40, 10, 8 }, 1, 0, 0, ThirdPerson.BOOM),
ThirdPerson.BOOM, "and with no world to ask at all -- headless, mid-warp")
-- ------- the eye
--
-- place() is pure arithmetic over the pivot and the look, given a world
-- with nothing in it: the suite lends the overworld away for the length of
-- the check so the march has nothing to shorten against.
local Game = require("src.core.Game")
local hadOw = Game.overworld
Game.overworld = nil
ThirdPerson.out, ThirdPerson.len = 1, ThirdPerson.BOOM
local eye, aim = ThirdPerson.place({ 100, 20, 200 }, 0, 0, 1,
{ 100, 20, 224 })
T.check(math.abs(eye[3] - (200 - ThirdPerson.BOOM)) < 1e-6,
"looking south, the eye stands a full boom north of the pivot")
T.eq(eye[2], 20 + ThirdPerson.PIVOT_LIFT,
"raised to the orbit point above the head")
T.check(math.abs(eye[1] - (100 - ThirdPerson.SHOULDER)) < 1e-6,
"and slid along the rail to the camera's own right -- which is west "
.. "looking south, and puts the player left of centre")
T.check(math.abs(aim[1] - eye[1]) < 1e-6
and math.abs(aim[3] - eye[3] - (ThirdPerson.BOOM + 24)) < 1e-6,
"the focus slides with it, so the rail moves the frame and not the look")
ThirdPerson.out, ThirdPerson.len = 0, 0
local head = { 100, 20, 200 }
local focus = { 100, 20, 224 }
T.eq(ThirdPerson.place(head, 0, 0, 1, focus), head,
"with the boom fully in, the eye IS the head -- 1ST to the pixel")
-- ------- what the extension decides
--
-- The rig is built through FirstPerson exactly as 1ST's is; the boom moves
-- the eye, and everything keyed to where the eye stands follows it.
ThirdPerson.out, ThirdPerson.len = 1, ThirdPerson.BOOM
FirstPerson.yaw, FirstPerson.pitch = 0, 0
FirstPerson.blend = 1
FirstPerson.frame({ px = 100, py = 200, gh = 0, lift = 0 }, 500, 600, 320, 288)
T.check(FirstPerson.cardBlend() == 1,
"the boomed rig turns the cards to face it, exactly as the head does")
T.check(not FirstPerson.hidePlayer(),
"but the player's own card is DRAWN -- it is what the camera is watching")
T.eq(FirstPerson.apparentFacing("down", 108, 208), "up",
"and it shows the camera behind it its back")
-- and a boom a wall has squeezed back into the head takes the card out
-- again: at that range it is the first-person problem word for word
ThirdPerson.len = ThirdPerson.SHOW_AT - 1
T.check(FirstPerson.hidePlayer(),
"backed into a fence, the collapsed boom stops drawing the card")
ThirdPerson.len = ThirdPerson.SHOW_AT
T.check(not FirstPerson.hidePlayer(), "and draws it again the moment it clears")
ThirdPerson.len = ThirdPerson.BOOM
T.eq(FirstPerson.bodyFacing(1, 0), "right",
"a body walking east turns east, whichever way the camera is pointed")
T.eq(FirstPerson.bodyFacing(0, -1), "up", "and north walking north")
T.eq(FirstPerson.bodyFacing(0, 0), FirstPerson.compassFacing(),
"standing still it comes back round to the camera's bearing, which is "
.. "the one A talks along")
-- ------- the spin-flicker guard
--
-- The player's card is the one whose body the camera is derived FROM: the
-- body is pointed along the camera's own yaw, so the angle between them is
-- a flat 180 degrees and the card should show its back and nothing else,
-- at every bearing.
--
-- Quantise the body to a compass point first and that stops being true.
-- The shoulder rail stands the eye a few degrees off the exact rear axis,
-- and the round trip through four directions has no margin to spare for
-- it: in a band just short of each 45-degree boundary the pair measures as
-- 135 degrees and picks the mirrored PROFILE frame. Standing perfectly
-- still. Spin the camera and you sweep four of those bands a revolution --
-- the character flicking sideways for a split second, which is the bug
-- this pair of checks exists to hold shut.
--
-- 44 degrees is inside the first band. No lag anywhere: the body is
-- pointed exactly where the camera looks, which is what standing still IS.
FirstPerson.yaw, FirstPerson.pitch = math.rad(44), 0
FirstPerson.frame({ px = 100, py = 200, gh = 0, lift = 0 }, 500, 600, 320, 288)
FirstPerson.bodyYaw = FirstPerson.yaw
T.eq(FirstPerson.apparentFacing("down", 108, 208), "right",
"measured off the compass point, a standing body picks the profile -- "
.. "the flicker, reproduced with the camera perfectly still")
T.eq(FirstPerson.playerFacing("down", 108, 208), "up",
"measured off the body's own bearing, the card keeps its back turned")
-- and the whole revolution, which is the assertion that actually matters:
-- there is no bearing at all where a standing body shows anything but its
-- back
;(function()
local wrong = {}
for deg = 0, 359 do
FirstPerson.yaw = math.rad(deg)
FirstPerson.frame({ px = 100, py = 200, gh = 0, lift = 0 },
500, 600, 320, 288)
FirstPerson.bodyYaw = FirstPerson.yaw
if FirstPerson.playerFacing(FirstPerson.compassFacing(), 108, 208)
~= "up" then
wrong[#wrong + 1] = deg
end
end
T.eq(#wrong, 0,
"a standing body shows its back at every one of 360 bearings (bad: "
.. table.concat(wrong, ",") .. ")")
end)()
-- and with nothing holding a bearing -- a scripted walk, a cutscene, the
-- grid walk -- the player falls back to the four-direction answer with
-- everybody else
FirstPerson.releaseBody()
T.eq(FirstPerson.bodyYaw, nil, "releasing the body drops the bearing")
T.eq(FirstPerson.playerFacing("down", 108, 208),
FirstPerson.apparentFacing("down", 108, 208),
"and the card reads exactly as an NPC's would")
T.eq(FirstPerson.pointBody(0, 0), FirstPerson.compassFacing(),
"pointing it again hands back the compass facing p.facing wants")
T.eq(FirstPerson.bodyYaw, FirstPerson.yaw, "and records the bearing behind it")
FirstPerson.yaw, FirstPerson.pitch = 0, 0
FirstPerson.frame({ px = 100, py = 200, gh = 0, lift = 0 }, 500, 600, 320, 288)
ThirdPerson.out, ThirdPerson.len = 0, 0
T.eq(FirstPerson.bodyFacing(1, 0), "down",
"in the head the body is the head, whichever way it walks")
T.check(FirstPerson.hidePlayer(),
"and the card the camera stands inside is left out of the frame again")
-- everything the section borrowed, put back
Game.overworld = hadOw
FirstPerson.blend = 0
ThirdPerson.out, ThirdPerson.len, ThirdPerson.want = 0, 0, 0
Voxel3D.camera = nil
VoxelState.reset()
end
-- ------- the VR rig's arithmetic
--
-- VRRig is the deliberately pure half of the VR stack: headset poses in,
+198
View File
@@ -0,0 +1,198 @@
-- Scratch driver: shots of the 3RD (third-person) rung -- the eye boomed
-- off the back of the player's head, the player's own card drawn and turned
-- to face it, the boom shortening against walls indoors, and the body
-- turning to face where it walks.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/tp_shots.lua \
-- SHOT_DIR=.scratchpad/tpshots lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/tp")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[tp] DRAMATIC_SHAPE mod not loaded")
return love.event.quit()
end
local V = handle.lib
local FirstPerson = V.require("FirstPerson")
local ThirdPerson = V.require("ThirdPerson")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
local DayNight = V.require("DayNight")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 0
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if FirstPerson.blend >= 1 and Voxel.ready
and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
-- the nearest WALKABLE cell (fp_shots' helper): a guessed coordinate
-- inside a building footprint buries the pivot in the geometry
local function place(mapId, x, y)
U.teleport(game, mapId, x, y, "down")
local ow = game.stack:top()
local map = ow and ow.map
if not map or map:isWalkableCell(x, y) then return end
for r = 1, 8 do
for dy = -r, r do
for dx = -r, r do
if math.max(math.abs(dx), math.abs(dy)) == r then
local cx, cy = x + dx, y + dy
if map:inBounds(cx, cy) and map:isWalkableCell(cx, cy) then
U.teleport(game, mapId, cx, cy, "down")
print(("[tp] (%d,%d) not walkable; standing at (%d,%d)")
:format(x, y, cx, cy))
return
end
end
end
end
end
end
-- yaw is a world bearing: 0 south, pi/2 east, pi north, -pi/2 west
local SCENES = {
-- Pallet Town, mid-street: the player's own card seen from behind, in
-- each compass direction plus a diagonal (the off-grid case, where a
-- south-facing card would be edge-on to any orbit camera)
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, label = "pallet_north" },
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = 0, label = "pallet_south" },
{ map = "PALLET_TOWN", x = 9, y = 7, yaw = math.pi / 2, label = "pallet_east" },
{ map = "PALLET_TOWN", x = 9, y = 7, yaw = 3 * math.pi / 4,
label = "pallet_diag" },
-- the shoreline: the boom over water, reflecting the player
{ map = "PALLET_TOWN", x = 9, y = 12, yaw = 0, label = "pallet_water" },
-- pitched down, the classic third-person framing; and up, where the
-- boom has to shorten rather than bury the eye in the ground
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi,
pitch = math.rad(35), label = "pallet_down" },
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi,
pitch = -math.rad(40), label = "pallet_skyward" },
-- Route 1: grass and ledges, with the character in frame for scale
{ map = "ROUTE_1", x = 10, y = 28, yaw = math.pi, label = "route1_north" },
-- interiors: the one place a 48px boom cannot fit, so the collision
-- march is the whole shot
{ map = "VIRIDIAN_POKECENTER", x = 3, y = 5, yaw = math.pi,
label = "center_north" },
{ map = "REDS_HOUSE_1F", x = 3, y = 4, yaw = math.pi,
label = "reds_house" },
}
local shots = 0
for _, s in ipairs(SCENES) do
place(s.map, s.x, s.y)
Pipelines.setLevel("voxel", Voxel.TP_LEVEL)
Pipelines.setLevel("tiltshift", 0)
settle()
FirstPerson.yaw = s.yaw
FirstPerson.pitch = s.pitch or FirstPerson.PITCH_DEFAULT
U.wait(20)
print(("[tp] %-16s boom %.1f of %.1f"):format(s.label, ThirdPerson.len,
ThirdPerson.BOOM))
if U.shot(game, ("%s/%s.png"):format(ROOT, s.label)) then
shots = shots + 1
end
end
-- the slide from 1ST to 3RD: the eye walks out of the head rather than
-- cutting, so the halfway frame is a real camera position
place("PALLET_TOWN", 13, 14)
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
settle()
if U.shot(game, ROOT .. "/from_1st.png") then shots = shots + 1 end
Pipelines.setLevel("voxel", Voxel.TP_LEVEL)
for _ = 1, 300 do
if ThirdPerson.out >= 0.5 then break end
U.wait(1)
end
if U.shot(game, ROOT .. "/boom_mid.png") then shots = shots + 1 end
U.wait(60)
if U.shot(game, ROOT .. "/boom_out.png") then shots = shots + 1 end
-- ------- the free walk, with the body turning
--
-- Hold forward with the head yawed off-grid: the player must GLIDE (off
-- the 16px grid, which no grid step can do) and the body must end up
-- facing its own travel rather than the lens.
place("PALLET_TOWN", 13, 14)
Pipelines.setLevel("voxel", Voxel.TP_LEVEL)
settle()
local ow = game.stack:top()
local p = ow.player
FirstPerson.yaw = 3 * math.pi / 4 -- northeast, deliberately off-grid
FirstPerson.pitch = FirstPerson.PITCH_DEFAULT
local x0, y0 = p.px, p.py
U.hold(game, "up", 90)
local moved = math.abs(p.px - x0) + math.abs(p.py - y0)
print(("[tp] walk moved %.1f px; off-grid: %s; body faces %s (camera %s)")
:format(moved,
tostring(p.px % 16 ~= 0 or p.py % 16 ~= 0),
tostring(p.facing), tostring(FirstPerson.compassFacing())))
if U.shot(game, ROOT .. "/walked.png") then shots = shots + 1 end
-- strafing: the one case the body facing exists for. Hold RIGHT with the
-- head due north and the character must walk east showing its flank.
place("PALLET_TOWN", 13, 14)
settle()
FirstPerson.yaw = math.pi
U.hold(game, "right", 40)
print(("[tp] strafe: body faces %s, camera looks %s")
:format(tostring(p.facing), tostring(FirstPerson.compassFacing())))
if U.shot(game, ROOT .. "/strafe.png") then shots = shots + 1 end
-- ------- the spin, sampled
--
-- Stand still and turn the camera fast, sampling the frame the player's
-- own card actually draws every rendered frame. A standing body is
-- pointed along the camera's own yaw, so it must show its back the whole
-- way round; anything else is the sideways flick. Sampled through the
-- live rig, so this measures the real thing rather than the arithmetic.
place("PALLET_TOWN", 13, 14)
Pipelines.setLevel("voxel", Voxel.TP_LEVEL)
settle()
ow = game.stack:top()
p = ow.player
local seen, frames = {}, 0
for _ = 1, 240 do
FirstPerson.lookBy(math.rad(7), 0) -- ~420 deg/s, a hard flick
U.wait(1)
local f = FirstPerson.playerFacing(p.facing, p.px + 8, p.py + 8)
seen[f] = (seen[f] or 0) + 1
frames = frames + 1
end
local report = {}
for f, n in pairs(seen) do
report[#report + 1] = ("%s x%d"):format(f, n)
end
table.sort(report)
print(("[tp] spin: %d frames, card frames seen: %s")
:format(frames, table.concat(report, ", ")))
print(("[tp] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end