DramaticShapeVoxelMod archival mirror · read-only
lib/FreeMove.lua

FreeMove.lua

lib/FreeMove.lua15.0 KB100644bf5f9c436c
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387
-- 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 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
-- 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
  -- 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
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, and the route-gate warp
-- fired by collision. 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.
--
-- The one verb NOT restated here is the bonk. On the grid a blocked step
-- is a discrete event -- you pressed a direction, the game refused, and
-- the bump answers you once. A free walk has no such moment: the body
-- SLIDES along whatever it grazes, so a player walking a fence line or
-- rounding a doorframe is blocked on one axis continuously, and the same
-- sound comes out as a rattle for as long as they keep walking. It is
-- feedback for a refusal that is not happening. The grid walk keeps its
-- own bump (the engine's, in OverworldController) untouched.
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
  -- and NO bonk. The grid walk's collision sound marks a discrete event:
  -- you pressed a direction, the step was refused, nothing happened. A
  -- free walk has no such moment -- the body slides along every wall it
  -- grazes, continuously, and a corridor taken at a slight angle is a
  -- steady graze from end to end. Rate-limited or not, that came out as a
  -- machine-gun of bonks for walking normally down a hallway. The wall
  -- stopping you is the feedback; the sound only ever said so twice a
  -- second whether or not anything had changed.
  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. (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)

  -- HORDE MODE takes both of these away for as long as it runs: there is
  -- no pausing (START), and nobody stops to read a sign with the horde
  -- coming (A, which is also the button the mode's own GAME OVER card
  -- wants left unspent). Everything below -- the walk, the wall slide and
  -- the blocked-push verbs, warps included -- keeps working, because the
  -- crowd has to be able to follow the player through a door.
  local suppressed = V.require("Horde").suppressWorldInput()

  if not suppressed and input:wasPressed("a") then
    state:interact()
    return
  end
  if not suppressed and 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

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

  -- the engine's own bonk clock, kept draining while the free walk has the
  -- wheel: nothing here rings it (see pushSpecials), but stepping back onto
  -- the grid must not inherit a cooldown frozen at whatever it held when
  -- the rung was picked
  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 walk still rules
    p.facing = FirstPerson.pointBody(wx, wz)
  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