diff --git a/src/core/Game2.lua b/src/core/Game2.lua index bddb3291..77856d79 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -1968,6 +1968,7 @@ function Game2:applyOptions() }) require("src.core.VideoMode").applyOptions(options) require("src.core.FrameCap").applyOptions(options) + require("src.world.gen2.BorderFill").applyOptions(options) local GBCFX = require("src.render.GBCFX") if GBCFX.applyOptions(options) and self.save then -- applyOptions returns true when it had to clear an unsupported level. diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index 04b3a637..ee2ee4f0 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -274,6 +274,9 @@ Save.DEFAULT_OPTIONS = { color = "gbc", videoMode = "windowed", fpsCap = 60, + -- VOID FILL: fade | water | trees | black. fade is each map header's own + -- border block with the dissolve across a boundary (#1418). + voidFill = "fade", musicVol = 7, -- 0-7, like the GB's NR50 master volume sfxVol = 7, -- 0-7 musicFilter = 0, -- low-pass steps, 0 = off diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index b4f5d3d9..43a820b4 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -529,9 +529,9 @@ end -- Gold reads NONE of the rows above. Its OPTION screen writes a different -- set of names, several of which collide with Gen 1's at a different TYPE -- (battleStyle "SHIFT" vs "shift", textSpeed a label vs a frame delay), and --- its renderer has no battle layout, no SGB palette packs and no void fill -- --- so a gear opened on the Gold tab used to offer a dozen controls that did --- nothing and hide the seven that the cart itself has. +-- its renderer has no battle layout and no SGB palette packs -- so a gear +-- opened on the Gold tab used to offer a dozen controls that did nothing +-- and hide the seven that the cart itself has. -- -- The block lives in options.lua under `gold`, which is exactly where -- src/core/gen2/Save.lua loadOptions reads it, so an edit here is live on the @@ -617,6 +617,21 @@ local function gen2Rows(opts, hooks) end) end + local okFill, BorderFill = pcall(require, "src.world.gen2.BorderFill") + if okFill and BorderFill.VOID_FILLS then + add(Strings("VOID FILL"), + function() return BorderFill.voidFillLabel(opts.voidFill) end, + function(dir) + local modes = BorderFill.VOID_FILLS + local cur, idx = opts.voidFill or "fade", 1 + for i, m in ipairs(modes) do + if m == cur then idx = i break end + end + opts.voidFill = modes[wrapIndex(idx - 1 + dir, #modes) + 1] + return true + end) + end + -- Same #136 gate as the Gen 1 row and the in-game one. local okFx, GBCFX = pcall(require, "src.render.GBCFX") if okFx and GBCFX.isSupported() then diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index e0e3c507..0ef01840 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -134,6 +134,19 @@ local ROWS = { text = function(options) return require("src.render.Zoom").offsetLabel(options.zoom or 0) end }, + -- VOID FILL: FADE is each map's own border block with the dissolve across + -- a boundary; WATER / TREES force one outdoor block; BLACK is a flat void. + -- #1418. Same key the Gen 1 OPTION screen uses, different ladder (FADE + -- is Gold's default because that is already what the maps call for). + { label = "VOID FILL", key = "voidFill", port = true, + cycle = function(options, delta) + local BorderFill = require("src.world.gen2.BorderFill") + BorderFill.setVoidFill(options.voidFill or "fade") + options.voidFill = BorderFill.cycle(delta) + end, + text = function(options) + return require("src.world.gen2.BorderFill").voidFillLabel(options.voidFill) + end }, { label = "TILT", key = "tilt", port = true, cycle = function(options, delta) local Tilt = require("src.render.Tilt") diff --git a/src/world/gen2/BorderFill.lua b/src/world/gen2/BorderFill.lua index 59718b36..be694dd5 100644 --- a/src/world/gen2/BorderFill.lua +++ b/src/world/gen2/BorderFill.lua @@ -152,16 +152,95 @@ function BorderFill.bake(atlas, tileset, blockId, bgSet, waterFrame) return img end +-- VOID FILL (#1418): the beyond-edge scenery under survey zoom. FADE (the +-- default) is each map header's own border block with the dissolve below; +-- WATER / TREES force one block on outdoor tilesets that actually have that +-- scenery; BLACK is a flat void. Indoor and cave tilesets have no tree wall +-- or water metatile, so water/trees fall through to the map's own border +-- rather than painting a random block. +-- +-- Canonical ids are the wMapBorderBlock values already used as those fills +-- in data/maps/attributes.asm: New Bark / Route 30 $05 trees, Cherrygrove +-- $35 water, Pallet $0f trees, Cinnabar $43 water, Ilex Forest $05 trees. +BorderFill.VOID_FILLS = { "fade", "water", "trees", "black" } +BorderFill.voidFill = "fade" + +local FILL_BLOCKS = { + TILESET_JOHTO = { trees = 0x05, water = 0x35 }, + TILESET_JOHTO_MODERN = { trees = 0x05, water = 0x35 }, + TILESET_KANTO = { trees = 0x0f, water = 0x43 }, + TILESET_FOREST = { trees = 0x05 }, +} + +function BorderFill.setVoidFill(mode) + local ok = false + for _, name in ipairs(BorderFill.VOID_FILLS) do + if name == mode then ok = true; break end + end + BorderFill.voidFill = ok and mode or "fade" +end + +function BorderFill.cycle(delta) + local cur = BorderFill.voidFill or "fade" + local at = 1 + for i, name in ipairs(BorderFill.VOID_FILLS) do + if name == cur then at = i; break end + end + local n = #BorderFill.VOID_FILLS + at = (at - 1 + (delta or 1)) % n + 1 + BorderFill.setVoidFill(BorderFill.VOID_FILLS[at]) + return BorderFill.voidFill +end + +function BorderFill.applyOptions(opts) + BorderFill.setVoidFill(opts and opts.voidFill or "fade") +end + +function BorderFill.voidFillLabel(mode) + mode = mode or BorderFill.voidFill or "fade" + if mode == "water" then return "WATER" end + if mode == "trees" then return "TREES" end + if mode == "black" then return "BLACK" end + -- trailing space blanks the 5-char WATER/TREES/BLACK from the value column + return "FADE " +end + +-- The metatile the void should bake, or false when BLACK skips tiling. +function BorderFill.fillBlock(def) + local mode = BorderFill.voidFill or "fade" + if mode == "black" then return false end + if (mode == "water" or mode == "trees") and def then + local fills = FILL_BLOCKS[def.tileset] + local block = fills and fills[mode] + if block ~= nil then return block end + end + return def and def.borderBlock or 0 +end + +-- Crossfade identity: FADE dissolves per map (Cherrygrove water -> Route 30 +-- trees). WATER/TREES dissolve only when the forced block itself changes +-- (Johto water -> Kanto water), so walking two Johto routes does not fade +-- identical water against itself. BLACK is one sheet everywhere. +function BorderFill.fillKey(def) + local mode = BorderFill.voidFill or "fade" + if mode == "black" then return "black" end + local block = BorderFill.fillBlock(def) + if mode == "water" or mode == "trees" then + return mode .. "|" .. tostring(def and def.tileset) .. "|" .. tostring(block) + end + return "fade|" .. tostring(def and def.id) +end + -- Each map header carries its OWN border block, so crossing a boundary swaps -- the whole void from one block to another: Cherrygrove's water becomes Route -- 30's trees between one frame and the next. On a 20x18 viewport that is a few -- pixels at the screen edge and nobody sees it; under survey zoom the void is -- most of the window, and the swap reads as the background popping. -- --- So the swap is dissolved rather than cut. `key` is the map the image belongs --- to, not the image itself: the same block gets re-baked by the daytime --- rollover, the COLOR option and the two-frame cave flicker, and a dissolve on --- any of those would smear the flicker into mush. +-- So the swap is dissolved rather than cut. `key` is the fill identity +-- (BorderFill.fillKey), not the image itself: the same block gets re-baked by +-- the daytime rollover, the COLOR option and the two-frame cave flicker, and a +-- dissolve on any of those would smear the flicker into mush. BorderFill.CROSSFADE_FRAMES = 20 -- The bookkeeping half, love-free so it can be checked without a canvas. diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 05e0cadf..4950bffe 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -7900,8 +7900,10 @@ function World:borderWaterFrame(def, tileset) end end if not tile then return nil end + local fill = BorderFill.fillBlock(def) + if fill == false then return nil end local block = tileset.blocks - and tileset.blocks[BorderFill.blockFor(0, def.borderBlock) + 1] + and tileset.blocks[BorderFill.blockFor(0, fill) + 1] if not block then return nil end local found = false for i = 1, 16 do @@ -7933,11 +7935,19 @@ function World:borderImageFor(mapId) if not def then return nil end local tileset = self.tilesets and self.tilesets[def.tileset] if not tileset then return nil end + -- VOID FILL black skips the tiled bake; drawGround paints a flat void. + local fill = BorderFill.fillBlock(def) + if fill == false then return nil end + local blockId = BorderFill.blockFor(0, fill) -- A border block made of water animates with the rest of the map, so this - -- frame's row joins the key: four bakes per map instead of one. + -- frame's row joins the key: four bakes per map instead of one. The VOID + -- FILL mode is in the key so switching FADE/WATER/TREES does not keep a + -- stale bake (#1418). local waterFrame = self:borderWaterFrame(def, tileset) local cacheKey = BorderFill.cacheKey(mapId .. "|" .. tostring(daytime) .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker) + .. "|" .. tostring(BorderFill.voidFill or "fade") + .. "|" .. tostring(blockId) .. "|" .. tostring(waterFrame and waterFrame.row or 0)) local cached = self.mapImages[cacheKey] if cached ~= nil then return cached or nil end @@ -7949,7 +7959,7 @@ function World:borderImageFor(mapId) bgSet = Palettes.withCaveFlicker(bgSet, flicker or 1) end local ok, img = pcall(BorderFill.bake, atlas, tileset, - BorderFill.blockFor(0, def.borderBlock), bgSet, waterFrame) + blockId, bgSet, waterFrame) -- `false` rather than nil: a bake that cannot be made (a headless run with -- no canvas support) must not be retried once per frame forever. self.mapImages[cacheKey] = (ok and img) or false @@ -9719,8 +9729,16 @@ function World:drawGround(s) else bw, bh = GameViewport.dimensions() end - BorderFill.draw(self, self:borderImageFor(self.map.id), - cam.x, cam.y, bw, bh, s, self.map.id) + if BorderFill.fillBlock(self.map.def) == false then + -- BLACK: World:draw clears to a brown letterbox, so the void itself + -- has to be an actual black sheet or the map sits on that colour. + G.setColor(0, 0, 0, 1) + G.rectangle("fill", 0, 0, bw, bh) + G.setColor(1, 1, 1, 1) + else + BorderFill.draw(self, self:borderImageFor(self.map.id), + cam.x, cam.y, bw, bh, s, BorderFill.fillKey(self.map.def)) + end end for _, nb in ipairs(self.neighbors) do G.draw(nb.image, diff --git a/tests/drivers/gold_void_fill_bug1418_test.lua b/tests/drivers/gold_void_fill_bug1418_test.lua new file mode 100644 index 00000000..07778cb7 --- /dev/null +++ b/tests/drivers/gold_void_fill_bug1418_test.lua @@ -0,0 +1,79 @@ +-- Gold VOID FILL (#1418): FADE (each map's own border, dissolving across a +-- boundary), WATER, TREES, or BLACK. Parks you on Cherrygrove zoomed out so +-- the void is most of the window, cycles the four modes, then hands the pad +-- over. Do not add POKEPORT_SPEED: the water anim and the dissolve both run +-- on the real clock. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_void_fill_bug1418_test.lua love . + +local U = require("tests.drivers.util") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-void-fill" + local BorderFill = require("src.world.gen2.BorderFill") + local Tilt = require("src.render.Tilt") + local Zoom = require("src.render.Zoom") + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[void] ok " .. label) + else + failures = failures + 1 + print("[void] FAIL " .. label .. " " .. tostring(detail)) + end + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + -- Cherrygrove's header border is water ($35). FADE shows that water, + -- TREES replaces it, BLACK drops the sheet. attributes.asm:123. + world:setMap("CHERRYGROVE_CITY", 22, 16, "down") + U.wait(10) + + Zoom.offset = -3 + world:rebuildNeighbors() + world:rebuildPeople({ seamless = true }) + Tilt.setLevel(3) + for _ = 1, 40 do + Tilt.update(1 / 60) + U.wait(1) + end + + local function show(mode, file, label) + BorderFill.setVoidFill(mode) + if game.options then game.options.voidFill = mode end + if game.save and game.save.options then + game.save.options.voidFill = mode + end + U.wait(8) + ok(label, BorderFill.voidFill == mode, BorderFill.voidFill) + U.shot(game, out .. "/" .. file) + end + + show("fade", "01-fade.png", "FADE is the live fill") + show("water", "02-water.png", "WATER forces the water block") + show("trees", "03-trees.png", "TREES forces the tree wall") + show("black", "04-black.png", "BLACK drops the tiled void") + show("fade", "05-fade-again.png", "and FADE restores the map's own border") + + Tilt.setLevel(0) + Zoom.offset = 0 + world:rebuildNeighbors() + world:rebuildPeople({ seamless = true }) + + if failures > 0 then + print(("[driver] FAIL gold void fill: %d check(s)"):format(failures)) + return + end + print("[driver] PASS gold void fill in " .. out) + U.log("cycled fade/water/trees/black on cherrygrove (#1418).") + U.log("OPTION > VOID FILL, zoom out (-) to see the void.") + + while true do + coroutine.yield() + end +end diff --git a/tests/engine/launcher_gold_touch_rows.lua b/tests/engine/launcher_gold_touch_rows.lua index 5b8e0e12..441e92b8 100644 --- a/tests/engine/launcher_gold_touch_rows.lua +++ b/tests/engine/launcher_gold_touch_rows.lua @@ -43,6 +43,15 @@ local gold = LauncherSettings.open(hooks, "gold") check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD") check(has(gold, "VIBRATION"), "and VIBRATION") check(has(gold, "TOUCH CONTROLS"), "and the layout editor") +check(has(gold, "VOID FILL"), "and VOID FILL") + +local voidFill = findRow(gold, "VOID FILL") +eq(voidFill.value(), "FADE ", "VOID FILL defaults to FADE") +voidFill.step(1) +eq(gold.opts.gold.voidFill, "water", "right stores water in the gold block") +eq(voidFill.value(), "WATER", "and the row reads WATER") +voidFill.step(-1) +eq(gold.opts.gold.voidFill, "fade", "left restores fade") -- Every write has to land in the gold block: the flat keys beside it are -- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299). diff --git a/tests/gen2_border_test.lua b/tests/gen2_border_test.lua index e66ac89e..2d5005ab 100644 --- a/tests/gen2_border_test.lua +++ b/tests/gen2_border_test.lua @@ -84,6 +84,63 @@ end local _, _, vw0, vh0 = BorderFill.viewport(0, 0, 160, 144, 0) check(vw0 > 0 and vh0 > 0, "scale 0 falls back to 1 rather than collapsing") +-- --------------------------------------------------------------------------- +-- VOID FILL (#1418): FADE / WATER / TREES / BLACK +-- --------------------------------------------------------------------------- + +eq(BorderFill.voidFill, "fade", "the live fill defaults to FADE") +eq(BorderFill.voidFillLabel(), "FADE ", "and prints with a trailing space") + +local johto = { id = "CHERRYGROVE_CITY", tileset = "TILESET_JOHTO", + borderBlock = 0x35 } +local route = { id = "ROUTE_30", tileset = "TILESET_JOHTO", borderBlock = 0x05 } +local kanto = { id = "PALLET_TOWN", tileset = "TILESET_KANTO", + borderBlock = 0x0f } +local cave = { id = "UNION_CAVE_1F", tileset = "TILESET_CAVE", borderBlock = 0x09 } +local house = { id = "PLAYERS_HOUSE_1F", tileset = "TILESET_PLAYERS_HOUSE", + borderBlock = 0x00 } + +eq(BorderFill.fillBlock(johto), 0x35, "FADE keeps Cherrygrove's water") +eq(BorderFill.fillBlock(route), 0x05, "and Route 30's trees") +eq(BorderFill.fillKey(johto), "fade|CHERRYGROVE_CITY", + "FADE dissolves per map") +eq(BorderFill.fillKey(route) ~= BorderFill.fillKey(johto), true, + "so the two maps are different fills") + +BorderFill.setVoidFill("water") +eq(BorderFill.fillBlock(johto), 0x35, "WATER uses Cherrygrove's water block") +eq(BorderFill.fillBlock(route), 0x35, "on Route 30 too") +eq(BorderFill.fillBlock(kanto), 0x43, "and Cinnabar's water on Kanto") +eq(BorderFill.fillBlock(cave), 0x09, "but a cave keeps its own border") +eq(BorderFill.fillBlock(house), 0x00, "and so does a house") +eq(BorderFill.fillKey(johto), BorderFill.fillKey(route), + "two Johto maps share one WATER fill") +eq(BorderFill.fillKey(johto) ~= BorderFill.fillKey(kanto), true, + "Kanto water is a different sheet") + +BorderFill.setVoidFill("trees") +eq(BorderFill.fillBlock(johto), 0x05, "TREES uses New Bark's tree wall") +eq(BorderFill.fillBlock(kanto), 0x0f, "and Pallet's on Kanto") +eq(BorderFill.fillBlock(cave), 0x09, "caves still keep their own") + +BorderFill.setVoidFill("black") +eq(BorderFill.fillBlock(johto), false, "BLACK skips the tiled bake") +eq(BorderFill.fillBlock(house), false, "indoors too") +eq(BorderFill.fillKey(johto), "black", "and is one sheet everywhere") + +BorderFill.setVoidFill("nope") +eq(BorderFill.voidFill, "fade", "an unknown mode falls back to FADE") +BorderFill.setVoidFill("fade") +eq(BorderFill.cycle(1), "water", "cycle steps FADE to WATER") +eq(BorderFill.cycle(1), "trees", "then TREES") +eq(BorderFill.cycle(1), "black", "then BLACK") +eq(BorderFill.cycle(1), "fade", "and wraps to FADE") +eq(BorderFill.cycle(-1), "black", "left wraps the other way") +BorderFill.applyOptions({ voidFill = "trees" }) +eq(BorderFill.voidFill, "trees", "applyOptions pushes the saved mode") +BorderFill.applyOptions({}) +eq(BorderFill.voidFill, "fade", "and a missing key restores FADE") + -- --------------------------------------------------------------------------- -- The real cache: the map the bug was reported on -- --------------------------------------------------------------------------- diff --git a/tests/gen2_field_anim_test.lua b/tests/gen2_field_anim_test.lua index 0cd418fd..3513c46b 100644 --- a/tests/gen2_field_anim_test.lua +++ b/tests/gen2_field_anim_test.lua @@ -213,6 +213,22 @@ previous, alpha = BorderFill.crossfade(owner, trees, "ROUTE_30") check("then it is over", previous, nil) check("and the new block owns the void", alpha, 1) +-- Forced WATER on two Johto maps shares a fill key, so the dissolve does +-- not run identical water against itself. +BorderFill.setVoidFill("water") +local johtoWater = { id = "CHERRYGROVE_CITY", tileset = "TILESET_JOHTO", + borderBlock = 0x35 } +local routeTrees = { id = "ROUTE_30", tileset = "TILESET_JOHTO", + borderBlock = 0x05 } +check("WATER fill keys match across Johto", + BorderFill.fillKey(johtoWater), BorderFill.fillKey(routeTrees)) +local forced = {} +BorderFill.crossfade(forced, "water", BorderFill.fillKey(johtoWater)) +local fromForced = BorderFill.crossfade(forced, "water", + BorderFill.fillKey(routeTrees)) +check("so walking Route 30 does not start a fade", fromForced, nil) +BorderFill.setVoidFill("fade") + -- No key at all (an old caller) is the plain single draw. local bare = {} previous, alpha = BorderFill.crossfade(bare, trees, nil) diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index 37b7b740..ac01c52d 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -358,7 +358,7 @@ local options = OptionsMenu.new(optionsGame, { }) -- The cart's seven rows, then the port's: CONTROLS, audio, speed, display, -- video mode, the mobile-gated touch three (buildRows), MAX FPS and CANCEL. -check("twenty-two rows", #OptionsMenu.ROWS, 22) +check("twenty-three rows", #OptionsMenu.ROWS, 23) check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame") check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls") check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol") @@ -861,10 +861,15 @@ check("GBC leaves a palette alone", 1)[1], 1) check("and has no present pass", GbcPalette.presentColors(), nil) -local gbcfxIndex +local zoomIndex, gbcfxIndex for i, row in ipairs(OptionsMenu.ROWS) do + if row.label == "ZOOM" then zoomIndex = i end if row.label == "GBC FX" then gbcfxIndex = i end end +check("VOID FILL follows ZOOM", OptionsMenu.ROWS[zoomIndex + 1].label, + "VOID FILL") +check("and TILT follows VOID FILL", OptionsMenu.ROWS[zoomIndex + 2].label, + "TILT") check("VIDEO MODE follows GBC FX", OptionsMenu.ROWS[gbcfxIndex + 1].label, "VIDEO MODE") check("and TOUCH PAD follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label, @@ -897,6 +902,28 @@ scrollOptions:cycle(videoRow, -1) check("left toggles back to windowed", scrollOptions.options.videoMode, "windowed") +local voidRow = select(2, rowNamed("VOID FILL")) +check("VOID FILL is a row", voidRow ~= nil, true) +check("and it defaults to FADE", Save.DEFAULT_OPTIONS.voidFill, "fade") +scrollOptions.options.voidFill = "fade" +local BorderFill = require("src.world.gen2.BorderFill") +BorderFill.setVoidFill("fade") +scrollOptions:cycle(voidRow, 1) +check("right steps to WATER", scrollOptions.options.voidFill, "water") +check("and the live fill tracks it", BorderFill.voidFill, "water") +scrollOptions:cycle(voidRow, 1) +check("then TREES", scrollOptions.options.voidFill, "trees") +scrollOptions:cycle(voidRow, 1) +check("then BLACK", scrollOptions.options.voidFill, "black") +scrollOptions:cycle(voidRow, 1) +check("and wraps back to FADE", scrollOptions.options.voidFill, "fade") +check("FADE blanks the longer labels", voidRow.text(scrollOptions.options), + "FADE ") +scrollOptions.options.voidFill = "water" +check("WATER fits the value column", voidRow.text(scrollOptions.options), + "WATER") +BorderFill.setVoidFill("fade") + check("text reads WINDOWED", videoRow.text(scrollOptions.options), "WINDOWED") scrollOptions.options.videoMode = "borderless" check("text reads FULL, not BORDERLESS", videoRow.text(scrollOptions.options),