-- 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 (palette pack switches, warps -- that re-enter the same map), so its mesh is stale for the same reason. mod.events:on("map.reloaded", function(payload) 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