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

184 lines
7.2 KiB
Lua

-- Voxel world mode: the camera angle and its tween.
--
-- Deliberately the same shape as src/render/Tilt.lua -- level 0 is off and
-- 1..3 are the same 15/35/50 degree ladder, eased the same way. What
-- differs is what the renderer does with the angle: tilt projects the flat
-- world canvas as one rigid plane, voxel mode drives a real 3D camera over
-- extruded terrain and voxel character models.
--
-- And because it is a real camera over real geometry, it has a rung tilt
-- could never have: 75 degrees, low enough to read as a diorama shot from
-- table height. Tilt's flat plane degenerates into a horizon line there,
-- but geometry only gets more of itself to show.
--
-- The LEVEL is not ours. The engine's render_pipelines plumbing owns it --
-- the options row, the hotkey, the ladder labels, persistence in
-- save.options.pipelines.voxel, and the mutual exclusion with tilt -- and
-- hands it to update() every frame. All this module keeps is the eased
-- ANGLE that level implies, because the tween is renderer state and only
-- the renderer knows what to do with a half-raised camera.
--
-- Purely presentational, like tilt and survey zoom: nothing here reaches
-- collision, movement, triggers or scripts.
local Voxel = {}
-- FULL is a PRESET, not another angle: one rung that puts the whole mode in
-- its intended state at once -- this camera, the miniature blur at full, the
-- horizon flat, the view fitted -- so a player who wants "the diorama" picks
-- it rather than assembling it from four rows. It sits directly after OFF
-- because that is the order those two get used in.
--
-- Its ANGLE is 35 degrees, the same as the rung of that name. The duplicate
-- in the table is deliberate: the ladder is a list of what each rung LOOKS
-- like, and two rungs may look the same while meaning different things.
--
-- 1ST is the other rung that is more than an angle: the camera steps off its
-- orbit entirely and stands in the player's own eyes (lib/FirstPerson.lua),
-- with free look and free movement. Its ANGLE entry is 75 -- the orbit rung
-- it hands over from -- because the tween in and out of first person starts
-- from whatever the orbit shows, and the lowest rung is the one a dive into
-- a head should start from. Everything angle-derived (the sky's fade, the
-- billboard lean the blend eases away) reads that 75 while the first-person
-- rig owns the actual camera.
Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75 }
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
Voxel.FULL_LEVEL = 1
function Voxel.isFull(level)
return (level or Voxel.level) == Voxel.FULL_LEVEL
end
-- the rung the first-person camera sits on, likewise
Voxel.FP_LEVEL = 6
function Voxel.isFirstPerson(level)
return (level or Voxel.level) == Voxel.FP_LEVEL
end
-- ------- what the hotkey walks
--
-- The ANGLE rungs only, with FULL left out. The key is a display-mode
-- cycler: pressing it should change the camera and nothing else, and FULL
-- reaches in and rewrites four other settings. Landing on it by accident,
-- mid-walk, would silently turn the blur to maximum and flatten the horizon
-- with no indication that a keypress had done so. FULL stays on the OPTIONS
-- row, which is where a preset that changes other rows belongs.
--
-- 1ST is on the path: it changes the camera and only the camera, which is
-- exactly what the key promises -- and the key is also the way back OUT of
-- first person on a keyboard, where the mouse is captured and the OPTIONS
-- menu is a trip.
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6 } -- OFF, 15, 35, 50, 75, 1ST
-- The rung a press moves to from `level`.
--
-- A level that is not on the key's path -- FULL, reached from the menu --
-- steps on from whichever rung shows the SAME camera it does. FULL is 35
-- degrees, so a press from it goes to 50 rather than back to 35, and the key
-- never appears to do nothing. Matched by ANGLE rather than by a hardcoded
-- rung, so retuning FULL moves the key's answer with it.
function Voxel.nextHotkeyLevel(level)
level = level or Voxel.level
local order = Voxel.HOTKEY_ORDER
local at = nil
for i, rung in ipairs(order) do
if rung == level then at = i break end
end
if not at then
local deg = Voxel.ANGLES_DEG[level + 1]
for i, rung in ipairs(order) do
if Voxel.ANGLES_DEG[rung + 1] == deg then at = i break end
end
end
if not at then return order[1] end
return order[at % #order + 1]
end
Voxel.level = 0
Voxel.angle = 0
Voxel.from = 0
Voxel.goal = 0
Voxel.t = 1
-- Whether the scene has terrain to show for the current map. VoxelScene
-- maintains it every frame; while the first mesh of a fresh toggle is
-- still building, update() holds the camera tween at flat -- the 2D
-- fallback IS the flat pose, so the switch waits invisibly instead of
-- tilting an empty stage (or, before builds went asynchronous, freezing
-- the whole frame for seconds).
Voxel.ready = true
Voxel.TWEEN_TIME = 0.25
-- Camera distance as a multiple of the view height, and the matching field
-- of view. Kept equal to Tilt.FOCAL so a given angle frames the world the
-- same way in both modes; Voxel3D derives the FOV from it.
Voxel.FOCAL = 1.0
local function ease(t)
return t * t * (3 - 2 * t)
end
local function goalFor(level)
return math.rad(Voxel.ANGLES_DEG[level + 1] or 0)
end
function Voxel.setLevel(level)
level = math.floor(tonumber(level) or 0)
if level < 0 then level = 0 end
if level > Voxel.MAX_LEVEL then level = Voxel.MAX_LEVEL end
local goal = goalFor(level)
if goal ~= Voxel.goal or level ~= Voxel.level then
Voxel.from = Voxel.angle
Voxel.goal = goal
Voxel.t = 0
-- leaving flat: presume no terrain until the scene reports some, so
-- the tween's very first frame already waits instead of easing over
-- an empty stage (render() confirms readiness the same frame when
-- the meshes are already cached)
if Voxel.angle == 0 and goal > 0 then Voxel.ready = false end
end
Voxel.level = level
end
function Voxel.reset()
Voxel.level, Voxel.angle = 0, 0
Voxel.from, Voxel.goal, Voxel.t = 0, 0, 1
end
function Voxel.levelLabel(level)
return Voxel.ANGLE_LABELS[(level or Voxel.level) + 1] or "OFF"
end
-- The pipeline's per-frame tick. `level` is what the engine currently has
-- the mode set to, so a hotkey press or an options row lands here as a new
-- goal to ease toward rather than as a jump. dt is real frame time, so
-- fast-forward does not speed the camera up.
function Voxel.update(dt, level)
if level ~= nil and level ~= Voxel.level then Voxel.setLevel(level) end
-- hold at flat until there is geometry to tilt over; easing OUT (goal
-- below the current angle) never waits
if Voxel.angle == 0 and Voxel.goal > 0 and not Voxel.ready then
return
end
if Voxel.t < 1 then
Voxel.t = math.min(1, Voxel.t + dt / Voxel.TWEEN_TIME)
Voxel.angle = Voxel.from + (Voxel.goal - Voxel.from) * ease(Voxel.t)
else
Voxel.angle = Voxel.goal
end
end
-- True while voxel mode is on *or* still easing out -- i.e. whenever the
-- renderer must take the 3D path instead of the flat blit. Mirrors
-- Tilt.active so the two gate the same way.
function Voxel.active()
return Voxel.level > 0 or Voxel.angle > 0
end
return Voxel