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, "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