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

VoxelState.lua

lib/VoxelState.lua8.0 KB100644e00e188aa1
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
-- 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 and 3RD are the other rungs that are more than an angle: the camera
-- steps off its orbit entirely and stands with the player -- in their eyes
-- (lib/FirstPerson.lua), or on a boom behind their shoulder
-- (lib/ThirdPerson.lua) -- with free look and free movement on both. Their
-- ANGLE entries are 75 -- the orbit rung they hand over from -- because the
-- tween in and out 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 free-roam rig owns the actual camera.
Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75, 75 }
Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75",
                       "1ST (EXPERIMENTAL)", "3RD (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

-- and the third-person one, which is the same rig with the eye boomed off
-- the back of the head (lib/ThirdPerson.lua)
Voxel.TP_LEVEL = 7

function Voxel.isThirdPerson(level)
  return (level or Voxel.level) == Voxel.TP_LEVEL
end

-- The two of them together: the rungs where the camera stands WITH the
-- player rather than orbiting the view centre, which is what decides that
-- the look inputs are read, the walk goes free and the cards turn to face
-- the eye. Everything that used to ask isFirstPerson for those asks this.
function Voxel.isFreeCam(level)
  level = level or Voxel.level
  return Voxel.isFirstPerson(level) or Voxel.isThirdPerson(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 and 3RD are on the path: they change the camera and only the camera,
-- which is exactly what the key promises -- and the key is also the way back
-- OUT of them on a keyboard, where the mouse is captured and the OPTIONS
-- menu is a trip.
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6, 7 }  -- OFF,15,35,50,75,1ST,3RD

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