new options, and hot keys and readme clean up (#148)

This commit is contained in:
bryanthaboi
2026-07-24 10:12:27 -04:00
committed by GitHub
parent 819150f50f
commit d872010209
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 ## Controls
arrow keys or WASD move; Z, Enter, or Space is A; X or Backspace is | Action | Keyboard | Controller |
B; Escape opens START. F1 saves and F2 loads. Controllers are supported. |--------|----------|------------|
| 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 ## Running From Source
+37 -10
View File
@@ -8,27 +8,42 @@ behavior is in docs/behavior-porting-notes.md.
## Survey zoom ## Survey zoom
The mouse wheel (or `-`/`=`) zooms the overworld between 1 pixel per world The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4`
pixel (full survey) and 2× the window fit scale (close-up), in crisp zooms the overworld between 1 pixel per world pixel (full survey) and 2×
integer steps. This has no Game Boy equivalent: 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 - Connected maps render their full bodies, and their NPCs appear as
visual-only "ghosts", they wander but have no sight lines, triggers, visual-only "ghosts", they wander but have no sight lines, triggers,
dialogue, or collision until the map is actually entered. dialogue, or collision until the map is actually entered.
- Menus, text boxes, and battles draw at normal scale on top of the - 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 zoomed world. Zoom input is ignored while a script, menu, or battle is
active; the zoom level persists across warps and is never saved. active; the zoom offset is persisted as `save.options.zoom` (default
- Beyond the border ring the border block repeats indefinitely (interiors `0` = FIT) and survives New Game via `options.lua`.
stay black, seaside towns stay water, except OVERWORLD-tileset maps, - Hotkey `4` ticks through every integer zoom level (survey → FIT →
whose beyond-edge space fills with the solid tree wall instead of the close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`.
per-map border block), and each visible map area is colorized with its - Beyond the border ring the void fill repeats indefinitely (see VOID
own SGB palette (the original recolored the whole screen per map). 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 - 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 don't pop in and out, and ghost NPCs share instances with the real ones
so their wander positions persist across seamless connection crossings so their wander positions persist across seamless connection crossings
(a warp or fresh map entry still respawns everything at its script (a warp or fresh map entry still respawns everything at its script
position, like the original's per-entry sprite init). 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 ## Tilt mode
The `3` key (and the Options menu TILT row) cycles a visual-only perspective 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 Options persist in a standalone `options.lua` (separate from the game
progress `save.lua`), so audio/display/battle preferences survive New 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 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 flushes the live options. Old saves that still embed an `options` table are
migrated once into `options.lua` on load. 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 hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
SuperPalettes + per-species mon colors) SuperPalettes + per-species mon colors)
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming - 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` - GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60), - 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 = {} local BattleState = {}
BattleState.__index = BattleState BattleState.__index = BattleState
BattleState.isOpaque = true 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 -- Battle colors itself per-pixel (species pics + HP bar tints), so the
-- SGB whole-screen remap must not run over it. -- SGB whole-screen remap must not run over it.
+15 -1
View File
@@ -246,7 +246,11 @@ end
function Game:zoomStep(delta) function Game:zoomStep(delta)
local Zoom = require("src.render.Zoom") local Zoom = require("src.render.Zoom")
if not Zoom.gateOK(self.stack:top(), self.overworld) then return end 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 end
function Game:wheelmoved(_, dy) function Game:wheelmoved(_, dy)
@@ -320,6 +324,14 @@ function Game:keypressed(key)
self:writeOptions() self:writeOptions()
end end
return 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 elseif key == "5" then
-- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on -- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on
-- desktop. Mobile refuses the present shader (issue #136). -- desktop. Mobile refuses the present shader (issue #136).
@@ -445,6 +457,8 @@ function Game:applyOptions(opts)
if Sound.applyOptions then Sound.applyOptions(opts) end if Sound.applyOptions then Sound.applyOptions(opts) end
require("src.render.PaletteFX").applyOptions(opts) require("src.render.PaletteFX").applyOptions(opts)
require("src.render.Tilt").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 -- returns true when a persisted GBC FX level was cleared on mobile
local gbcCleared = require("src.render.GBCFX").applyOptions(opts) local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts) require("src.core.VideoMode").applyOptions(opts)
+5 -1
View File
@@ -186,10 +186,14 @@ function SaveData.defaultOptions()
musicFilter = 0, musicFilter = 0,
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua) -- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
speed = 1, speed = 1,
-- port display options (OptionsMenu / hotkeys 2/3/5) -- port display options (OptionsMenu / hotkeys 2/3/4/5)
colors = "gbc", colors = "gbc",
tilt = 0, tilt = 0,
gbcfx = 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 -- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed", videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua) -- 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) love.graphics.setColor(0, 0, 0, 1)
local prog = math.min(1, self.t / self.wipeLen) 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 local style = self.style
-- a registered style may draw itself; the eight built-ins do not -- 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 -- warp-fade overlay from Transition (issue #121); cleared each frame so
-- a popped transition cannot leave a sticky black veil -- a popped transition cannot leave a sticky black veil
self.worldFadeAlpha = nil 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 -- last frame's trueColor rects and sprite redraws go before anything
-- draws this one -- draws this one
PaletteFX.clearTrueColor() PaletteFX.clearTrueColor()
@@ -107,6 +109,46 @@ function Renderer:beginFrame(transparent)
end end
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() function Renderer:beginWorldPass()
local vw, vh = self:worldViewSize() local vw, vh = self:worldViewSize()
if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw
@@ -349,7 +391,20 @@ function Renderer:endFrame(zones, worldZones)
present = self.presentCanvas present = self.presentCanvas
love.graphics.setCanvas(present) love.graphics.setCanvas(present)
end 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.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1) 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.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end 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 end
-- UI stays in the classic centered GB letterbox -- UI stays in the classic centered GB letterbox
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph) blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
+132 -15
View File
@@ -10,19 +10,57 @@ TileRenderer.__index = TileRenderer
local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks) local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks)
-- OVERWORLD maps fill beyond-edge space with the solid tree wall -- OVERWORLD maps fill beyond-edge space from save.options.voidFill:
-- (blockset $0F: four regular-tree metatiles, tiles $40/$41/$50/$51, -- trees (default) — solid tree wall $0F (Viridian/Cerulean/Celadon)
-- the border block of ViridianCity/CeruleanCity/CeladonCity et al.), -- water — solid water $43 (Cinnabar/Route 19 border block)
-- not each map's own border_block, which can be grass ($0B, the -- black — solid black (no tiled metatile)
-- CutTreeBlockSwaps $0B->$0A cut-grass block) or water; other -- Other tilesets keep their designated border (interiors stay black/void).
-- tilesets keep their designated border (interiors stay black/void)
local TREE_WALL_BLOCK = 0x0F local TREE_WALL_BLOCK = 0x0F
local WATER_BORDER_BLOCK = 0x43
TileRenderer.VOID_FILLS = { "trees", "water", "black" }
TileRenderer.voidFill = "trees"
local function borderBlockFor(map) 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 return map.def.borderBlock
end end
TileRenderer.borderBlockFor = borderBlockFor 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 imageCache = {}
local function getImage(path) local function getImage(path)
@@ -487,11 +525,17 @@ function TileRenderer.new(map, data)
self.anims = anims self.anims = anims
self.claimedBy = claimedBy self.claimedBy = claimedBy
-- a repeating 32x32 image of the border block, tiled behind -- border-fill image is built lazily in :ensureBorderFill so a VOID FILL
-- everything the 3-block ring doesn't cover (the survey zoom sees -- option change can swap trees/water/black without reloading the map
-- far past the ring; interiors keep their black border this way) self.borderFillMode = nil
pcall(function() self.borderFill = nil
local border = map.tileset.blocks[borderBlockFor(map) + 1]
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 if not border then return end
local canvas = love.graphics.newCanvas(32, 32) local canvas = love.graphics.newCanvas(32, 32)
love.graphics.push("all") love.graphics.push("all")
@@ -509,20 +553,90 @@ function TileRenderer.new(map, data)
img:setWrap("repeat", "repeat") img:setWrap("repeat", "repeat")
img:setFilter("nearest", "nearest") img:setFilter("nearest", "nearest")
self.borderFill = img self.borderFill = img
end) end
return self -- 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 end
-- tile the border block across the whole view (world-aligned so it -- tile the border block across the whole view (world-aligned so it
-- meshes seamlessly with the ring batch) -- meshes seamlessly with the ring batch)
function TileRenderer:drawBorderFill(camX, camY, vw, vh) 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 if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end
local x, y = math.floor(camX), math.floor(camY) local x, y = math.floor(camX), math.floor(camY)
-- one reused Quad per renderer, mutated in place: this runs every -- one reused Quad per renderer, mutated in place: this runs every
-- overworld frame, so allocating a fresh Quad here churned the GC -- overworld frame, so allocating a fresh Quad here churned the GC
local q = self.borderQuad 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 if q then
q:setViewport(x, y, vw, vh, 32, 32) q:setViewport(x, y, vw, vh, 32, 32)
else else
@@ -730,6 +844,9 @@ function TileRenderer:releaseBatches()
safeRelease(self.winBatch); self.winBatch = nil safeRelease(self.winBatch); self.winBatch = nil
safeRelease(self.borderFill); self.borderFill = nil safeRelease(self.borderFill); self.borderFill = nil
safeRelease(self.borderQuad); self.borderQuad = nil safeRelease(self.borderQuad); self.borderQuad = nil
-- shared shift-variant cache; only drop the reference
self.borderWaterTextures = nil
self.borderFillMode = nil
self.win = nil self.win = nil
if self.quads then if self.quads then
for _, q in pairs(self.quads) do safeRelease(q) end 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 -- Overworld survey zoom: integer pixels-per-world-pixel scales stepped
-- by the mouse wheel. Stored as an offset from the window fit scale S -- by the mouse wheel, Options ZOOM row, or hotkey `4`. Stored as an
-- so a resize keeps the relative zoom. Session-only; never saved. -- 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) -- Spec: docs/new-features.md (survey zoom)
local Zoom = {} local Zoom = {}
@@ -12,16 +13,51 @@ function Zoom.scale(S)
return math.max(1, math.min(2 * S, S + Zoom.offset)) return math.max(1, math.min(2 * S, S + Zoom.offset))
end 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) function Zoom.step(delta, S)
Zoom.offset = Zoom.offset + delta Zoom.offset = Zoom.clampOffset(Zoom.offset + delta, S)
if S + Zoom.offset < 1 then Zoom.offset = 1 - S end return Zoom.offset
if S + Zoom.offset > 2 * S then Zoom.offset = S end 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 end
function Zoom.reset() function Zoom.reset()
Zoom.offset = 0 Zoom.offset = 0
end 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 -- 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) -- (legacy GB-framed size; prefer fillViewSize for the live world pass)
function Zoom.viewSize(S, w, h) function Zoom.viewSize(S, w, h)
+36 -1
View File
@@ -3,7 +3,8 @@
-- (cycles the merged rulesets registry; gen1_faithful keeps the original -- (cycles the merged rulesets registry; gen1_faithful keeps the original
-- quirks), plus the port's audio rows and display rows: music/SFX -- 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 / -- 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 -- 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 -- add their own; CANCEL is appended after the hook and stays fixed on the
-- bottom line like pokered's. -- bottom line like pokered's.
@@ -11,12 +12,15 @@
local PaletteFX = require("src.render.PaletteFX") local PaletteFX = require("src.render.PaletteFX")
local Tilt = require("src.render.Tilt") local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX") 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 GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode") local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap") local FrameCap = require("src.core.FrameCap")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows") local OptionRows = require("src.ui.OptionRows")
local Renderer = require("src.render.Renderer")
local OptionsMenu = {} local OptionsMenu = {}
OptionsMenu.__index = OptionsMenu OptionsMenu.__index = OptionsMenu
@@ -201,6 +205,37 @@ local function buildRows(game)
GBCFX.setLevel(o.gbcfx) GBCFX.setLevel(o.gbcfx)
return true return true
end }, 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", { id = "videoMode", label = "VIDEO MODE",
value = function(g) value = function(g)
return VideoMode.modeLabel(g.save.options.videoMode) return VideoMode.modeLabel(g.save.options.videoMode)
+91
View File
@@ -754,6 +754,97 @@ do
love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor
end 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 -- ------- the transition.style hook
local stack = { pop = function() end } local stack = { pop = function() end }
+27 -8
View File
@@ -206,7 +206,8 @@ end
local om = OptionsMenu.new(optGame()) local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset", local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
"musicVol", "sfxVol", "musicFilter", "colors", "tilt", "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)") check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
for i, id in ipairs(WANT_IDS) do for i, id in ipairs(WANT_IDS) do
check(om.rows[i].id == id, "options row order: " .. id) 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 for _ = 1, 10 do om.rows[5].step(om.game, -1) end
check(om.game.save.options.musicVol == 0, "music volume clamps at 0") 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 -- the MAX FPS row cycles the render-cap steps and shows the value plain
om.game.save.options.fpsCap = nil 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") "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.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.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") 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") check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
-- ------- FrameCap normalize / cycle (issue #88) -- ------- 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 -- the MODS row is the manager's discoverable home
local mgGame = optGame() local mgGame = optGame()
om = OptionsMenu.new(mgGame) om = OptionsMenu.new(mgGame)
om.rows[14].activate(mgGame) om.rows[16].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState, check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager") "the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState", check(mgGame.stack:top().screenId == "ManagerState",
@@ -286,7 +305,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu") local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame() local cbGame = optGame()
om = OptionsMenu.new(cbGame) om = OptionsMenu.new(cbGame)
om.rows[15].activate(cbGame) om.rows[17].activate(cbGame)
local bm = cbGame.stack:top() local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu, check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list") "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.colors, "og", "options.lua round-trips colors")
eq(back.options.tilt, 2, "options.lua round-trips tilt") eq(back.options.tilt, 2, "options.lua round-trips tilt")
eq(back.options.gbcfx, 3, "options.lua round-trips gbcfx") 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 local origOpts, loadedOpts = rep.options, back.options
rep.options, back.options = nil, nil rep.options, back.options = nil, nil
local same, where = deepEq(rep, back, "save") local same, where = deepEq(rep, back, "save")
@@ -1720,6 +1727,16 @@ do
ow.runner = { isRunning = function() return true end } ow.runner = { isRunning = function() return true end }
check(not Zoom.gateOK(ow, ow), "gate closed while a script runs") check(not Zoom.gateOK(ow, ow), "gate closed while a script runs")
Zoom.reset() 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 end
-- ---------------------------------------------------------------- zoom camera -- ---------------------------------------------------------------- zoom camera
@@ -2307,6 +2324,8 @@ do
eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC") 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.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.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", eq(og.save.options.videoMode, "windowed",
"new saves default VIDEO MODE to WINDOWED") "new saves default VIDEO MODE to WINDOWED")
eq(om.scroll, 0, "options viewport starts at the top") eq(om.scroll, 0, "options viewport starts at the top")
@@ -2345,7 +2364,25 @@ do
for _ = 1, 4 do press("a") end for _ = 1, 4 do press("a") end
eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF") eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF")
press("down") 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") press("a")
eq(og.save.options.videoMode, "borderless", eq(og.save.options.videoMode, "borderless",
"A cycles VIDEO MODE to BORDERLESS") "A cycles VIDEO MODE to BORDERLESS")
@@ -2353,7 +2390,7 @@ do
eq(og.save.options.videoMode, "windowed", eq(og.save.options.videoMode, "windowed",
"VIDEO MODE wraps back to WINDOWED") "VIDEO MODE wraps back to WINDOWED")
press("down") press("down")
eq(om.index, 12, "cursor reaches MAX FPS") eq(om.index, 14, "cursor reaches MAX FPS")
press("a") press("a")
eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75") 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") 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 for _ = 1, #FrameCap.STEPS - 1 do press("a") end
eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60") eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60")
press("down") press("down")
eq(om.index, 13, "cursor reaches GAME SPEED") eq(om.index, 15, "cursor reaches GAME SPEED")
press("a") press("a")
eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X") 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 -- 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 for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL") eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL")
press("down") press("down")
eq(om.index, 14, "cursor reaches MODS") eq(om.index, 16, "cursor reaches MODS")
press("down") press("down")
eq(om.index, 15, "cursor reaches CONTROLS") eq(om.index, 17, "cursor reaches CONTROLS")
press("down") press("down")
eq(om.index, 16, "CANCEL stays the fixed final row") eq(om.index, 18, "CANCEL stays the fixed final row")
eq(om.scroll, 11, "CANCEL keeps the last option boxes on screen") eq(om.scroll, 13, "CANCEL keeps the last option boxes on screen")
om:draw() -- smoke: scrolled layout draws under the headless stub om:draw() -- smoke: scrolled layout draws under the headless stub
press("a") press("a")
check(popped, "A on CANCEL closes the options menu") check(popped, "A on CANCEL closes the options menu")
local om2 = OptionsMenu.new(og) local om2 = OptionsMenu.new(og)
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {} OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
eq(om2.index, 16, "up from the top wraps to CANCEL") eq(om2.index, 18, "up from the top wraps to CANCEL")
eq(om2.scroll, 11, "wrapping to CANCEL scrolls to the tail") eq(om2.scroll, 13, "wrapping to CANCEL scrolls to the tail")
-- headless-safe: no love.audio, setters only update internal state -- headless-safe: no love.audio, setters only update internal state
require("src.core.Music").applyOptions(og.save.options) require("src.core.Music").applyOptions(og.save.options)
require("src.core.Sound").applyOptions(og.save.options) require("src.core.Sound").applyOptions(og.save.options)
PaletteFX.applyOptions(og.save.options) PaletteFX.applyOptions(og.save.options)
Tilt.applyOptions(og.save.options) Tilt.applyOptions(og.save.options)
GBCFX.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) VideoMode.applyOptions(og.save.options)
end end
end end
@@ -2727,18 +2766,26 @@ end
-- ================= BUGS.md batch: border-tree ================= -- ================= BUGS.md batch: border-tree =================
do do
-- ---------------------------------------------------------------- border fill (tree wall) -- ---------------------------------------------------------------- border fill (tree wall / VOID FILL)
local TileRenderer = require("src.render.TileRenderer") local TileRenderer = require("src.render.TileRenderer")
-- OVERWORLD maps fill beyond-edge space with the solid tree wall $0F -- OVERWORLD maps fill beyond-edge space from VOID FILL (default trees $0F);
-- (ViridianCity/CeruleanCity/CeladonCity border_block: four regular-tree -- per-map borders like Pallet's all-grass $0B only apply to other tilesets
-- metatiles); per-map borders like Pallet's all-grass $0B (the TileRenderer.setVoidFill("trees")
-- CutTreeBlockSwaps $0B->$0A block) only apply to other tilesets
eq(TileRenderer.borderBlockFor({ def = { tileset = "OVERWORLD", borderBlock = 33 } }), 0x0F, eq(TileRenderer.borderBlockFor({ def = { tileset = "OVERWORLD", borderBlock = 33 } }), 0x0F,
"OVERWORLD border fill uses the tree wall block") "OVERWORLD border fill uses the tree wall block")
eq(TileRenderer.borderBlockFor({ def = { tileset = "HOUSE", borderBlock = 7 } }), 7, eq(TileRenderer.borderBlockFor({ def = { tileset = "HOUSE", borderBlock = 7 } }), 7,
"interior border fill keeps the map's border block") "interior border fill keeps the map's border block")
eq(TileRenderer.borderBlockFor({ def = Data.maps.PALLET_TOWN }), 0x0F, eq(TileRenderer.borderBlockFor({ def = Data.maps.PALLET_TOWN }), 0x0F,
"Pallet Town border fill is trees, not its all-grass border block") "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 local treeWallCuttable = false
for _, swap in ipairs(Data.field.cutTreeSwaps) do for _, swap in ipairs(Data.field.cutTreeSwaps) do
if swap.before == 0x0F then treeWallCuttable = true end if swap.before == 0x0F then treeWallCuttable = true end