From 63069753528430f0059ca380a8e86ef0839221a9 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 24 Jul 2026 10:10:51 -0400 Subject: [PATCH] new options, and hot keys and readme clean up --- README.md | 28 ++++- docs/new-features.md | 49 +++++++-- src/battle/BattleState.lua | 3 + src/core/Game.lua | 16 ++- src/core/SaveData.lua | 6 +- src/render/BattleTransition.lua | 5 + src/render/Renderer.lua | 62 ++++++++++- src/render/TileRenderer.lua | 179 ++++++++++++++++++++++++++------ src/render/Zoom.lua | 46 +++++++- src/ui/OptionsMenu.lua | 37 ++++++- tests/mod_graphics_tests.lua | 91 ++++++++++++++++ tests/mod_ui_tests.lua | 35 +++++-- tests/run_tests.lua | 75 ++++++++++--- 13 files changed, 557 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index ed4d9a97..e1a2a9e1 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,32 @@ audio channel programs copied out of the verified ROM. ## Controls -arrow keys or WASD move; Z, Enter, or Space is A; X or Backspace is -B; Escape opens START. F1 saves and F2 loads. Controllers are supported. +| Action | Keyboard | Controller | +|--------|----------|------------| +| Move | Arrow keys / WASD | D-pad / left stick | +| A | Z / Enter / Space | A | +| B | X / Backspace | B | +| Start | Escape | Start | +| Select | Tab / Shift | Back / Select | + +Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are +supported out of the box. + +### Hotkeys + +| Key | What it does | +|-----|----------------| +| `-` / `=` | Zoom out / in (overworld; also mouse wheel) | +| `2` | Cycle COLORS | +| `3` | Cycle TILT (free-roam overworld) | +| `4` | Cycle ZOOM through every level (free-roam overworld) | +| `5` | Cycle GBC FX | +| `F1` | Save | +| `F2` | Load | +| `F10` | Open / close the mod manager | + +COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu +and persist in `options.lua`. ## Running From Source diff --git a/docs/new-features.md b/docs/new-features.md index d4b747f7..3db7507a 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -8,27 +8,42 @@ behavior is in docs/behavior-porting-notes.md. ## Survey zoom -The mouse wheel (or `-`/`=`) zooms the overworld between 1 pixel per world -pixel (full survey) and 2× the window fit scale (close-up), in crisp -integer steps. This has no Game Boy equivalent: +The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4` +zooms the overworld between 1 pixel per world pixel (full survey) and 2× +the window fit scale (close-up), in crisp integer steps. This has no Game +Boy equivalent: - Connected maps render their full bodies, and their NPCs appear as visual-only "ghosts", they wander but have no sight lines, triggers, dialogue, or collision until the map is actually entered. - Menus, text boxes, and battles draw at normal scale on top of the zoomed world. Zoom input is ignored while a script, menu, or battle is - active; the zoom level persists across warps and is never saved. -- Beyond the border ring the border block repeats indefinitely (interiors - stay black, seaside towns stay water, except OVERWORLD-tileset maps, - whose beyond-edge space fills with the solid tree wall instead of the - per-map border block), and each visible map area is colorized with its - own SGB palette (the original recolored the whole screen per map). + active; the zoom offset is persisted as `save.options.zoom` (default + `0` = FIT) and survives New Game via `options.lua`. +- Hotkey `4` ticks through every integer zoom level (survey → FIT → + close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`. +- Beyond the border ring the void fill repeats indefinitely (see VOID + FILL below); interiors keep their own border block. Each visible map + area is colorized with its own SGB palette (the original recolored the + whole screen per map). - Neighbor maps load two connection hops out so corner-adjacent maps don't pop in and out, and ghost NPCs share instances with the real ones so their wander positions persist across seamless connection crossings (a warp or fresh map entry still respawns everything at its script position, like the original's per-entry sprite init). +## VOID FILL + +The Options **VOID FILL** row picks what paints the infinite beyond-edge +space on OVERWORLD-tileset maps during survey zoom: + +- **TREES** (default): solid tree wall block `$0F`. +- **WATER**: animated water tile `$14` (same hshift cycle as on-map water). +- **BLACK**: solid black. + +Other tilesets are unchanged (house/cave borders stay as authored). +Persisted as `save.options.voidFill`. + ## Tilt mode The `3` key (and the Options menu TILT row) cycles a visual-only perspective @@ -139,7 +154,7 @@ Nidorino-vs-Gengar attract scene) mirror the original. Options persist in a standalone `options.lua` (separate from the game progress `save.lua`), so audio/display/battle preferences survive New Game and aren't wiped when a save slot is cleared. Changing a row in the Options -menu or cycling hotkeys `2`/`3`/`5` writes immediately; an in-game save also +menu or cycling hotkeys `2`/`3`/`4`/`5` writes immediately; an in-game save also flushes the live options. Old saves that still embed an `options` table are migrated once into `options.lua` on load. @@ -150,6 +165,18 @@ migrated once into `options.lua` on load. hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc SuperPalettes + per-species mon colors) - TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming +- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and + `-`/`=` step one level and save +- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space - GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5` - MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60), - a hard render frame-rate cap (`save.options.fpsCap`). \ No newline at end of file + a hard render frame-rate cap (`save.options.fpsCap`). + +## Battle transition cascade + white battle letterbox + +Into-battle wipes still run the original eight styles inside the classic +160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8 +blocks cascade outward from that square into the surrounding world so the +void outside the OG wipe fills in lockstep. Once the battle state is up, +letterbox voids around the battle canvas fill **white** instead of black +so the whole window reads as one continuous battle screen. \ No newline at end of file diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 0d7dfa26..951a49ed 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -30,6 +30,9 @@ local TypeChart = require("src.battle.TypeChart") local BattleState = {} BattleState.__index = BattleState BattleState.isOpaque = true +-- Letterbox voids around the 160x144 battle canvas fill white so the +-- window reads as one continuous battle screen (no black bars). +BattleState.letterboxWhite = true -- Battle colors itself per-pixel (species pics + HP bar tints), so the -- SGB whole-screen remap must not run over it. diff --git a/src/core/Game.lua b/src/core/Game.lua index 5175c42f..0d9532fe 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -246,7 +246,11 @@ end function Game:zoomStep(delta) local Zoom = require("src.render.Zoom") if not Zoom.gateOK(self.stack:top(), self.overworld) then return end - Zoom.step(delta, Renderer:fitScale()) + local offset = Zoom.step(delta, Renderer:fitScale()) + if self.save and self.save.options then + self.save.options.zoom = offset + self:writeOptions() + end end function Game:wheelmoved(_, dy) @@ -320,6 +324,14 @@ function Game:keypressed(key) self:writeOptions() end return + elseif key == "4" then + -- cycle ZOOM through every integer level (survey → FIT → close-up → wrap) + local Zoom = require("src.render.Zoom") + if Zoom.gateOK(self.stack:top(), self.overworld) then + self.save.options.zoom = Zoom.cycle(Renderer:fitScale()) + self:writeOptions() + end + return elseif key == "5" then -- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on -- desktop. Mobile refuses the present shader (issue #136). @@ -445,6 +457,8 @@ function Game:applyOptions(opts) if Sound.applyOptions then Sound.applyOptions(opts) end require("src.render.PaletteFX").applyOptions(opts) require("src.render.Tilt").applyOptions(opts) + require("src.render.Zoom").applyOptions(opts) + require("src.render.TileRenderer").applyOptions(opts) -- returns true when a persisted GBC FX level was cleared on mobile local gbcCleared = require("src.render.GBCFX").applyOptions(opts) require("src.core.VideoMode").applyOptions(opts) diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 9d810c79..3e96383a 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -186,10 +186,14 @@ function SaveData.defaultOptions() musicFilter = 0, -- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua) speed = 1, - -- port display options (OptionsMenu / hotkeys 2/3/5) + -- port display options (OptionsMenu / hotkeys 2/3/4/5) colors = "gbc", tilt = 0, gbcfx = 0, + -- survey zoom offset from window fit scale (0 = FIT); see Zoom.lua + zoom = 0, + -- OVERWORLD beyond-edge fill: trees | water | black + voidFill = "trees", -- windowed | borderless (desktop fullscreen); ignored on mobile videoMode = "windowed", -- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua) diff --git a/src/render/BattleTransition.lua b/src/render/BattleTransition.lua index 88e40c4b..f83c6ac3 100644 --- a/src/render/BattleTransition.lua +++ b/src/render/BattleTransition.lua @@ -230,6 +230,11 @@ function BattleTransition:draw() love.graphics.setColor(0, 0, 0, 1) local prog = math.min(1, self.t / self.wipeLen) + -- Cascade black 8x8 blocks across the window area *outside* the classic + -- 160x144 wipe square, in lockstep with the OG wipe progress. Renderer + -- paints them in screen space after the world blit (see endFrame). + local renderer = self.game and self.game.renderer + if renderer then renderer.battleCascadeProg = prog end local style = self.style -- a registered style may draw itself; the eight built-ins do not diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 470adb42..9ff968e8 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -94,6 +94,8 @@ function Renderer:beginFrame(transparent) -- warp-fade overlay from Transition (issue #121); cleared each frame so -- a popped transition cannot leave a sticky black veil self.worldFadeAlpha = nil + -- battle-transition cascade outside the 160x144 wipe (BattleTransition) + self.battleCascadeProg = nil -- last frame's trueColor rects and sprite redraws go before anything -- draws this one PaletteFX.clearTrueColor() @@ -107,6 +109,46 @@ function Renderer:beginFrame(transparent) end end +-- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox +-- into the surrounding window, matching BattleTransition wipe progress. +-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe. +function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S) + if not prog or prog <= 0 then return end + local TILE = 8 * S + if TILE < 1 then TILE = 1 end + local cols = math.ceil(ww / TILE) + local rows = math.ceil(wh / TILE) + local cx, cy = ox + vpw / 2, oy + vph / 2 + local order = {} + for row = 0, rows - 1 do + for col = 0, cols - 1 do + local x, y = col * TILE, row * TILE + -- any tile with area outside the letterbox participates + if x < ox or y < oy or x + TILE > ox + vpw or y + TILE > oy + vph then + local dist = math.max(math.abs(x + TILE / 2 - cx), + math.abs(y + TILE / 2 - cy)) + order[#order + 1] = { x, y, dist } + end + end + end + if #order == 0 then return end + table.sort(order, function(a, b) + if a[3] ~= b[3] then return a[3] < b[3] end + if a[2] ~= b[2] then return a[2] < b[2] end + return a[1] < b[1] + end) + local n = math.floor(#order * math.min(1, prog) + 1e-6) + if prog >= 1 then n = #order end + love.graphics.setColor(0, 0, 0, 1) + love.graphics.setScissor(0, 0, ww, wh) + for i = 1, n do + local t = order[i] + love.graphics.rectangle("fill", t[1], t[2], TILE, TILE) + end + love.graphics.setScissor() + love.graphics.setColor(1, 1, 1, 1) +end + function Renderer:beginWorldPass() local vw, vh = self:worldViewSize() if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw @@ -349,7 +391,20 @@ function Renderer:endFrame(zones, worldZones) present = self.presentCanvas love.graphics.setCanvas(present) end - love.graphics.setColor(0, 0, 0, 1) + -- Default letterbox is black. Battle (and any state that opts in via + -- letterboxWhite) fills the voids white so the window matches the + -- white battle canvas instead of showing black bars. + local clearR, clearG, clearB = 0, 0, 0 + if not self.worldActive then + local ok, Game = pcall(require, "src.core.Game") + local stack = ok and Game and Game.stack + local base = stack and stack.visibleBase and stack:visibleBase() + local state = base and stack.states and stack.states[base] + if state and state.letterboxWhite then + clearR, clearG, clearB = 1, 1, 1 + end + end + love.graphics.setColor(clearR, clearG, clearB, 1) love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.setColor(1, 1, 1, 1) @@ -460,6 +515,11 @@ function Renderer:endFrame(zones, worldZones) love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.setColor(1, 1, 1, 1) end + -- Battle transition: cascade black blocks into the area outside the + -- classic 160x144 wipe square (world still shows through until filled). + if self.battleCascadeProg then + self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S) + end end -- UI stays in the classic centered GB letterbox blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph) diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 1e01b1fd..54534cb9 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -10,19 +10,57 @@ TileRenderer.__index = TileRenderer local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks) --- OVERWORLD maps fill beyond-edge space with the solid tree wall --- (blockset $0F: four regular-tree metatiles, tiles $40/$41/$50/$51, --- the border block of ViridianCity/CeruleanCity/CeladonCity et al.), --- not each map's own border_block, which can be grass ($0B, the --- CutTreeBlockSwaps $0B->$0A cut-grass block) or water; other --- tilesets keep their designated border (interiors stay black/void) +-- OVERWORLD maps fill beyond-edge space from save.options.voidFill: +-- trees (default) — solid tree wall $0F (Viridian/Cerulean/Celadon) +-- water — solid water $43 (Cinnabar/Route 19 border block) +-- black — solid black (no tiled metatile) +-- Other tilesets keep their designated border (interiors stay black/void). local TREE_WALL_BLOCK = 0x0F +local WATER_BORDER_BLOCK = 0x43 +TileRenderer.VOID_FILLS = { "trees", "water", "black" } +TileRenderer.voidFill = "trees" + local function borderBlockFor(map) - if map.def.tileset == "OVERWORLD" then return TREE_WALL_BLOCK end + if map.def.tileset == "OVERWORLD" then + local mode = TileRenderer.voidFill or "trees" + if mode == "water" then return WATER_BORDER_BLOCK end + if mode == "black" then return false end + return TREE_WALL_BLOCK + end return map.def.borderBlock end TileRenderer.borderBlockFor = borderBlockFor +function TileRenderer.setVoidFill(mode) + local ok = false + for _, m in ipairs(TileRenderer.VOID_FILLS) do + if m == mode then ok = true; break end + end + TileRenderer.voidFill = ok and mode or "trees" +end + +function TileRenderer.cycleVoidFill() + local cur = TileRenderer.voidFill or "trees" + local idx = 1 + for i, m in ipairs(TileRenderer.VOID_FILLS) do + if m == cur then idx = i; break end + end + TileRenderer.setVoidFill( + TileRenderer.VOID_FILLS[idx % #TileRenderer.VOID_FILLS + 1]) + return TileRenderer.voidFill +end + +function TileRenderer.applyOptions(opts) + TileRenderer.setVoidFill(opts and opts.voidFill or "trees") +end + +function TileRenderer.voidFillLabel(mode) + mode = mode or TileRenderer.voidFill or "trees" + if mode == "water" then return "WATER" end + if mode == "black" then return "BLACK" end + return "TREES" +end + local imageCache = {} local function getImage(path) @@ -487,42 +525,118 @@ function TileRenderer.new(map, data) self.anims = anims self.claimedBy = claimedBy - -- a repeating 32x32 image of the border block, tiled behind - -- everything the 3-block ring doesn't cover (the survey zoom sees - -- far past the ring; interiors keep their black border this way) - pcall(function() - local border = map.tileset.blocks[borderBlockFor(map) + 1] - if not border then return end - local canvas = love.graphics.newCanvas(32, 32) - love.graphics.push("all") - love.graphics.setCanvas(canvas) - love.graphics.clear(1, 1, 1, 1) - for ty = 0, 3 do - for tx = 0, 3 do - local quad = self.quads[border[ty * 4 + tx + 1]] - if quad then love.graphics.draw(self.image, quad, tx * 8, ty * 8) end - end - end - love.graphics.setCanvas() - love.graphics.pop() - local img = love.graphics.newImage(canvas:newImageData()) - img:setWrap("repeat", "repeat") - img:setFilter("nearest", "nearest") - self.borderFill = img - end) + -- border-fill image is built lazily in :ensureBorderFill so a VOID FILL + -- option change can swap trees/water/black without reloading the map + self.borderFillMode = nil + self.borderFill = nil return self end +-- Bake a static repeating 32x32 of `block` into self.borderFill. +local function bakeBorderFill(self, block) + local border = self.map.tileset.blocks[block + 1] + if not border then return end + local canvas = love.graphics.newCanvas(32, 32) + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.clear(1, 1, 1, 1) + for ty = 0, 3 do + for tx = 0, 3 do + local quad = self.quads[border[ty * 4 + tx + 1]] + if quad then love.graphics.draw(self.image, quad, tx * 8, ty * 8) end + end + end + love.graphics.setCanvas() + love.graphics.pop() + local img = love.graphics.newImage(canvas:newImageData()) + img:setWrap("repeat", "repeat") + img:setFilter("nearest", "nearest") + self.borderFill = img +end + +-- WATER void fill: the eight hshift frames of tile $14 (same cycle as map +-- water), wrap-tiled so the void scrolls in lockstep with on-map water. +local function ensureWaterBorderFill(self) + if self.borderWaterTextures then return true end + local map = self.map + local perRow = map.tileset.tilesPerRow + local colors, gbcKey + if self.gbcAtlas and self.data then + local group = PaletteFX.worldGroupAt(map.tileset.id, map.id, WATER_TILE) + local groupColors = PaletteFX.worldGroupColors( + self.data, map.tileset.id, map.id, nil) + colors = group and groupColors and groupColors[group + 1] or nil + gbcKey = "#gbc:" .. map.id + end + local textures = getShiftVariants(map.tileset.image, perRow, WATER_TILE, + colors, gbcKey) + if not textures then return false end + for _, img in ipairs(textures) do + img:setWrap("repeat", "repeat") + img:setFilter("nearest", "nearest") + end + self.borderWaterTextures = textures + return true +end + +-- (re)build the repeating border image when the VOID FILL mode or tileset +-- choice changes. OVERWORLD "black" leaves borderFill nil and draws a +-- solid clear; "water" keeps the live hshift textures instead of a bake. +function TileRenderer:ensureBorderFill() + local block = borderBlockFor(self.map) + local mode = block == false and "black" + or ((self.map.def.tileset == "OVERWORLD") + and (TileRenderer.voidFill or "trees") + or "map") + local ready = (mode == "black") + or (mode == "water" and self.borderWaterTextures) + or (mode ~= "water" and mode ~= "black" and self.borderFill) + if self.borderFillMode == mode and ready then return end + if self.borderFill and self.borderFill.release then + pcall(self.borderFill.release, self.borderFill) + end + self.borderFill = nil + -- shared shift-variant cache: drop the reference only, never release + self.borderWaterTextures = nil + self.borderFillMode = mode + if mode == "black" or block == false or block == nil then return end + if mode == "water" then + if ensureWaterBorderFill(self) then return end + -- headless / missing pixels: fall back to the static water block bake + end + pcall(bakeBorderFill, self, block) +end + -- tile the border block across the whole view (world-aligned so it -- meshes seamlessly with the ring batch) function TileRenderer:drawBorderFill(camX, camY, vw, vh) - if not self.borderFill then return end + self:ensureBorderFill() + if self.borderFillMode == "black" then + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", 0, 0, vw, vh) + love.graphics.setColor(1, 1, 1, 1) + return + end if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end local x, y = math.floor(camX), math.floor(camY) -- one reused Quad per renderer, mutated in place: this runs every -- overworld frame, so allocating a fresh Quad here churned the GC local q = self.borderQuad + if self.borderFillMode == "water" and self.borderWaterTextures then + local step = math.floor(animFrame / ANIM_PERIOD) % #WATER_OFFSETS + 1 + local tex = self.borderWaterTextures[WATER_OFFSETS[step] + 1] + if not tex then return end + if q then + q:setViewport(x, y, vw, vh, 8, 8) + else + q = love.graphics.newQuad(x, y, vw, vh, 8, 8) + self.borderQuad = q + end + love.graphics.draw(tex, q, 0, 0) + return + end + if not self.borderFill then return end if q then q:setViewport(x, y, vw, vh, 32, 32) else @@ -730,6 +844,9 @@ function TileRenderer:releaseBatches() safeRelease(self.winBatch); self.winBatch = nil safeRelease(self.borderFill); self.borderFill = nil safeRelease(self.borderQuad); self.borderQuad = nil + -- shared shift-variant cache; only drop the reference + self.borderWaterTextures = nil + self.borderFillMode = nil self.win = nil if self.quads then for _, q in pairs(self.quads) do safeRelease(q) end diff --git a/src/render/Zoom.lua b/src/render/Zoom.lua index 4e76a11f..a3208242 100644 --- a/src/render/Zoom.lua +++ b/src/render/Zoom.lua @@ -1,6 +1,7 @@ -- Overworld survey zoom: integer pixels-per-world-pixel scales stepped --- by the mouse wheel. Stored as an offset from the window fit scale S --- so a resize keeps the relative zoom. Session-only; never saved. +-- by the mouse wheel, Options ZOOM row, or hotkey `4`. Stored as an +-- offset from the window fit scale S so a resize keeps the relative +-- zoom. Persisted as save.options.zoom (default 0 = FIT). -- Spec: docs/new-features.md (survey zoom) local Zoom = {} @@ -12,16 +13,51 @@ function Zoom.scale(S) return math.max(1, math.min(2 * S, S + Zoom.offset)) end +-- legal offset range for a given fit scale +function Zoom.offsetRange(S) + S = math.max(1, math.floor(tonumber(S) or 1)) + return 1 - S, S +end + +function Zoom.clampOffset(offset, S) + local lo, hi = Zoom.offsetRange(S) + offset = math.floor(tonumber(offset) or 0) + if offset < lo then return lo end + if offset > hi then return hi end + return offset +end + function Zoom.step(delta, S) - Zoom.offset = Zoom.offset + delta - if S + Zoom.offset < 1 then Zoom.offset = 1 - S end - if S + Zoom.offset > 2 * S then Zoom.offset = S end + Zoom.offset = Zoom.clampOffset(Zoom.offset + delta, S) + return Zoom.offset +end + +-- Advance one zoom level toward max close-up, then wrap to full survey. +-- Returns the new offset. +function Zoom.cycle(S) + local lo, hi = Zoom.offsetRange(S) + local next = Zoom.offset + 1 + if next > hi then next = lo end + Zoom.offset = next + return Zoom.offset end function Zoom.reset() Zoom.offset = 0 end +function Zoom.applyOptions(opts) + Zoom.offset = math.floor(tonumber(opts and opts.zoom) or 0) +end + +-- FIT / OUT1 / OUT2 / … / IN1 / IN2 / … +function Zoom.offsetLabel(offset) + offset = math.floor(tonumber(offset) or 0) + if offset == 0 then return "FIT" end + if offset < 0 then return "OUT" .. tostring(-offset) end + return "IN" .. tostring(offset) +end + -- world pixels covered by a w x h letterbox viewport at fit scale S -- (legacy GB-framed size; prefer fillViewSize for the live world pass) function Zoom.viewSize(S, w, h) diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index c838e422..0e6bb3ca 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -3,7 +3,8 @@ -- (cycles the merged rulesets registry; gen1_faithful keeps the original -- quirks), plus the port's audio rows and display rows: music/SFX -- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT / --- GBC FX / VIDEO MODE, and the MODS row that opens the mod manager. +-- GBC FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens +-- the mod manager. -- Rows are descriptors fed through the ui.options.rows hook, so mods can -- add their own; CANCEL is appended after the hook and stays fixed on the -- bottom line like pokered's. @@ -11,12 +12,15 @@ local PaletteFX = require("src.render.PaletteFX") local Tilt = require("src.render.Tilt") local GBCFX = require("src.render.GBCFX") +local Zoom = require("src.render.Zoom") +local TileRenderer = require("src.render.TileRenderer") local GameSpeed = require("src.core.GameSpeed") local VideoMode = require("src.core.VideoMode") local FrameCap = require("src.core.FrameCap") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local OptionRows = require("src.ui.OptionRows") +local Renderer = require("src.render.Renderer") local OptionsMenu = {} OptionsMenu.__index = OptionsMenu @@ -201,6 +205,37 @@ local function buildRows(game) GBCFX.setLevel(o.gbcfx) return true end }, + { id = "zoom", label = "ZOOM", + value = function(g) + return Zoom.offsetLabel(g.save.options.zoom or 0) + end, + step = function(g, dir) + local o = g.save.options + local S = Renderer:fitScale() + local lo, hi = Zoom.offsetRange(S) + local off = (o.zoom or 0) + dir + if off > hi then off = lo + elseif off < lo then off = hi end + o.zoom = off + Zoom.offset = off + return true + end }, + { id = "voidFill", label = "VOID FILL", + value = function(g) + return TileRenderer.voidFillLabel(g.save.options.voidFill) + end, + step = function(g, dir) + local o = g.save.options + local modes = TileRenderer.VOID_FILLS + local cur = o.voidFill or "trees" + local i = 1 + for idx, m in ipairs(modes) do + if m == cur then i = idx; break end + end + o.voidFill = modes[wrapIndex(i - 1 + dir, #modes) + 1] + TileRenderer.setVoidFill(o.voidFill) + return true + end }, { id = "videoMode", label = "VIDEO MODE", value = function(g) return VideoMode.modeLabel(g.save.options.videoMode) diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index ff306751..dd365866 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -754,6 +754,97 @@ do love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor end +-- ------- battle transition cascade outside the OG square + white letterbox +do + local rects = {} + local color = { 1, 1, 1, 1 } + local savedRect, savedColor = love.graphics.rectangle, love.graphics.setColor + local savedGetDims = love.graphics.getDimensions + local savedGetPix = love.graphics.getPixelDimensions + -- taller-than-fit window so the 160x144 letterbox leaves real voids + -- (default 640x576 is an exact 4x fit with nothing outside the square) + love.graphics.getDimensions = function() return 800, 600 end + love.graphics.getPixelDimensions = function() return 800, 600 end + love.graphics.rectangle = function(mode, x, y, w, h) + rects[#rects + 1] = { + mode = mode, x = x, y = y, w = w, h = h, + r = color[1], g = color[2], b = color[3], a = color[4], + } + end + love.graphics.setColor = function(r, g, b, a) + color[1], color[2], color[3], color[4] = r, g, b, a or 1 + end + + Renderer:init() + local btGame = { renderer = Renderer, stack = { pop = noop } } + local wipe = BattleTransition.new(btGame, nil, {}) + wipe.phase = "wipe" + wipe.t = math.floor(wipe.wipeLen / 2) + Renderer:beginFrame(true) + Renderer:beginWorldPass() + Renderer:endWorldPass() + wipe:draw() + check(Renderer.battleCascadeProg ~= nil + and Renderer.battleCascadeProg > 0 + and Renderer.battleCascadeProg < 1, + "battle wipe publishes mid-progress cascade to the renderer") + rects = {} + Renderer:endFrame(nil, fullWorldZones()) + local cascadeTile + for _, r in ipairs(rects) do + -- fitScale at 800x600 is min(5,4)=4 → 32px tiles outside the letterbox + if r.mode == "fill" and r.r == 0 and r.a == 1 and r.w == 32 and r.h == 32 then + cascadeTile = r + break + end + end + check(cascadeTile ~= nil, + "endFrame paints cascading black tiles outside the OG wipe square") + love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor + love.graphics.getDimensions = savedGetDims + love.graphics.getPixelDimensions = savedGetPix +end + +do + -- BattleState asks for white letterbox voids instead of black bars + local BattleState = require("src.battle.BattleState") + check(BattleState.letterboxWhite == true, + "BattleState opts into white letterbox fill") + local rects = {} + local color = { 1, 1, 1, 1 } + local savedRect, savedColor = love.graphics.rectangle, love.graphics.setColor + love.graphics.rectangle = function(mode, x, y, w, h) + rects[#rects + 1] = { + mode = mode, x = x, y = y, w = w, h = h, + r = color[1], g = color[2], b = color[3], a = color[4], + } + end + love.graphics.setColor = function(r, g, b, a) + color[1], color[2], color[3], color[4] = r, g, b, a or 1 + end + local Game = require("src.core.Game") + local savedStack = Game.stack + Game.stack = { + states = { { letterboxWhite = true, isOpaque = true } }, + visibleBase = function() return 1 end, + } + Renderer:init() + Renderer:beginFrame(false) + rects = {} + Renderer:endFrame(nil, nil) + local clear + for _, r in ipairs(rects) do + if r.mode == "fill" and r.x == 0 and r.y == 0 and r.w == 640 and r.h == 576 then + clear = r + break + end + end + check(clear and clear.r == 1 and clear.g == 1 and clear.b == 1, + "endFrame fills the window white when the visible base wants letterboxWhite") + Game.stack = savedStack + love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor +end + -- ------- the transition.style hook local stack = { pop = function() end } diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 5db78621..0b40ac6b 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -206,7 +206,8 @@ end local om = OptionsMenu.new(optGame()) local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset", "musicVol", "sfxVol", "musicFilter", "colors", "tilt", - "gbcfx", "videoMode", "fpsCap", "speed", "mods", "controls" } + "gbcfx", "zoom", "voidFill", "videoMode", "fpsCap", + "speed", "mods", "controls" } check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)") for i, id in ipairs(WANT_IDS) do check(om.rows[i].id == id, "options row order: " .. id) @@ -236,17 +237,35 @@ check(om.game.save.options.musicVol == 6, "music volume steps down") for _ = 1, 10 do om.rows[5].step(om.game, -1) end check(om.game.save.options.musicVol == 0, "music volume clamps at 0") +-- ZOOM / VOID FILL rows +local Zoom = require("src.render.Zoom") +local TileRenderer = require("src.render.TileRenderer") +om.game.save.options.zoom = 0 +Zoom.offset = 0 +check(om.rows[11].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0") +om.rows[11].step(om.game, 1) +check(om.game.save.options.zoom == 1 and Zoom.offset == 1, + "ZOOM row steps to IN1") +om.rows[12].step(om.game, 1) +check(om.game.save.options.voidFill == "water" + and TileRenderer.voidFill == "water", + "VOID FILL row cycles TREES → WATER") +om.rows[12].step(om.game, 1) +check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK") +om.rows[12].step(om.game, 1) +check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES") + -- the MAX FPS row cycles the render-cap steps and shows the value plain om.game.save.options.fpsCap = nil -check(om.rows[12].value(om.game) == "60", +check(om.rows[14].value(om.game) == "60", "MAX FPS row defaults to 60 with no saved cap") -om.rows[12].step(om.game, 1) +om.rows[14].step(om.game, 1) check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75") -check(om.rows[12].value(om.game) == "75", "the MAX FPS row renders the cap") +check(om.rows[14].value(om.game) == "75", "the MAX FPS row renders the cap") om.game.save.options.fpsCap = 160 -om.rows[12].step(om.game, 1) +om.rows[14].step(om.game, 1) check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30") -om.rows[12].step(om.game, -1) +om.rows[14].step(om.game, -1) check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling") -- ------- FrameCap normalize / cycle (issue #88) @@ -276,7 +295,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6 -- the MODS row is the manager's discoverable home local mgGame = optGame() om = OptionsMenu.new(mgGame) -om.rows[14].activate(mgGame) +om.rows[16].activate(mgGame) check(getmetatable(mgGame.stack:top()) == ManagerState, "the MODS row opens the manager") check(mgGame.stack:top().screenId == "ManagerState", @@ -286,7 +305,7 @@ check(mgGame.stack:top().screenId == "ManagerState", local BindingsMenu = require("src.ui.BindingsMenu") local cbGame = optGame() om = OptionsMenu.new(cbGame) -om.rows[15].activate(cbGame) +om.rows[17].activate(cbGame) local bm = cbGame.stack:top() check(getmetatable(bm) == BindingsMenu, "the CONTROLS row opens the rebind list") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 83edcb96..5c56b70d 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1573,6 +1573,13 @@ do eq(back.options.colors, "og", "options.lua round-trips colors") eq(back.options.tilt, 2, "options.lua round-trips tilt") eq(back.options.gbcfx, 3, "options.lua round-trips gbcfx") + -- zoom / voidFill ride the same options.lua path when present + rep.options.zoom = -2 + rep.options.voidFill = "water" + SD.saveOptions(rep.options) + local optBack = SD.loadOptions() + eq(optBack.zoom, -2, "options.lua round-trips zoom") + eq(optBack.voidFill, "water", "options.lua round-trips voidFill") local origOpts, loadedOpts = rep.options, back.options rep.options, back.options = nil, nil local same, where = deepEq(rep, back, "save") @@ -1720,6 +1727,16 @@ do ow.runner = { isRunning = function() return true end } check(not Zoom.gateOK(ow, ow), "gate closed while a script runs") Zoom.reset() + + -- cycle wraps survey → … → close-up → survey; labels track offset + eq(Zoom.offsetLabel(0), "FIT", "zoom label FIT at offset 0") + eq(Zoom.offsetLabel(-2), "OUT2", "zoom label OUT for negative offset") + eq(Zoom.offsetLabel(3), "IN3", "zoom label IN for positive offset") + Zoom.offset = S -- max close-up + eq(Zoom.cycle(S), 1 - S, "zoom cycle wraps from max in to full survey") + Zoom.applyOptions({ zoom = -1 }) + eq(Zoom.offset, -1, "applyOptions restores saved zoom offset") + Zoom.reset() end -- ---------------------------------------------------------------- zoom camera @@ -2307,6 +2324,8 @@ do eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC") eq(og.save.options.tilt, 0, "new saves default TILT to OFF") eq(og.save.options.gbcfx, 0, "new saves default GBC FX to OFF") + eq(og.save.options.zoom, 0, "new saves default ZOOM to FIT") + eq(og.save.options.voidFill, "trees", "new saves default VOID FILL to TREES") eq(og.save.options.videoMode, "windowed", "new saves default VIDEO MODE to WINDOWED") eq(om.scroll, 0, "options viewport starts at the top") @@ -2345,7 +2364,25 @@ do for _ = 1, 4 do press("a") end eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF") press("down") - eq(om.index, 11, "cursor reaches VIDEO MODE") + eq(om.index, 11, "cursor reaches ZOOM") + local ZoomOpt = require("src.render.Zoom") + press("a") + eq(og.save.options.zoom, 1, "A cycles ZOOM to IN1") + eq(ZoomOpt.offset, 1, "Zoom.offset tracks ZOOM option") + press("left") + eq(og.save.options.zoom, 0, "left steps ZOOM back to FIT") + press("down") + eq(om.index, 12, "cursor reaches VOID FILL") + local TR = require("src.render.TileRenderer") + press("a") + eq(og.save.options.voidFill, "water", "A cycles VOID FILL to WATER") + eq(TR.voidFill, "water", "TileRenderer.voidFill tracks VOID FILL option") + press("a") + eq(og.save.options.voidFill, "black", "A cycles VOID FILL to BLACK") + press("a") + eq(og.save.options.voidFill, "trees", "VOID FILL wraps back to TREES") + press("down") + eq(om.index, 13, "cursor reaches VIDEO MODE") press("a") eq(og.save.options.videoMode, "borderless", "A cycles VIDEO MODE to BORDERLESS") @@ -2353,7 +2390,7 @@ do eq(og.save.options.videoMode, "windowed", "VIDEO MODE wraps back to WINDOWED") press("down") - eq(om.index, 12, "cursor reaches MAX FPS") + eq(om.index, 14, "cursor reaches MAX FPS") press("a") eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75") eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option") @@ -2362,7 +2399,7 @@ do for _ = 1, #FrameCap.STEPS - 1 do press("a") end eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60") press("down") - eq(om.index, 13, "cursor reaches GAME SPEED") + eq(om.index, 15, "cursor reaches GAME SPEED") press("a") eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X") -- Driven by the level list rather than a literal press count: adding a @@ -2371,25 +2408,27 @@ do for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL") press("down") - eq(om.index, 14, "cursor reaches MODS") + eq(om.index, 16, "cursor reaches MODS") press("down") - eq(om.index, 15, "cursor reaches CONTROLS") + eq(om.index, 17, "cursor reaches CONTROLS") press("down") - eq(om.index, 16, "CANCEL stays the fixed final row") - eq(om.scroll, 11, "CANCEL keeps the last option boxes on screen") + eq(om.index, 18, "CANCEL stays the fixed final row") + eq(om.scroll, 13, "CANCEL keeps the last option boxes on screen") om:draw() -- smoke: scrolled layout draws under the headless stub press("a") check(popped, "A on CANCEL closes the options menu") local om2 = OptionsMenu.new(og) OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {} - eq(om2.index, 16, "up from the top wraps to CANCEL") - eq(om2.scroll, 11, "wrapping to CANCEL scrolls to the tail") + eq(om2.index, 18, "up from the top wraps to CANCEL") + eq(om2.scroll, 13, "wrapping to CANCEL scrolls to the tail") -- headless-safe: no love.audio, setters only update internal state require("src.core.Music").applyOptions(og.save.options) require("src.core.Sound").applyOptions(og.save.options) PaletteFX.applyOptions(og.save.options) Tilt.applyOptions(og.save.options) GBCFX.applyOptions(og.save.options) + require("src.render.Zoom").applyOptions(og.save.options) + require("src.render.TileRenderer").applyOptions(og.save.options) VideoMode.applyOptions(og.save.options) end end @@ -2727,18 +2766,26 @@ end -- ================= BUGS.md batch: border-tree ================= do --- ---------------------------------------------------------------- border fill (tree wall) +-- ---------------------------------------------------------------- border fill (tree wall / VOID FILL) local TileRenderer = require("src.render.TileRenderer") --- OVERWORLD maps fill beyond-edge space with the solid tree wall $0F --- (ViridianCity/CeruleanCity/CeladonCity border_block: four regular-tree --- metatiles); per-map borders like Pallet's all-grass $0B (the --- CutTreeBlockSwaps $0B->$0A block) only apply to other tilesets +-- OVERWORLD maps fill beyond-edge space from VOID FILL (default trees $0F); +-- per-map borders like Pallet's all-grass $0B only apply to other tilesets +TileRenderer.setVoidFill("trees") eq(TileRenderer.borderBlockFor({ def = { tileset = "OVERWORLD", borderBlock = 33 } }), 0x0F, "OVERWORLD border fill uses the tree wall block") eq(TileRenderer.borderBlockFor({ def = { tileset = "HOUSE", borderBlock = 7 } }), 7, "interior border fill keeps the map's border block") eq(TileRenderer.borderBlockFor({ def = Data.maps.PALLET_TOWN }), 0x0F, "Pallet Town border fill is trees, not its all-grass border block") +TileRenderer.setVoidFill("water") +eq(TileRenderer.borderBlockFor({ def = { tileset = "OVERWORLD", borderBlock = 33 } }), 0x43, + "VOID FILL water uses the solid water border block") +TileRenderer.setVoidFill("black") +eq(TileRenderer.borderBlockFor({ def = { tileset = "OVERWORLD", borderBlock = 33 } }), false, + "VOID FILL black disables the tiled border block") +eq(TileRenderer.borderBlockFor({ def = { tileset = "HOUSE", borderBlock = 7 } }), 7, + "VOID FILL does not change interior borders") +TileRenderer.setVoidFill("trees") local treeWallCuttable = false for _, swap in ipairs(Data.field.cutTreeSwaps) do if swap.before == 0x0F then treeWallCuttable = true end