added FULL menu option for voxel mod
DramaticShape · 2026-07-28 ·
parent 6280cdffe7
added FULL menu option for voxel mod
Changed files
CHANGELOG.md | 38 ++++++++++++++++
data/battle_arenas.lua | 6 ++-
lib/BattleArena.lua | 9 +++-
lib/BattleScene.lua | 8 ++++
lib/VoxelGrid.lua | 12 +++++
lib/VoxelState.lua | 53 +++++++++++++++++++++-
main.lua | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
manifest.json | 6 ++-
tests/arena_pick.lua | 70 ++++++++++++++++++++++++++---
tests/dramatic_shape_test.lua | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
10 files changed, 478 insertions(+), 26 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 495b14f..a814303 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,43 @@
# Changelog
+## 1.2.0
+
+### Added
+
+- **A `FULL` rung on the VOXEL row**, directly after `OFF`. One choice that
+ puts the whole mode in its intended state -- the 50-degree camera, the
+ miniature blur at maximum, the horizon flat, the view fitted, and battles
+ on the map -- rather than making a player assemble it from four rows.
+
+ While it is selected, every row it owns comes OFF the menu: V-GRID,
+ V-CURVE, 3D-BTL and T-SHIFT. A row that no longer decides anything is
+ worse than no row. Stepping onto or off `FULL` rebuilds the open menu in
+ place, so the rows leave and return under the cursor instead of waiting
+ for the menu to be reopened.
+
+ It applies its settings when the row ARRIVES at `FULL`, not every frame:
+ holding them would make the zoom keys and the wheel dead while it was on.
+ Leaving it deliberately undoes nothing -- reverting would discard whatever
+ had been changed since.
+
+### Changed
+
+- **Hotkey `3` walks the angle rungs only and steps over `FULL`.** The key is
+ a display-mode cycler -- it should change the camera and nothing else --
+ and `FULL` reaches in and rewrites four other settings. Landing on it
+ mid-walk would silently push the blur to maximum and flatten the horizon
+ with nothing on screen saying a keypress had done it. `FULL` stays on the
+ OPTIONS row, where a preset that changes other rows belongs.
+
+ A press FROM `FULL` goes to `75`. `FULL` is already the 50-degree camera,
+ so stepping to the rung of that name would look like the key had done
+ nothing.
+
+- **The mode's four options are one block in the menu.** The engine splices
+ a pipeline row in beside TILT and lands a mod's own rows at the end of the
+ list, which had these four in two places with unrelated engine rows
+ between them. The settings now follow the pipeline rows directly.
+
## 1.1.0
### Added
diff --git a/data/battle_arenas.lua b/data/battle_arenas.lua
index f9b1a26..df2d343 100644
--- a/data/battle_arenas.lua
+++ b/data/battle_arenas.lua
@@ -139,8 +139,10 @@ return {
-- ------- the remaining routes
["ROUTE_19"] = { x = 8, y = 6, shape = "narrow" },
- ["ROUTE_20"] = { x = 53, y = 3, shape = "wide" },
- ["ROUTE_21"] = { x = 16, y = 4, shape = "wide" },
+ -- the two surf routes fight AFLOAT, in the middle of their own sea rather
+ -- than on the rim of beach the land search would otherwise find
+ ["ROUTE_20"] = { x = 23, y = 7, shape = "wide" },
+ ["ROUTE_21"] = { x = 8, y = 46, shape = "wide" },
["ROUTE_22"] = { x = 35, y = 7, shape = "wide" },
["ROUTE_23"] = { x = 4, y = 36, shape = "wide" },
["ROUTE_24"] = { x = 13, y = 15, shape = "wide" },
diff --git a/lib/BattleArena.lua b/lib/BattleArena.lua
index e215b8c..20f0e04 100644
--- a/lib/BattleArena.lua
+++ b/lib/BattleArena.lua
@@ -291,7 +291,14 @@ function BattleArena.find(map, fromX, fromY, surfing)
host = (ok and other) or nil
end
if shape and host then
- local grid, gw = openGrid(host, surfing)
+ -- An authored spot is checked with WATER COUNTING AS GROUND, whatever
+ -- the player is doing. The surfing test exists to stop the automatic
+ -- search staging a walker's fight out at sea; an authored entry was
+ -- chosen and looked at by a person, so if it is on water that is the
+ -- point of it -- the surf routes fight in the middle of their own
+ -- ocean rather than on a scrap of beach at the edge of the map. Land
+ -- entries are unaffected: land passes the test either way.
+ local grid, gw = openGrid(host, true)
if fits(grid, gw, pick.x, pick.y, shape.w, shape.h) then
local arena = place(shape, pick.x, pick.y)
arena.map = host
diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua
index 8d80615..7cb945f 100644
--- a/lib/BattleScene.lua
+++ b/lib/BattleScene.lua
@@ -40,6 +40,7 @@ local TerrainAtlas = V.require("TerrainAtlas")
local VoxelScene = V.require("VoxelScene")
local BattleCam = V.require("BattleCam")
local BattleBillboard = V.require("BattleBillboard")
+local VoxelGrid = V.require("VoxelGrid")
local PaletteFX = require("src.render.PaletteFX")
local BattleScene = {}
@@ -333,6 +334,12 @@ function BattleScene.render(state, arena, textures, token)
-- free-roam world it shares this module with keeps its own weight
local sunWas = Voxel3D.SHADOW_ALPHA
Voxel3D.SHADOW_ALPHA = BattleScene.SHADOW_ALPHA
+ -- and the wireframe is ON for a battle whatever the V-GRID row says. The
+ -- arena is a staged shot rather than the world being walked through, and
+ -- the seams are what make it read as built rather than photographed. Forced
+ -- through the override so the player's own row is never written to.
+ local gridWas = VoxelGrid.override
+ VoxelGrid.override = true
local out = nil
local ok, err = pcall(function()
-- its own canvas slot: this renders at the window's pixel size and the
@@ -408,6 +415,7 @@ function BattleScene.render(state, arena, textures, token)
-- renders (the free-roam pipeline, next frame) must find the orbit back
Voxel3D.camera = nil
Voxel3D.SHADOW_ALPHA = sunWas
+ VoxelGrid.override = gridWas
if not ok then
-- endScene never ran, so the canvas is still bound and the shader still
-- set; put the frame back the way it was found before rethrowing
diff --git a/lib/VoxelGrid.lua b/lib/VoxelGrid.lua
index 1f68bab..1fb7ce7 100644
--- a/lib/VoxelGrid.lua
+++ b/lib/VoxelGrid.lua
@@ -48,7 +48,19 @@ VoxelGrid.WIDTH = 1.0
VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL,
{ false, true }, { "OFF", "ON" })
+-- A pass that needs the wireframe whatever the player left the row on sets
+-- this for the length of its own draw and puts it back after. nil means
+-- "follow the setting", which is every frame outside such a pass.
+--
+-- The overworld battle is the one user: a fight is a STAGED shot, not the
+-- world being walked around in, and the seams are what make it read as
+-- constructed rather than as a photograph of somewhere. The row still owns
+-- what free-roam looks like, and is not written to -- switching the mode off
+-- mid-battle would silently rewrite the player's own setting.
+VoxelGrid.override = nil
+
function VoxelGrid.enabled()
+ if VoxelGrid.override ~= nil then return VoxelGrid.override end
return VoxelGrid.setting:get() and true or false
end
diff --git a/lib/VoxelState.lua b/lib/VoxelState.lua
index 38a920b..ca85fac 100644
--- a/lib/VoxelState.lua
+++ b/lib/VoxelState.lua
@@ -23,10 +23,59 @@
local Voxel = {}
-Voxel.ANGLES_DEG = { 0, 15, 35, 50, 75 }
-Voxel.ANGLE_LABELS = { "OFF", "15", "35", "50", "75" }
+-- FULL is a PRESET, not another angle: one rung that puts the whole mode in
+-- its intended state at once -- this camera, the miniature blur at full, the
+-- horizon flat, the view fitted -- so a player who wants "the diorama" picks
+-- it rather than assembling it from four rows. It sits directly after OFF
+-- because that is the order those two get used in.
+--
+-- Its ANGLE is 50 degrees, the same as the rung of that name. The duplicate
+-- in the table is deliberate: the ladder is a list of what each rung LOOKS
+-- like, and two rungs may look the same while meaning different things.
+Voxel.ANGLES_DEG = { 0, 50, 15, 35, 50, 75 }
+Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75" }
Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1
+-- the rung FULL sits on, so nothing has to hunt for it by label
+Voxel.FULL_LEVEL = 1
+
+function Voxel.isFull(level)
+ return (level or Voxel.level) == Voxel.FULL_LEVEL
+end
+
+-- ------- what the hotkey walks
+--
+-- The ANGLE rungs only, with FULL left out. The key is a display-mode
+-- cycler: pressing it should change the camera and nothing else, and FULL
+-- reaches in and rewrites four other settings. Landing on it by accident,
+-- mid-walk, would silently turn the blur to maximum and flatten the horizon
+-- with no indication that a keypress had done so. FULL stays on the OPTIONS
+-- row, which is where a preset that changes other rows belongs.
+Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5 } -- OFF, 15, 35, 50, 75
+
+-- The rung a press moves to from `level`.
+--
+-- A level that is not on the key's path -- FULL, reached from the menu --
+-- steps on from whichever rung shows the SAME camera it does. FULL is 50
+-- degrees, so a press from it goes to 75 rather than back to 50, and the key
+-- never appears to do nothing.
+function Voxel.nextHotkeyLevel(level)
+ level = level or Voxel.level
+ local order = Voxel.HOTKEY_ORDER
+ local at = nil
+ for i, rung in ipairs(order) do
+ if rung == level then at = i break end
+ end
+ if not at then
+ local deg = Voxel.ANGLES_DEG[level + 1]
+ for i, rung in ipairs(order) do
+ if Voxel.ANGLES_DEG[rung + 1] == deg then at = i break end
+ end
+ end
+ if not at then return order[1] end
+ return order[at % #order + 1]
+end
+
Voxel.level = 0
Voxel.angle = 0
Voxel.from = 0
diff --git a/main.lua b/main.lua
index b77e555..d07292b 100644
--- a/main.lua
+++ b/main.lua
@@ -79,6 +79,12 @@ local VoxelGrid = V.require("VoxelGrid")
local WorldCurve = V.require("WorldCurve")
local OverworldBattle = V.require("OverworldBattle")
+-- Forward declaration: the voxel pipeline's update hook (registered below)
+-- calls this, and it is defined further down with the settings it drives.
+-- Declared rather than left global -- a mod writing to _G would leak into
+-- every other mod's namespace.
+local applyFull
+
-- The last VOID FILL the terrain was meshed under; see the update hook.
-- The scene canvas's size, in FRAMEBUFFER PIXELS.
--
@@ -145,6 +151,11 @@ mod.content.render_pipelines:register("voxel", {
-- pump slice -- so stepping out of a door lands on terrain that is
-- already there instead of a flat flash.
update = function(dt, level)
+ -- FULL is a preset, so it is applied ON THE PRESS rather than held every
+ -- frame: it SETS the other rows and then leaves them alone. Holding them
+ -- would make the zoom keys and the wheel dead while the mode was on, and
+ -- would fight anyone who changed one deliberately.
+ applyFull(level)
Voxel.update(dt, level)
-- The overworld battle rides this hook rather than owning a pipeline of
-- its own, because it owns no pass of the FRAME: it draws under a battle
@@ -233,6 +244,46 @@ mod.content.render_pipelines:register("tiltshift", {
-- instead -- see ModSetting for where they persist and how the two rows
-- each ends up on stay in step.
+-- ------- the FULL preset
+--
+-- Everything the mode wants switched to at once. Applied when the VOXEL row
+-- ARRIVES at FULL and not again, so the player can still move the camera or
+-- the zoom afterwards -- it is a starting point, not a lock.
+--
+-- Leaving FULL deliberately does NOT undo any of it. A preset that reverted
+-- would throw away whatever the player had changed since, and "put it back
+-- how it was" is not a thing this can know.
+local fullWas = nil
+
+applyFull = function(level)
+ local isFull = Voxel.isFull(level)
+ local was = fullWas
+ fullWas = isFull
+ if not isFull or was == true or was == nil then return end
+
+ local Game = require("src.core.Game")
+ local Pipelines = require("src.render.Pipelines")
+ local Zoom = require("src.render.Zoom")
+ local opts = Game.save and Game.save.options
+ if not opts then return end
+
+ -- the miniature blur at its strongest: FULL is the diorama look, and the
+ -- tilt-shift is most of what makes it read as a model
+ Pipelines.setLevel("tiltshift", Pipelines.maxLevel("tiltshift"))
+ Pipelines.syncOptions(opts)
+ -- the horizon flat. The curve bends the world away from a walking player,
+ -- which fights a fixed diorama framing
+ WorldCurve.setting:setIndex(1, Game)
+ -- and the view fitted to the window
+ opts.zoom = 0
+ Zoom.applyOptions(opts)
+ -- battles on the map too: FULL means the whole mode, and a fight is where
+ -- half of it is spent. Set rather than forced -- the row is gone from the
+ -- menu while FULL is on, but a save that already had it off gets it on.
+ OverworldBattle.setting:setIndex(1, Game)
+ if Game.writeOptions then pcall(Game.writeOptions, Game) end
+end
+
local SETTINGS = {
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
{ WorldCurve.setting,
@@ -250,7 +301,7 @@ mod.options:define(schema)
-- ------- this mod's hotkeys
--
--- 3 VOXEL cycle the camera ladder (was 6)
+-- 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)
@@ -299,7 +350,20 @@ do
-- 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
+ -- 3 walks the ANGLE rungs and steps over FULL (Voxel.HOTKEY_ORDER),
+ -- so the registry's plain "advance one and wrap" is not what it
+ -- wants; 6 still is. The gate is the registry's own either way.
+ local stepped = false
+ if key == "3" then
+ if Pipelines.canToggle("voxel", top, self.overworld) then
+ Pipelines.setLevel("voxel",
+ Voxel.nextHotkeyLevel(Pipelines.level("voxel")))
+ stepped = true
+ end
+ else
+ stepped = Pipelines.hotkey(key, top, self.overworld) and true
+ end
+ if stepped then
Pipelines.syncOptions(self.save.options)
-- 3 is the key that used to turn TILT on and sits next to the one
-- that used to turn GBC FX on, and this mod has taken both away.
@@ -336,15 +400,54 @@ do
end
end
+-- ------- the mode's rows, kept together
+--
+-- The engine splices a pipeline's row in beside TILT, because a display mode
+-- belongs with the other display modes; a mod's own ui.options.rows
+-- additions land at the END of the list. That left this mod's four rows in
+-- two places with unrelated engine rows between them, which reads as two
+-- unrelated features rather than one mode with settings.
+--
+-- So the plain settings are inserted directly after the last of this mod's
+-- PIPELINE rows instead of appended. Nothing else moves: the block lands
+-- where the engine already decided display modes go.
+local function insertGrouped(out, extra)
+ local anchor = nil
+ for i, row in ipairs(out) do
+ local id = type(row) == "table" and row.id
+ if id == "pipeline:voxel" or id == "pipeline:tiltshift" then anchor = i end
+ end
+ if not anchor then
+ for _, row in ipairs(extra) do out[#out + 1] = row end
+ return out
+ end
+ for i, row in ipairs(extra) do table.insert(out, anchor + i, row) end
+ return out
+end
+
+-- FULL owns every one of those settings, so while it is selected they are
+-- taken off the menu rather than left to be changed under it -- including
+-- T-SHIFT, which is a pipeline row the engine put there. A row that no
+-- longer decides anything is worse than no row.
+local function dropRow(out, id)
+ for i = #out, 1, -1 do
+ if type(out[i]) == "table" and out[i].id == id then table.remove(out, i) end
+ end
+ return out
+end
+
-- call next() first and decorate what comes back, so every other mod's
-- rows survive this one
mod.hooks:wrap("ui.options.rows", function(next, game, rows)
local out = next(game, rows)
if type(out) ~= "table" then return out end
- for _, entry in ipairs(SETTINGS) do
- out[#out + 1] = entry[1]:row()
+ local Pipelines = require("src.render.Pipelines")
+ if Voxel.isFull(Pipelines.level("voxel")) then
+ return dropRow(out, "pipeline:tiltshift")
end
- return out
+ local extra = {}
+ for _, entry in ipairs(SETTINGS) do extra[#extra + 1] = entry[1]:row() end
+ return insertGrouped(out, extra)
end)
-- The mod manager writes and persists on its own, so the only thing left
@@ -436,6 +539,42 @@ mod.events:on("map.reloaded", function(payload)
if mapId then ChunkMesher.invalidate(mapId) end
end)
+-- ------- FULL takes rows off the menu, so the menu has to notice
+--
+-- OptionsMenu builds its row list ONCE, when it is opened, and then reads
+-- that list every frame. So stepping the VOXEL row onto or off FULL changed
+-- which rows the hook would return but not which rows were on screen -- the
+-- settings FULL owns stayed visible until the menu was closed and reopened,
+-- and a player who stepped off FULL could not see the rows come back.
+--
+-- Rebuilt in place, and only on a step that crosses FULL: every other rung
+-- returns the same list, and rebuilding on all of them would rerun every
+-- mod's ui.options.rows hook once per keypress. The cursor is clamped rather
+-- than reset, so it stays on the VOXEL row it was just used on instead of
+-- jumping to the top when the list below it shortens.
+do
+ local OptionsMenu = require("src.ui.OptionsMenu")
+ if not OptionsMenu.dramaticShapeFullHook then
+ local Pipelines = require("src.render.Pipelines")
+ local inner = OptionsMenu.update
+
+ function OptionsMenu:update(dt)
+ local before = Pipelines.level("voxel")
+ inner(self, dt)
+ local after = Pipelines.level("voxel")
+ if after ~= before
+ and (Voxel.isFull(before) or Voxel.isFull(after)) then
+ local rebuilt = OptionsMenu.new(self.game)
+ self.rows = rebuilt.rows
+ local cancel = #self.rows + 1
+ if (self.index or 1) > cancel then self.index = cancel end
+ end
+ end
+
+ OptionsMenu.dramaticShapeFullHook = true
+ end
+end
+
-- ------- battles on the map
--
-- The wraps this needs -- OverworldState:pushBattle, BattleState:draw and
@@ -481,7 +620,7 @@ mod.events:on("battle.ended", function()
OverworldBattle.finish()
end)
-mod.exports.version = "1.1.0"
+mod.exports.version = "1.2.0"
-- 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
diff --git a/manifest.json b/manifest.json
index 3ac3b2e..e53591d 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
- "version": "1.1.0",
+ "version": "1.2.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
@@ -11,7 +11,9 @@
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
- "permissions": ["engine_internals"],
+ "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 -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Registers two render pipelines and claims hotkeys 3, 5, 6, 7 and 8 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands."
}
diff --git a/tests/arena_pick.lua b/tests/arena_pick.lua
index d8af2d8..06bd92a 100644
--- a/tests/arena_pick.lua
+++ b/tests/arena_pick.lua
@@ -16,9 +16,11 @@
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love .
--
-- ARENA_FROM / ARENA_COUNT slice the map list so several runs can share the
--- work; ARENA_MAPS=ID,ID,... does an explicit set instead. SHOT_DIR must
--- already exist -- the capture writes with io.open, which does not create
--- directories.
+-- work; ARENA_MAPS=ID,ID,... does an explicit set instead. ARENA_COUNT=0
+-- lists the maps and stops. ARENA_SURF=1 stages the fight out on the water,
+-- centred in the map's biggest body of it, which is what the surf routes
+-- want. SHOT_DIR must already exist -- the capture writes with io.open,
+-- which does not create directories.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/arenas"
@@ -37,6 +39,51 @@ return function(game)
local Arena = lib.require("BattleArena")
local Battles = lib.require("OverworldBattle")
+ -- ------- staging out on the water
+ --
+ -- A surf route's land is a rim of beach round the edge of the map, so the
+ -- ordinary search always picks the rim and the fight happens on sand at
+ -- the corner of a sea. ARENA_SURF=1 says stage this map afloat: water
+ -- counts as ground, and the search starts from the middle of the map's
+ -- BIGGEST body of water rather than the middle of the map, so the arena
+ -- lands out in the open sea instead of against the first shoreline it
+ -- finds.
+ --
+ -- Biggest body, not all water at once: a map with a lake and an ocean has
+ -- a centroid between them that is on neither, and the arena would be
+ -- pinned to whichever shore that landed nearest.
+ local function waterCentre(map)
+ local w, h = map.widthCells, map.heightCells
+ local seen, best = {}, nil
+ for y0 = 0, h - 1 do
+ for x0 = 0, w - 1 do
+ if not seen[y0 * w + x0] and map:isWaterCell(x0, y0) then
+ -- one connected body, flooded from this cell
+ local stack, n, sx, sy = { { x0, y0 } }, 0, 0, 0
+ seen[y0 * w + x0] = true
+ while #stack > 0 do
+ local cell = table.remove(stack)
+ local cx, cy = cell[1], cell[2]
+ n, sx, sy = n + 1, sx + cx, sy + cy
+ for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do
+ local nx, ny = cx + d[1], cy + d[2]
+ local key = ny * w + nx
+ if nx >= 0 and ny >= 0 and nx < w and ny < h and not seen[key]
+ and map:isWaterCell(nx, ny) then
+ seen[key] = true
+ stack[#stack + 1] = { nx, ny }
+ end
+ end
+ end
+ if not best or n > best.n then
+ best = { n = n, x = sx / n, y = sy / n }
+ end
+ end
+ end
+ end
+ return best
+ end
+
-- Which maps a battle can actually happen on: anything with a wild
-- encounter table or an object that fights. Everything else -- a shop
-- floor, a stairwell, a bedroom -- would be authoring a spot for a fight
@@ -134,9 +181,22 @@ return function(game)
clear = any and Arena.clearance(any.map or map, any) or false
end
end
+ local surf = os.getenv("ARENA_SURF")
+ surf = surf ~= nil and surf ~= "" and surf ~= "0"
if not any then
- clear = Arena.search(map, cx, cy, false, true)
- any = clear or Arena.search(map, cx, cy, false)
+ local ox, oy = cx, cy
+ if surf then
+ local sea = waterCentre(map)
+ if sea then
+ ox, oy = sea.x, sea.y
+ U.log(("SEA %s: biggest body is %d cells, centre %.1f,%.1f")
+ :format(id, sea.n, sea.x, sea.y))
+ else
+ U.log("SEA " .. id .. ": no water on this map")
+ end
+ end
+ clear = Arena.search(map, ox, oy, surf, true)
+ any = clear or Arena.search(map, ox, oy, surf)
end
if not any then
U.log(("NONE %s -- no arena of either shape"):format(id))
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 006a23e..a685495 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -49,11 +49,13 @@ T.eq(defs._owners and defs._owners.voxel, "DRAMATIC_SHAPE",
-- ------- the ladders the engine drives
-T.eq(#defs.voxel.levels, 5, "voxel exposes a five-rung ladder")
+T.eq(#defs.voxel.levels, 6, "voxel exposes a six-rung ladder")
T.eq(defs.voxel.levels[1], "OFF", "rung 0 is OFF")
-T.eq(defs.voxel.levels[5], "75", "the top rung is the 75-degree camera")
-T.eq(Pipelines.maxLevel("voxel"), 4, "the engine reads the ladder height")
-T.eq(Pipelines.levelLabel("voxel", 2), "35", "the engine reads the rung labels")
+T.eq(defs.voxel.levels[2], "FULL",
+ "FULL is the first rung after OFF -- the order those two get used in")
+T.eq(defs.voxel.levels[6], "75", "the top rung is the 75-degree camera")
+T.eq(Pipelines.maxLevel("voxel"), 5, "the engine reads the ladder height")
+T.eq(Pipelines.levelLabel("voxel", 3), "35", "the engine reads the rung labels")
-- ------- gating: inert until switched on, and inert without a GPU
@@ -101,7 +103,7 @@ local byLabel = {}
for _, row in ipairs(rows) do byLabel[row.label] = row end
T.check(byLabel.VOXEL ~= nil, "the VOXEL row is offered")
T.check(byLabel["T-SHIFT"] ~= nil, "the T-SHIFT row is offered")
-T.eq(byLabel.VOXEL.value(), "15", "the row renders the current rung's label")
+T.eq(byLabel.VOXEL.value(), "FULL", "the row renders the current rung's label")
-- ------- this mod's own settings
--
@@ -112,6 +114,95 @@ T.eq(byLabel.VOXEL.value(), "15", "the row renders the current rung's label")
-- settings page looks.
local Runtime = require("src.mods.Runtime")
+local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
+
+-- ------- FULL is a preset that owns the other rows
+--
+-- While it is selected the settings it drives come OFF the menu -- including
+-- T-SHIFT, which is a pipeline row the engine spliced in. A row that no
+-- longer decides anything is worse than no row.
+Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
+local fullRows = Runtime.call("ui.options.rows", function(_, r) return r end,
+ { data = Data },
+ { { id = "tilt" }, { id = "pipeline:voxel" },
+ { id = "pipeline:tiltshift" } })
+local fullIds = {}
+for _, row in ipairs(fullRows) do fullIds[row.id] = true end
+T.check(fullIds["pipeline:voxel"], "FULL keeps the VOXEL row it lives on")
+T.check(not fullIds["pipeline:tiltshift"],
+ "FULL takes T-SHIFT off the menu -- it owns the blur")
+T.check(not fullIds["DRAMATIC_SHAPE:grid"], "and V-GRID")
+T.check(not fullIds["DRAMATIC_SHAPE:curve"], "and V-CURVE")
+T.check(not fullIds["DRAMATIC_SHAPE:battles"], "and 3D-BTL")
+
+-- ------- and off FULL, the rows come back, grouped with the mode
+--
+-- The engine splices a pipeline row in beside TILT and lands a mod's own
+-- additions at the END of the list, which would leave this mode's four rows
+-- in two places with unrelated rows between them.
+Pipelines.setLevel("voxel", 2)
+local grouped = Runtime.call("ui.options.rows", function(_, r) return r end,
+ { data = Data },
+ { { id = "tilt" }, { id = "pipeline:voxel" },
+ { id = "pipeline:tiltshift" },
+ { id = "void_fill" } })
+local order = {}
+for i, row in ipairs(grouped) do order[row.id] = i end
+T.check(order["pipeline:tiltshift"] < order["DRAMATIC_SHAPE:grid"],
+ "the mode's settings follow its pipeline rows")
+T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 3,
+ "and sit in one unbroken block, not scattered to the end of the list")
+T.check(order["void_fill"] > order["DRAMATIC_SHAPE:battles"],
+ "with the engine's own later rows still after them")
+
+-- ------- the open menu notices when FULL is stepped onto or off
+--
+-- OptionsMenu reads its row list every frame but builds it once, so without
+-- a rebuild the rows FULL owns stay on screen until the menu is reopened --
+-- and stepping OFF FULL never brings them back.
+local OptionsMenu = require("src.ui.OptionsMenu")
+local pressed = {}
+local menuGame = {
+ data = Data,
+ save = { options = { pipelines = {}, modOptions = {} } },
+ mods = { modOptions = {} },
+ input = { wasPressed = function(_, k) return pressed[k] or false end },
+ stack = { pop = function() end },
+ writeOptions = function() end,
+}
+
+Pipelines.setLevel("voxel", 2)
+local menu = OptionsMenu.new(menuGame)
+local function rowIndex(m, id)
+ for i, row in ipairs(m.rows) do if row.id == id then return i end end
+end
+T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"),
+ "off FULL the menu opens with the mode's settings on it")
+
+-- step the VOXEL row from 15 down to FULL, the way the player would
+menu.index = rowIndex(menu, "pipeline:voxel")
+pressed = { left = true }
+menu:update(0)
+pressed = {}
+T.eq(Pipelines.level("voxel"), 1, "the step landed on FULL")
+T.check(not rowIndex(menu, "DRAMATIC_SHAPE:grid"),
+ "and the rows FULL owns left the OPEN menu at once")
+T.check(not rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT with them")
+T.check(menu.index <= #menu.rows + 1, "the cursor stayed in range")
+
+-- and back off it again
+menu.index = rowIndex(menu, "pipeline:voxel")
+pressed = { right = true }
+menu:update(0)
+pressed = {}
+T.eq(Pipelines.level("voxel"), 2, "the step left FULL")
+T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"),
+ "and the rows came straight back without reopening the menu")
+T.check(rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT too")
+
+-- level 2 is the "15" rung: any rung that is not FULL, so the settings the
+-- preset owns are back on the menu
+Pipelines.setLevel("voxel", 2)
local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data }, { { id = "text_speed" } })
T.eq(#hookedRows, 4, "the options hook added a row per setting")
@@ -734,10 +825,33 @@ keyGame = {
local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
local Curve = run.loader.exports.DRAMATIC_SHAPE.lib.require("WorldCurve")
+-- ------- 3 walks the ANGLE rungs and steps over FULL
+--
+-- The key is a display-mode cycler: it should change the camera and nothing
+-- else. FULL reaches in and rewrites four other settings, so landing on it
+-- mid-walk would silently turn the blur to maximum and flatten the horizon
+-- with nothing on screen saying a keypress had done it.
+Pipelines.setLevel("voxel", 0)
+local walk = {}
+for _ = 1, 6 do
+ Game.keypressed(keyGame, "3")
+ walk[#walk + 1] = Pipelines.levelLabel("voxel")
+end
+T.eq(table.concat(walk, ","), "15,35,50,75,OFF,15",
+ "3 walks OFF -> 15 -> 35 -> 50 -> 75 and wraps, never touching FULL")
+
+-- FULL is 50 degrees, so a press from it goes ON to 75 rather than back to
+-- the rung that shows the same camera -- the key never appears to do nothing
+Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
Game.keypressed(keyGame, "3")
-T.eq(Pipelines.level("voxel"), 1, "3 cycles the voxel camera ladder")
+T.eq(Pipelines.levelLabel("voxel"), "75",
+ "a press from FULL goes to 75, since FULL already IS the 50 camera")
+
+Pipelines.setLevel("voxel", 0)
+Game.keypressed(keyGame, "3")
+T.eq(Pipelines.level("voxel"), 2, "3 cycles the voxel camera ladder")
Game.keypressed(keyGame, "3")
-T.eq(Pipelines.level("voxel"), 2, "and keeps climbing it")
+T.eq(Pipelines.level("voxel"), 3, "and keeps climbing it")
Game.keypressed(keyGame, "6")
T.eq(Pipelines.level("tiltshift"), 1, "6 cycles the tilt-shift blur")
@@ -780,7 +894,8 @@ T.eq(GBCFX.level, 0, "and on the live renderer")
-- 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"))
+-- 5 is the "75" rung, the last one the key walks before it wraps to OFF
+Pipelines.setLevel("voxel", 5)
Tilt.setLevel(3)
GBCFX.setLevel(4)
keyGame.save.options.tilt = 3
@@ -1256,6 +1371,26 @@ T.check(math.abs(sx - px) < 0.2,
-- arena floor out from under the two mons pinned to it
T.eq(rig.curve, 0, "the battle camera switches the world curve off")
+-- ------- the wireframe is forced on in a battle
+--
+-- A fight is a staged shot rather than the world being walked through, so it
+-- always wears the seams. The player's own V-GRID row must not be touched by
+-- that -- an override, not a write, or switching the mode off mid-battle
+-- would quietly rewrite a setting they chose.
+local Grid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
+Grid.override = nil
+local rowWas = Grid.setting:get()
+T.eq(Grid.enabled(), rowWas and true or false,
+ "with no override the wireframe follows the row")
+Grid.override = true
+T.eq(Grid.enabled(), true, "an override forces it on")
+T.eq(Grid.setting:get(), rowWas, "and leaves the player's row alone")
+Grid.override = false
+T.eq(Grid.enabled(), false, "an override can force it off too")
+Grid.override = nil
+T.eq(Grid.enabled(), rowWas and true or false,
+ "and clearing it hands the answer back to the row")
+
-- ------- the depth of field is measured off the two marks
--
-- The slab held sharp is the one the mons are standing in, so the band has