Files
DramaticShapeVoxelMod/main.lua
T
2026-07-26 17:22:56 -04:00

265 lines
10 KiB
Lua

-- Dramatic Shape Voxel Mod: a full 3D diorama overworld, shipped as a
-- rendering pipeline mod.
--
-- The engine's render_pipelines registry (src/mods/Schemas.lua) lets a mod
-- own part of the frame. This mod registers two:
--
-- voxel a drawWorld pipeline. Instead of the flat tile blit, the
-- overworld's terrain is extruded into real geometry, walked
-- by a depth-buffered 3D camera, with characters as leaning
-- sprite slabs and a shadow map throwing real cast shadows
-- across whatever they land on. Occlusion is the depth
-- buffer, not a y-sort: walk behind a building and the
-- building is simply in front.
--
-- tiltshift a worldPresent pipeline -- the stage that post-processes
-- the finished world BEFORE the UI composites over it. A
-- tilt-shift blur that sells the miniature-model look, on the
-- diorama only, leaving text boxes and menus crisp.
--
-- Everything a display mode needs beyond the two draw functions -- the
-- OFF/15/35/50 ladder, the options rows, the hotkeys, persistence in
-- save.options.pipelines, the free-roam gate, the mutual exclusion with
-- the engine's TILT mode -- is engine plumbing driven by the records
-- below. This file declares; lib/ draws.
--
-- Nothing here reaches collision, movement, triggers or scripts. Voxel
-- mode is purely presentational: it changes what the world LOOKS like and
-- nothing about what it IS.
local mod = ...
-- ------- the mod namespace
--
-- lib/ modules require each other through V rather than package.path: a
-- mod directory is not on it, and may live inside a mounted .love archive
-- that plain require cannot reach. Each module is loaded once, with V
-- passed in as its vararg (`local V = ...`).
local V = { mod = mod, path = mod.path }
local function chunkFor(rel)
local source = mod:read(rel)
if not source then
error(("DRAMATIC_SHAPE: %s is missing -- reinstall the mod"):format(rel), 0)
end
local chunk, err = load(source, "@" .. mod.path .. "/" .. rel)
if not chunk then
error(("DRAMATIC_SHAPE: %s did not compile: %s"):format(rel, tostring(err)), 0)
end
return chunk
end
local modules = {}
function V.require(name)
local hit = modules[name]
if hit ~= nil then return hit end
local value = chunkFor("lib/" .. name .. ".lua")(V)
modules[name] = value
return value
end
local dataFiles = {}
function V.data(name)
local hit = dataFiles[name]
if hit ~= nil then return hit end
local value = chunkFor("data/" .. name .. ".lua")(V)
dataFiles[name] = value
return value
end
-- ------- pipelines
local Voxel = V.require("VoxelState")
local Voxel3D = V.require("Voxel3D")
local VoxelScene = V.require("VoxelScene")
local TiltShift = V.require("TiltShift")
local ChunkMesher = V.require("ChunkMesher")
local VoxelGrid = V.require("VoxelGrid")
local WorldCurve = V.require("WorldCurve")
-- The last VOID FILL the terrain was meshed under; see the update hook.
local voidFill = { last = nil }
function voidFill.check()
local TileRenderer = require("src.render.TileRenderer")
local now = TileRenderer.voidFill
if voidFill.last ~= nil and now ~= voidFill.last then
ChunkMesher.invalidate() -- no map id: every ring on every map is stale
end
voidFill.last = now
end
mod.content.render_pipelines:register("voxel", {
label = "VOXEL",
levels = Voxel.ANGLE_LABELS,
-- 2/3/4/5 are the engine's own display hotkeys
hotkey = "6",
-- above tiltshift, so the two sort together in the options list with the
-- mode first and its post-process under it
priority = 20,
-- Headless runs and drivers without a depth canvas or shader support
-- answer false here, and the engine keeps the vanilla 2D path -- which
-- is why no caller ever has to guard for a missing 3D pass.
available = function()
return Voxel3D.available()
end,
-- the engine hands over the live level; we ease the camera toward it.
-- pump() advances queued mesh builds inside a few-millisecond budget,
-- so entering voxel mode (and streaming neighbours while walking)
-- costs frames nothing visible -- the old synchronous build froze the
-- first frame for seconds. prefetch() runs here as well as in the
-- draw, because update ticks even while a warp's Transition covers
-- the screen: the destination's meshes start building the moment the
-- map swaps behind the fade, and the fade-covered frames get a wider
-- pump slice -- so stepping out of a door lands on terrain that is
-- already there instead of a flat flash.
update = function(dt, level)
Voxel.update(dt, level)
-- VOID FILL picks the block the border ring is made of, and in this
-- mode that ring is BAKED INTO THE MESH rather than drawn each frame.
-- So the option has to reach the cache or nothing happens on screen
-- until the meshes are dropped for some other reason -- which reads
-- exactly like the option doing nothing at all. Polled rather than
-- hooked because the engine changes it from three places (the options
-- row, applyOptions on load, TileRenderer.setVoidFill) and none of
-- them announces it. Ahead of the active() gate, so switching it
-- while voxel mode is OFF still invalidates what is cached.
voidFill.check()
if not Voxel.active() then return end
local Game = require("src.core.Game")
local ow = Game and Game.overworld
if ow and ow.map and ow.camera then
pcall(VoxelScene.prefetch, ow)
end
ChunkMesher.pump(Game and Game.stack
and Game.stack:top() ~= ow)
end,
drawWorld = function(ctx)
-- Terrain and characters are geometry; the field FX stay ordinary 2D
-- draws composited on top, anchored through the same camera the 3D
-- pass used (ctx.drawFx below). The scene renders at window
-- resolution so the 3D pass is crisp rather than a magnified low-res
-- image, while the FX closures keep drawing in world-pixel units.
local canvas = VoxelScene.render(ctx.state, ctx.width, ctx.height,
ctx.vw, ctx.vh, ctx.paletteFor)
if not canvas then return nil end -- fall back to the 2D path
if Voxel3D.beginOverlay() then
ctx.drawFx(function(wx, wy) return Voxel3D.project(wx, 0, wy) end,
ctx.scale)
Voxel3D.endOverlay()
end
return canvas
end,
invalidate = function()
Voxel3D.invalidate()
ChunkMesher.invalidate() -- no map id = every cached mesh
end,
})
mod.content.render_pipelines:register("tiltshift", {
label = "T-SHIFT",
levels = TiltShift.LABELS,
hotkey = "9",
priority = 10,
update = function(dt, level)
TiltShift.update(dt, level)
end,
-- worldPresent, not present: the blur belongs on the diorama, not on the
-- dialog box in front of it. A pass-through when the level is 0 or the
-- shader is unavailable, so the frame is untouched in every other case.
worldPresent = function(canvas)
return TiltShift.apply(canvas)
end,
invalidate = function()
TiltShift.invalidate()
end,
})
-- ------- this mod's own settings
--
-- Neither of these is a pipeline: they own no pass of the frame, they
-- PARAMETERISE the voxel one, so they have nothing to put in drawWorld or
-- present and the registry would rightly reject them. Plain mod settings
-- instead -- see ModSetting for where they persist and how the two rows
-- each ends up on stay in step.
local SETTINGS = {
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
{ WorldCurve.setting,
"Bend the world down over the horizon, Animal Crossing style." },
}
local schema = {}
for i, entry in ipairs(SETTINGS) do
schema[i] = entry[1]:schema(entry[2])
end
mod.options:define(schema)
-- call next() first and decorate what comes back, so every other mod's
-- rows survive this one
mod.hooks:wrap("ui.options.rows", function(next, game, rows)
local out = next(game, rows)
if type(out) ~= "table" then return out end
for _, entry in ipairs(SETTINGS) do
out[#out + 1] = entry[1]:row()
end
return out
end)
-- The mod manager writes and persists on its own, so the only thing left
-- to do is move our cached index and pick the new value up.
mod.events:on("mod.options_changed", function(payload)
if not (payload and payload.mod == mod.id) then return end
for _, entry in ipairs(SETTINGS) do
if payload.key == entry[1].key then entry[1]:sync(payload.value) end
end
end)
-- ------- keeping the geometry in step with the world
--
-- Terrain meshes are derived from a map's block layer, so anything that
-- rewrites a block (a cut tree, a smashed rock, a script's replaceBlock)
-- has to drop that map's cached mesh or the 3D world keeps showing the
-- tree that is no longer there. The 2D tile renderer invalidates its own
-- caches off the same edit.
mod.events:on("world.block_replaced", function(payload)
local mapId = payload and (payload.mapId or (payload.map and payload.map.id))
if mapId then ChunkMesher.invalidate(mapId) end
end)
-- A reloaded map is rebuilt from scratch (warps that re-enter the same map,
-- hot reload), so its mesh is stale for the same reason -- with one
-- exception, and it is the common one.
--
-- A palette switch reloads the map ONLY to rebuild its atlas
-- (PaletteFX.setMode -> reloadMap(id, "colors")). The geometry that comes
-- back is identical: this mesher reads block layout and tile ids and never
-- reads colour, and the palette lives entirely in the texture TerrainAtlas
-- hands back per frame -- which is keyed BY palette, so the new colours are
-- already built by the time the next frame draws.
--
-- Dropping the mesh anyway cost a visible flash of the flat 2D world on
-- every palette toggle. Mesh builds are asynchronous, so the frames between
-- the drop and the first finished mesh have no terrain to draw, and
-- drawWorld returning nil IS the 2D fallback. Keeping the geometry lets the
-- new colours land on the diorama already on screen, in one frame, which is
-- what a palette toggle should look like from inside voxel mode.
mod.events:on("map.reloaded", function(payload)
if payload and payload.reason == "colors" then return end
local mapId = payload and (payload.mapId or (payload.map and payload.map.id))
if mapId then ChunkMesher.invalidate(mapId) end
end)
mod.exports.version = "1.0.0"
-- exposed so a companion mod can pin its own tiles' shapes or read the
-- camera without reaching into this mod's file layout
mod.exports.lib = V