added FULL menu option for voxel mod

This commit is contained in:
DramaticShape
2026-07-28 13:37:19 -04:00
parent 6280cdffe7
commit 5781c28b60
10 changed files with 478 additions and 26 deletions
+38
View File
@@ -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
+4 -2
View File
@@ -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" },
+8 -1
View File
@@ -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
+8
View File
@@ -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
+12
View File
@@ -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
+51 -2
View File
@@ -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
+145 -6
View File
@@ -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
+4 -2
View File
@@ -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."
}
+65 -5
View File
@@ -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))
+143 -8
View File
@@ -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, "and keeps climbing it")
T.eq(Pipelines.level("voxel"), 2, "3 cycles the voxel camera ladder")
Game.keypressed(keyGame, "3")
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