diff --git a/CHANGELOG.md b/CHANGELOG.md index e6f0ad3..3823961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,57 @@ # Changelog +## 1.5.0 + +### Added + +- **1ST: a first-person camera, played like a modern one.** A seventh + rung on the VOXEL ladder (hotkey 3 walks it; the OPTIONS row carries + it). Stepping onto it dives the camera from wherever the orbit was + into the player's own head over half a second, and stepping off flies + it back out. The rig rides the same placed-camera seam the staged + battle proved out, so the sky's bands meet the horizon, the sun and + moon hang where their shadows say, and the water reflects at eye + level -- all through math that was already there. + + - **Free look.** Relative mouse motion (the cursor is captured while + the rung is on; left click is A, right click is B), the right + stick at a rate with a squared response curve, or a touch dragged + across any open screen -- the overlay's d-pad and buttons still + work, and a second finger can drag the view while the first + walks. Pitch clamps short of straight up and straight down. + + - **Free movement.** While 1ST drives, the grid walk is replaced by + a continuous, camera-relative one: push forward and you go where + you look, at any angle, sliding along whatever you graze. The left + stick's raw deflection, the touch d-pad's true vector, or the held + keys (forward / backpedal / strafe) all steer it. The grid is + still the game: the walk asks the engine's own collision the same + per-cell questions a grid step asks, the logical cell tracks the + body, and every cell crossed runs the engine's own landing + pipeline -- warps, encounters, spinners, gates, poison, repel, the + step counters. Walking off the map edge, into a ledge or into a + boulder hands the push to the engine's own handlers, so + connections cross, ledges hop and boulders shove exactly as + themselves. Speed is the grid walker's own (bike included), so + distance per second and encounters per tile are unchanged. + + - **Billboards seen from inside the world.** Character cards stop + leaning and start turning: upright, yawed about their feet to face + the eye, wearing the frame their pose shows *this* viewer -- walk + behind an NPC and you see their back, circle to a flank and you + get the profile, exactly the four frames Gen 1 drew. The authored + figures (the couch sitters) turn the same way, about their own + middle. The sun pass swaps frames in step, so a card never reads + its own shadow through a mirror-flipped record of itself. The + player's own card is left out of the camera draw -- the eye stands + in it -- but still casts its shadow on the ground ahead. + + - The shadow map's box follows the look (the orbit's fit reaches far + north and barely south, which is wrong for a head facing south); + the world curve is declined outright while the head owns the + camera; and the whole rung falls back to the 75-degree orbit on + hardware without the 3D pass. + ## 1.4.3 ### Added diff --git a/README.md b/README.md index 14e2cb8..2ec0b45 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,26 @@ clear ground — shot over the shoulder, the player's mon low and left and the enemy high and right, with a slow parallax drift behind them and a depth-of-field pass that keeps both of them sharp. -Purely presentational. Nothing here reaches collision, movement, triggers -or scripts — it changes what the world *looks* like and nothing about what -it *is*. The battle arena is where the **camera** goes, not where anybody -goes: no cell, facing, flag or warp is written, so the player is standing -exactly where the fight found them when it ends. +And the whole thing from inside. The ladder's top rung, **1ST**, dives the +camera into the player's own head: free look on the mouse (captured while +the rung is on — left click is A, right click is B), the right stick, or a +touch dragged across open screen; free movement that goes where you look, +at any angle, sliding along walls — the left stick's raw deflection, the +touch d-pad's true vector, or WASD as forward/backpedal/strafe. NPCs turn +to face the eye wearing the frame their pose shows *this* viewer — walk +behind someone and you see their back — and the sky, the shadows and the +water reflections all carry over, because the head rides the same placed +camera the battle shot proved out. + +Presentational, with one deliberate exception. Every rung but 1ST changes +what the world *looks* like and nothing about what it *is*; the battle +arena is where the **camera** goes, not where anybody goes. 1ST replaces +the grid walk with a free one while it is selected — but 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 cell synced, and runs the +engine's own landing pipeline per cell crossed, so warps, encounters, +ledges, gates and scripts all fire exactly as themselves. Step off the +rung and the grid walk is back. ## Controls diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index 73ed890..eb2dbb9 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -853,14 +853,24 @@ end -- character card (VoxelScene). A figure baked into the terrain mesh could -- not lean, and a shared mesh could not carry per-figure placement. -- --- A list, not a mesh: `{ mesh, wx, wz, y }` per figure. Maps have one or --- none, so the loop that draws them is shorter than the terrain's. +-- A list, not a mesh: `{ mesh, wx, wz, y, w }` per figure. Maps have one +-- or none, so the loop that draws them is shorter than the terrain's. +-- `w` is the card's own width in its local space (its quads start at +-- x = 0), measured here because the first-person pass yaws a card about +-- its middle -- a card yawed about its left edge swings off its seat. local function buildFigureMeshes(map) local out = {} for _, f in ipairs(Structures.forMap(map).figures or {}) do local mesh = quadsMesh(f.quads) if mesh then - out[#out + 1] = { mesh = mesh, wx = f.wx, wz = f.wz, y = f.y } + local w = 0 + for _, q in ipairs(f.quads) do + for c = 1, 4 do + local x = q[c] and q[c][1] + if x and x > w then w = x end + end + end + out[#out + 1] = { mesh = mesh, wx = f.wx, wz = f.wz, y = f.y, w = w } end end return out @@ -1132,8 +1142,8 @@ function ChunkMesher.flowers(map) return c and c.flowers or nil end --- Authored figures as `{ mesh, wx, wz, y }` records -- each placed by its --- own leaning matrix at draw time, so they cannot share one mesh. +-- Authored figures as `{ mesh, wx, wz, y, w }` records -- each placed by +-- its own leaning matrix at draw time, so they cannot share one mesh. function ChunkMesher.figures(map) local c = cache[map.id] local list = c and c.figures diff --git a/lib/FirstPerson.lua b/lib/FirstPerson.lua new file mode 100644 index 0000000..7722adb --- /dev/null +++ b/lib/FirstPerson.lua @@ -0,0 +1,680 @@ +-- Voxel world mode: the first-person camera -- the 1ST rung. +-- +-- 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 +-- entirely: the eye stands in the player's own head, the view direction is +-- the player's to steer -- mouse, right stick or a touch drag -- and the +-- rig rides the placed-camera seam (Voxel3D.camera) that the staged battle +-- already proved out. Everything downstream of that seam -- the shader +-- uniforms, project(), the sky's vanishing line, the water's lean -- reads +-- eye and focus the same way it always has. +-- +-- What this module owns: +-- +-- the ATTITUDE yaw and pitch, fed by whichever look input speaks: +-- relative mouse motion, the right stick's rate, or a +-- touch dragged across open screen. All three drive the +-- same two numbers, so they compose instead of fighting. +-- +-- the BLEND easing between the orbit and the head. Stepping onto +-- the rung dives the camera from wherever the orbit was +-- into the player's eyes over half a second; stepping off +-- flies it back out. Mid-blend the rig is a straight lerp +-- of the two cameras -- eye, focus, fov, up -- through +-- the same placed-camera record. +-- +-- the MOVE INTENT the analog vector FreeMove walks the player by, +-- gathered here because it is made of the same devices: +-- the left stick's raw axes, the touch d-pad's true +-- deflection, or the held keys, rotated by this camera's +-- yaw so "forward" means "where I am looking". +-- +-- Deliberately NOT here: movement itself (lib/FreeMove.lua, which owns the +-- collision walk and the grid the game logic still lives on), and the +-- billboard math that faces cards at this eye (VoxelScene, which owns +-- every other card matrix too). +-- +-- Everything the module reaches -- the mouse's relative mode, the wrapped +-- love handlers, the touch overlay's hit test -- is pcall-guarded the same +-- way the 3D pass is: headless runs and drivers without a mouse simply +-- never see the input, and the rung falls back to holding the 75-degree +-- orbit. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Mat4 = V.require("Mat4") +local Voxel = V.require("VoxelState") +local Voxel3D = V.require("Voxel3D") +local WorldCurve = V.require("WorldCurve") + +local FirstPerson = {} + +-- ------- the rig's numbers +-- +-- EYE_HEIGHT stands the eye near the top of the 16px sprite -- the head, +-- not the hat tip -- above the same ground-plus-lift the character card +-- stands on, so surfing bobs and ledge hops carry the view with them. +-- +-- FOV is wider than the diorama's ~53 degrees: inside the world, the +-- diorama's lens reads as a keyhole. 65 vertical is the modern-shooter +-- middle ground. +-- +-- FOCUS_DIST is short on purpose: the placed-camera branch derives its +-- near plane from |eye - focus| (dist * 0.05), and the eye walks within +-- 2-3 world pixels of a wall face when sliding along it -- a far focus +-- would push the near plane through the wall and clip a hole in it. +FirstPerson.EYE_HEIGHT = 13 +FirstPerson.FOV = math.rad(65) +FirstPerson.FOCUS_DIST = 24 + +-- Pitch limits, in radians below horizontal (positive looks DOWN). The +-- world has no ceiling and the sky's bands sit low, so looking far up +-- shows the void above the gradient; the up-range is clamped tighter than +-- the down-range for that reason, not a technical one. +FirstPerson.PITCH_DOWN = math.rad(70) +FirstPerson.PITCH_UP = -math.rad(50) +FirstPerson.PITCH_DEFAULT = math.rad(10) + +-- how long the dive into (and out of) the head takes, in seconds +FirstPerson.BLEND_TIME = 0.45 + +-- ------- look input tuning +-- +-- MOUSE_SENS is radians per relative-mode count -- about 0.18 degrees per +-- count, the conventional shooter default. STICK rates are radians per +-- second at full deflection, with a squared response curve so small +-- deflections aim and full ones turn. TOUCH_TURN is what one full screen +-- width of drag turns, mobile-shooter convention. +FirstPerson.MOUSE_SENS = 0.0032 +FirstPerson.STICK_YAW = 3.5 +FirstPerson.STICK_PITCH = 2.4 +FirstPerson.STICK_DEAD = 0.18 +FirstPerson.TOUCH_TURN = 2.2 * math.pi +FirstPerson.MOVE_DEAD = 0.25 + +-- ------- state +-- +-- Yaw is a world bearing: 0 faces south (+Z, the way a resting sprite +-- faces), pi/2 east -- the same convention VoxelScene.YAW uses, so a +-- facing converts to a yaw by table lookup. +FirstPerson.yaw = 0 +FirstPerson.pitch = FirstPerson.PITCH_DEFAULT +FirstPerson.blend = 0 + +local wasEngaged = false +local stick = { x = 0, y = 0 } -- right stick, latest event values +local mouseDX, mouseDY = 0, 0 -- relative counts since last update +local lookTouch = nil -- { id, x, y } of the claimed finger +local touchMove = nil -- the touch d-pad's analog deflection +local captured = false -- mouse relative mode engaged by us + +-- the placed-camera record this module last handed to Voxel3D, so passes +-- that key behaviour off "is the first-person rig the one drawing" (the +-- billboard yaw, the frame remap) can ask by identity rather than by mode +-- -- the battle's own placed camera must never read as first person +local rig = nil + +local FACING_ANGLE = { + down = 0, + right = math.pi / 2, + up = math.pi, + left = -math.pi / 2, +} +local FACING_ORDER = { "down", "right", "up", "left" } + +local function wrapPi(a) + return (a + math.pi) % (2 * math.pi) - math.pi +end + +local function ease(t) + return t * t * (3 - 2 * t) +end + +-- ------- gates + +-- Whether the 1ST rung is selected and the 3D pass can carry it. +function FirstPerson.engaged() + return Voxel.isFirstPerson(Voxel.level) and Voxel3D.available() +end + +-- Whether first person should be READING the player's inputs right now: +-- engaged, with the overworld on top of the stack (a menu, a dialog or a +-- battle above it owns the buttons, exactly as it does for grid walking). +function FirstPerson.driving() + if not FirstPerson.engaged() then return false end + local ok, top, ow = pcall(function() + local Game = require("src.core.Game") + return Game.stack and Game.stack:top(), Game.overworld + end) + return ok and top ~= nil and top == ow +end + +-- The eased blend, 0 at the orbit and 1 in the head. +function FirstPerson.blendEased() + return ease(FirstPerson.blend) +end + +-- The blend, but only while the free-roam pass's own rig is the placed +-- camera. The battle scene places a camera of its own through the same +-- seam, and its cards must keep their stage lean rather than yawing at a +-- first-person eye that is not looking at them. +function FirstPerson.cardBlend() + if not rig or Voxel3D.camera ~= rig then return 0 end + return ease(FirstPerson.blend) +end + +-- Whether the player's own card should be left out of the camera draw: +-- 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. +function FirstPerson.hidePlayer() + return FirstPerson.cardBlend() > 0.9 +end + +-- ------- attitude + +-- Apply a look delta, in radians. Everything that turns the head funnels +-- through here, so the clamps live once. +function FirstPerson.lookBy(dyaw, dpitch) + FirstPerson.yaw = wrapPi(FirstPerson.yaw + dyaw) + FirstPerson.pitch = math.max(FirstPerson.PITCH_UP, + math.min(FirstPerson.PITCH_DOWN, + 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) + 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 unit look direction, and its flat (ground-plane) part. +local function lookDir() + local cp = math.cos(FirstPerson.pitch) + return math.sin(FirstPerson.yaw) * cp, + -math.sin(FirstPerson.pitch), + math.cos(FirstPerson.yaw) * cp +end + +function FirstPerson.lookFlat() + return math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw) +end + +-- ------- billboards seen from inside the world +-- +-- The diorama's cards face south and lean back by the camera's pitch -- +-- correct for a camera that always stands south. An eye that can stand +-- ANYWHERE sees a south-facing card edge-on from the east, so in first +-- person every card yaws about its feet to face the eye (cylindrical +-- billboarding: upright, never tipping). VoxelScene blends its matrices +-- between the two by cardBlend. + +-- The yaw that turns a card's south-facing normal toward the eye. +function FirstPerson.cardYaw(wx, wz) + local eye = rig and rig.eye + if not eye then return 0 end + local dx, dz = eye[1] - wx, eye[3] - wz + if dx * dx + dz * dz < 1e-9 then return 0 end + return math.atan2(dx, dz) +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. +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] +end + +-- ------- the move intent +-- +-- The analog vector FreeMove walks by, in CAMERA space: mx strafes (+ +-- right), mz advances (+ forward). Whichever device is actually deflected +-- answers -- the left stick's raw axes first (the engine quantises them to +-- a d-pad; the raw pair is the analog truth), then a touch d-pad finger, +-- then the held keys. Magnitude caps at 1. +function FirstPerson.moveVector() + local ok, Game = pcall(require, "src.core.Game") + local input = ok and Game.input or nil + + local ax = input and input.stickAxis or nil + if ax then + local mag = math.sqrt(ax.x * ax.x + ax.y * ax.y) + if mag > FirstPerson.MOVE_DEAD then + local t = math.min(1, (mag - FirstPerson.MOVE_DEAD) + / (1 - FirstPerson.MOVE_DEAD)) + return ax.x / mag * t, -ax.y / mag * t + end + end + + if touchMove then + local mag = math.sqrt(touchMove.x * touchMove.x + + touchMove.y * touchMove.y) + if mag > FirstPerson.MOVE_DEAD then + local t = math.min(1, mag) + return touchMove.x / mag * t, -touchMove.y / mag * t + end + end + + if input then + local mx = (input:isDown("right") and 1 or 0) + - (input:isDown("left") and 1 or 0) + local mz = (input:isDown("up") and 1 or 0) + - (input:isDown("down") and 1 or 0) + if mx ~= 0 or mz ~= 0 then + local mag = math.sqrt(mx * mx + mz * mz) + return mx / mag, mz / mag + end + end + return 0, 0 +end + +-- Rotate a camera-space move into world space: forward is the flat look +-- direction, strafe-right is its right hand. (cross(forward, up) with +-- forward = (sin y, 0, cos y) and up = +Y lands right on (-cos y, 0, +-- sin y): face south and your right hand points west.) +function FirstPerson.moveWorld(mx, mz) + local s, c = math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw) + return -c * mx + s * mz, s * mx + c * mz +end + +-- ------- the tick + +-- Runs from the pipeline's update hook, every frame whatever the level -- +-- the same tick VoxelState eases the orbit on. Owns the blend, the mouse +-- capture lifecycle, and the frame's stick-rate look. +function FirstPerson.update(dt) + local engagedNow = FirstPerson.engaged() + + -- entering the rung: the head starts looking the way the sprite faces, + -- pitched gently down -- the reading pose of the flat game + if engagedNow and not wasEngaged then + local ok, facing = pcall(function() + local Game = require("src.core.Game") + return Game.overworld and Game.overworld.player + and Game.overworld.player.facing + end) + FirstPerson.yaw = (ok and FACING_ANGLE[facing]) or 0 + FirstPerson.pitch = FirstPerson.PITCH_DEFAULT + end + wasEngaged = engagedNow + + -- the blend, held at flat until there is terrain to dive into -- the + -- same wait Voxel.update keeps for the orbit tween, for the same reason + local target = engagedNow and 1 or 0 + if target > FirstPerson.blend and FirstPerson.blend == 0 + and not Voxel.ready then + target = 0 + end + local step = dt / FirstPerson.BLEND_TIME + if FirstPerson.blend < target then + FirstPerson.blend = math.min(target, FirstPerson.blend + step) + elseif FirstPerson.blend > target then + FirstPerson.blend = math.max(target, FirstPerson.blend - step) + end + if FirstPerson.blend <= 0 and rig then + -- fully out: let go of the placed camera (unless a battle already + -- swapped its own in, which is not ours to clear) + if Voxel3D.camera == rig then Voxel3D.camera = nil end + rig = nil + end + + -- 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 + -- OS (alt-tab) re-arms itself on the next focused frame. + local wantCapture = engagedNow + if wantCapture and love.window and love.window.hasFocus then + local okF, focus = pcall(love.window.hasFocus) + wantCapture = okF and focus or false + end + if love.mouse and love.mouse.setRelativeMode then + local okM, isRel = pcall(love.mouse.getRelativeMode) + if okM and isRel ~= wantCapture then + pcall(love.mouse.setRelativeMode, wantCapture) + end + captured = wantCapture + end + + local driving = FirstPerson.driving() + + -- The mouse's counts, accumulated by the wrapped handler since the last + -- tick; dropped unread while something else owns the screen. + -- + -- The yaw sign is NEGATED, here and in every look input below: yaw grows + -- south -> east -> north (the world runs +X east, +Z south, and the + -- direction is (sin yaw, cos yaw)), which seen from behind the eye is a + -- LEFT turn -- so "move the mouse right, look right" means subtracting. + local dx, dy = mouseDX, mouseDY + mouseDX, mouseDY = 0, 0 + if driving and (dx ~= 0 or dy ~= 0) then + FirstPerson.lookBy(-dx * FirstPerson.MOUSE_SENS, + dy * FirstPerson.MOUSE_SENS) + end + + -- the right stick is a rate: radians per second, squared response so + -- the first half of the throw aims and the rest turns + if driving then + local rx, ry = stick.x, stick.y + local function curve(v) + local a = math.abs(v) + if a < FirstPerson.STICK_DEAD then return 0 end + a = (a - FirstPerson.STICK_DEAD) / (1 - FirstPerson.STICK_DEAD) + return (v < 0 and -1 or 1) * a * a + end + local cy, cp = curve(rx), curve(ry) + if cy ~= 0 or cp ~= 0 then + -- negated yaw for the same reason as the mouse above + FirstPerson.lookBy(-cy * FirstPerson.STICK_YAW * dt, + cp * FirstPerson.STICK_PITCH * dt) + end + end +end + +-- ------- the rig itself + +-- The orbit camera's eye/focus/fov/up for the frame's centre -- the same +-- arithmetic Voxel3D.viewProjection runs, restated here because the blend +-- needs both ends as DATA. Kept textually tiny so the two cannot drift: +-- focus on the centre, eye FOCAL*vh away at the pitch, up perpendicular +-- in the YZ plane. +local function orbitRig(cx, cy, vh) + local a = Voxel.angle + local dist = Voxel.FOCAL * vh + return { cx, dist * math.cos(a), cy + dist * math.sin(a) }, + { cx, 0, cy }, + 2 * math.atan(1 / (2 * Voxel.FOCAL)), + { 0, math.sin(a), -math.cos(a) } +end + +local lastEye = nil -- frozen head pose for player-less frames + +-- Build this frame's placed camera and hand it to Voxel3D, plus the scene +-- centre the curve and the depth reference should use. `me` is the +-- player's posed entry (px, py, gh, lift) or nil (a Fly animation), and +-- (cx, cy) the orbit's own view centre. +-- +-- Returns nil with the blend fully out, which is the caller's signal to +-- leave the orbit in charge. +function FirstPerson.frame(me, cx, cy, vw, vh) + local b = FirstPerson.blend + if b <= 0 then + if rig and Voxel3D.camera == rig then Voxel3D.camera = nil end + rig = nil + return nil + end + local e = ease(b) + + local head + if me then + head = { me.px + 8, + (me.gh or 0) + (me.lift or 0) + FirstPerson.EYE_HEIGHT, + me.py + 8 } + lastEye = head + else + head = lastEye or { cx, FirstPerson.EYE_HEIGHT, cy } + end + local lx, ly, lz = lookDir() + local fpFocus = { head[1] + lx * FirstPerson.FOCUS_DIST, + head[2] + ly * FirstPerson.FOCUS_DIST, + head[3] + lz * FirstPerson.FOCUS_DIST } + + local oEye, oFocus, oFov, oUp = orbitRig(cx, cy, vh) + local function mix(p, q) + return { p[1] + (q[1] - p[1]) * e, + p[2] + (q[2] - p[2]) * e, + p[3] + (q[3] - p[3]) * e } + end + local up = mix(oUp, { 0, 1, 0 }) + local ul = math.sqrt(up[1] * up[1] + up[2] * up[2] + up[3] * up[3]) + if ul > 1e-6 then up[1], up[2], up[3] = up[1] / ul, up[2] / ul, up[3] / ul + else up = { 0, 1, 0 } end + + -- the world curve eases out with the blend: standing inside the world, + -- the bend that sells the diorama reads as the ground falling away. A + -- true zero (curve declined) needs the field present -- nil would let + -- Voxel3D fall back to the setting + local k = WorldCurve.k(vh) * (1 - e) + + rig = { + eye = mix(oEye, head), + focus = mix(oFocus, fpFocus), + fov = oFov + (FirstPerson.FOV - oFov) * e, + up = up, + curve = k, + } + Voxel3D.camera = rig + + -- the scene centre walks from the orbit's view centre to the head, so + -- the curve's focus, the depth reference and the glint's travel follow + -- the camera that is actually in charge + local sx = cx + (head[1] - cx) * e + local sy = cy + (head[3] - cy) * e + return rig, sx, sy +end + +-- Where the shadow pass should centre its box: pushed along the flat look +-- so the fitted frustum -- built for an orbit that always looks north -- +-- covers the ground THIS camera sees. The push is strongest looking +-- south (the direction the orbit's box barely reaches) and scales with +-- the blend. +function FirstPerson.shadowCenter(sx, sy, vh) + local e = FirstPerson.cardBlend() + if e <= 0 then return sx, sy end + local fx, fz = FirstPerson.lookFlat() + local ShadowMap = V.require("ShadowMap") + local cap = (ShadowMap.FAR_CAP or 2.5) * vh + return sx + fx * 0.6 * vh * e, + sy + fz * (fz > 0 and (cap - vh * 0.5) or vh * 0.4) * e +end + +-- The first-person facts a shadow signature has to include: the sun's +-- box is fitted around this camera, so turning the head or walking the +-- blend has to re-fit it even standing still. +function FirstPerson.signature() + local b = FirstPerson.blend + if b <= 0 then return "" end + return table.concat({ + math.floor(b * 64), + math.floor(FirstPerson.yaw * 64), + math.floor(FirstPerson.pitch * 64), + }, ",") +end + +-- ------- input capture +-- +-- The seams: relative mouse motion has no Game handler at all (the +-- engine's love.mousemoved only feeds the mouse-as-touch debug path), the +-- right stick's axes are explicitly ignored by Input, and a touch +-- anywhere off the overlay's controls dies in TouchControls. Each wrap +-- forwards everything it does not claim, and claims only while first +-- person is actually driving -- so with the rung off, every byte flows +-- exactly where it always did. + +local installed = false + +function FirstPerson.install() + if installed then return end + installed = true + + local Game = require("src.core.Game") + + -- ------- right stick + do + local inner = Game.gamepadaxis + function Game:gamepadaxis(joystick, axis, value) + if axis == "rightx" then stick.x = value + elseif axis == "righty" then stick.y = value end + return inner(self, joystick, axis, value) + end + end + -- generic (non-gamepad) sticks: axes 1/2 are the left stick by SDL + -- convention and Input already claims them; 3/4 are the usual right + -- pair on the same class of device. Real gamepads are excluded -- they + -- already spoke through the mapped rightx/righty above, and their RAW + -- axis 3 is as likely a trigger as a stick. + do + local inner = Game.joystickaxis + function Game:joystickaxis(joystick, axis, value) + local mapped = joystick and joystick.isGamepad and joystick:isGamepad() + if not mapped then + if axis == 3 then stick.x = value + elseif axis == 4 then stick.y = value end + end + return inner(self, joystick, axis, value) + end + end + + -- ------- mouse + -- + -- love.mousemoved rather than a Game method, because the engine has no + -- Game:mousemoved to wrap -- the callback in the project's main.lua is + -- the one place relative counts arrive. Claimed only while captured; + -- pass-through otherwise, including the mouse-as-touch path. + do + local inner = love.mousemoved + love.mousemoved = function(x, y, dx, dy, istouch) + if captured and not istouch then + mouseDX = mouseDX + (dx or 0) + mouseDY = mouseDY + (dy or 0) + return + end + if inner then return inner(x, y, dx, dy, istouch) end + end + end + -- While the mouse is captured there is no cursor to click UI with, so + -- the buttons become GB buttons: left is A, right is B -- through the + -- overlay's own press path, which a rebind can never detach. What WE + -- pressed is remembered per button, so the release always reaches the + -- overlay even if the capture ended while the button was down -- + -- otherwise a click that outlives the rung strands A held forever. + local mouseHeld = {} + local MOUSE_BTN = { [1] = "a", [2] = "b" } + do + local inner = love.mousepressed + love.mousepressed = function(x, y, button, istouch, presses) + if captured and not istouch and MOUSE_BTN[button] then + local Input = require("src.core.Input") + mouseHeld[button] = true + Input:overlayPressed(MOUSE_BTN[button]) + return + end + if inner then return inner(x, y, button, istouch, presses) end + end + end + do + local inner = love.mousereleased + love.mousereleased = function(x, y, button, istouch, presses) + if mouseHeld[button] then + local Input = require("src.core.Input") + mouseHeld[button] = nil + Input:overlayReleased(MOUSE_BTN[button]) + return + end + if inner then return inner(x, y, button, istouch, presses) end + end + end + + -- ------- touch + -- + -- A finger on open screen -- not on the overlay's d-pad or buttons -- + -- becomes the look drag. One finger owns the look at a time; every + -- other touch flows to TouchControls untouched, so a thumb can drag the + -- view while the other walks the d-pad. That d-pad finger is also read + -- back ANALOG here: TouchControls quantises it to four directions for + -- the grid game, but the deflection it quantised is exactly the move + -- vector a free walk wants. + local TouchControls = require("src.core.TouchControls") + + local function dpadVector(x, y) + local ok, v = pcall(function() + local L = TouchControls:layout() + local dz = L.dpad + local half = dz.w * 0.65 + return { x = math.max(-1, math.min(1, (x - dz.cx) / half)), + y = math.max(-1, math.min(1, (y - dz.cy) / half)) } + end) + return ok and v or nil + end + + do + local inner = Game.touchpressed + function Game:touchpressed(id, x, y) + if FirstPerson.driving() then + local onControl = nil + pcall(function() onControl = TouchControls:hitTest(x, y) end) + if not onControl and not lookTouch then + lookTouch = { id = id, x = x, y = y } + return + end + inner(self, id, x, y) + if onControl == "dpad" and TouchControls.dpadTouch == id then + touchMove = dpadVector(x, y) + end + return + end + return inner(self, id, x, y) + end + end + do + local inner = Game.touchmoved + function Game:touchmoved(id, x, y) + if lookTouch and lookTouch.id == id then + local w = 1280 + pcall(function() w = love.graphics.getWidth() end) + local per = FirstPerson.TOUCH_TURN / math.max(320, w) + if FirstPerson.driving() then + -- negated yaw for the same reason as the mouse (see update): + -- drag right, look right, the mobile-shooter convention + FirstPerson.lookBy(-(x - lookTouch.x) * per, + (y - lookTouch.y) * per) + end + lookTouch.x, lookTouch.y = x, y + return + end + if touchMove and TouchControls.dpadTouch == id then + touchMove = dpadVector(x, y) or touchMove + end + return inner(self, id, x, y) + end + end + do + local inner = Game.touchreleased + function Game:touchreleased(id, x, y) + if lookTouch and lookTouch.id == id then + lookTouch = nil + return + end + if TouchControls.dpadTouch == id then touchMove = nil end + return inner(self, id, x, y) + end + end + + -- a reset that drops held input state drops ours with it + do + local inner = Game.focus + function Game:focus(f) + lookTouch, touchMove = nil, nil + stick.x, stick.y = 0, 0 + mouseDX, mouseDY = 0, 0 + return inner(self, f) + end + end +end + +return FirstPerson diff --git a/lib/FreeMove.lua b/lib/FreeMove.lua new file mode 100644 index 0000000..eacd26a --- /dev/null +++ b/lib/FreeMove.lua @@ -0,0 +1,345 @@ +-- Voxel world mode: free movement for the first-person rung. +-- +-- 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. +-- +-- 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 +-- this module keeps the player's logical cell synced to wherever the free +-- walk stands, then reuses the engine's own machinery for every one of +-- those questions: +-- +-- passability the same isWalkableCell / water-while-surfing / +-- tile-pair / entity-occupancy verdicts Collision +-- hands the grid walker, asked per cell the player's +-- body overlaps. +-- +-- cell arrival OverworldState:onStepComplete, the same landing +-- pipeline a grid step runs -- warps, spinners, gates, +-- forced currents, poison, repel, encounters, the +-- step counters -- fired once per cell crossed, which +-- is exactly the rate a grid walk fires it. +-- +-- the special pushes walking off the map edge, into a ledge, or into +-- a boulder hands the quantised direction straight to +-- checkEdgeExit / checkLedgeHop / checkBoulderPush, +-- the engine's own handlers, which validate and stage +-- everything themselves (connections, the hop arc, +-- the two-push arm). While any of those animates a +-- scripted grid move, this module stands aside and +-- adopts the result. +-- +-- Nothing here writes save state, rolls encounters, or decides what a +-- warp does -- it moves a point, keeps the cell honest, and lets the +-- engine be the engine. Stepping off the rung snaps the point to its +-- cell and hands the walk back to the grid, and with the rung off this +-- module costs one gate check per frame. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local FirstPerson = V.require("FirstPerson") + +local FreeMove = {} + +-- The body: a circle in the ground plane. Small enough to walk every +-- one-cell corridor the grid game has (half a cell is 8), big enough to +-- keep the eye's near plane out of wall faces when sliding along them. +FreeMove.RADIUS = 5.5 + +-- World pixels per fixed 60Hz frame -- the grid walker's own speeds (16 +-- frames per 16px cell on foot, 8 on the bike), so distance covered per +-- second is unchanged and the encounter rate per tile crossed stays the +-- game's own. +FreeMove.WALK = 1.0 +FreeMove.BIKE = 2.0 + +local EPS = 0.01 + +-- the free position (player centre, world px) and the px/py we last wrote +-- -- if they differ from the player's, something else (a warp, a script) +-- moved them, and the free walk adopts rather than fights +local pos = nil +local lastPx, lastPy = nil, nil + +local function adopt(p) + pos = { x = p.px + 8, z = p.py + 8 } + lastPx, lastPy = p.px, p.py +end + +function FreeMove.drop() + pos = nil +end + +-- named for the suite: the module's live position, nil while dropped +function FreeMove._pos() + return pos +end + +-- ------- the per-cell verdict +-- +-- The same questions Collision.canMove asks for a grid step, asked of one +-- cell from the player's current standing. The player's OWN cell never +-- blocks -- the body must always be free to leave wherever it stands +-- (a warp mat, the water it is surfing, a cell an NPC just stepped +-- against). + +local function pairBlocked(map, surfing, sx, sy, tx, ty) + local Game = require("src.core.Game") + local tp = Game.data and Game.data.field and Game.data.field.tilePairs + if not tp then return false end + local list = surfing and tp.water or tp.land + if not list or #list == 0 then return false end + local tileset = map.def.tileset + local a = map:cellTile(sx, sy) + local b = map:cellTile(tx, ty) + for _, p in ipairs(list) do + if p.tileset == tileset + and ((p.a == a and p.b == b) or (p.a == b and p.b == a)) then + return true + end + end + return false +end + +-- Why (cx, cy) refuses the player's body, or nil when it may enter: +-- "bounds" | "tile" | "entity", the grid verdict's own names. +local function blockedCell(state, p, cx, cy) + if cx == p.cellX and cy == p.cellY then return nil end + local map = state.map + if not map:inBounds(cx, cy) then return "bounds" end + if not map:isWalkableCell(cx, cy) then + if not (p.surfing and map:isWaterCell(cx, cy)) then return "tile" end + end + if pairBlocked(map, p.surfing, p.cellX, p.cellY, cx, cy) then + return "tile" + end + local Collision = require("src.world.Collision") + if Collision.occupied(state.entities, cx, cy, p) then return "entity" end + return nil +end + +FreeMove._blockedCell = blockedCell -- named for the suite + +-- ------- the slide +-- +-- One axis at a time, clamped at the first refusing cell's face: the +-- classic axis-separated walk, which is where wall-sliding comes from -- +-- the blocked axis stops and the free one keeps going. Returns the +-- refusal ("bounds"/"tile"/"entity") when this axis was clamped. + +local function slideX(state, p, dx) + if dx == 0 then return nil end + local r = FreeMove.RADIUS + local nx = pos.x + dx + local z0 = math.floor((pos.z - r + EPS) / 16) + local z1 = math.floor((pos.z + r - EPS) / 16) + local hit = nil + local edge = dx > 0 and math.floor((nx + r) / 16) + or math.floor((nx - r) / 16) + for zc = z0, z1 do + hit = blockedCell(state, p, edge, zc) + if hit then break end + end + if hit then + if dx > 0 then nx = math.min(nx, edge * 16 - r - EPS) + else nx = math.max(nx, (edge + 1) * 16 + r + EPS) end + end + pos.x = nx + return hit +end + +local function slideZ(state, p, dz) + if dz == 0 then return nil end + local r = FreeMove.RADIUS + local nz = pos.z + dz + local x0 = math.floor((pos.x - r + EPS) / 16) + local x1 = math.floor((pos.x + r - EPS) / 16) + local hit = nil + local edge = dz > 0 and math.floor((nz + r) / 16) + or math.floor((nz - r) / 16) + for xc = x0, x1 do + hit = blockedCell(state, p, xc, edge) + if hit then break end + end + if hit then + if dz > 0 then nz = math.min(nz, edge * 16 - r - EPS) + else nz = math.max(nz, (edge + 1) * 16 + r + EPS) end + end + pos.z = nz + return hit +end + +-- ------- the blocked push +-- +-- The grid game's blocked step is where half its verbs live: the map-edge +-- crossing, the ledge hop, the boulder shove, the route-gate warp fired +-- by collision, and the honest bonk. Hand the engine the quantised +-- direction and let its own handlers decide -- each one validates itself +-- (checkLedgeHop matches the tile pair, checkEdgeExit checks the bounds), +-- so calling them on every firm push is safe. Returns true when one of +-- them took the frame over. +local function pushSpecials(state, dir, why) + local p = state.player + p.facing = dir -- the handlers read the push off the facing + if why == "bounds" and state:checkEdgeExit(dir) then return true end + if state:checkLedgeHop(dir) then return true end + if state:checkBoulderPush(dir) then return true end + if why ~= "entity" and state:canCollisionWarp() then + local Game = require("src.core.Game") + local Warp = require("src.world.Warp") + local w = Warp.onCollision(state.map, Game.data.field.warpCarpets, + p.cellX, p.cellY, dir) + if w then + state:takeWarp(w.def) + return true + end + end + if why ~= "entity" then + if (state.bumpCooldown or 0) <= 0 then + local Game = require("src.core.Game") + require("src.core.Sound").play(Game.data, "Collision") + state.bumpCooldown = 16 + end + end + return false +end + +-- ------- the tick +-- +-- Runs in place of OverworldState:handleInput while first person drives +-- (see install below), which means it inherits every gate the grid walk +-- has: never during scripted moves, transitions, or with anything above +-- the overworld on the stack. + +function FreeMove.tick(state) + local p = state.player + + -- a grid move is animating -- a ledge hop, a spinner slide, a scripted + -- walk -- or a cutscene owns the player: stand aside, adopt the result + if p.moving or p.inputLocked then + FreeMove.drop() + return + end + if not pos or p.px ~= lastPx or p.py ~= lastPy then adopt(p) end + + local Game = require("src.core.Game") + 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() + + if input:wasPressed("a") then + state:interact() + return + end + if input:wasPressed("start") then + require("src.core.Sound").play(Game.data, "Start_Menu") + require("src.ui.Screens").push(Game, "StartMenu") + return + end + + local mx, mz = FirstPerson.moveVector() + local wx, wz = FirstPerson.moveWorld(mx, mz) + + -- Cycling Road's downhill pull, the free-walk restatement of the grid + -- path's simulated PAD_DOWN: south drift with nothing held, braked by + -- holding A or B exactly as the Route 17 sign promises + local moving = (mx ~= 0 or mz ~= 0) + if not moving and Game.save and Game.save.onBike then + local fm = Game.data.field.forcedMovement + local braking = input:isDown("a") or input:isDown("b") + if fm and not braking then + for _, m in ipairs(fm.slopeMaps or {}) do + if m == state.map.id then + wx, wz, moving = 0, 1, true + break + end + end + end + end + + if not moving then return end + + state.bumpCooldown = math.max(0, (state.bumpCooldown or 0) - 1) + + local speed = (Game.save and Game.save.onBike) and FreeMove.BIKE + or FreeMove.WALK + local dx, dz = wx * speed, wz * speed + + local hitX = slideX(state, p, dx) + local hitZ = slideZ(state, p, dz) + + -- the walk cycle: the wall-bonk clock animates the legs of a player the + -- grid thinks is standing still, refreshed while the free walk covers + -- ground (Player:update ticks animClock off it; walkPhase reads it) + p.bumpFrames = 2 + + p.px, p.py = pos.x - 8, pos.z - 8 + lastPx, lastPy = p.px, p.py + + -- the cell the body stands in; crossing into a new one IS a step + local ncx = math.floor(pos.x / 16) + local ncy = math.floor(pos.z / 16) + if ncx ~= p.cellX or ncy ~= p.cellY then + p.cellX, p.cellY = ncx, ncy + state:onStepComplete() + -- a warp or a battle may have moved the world out from under the + -- walk; the adopt check on the next tick picks the pieces up + return + end + + -- a firm push into something that refused: the engine's own blocked-step + -- verbs, aimed the way the push leans + local hit, dir + if hitX and (not hitZ or math.abs(dx) >= math.abs(dz)) then + hit, dir = hitX, (dx > 0 and "right" or "left") + elseif hitZ then + hit, dir = hitZ, (dz > 0 and "down" or "up") + end + if hit and math.max(math.abs(dx), math.abs(dz)) > 0.4 * speed then + if pushSpecials(state, dir, hit) then + FreeMove.drop() + return + end + -- the push handlers may have turned the facing; the head still rules + p.facing = FirstPerson.compassFacing() + end +end + +-- ------- the seam +-- +-- OverworldState:handleInput is the one choke point where the grid walk +-- reads the pad -- the same seam the engine's own Cycling Road pull and +-- collision warps live behind -- so replacing the walk means wrapping it +-- and nothing else. Every gate ABOVE the call (scripted moves, trainer +-- engagement, transitions, anything on the stack) still applies to the +-- free walk, because the wrap sits below them all. +function FreeMove.install() + local OverworldState = require("src.world.OverworldController") + if OverworldState.dramaticShapeFreeMoveHook then return end + local inner = OverworldState.handleInput + + function OverworldState:handleInput() + if not FirstPerson.driving() then + if pos then + -- stepping off the rung: back onto the grid, on the cell the + -- free walk stood in + local p = self.player + p.px, p.py = p.cellX * 16, p.cellY * 16 + FreeMove.drop() + end + return inner(self) + end + return FreeMove.tick(self) + end + + OverworldState.dramaticShapeFreeMoveHook = true +end + +return FreeMove diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 54ef847..1749402 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -421,7 +421,8 @@ end -- ---------------------------------------------------------------- camera -- -- An explicit camera, replacing the orbit below for as long as it is set: --- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil }. +-- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil, +-- up = {x,y,z} or nil }. -- -- The orbit is the free-roam camera and it is described entirely by ONE -- number, the pitch, because that is all a camera following the player over @@ -496,9 +497,12 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) -- the same clip-space Y flip the orbit needs, for the same reason: we -- bypass LOVE's transform_projection and canvas coordinates run Y down proj = Mat4.mul(Mat4.scale(1, -1, 1), proj) - -- world up, so the horizon stays level -- a placed camera that rolled - -- with its own pitch would tip the whole arena - return Mat4.mul(proj, Mat4.lookAt(eye, focus, { 0, 1, 0 })) + -- world up by default, so the horizon stays level -- a placed camera + -- that rolled with its own pitch would tip the whole arena. A caller + -- may hand its own up: the first-person BLEND does, because its far + -- end is the orbit, whose up leans with the pitch -- world up at the + -- orbit's steep end degenerates against a straight-down view. + return Mat4.mul(proj, Mat4.lookAt(eye, focus, cam.up or { 0, 1, 0 })) end local a = Voxel.angle diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index 529f367..48c719d 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -24,6 +24,7 @@ local Sky = V.require("Sky") local Water = V.require("Water") local VoxelGrid = V.require("VoxelGrid") local DayNight = V.require("DayNight") +local FirstPerson = V.require("FirstPerson") local PaletteFX = require("src.render.PaletteFX") local Map = require("src.world.Map") @@ -214,6 +215,22 @@ local function frameFor(def, facing, phase, flip) return frame, mirror end +-- The facing a pose SHOWS this camera. The flat frames are "how this pose +-- looks from the south", which is where the orbit always stands; a +-- first-person eye stands anywhere, so deep enough into the blend the +-- facing is remapped to how the pose looks from THERE -- walk behind an +-- NPC and their card wears the back sprite. Used by the camera draw and +-- the sun pass BOTH: the card the sun stored and the transform a lit card +-- 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. +local function viewFacing(p) + if FirstPerson.cardBlend() > 0.5 then + return FirstPerson.apparentFacing(p.facing, p.px + 8, p.py + 8) + end + return p.facing +end + -- FALLBACK ONLY (see castShadows below). Draw one entity's drop shadow as -- a decal: its current sprite frame as a single quad, flattened onto the -- ground along the sun line (Voxel3D.shadowMatrix). Runs inside @@ -236,10 +253,22 @@ end -- Shared by the solid draw and the silhouette below, so the two can never -- drift apart -- a silhouette standing anywhere but exactly behind the -- figure would read as a second character. +-- +-- IN FIRST PERSON the card stops leaning and starts TURNING: upright, yawed +-- about its feet to face the eye (cylindrical billboarding). A south-facing +-- card is invisible edge-on to an eye standing east of it, which no orbit +-- camera could ever do and a first-person one does constantly. The blend +-- carries one pose into the other -- the lean eases out as the yaw eases in +-- -- and cardBlend is zero for every camera that is not the first-person +-- rig, the battle's placed shot included, so nothing else moves. local function billboardMatrix(px, py, y, mirror) local Voxel = V.require("VoxelState") - local m = Mat4.mul(Mat4.translate(px + 8, y, py + 8), - Mat4.rotateX(Voxel.angle - math.pi / 2)) + local b = FirstPerson.cardBlend() + local m = Mat4.translate(px + 8, y, py + 8) + if b > 0 then + m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(px + 8, py + 8) * b)) + end + m = Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b))) if mirror then m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) end return Mat4.mul(m, Mat4.translate(-8, 0, 0)) end @@ -258,10 +287,24 @@ end -- the Pokemon Center couch reads face-on at every tilt like the NPCs -- around him. No cell centring: unlike a character he is not standing on a -- cell, he is standing where he was drawn, which may straddle two. +-- +-- First person turns him at the eye like the walkers (see billboardMatrix) +-- -- about his own middle, because unlike a character card his local space +-- starts at x = 0 rather than being anchored by a -8 shift, and a yaw about +-- his edge would swing him off his seat. The width rode in on the record +-- for exactly this (ChunkMesher.buildFigureMeshes). local function figureMatrix(f, offX, offZ) local Voxel = V.require("VoxelState") - return Mat4.mul(Mat4.translate(f.wx + (offX or 0), f.y, f.wz + (offZ or 0)), - Mat4.rotateX(Voxel.angle - math.pi / 2)) + local b = FirstPerson.cardBlend() + local wx, wz = f.wx + (offX or 0), f.wz + (offZ or 0) + local m = Mat4.translate(wx, f.y, wz) + if b > 0 and f.w and f.w > 0 then + local half = f.w / 2 + m = Mat4.mul(m, Mat4.translate(half, 0, 0)) + m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(wx + half, wz) * b)) + m = Mat4.mul(m, Mat4.translate(-half, 0, 0)) + end + return Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b))) end -- What the sun sees: the same card UNLEANED and flattened, exactly as @@ -333,7 +376,7 @@ VoxelScene.drawEntity = drawEntity -- mesh for it. local function drawGhost(p) local def = p.sprite.def - local frame, mirror = frameFor(def, p.facing, p.phase, p.flip) + local frame, mirror = frameFor(def, viewFacing(p), p.phase, p.flip) local mesh = SpriteBillboards.shadowQuad(def, frame) if not mesh then return end local tex = p.sprite:resolveImage() @@ -462,7 +505,13 @@ local function posesOf(state, spriteColors) gh = groundAt(state.map, e.cellX, e.cellY), lift = e.py - vy, colors = colors, } - if e == state.player then me = posed[#posed] end + if e == state.player then + me = posed[#posed] + -- marked so the camera draw can leave the card out in first + -- person, where it would fill the lens from inside; the SUN pass + -- reads the same list and deliberately does not check the mark + me.isPlayer = true + end end end return posed, me @@ -529,9 +578,18 @@ local function drawCast(state, posed, atlasFor) -- drawEntity resolves the lean-over-the-wall-in-front case, and a -- character genuinely behind a building is far deeper and loses the -- test, so buildings and trees really occlude. + -- + -- In first person two of them change: the player's own card is left out + -- (the eye is standing in it), and every other card wears the frame its + -- pose SHOWS this eye (viewFacing) rather than the one it shows the + -- south. Both run through here, so the water's reflection copy -- drawn + -- by this same function -- agrees with the frame to the pixel. + local hideMe = FirstPerson.hidePlayer() for _, p in ipairs(posed) do - drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh, - p.colors, p.lift) + if not (p.isPlayer and hideMe) then + drawEntity(p.sprite, p.px, p.py, viewFacing(p), p.phase, p.flip, p.gh, + p.colors, p.lift) + end end -- back on for everything textured from the atlas again -- figures, grass -- and flowers all sample it, where the mask's coordinates are honest @@ -687,6 +745,10 @@ local function shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh) -- few times a minute rather than every frame. put(math.floor(ShadowMap.KX * 128)) put(math.floor(ShadowMap.KZ * 128)) + -- and the first-person head: the box is fitted around wherever it looks + -- and the sprite cards swap frames as it circles them, so a turn on the + -- spot re-fits and redraws exactly like a camera move ("" outside 1ST) + put(FirstPerson.signature()) put(tostring(terrain)) for i = 1, #nbMesh do put(tostring(nbMesh[i])) end for _, p in ipairs(posed) do @@ -759,7 +821,12 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, end for _, p in ipairs(posed) do local def = p.sprite.def - local frame, mirror = frameFor(def, p.facing, p.phase, p.flip) + -- viewFacing, exactly as the camera draw picks it (see viewFacing for + -- why the two passes must agree): in first person the sun's card + -- swaps frame as the eye circles, which costs a redraw the signature + -- already charges for (FirstPerson.signature) and keeps a card from + -- fringing against a mirror-flipped record of itself + local frame, mirror = frameFor(def, viewFacing(p), p.phase, p.flip) local mesh = SpriteBillboards.shadowQuad(def, frame) if mesh then ShadowMap.draw(mesh, p.sprite:resolveImage(), @@ -815,7 +882,23 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) end local posed, me = posesOf(state, spriteColors) - castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor, + + -- The first-person rig, built (or blended) for this frame and handed to + -- Voxel3D BEFORE either pass runs: the sun's box is fitted around this + -- camera, and every card matrix asks it which way to turn. With the + -- blend fully out the call clears the placed camera and the orbit is + -- exactly what it always was. The scene centre it returns walks from + -- the orbit's view centre into the head, so the curve's focus and the + -- depth reference follow the camera actually in charge. + local fpRig, fpCx, fpCy = FirstPerson.frame(me, cx, cy, vw, vh) + if fpRig then cx, cy = fpCx, fpCy end + + -- The sun's box, pushed along the first-person look so it covers the + -- ground THIS camera sees (a no-op at blend zero): the orbit's fit + -- reaches far north and barely south, which is right for every rung + -- but a head free to face south. + local shCx, shCy = FirstPerson.shadowCenter(cx, cy, vh) + castShadows(state, terrain, nbMesh, posed, shCx, shCy, vw, vh, atlasFor, water, nbWater) if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then @@ -838,7 +921,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) if not Voxel3D.shadowsActive() then Voxel3D.beginShadows() for _, p in ipairs(posed) do - drawShadow(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh, + drawShadow(p.sprite, p.px, p.py, viewFacing(p), p.phase, p.flip, p.gh, p.lift) end Voxel3D.endShadows() @@ -884,7 +967,11 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) -- wrote it, so the silhouette would paint over the player at all times. -- Every character then draws on top as usual, which leaves the silhouette -- showing in exactly one situation: where the world hides them. - if me then + -- + -- Not in first person: the card it silhouettes is the one the camera is + -- standing inside, and "the world is in front of the player" is every + -- wall the player faces. + if me and not FirstPerson.hidePlayer() then Voxel3D.beginGhost() drawGhost(me) Voxel3D.endGhost() diff --git a/lib/VoxelState.lua b/lib/VoxelState.lua index e19b314..3e8bb45 100644 --- a/lib/VoxelState.lua +++ b/lib/VoxelState.lua @@ -42,7 +42,8 @@ local Voxel = {} -- 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 } -Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75", "1ST" } +Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75", + "1ST (EXPERIMENTAL)" } Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1 -- the rung FULL sits on, so nothing has to hunt for it by label diff --git a/main.lua b/main.lua index f0b4883..6f50f7c 100644 --- a/main.lua +++ b/main.lua @@ -23,9 +23,16 @@ -- the engine's TILT mode -- is engine plumbing driven by the records -- below. This file declares; lib/ draws. -- --- Nothing here reaches collision, movement, triggers or scripts. Voxel --- mode is purely presentational: it changes what the world LOOKS like and --- nothing about what it IS. +-- 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. +-- 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 +-- cell crossed -- warps, encounters, ledges, gates and scripts all run +-- exactly as themselves. Step off the rung and the grid walk is back. local mod = ... @@ -83,6 +90,8 @@ local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") local Water = V.require("Water") local AntiAlias = V.require("AntiAlias") +local FirstPerson = V.require("FirstPerson") +local FreeMove = V.require("FreeMove") -- Forward declaration: the voxel pipeline's update hook (registered below) -- calls this, and it is defined further down with the settings it drives. @@ -162,6 +171,11 @@ mod.content.render_pipelines:register("voxel", { -- would fight anyone who changed one deliberately. applyFull(level) Voxel.update(dt, level) + -- the first-person head, on the same tick: its blend in and out of the + -- orbit, the mouse capture lifecycle, and the frame's stick-rate look. + -- Unconditional like Voxel.update, because the blend has to keep easing + -- OUT after the rung is left + FirstPerson.update(dt) -- the day/night clock, on the same always-running tick: Pipelines.update -- runs whatever the level, so time passes with the mode off, through -- battles and menus, and a CYCLE evening falls mid-fight exactly as it @@ -789,6 +803,32 @@ end -- so this file keeps naming every engine seam the mod touches. OverworldBattle.install() +-- ------- the first-person rung's inputs and its walk +-- +-- 1ST needs two things no other rung does, and each is a named seam: +-- +-- FirstPerson.install claims the LOOK inputs the engine ignores: the right +-- stick's axes (Game:gamepadaxis passes them to Input, which returns early +-- on anything but the left pair), relative mouse motion (love.mousemoved -- +-- there is no Game handler to wrap; the engine's own callback only feeds +-- the mouse-as-touch debug path, which stays untouched), the mouse buttons +-- while the cursor is captured (A and B -- there is no cursor to click UI +-- with), and any touch that lands off the overlay's controls (a drag on +-- 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. +-- +-- 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 +-- 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 +-- file argues the whole arrangement. +FirstPerson.install() +FreeMove.install() + -- The overworld's own pushBattle is the choke point for a wild encounter or -- a trainer, and it is wrapped. A battle that arrives some other way -- a -- link battle, a script pushing a BattleState directly -- reaches this @@ -890,7 +930,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.4.3" +mod.exports.version = "1.5.0" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index 1f6184c..8ea928d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.4.3", + "version": "1.5.0", "api": 2, "entry": "main.lua", "profile": "content", diff --git a/mod.card b/mod.card index af77b03..08ec65a 100644 --- a/mod.card +++ b/mod.card @@ -16,9 +16,11 @@ return { "a battle's letterbox voids go black rather than white, because the battle canvas is no longer white", "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", + "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", }, added = { - "VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees)", + "VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees / 1ST, a first-person camera 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", @@ -39,6 +41,9 @@ 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", + "rooms have no ceilings, so a first-person look over an interior wall shows the void the diorama always had behind it", }, }, credits = { diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 703763a..032b297 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -49,12 +49,14 @@ T.eq(defs._owners and defs._owners.voxel, "DRAMATIC_SHAPE", -- ------- the ladders the engine drives -T.eq(#defs.voxel.levels, 6, "voxel exposes a six-rung ladder") +T.eq(#defs.voxel.levels, 7, "voxel exposes a seven-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", "the top rung is the 75-degree camera") -T.eq(Pipelines.maxLevel("voxel"), 5, "the engine reads the ladder height") +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") T.eq(Pipelines.levelLabel("voxel", 3), "35", "the engine reads the rung labels") -- ------- gating: inert until switched on, and inert without a GPU @@ -1079,12 +1081,12 @@ 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, 6 do +for _ = 1, 7 do Game.keypressed(keyGame, "3") walk[#walk + 1] = Pipelines.levelLabel("voxel") end -T.eq(table.concat(walk, ","), "15,35,50,75,OFF,15", - "3 walks OFF -> 15 -> 35 -> 50 -> 75 and wraps, never touching FULL") +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") -- 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. @@ -1143,8 +1145,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. --- 5 is the "75" rung, the last one the key walks before it wraps to OFF -Pipelines.setLevel("voxel", 5) +-- 6 is the "1ST" rung, the last one the key walks before it wraps to OFF +Pipelines.setLevel("voxel", 6) Tilt.setLevel(3) GBCFX.setLevel(4) keyGame.save.options.tilt = 3 @@ -3336,7 +3338,10 @@ Commands.start_battle(ctx, "trainer", "RIVAL1", 1) T.check(viaOverworld ~= nil and pushed == nil, "a scripted trainer goes through pushBattle: the flash, the wipe and the " .. "theme, like any fight walked into") -T.eq(viaOverworld.kind, "trainer", "with the battle it was asked to start") +-- guarded index: when the seam above regresses, this must FAIL like any +-- assertion rather than crash the suite half-run +T.eq(viaOverworld and viaOverworld.kind, "trainer", + "with the battle it was asked to start") ctx.overworld = nil Commands.start_battle(ctx, "wild", "PIDGEY", 5) T.check(pushed ~= nil, @@ -3462,6 +3467,182 @@ T.eq(g.amp, 0, "standing still fades it back out within a beat") T.eq(g.phase, held, "and the phase does not move while the camera does not") end +-- ------- the first-person rung +-- +-- 1ST rides the same placed-camera seam the battle proved out, so most of +-- what it adds is arithmetic this suite can hold still: the rig built from +-- a pose, the compass the grid game still thinks in, the camera-relative +-- walk vector, and the frame an NPC shows an eye that can stand anywhere. + +do +local FirstPerson = + run.loader.exports.DRAMATIC_SHAPE.lib.require("FirstPerson") +local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") +local FreeMove = run.loader.exports.DRAMATIC_SHAPE.lib.require("FreeMove") +local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") + +T.eq(VoxelState.FP_LEVEL, 6, "1ST is the seventh rung") +T.check(VoxelState.isFirstPerson(6), "and isFirstPerson answers for it") +T.check(not VoxelState.isFirstPerson(5), "but not for the 75 orbit") +T.eq(VoxelState.ANGLE_LABELS[VoxelState.FP_LEVEL + 1], "1ST (EXPERIMENTAL)", + "the rung wears the experimental label") +T.eq(VoxelState.ANGLES_DEG[VoxelState.FP_LEVEL + 1], 75, + "and hands the blend the 75-degree orbit as its far end") + +-- ------- the compass and the walk vector +-- +-- Yaw 0 faces south (+Z), the way a resting sprite faces; the compass is +-- the dominant axis and the walk rotates camera space into world space. +FirstPerson.yaw, FirstPerson.pitch = 0, 0 +T.eq(FirstPerson.compassFacing(), "down", "yaw 0 looks south") +FirstPerson.yaw = math.pi / 2 +T.eq(FirstPerson.compassFacing(), "right", "a quarter turn looks east") +FirstPerson.yaw = math.pi +T.eq(FirstPerson.compassFacing(), "up", "a half turn looks north") +FirstPerson.yaw = -math.pi / 2 +T.eq(FirstPerson.compassFacing(), "left", "and three quarters looks west") + +local function near(a, b) return math.abs(a - b) < 1e-9 end + +FirstPerson.yaw = 0 +local wx, wz = FirstPerson.moveWorld(0, 1) +T.check(near(wx, 0) and near(wz, 1), "facing south, forward walks south") +wx, wz = FirstPerson.moveWorld(1, 0) +T.check(near(wx, -1) and near(wz, 0), + "facing south, the right hand points west") +FirstPerson.yaw = math.pi +wx, wz = FirstPerson.moveWorld(0, 1) +T.check(near(wx, 0) and near(wz, -1), "facing north, forward walks north") +wx, wz = FirstPerson.moveWorld(1, 0) +T.check(near(wx, 1) and near(wz, 0), + "facing north, the right hand points east") + +-- the look clamps: pitch stops at the limits, yaw wraps +FirstPerson.pitch = 0 +FirstPerson.lookBy(0, 100) +T.eq(FirstPerson.pitch, FirstPerson.PITCH_DOWN, "pitch clamps looking down") +FirstPerson.lookBy(0, -100) +T.eq(FirstPerson.pitch, FirstPerson.PITCH_UP, "and looking up") +FirstPerson.yaw = 0 +FirstPerson.lookBy(2 * math.pi, 0) +T.check(math.abs(FirstPerson.yaw) < 1e-9, "a full turn of yaw wraps to zero") + +-- ------- the rig +-- +-- frame() is pure arithmetic over the pose and the orbit, so the suite can +-- stand the blend anywhere and read the record it hands Voxel3D. +FirstPerson.yaw, FirstPerson.pitch = 0, 0 +FirstPerson.blend = 1 +local me = { px = 100, py = 200, gh = 0, lift = 0 } +local rig, sx, sy = FirstPerson.frame(me, 500, 600, 320, 288) +T.check(rig ~= nil, "with the blend in, frame() builds a rig") +T.eq(Voxel3D.camera, rig, "and hands it to Voxel3D") +T.check(near(rig.eye[1], 108) and near(rig.eye[3], 208), + "the eye stands on the player's centre") +T.eq(rig.eye[2], FirstPerson.EYE_HEIGHT, "at head height") +T.check(near(rig.fov, FirstPerson.FOV), "wearing the first-person lens") +T.check(near(sx, 108) and near(sy, 208), + "and the scene centre stands with it") +T.check(rig.curve == 0, + "the world curve is declined outright -- a zero, not a nil the setting " + .. "could override") +T.check(rig.focus[3] > rig.eye[3], + "yaw 0 focuses south of the eye") + +-- surf bob and ledge lift carry the eye with them +local bobbed = FirstPerson.frame({ px = 100, py = 200, gh = 4, lift = 6 }, + 500, 600, 320, 288) +T.eq(bobbed.eye[2], 10 + FirstPerson.EYE_HEIGHT, + "ground height and lift both raise the eye") + +-- mid-blend, the rig is a lerp of the orbit and the head: its eye sits +-- between the two ends, and the curve is only half declined +VoxelState.angle = math.rad(75) +FirstPerson.blend = 0.5 +local mid = FirstPerson.frame(me, 500, 600, 320, 288) +T.check(mid.eye[2] > FirstPerson.EYE_HEIGHT, + "half way out, the eye is higher than the head") +T.check(mid.eye[2] < 288 * VoxelState.FOCAL, + "and lower than the orbit") + +-- ------- the cards ask the rig, and only the rig +FirstPerson.blend = 1 +FirstPerson.frame(me, 500, 600, 320, 288) +T.check(FirstPerson.cardBlend() == 1, "the free-roam rig turns the cards") +T.check(FirstPerson.hidePlayer(), "and hides the player's own card") + +-- an NPC south of the eye: the card yaws to face north, back at the eye +local yaw = FirstPerson.cardYaw(108, 300) +T.check(near(math.sin(yaw), 0) and near(math.cos(yaw), -1), + "a card south of the eye turns its face north") + +-- the frame an entity SHOWS this eye: stand north of someone facing away +-- and you see their back; face to face you see their front; flanks show +-- profiles, named by which flank is toward you +T.eq(FirstPerson.apparentFacing("down", 108, 300), "up", + "an NPC facing south, seen from the north, shows their back") +T.eq(FirstPerson.apparentFacing("up", 108, 300), "down", + "an NPC facing north, seen from the north, shows their face") +T.eq(FirstPerson.apparentFacing("right", 108, 300), "left", + "an NPC facing east, seen from the north, shows their left flank") +T.eq(FirstPerson.apparentFacing("left", 108, 300), "right", + "an NPC facing west, seen from the north, shows their right flank") + +-- another camera on the same seam -- the battle's placed shot -- and the +-- cards stand down: blend still 1, but it is not our rig drawing +local battleCam = { eye = { 0, 40, 120 }, focus = { 0, 8, 0 }, + fov = math.rad(30) } +Voxel3D.camera = battleCam +T.eq(FirstPerson.cardBlend(), 0, + "a battle camera on the seam turns no cards") +T.check(not FirstPerson.hidePlayer(), + "and hides nobody") + +-- blend fully out: frame() clears only a camera that is still ours +FirstPerson.blend = 0 +T.eq(FirstPerson.frame(me, 500, 600, 320, 288), nil, + "with the blend out, frame() answers nil and the orbit rules") +T.eq(Voxel3D.camera, battleCam, + "without touching a camera some other pass placed") +Voxel3D.camera = nil + +-- ------- the free walk's per-cell verdict +-- +-- The same questions Collision asks a grid step, asked per cell the body +-- overlaps -- through a map stub shaped like the engine's own. +local blocked = FreeMove._blockedCell +local stubMap = { + def = { tileset = "OVERWORLD" }, + inBounds = function(self, x, y) + return x >= 0 and y >= 0 and x < 10 and y < 10 + end, + isWalkableCell = function(self, x, y) return x ~= 3 end, + isWaterCell = function(self, x, y) return x == 3 and y == 3 end, + cellTile = function() return 0 end, +} +local p = { cellX = 5, cellY = 5, surfing = false } +local state = { map = stubMap, entities = {} } + +T.eq(blocked(state, p, 5, 5), nil, "the body's own cell never refuses it") +T.eq(blocked(state, p, 6, 5), nil, "an open neighbour admits it") +T.eq(blocked(state, p, 3, 5), "tile", "an unwalkable cell refuses it") +T.eq(blocked(state, p, -1, 5), "bounds", "off the map is the edge's answer") +T.eq(blocked(state, p, 3, 3), "tile", "water refuses a walker") +p.surfing = true +T.eq(blocked(state, p, 3, 3), nil, "and admits a surfer") +p.surfing = false + +state.entities = { { cellX = 6, cellY = 5 } } +T.eq(blocked(state, p, 6, 5), "entity", "an occupied cell refuses the body") +state.entities = { { cellX = 7, cellY = 5, targetX = 6, targetY = 5 } } +T.eq(blocked(state, p, 6, 5), "entity", + "and so does one an NPC is mid-step into") + +-- the ladder's own state, put back the way the suite found it +FirstPerson.blend = 0 +VoxelState.reset() +end + Pipelines.reset() run.release() diff --git a/tests/fp_shots.lua b/tests/fp_shots.lua new file mode 100644 index 0000000..e972bfe --- /dev/null +++ b/tests/fp_shots.lua @@ -0,0 +1,159 @@ +-- Scratch driver: shots of the 1ST (first-person) rung -- the rig standing +-- in the player's head, billboards yawing to face it, the sky meeting the +-- horizon, the shadow box following the look, water seen from eye level, +-- and an interior with its figures. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/fp_shots.lua \ +-- SHOT_DIR=.scratchpad/fpshots 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/fp") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[fp] DRAMATIC_SHAPE mod not loaded") + return love.event.quit() + end + local V = handle.lib + local FirstPerson = V.require("FirstPerson") + 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 + + -- Teleport to the nearest WALKABLE cell: a guessed coordinate inside a + -- building footprint buries the eye in the geometry, which is what the + -- first cut of every Pallet shot did. + 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(("[fp] (%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: houses, the lab, NPCs -- the town seen + -- from inside it, in each compass direction plus a diagonal + { 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: water at eye level, which is where the battle pass + -- says a low placed camera reads the reflection wrong -- the shot + -- decides whether 1ST keeps it + { map = "PALLET_TOWN", x = 9, y = 12, yaw = 0, label = "pallet_water" }, + -- looking up: the sky's bands and the horizon line + { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, + pitch = -math.rad(25), label = "pallet_skyward" }, + -- and down: the ground, the feet-level shadow + { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, + pitch = math.rad(45), label = "pallet_down" }, + -- Route 1: grass rows and ledges from inside them + { map = "ROUTE_1", x = 10, y = 28, yaw = math.pi, label = "route1_north" }, + -- an interior: the Center's counter, machines and couch figures + { map = "VIRIDIAN_POKECENTER", x = 3, y = 5, yaw = math.pi, + label = "center_north" }, + { map = "VIRIDIAN_POKECENTER", x = 6, y = 4, yaw = -math.pi / 2, + label = "center_west" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + place(s.map, s.x, s.y) + Pipelines.setLevel("voxel", Voxel.FP_LEVEL) + Pipelines.setLevel("tiltshift", 0) + settle() + FirstPerson.yaw = s.yaw + FirstPerson.pitch = s.pitch or FirstPerson.PITCH_DEFAULT + U.wait(20) + if U.shot(game, ("%s/%s.png"):format(ROOT, s.label)) then + shots = shots + 1 + end + end + + -- one mid-blend shot: step the ladder onto 1ST from 75 and catch the + -- dive halfway + place("PALLET_TOWN", 13, 14) + Pipelines.setLevel("voxel", 5) + settle() + Pipelines.setLevel("voxel", Voxel.FP_LEVEL) + for _ = 1, 300 do + if FirstPerson.blend >= 0.5 then break end + U.wait(1) + end + if U.shot(game, ROOT .. "/blend_mid.png") then shots = shots + 1 end + + -- ------- the free walk, exercised + -- + -- Hold forward with the head yawed off-grid and confirm the player + -- GLIDES: the position moves along the look direction, lands off the + -- 16px grid (which no grid step can do), and the logical cell follows. + place("PALLET_TOWN", 13, 14) + Pipelines.setLevel("voxel", Voxel.FP_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, c0x, c0y = p.px, p.py, p.cellX, p.cellY + U.hold(game, "up", 90) + U.wait(5) + local moved = math.abs(p.px - x0) + math.abs(p.py - y0) + print(("[fp] walk: (%.1f,%.1f) cell(%d,%d) -> (%.1f,%.1f) cell(%d,%d)") + :format(x0, y0, c0x, c0y, p.px, p.py, p.cellX, p.cellY)) + print(("[fp] walk moved %.1f px; off-grid: %s; diagonal: %s") + :format(moved, + tostring(p.px % 16 ~= 0 or p.py % 16 ~= 0), + tostring(math.abs(p.px - x0) > 8 + and math.abs(p.py - y0) > 8))) + if U.shot(game, ROOT .. "/walked.png") then shots = shots + 1 end + + print(("[fp] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end