mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 09:00:51 +02:00
8b73cbd54d
Cut, the tree regrowth on re-entry and the card-key door stamps all write the block layer directly without emitting world.block_replaced, so meshes derived from that layer went stale unannounced. An earlier cut of this work made the engine announce each one -- 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, so wrap Map:setBlock from the mod instead. Map is a plain metatable shared by every instance, so this covers paths written after this mod too. The write is read back rather than trusted: setBlock ignores an out-of-bounds write, and a stamp that rewrites a block with the value it already held is not an edit and must not throw the mesh away. src/world/OverworldController.lua is reverted to stock.
423 lines
18 KiB
Lua
423 lines
18 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.
|
|
-- 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)
|
|
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 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()
|
|
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.
|
|
|
|
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)
|
|
|
|
-- ------- this mod's hotkeys
|
|
--
|
|
-- 3 VOXEL cycle the camera ladder (was 6)
|
|
-- 5 V-GRID toggle the wireframe (new)
|
|
-- 6 T-SHIFT cycle the blur ladder (was 9)
|
|
-- 7 V-CURVE cycle the horizon bend (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 belongs to a pair of 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,
|
|
}
|
|
|
|
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
|
|
if Pipelines.hotkey(key, top, self.overworld) 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
|
|
-- Both settings parameterise the voxel pass, so they answer to the
|
|
-- same free-roam gate it does -- 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.
|
|
claim:cycle(self)
|
|
return
|
|
end
|
|
end
|
|
return inner(self, key)
|
|
end
|
|
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
|
|
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.
|
|
|
|
-- 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)
|
|
|
|
mod.exports.version = "1.0.6"
|
|
-- 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
|