From 7046ead648eca18946de41fcb980ba5b684babee Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sun, 26 Jul 2026 17:47:26 -0400 Subject: [PATCH] fix versioning, add sky blue on 75 degree tilt --- CHANGELOG.md | 57 ++++++++++ README.md | 52 +++++++-- lib/Voxel3D.lua | 11 +- lib/VoxelScene.lua | 55 ++++++++- main.lua | 91 ++++++++++++++- manifest.json | 4 +- mod.card | 8 +- tests/dramatic_shape_test.lua | 206 +++++++++++++++++++++++++++++++++- 8 files changed, 463 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d72c32c..a2b074e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,63 @@ long as the thing throwing them is tall. ### Added +- A sky at the 75-degree rung. Pitched that far over, the horizon comes + into frame and a good part of the picture is void, so the void gets + filled instead of reading as the black plate it does at every rung below. + No skybox and no geometry -- it is the colour the scene canvas clears to. + + **Outdoor maps only.** A house, a cave or a gym is a room with a ceiling, + and the void past its walls is the outside of a box rather than open air. + The test is `Map.isOutdoor`, the same one the engine uses for door SFX + and the town map, and the same one `Structures` already asks to decide + whether a map rings with trees. + + The colour is a four-shade ramp shaped like a world palette and run + through `PaletteFX.effectiveColors`, so it answers to the display mode + exactly as the baked terrain does: blue in the colour modes, grey under + GRAY, green under CLASSIC, dark under GBC INV. A hardcoded blue would sit + wrong in every mode that is not a colour mode. It fades across the + approach to the top rung rather than switching at the keypress, so it + arrives with the camera tween. + +- The mod's four controls sit on adjacent keys, and the two that never had + one now have a hotkey at all: + + | key | control | was | + | --- | --- | --- | + | 3 | VOXEL, the camera ladder | 6 | + | 5 | V-GRID, the wireframe | menu only | + | 6 | T-SHIFT, the blur ladder | 9 | + | 7 | V-CURVE, the horizon bend | menu only | + + 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 that "a pipeline can never shadow one". Two of the four wanted keys are + in that set, and V-GRID and V-CURVE own no render pass, so they have no + registry to claim a key from in the first place. The mod wraps + `Game:keypressed` to take the four. Polling the keyboard in `update` + would not do: it fires alongside the engine's handler rather than instead + of it, so 3 would cycle this mode AND the engine's TILT on one press. + + **TILT (3) and GBC FX (5) are no longer reachable by key while this mod + is enabled.** Both are still on the OPTIONS menu. Key 9 is now free. + + So the VOXEL key turns both off itself, on every press. Both fight the + diorama -- TILT is the flat fake of what this mode does for real, GBC FX + a full-screen present pass laid over the top -- and with 3 the only key + that now reaches either, it also has to be the way back from having left + one on. Every press, not just the one that switches the mode on: the + registry's own tilt exclusion covers switching ON, but the press that + cycles the ladder round to OFF would otherwise leave both running with + no key left to clear them. + + The wrapper delegates rather than reimplements: `Pipelines.hotkey` still + applies its own gate and ladder, the settings borrow the same free-roam + gate the voxel pipeline uses, and a screen with its own key handler keeps + the keyboard -- so typing a nickname cannot cycle a render mode behind + the text box. + - `lib/ShadowMap.lua`: the scene rendered once from the sun into an orthographic depth map, which the main pass then samples per fragment. What the sun cannot see is in shadow, whatever surface it is, so a diff --git a/README.md b/README.md index 6210320..596adea 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,37 @@ overworld draws differently. | control | does | | --- | --- | -| `6`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → OFF (camera pitch) | -| `9`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) | -| the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel | -| the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon | +| `3`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → OFF (camera pitch) | +| `5`, or the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel | +| `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) | +| `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon | + +The four keys sit together, and two of them are taken off the engine: `3` +was **TILT** and `5` was **GBC FX**. Neither is reachable by key while this +mod is enabled, and both are still on the OPTIONS menu. Every key is +free-roam only, and a screen that handles its own keys keeps them, so +nothing here fires while a menu or a text box has focus. + +**`3` also turns TILT and GBC FX off**, on every press — including the one +that cycles the ladder back round to OFF. Both fight the diorama: TILT is +the flat fake of what this mode does for real, and GBC FX lays a +full-screen present pass over the top of it. Since `3` is now the only key +that reaches either, it is also the way back from having left one on. 75 degrees is a rung the engine's flat **TILT** could not have: it projects the world canvas as one rigid plane, which degenerates into a horizon line down there, where real geometry only gets more of itself to show. +That rung is also the one that gets a **sky**. Pitched that far over the +horizon comes into frame, so the void behind the diorama is filled rather +than left as the black plate it reads as at every rung below — enough to +stand the world under something without a skybox to draw. Outdoors only: a +house, a cave or a gym is a room with a ceiling, and the void past its +walls is the outside of a box, not open air. The colour follows the display +mode like everything else here — blue in the colour modes, grey under +GRAY, green under CLASSIC — and fades in with the camera tween rather than +snapping on at the keypress. + V-CURVE is the *curved world* — every vertex pushed down by the square of its horizontal distance from the camera's focus, so the ground under you stays flat and the map bends away over a near horizon. It is not a fisheye: @@ -45,9 +67,8 @@ vanilla path. This is a *rendering pipeline* mod (see `docs/modding.md`). `main.lua` registers two records in the engine's `render_pipelines` registry and -writes almost nothing else — the ladder, the options rows, the hotkeys, -persistence and the free-roam gate are all engine plumbing driven from -those records: +writes almost nothing else — the ladder, the options rows, persistence and +the free-roam gate are all engine plumbing driven from those records: - **`voxel`** — a `drawWorld` pipeline. Replaces the world pass with a rendered 3D scene and returns it as one window-resolution canvas. @@ -93,9 +114,22 @@ voxel and shell counts are what `lib/Buildings.lua` is checked against. ## Engine seams it relies on -The mod reads a few engine internals (hence `"permissions": -["engine_internals"]`), all of them deliberate read-only seams: +The mod reaches into a few engine internals (hence `"permissions": +["engine_internals"]`). All but the last are read-only: +- `Game:keypressed` — **wrapped, not read.** The only thing here that + changes engine behaviour, and the one seam worth arguing about. The + engine answers its own display keys and returns before a mod's pipeline + is offered the key, expressly so a pipeline cannot shadow one — so `3` + (TILT) and `5` (GBC FX) cannot be claimed the documented way, and V-GRID + and V-CURVE own no render pass and have no registry to claim from at all. + Only `6` arrives by the supported route. Polling the keyboard instead + would fire *alongside* the engine's handler rather than instead of it, so + `3` would cycle both this mode and TILT on one press. The wrapper + delegates everything it can: `Pipelines.hotkey` still applies its own + gate and ladder, the settings borrow the voxel pipeline's free-roam gate, + unclaimed keys pass straight through, and a screen with its own handler + keeps the keyboard. - `Player:pose()` / `NPC:pose()` — the sheet, position, facing and step phase a frame renders to, so 2D and 3D can never disagree about a pose. - `SpriteRenderer:resolveImage()` and `SpriteRenderer.STAND/WALK` — the diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index e9a8869..edff87f 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -338,7 +338,10 @@ end -- Begin the 3D pass into a `w` x `h` pixel canvas centred on world -- (cx, cy), covering `vw` x `vh` world pixels. Returns false when the pass -- could not start, in which case the caller must not call endScene. -function Voxel3D.beginScene(w, h, cx, cy, vw, vh) +-- `sky` is an optional {r, g, b, a} in 0..1 to clear the void to, for the +-- pitch where the horizon is in frame (VoxelScene.skyFor). nil leaves the +-- void transparent, which is what every rung below it wants. +function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky) -- the wireframe variant when the player has it on AND it built; either -- answer falls through to the plain scene rather than to no scene local grid = VoxelGrid.enabled() @@ -361,7 +364,11 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh) pcall(love.graphics.setCanvas) return false end - love.graphics.clear(0, 0, 0, 0, true, true) + if sky then + love.graphics.clear(sky[1], sky[2], sky[3], sky[4] or 1, true, true) + else + love.graphics.clear(0, 0, 0, 0, true, true) + end love.graphics.setDepthMode("lequal", true) -- models mirror on X for right-facing and alternate walk steps, which -- flips winding; hidden faces are already culled at build time, so there diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index 6fb4351..577165f 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -20,7 +20,9 @@ local VoxelModels = V.require("VoxelModels") local SpriteBillboards = V.require("SpriteBillboards") local TileShape = V.require("TileShape") local TerrainAtlas = V.require("TerrainAtlas") +local Voxel = V.require("VoxelState") local PaletteFX = require("src.render.PaletteFX") +local Map = require("src.world.Map") local VoxelScene = {} @@ -46,6 +48,55 @@ end VoxelScene._modeColors = modeColors -- named for the suite +-- ------------------------------------------------------------------ sky -- +-- +-- At the top rung the camera is pitched far enough over that the horizon +-- comes into frame and a good part of the picture is void -- so the void +-- becomes the sky, and the diorama reads as standing under something +-- rather than floating on a black plate. Below that rung the camera looks +-- down steeply enough that the horizon is off-screen, and painting the +-- void only tints the gaps between meshes, so it stays transparent. +-- +-- INDOORS THERE IS NO SKY. A house, a cave or a gym is a room with a +-- ceiling, and the void past its walls is the outside of a box, not open +-- air. Map.isOutdoor is the same test the engine uses for door SFX and the +-- town map, and the same one Structures already asks to decide whether a +-- map rings with trees. +-- +-- The colour is a four-shade ramp shaped like a world palette so the +-- display mode can transform it exactly like one: GRAY gets a grey sky, +-- CLASSIC a green one, GBC INV a dark one, and the colour modes the blue. +-- A hardcoded blue would sit wrong in every non-colour mode -- the same +-- mismatch the terrain bake had. +local SKY_SHADES = { { 222, 242, 255 }, { 135, 196, 240 }, + { 64, 120, 192 }, { 16, 40, 80 } } +local SKY_SHADE = 2 -- the ramp's "sky" proper; 1 is its highlight + +-- fade across the approach to the top rung, so the sky arrives with the +-- camera tween instead of popping in on the keypress +local function skyStrength(angleRad) + local deg = math.deg(angleRad or 0) + local from = Voxel.ANGLES_DEG[Voxel.MAX_LEVEL] or 50 -- the rung below + local to = Voxel.ANGLES_DEG[Voxel.MAX_LEVEL + 1] or 75 -- the top rung + if to <= from then return deg >= to and 1 or 0 end + local t = (deg - from) / (to - from) + if t < 0 then return 0 end + if t > 1 then return 1 end + return t +end + +local function skyFor(map) + if not (map and map.def and Map.isOutdoor(map.def)) then return nil end + local t = skyStrength(Voxel.angle) + if t <= 0 then return nil end + local shades = PaletteFX.effectiveColors(SKY_SHADES) or SKY_SHADES + local c = shades[SKY_SHADE] or SKY_SHADES[SKY_SHADE] + return { c[1] / 255, c[2] / 255, c[3] / 255, t } +end + +VoxelScene._skyFor = skyFor -- named for the suite +VoxelScene._skyStrength = skyStrength + -- A facing is a yaw about +Y. Models are carved facing +Z (south, "down"), -- and Mat4.rotateY(+90 deg) maps +Z to +X, so east is +90 and west is -90. -- Nothing is mirrored: yawing to face east shows the model's east side, @@ -403,7 +454,9 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) local posed = posesOf(state, spriteColors) castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor) - if not Voxel3D.beginScene(w, h, cx, cy, vw, vh) then return nil end + if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then + return nil + end Voxel3D.draw(terrain, atlasFor(state.map), nil) for i, nb in ipairs(state.neighbors or {}) do diff --git a/main.lua b/main.lua index a78783e..186ef16 100644 --- a/main.lua +++ b/main.lua @@ -92,8 +92,9 @@ end mod.content.render_pipelines:register("voxel", { label = "VOXEL", levels = Voxel.ANGLE_LABELS, - -- 2/3/4/5 are the engine's own display hotkeys - hotkey = "6", + -- 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, @@ -163,7 +164,9 @@ mod.content.render_pipelines:register("voxel", { mod.content.render_pipelines:register("tiltshift", { label = "T-SHIFT", levels = TiltShift.LABELS, - hotkey = "9", + -- 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) @@ -202,6 +205,88 @@ for i, entry in ipairs(SETTINGS) do 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) diff --git a/manifest.json b/manifest.json index eae4ca9..e0b25f8 100644 --- a/manifest.json +++ b/manifest.json @@ -6,12 +6,12 @@ "entry": "main.lua", "profile": "content", "category": "GRAPHICS", - "game_version": "<2.0.0", + "game_version": "0.0.0-dev || >=0.1.28 <2.0.0", "priority": 100, "dependencies": [], "optional_dependencies": [], "conflicts": [], "permissions": ["engine_internals"], "affects_link": false, - "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass. Registers two render pipelines (hotkeys 6 and 9); presentational only." + "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass. Registers two render pipelines and claims hotkeys 3, 5, 6 and 7 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only." } diff --git a/mod.card b/mod.card index 7b442b5..9e5f37c 100644 --- a/mod.card +++ b/mod.card @@ -10,10 +10,14 @@ return { "with VOXEL on, the overworld draws as 3D geometry instead of flat tiles", "occlusion comes from a depth buffer rather than a y-sort, so buildings really hide what is behind them", "VOXEL and the engine's TILT are mutually exclusive -- turning one on switches the other off", + "hotkeys 3 and 5 are taken over from the engine's TILT and GBC FX; both remain on the OPTIONS menu", + "the VOXEL key (3) turns TILT and GBC FX off on every press -- both fight the diorama, and 3 is now the only key that reaches either", }, added = { - "VOXEL options row and hotkey 6 (OFF / 15 / 35 / 50 degrees)", - "T-SHIFT options row and hotkey 9 (OFF / 1 / 2 / 3), the miniature blur", + "VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 degrees)", + "T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur", + "V-GRID on hotkey 5 and V-CURVE on hotkey 7", + "a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode", "carved voxel models for 67 overworld sprites (assets/voxels/)", "a hand-authored tile shape profile (data/voxel_heights.lua) a mod can extend", }, diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 12d655b..c51ddc9 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -28,8 +28,8 @@ T.check(type(defs.tiltshift) == "table", "the tiltshift pipeline is registered") T.eq(defs.voxel.label, "VOXEL", "voxel carries its options-row label") T.eq(defs.tiltshift.label, "T-SHIFT", "tiltshift carries its options-row label") -T.eq(defs.voxel.hotkey, "6", "voxel claims hotkey 6") -T.eq(defs.tiltshift.hotkey, "9", "tiltshift claims hotkey 9") +T.eq(defs.voxel.hotkey, "3", "voxel claims hotkey 3") +T.eq(defs.tiltshift.hotkey, "6", "tiltshift claims hotkey 6") T.check(type(defs.voxel.drawWorld) == "function", "voxel is a world pipeline (drawWorld)") T.check(type(defs.tiltshift.worldPresent) == "function", @@ -501,6 +501,208 @@ T.eq(modeColors(function() return nil end), nil, "a map with no world palette bakes no colour, as on the flat path") T.eq(modeColors(nil), nil, "and a pipeline given no paletteFor at all is safe") +-- ------- the hotkeys this mod claims +-- +-- 3 VOXEL cycle the camera ladder +-- 5 V-GRID toggle the wireframe +-- 6 T-SHIFT cycle the blur ladder +-- 7 V-CURVE cycle the horizon bend +-- +-- Only 6 reaches the pipeline registry the documented way. Game:keypressed +-- answers the engine's own display keys first and returns -- 3 is TILT and +-- 5 is GBC FX -- expressly so a pipeline cannot shadow one, and 7 belongs +-- to settings that own no pass and so have no registry to claim from. The +-- mod therefore wraps Game:keypressed, and these pin what that wrapper is +-- allowed to take. + +local Game = require("src.core.Game") +Pipelines.reset() +Pipelines.setLevel("voxel", 0) +Pipelines.setLevel("tiltshift", 0) + +-- a free-roam game: Zoom.gateOK wants the top screen to BE the overworld, +-- not transitioning and not running a script +local keyGame +local overworld = { transitioning = false } +keyGame = { + overworld = overworld, + stack = { top = function() return overworld end }, + save = { options = { modOptions = {} } }, + mods = { modOptions = {} }, + writeOptions = function() end, +} + +local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid") +local Curve = run.loader.exports.DRAMATIC_SHAPE.lib.require("WorldCurve") + +Game.keypressed(keyGame, "3") +T.eq(Pipelines.level("voxel"), 1, "3 cycles the voxel camera ladder") +Game.keypressed(keyGame, "3") +T.eq(Pipelines.level("voxel"), 2, "and keeps climbing it") + +Game.keypressed(keyGame, "6") +T.eq(Pipelines.level("tiltshift"), 1, "6 cycles the tilt-shift blur") + +Game.keypressed(keyGame, "5") +T.eq(VoxelGrid.setting:get(), true, "5 toggles V-GRID on") +Game.keypressed(keyGame, "5") +T.eq(VoxelGrid.setting:get(), false, "and off again") + +local curveBefore = Curve.setting:get() +Game.keypressed(keyGame, "7") +T.neq(Curve.setting:get(), curveBefore, "7 cycles V-CURVE") + +-- 3 also clears the two engine modes it displaced. Without this a player +-- who left TILT or GBC FX on before enabling the mod has no key left to +-- turn them off with, and both fight the diorama -- TILT is the flat fake +-- of what this mode does for real, GBC FX a present pass over the top. +local GBCFX = require("src.render.GBCFX") +Tilt.setLevel(2) +GBCFX.setLevel(3) +keyGame.save.options.tilt = 2 +keyGame.save.options.gbcfx = 3 + +Game.keypressed(keyGame, "3") +T.eq(keyGame.save.options.tilt, 0, "3 turns TILT off in the save") +T.eq(Tilt.level, 0, "and on the live renderer") +T.eq(keyGame.save.options.gbcfx, 0, "3 turns GBC FX off in the save") +T.eq(GBCFX.level, 0, "and on the live renderer") + +-- Every press, not just the one that switches the mode on. This is the +-- half the registry does NOT cover: its tilt exclusion fires when a world +-- pipeline takes the pass, so a press that switches voxel ON would clear +-- TILT with or without us. Park the ladder on its top rung and turn both +-- back on, so the single press under test is the one that wraps to OFF -- +-- where nothing else is going to clear them. +Pipelines.setLevel("voxel", Pipelines.maxLevel("voxel")) +Tilt.setLevel(3) +GBCFX.setLevel(4) +keyGame.save.options.tilt = 3 +keyGame.save.options.gbcfx = 4 + +Game.keypressed(keyGame, "3") +T.eq(Pipelines.level("voxel"), 0, "the press wraps the ladder back to OFF") +T.eq(keyGame.save.options.tilt, 0, "the press that wraps to OFF still clears TILT") +T.eq(Tilt.level, 0, "with the renderer agreeing") +T.eq(keyGame.save.options.gbcfx, 0, "and still clears GBC FX") +T.eq(GBCFX.level, 0, "with the renderer agreeing there too") + +-- but 6 must not: T-SHIFT is a post-process that composes with TILT, and +-- the registry deliberately leaves it alone +Tilt.setLevel(2) +keyGame.save.options.tilt = 2 +Game.keypressed(keyGame, "6") +T.eq(keyGame.save.options.tilt, 2, "6 leaves TILT alone -- the blur composes with it") +Tilt.setLevel(0) +keyGame.save.options.tilt = 0 + +-- the engine's own keys the mod did NOT claim must still reach it: 4 is +-- ZOOM, and taking it would be a bug rather than a feature +local zoomKeyReached = false +local realZoomGate = require("src.render.Zoom").gateOK +require("src.render.Zoom").gateOK = function() zoomKeyReached = true; return false end +Game.keypressed(keyGame, "4") +require("src.render.Zoom").gateOK = realZoomGate +T.check(zoomKeyReached, "a key this mod does not claim still reaches the engine") + +-- A screen with its own key handler owns the keyboard: typing a nickname +-- must not cycle a render mode behind the text box. +local gridBefore = VoxelGrid.setting:get() +local voxelBefore = Pipelines.level("voxel") +local typed = {} +local menu = { onKeyPressed = function(_, k) typed[#typed + 1] = k end } +keyGame.stack.top = function() return menu end +for _, k in ipairs({ "3", "5", "6", "7" }) do Game.keypressed(keyGame, k) end +T.eq(#typed, 4, "every claimed key goes to a screen that handles keys itself") +T.eq(VoxelGrid.setting:get(), gridBefore, "V-GRID is untouched while a screen has focus") +T.eq(Pipelines.level("voxel"), voxelBefore, "and so is the voxel ladder") + +-- and the free-roam gate the engine applies to its own display keys applies +-- to the settings too: no flipping the wireframe mid-cutscene +keyGame.stack.top = function() return overworld end +overworld.transitioning = true +local midWarp = VoxelGrid.setting:get() +Game.keypressed(keyGame, "5") +T.eq(VoxelGrid.setting:get(), midWarp, "V-GRID refuses mid-transition, as the mode does") +overworld.transitioning = false + +-- ------- the sky at the top rung +-- +-- At 75 degrees the camera is pitched far enough over that the horizon is +-- in frame, so the void behind the diorama becomes a sky rather than the +-- black plate it reads as at every rung below. Outdoors only: a house or a +-- cave is a room with a ceiling, and the void past its walls is the +-- outside of a box, not open air. + +local Voxel = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") +local skyFor = VoxelScene._skyFor +local skyStrength = VoxelScene._skyStrength +T.check(type(skyFor) == "function", "the scene exposes its sky resolve") + +local outside = { def = { id = "PALLET_TOWN", tileset = "OVERWORLD" } } +local inside = { def = { id = "REDS_HOUSE_1F", tileset = "HOUSE" } } +local TOP = math.rad(Voxel.ANGLES_DEG[Voxel.MAX_LEVEL + 1]) + +-- the ladder, by angle: only the top rung paints anything +for level = 0, Voxel.MAX_LEVEL do + Voxel.angle = math.rad(Voxel.ANGLES_DEG[level + 1]) + local sky = skyFor(outside) + if level == Voxel.MAX_LEVEL then + T.check(sky ~= nil, "the 75-degree rung paints a sky outdoors") + T.eq(sky[4], 1, "and paints it at full strength") + else + T.eq(sky, nil, "rung " .. level .. " leaves the void alone") + end +end + +-- indoors, never -- at any rung, including the top one +for level = 0, Voxel.MAX_LEVEL do + Voxel.angle = math.rad(Voxel.ANGLES_DEG[level + 1]) + T.eq(skyFor(inside), nil, "an interior has no sky at rung " .. level) +end +Voxel.angle = TOP +T.eq(skyFor(inside), nil, "not even at 75 degrees, where outdoors would") + +-- a map record with nothing to ask is not a crash +T.eq(skyFor(nil), nil, "no map, no sky") +T.eq(skyFor({}), nil, "a map with no def is not an outdoor map") + +-- it fades in with the camera tween rather than popping on the keypress +T.eq(skyStrength(math.rad(50)), 0, "the rung below the top is still skyless") +T.eq(skyStrength(TOP), 1, "the top rung is full sky") +local mid = skyStrength(math.rad(62.5)) +T.check(mid > 0 and mid < 1, "and the tween between them is partial") +T.check(skyStrength(math.rad(70)) > skyStrength(math.rad(60)), + "strengthening as the camera pitches over") +T.eq(skyStrength(math.rad(15)), 0, "a shallow pitch paints nothing at all") + +-- the colour answers to the display mode, exactly as the terrain does: a +-- hardcoded blue would sit wrong in the modes that are not colour modes +Voxel.angle = TOP +local function skyRGB(mode) + local prev = PaletteFX.mode + PaletteFX.mode = mode + local c = skyFor(outside) + PaletteFX.mode = prev + return c +end + +local blue = skyRGB("gbc") +T.check(blue[3] > blue[1], "in a colour mode the sky is blue -- more blue than red") + +local grey = skyRGB("og") +T.check(math.abs(grey[1] - grey[2]) < 1e-6 and math.abs(grey[2] - grey[3]) < 1e-6, + "GRAY paints a grey sky, not a blue one") + +local green = skyRGB("classic") +T.check(green[2] > green[1] and green[2] > green[3], + "CLASSIC paints a green sky, matching its green world") + +T.check(skyRGB("gbc_inv")[3] ~= blue[3], + "GBC INV does not paint the same sky as GBC") + +Voxel.angle = 0 + Pipelines.reset() run.release()