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

ThirdPerson.lua

lib/ThirdPerson.lua17.2 KB1006442b0c69d2fd
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 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407
-- Voxel world mode: the third-person camera -- the 3RD rung.
--
-- 3RD is 1ST with the eye pulled off the back of the head. Everything that
-- makes the first-person rung work -- the steered attitude, the placed
-- camera on Voxel3D's seam, the cards that turn to face the eye, the
-- continuous camera-relative walk -- is already general over WHERE the eye
-- stands, so this module adds exactly one thing to it: a BOOM.
--
-- What the boom owns:
--
--   the LENGTH    how far behind the pivot the eye sits, eased in and out
--                 so stepping between 1ST and 3RD slides rather than cuts,
--                 and clamped every frame by what the world will allow.
--
--   the COLLISION a march back along the boom line through the terrain
--                 height field and the map's own walkability, so backing
--                 into a wall walks the camera in toward the player's
--                 shoulders instead of through the wall into the void.
--                 The recovery is deliberately slower than the intrusion:
--                 a camera must never be a frame late leaving geometry,
--                 and must never snap back out the instant a corner clears.
--
--   the SHOULDER  the small lateral rail offset that keeps the character
--                 off dead centre, faded out with the boom so a camera
--                 jammed against a wall does not also slide sideways into
--                 it.
--
-- Deliberately NOT here: the attitude, the look inputs, the blend, the
-- move intent (all lib/FirstPerson.lua, which drives this module and reads
-- its answer while building the frame's rig), and movement itself
-- (lib/FreeMove.lua, unchanged -- the walk is camera-relative either way,
-- and the camera's yaw is the same number on both rungs).
--
-- Nothing here is required for the rung to draw: with no overworld to ask
-- (a headless run, the test suite) every query answers "clear" and the boom
-- extends to its full length over an empty world.

-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...

local Voxel = V.require("VoxelState")

local ThirdPerson = {}

-- ------- the boom's numbers
--
-- BOOM is world pixels behind the pivot at full extension. A cell is 16 and
-- a character card is 16 tall, so 48 stands the camera three cells back:
-- with the first-person lens (65 degrees vertical) that frames the player
-- at roughly a quarter of the frame height -- the modern action-game
-- middle ground, close enough to read the four-frame sprite and far enough
-- to see the cell you are about to walk into.
--
-- PIVOT_LIFT raises the orbit point above the first-person eye, so the
-- boom looks slightly DOWN across the player's shoulder rather than
-- straight through the back of their head.
--
-- SHOULDER is the lateral rail offset, in world pixels, positive to the
-- camera's right -- which puts the player left of centre, leaving the
-- larger half of the frame in front of them.
ThirdPerson.BOOM = 48
ThirdPerson.PIVOT_LIFT = 4
ThirdPerson.SHOULDER = 4

-- how long the eye takes to slide out to the boom (and back into the head
-- when 1ST is picked), in seconds -- the same order as FirstPerson's own
-- dive so stepping 75 -> 1ST -> 3RD reads as one continuous camera
ThirdPerson.BOOM_TIME = 0.35

-- ------- the player's own zoom
--
-- A multiplier on BOOM, stepped by the wheel, Q/E or a pinch (see
-- CamControl, which owns every one of those and decides which camera a
-- given input is aimed at). The range is deliberately wider IN than OUT:
-- close is the shot people reach for, and far enough out the character is
-- a few pixels and the rung may as well be an orbit rung.
--
-- Stepped in fractions rather than world pixels so a notch feels the same
-- at both ends -- the near end of a linear step would crawl and the far
-- end would leap.
ThirdPerson.ZOOM_MIN = 0.45           -- ~22px: over the shoulder, close
ThirdPerson.ZOOM_MAX = 2.4            -- ~115px: the character in a landscape
ThirdPerson.ZOOM_STEP = 1.18          -- one wheel notch / key press
ThirdPerson.ZOOM_TIME = 0.18          -- how fast the eye eases to a new one

ThirdPerson.zoom = 1                  -- eased, what place() actually uses
ThirdPerson.zoomGoal = 1              -- what the input asked for

-- Step the zoom by `notches` (positive pulls the camera OUT). Returns true
-- when the goal actually moved, so a caller can tell "zoomed" from "already
-- at the stop" and let the input fall through.
function ThirdPerson.stepZoom(notches)
  local was = ThirdPerson.zoomGoal
  local goal = was * (ThirdPerson.ZOOM_STEP ^ (notches or 0))
  ThirdPerson.zoomGoal = math.max(ThirdPerson.ZOOM_MIN,
                          math.min(ThirdPerson.ZOOM_MAX, goal))
  return ThirdPerson.zoomGoal ~= was
end

-- Scale the zoom by a continuous factor -- what a pinch hands over, where
-- the gesture's own scale IS the answer and there are no notches.
function ThirdPerson.scaleZoom(factor)
  if not (factor and factor > 0) then return false end
  return ThirdPerson.stepZoom(math.log(factor) / math.log(ThirdPerson.ZOOM_STEP))
end

-- ------- the collision's numbers
--
-- STEP is how far apart the samples along the boom line are, in world
-- pixels, and REFINE how many bisections narrow the first blocked one --
-- four halvings of a 4px step lands the eye within a quarter pixel of the
-- face, which is finer than the boom ever needs to be.
--
-- PAD is the clearance kept between the eye and whatever stopped it. It
-- has to beat the placed camera's near plane (|eye - focus| * 0.05, which
-- at full extension is about 3.6 world pixels -- see Voxel3D) or the near
-- plane clips a hole in the very wall the boom stopped at.
--
-- CLEAR is how high above a cell's ground the eye must be to pass OVER
-- something unwalkable rather than being stopped by it: a fence, a kerb or
-- a plant pot should not shove the camera in, a building should. Roughly
-- head height, so the eye clears the props and never the walls.
ThirdPerson.STEP = 4
ThirdPerson.REFINE = 4
ThirdPerson.PAD = 5
ThirdPerson.CLEAR = 20

-- How fast the boom is allowed to grow BACK once whatever shortened it is
-- out of the way, in world pixels per second. Shortening is instant (a
-- camera inside a wall is a hole in the frame); lengthening is rationed,
-- so rounding a corner eases the eye back out instead of snapping it.
ThirdPerson.RETURN = 150

-- ------- state
--
-- `out` is the eased extension, 0 in the head and 1 fully boomed -- the
-- number that carries 1ST into 3RD. `len` is the boom's actual length in
-- world pixels after the world has had its say, which is what place()
-- stands the eye at and update() eases back toward `want`.
ThirdPerson.out = 0
ThirdPerson.len = 0
ThirdPerson.want = 0

local function ease(t)
  return t * t * (3 - 2 * t)
end

-- ------- gates

-- Whether the 3RD rung is the one selected. Not "is the boom out" -- that
-- is extended() below, which stays true through the ease after the rung is
-- left, the same way FirstPerson.blend outlives its own rung.
--
-- A live headset declines the boom outright: VR builds its own eye cameras
-- from the tracked pose and never asks place() where to stand, and a
-- headset that seats its wearer three cells behind their own body is a
-- well-known way to make people ill. Answering false here is what keeps
-- everything ELSE the extension decides -- the player's own card, the body
-- that turns as it walks -- honest about the head VR actually puts you in.
-- Required lazily and guarded: VR reaches this module through FirstPerson,
-- and a headless run has no VR module worth loading at all.
local function headset()
  local ok, on = pcall(function() return V.require("VR").active() end)
  return ok and on or false
end

function ThirdPerson.selected()
  return Voxel.isThirdPerson(Voxel.level) and not headset()
end

-- The eased extension, 0 at the head and 1 at the full boom.
function ThirdPerson.extension()
  return ease(ThirdPerson.out)
end

-- Whether the boom is out far enough to be a third-person camera at all --
-- read off the TARGET extension rather than the live length, so it is
-- steady while the world shoves the eye about. What the body reads to
-- decide whether it turns along its own travel.
function ThirdPerson.extended()
  return ThirdPerson.extension() > 0.5
end

-- How far back the eye must ACTUALLY be, in world pixels, for the player's
-- own card to be worth drawing: a shade under a cell, which is the point
-- where a 16-pixel card stops being a character and starts being a wall of
-- pixels across the lens.
ThirdPerson.SHOW_AT = 14

-- Whether the player's own card belongs in the frame. Not the same
-- question as extended(): back into a fence and the boom collapses into
-- the head whatever the rung says, and a card drawn there fills the lens
-- from inside exactly as it would in first person -- so it comes out, and
-- the rung reads as first person for as long as the world insists on it.
function ThirdPerson.showsPlayer()
  return ThirdPerson.extension() > 0 and ThirdPerson.len >= ThirdPerson.SHOW_AT
end

-- ------- the world the boom has to fit through
--
-- Everything below asks the live overworld and pcall-guards the asking:
-- with no map (headless, the suite, a frame mid-warp) the boom simply
-- extends to its full length, which is the right answer for a world with
-- nothing in it.

local function overworld()
  local ok, ow = pcall(function()
    return require("src.core.Game").overworld
  end)
  if not ok or not ow or not ow.map then return nil end
  return ow
end

-- Which map, and which of its cells, covers a world point. The player's own
-- map first, then the neighbours the scene streams in around it (same ox/oy
-- offsets VoxelScene draws them at) -- without that pass the boom would
-- shorten against "off the map" every time the player walked within three
-- cells of a route connection, which is most of the time.
--
-- nil means no map covers it: genuinely off the world, where the border
-- ring is drawn and the camera has no business going.
local function cellAt(ow, wx, wz)
  local map = ow.map
  local cx, cy = math.floor(wx / 16), math.floor(wz / 16)
  if map:inBounds(cx, cy) then return map, cx, cy end
  for _, nb in ipairs(ow.neighbors or {}) do
    if nb.map then
      local nx = math.floor((wx - (nb.ox or 0)) / 16)
      local ny = math.floor((wz - (nb.oy or 0)) / 16)
      if nb.map:inBounds(nx, ny) then return nb.map, nx, ny end
    end
  end
  return nil
end

-- Whether the eye may not stand at this world point. Two refusals, and
-- they are different questions:
--
--   the GROUND is the terrain height field the mesh is actually built from
--   (VoxelScene.groundAt -- the same answer a character stands on), so a
--   ledge, a raised bank or a cliff stops the boom exactly where it stops
--   the geometry, at any pitch.
--
--   the WALKABILITY is the map's own, and stands in for everything built
--   ON the ground that the height field does not describe: house walls,
--   trees, signs, counters. Held to CLEAR above that cell's ground so the
--   short furniture of the world is passed over rather than bumped into.
local function occupied(ow, wx, y, wz)
  local map, cx, cy = cellAt(ow, wx, wz)
  if not map then return true end
  local VoxelScene = V.require("VoxelScene")
  local okG, gh = pcall(VoxelScene.groundAt, map, cx, cy)
  gh = (okG and gh) or 0
  if y < gh + ThirdPerson.PAD then return true end
  local okW, walkable = pcall(function() return map:isWalkableCell(cx, cy) end)
  if okW and not walkable and y < gh + ThirdPerson.CLEAR then return true end
  return false
end

ThirdPerson._occupied = occupied       -- named for the suite

-- How far back along (bx, by, bz) from `pivot` the eye can stand, up to
-- `want`. March at STEP, and when a sample refuses, bisect back into the
-- gap between it and the last clear one -- so the answer is the face's own
-- position rather than the sampling grid's, and walking toward a wall
-- draws the camera in smoothly instead of in four-pixel jerks. PAD comes
-- off whatever survives.
function ThirdPerson.reach(ow, pivot, bx, by, bz, want)
  if not ow or want <= 0 then return math.max(0, want) end
  local function clear(t)
    return not occupied(ow, pivot[1] + bx * t, pivot[2] + by * t,
                        pivot[3] + bz * t)
  end
  local lo = 0
  local steps = math.ceil(want / ThirdPerson.STEP)
  local hi = nil
  for i = 1, steps do
    local t = math.min(want, i * ThirdPerson.STEP)
    if clear(t) then
      lo = t
    else
      hi = t
      break
    end
  end
  if not hi then return want end
  for _ = 1, ThirdPerson.REFINE do
    local mid = (lo + hi) / 2
    if clear(mid) then lo = mid else hi = mid end
  end
  return math.max(0, lo - ThirdPerson.PAD)
end

-- ------- the tick
--
-- Rides FirstPerson.update, which is itself on the pipeline's own update
-- hook, so this runs every frame whatever the rung -- the extension has to
-- keep easing back in after 3RD is left. `blend` is FirstPerson's dive into
-- the head: while it is fully out (the diorama), the extension SNAPS to its
-- target rather than easing, so picking 3RD from an orbit rung is one
-- motion (the dive) rather than two (a dive, then a slide backwards).
function ThirdPerson.update(dt, blend)
  -- the player's own zoom FIRST, so everything below measures itself
  -- against the boom length this frame actually wants. A step is a request
  -- rather than a jump: three notches of wheel should read as one glide.
  local zg = ThirdPerson.zoomGoal
  if ThirdPerson.zoom ~= zg then
    local k = math.min(1, dt / ThirdPerson.ZOOM_TIME)
    local z = ThirdPerson.zoom + (zg - ThirdPerson.zoom) * k
    ThirdPerson.zoom = (math.abs(zg - z) < 1e-4) and zg or z
  end

  local target = ThirdPerson.selected() and 1 or 0
  if (blend or 0) <= 0 then
    ThirdPerson.out = target
    ThirdPerson.len = ThirdPerson.reachFor() * target
    -- and the wanted length with it: place() is what normally maintains it
    -- and it does not run at all while the rig is out of the frame, so a
    -- stale want left here would have the recovery below creeping the boom
    -- back out over a camera that is not on screen
    ThirdPerson.want = ThirdPerson.len
  else
    local step = dt / ThirdPerson.BOOM_TIME
    if ThirdPerson.out < target then
      ThirdPerson.out = math.min(target, ThirdPerson.out + step)
    elseif ThirdPerson.out > target then
      ThirdPerson.out = math.max(target, ThirdPerson.out - step)
    end
  end

  -- the rationed recovery: place() already pulled `len` in to whatever the
  -- world allowed this frame, and this is the only thing that lets it back
  -- out again
  if ThirdPerson.len < ThirdPerson.want then
    ThirdPerson.len = math.min(ThirdPerson.want,
                               ThirdPerson.len + ThirdPerson.RETURN * dt)
  end
end

-- The boom's full length right now, before the world has its say: BOOM at
-- the player's own zoom. Named so the collision march and the shoulder
-- fade measure themselves against the same number.
function ThirdPerson.reachFor()
  return ThirdPerson.BOOM * ThirdPerson.zoom
end

-- ------- the eye
--
-- Where the camera stands, given the pivot the first-person rig would have
-- put the eye at and the unit look direction it would have looked along.
-- Returns the eye and the focus: both slide by the shoulder offset, so the
-- view direction is untouched and only the frame's contents shift.
--
-- With the boom fully in this is exactly the first-person answer, to the
-- pixel -- which is what makes 1ST and 3RD one rig with a number between
-- them rather than two cameras to keep in sync.
function ThirdPerson.place(pivot, lx, ly, lz, focus)
  local e = ThirdPerson.extension()
  if e <= 0 then
    ThirdPerson.want, ThirdPerson.len = 0, 0
    return pivot, focus
  end

  local up = ThirdPerson.PIVOT_LIFT * e
  local orbit = { pivot[1], pivot[2] + up, pivot[3] }

  local want = ThirdPerson.reachFor() * e
  ThirdPerson.want = want
  local room = ThirdPerson.reach(overworld(), orbit, -lx, -ly, -lz, want)
  -- in instantly, out only as fast as update() allows
  ThirdPerson.len = math.min(ThirdPerson.len, room)
  local len = ThirdPerson.len

  -- the rail offset, faded with how much boom actually survived: a camera
  -- squeezed against a wall gives up its shoulder before it gives up its
  -- distance. Right of the look, flat: cross(look, worldUp) normalized,
  -- which for a look of (sin y, *, cos y) is (-cos y, 0, sin y) -- the same
  -- right hand FirstPerson.moveWorld strafes along.
  -- The rail rides the ZOOM as well, so it stays the same fraction of the
  -- frame at every distance: a fixed four pixels would swamp the close shot
  -- and vanish from the wide one.
  local flat = math.sqrt(lx * lx + lz * lz)
  local sx, sz = 0, 0
  if flat > 1e-6 then
    local s = ThirdPerson.SHOULDER * ThirdPerson.zoom * e
              * (len / math.max(want, 1e-6))
    sx, sz = -lz / flat * s, lx / flat * s
  end

  local eye = { orbit[1] - lx * len + sx,
                orbit[2] - ly * len,
                orbit[3] - lz * len + sz }
  local aim = focus and { focus[1] + sx, focus[2] + up, focus[3] + sz }
              or nil
  return eye, aim
end

-- What a shadow signature has to include about the boom: the sun's box is
-- fitted around this camera, so sliding the eye back (or having a wall
-- shove it in) re-fits it even standing still.
function ThirdPerson.signature()
  if ThirdPerson.extension() <= 0 then return "" end
  return math.floor(ThirdPerson.len) .. "/" ..
         math.floor(ThirdPerson.extension() * 64)
end

return ThirdPerson