Compare commits

...

1 Commits

Author SHA1 Message Date
bryanthaboi 6306975352 new options, and hot keys and readme clean up 2026-07-24 10:10:51 -04:00
13 changed files with 557 additions and 75 deletions
+26 -2
View File
@@ -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
+38 -11
View File
@@ -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`).
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.
+3
View File
@@ -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.
+15 -1
View File
@@ -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)
+5 -1
View File
@@ -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)
+5
View File
@@ -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
+61 -1
View File
@@ -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)
+148 -31
View File
@@ -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
+41 -5
View File
@@ -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)
+36 -1
View File
@@ -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)
+91
View File
@@ -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 }
+27 -8
View File
@@ -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")
+61 -14
View File
@@ -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