From b4023013aec00cedc373fde4c0dbb220346707b2 Mon Sep 17 00:00:00 2001 From: hernan Date: Mon, 3 Aug 2026 09:32:09 -0400 Subject: [PATCH] Add a live-tuning panel: change the look while looking at it Every row this mod owns is judged with the eyes, and OPTIONS covers the thing being judged. START, OPTION, step, B, B, look, decide, back again -- long enough to forget what STRONG looked like before seeing MEDIUM. A translucent handle in the top-right opens a small panel of the same rows over the running world. Stepping a value re-renders the next frame with it, so the comparison is immediate. It owns no settings of its own: every row calls the same ModSetting:cycle / Pipelines.cycle the OPTIONS row calls, and is handed the live Game so the change persists the same way. The two menus cannot disagree about what is set. `when` is honoured, so a row OPTIONS hides is hidden here too. Drawn after Game:draw, on the composited frame, in window units. Drawing it inside the 3D overlay put it upstream of the tilt-shift blur, so the handle came out soft inside MINIATURE blurred band -- and that pass only runs on a 3D rung, so the panel could not be used to switch 3D on in the first place. Drawing last fixes both. Installed after FirstPerson so it gets first refusal on a touch: first person claims anything on open screen as a look drag. TUNE PANEL turns it off, and is marked full so the preset cannot take away the switch that brings it back. Co-Authored-By: Claude Opus 5 --- lib/LiveTune.lua | 263 ++++++++++++++++++++++++++++++++++ lib/ModSetting.lua | 10 +- main.lua | 51 +++++++ tests/dramatic_shape_test.lua | 2 +- 4 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 lib/LiveTune.lua diff --git a/lib/LiveTune.lua b/lib/LiveTune.lua new file mode 100644 index 0000000..d8f61e2 --- /dev/null +++ b/lib/LiveTune.lua @@ -0,0 +1,263 @@ +-- Live tuning: change the world's look while you are looking at it. +-- +-- The OPTIONS menu is the wrong instrument for a setting you judge with your +-- eyes. START, OPTION, step a row, B, B, look, decide, and back again -- +-- five inputs and a scene change between seeing STRONG and seeing MEDIUM, +-- which is exactly long enough to forget what STRONG looked like. Every row +-- this mod owns is visual, so all of them have that problem. +-- +-- This is a translucent handle in the corner that opens a small panel of the +-- same rows, drawn OVER the running world. The game never stops: stepping a +-- value re-renders the next frame with it, so the comparison is immediate +-- and the decision is made looking at the thing being decided. +-- +-- WHERE IT LIVES. After Game:draw, on the composited frame, in window units. +-- It started inside the 3D pass's overlay next to the horde HUD, which was +-- wrong twice over: that canvas is upstream of the tilt-shift blur, so the +-- handle came out soft inside MINIATURE's blurred band, and the pass only +-- runs on a 3D rung, so the panel could not be used to switch 3D ON in the +-- first place. Drawing last fixes both -- it is sharp, and it is reachable +-- from the flat game. +-- +-- WHAT IT STEPS. Nothing of its own. Every row here is the same +-- ModSetting:cycle / Pipelines.cycle the OPTIONS row calls, so a value +-- changed here persists exactly as one changed there, and the two can never +-- drift into disagreeing about what is set. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local LiveTune = {} + +-- Set by main.lua: a function answering the rows to show, each +-- { label = string, value = function -> string, step = function(dir) }. +-- A function rather than a table because which rows apply depends on the +-- rung -- the first-person rows only exist on 1ST. +LiveTune.provider = nil + +LiveTune.open = false + +-- The row that turns the handle off. Declared here rather than in main.lua +-- so the module that draws the handle is the one that owns whether it is +-- drawn -- there is no way to add the panel without also adding its off +-- switch. +local ModSetting = V.require("ModSetting") +LiveTune.setting = ModSetting.new("livetune", "TUNE PANEL", + { true, false }, { "ON", "OFF" }) + +function LiveTune.enabled() + local ok, v = pcall(function() return LiveTune.setting:get() end) + if not ok or v == nil then return true end + return v and true or false +end + +-- Shown while the player is standing in the world: not over a menu, a +-- dialog or a battle, which own the screen and the touches while they are +-- up. Same test first person uses to decide whether it is steering. +function LiveTune.visible() + if not LiveTune.enabled() then return false end + local ok, top, ow = pcall(function() + local Game = require("src.core.Game") + return Game.stack and Game.stack:top(), Game.overworld + end) + return ok and top ~= nil and top == ow +end + +-- geometry, in LOVE units +local HANDLE_R = 21 +local ROW_H = 27 +local PANEL_W = 232 +local MARGIN = 14 + +local font = nil +local function theFont() + if not font then + local ok, f = pcall(love.graphics.newFont, 13) + font = ok and f or love.graphics.getFont() + end + return font +end + +-- The window's usable rectangle: the handle must not sit under the Dynamic +-- Island, and in landscape must not sit under the notch either. +local function insets(ww) + local t, l, r = 0, 0, 0 + if love.window and love.window.getSafeArea then + local ok, ax, ay, aw = pcall(love.window.getSafeArea) + if ok and type(aw) == "number" and aw > 0 then + t = math.max(0, ay or 0) + l = math.max(0, ax or 0) + r = math.max(0, ww - ((ax or 0) + aw)) + end + end + return t, l, r +end + +-- Handle centre, top-right: the d-pad owns the bottom left, A/B and the look +-- stick own the right side lower down, and the top-left is where the engine +-- puts its own notices. +function LiveTune.handle() + local ww = love.graphics.getWidth() + local insetT, _, insetR = insets(ww) + return { cx = ww - insetR - MARGIN - HANDLE_R, + cy = insetT + MARGIN + HANDLE_R, + r = HANDLE_R } +end + +local function rows() + if type(LiveTune.provider) ~= "function" then return {} end + local ok, list = pcall(LiveTune.provider) + if not ok or type(list) ~= "table" then return {} end + return list +end + +-- The panel's rectangle and its row rectangles, so drawing and hit-testing +-- cannot disagree about where anything is. +local function panelRects() + local list = rows() + if #list == 0 then return nil, list end + local h = LiveTune.handle() + local ww = love.graphics.getWidth() + local _, insetL = insets(ww) + local w = math.min(PANEL_W, ww - insetL - MARGIN * 2) + local x = math.max(insetL + MARGIN, h.cx + h.r - w) + local y = h.cy + h.r + 8 + return { x = x, y = y, w = w, h = #list * ROW_H + 10 }, list +end + +-- ------- drawing + +local function roundRect(mode, x, y, w, h, r) + love.graphics.rectangle(mode, x, y, w, h, r, r) +end + +function LiveTune.draw() + if not LiveTune.visible() then return end + if #rows() == 0 then return end + -- Drawn in plain LOVE units, straight onto the window, because this runs + -- AFTER Game:draw has composited everything. The first version drew into + -- the 3D pass's overlay canvas, which put it upstream of the tilt-shift + -- blur -- so the handle sat inside MINIATURE's blurred top band and came + -- out soft, while everything around it was sharp. + local s = 1 + + love.graphics.push("all") + love.graphics.setFont(theFont()) + + local h = LiveTune.handle() + -- the handle: deliberately faint. It sits over the world all the time, and + -- a bright chip in the corner of every screenshot is worse than a dim one + -- that takes a moment to find the first time. + love.graphics.setColor(0, 0, 0, LiveTune.open and 0.55 or 0.34) + love.graphics.circle("fill", h.cx * s, h.cy * s, h.r * s) + love.graphics.setColor(1, 1, 1, LiveTune.open and 0.75 or 0.42) + love.graphics.setLineWidth(math.max(1, 1.5 * s)) + love.graphics.circle("line", h.cx * s, h.cy * s, h.r * s) + -- a small cube glyph, drawn rather than shipped as an asset so there is no + -- image to load, fail to load, or mismatch the density + do + local q = h.r * 0.42 * s + local cx, cy = h.cx * s, h.cy * s + love.graphics.polygon("line", cx - q, cy - q * 0.3, cx, cy - q, + cx + q, cy - q * 0.3, cx, cy + q * 0.4) + love.graphics.line(cx - q, cy - q * 0.3, cx - q, cy + q * 0.7) + love.graphics.line(cx + q, cy - q * 0.3, cx + q, cy + q * 0.7) + love.graphics.line(cx, cy + q * 0.4, cx, cy + q * 1.4) + love.graphics.line(cx - q, cy + q * 0.7, cx, cy + q * 1.4) + love.graphics.line(cx + q, cy + q * 0.7, cx, cy + q * 1.4) + end + + if LiveTune.open then + local p, list = panelRects() + if p then + love.graphics.setColor(0, 0, 0, 0.62) + roundRect("fill", p.x * s, p.y * s, p.w * s, p.h * s, 10 * s) + love.graphics.setColor(1, 1, 1, 0.22) + roundRect("line", p.x * s, p.y * s, p.w * s, p.h * s, 10 * s) + + for i, row in ipairs(list) do + local ry = p.y + 5 + (i - 1) * ROW_H + local ok, val = pcall(row.value) + val = (ok and val) or "-" + -- label left, value right, arrows at the two ends of the row + love.graphics.setColor(1, 1, 1, 0.62) + love.graphics.print(row.label, (p.x + 30) * s, + (ry + 6) * s, 0, s, s) + love.graphics.setColor(1, 1, 1, 0.95) + local vw = theFont():getWidth(tostring(val)) + love.graphics.print(tostring(val), + (p.x + p.w - 30) * s - vw * s, (ry + 6) * s, 0, s, s) + -- the steppers + love.graphics.setColor(1, 1, 1, 0.55) + local ay = (ry + ROW_H * 0.5) * s + local aw = 5 * s + local lx = (p.x + 15) * s + love.graphics.polygon("fill", lx + aw, ay - aw, lx + aw, ay + aw, lx - aw * 0.4, ay) + local rx = (p.x + p.w - 15) * s + love.graphics.polygon("fill", rx - aw, ay - aw, rx - aw, ay + aw, rx + aw * 0.4, ay) + end + end + end + love.graphics.pop() +end + +-- ------- input +-- +-- Answers true when the touch was ours, so the caller knows to stop: a tap +-- on the panel must not also walk the player or fire a horde shot. + +function LiveTune.touchpressed(x, y) + if not LiveTune.visible() then return false end + if #rows() == 0 then return false end + local h = LiveTune.handle() + local dx, dy = x - h.cx, y - h.cy + -- a generous target: the handle is small on purpose, the tap area is not + local reach = h.r * 1.45 + if dx * dx + dy * dy <= reach * reach then + LiveTune.open = not LiveTune.open + return true + end + if not LiveTune.open then return false end + local p, list = panelRects() + if not p then return false end + if x < p.x or x > p.x + p.w or y < p.y or y > p.y + p.h then + -- a tap anywhere else closes it, the way a popover does. Not consumed: + -- closing should not also swallow the tap that closed it. + LiveTune.open = false + return false + end + local i = math.floor((y - p.y - 5) / ROW_H) + 1 + local row = list[i] + if not row or type(row.step) ~= "function" then return true end + -- Left half steps down, right half steps up. Splitting the whole row + -- rather than hit-testing the little arrows keeps the targets thumb-sized; + -- the arrows are there to say which way, not to be aimed at. + pcall(row.step, (x < p.x + p.w * 0.5) and -1 or 1) + return true +end + +function LiveTune.install(Game) + if LiveTune._installed then return end + LiveTune._installed = true + do + -- After Game:draw, so the panel lands on top of the composited frame -- + -- past the 3D pass, past the tilt-shift blur, past the touch overlay -- + -- and in window units, which is what keeps it crisp. + local innerDraw = Game.draw + function Game:draw(...) + local r = innerDraw(self, ...) + pcall(LiveTune.draw) + return r + end + end + local inner = Game.touchpressed + function Game:touchpressed(id, tx, ty) + -- First refusal: this panel is drawn over everything, so it has to be + -- offered the touch before the pad, the look stick or the horde gun. + local ok, taken = pcall(LiveTune.touchpressed, tx, ty) + if ok and taken then return end + return inner(self, id, tx, ty) + end +end + +return LiveTune diff --git a/lib/ModSetting.lua b/lib/ModSetting.lua index 2bed605..1a79742 100644 --- a/lib/ModSetting.lua +++ b/lib/ModSetting.lua @@ -33,9 +33,10 @@ end -- `values` are the stored values in ladder order and `labels` what the row -- shows for each; values[1] is the default, and the one an unreadable or -- unrecognised stored value falls back to. -function ModSetting.new(key, label, values, labels) +function ModSetting.new(key, label, values, labels, default) return setmetatable({ key = key, label = label, values = values, labels = labels, + default = default, index = nil, -- nil = not yet read back from the persisted options }, ModSetting) end @@ -44,7 +45,12 @@ local function indexOf(self, value) for i, v in ipairs(self.values) do if v == value then return i end end - return 1 + -- Nothing persisted yet. Rung 1 is the right default for a ladder that + -- starts at OFF, and the wrong one for a ladder whose neutral sits in the + -- middle -- LOOK SPEED reads SLOW/NORMAL/FAST in that order because that + -- is the order a player expects to step through, not because slow is the + -- default. `default` lets those two facts differ. + return self.default or 1 end -- What the player left it at last session. Read lazily rather than at load diff --git a/main.lua b/main.lua index d4abea1..a2481ba 100644 --- a/main.lua +++ b/main.lua @@ -91,6 +91,7 @@ local DayTint = V.require("DayTint") local Water = V.require("Water") local AntiAlias = V.require("AntiAlias") local FirstPerson = V.require("FirstPerson") +local LiveTune = V.require("LiveTune") local FreeMove = V.require("FreeMove") local VR = V.require("VR") -- HORDE MODE: the konami code's minigame. Horde owns the state machine and @@ -404,6 +405,12 @@ local function stagedBattles() end local SETTINGS = { + -- `full` because FULL is a preset for the LOOK and takes the look's knobs + -- off the menu while selected. The handle's own off switch is not one of + -- them -- losing it there leaves no visible way to bring the panel back. + { LiveTune.setting, + "The floating handle that opens the live-tuning panel over the world.", + full = true }, { VoxelGrid.setting, "One-pixel wireframe along every voxel edge." }, { WorldCurve.setting, "Bend the world down over the horizon, Animal Crossing style." }, @@ -928,7 +935,51 @@ OverworldBattle.install() -- per-cell consequence still runs through the engine's own machinery -- (onStepComplete, checkEdgeExit, checkLedgeHop, checkBoulderPush). The -- file argues the whole arrangement. +-- ------- live tuning +-- +-- The panel shows the same rows OPTIONS does and steps them the same way, so +-- there is one source of truth for what a setting is and one code path for +-- changing it. `when` is honoured here too, so a row OPTIONS would hide is +-- hidden here as well. +LiveTune.provider = function() + local out = {} + local Pipelines = require("src.render.Pipelines") + local function game() + local ok, g = pcall(require, "src.core.Game") + return ok and g or nil + end + -- the camera rung first: it is the one people step most + out[#out + 1] = { + label = "3D WORLD", + value = function() return Pipelines.levelLabel("voxel") end, + step = function(dir) + Pipelines.cycle("voxel", dir) + local g = game() + local opts = g and g.save and g.save.options + if opts then Pipelines.syncOptions(opts) end + end, + } + for _, entry in ipairs(SETTINGS) do + local setting = entry[1] + if setting ~= LiveTune.setting and (not entry.when or entry.when()) then + out[#out + 1] = { + label = setting.label, + value = function() return setting.labels[setting:read()] end, + -- handed the live Game because that is what persists it: + -- ModSetting:setIndex writes through game.save.options and silently + -- does not when it has none + step = function(dir) setting:cycle(game(), dir) end, + } + end + end + return out +end + FirstPerson.install() +-- AFTER FirstPerson: each install wraps the previous handler, so the last one +-- installed is asked first -- and first person claims any touch on open +-- screen as a look drag, which would swallow every tap on the handle. +LiveTune.install(require("src.core.Game")) FreeMove.install() -- ------- horde mode diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 2b7cf7d..155e54b 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -387,7 +387,7 @@ end Pipelines.setLevel("voxel", 2) local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, { data = Data }, { { id = "text_speed" } }) -T.eq(#hookedRows, 9, "the options hook added a row per setting") +T.eq(#hookedRows, 10, "the options hook added a row per setting") local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4] local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7] -- the AA row is hookedRows[8]; it is read in its own block below, because