mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 10:50:50 +02:00
1422 lines
67 KiB
Lua
1422 lines
67 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.
|
|
--
|
|
-- Voxel mode is presentational: it changes what the world LOOKS like and
|
|
-- nothing about what it IS. TWO rungs are the deliberate exception. 1ST
|
|
-- (the camera in the player's own eyes) and 3RD (the same rig, boomed back
|
|
-- behind their shoulder) replace the grid WALK with a free,
|
|
-- camera-relative one while either is selected (lib/FreeMove.lua), because
|
|
-- a camera you can steer with a mouse demands feet that go where it looks.
|
|
-- Even there the game is untouched: the walk asks the engine's own
|
|
-- collision the same questions a grid step asks, keeps the player's
|
|
-- logical cell synced, and fires the engine's own landing pipeline per
|
|
-- cell crossed -- warps, encounters, ledges, gates and scripts all run
|
|
-- exactly as themselves. Step off the rung and the grid walk is back.
|
|
|
|
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 ViewBox = V.require("ViewBox")
|
|
local OverworldBattle = V.require("OverworldBattle")
|
|
local BattleExit = V.require("BattleExit")
|
|
local Shiny = V.require("Shiny")
|
|
local ShinyBattle = V.require("ShinyBattle")
|
|
local ShinyUI = V.require("ShinyUI")
|
|
local DayNight = V.require("DayNight")
|
|
local DayTint = V.require("DayTint")
|
|
local Water = V.require("Water")
|
|
local ForestAtmos = V.require("ForestAtmos")
|
|
local Shadows = V.require("Shadows")
|
|
local AntiAlias = V.require("AntiAlias")
|
|
local FirstPerson = V.require("FirstPerson")
|
|
local FreeMove = V.require("FreeMove")
|
|
local CamControl = V.require("CamControl")
|
|
local VR = V.require("VR")
|
|
-- the mod's settings menus: the categories, the screens they open, and the
|
|
-- red ink that marks this mod's one row on the engine's OPTIONS list
|
|
local SettingsMenu = V.require("SettingsMenu")
|
|
-- HORDE MODE: the konami code's minigame. Horde owns the state machine and
|
|
-- every hook; the other four are the gun, the crowd, the readout and the
|
|
-- chip-synthesized sounds it fires. See lib/Horde.lua for the whole design.
|
|
local Horde = V.require("Horde")
|
|
local HordeGun = V.require("HordeGun")
|
|
local HordeHud = V.require("HordeHud")
|
|
local HordeSfx = V.require("HordeSfx")
|
|
-- LET'S GO: the flick-to-throw capture mode. LetsGo owns the row, the
|
|
-- wraps and the experience math; CatchThrow the session (input, arc,
|
|
-- ring, choreography); Pokeball the animated prop they throw.
|
|
local LetsGo = V.require("LetsGo")
|
|
local Pokeball = V.require("Pokeball")
|
|
|
|
-- 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 first-person head, on the same tick: its blend in and out of the
|
|
-- orbit, the mouse capture lifecycle, and the frame's stick-rate look.
|
|
-- Unconditional like Voxel.update, because the blend has to keep easing
|
|
-- OUT after the rung is left
|
|
FirstPerson.update(dt)
|
|
-- the day/night clock, on the same always-running tick: Pipelines.update
|
|
-- runs whatever the level, so time passes with the mode off, through
|
|
-- battles and menus, and a CYCLE evening falls mid-fight exactly as it
|
|
-- would mid-walk
|
|
DayNight.update(dt)
|
|
-- the atmosphere's own clock (shaft shimmer, drifting motes), on the
|
|
-- same tick so the beams keep breathing through a dialog box
|
|
ForestAtmos.update(dt)
|
|
-- LET'S GO rides the same always-running tick, and BEFORE the battle's
|
|
-- own update on purpose: the capture session poses the Poke Ball here,
|
|
-- and OverworldBattle.update renders the arena a moment later -- so
|
|
-- the ball each frame draws is the ball that frame computed. Guarded,
|
|
-- and loudly: a fault in the capture game must cost the capture game,
|
|
-- not the whole voxel pipeline.
|
|
do
|
|
local okLG, errLG = pcall(LetsGo.update, dt)
|
|
if not okLG and not V.letsGoWarned then
|
|
V.letsGoWarned = true
|
|
mod.log:warn("LET'S GO update failed: %s", tostring(errLG))
|
|
end
|
|
end
|
|
-- 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)
|
|
-- The one-time build of the Pokemon Stadium battle models out of the
|
|
-- player's own ROM, if there is one to build from and it has not been
|
|
-- done (see StadiumInstall). Rides this hook for the same reason the
|
|
-- battle does -- it is the tick that runs whatever is on the stack -- and
|
|
-- asks exactly once, on the first frame the player is actually in the
|
|
-- world, so it is never fighting the engine's own launcher for the
|
|
-- screen.
|
|
pcall(function() V.require("StadiumScreen").maybePush() end)
|
|
-- and a ROM the system file picker dropped in the save directory while
|
|
-- we were not the top activity (Android; see StadiumRomPick.poll)
|
|
pcall(function()
|
|
V.require("StadiumRomPick").poll(require("src.core.Game"))
|
|
end)
|
|
-- The horde, on the same always-running tick and for the same reason:
|
|
-- it owns no pass of the frame, it is a MODE over the overworld, and
|
|
-- it has to keep thinking while a warp's wipe covers the screen (the
|
|
-- crowd follows the player through the door) and under the GAME OVER
|
|
-- card, which is a pushed state that stops everything below it.
|
|
Horde.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()
|
|
-- The whole VR frame -- session lifecycle, xrWaitFrame's pacing, both
|
|
-- eye renders, the layer submit -- rides this hook, because it is the
|
|
-- one tick that runs through menus, dialogs and battles, which is
|
|
-- what a headset needs the world (or at least the UI panel) to do.
|
|
-- Ahead of the active() gate: with the mode off, the headset still
|
|
-- shows the flat screen on the floating panel.
|
|
VR.update(dt)
|
|
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)
|
|
-- the palette closure, stashed for the VR frame: it renders from the
|
|
-- update hook, where no ctx exists to carry one
|
|
VR.paletteFor = ctx.paletteFor
|
|
-- With a headset running, the window's world pass becomes the MIRROR
|
|
-- -- the left eye, fitted to the window -- rather than a third full
|
|
-- render of the scene. Everything else about the frame (the UI the
|
|
-- engine composites over this) is unchanged, which is exactly what
|
|
-- the headset's floating panel photographs.
|
|
if VR.active() then
|
|
local sw, sh = sceneSize(ctx)
|
|
local m = VR.mirror(sw, sh)
|
|
if m then return m end
|
|
end
|
|
-- 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)
|
|
-- With AA on, the whole pass runs into a canvas BIGGER than the window
|
|
-- and is folded back down at the end (see AntiAlias). Nothing between
|
|
-- these two lines knows: every pass in the frame measures itself in the
|
|
-- canvas it was handed, so the sky's dither, the water's march and the
|
|
-- camera itself all come out the same picture at a higher sample rate.
|
|
local rw, rh = AntiAlias.expand(sw, sh)
|
|
local canvas = VoxelScene.render(ctx.state, rw, rh,
|
|
ctx.vw, ctx.vh, ctx.paletteFor)
|
|
if not canvas then return nil end -- fall back to the 2D path
|
|
if Voxel3D.beginOverlay() then
|
|
-- the FX closures are ordinary 2D draws sized in DISPLAY pixels, and
|
|
-- they are drawing into the supersampled canvas alongside everything
|
|
-- else -- so the scale goes up with it, or the "!" bubble lands the
|
|
-- right place at half the size. project() already answers in canvas
|
|
-- pixels, so only the scale needs saying.
|
|
ctx.drawFx(function(wx, wy) return Voxel3D.project(wx, 0, wy) end,
|
|
ctx.scale * AntiAlias.factor())
|
|
-- the horde's readout rides the same overlay, over the FX: health,
|
|
-- ammunition, the crosshair and the banners, sized in the same
|
|
-- supersampled canvas pixels everything else here is drawn in. A
|
|
-- headset never reaches this line (drawWorld returns the mirror
|
|
-- above) -- lib/VR draws the same HUD onto each eye instead.
|
|
HordeHud.drawFlat(rw, rh, ctx.scale * AntiAlias.factor())
|
|
Voxel3D.endOverlay()
|
|
end
|
|
-- and back to the window's own size, which is what the engine composites
|
|
-- one canvas pixel to one display pixel. A pass-through when AA is off.
|
|
return AntiAlias.resolve(canvas, sw, sh, "world")
|
|
end,
|
|
|
|
invalidate = function()
|
|
Voxel3D.invalidate()
|
|
OverworldBattle.invalidate()
|
|
AntiAlias.invalidate()
|
|
ChunkMesher.invalidate() -- no map id = every cached mesh
|
|
ForestAtmos.invalidate() -- shaft/particle meshes and shader sentinels
|
|
VR.invalidate() -- the mirror, and FBO ids of dead canvases
|
|
Pokeball.invalidate() -- the ball's meshes and palette texture
|
|
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 world cut to the window it is framed in (lib/ViewBox). FULL is
|
|
-- the model-on-a-table read and the sides are most of what makes it one:
|
|
-- a slab of Kanto with edges, rather than a map whose corners happen to
|
|
-- fall off the frame.
|
|
ViewBox.setting:setIndex(1, Game)
|
|
-- and the water reflecting everything it can: FULL is the diorama at its
|
|
-- most photographed, and a lake with the sky and the shoreline in it is
|
|
-- most of what makes the model read as being outdoors
|
|
Water.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 and then LET GO of -- unlike the rows above, both
|
|
-- battle rows stay on the menu under FULL (see the rows hook), so this is
|
|
-- where the preset puts them and not where they are held.
|
|
OverworldBattle.setting:setIndex(1, Game)
|
|
-- with both mons out there on it: BACK SPRITES keeps the player's own on the
|
|
-- menu, which is the one part of the old screen FULL is least about. Set the
|
|
-- same way, and changed back on the same row a keypress later.
|
|
OverworldBattle.backSetting:setIndex(1, Game)
|
|
-- and the battle screen the staged fight is composed for. WIDE re-lays that
|
|
-- screen out on a 304x144 surface, which moves every anchor the arena camera
|
|
-- is solved against (OverworldBattle.forceOG); FULL has just switched staged
|
|
-- fights on, so the layout follows them.
|
|
OverworldBattle.forceOG(Game)
|
|
-- and the sky on the clock on the wall: FULL pins DAYTIME to SYNC. Unlike
|
|
-- the rest of the preset this one IS held, not just set -- the row is off
|
|
-- the menu while FULL owns it (the rows hook below), so a value changed
|
|
-- under it could never be seen or changed back.
|
|
DayNight.forceSync(Game)
|
|
if Game.writeOptions then pcall(Game.writeOptions, Game) end
|
|
end
|
|
|
|
-- Whether a fight can be staged on the map, as far as the OPTIONS menu is
|
|
-- concerned: the 3D-BTL row, and nothing else.
|
|
--
|
|
-- It used to answer yes under FULL as well, on the grounds that FULL owned
|
|
-- that row and switched it on. FULL no longer owns it -- the row stays on the
|
|
-- menu under FULL and can be switched off there (see the rows hook) -- so that
|
|
-- clause would now claim staged battles for a preset the player had just
|
|
-- turned them off inside, pinning BATTLE LAYOUT to OG for a fight that is
|
|
-- never staged. The row is the only thing that decides, which is what every
|
|
-- other reader of this setting already believed: OverworldBattle.begin and
|
|
-- wantsFront both gate on enabled() alone.
|
|
--
|
|
-- Deliberately NOT gated on Voxel3D.available(): the engine offers a
|
|
-- pipeline's row whether or not the hardware can run it (Pipelines.rows), so
|
|
-- this mode's rows say ON on a machine without a depth buffer too, and a menu
|
|
-- that claims 3D battles are on must not also offer the layout they cannot be
|
|
-- drawn in.
|
|
local function stagedBattles()
|
|
return OverworldBattle.enabled()
|
|
end
|
|
|
|
-- ------- this mod's settings, grouped the way the menus present them
|
|
--
|
|
-- One entry per setting: the ModSetting itself, the help text the mod
|
|
-- manager's page carries, and the fields that decide where it is offered.
|
|
--
|
|
-- cat which of SettingsMenu's categories the row lives on. The table is
|
|
-- kept in category order as well, so the mod manager's own page --
|
|
-- which has no categories to give and lists every row flat -- at
|
|
-- least keeps related settings next to each other.
|
|
-- when a predicate. The row is off the menu entirely while it answers
|
|
-- false, because a row that decides nothing reads as a broken mod.
|
|
-- full the row SURVIVES the FULL preset. FULL owns the look, so a row
|
|
-- goes with it by default; `full` marks the ones that were never
|
|
-- about the look. SettingsMenu leans on this and needs no rule of
|
|
-- its own: 3D WORLD is exactly the rows WITHOUT it, so that whole
|
|
-- category empties out under FULL and takes itself off the menu.
|
|
local SETTINGS = {
|
|
-- ------- the top-level menu -- settings that are about the GAME
|
|
--
|
|
-- SettingsMenu.ROOT as a `cat` puts a row on the DRAMATIC SHAPE screen
|
|
-- itself rather than inside one of the four categories, which is right
|
|
-- here: the categories are the diorama, the fights, what the look costs
|
|
-- and the headset, and how often a shiny appears is none of those.
|
|
--
|
|
-- `full` for the battle rows' reason: FULL is a preset for the LOOK, and
|
|
-- an encounter rate is a rule of the game. A player inside FULL must be
|
|
-- able to reach it, and FULL must never set it.
|
|
{ Shiny.setting,
|
|
"How often a wild Pokemon turns up shiny. 1:8192 is the games' own "
|
|
.. "rate, and every rung below it is twice as often as the one above.",
|
|
cat = SettingsMenu.ROOT, full = true },
|
|
|
|
-- ------- 3D WORLD -- the diorama's own knobs, every one of them FULL's
|
|
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge.",
|
|
cat = "world" },
|
|
{ WorldCurve.setting,
|
|
"Bends the world down over the horizon, until a town sits on top of its "
|
|
.. "own little planet.",
|
|
cat = "world" },
|
|
{ ViewBox.setting,
|
|
"How far out the camera bothers to draw, which only changes the picture "
|
|
.. "above about 63 degrees where the horizon comes into view.",
|
|
cat = "world" },
|
|
{ Water.setting,
|
|
"Reflections on water: SKY is the sun, moon and sky alone, and FULL "
|
|
.. "adds the shoreline and trees behind it.",
|
|
cat = "world" },
|
|
{ DayNight.setting,
|
|
"What time it is outdoors -- pinned to an hour, running on a ten-minute "
|
|
.. "cycle, or synced to the clock on your wall.",
|
|
cat = "world" },
|
|
|
|
-- ------- BATTLES -- what a fight is drawn over, and how it is played
|
|
--
|
|
-- `full` marks a row FULL does not take away. FULL owns the diorama's own
|
|
-- knobs; what a battle is drawn over, and how it is framed, are not that.
|
|
-- Off the OPTIONS menu while VR is on: the headset REQUIRES staged
|
|
-- battles (OverworldBattle.enabled answers true regardless of this row)
|
|
-- and forbids back sprites (backPinned answers false), so both rows
|
|
-- decide nothing there and a dead switch on the menu reads as broken.
|
|
{ OverworldBattle.setting,
|
|
"Fights staged in 3D over your shoulder, on the map or on discs against "
|
|
.. "the sky, as cards or Stadium's animated models.",
|
|
cat = "battles",
|
|
when = function() return not VR.enabled() end, full = true },
|
|
-- Only offered while a fight can actually be staged on the map: with 3D-BTL
|
|
-- off the engine draws the classic screen, which is this row's ON already,
|
|
-- and a row that no longer decides anything is worse than no row.
|
|
{ OverworldBattle.backSetting,
|
|
"Keeps your own Pokemon on the battle menu, seen from behind, instead "
|
|
.. "of standing it on the map facing the foe.",
|
|
cat = "battles",
|
|
when = function() return stagedBattles() and not VR.enabled() end,
|
|
full = true },
|
|
-- `full` like the battle rows: this is a GAMEPLAY mode, not a knob on
|
|
-- the diorama, so the FULL preset neither sets it nor takes it away.
|
|
{ LetsGo.setting,
|
|
"Pokemon GO-style catching -- flick to throw the ball, with FULL adding "
|
|
.. "half-price balls and party experience (needs 3D-BTL).",
|
|
cat = "battles", full = true },
|
|
|
|
-- ------- PERFORMANCE -- what the look COSTS, which is a different question
|
|
--
|
|
-- All three are `full`, and all three for the same reason: FULL is a preset
|
|
-- for the diorama, not a licence to spend whatever the machine it happens
|
|
-- to be running on has got. The player decides what their hardware can
|
|
-- carry, from inside FULL like anywhere else.
|
|
-- `full` for the AA reason: additive shafts are fill rate, and under 4X
|
|
-- supersampling that is a question about the hardware, not the look.
|
|
{ ForestAtmos.setting,
|
|
"Haze and volumetric light shafts in the deep woods, with pollen in the "
|
|
.. "beams by day and fireflies at night.",
|
|
cat = "perf", full = true },
|
|
-- `full` on AA's reasoning below, and for the same reason: the sun's pass
|
|
-- is the most expensive thing in the frame after the geometry, so this is
|
|
-- a question about the machine rather than a knob on the diorama, and it
|
|
-- has to stay reachable from inside FULL -- which never sets it either.
|
|
{ Shadows.setting,
|
|
"Real cast shadows from the sun, and the first thing to switch off on a "
|
|
.. "phone or an old machine.",
|
|
cat = "perf", full = true },
|
|
-- Marked `full` for the opposite reason the battle rows are: this is not a
|
|
-- knob on the look at all, it is what the look COSTS.
|
|
{ AntiAlias.setting,
|
|
"Smooths the stair-stepped edges of the 3D world, and the most "
|
|
.. "expensive row in the mod.",
|
|
cat = "perf", full = true },
|
|
|
|
-- ------- VR -- the headset, and the one comfort knob that is only its
|
|
--
|
|
-- `full` for the same reason as AA: not a knob on the look, a question
|
|
-- about the hardware on the desk.
|
|
{ VR.setting,
|
|
"PCVR through OpenXR on Windows, either following the VOXEL ladder or "
|
|
.. "as a DIORAMA you carry and turn with the grips.",
|
|
cat = "vr",
|
|
-- on Windows the row stays even when a runtime is missing (the console
|
|
-- says why); off Windows -- mobile above all -- there is no VR to have
|
|
-- and the row does not exist
|
|
when = function() return VR.supported() end, full = true },
|
|
-- Under the VR row and only while it is ON: a comfort setting for a
|
|
-- device that is not plugged in decides nothing, and this one is read
|
|
-- exclusively by the headset's right stick.
|
|
{ VR.smoothTurn,
|
|
"Turns smoothly with the right stick instead of snapping 45 degrees, "
|
|
.. "if you have your sea legs for it.",
|
|
cat = "vr",
|
|
-- and only under STANDARD: the stick turns a HEAD, and neither diorama
|
|
-- mode has the player standing in the world to be turned
|
|
when = function() return VR.enabled() and not VR.dioramaMode() end,
|
|
full = true },
|
|
}
|
|
|
|
SettingsMenu.define(SETTINGS)
|
|
|
|
local schema = {}
|
|
for _, entry in ipairs(SETTINGS) do
|
|
-- the VR rows are absent from the mod manager's page too where the
|
|
-- platform cannot do VR at all -- the OPTIONS menu's `when` gates are
|
|
-- situational (a row hidden for now), this one is existential
|
|
local vrOnly = entry[1] == VR.setting or entry[1] == VR.smoothTurn
|
|
if not vrOnly or VR.supported() then
|
|
schema[#schema + 1] = entry[1]:schema(entry[2])
|
|
end
|
|
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 cycle overworld battles (new)
|
|
-- 9 WATER cycle the water reflections (new; 9 was T-SHIFT's old key)
|
|
--
|
|
-- 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 -- and unreachable on the OPTIONS
|
|
-- menu too, where both rows are taken away and both values held at zero (see
|
|
-- pinEngineFx). Nothing is being hidden that still does something: TILT is the
|
|
-- flat fake of what this mode does for real, the registry already forces it
|
|
-- off whenever a world pipeline takes the pass, and GBC FX is a full-screen
|
|
-- present pass over the top of the diorama. Uninstalling puts both back.
|
|
--
|
|
-- 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,
|
|
["9"] = Water.setting,
|
|
}
|
|
|
|
-- One step of the VOXEL angle ladder: everything a "3" press does, named
|
|
-- so the pad's SELECT button (below) can make exactly the same step. The
|
|
-- gate is the registry's own; the tilt/GBC FX clearing is the engine work
|
|
-- the key has always delegated (see the wrap below for why).
|
|
local function cycleVoxel(game)
|
|
local Pipelines = require("src.render.Pipelines")
|
|
-- HORDE MODE holds the rung at 1ST for as long as it runs. Refused HERE
|
|
-- rather than at each caller because this one function IS every way a
|
|
-- player can step the ladder: the "3" key, the pad's SELECT, and the VR
|
|
-- left-stick click all come through it.
|
|
if Horde.viewLocked() then return false end
|
|
local top = game.stack and game.stack:top()
|
|
if not Pipelines.canToggle("voxel", top, game.overworld) then return false end
|
|
Pipelines.setLevel("voxel", Voxel.nextHotkeyLevel(Pipelines.level("voxel")))
|
|
Pipelines.syncOptions(game.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 -- so the VOXEL step
|
|
-- clears them on EVERY press, not just the press that switches on.
|
|
game.save.options.tilt = 0
|
|
game.save.options.gbcfx = 0
|
|
require("src.render.GBCFX").setLevel(0)
|
|
require("src.render.Tilt").setLevel(game.save.options.tilt or 0)
|
|
game:writeOptions()
|
|
return true
|
|
end
|
|
|
|
-- The same, to a NAMED rung rather than one step on: what a diorama mode
|
|
-- holds the ladder with, since 2D and both free-roam rungs are things it
|
|
-- cannot present (see VR.setVoxelLevel). Everything after the setLevel is
|
|
-- the engine work above, for the same reasons.
|
|
local function setVoxelLevel(game, level)
|
|
local Pipelines = require("src.render.Pipelines")
|
|
if Horde.viewLocked() then return false end
|
|
if Pipelines.level("voxel") == level then return false end
|
|
Pipelines.setLevel("voxel", level)
|
|
Pipelines.syncOptions(game.save.options)
|
|
game.save.options.tilt = 0
|
|
game.save.options.gbcfx = 0
|
|
require("src.render.GBCFX").setLevel(0)
|
|
require("src.render.Tilt").setLevel(game.save.options.tilt or 0)
|
|
game:writeOptions()
|
|
return true
|
|
end
|
|
|
|
-- The VR stick click makes this same step (VR.stepView): the function is
|
|
-- a local of this file, so the handoff is explicit rather than a
|
|
-- reimplementation drifting out of date in lib/VR.lua.
|
|
VR.cycleVoxel = cycleVoxel
|
|
VR.setVoxelLevel = setVoxelLevel
|
|
|
|
do
|
|
local Game = require("src.core.Game")
|
|
local Pipelines = require("src.render.Pipelines")
|
|
local inner = Game.keypressed
|
|
|
|
function Game:keypressed(key)
|
|
-- HORDE MODE owns the keyboard's spare keys while it runs: R reloads,
|
|
-- and the mode keys are swallowed rather than left to change the rung
|
|
-- or the post-processing out from under a locked camera.
|
|
if Horde.active then
|
|
if key == "r" then
|
|
HordeGun.reload()
|
|
return
|
|
end
|
|
if HOTKEYS[key] then return end
|
|
end
|
|
local claim = HOTKEYS[key]
|
|
local top = self.stack and self.stack:top()
|
|
-- Q and E work whichever camera is in front of the player -- the
|
|
-- battle's lens, the third-person boom, or the engine's own survey
|
|
-- zoom on an orbit rung. CamControl answers which, and answers "none"
|
|
-- for 1ST and for every screen with no camera of ours behind it, in
|
|
-- which case the key falls through untouched. Ahead of the hotkey
|
|
-- table because unlike those it is NOT free-roam only: a staged battle
|
|
-- is exactly where the zoom is most wanted.
|
|
if (key == "q" or key == "e")
|
|
and not (top and top.onKeyPressed) then
|
|
if CamControl.zoomBy(key == "q" and 1 or -1) then return end
|
|
end
|
|
-- 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.
|
|
-- The whole of 3's step lives in cycleVoxel, because the pad's
|
|
-- SELECT button makes the same step (see the handleInput wrap).
|
|
if key == "3" then
|
|
if cycleVoxel(self) then return end
|
|
elseif Pipelines.hotkey(key, top, self.overworld) then
|
|
Pipelines.syncOptions(self.save.options)
|
|
require("src.render.Tilt").setLevel(self.save.options.tilt or 0)
|
|
self:writeOptions()
|
|
return
|
|
end
|
|
elseif Pipelines.canToggle("voxel", top, self.overworld) then
|
|
-- All four 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. Three of them parameterise that
|
|
-- pass; the fourth (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)
|
|
-- 8 is one of the two ways staged battles get switched on, and they
|
|
-- pin BATTLE LAYOUT to OG (see the rows hook). The other keys
|
|
-- parameterise the pass and leave the layout alone; the guard answers
|
|
-- for all of them, so nothing here has to know which key it was.
|
|
if stagedBattles() then OverworldBattle.forceOG(self) end
|
|
return
|
|
end
|
|
end
|
|
return inner(self, key)
|
|
end
|
|
end
|
|
|
|
-- ------- the mode's rows, on menus of their own
|
|
--
|
|
-- This mod used to put FOURTEEN rows on the engine's OPTIONS list, in one
|
|
-- block spliced in beside the pipeline rows. OptionRows shows four boxes at a
|
|
-- time, so that was four screens of scrolling inside a list that already
|
|
-- carried twenty engine rows, and finding SHADOWS meant knowing it was in
|
|
-- there past the wireframe and the horizon bend.
|
|
--
|
|
-- Now there is ONE row, and it leads the list. What it opens -- the
|
|
-- categories, the screens, and why the split falls where it does -- is
|
|
-- lib/SettingsMenu.lua. VOXEL and T-SHIFT go with it: they are this mod's
|
|
-- display modes, the engine only spliced them beside TILT because it had
|
|
-- nowhere better, and TILT is not on the menu any more anyway (see below).
|
|
--
|
|
-- Two things it takes to move a pipeline row: the engine's descriptor is
|
|
-- captured on the way past and handed to SettingsMenu VERBATIM -- it persists
|
|
-- through its own step function into save.options.pipelines, and rebuilding
|
|
-- it here would be a second implementation of something the engine already
|
|
-- got right -- and the row is then dropped from the top-level list so it is
|
|
-- not in two places at once.
|
|
local function captureRow(out, id)
|
|
for _, row in ipairs(out) do
|
|
if type(row) == "table" and row.id == id then return row end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- FULL owns the settings that describe the LOOK, so while it is selected those
|
|
-- 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.
|
|
--
|
|
-- The battle rows are the exception and they stay; see the rows hook.
|
|
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
|
|
|
|
-- ------- TILT and GBC FX are gone while this mod is installed
|
|
--
|
|
-- Both fight the diorama, and both were already half-taken: the mode's own key
|
|
-- (3) forces them off on every press, and the registry switches TILT off
|
|
-- whenever a world pipeline takes the pass. What was left was two rows the
|
|
-- player could set and watch get reverted -- 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 the whole thing.
|
|
--
|
|
-- So they come OFF the menu, and are HELD at zero rather than merely dropped.
|
|
-- Hiding a live setting is a trap: a save written before the mod was installed
|
|
-- can carry TILT 3, and a row that is not there is a row that cannot turn it
|
|
-- back off. Pinned wherever the value could have arrived from -- the menu
|
|
-- opening, a save being loaded or begun -- so there is no route by which one
|
|
-- of them is on and unreachable.
|
|
--
|
|
-- Everything they did is still reachable: uninstall the mod and both rows are
|
|
-- back, at whatever they were last set to.
|
|
-- BATTLE BG rides the same reasoning, and comes off for a reason of its own.
|
|
-- The row picks what fills the screen AROUND the battle's 160x144 field --
|
|
-- WHITE paper, BLACK bars, or the frozen overworld dimmed behind it -- and
|
|
-- all three were answers to the same question: what to do with the voids,
|
|
-- given the battle is a small picture in the middle of a big window.
|
|
--
|
|
-- This mod answers that question differently and permanently. A staged fight
|
|
-- fills the whole window with the map the fight is standing on, and the
|
|
-- flat battle screen it composites over it is drawn on the mode's own
|
|
-- surface; there are no voids left for the row to fill. WORLD is the worst
|
|
-- of the three under it -- it makes the battle non-opaque so the engine
|
|
-- draws the overworld underneath, which is a SECOND copy of the world drawn
|
|
-- under the one the arena pass already put there, dimmed and at a different
|
|
-- camera. BLACK bars over a diorama read as a letterboxed screenshot.
|
|
--
|
|
-- So the value is pinned at WHITE, which is the one the mode was composed
|
|
-- against, and the row comes off the menu on the same reasoning as TILT and
|
|
-- GBC FX: a row that no longer decides anything is worse than no row.
|
|
-- Uninstall the mod and it is back, at whatever it was last set to.
|
|
local function pinEngineFx(game)
|
|
game = game or require("src.core.Game")
|
|
local opts = game and game.save and game.save.options
|
|
local Tilt = require("src.render.Tilt")
|
|
local GBCFX = require("src.render.GBCFX")
|
|
local changed = false
|
|
if opts then
|
|
changed = (opts.tilt or 0) ~= 0 or (opts.gbcfx or 0) ~= 0
|
|
or (opts.battleBg or "white") ~= "white"
|
|
opts.tilt, opts.gbcfx = 0, 0
|
|
opts.battleBg = "white"
|
|
end
|
|
pcall(Tilt.setLevel, 0)
|
|
pcall(GBCFX.setLevel, 0)
|
|
if changed and game.writeOptions then pcall(game.writeOptions, game) end
|
|
end
|
|
|
|
-- ------- the values that follow other values
|
|
--
|
|
-- Two settings hold a third in place. 3D-BTL pins BATTLE LAYOUT to OG while a
|
|
-- fight can be staged on the map, and FULL pins DAYTIME to SYNC while it owns
|
|
-- that row. Both pins used to be a side effect of the rows hook, which every
|
|
-- step on the OPTIONS menu reran -- so they happened whether or not the step
|
|
-- was the one that mattered, and nothing had to name them.
|
|
--
|
|
-- Now a step can happen on the mod's own menu, where no hook runs, or on the
|
|
-- mod manager's page, where one never did. So the pinning is a function, and
|
|
-- all three routes ask for it.
|
|
local function pinDependents(game)
|
|
if stagedBattles() then OverworldBattle.forceOG(game) end
|
|
local Pipelines = require("src.render.Pipelines")
|
|
if Voxel.isFull(Pipelines.level("voxel")) then DayNight.forceSync(game) end
|
|
end
|
|
|
|
SettingsMenu.setOnChanged(pinDependents)
|
|
|
|
-- 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")
|
|
-- ahead of every branch below, including FULL's early return: these two are
|
|
-- off the menu whatever else this mod is or is not doing
|
|
pinEngineFx(game)
|
|
dropRow(out, "tilt")
|
|
dropRow(out, "gbcfx")
|
|
-- and BATTLE BG with them: this mode fills the window with the map, so
|
|
-- the row's whole question -- what to put in the voids around the battle
|
|
-- -- no longer has voids to be about (see pinEngineFx)
|
|
dropRow(out, "battleBg")
|
|
-- BATTLE LAYOUT is the ENGINE's row, and this is the one place the mod takes
|
|
-- one away. While a fight can be staged on the map, OG is the only layout it
|
|
-- can be composed in (OverworldBattle.forceOG), so the value is pinned there
|
|
-- and the row comes off the list on the same reasoning as the rows FULL owns:
|
|
-- a row that no longer decides anything is worse than no row. Nothing is
|
|
-- lost by switching 3D-BTL off -- the row is back, WIDE and all, on the same
|
|
-- keypress.
|
|
if stagedBattles() then
|
|
OverworldBattle.forceOG(game)
|
|
dropRow(out, "battleLayout")
|
|
end
|
|
if Voxel.isFull(Pipelines.level("voxel")) then
|
|
-- FULL owns the rows that PARAMETERISE the diorama -- the wireframe, the
|
|
-- horizon bend, the blur, the hour -- so DAYTIME is held at SYNC while its
|
|
-- row is unreachable. The rows themselves come off inside SettingsMenu,
|
|
-- which is where they live now: T-SHIFT with the wireframe and the bend,
|
|
-- and each of them by the same `full` rule rather than by name.
|
|
DayNight.forceSync(game)
|
|
end
|
|
-- The two pipeline rows move INTO the mod's own root menu: captured as the
|
|
-- engine built them, then dropped from here so they are not in two places.
|
|
local captured, voxelRow = {}, nil
|
|
for _, id in ipairs({ "pipeline:voxel", "pipeline:tiltshift" }) do
|
|
local row = captureRow(out, id)
|
|
-- a pipeline the registry refused is simply not there, and the menu says
|
|
-- so by not offering it rather than by offering a hole
|
|
if row then captured[#captured + 1] = row end
|
|
if id == "pipeline:voxel" then voxelRow = row end
|
|
dropRow(out, id)
|
|
end
|
|
SettingsMenu.setPipelineRows(captured)
|
|
-- ------- one row, and it leads the list
|
|
--
|
|
-- At the TOP rather than spliced in beside the display modes it used to sit
|
|
-- with. This is a mod that replaces the whole look of the game, and a player
|
|
-- who installed it and went looking for its settings should not have to
|
|
-- scroll to find out where they went -- least of all past the engine rows it
|
|
-- has quietly taken away.
|
|
--
|
|
-- Inserted after next() has run, so it leads every OTHER mod's rows too. The
|
|
-- second line is VOXEL's own value function, which makes the row say what
|
|
-- the mode is currently doing without opening it -- and reuses the engine's
|
|
-- label ladder rather than restating it.
|
|
table.insert(out, 1, {
|
|
id = SettingsMenu.id(SettingsMenu.ROOT),
|
|
label = SettingsMenu.ROOT_LABEL,
|
|
value = voxelRow and voxelRow.value or nil,
|
|
-- `activate` and not `step`: the engine fires activate on A alone, and a
|
|
-- row that OPENS something should not also answer Left and Right
|
|
-- (src/ui/OptionsMenu.update).
|
|
activate = function(g)
|
|
g.stack:push(SettingsMenu.new(g, SettingsMenu.ROOT))
|
|
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
|
|
-- 3D-BTL switched on from the manager's page pins BATTLE LAYOUT exactly as
|
|
-- the mod's own row does, and DAYTIME changed there while FULL owns it snaps
|
|
-- straight back to SYNC -- that row is off the mod's menus under FULL, but
|
|
-- the manager's page carries every setting unconditionally, and the pin has
|
|
-- to hold against both.
|
|
pinDependents()
|
|
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
|
|
-- the atmosphere's layout stands on the same carved stamps the meshes
|
|
-- do, so it goes stale on exactly the same event
|
|
if mapId then ForestAtmos.invalidate(mapId) end
|
|
end)
|
|
|
|
-- ------- rows come and go, 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 changes the LIST: crossing FULL,
|
|
-- or toggling 3D-BTL, which is the other row that owns one (BATTLE LAYOUT).
|
|
-- 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 row it was just used on
|
|
-- instead of jumping to the top when the list below it shortens.
|
|
--
|
|
-- Held on the INSTANCE rather than compared across one call of update, and
|
|
-- that is not a tidying: those three rows live in a SUBMENU now, and the
|
|
-- stack only ticks its top state (src/core/StateStack.update). So the step
|
|
-- that changes them happens while this menu is suspended and a
|
|
-- before/after pair taken around inner() would both be read after the fact
|
|
-- and always agree. A signature that outlives the suspension does not.
|
|
do
|
|
local OptionsMenu = require("src.ui.OptionsMenu")
|
|
if not OptionsMenu.dramaticShapeFullHook then
|
|
local OptionRows = require("src.ui.OptionRows")
|
|
local Pipelines = require("src.render.Pipelines")
|
|
local inner = OptionsMenu.update
|
|
local innerPalettes = OptionsMenu.sgbPalettes
|
|
|
|
local function idAt(menu, index)
|
|
local row = menu.rows and menu.rows[index or 1]
|
|
return type(row) == "table" and row.id or nil
|
|
end
|
|
|
|
-- What the row LIST depends on: whether FULL is selected (it owns the
|
|
-- rows that describe the look), and the two switches that give and take
|
|
-- an engine row -- 3D-BTL, which owns BATTLE LAYOUT, and VR, which hides
|
|
-- both battle rows while it is on. Only the FULL-ness of the voxel level
|
|
-- matters, so stepping 35 to 50 is not a change.
|
|
local function signature()
|
|
return string.format("%s|%s|%s",
|
|
tostring(Voxel.isFull(Pipelines.level("voxel"))),
|
|
tostring(OverworldBattle.enabled()), tostring(VR.enabled()))
|
|
end
|
|
|
|
-- Stamped where the ROWS are built, which is the thing the signature is a
|
|
-- signature OF. Read lazily on the first update instead and a menu opened
|
|
-- before the change and updated after it would compare the new state
|
|
-- against itself and never rebuild.
|
|
local innerNew = OptionsMenu.new
|
|
function OptionsMenu.new(game, opts)
|
|
local menu = innerNew(game, opts)
|
|
menu.dramaticShapeSig = signature()
|
|
return menu
|
|
end
|
|
|
|
function OptionsMenu:update(dt)
|
|
local wasOn = idAt(self, self.index)
|
|
local before = self.dramaticShapeSig or signature()
|
|
inner(self, dt)
|
|
local after = signature()
|
|
self.dramaticShapeSig = after
|
|
if before ~= after then
|
|
local rebuilt = OptionsMenu.new(self.game)
|
|
self.rows = rebuilt.rows
|
|
-- Follow the row the cursor was ON rather than the slot it was in:
|
|
-- 3D-BTL takes BATTLE LAYOUT off the list ABOVE itself, which would
|
|
-- otherwise slide the cursor onto the row under the one just used.
|
|
for i = 1, #self.rows do
|
|
if wasOn and idAt(self, i) == wasOn then self.index = i; break end
|
|
end
|
|
local cancel = #self.rows + 1
|
|
if (self.index or 1) > cancel then self.index = cancel end
|
|
end
|
|
end
|
|
|
|
-- ------- and the mod's own row is red
|
|
--
|
|
-- Why this is a palette zone and not love.graphics.setColor -- twice over
|
|
-- -- is written out in lib/SettingsMenu.lua, next to the code that builds
|
|
-- the palette. The short of it: setColor picks a SHADE on this screen and
|
|
-- the zone picks the COLOR.
|
|
--
|
|
-- Addressed by SLOT, because the row scrolls: it leads the list, so it is
|
|
-- normally the top box, but a player who scrolls past it must not leave a
|
|
-- red band behind on whatever takes its place. Searched by id rather than
|
|
-- assumed to be row 1 for the same reason -- another mod's hook running
|
|
-- after ours could put something above it.
|
|
function OptionsMenu:sgbPalettes(game)
|
|
local zones = innerPalettes and innerPalettes(self, game) or nil
|
|
local scroll = self.scroll or 0
|
|
for slot = 1, OptionRows.VISIBLE do
|
|
local row = self.rows and self.rows[scroll + slot]
|
|
if type(row) == "table"
|
|
and row.id == SettingsMenu.id(SettingsMenu.ROOT) then
|
|
local zone = SettingsMenu.rowZone(game and game.data, slot)
|
|
if zone then
|
|
zones = zones or {}
|
|
zones[#zones + 1] = zone
|
|
end
|
|
break
|
|
end
|
|
end
|
|
return zones
|
|
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()
|
|
|
|
-- ------- shiny Pokemon
|
|
--
|
|
-- ON, always, with no row to switch it off: shininess is a property of the
|
|
-- Pokemon rather than a display mode, and a Pokemon that is shiny in one
|
|
-- player's save and not another's is not a Pokemon, it is a setting.
|
|
--
|
|
-- It rests on a fact the engine already ships. Gen 1 has no shininess of its
|
|
-- own, but it has the four DVs Gen 2 reads to decide it, and
|
|
-- src/pokemon/Stats.lua:90 carries that reading -- the engine's own comment
|
|
-- calls it "the RBY virtual shiny" and says it is there for indicator mods.
|
|
-- So nothing new is stored on a Pokemon and nothing has to migrate: every
|
|
-- save ever made already contains the answer, and this only starts drawing
|
|
-- it. See lib/Shiny.lua for why deriving beats storing.
|
|
--
|
|
-- Three seams, each in its own file with its own reasoning:
|
|
-- ShinyBattle wraps Pokemon.new, which is where every wild, gift,
|
|
-- starter and traded mon is built, so the roll lands before
|
|
-- the sprite is baked
|
|
-- ShinyUI the battle pics' tint and the status page's mark
|
|
-- ShinyFx the arrival sparkle (armed from Stadium.update)
|
|
--
|
|
-- The Stadium models need no seam here at all: their recolour happens at
|
|
-- extraction (lib/StadiumBuild.lua), and the battle simply asks for the
|
|
-- shiny pack.
|
|
ShinyBattle.install()
|
|
ShinyUI.install()
|
|
|
|
-- A save opened for the first time under this mod has shiny Pokemon in it
|
|
-- already -- they always did -- so refresh the cached flag across the party
|
|
-- rather than leaving it absent until each mon next changes.
|
|
mod.events:on("save.loaded", function() ShinyBattle.markParty() end)
|
|
mod.events:on("save.created", function() ShinyBattle.markParty() end)
|
|
|
|
-- ------- the free-roam rungs' inputs and their walk
|
|
--
|
|
-- 1ST and 3RD need two things no other rung does, and each is a named seam.
|
|
-- Both rungs are one rig -- the boom behind the shoulder is a number inside
|
|
-- it (lib/ThirdPerson.lua) -- so both are installed by the same two calls:
|
|
--
|
|
-- FirstPerson.install claims the LOOK inputs the engine ignores: the right
|
|
-- stick's axes (Game:gamepadaxis passes them to Input, which returns early
|
|
-- on anything but the left pair), relative mouse motion (love.mousemoved --
|
|
-- there is no Game handler to wrap; the engine's own callback only feeds
|
|
-- the mouse-as-touch debug path, which stays untouched), the mouse buttons
|
|
-- while the cursor is captured (A and B -- there is no cursor to click UI
|
|
-- with), and any touch that lands off the overlay's controls (a drag on
|
|
-- open screen is the look; the d-pad and buttons still go to
|
|
-- TouchControls, whose own d-pad finger is also read back analog as the
|
|
-- move vector). Every wrap forwards whatever it does not claim, and claims
|
|
-- only while one of the two rungs is actually driving.
|
|
--
|
|
-- FreeMove.install wraps OverworldState:handleInput -- the one choke point
|
|
-- where the grid walk reads the pad, and the same seam the engine's own
|
|
-- Cycling Road pull lives behind. While either drives, the walk is continuous
|
|
-- and camera-relative; the player's logical cell stays synced and every
|
|
-- per-cell consequence still runs through the engine's own machinery
|
|
-- (onStepComplete, checkEdgeExit, checkLedgeHop, checkBoulderPush). The
|
|
-- file argues the whole arrangement.
|
|
FirstPerson.install()
|
|
FreeMove.install()
|
|
|
|
-- ------- the zooms, and the battle camera the player can steer
|
|
--
|
|
-- CamControl claims the wheel, Q/E, the mouse and the touch screen for
|
|
-- whichever camera is actually in front of the player -- the staged
|
|
-- battle's, the third-person boom, or the engine's own survey zoom -- and
|
|
-- forwards everything else. Installed AFTER the two above deliberately: a
|
|
-- wrap installed later is the OUTER one, so a fight gets first refusal on
|
|
-- the mouse and the fingers, which is right, because while one is staged
|
|
-- the free-roam look is not driving.
|
|
CamControl.install()
|
|
|
|
-- ------- SELECT walks the angle ladder
|
|
--
|
|
-- The same step the "3" key makes, on the pad's own button: a phone (and
|
|
-- a controller) has no number row, and SELECT has no overworld job in
|
|
-- Gen 1 -- its work is all in-menu, which this wrap never sees. The seam
|
|
-- is OverworldState:handleInput, the same choke point the free walk
|
|
-- replaced: every gate above it -- menus, dialogs, scripted moves,
|
|
-- transitions -- already decided the overworld owns the buttons, so a
|
|
-- SELECT here is free-roam by construction, exactly like the key. When
|
|
-- the step is refused (mid-warp, no 3D pass) the press falls through to
|
|
-- the engine's own handling, which is a no-op, as ever.
|
|
--
|
|
-- Installed AFTER FreeMove.install, deliberately: its wrap must sit
|
|
-- OUTSIDE the free walk's, or first person -- where FreeMove.tick takes
|
|
-- the frame and never calls further in -- would eat the button, and the
|
|
-- one rung SELECT could not step off of would be 1ST itself.
|
|
do
|
|
local OverworldState = require("src.world.OverworldController")
|
|
if not OverworldState.dramaticShapeSelectHook then
|
|
local inner = OverworldState.handleInput
|
|
function OverworldState:handleInput(...)
|
|
local Game = require("src.core.Game")
|
|
local input = Game.input
|
|
if input and input.wasPressed and input:wasPressed("select") then
|
|
if cycleVoxel(Game) then return end
|
|
end
|
|
return inner(self, ...)
|
|
end
|
|
OverworldState.dramaticShapeSelectHook = true
|
|
end
|
|
end
|
|
|
|
-- ------- the konami code, and everything it turns on
|
|
--
|
|
-- Installed last of the input seams so its handleInput reasoning sits
|
|
-- outside FreeMove's and SELECT's. The detector itself does not live on
|
|
-- handleInput at all -- it reads the fixed step's own press queue, which
|
|
-- is where keyboard, pad, touch and the VR controllers have all already
|
|
-- become the same eight buttons. See lib/Horde.lua.
|
|
Horde.install()
|
|
|
|
-- ------- LET'S GO capture mode
|
|
--
|
|
-- After every other input seam on purpose: while a throw is being aimed
|
|
-- the capture's mouse and touch wraps are the OUTERMOST, so the flick is
|
|
-- read before anything else can claim the pointer -- and outside the aim
|
|
-- they forward every byte untouched. The battle-side wraps (throwBall,
|
|
-- safariAction) and the experience hooks install here too.
|
|
LetsGo.install()
|
|
|
|
-- ------- edge-anchored menus stay in the GB frame while a headset is live
|
|
--
|
|
-- The engine's zoom-aware anchoring (Renderer:setUIAnchor) docks the START
|
|
-- menu to the WINDOW's top-right edge. Both VR screens -- the floating
|
|
-- panel and the Pokedex -- crop the window to the GB frame, so a menu at
|
|
-- the window's edge is cropped away with the border it docked to. The
|
|
-- engine's own answer to "a state composes its screen, keep every element
|
|
-- inside it" is uiAnchorHold, computed per frame from this predicate; a
|
|
-- live headset is exactly that situation for the WHOLE window, so the
|
|
-- predicate answers yes for as long as one is. Held menus blit where they
|
|
-- were drawn in the 160x144 canvas -- the START menu's 9,0 x 11 slot is
|
|
-- already flush with the frame's right edge, which is the right edge of
|
|
-- what the headset sees. Off-headset frames fall through untouched.
|
|
do
|
|
local Game = require("src.core.Game")
|
|
if not Game.dramaticShapeAnchorHold then
|
|
local inner = Game.uiAnchorsHeldInStack
|
|
function Game.uiAnchorsHeldInStack(stack)
|
|
if VR.active() then return true end
|
|
return inner(stack)
|
|
end
|
|
Game.dramaticShapeAnchorHold = true
|
|
end
|
|
end
|
|
|
|
-- 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)
|
|
|
|
-- ------- and the way back out
|
|
--
|
|
-- The engine wipes INTO a battle with one of the original's eight transitions
|
|
-- and cuts straight OUT of it. That cut is between two very different cameras
|
|
-- in this mode, so while voxel mode is on the battle fades out, closes behind
|
|
-- the black, and the map fades up. The two seams it needs -- BattleState:finish
|
|
-- and Renderer:endFrame -- and the reasoning for each live in lib/BattleExit.lua.
|
|
--
|
|
-- Declared as a transitions record rather than a constant in that file, so the
|
|
-- fade is retunable in data exactly like the eight wipes it answers, and a total
|
|
-- conversion can make it as long or as short as its own pacing wants.
|
|
mod.content.transitions:register(BattleExit.ID, {
|
|
frames = BattleExit.FRAMES,
|
|
})
|
|
|
|
BattleExit.install()
|
|
|
|
-- ------- and the hour on the flat world
|
|
--
|
|
-- The clock reaches the diorama through the voxel shader's own tint uniform,
|
|
-- which the 2D tile path never runs -- so with the mode off, the same evening
|
|
-- that fell on the diorama left the flat world at permanent noon. One clock,
|
|
-- two worlds, one of them ignoring it. DayTint paints the same multiply over
|
|
-- the composited flat world, between the world blit and the UI blit; the
|
|
-- reasoning for that exact instant is in the file.
|
|
DayTint.install()
|
|
|
|
-- ------- what time it is
|
|
--
|
|
-- The cycle's clock rides the SAVE SLOT (save.modData, via mod.save): what
|
|
-- time it is in Kanto is a fact about that journey, like where the player is
|
|
-- standing. Written on the engine's save.writing event -- the moment before
|
|
-- the bytes hit disk -- and read back whenever a save is opened or begun. A
|
|
-- save with no clock in it starts at day; that is DayNight.restore's
|
|
-- fallback, and also the DAYTIME row's own default.
|
|
mod.events:on("save.writing", function()
|
|
DayNight.store()
|
|
end)
|
|
|
|
mod.events:on("save.loaded", function()
|
|
DayNight.restore()
|
|
-- a save written before this mod was installed can carry TILT or GBC FX
|
|
-- switched on, and their rows are not there to switch them back off (see
|
|
-- pinEngineFx). Answered here rather than only when the menu opens, so a
|
|
-- player who never opens it is not left playing under one.
|
|
pinEngineFx()
|
|
end)
|
|
|
|
mod.events:on("save.created", function()
|
|
DayNight.restore()
|
|
pinEngineFx()
|
|
end)
|
|
|
|
-- The engine's own time-of-day seam. OverworldState:timeOfDay() is an
|
|
-- eternal "DAY" until a mod answers here; answering it hands the period to
|
|
-- the map.palette hook (ctx.tod) and music.select, so a palette or music
|
|
-- pack keyed to night works with this mod's clock for free. next() first: a
|
|
-- mod loaded before this one that already moved the time keeps its answer.
|
|
mod.hooks:wrap("world.tod", function(next, tod, ctx)
|
|
local out = next(tod, ctx)
|
|
if out ~= tod then return out end
|
|
return DayNight.tod()
|
|
end)
|
|
|
|
mod.exports.version = "1.5.5"
|
|
-- 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
|