-- 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") local OverworldBattle = V.require("OverworldBattle") -- Forward declaration: the voxel pipeline's update hook (registered below) -- calls this, and it is defined further down with the settings it drives. -- Declared rather than left global -- a mod writing to _G would leak into -- every other mod's namespace. local applyFull -- The last VOID FILL the terrain was meshed under; see the update hook. -- The scene canvas's size, in FRAMEBUFFER PIXELS. -- -- `ctx.width/height` are the window measured in LOVE UNITS -- (love.graphics.getDimensions), but the engine composites a pipeline's -- returned canvas with `draw(canvas, 0, 0, 0, 1/dpiX, 1/dpiY)` -- a scale -- that only covers the window when the canvas is at PIXEL resolution. -- Sizing it in units costs the DPI scale TWICE: the canvas is that much -- smaller, then it is drawn that much smaller again, so the diorama lands -- in the top-left corner at 1/dpi of the screen. Desktop never sees it -- -- units and pixels are the same thing there -- but on Android the DPI scale -- is the display density (2.625 on a 420dpi panel), and the world came out -- a third of the size in each direction. -- -- So ask for the pixel dimensions rather than trusting the ctx. That is -- the number a fixed engine would hand over, so this keeps working either -- way instead of double-correcting. It also squares the FX pass: ctx.scale -- is ALREADY in pixels per world pixel (Zoom.scale over Renderer:fitScale, -- which measures the drawable), so the closures ctx.drawFx runs were being -- scaled for a canvas 2.6x bigger than the one they drew into. local function sceneSize(ctx) if love.graphics and love.graphics.getPixelDimensions then local pw, ph = love.graphics.getPixelDimensions() if pw and ph and pw > 0 and ph > 0 then return pw, ph end end return ctx.width, ctx.height end 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, -- 3 is the engine's TILT key, which this mode supersedes -- see the -- hotkey block near the bottom of this file for how it is claimed hotkey = "3", -- 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) -- FULL is a preset, so it is applied ON THE PRESS rather than held every -- frame: it SETS the other rows and then leaves them alone. Holding them -- would make the zoom keys and the wheel dead while the mode was on, and -- would fight anyone who changed one deliberately. applyFull(level) Voxel.update(dt, level) -- The overworld battle rides this hook rather than owning a pipeline of -- its own, because it owns no pass of the FRAME: it draws under a battle -- screen the engine composites, which is not a stage the registry has. -- What it needs is a tick that keeps running once the overworld stops -- being the top state, and this is one -- Game:update calls -- Pipelines.update unconditionally, so it survives the transition wipe -- and the whole battle. Ahead of the active() gate below, because a 3D -- battle does not require the free-roam mode to be switched on. OverworldBattle.update(dt) -- 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 the window's -- PIXEL resolution (see sceneSize) so the 3D pass is crisp rather than -- a magnified low-res image, while the FX closures keep drawing in -- world-pixel units. local sw, sh = sceneSize(ctx) local canvas = VoxelScene.render(ctx.state, sw, sh, 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() OverworldBattle.invalidate() ChunkMesher.invalidate() -- no map id = every cached mesh end, }) mod.content.render_pipelines:register("tiltshift", { label = "T-SHIFT", levels = TiltShift.LABELS, -- 6 is free: no engine branch claims it, so this one alone reaches the -- registry by the documented route hotkey = "6", 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. -- ------- the FULL preset -- -- Everything the mode wants switched to at once. Applied when the VOXEL row -- ARRIVES at FULL and not again, so the player can still move the camera or -- the zoom afterwards -- it is a starting point, not a lock. -- -- Leaving FULL deliberately does NOT undo any of it. A preset that reverted -- would throw away whatever the player had changed since, and "put it back -- how it was" is not a thing this can know. local fullWas = nil applyFull = function(level) local isFull = Voxel.isFull(level) local was = fullWas fullWas = isFull if not isFull or was == true or was == nil then return end local Game = require("src.core.Game") local Pipelines = require("src.render.Pipelines") local Zoom = require("src.render.Zoom") local opts = Game.save and Game.save.options if not opts then return end -- the miniature blur at its strongest: FULL is the diorama look, and the -- tilt-shift is most of what makes it read as a model Pipelines.setLevel("tiltshift", Pipelines.maxLevel("tiltshift")) Pipelines.syncOptions(opts) -- the horizon flat. The curve bends the world away from a walking player, -- which fights a fixed diorama framing WorldCurve.setting:setIndex(1, Game) -- and the view fitted to the window opts.zoom = 0 Zoom.applyOptions(opts) -- battles on the map too: FULL means the whole mode, and a fight is where -- half of it is spent. Set rather than forced -- the row is gone from the -- menu while FULL is on, but a save that already had it off gets it on. OverworldBattle.setting:setIndex(1, Game) if Game.writeOptions then pcall(Game.writeOptions, Game) end end local SETTINGS = { { VoxelGrid.setting, "One-pixel wireframe along every voxel edge." }, { WorldCurve.setting, "Bend the world down over the horizon, Animal Crossing style." }, { OverworldBattle.setting, "Fight on the map: the battle draws over the nearest clear ground, " .. "shot over the shoulder with a slow parallax drift." }, } local schema = {} for i, entry in ipairs(SETTINGS) do schema[i] = entry[1]:schema(entry[2]) end mod.options:define(schema) -- ------- this mod's hotkeys -- -- 3 VOXEL cycle the camera ladder (was 6; skips FULL) -- 5 V-GRID toggle the wireframe (new) -- 6 T-SHIFT cycle the blur ladder (was 9) -- 7 V-CURVE cycle the horizon bend (new) -- 8 3D-BTL toggle overworld battles (new) -- -- Only 6 arrives by the documented route. Game:keypressed answers the -- engine's own display keys FIRST and returns -- 2 COLORS, 3 TILT, 4 ZOOM, -- 5 GBC FX -- and only then offers the key to Pipelines.hotkey, expressly -- so "a pipeline can never shadow one" (Schemas, render_pipelines.hotkey). -- 3 and 5 are two of those, and 7 and 8 belong to plain mod settings that -- own no pass and so have no registry to claim a key from at all. -- -- So this wraps Game:keypressed. It is the invasive option and it is the -- only one: polling the keyboard in update() would fire alongside the -- engine's handler rather than instead of it, so 3 would cycle this mode -- AND the engine's TILT on the same press. -- -- Consequences worth being explicit about: while this mod is enabled, TILT -- (3) and GBC FX (5) are unreachable by key. Both are still reachable on -- the OPTIONS menu, and TILT is the one this mode supersedes anyway -- the -- registry already forces it off whenever a world pipeline takes the pass. -- -- Everything the engine does around a pipeline hotkey has to happen here -- too, so the work is DELEGATED rather than reimplemented: Pipelines.hotkey -- applies its own gate and ladder, and the three lines after it are the -- engine's own (syncOptions, the tilt exclusion, writeOptions). local HOTKEYS = { ["3"] = "pipeline", -- voxel, by its declared hotkey ["6"] = "pipeline", -- tiltshift, likewise ["5"] = VoxelGrid.setting, ["7"] = WorldCurve.setting, ["8"] = OverworldBattle.setting, } do local Game = require("src.core.Game") local Pipelines = require("src.render.Pipelines") local inner = Game.keypressed function Game:keypressed(key) local claim = HOTKEYS[key] local top = self.stack and self.stack:top() -- A screen with its own key handler gets the key first, exactly as the -- engine's first branch does: typing a nickname must not toggle a -- render mode. Only free-roam presses are ours to take. if claim and not (top and top.onKeyPressed) then if claim == "pipeline" then -- 3 walks the ANGLE rungs and steps over FULL (Voxel.HOTKEY_ORDER), -- so the registry's plain "advance one and wrap" is not what it -- wants; 6 still is. The gate is the registry's own either way. local stepped = false if key == "3" then if Pipelines.canToggle("voxel", top, self.overworld) then Pipelines.setLevel("voxel", Voxel.nextHotkeyLevel(Pipelines.level("voxel"))) stepped = true end else stepped = Pipelines.hotkey(key, top, self.overworld) and true end if stepped then Pipelines.syncOptions(self.save.options) -- 3 is the key that used to turn TILT on and sits next to the one -- that used to turn GBC FX on, and this mod has taken both away. -- A player who left either running before enabling the mod would -- otherwise have no way back to off, and both fight the diorama: -- TILT is the flat fake of what this mode does for real, and GBC -- FX is a full-screen present pass over the top of it. So the -- VOXEL key clears them on EVERY press, not just the press that -- switches the mode on -- cycling back round to OFF leaves them -- off too, which is the state the key is now the only route to. if key == "3" then self.save.options.tilt = 0 self.save.options.gbcfx = 0 require("src.render.GBCFX").setLevel(0) end require("src.render.Tilt").setLevel(self.save.options.tilt or 0) self:writeOptions() return end elseif Pipelines.canToggle("voxel", top, self.overworld) then -- All three answer to the voxel pass's own free-roam gate -- -- borrowed from the registry rather than restated, so a press -- mid-warp or mid-cutscene is refused for the wireframe exactly when -- it would be for the mode itself. Two of them parameterise that -- pass; the third (3D-BTL) decides what a battle is drawn over, and -- wants the same gate for a different reason: the answer is read -- when the fight starts, so flipping it from inside one would be a -- switch that appeared to do nothing. claim:cycle(self) return end end return inner(self, key) end end -- ------- the mode's rows, kept together -- -- The engine splices a pipeline's row in beside TILT, because a display mode -- belongs with the other display modes; a mod's own ui.options.rows -- additions land at the END of the list. That left this mod's four rows in -- two places with unrelated engine rows between them, which reads as two -- unrelated features rather than one mode with settings. -- -- So the plain settings are inserted directly after the last of this mod's -- PIPELINE rows instead of appended. Nothing else moves: the block lands -- where the engine already decided display modes go. local function insertGrouped(out, extra) local anchor = nil for i, row in ipairs(out) do local id = type(row) == "table" and row.id if id == "pipeline:voxel" or id == "pipeline:tiltshift" then anchor = i end end if not anchor then for _, row in ipairs(extra) do out[#out + 1] = row end return out end for i, row in ipairs(extra) do table.insert(out, anchor + i, row) end return out end -- FULL owns every one of those settings, so while it is selected they are -- taken off the menu rather than left to be changed under it -- including -- T-SHIFT, which is a pipeline row the engine put there. A row that no -- longer decides anything is worse than no row. local function dropRow(out, id) for i = #out, 1, -1 do if type(out[i]) == "table" and out[i].id == id then table.remove(out, i) end end return out end -- 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 local Pipelines = require("src.render.Pipelines") if Voxel.isFull(Pipelines.level("voxel")) then return dropRow(out, "pipeline:tiltshift") end local extra = {} for _, entry in ipairs(SETTINGS) do extra[#extra + 1] = entry[1]:row() end return insertGrouped(out, extra) 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. -- refresh, not invalidate: the stale mesh keeps drawing while the -- replacement builds in the background, so a one-block edit (Cut, a -- door stamp, the tree regrowing on re-entry) repopulates in place -- instead of blinking the whole scene down to the flat 2D path 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.refresh(mapId) end end) -- The event above is the ANNOUNCED edit -- OverworldState:replaceBlock -- emits it, which is the path Victory Road's barriers and a script's -- replaceBlock take. Several edits do not go through it: -- -- Cut swaps the tree block and rebuilds the 2D renderer -- the regrowth restores those blocks when the map is re-entered -- card-key doors are stamped closed on floor load -- -- all of them writing the block layer directly. Meshes derived from that -- layer went stale with no announcement -- the cut tree stayed standing, -- and after a round trip through a door the stump stayed cut because this -- map's mesh survives in the cache (that is what prevLive is for). -- -- The engine could announce each of those, and an earlier cut of this -- work changed it to. That is the wrong place: it edits the game for one -- mod's benefit, and every future path that writes a block has to -- remember to do the same. They all funnel through ONE choke point -- -- Map:setBlock -- so wrap that from here instead. Map is a plain -- metatable shared by every map instance, so this covers all of them, -- including paths written after this mod. -- -- Read back rather than trust the argument: setBlock silently ignores an -- out-of-bounds write, and a stamp that rewrites a block with the value -- it already held (the door code guards for this, the regrowth does not) -- is not a change and must not throw the mesh away. do local Map = require("src.world.Map") if not Map.dramaticShapeBlockHook then local setBlock = Map.setBlock Map.setBlock = function(self, bx, by, block) local before = self:blockAt(bx, by) setBlock(self, bx, by, block) if self.id and self:blockAt(bx, by) ~= before then ChunkMesher.refresh(self.id) end end Map.dramaticShapeBlockHook = true 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) -- ------- FULL takes rows off the menu, so the menu has to notice -- -- OptionsMenu builds its row list ONCE, when it is opened, and then reads -- that list every frame. So stepping the VOXEL row onto or off FULL changed -- which rows the hook would return but not which rows were on screen -- the -- settings FULL owns stayed visible until the menu was closed and reopened, -- and a player who stepped off FULL could not see the rows come back. -- -- Rebuilt in place, and only on a step that crosses FULL: every other rung -- returns the same list, and rebuilding on all of them would rerun every -- mod's ui.options.rows hook once per keypress. The cursor is clamped rather -- than reset, so it stays on the VOXEL row it was just used on instead of -- jumping to the top when the list below it shortens. do local OptionsMenu = require("src.ui.OptionsMenu") if not OptionsMenu.dramaticShapeFullHook then local Pipelines = require("src.render.Pipelines") local inner = OptionsMenu.update function OptionsMenu:update(dt) local before = Pipelines.level("voxel") inner(self, dt) local after = Pipelines.level("voxel") if after ~= before and (Voxel.isFull(before) or Voxel.isFull(after)) then local rebuilt = OptionsMenu.new(self.game) self.rows = rebuilt.rows local cancel = #self.rows + 1 if (self.index or 1) > cancel then self.index = cancel end end end OptionsMenu.dramaticShapeFullHook = true end end -- ------- battles on the map -- -- The wraps this needs -- OverworldState:pushBattle, BattleState:draw and -- BattleState:drawHUDs -- all live in lib/OverworldBattle.lua, which is -- where the reasoning for each one is written down. Installed once, here, -- so this file keeps naming every engine seam the mod touches. OverworldBattle.install() -- The overworld's own pushBattle is the choke point for a wild encounter or -- a trainer, and it is wrapped. A battle that arrives some other way -- a -- link battle, a script pushing a BattleState directly -- reaches this -- instead, which stages the arena from wherever the player is standing. -- Nothing visible is lost by being late: the cull only has to beat the -- battle screen, and the wipe those battles skip is where it would have -- shown. mod.events:on("battle.started", function(payload) OverworldBattle.ensure(payload and payload.battle) end) -- Both mons face the camera, so the player's side wants its FRONT pic where -- the battle screen would have used the back one. The engine's own -- pokemon.sprite hook is the seam for exactly this: it is asked for every -- battle pic with the side it is resolving, so swapping one side's answer -- needs no battle code at all -- and every path that builds a battler goes -- through it, including a Transform mid-fight. -- -- next() first, so a sprite-replacing mod loaded before this one still gets -- the last word on WHICH art is used; this only changes which SIDE is asked -- for. mod.hooks:wrap("pokemon.sprite", function(next, path, ctx) local out = next(path, ctx) if not (ctx and ctx.kind == "battle" and ctx.side == "back") then return out end if not OverworldBattle.wantsFront() then return out end local def = ctx.data and ctx.data.pokemon and ctx.data.pokemon[ctx.species] return (def and def.spriteFront) or out end) -- Every ending path emits this, including a battle skipped before it drew, -- so this is where the map's cast comes back. mod.events:on("battle.ended", function() OverworldBattle.finish() end) mod.exports.version = "1.1.1" -- 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