mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 10:00:50 +02:00
89b6c8513f
Adds `when_above` conditional pins, resolved per position in TileShape.at: one graphic can mean two things (the gates' $32 is both wall base course and counter front), and a flat pin has to pick one. Gates get half-height counters and level walls. Pokemon Tower gains a buildings entry -- it is the drawing the map edge cuts off, sealed on the north -- and the Indigo Plateau statues are built like the gym statues. Fixes: grass stands one full-height clump per tile instead of two half-cut stubs at different depths; ledge pillars drop to ledge height; a prop only rides furniture when its own cell is blocked (chairs were being lifted onto tables); and the caves had water and rock pinned backwards, which stood the Seafoam sea up as rock and cut trenches through Mt Moon. No cave tile is below the datum now.
383 lines
16 KiB
Lua
383 lines
16 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)
|
|
|
|
-- 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
|