Files
DramaticShapeVoxelMod/lib/FreeMove.lua
T
2026-08-02 01:16:42 -04:00

346 lines
12 KiB
Lua

-- 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