mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 13:09:54 +02:00
new options, and hot keys and readme clean up (#148)
This commit is contained in:
@@ -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
@@ -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.
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user