-- Draws a map's tile layer: one texture atlas per tileset, 8x8 quads, -- a single static SpriteBatch covering the map plus a border-block ring -- (the ring plays the role of the GB border blocks around small maps). local Assets = require("src.render.Assets") local PaletteFX = require("src.render.PaletteFX") local TileRenderer = {} TileRenderer.__index = TileRenderer local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks) -- OVERWORLD maps fill beyond-edge space from save.options.voidFill: -- trees (default) -- solid tree wall $0F (Viridian/Cerulean/Celadon) -- water -- solid water $43 (Cinnabar/Route 19 border block) -- black -- solid black (no tiled metatile) -- Other tilesets keep their designated border (interiors stay black/void). local TREE_WALL_BLOCK = 0x0F local WATER_BORDER_BLOCK = 0x43 TileRenderer.VOID_FILLS = { "trees", "water", "black" } TileRenderer.voidFill = "trees" local function borderBlockFor(map) if map.def.tileset == "OVERWORLD" then local mode = TileRenderer.voidFill or "trees" if mode == "water" then return WATER_BORDER_BLOCK end if mode == "black" then return false end return TREE_WALL_BLOCK end return map.def.borderBlock end TileRenderer.borderBlockFor = borderBlockFor function TileRenderer.setVoidFill(mode) local ok = false for _, m in ipairs(TileRenderer.VOID_FILLS) do if m == mode then ok = true; break end end TileRenderer.voidFill = ok and mode or "trees" end function TileRenderer.cycleVoidFill() local cur = TileRenderer.voidFill or "trees" local idx = 1 for i, m in ipairs(TileRenderer.VOID_FILLS) do if m == cur then idx = i; break end end TileRenderer.setVoidFill( TileRenderer.VOID_FILLS[idx % #TileRenderer.VOID_FILLS + 1]) return TileRenderer.voidFill end function TileRenderer.applyOptions(opts) TileRenderer.setVoidFill(opts and opts.voidFill or "trees") end function TileRenderer.voidFillLabel(mode) mode = mode or TileRenderer.voidFill or "trees" if mode == "water" then return "WATER" end if mode == "black" then return "BLACK" end return "TREES" end local imageCache = {} local function getImage(path) if not imageCache[path] then imageCache[path] = Assets.image(path) end return imageCache[path] end -- ------------------------------------------------------------------ -- Tile animation (home/vcopy.asm): tilesets with TILEANIM_WATER[_FLOWER] -- rotate water tile $14 one pixel every 20 frames (4 steps right, 4 -- left) and cycle flower tile $03 through 3 frames. -- Those two cycles are the *defaults* a vanilla tileset record derives -- from its `animation` string; a tileset that carries `animatedTiles` -- declares its own set instead and animates with no engine change. -- ------------------------------------------------------------------ local WATER_TILE, FLOWER_TILE = 0x14, 0x03 -- cumulative pixel offset per animation step (the rrca/rlca sequence) local WATER_OFFSETS = { 1, 2, 3, 2, 1, 0, 7, 0 } -- flower frame per step (wMovingBGTilesCounter2 & 3: <2 -> 1, 2, 3) local FLOWER_FRAMES = { 1, 2, 3, 1, 1, 2, 3, 1 } local ANIM_PERIOD = 20 local FLOWER_IMAGES = { "assets/generated/tilesets/flower1.png", "assets/generated/tilesets/flower2.png", "assets/generated/tilesets/flower3.png", } local SPINNER_STRIP = "assets/generated/tilesets/spinners.png" local animFrame = 0 local animAccum = 0 local ANIM_STEP = 1 / 60 -- Game Boy logic rate (matches FixedStep.STEP) -- Advance water/flower/spinner tile animation. Called from the overworld -- draw path so it keeps running under dialogs (overworld update does not), -- but consumes wall-clock dt into 60Hz steps so high/low display refresh -- no longer speeds up or slows the cycle (issue #4). -- tick() / tick(nil) with no love.timer.getDelta (headless tests) still -- advances exactly one frame per call. function TileRenderer.tick(dt) if dt == nil and love and love.timer and love.timer.getDelta then dt = love.timer.getDelta() end if dt == nil then animFrame = animFrame + 1 return end -- Cap catch-up so a long stall cannot jump many water/flower periods animAccum = math.min(animAccum + dt, 0.25) while animAccum >= ANIM_STEP do animAccum = animAccum - ANIM_STEP animFrame = animFrame + 1 end end -- ------------------------------------------------------------------ -- Spinner arrow tiles (engine/overworld/spinners.asm LoadSpinnerArrowTiles): -- a wholly separate, contextually-triggered VRAM patch layered on top of -- the ambient water/flower cycle above -- while wMovementFlags.BIT_SPINNING -- is set (Gym/Rocket Hideout spinner puzzles), each forced-movement step -- farcalls LoadSpinnerArrowTiles, which flips 4 fixed destination tile IDs -- per tileset between the shared 'blur' graphic (gfx/overworld/spinners.2bpp, -- SpinnerArrowAnimTiles) and the tileset's own static graphic (restore). -- Only 2 distinct frames exist -- no continuous multi-frame cycle. -- ------------------------------------------------------------------ -- data/tilesets/spinner_tiles.asm: dest tile IDs patched per tileset TileRenderer.SPINNER_ARROW_TILES = { GYM = { 0x3c, 0x3d, 0x4c, 0x4d }, FACILITY = { 0x20, 0x21, 0x30, 0x31 }, } -- dest tile id -> offset (in 8x8 tiles) into the SpinnerArrowAnimTiles strip, -- taken verbatim from the `spinner SpinnerArrowAnimTiles, , ` -- rows of data/tilesets/spinner_tiles.asm local SPINNER_STRIP_OFFSET = { GYM = { [0x3c] = 1, [0x3d] = 3, [0x4c] = 0, [0x4d] = 2 }, FACILITY = { [0x20] = 0, [0x21] = 1, [0x30] = 2, [0x31] = 3 }, } local spinning = false function TileRenderer.setSpinning(active) spinning = active end -- true while the spinner arrow tiles should show the 'blur' graphic; false -- means draw nothing extra (the static window tile shows through, -- matching the asm's restore-to-original behavior). The 8-tick -- half-period approximates one GB movement step (2px/frame); this is a -- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not -- a cycle-accurate replication -- the port's tweened scriptMove has no -- direct equivalent discrete step counter. function TileRenderer.spinBlurActive() return spinning and (math.floor(animFrame / 8) % 2 == 0) end -- ------------------------------------------------------------------ -- animatedTiles: the per-kind resource builders. Each returns the -- texture list a step indexes into, or false when the pixels are -- unreachable (headless, or a missing frame file) -- false disables that -- one entry and leaves the static batch showing through, which is what -- the water/flower branches did before they were data. -- ------------------------------------------------------------------ -- shade 0-3 -> one of `colors`' 4 entries (same cutoffs PaletteFX's shader -- uses), alpha passed through unchanged; nil colors leaves r,g,b as-is. -- Shared by the whole-atlas bake (getGbcAtlas) and the animated-tile -- variants below, so water/flowers/spinners match the static tiles around -- them under RED++ instead of showing their un-recolored grayscale. local function recolorSample(r, g, b, a, colors) if not (colors and a > 0) then return r, g, b, a end local col = r > 0.83 and colors[1] or r > 0.5 and colors[2] or r > 0.17 and colors[3] or colors[4] return col[1] / 255, col[2] / 255, col[3] / 255, a end -- exported: a render pipeline bakes a map's palette into its own texture -- atlas the same way, and has to land on the identical colors as the 2D -- tiles it is standing in for TileRenderer.recolorSample = recolorSample -- the 8 shifted variants of one tile (built once per sheet + tile id [+ -- gbcKey, when `colors` recolors it for RED++ -- see buildAnim]) local shiftVariants = {} local function getShiftVariants(tilesetImagePath, perRow, tile, colors, gbcKey) local key = tilesetImagePath .. "#" .. tile .. (gbcKey or "") if shiftVariants[key] ~= nil then return shiftVariants[key] end if not (love.image and love.image.newImageData) then shiftVariants[key] = false return false end local id = Assets.imageData(tilesetImagePath) local sx = (tile % perRow) * 8 local sy = math.floor(tile / perRow) * 8 local out = {} for o = 0, 7 do local v = love.image.newImageData(8, 8) for y = 0, 7 do for x = 0, 7 do local r, g, b, a = id:getPixel(sx + x, sy + y) r, g, b, a = recolorSample(r, g, b, a, colors) v:setPixel((x + o) % 8, y, r, g, b, a) end end out[o + 1] = love.graphics.newImage(v) end shiftVariants[key] = out return out end local frameImages = {} local function getFrameImages(paths, colors, gbcKey) local key = table.concat(paths, "|") .. (gbcKey or "") if frameImages[key] ~= nil then return frameImages[key] end local out = {} for i, path in ipairs(paths) do local ok, img = pcall(function() if not (colors and love.image and love.image.newImageData) then return getImage(path) end local id = Assets.imageData(path) local w, h = id:getDimensions() local out2 = love.image.newImageData(w, h) for y = 0, h - 1 do for x = 0, w - 1 do local r, g, b, a = id:getPixel(x, y) r, g, b, a = recolorSample(r, g, b, a, colors) out2:setPixel(x, y, r, g, b, a) end end return love.graphics.newImage(out2) end) if not ok then frameImages[key] = false return false end out[i] = img end frameImages[key] = out return out end -- the tileset's own atlas ImageData with the patched tile slots blitted -- over with a shared strip (vanilla: assets/generated/tilesets/spinners.png, -- extracted from gfx/overworld/spinners.png); cached per tileset + strip local toggleImages = {} local stripData = {} local function getToggleImage(spec, tilesetImagePath, perRow) local key = tilesetImagePath .. "#" .. tostring(spec.image) if toggleImages[key] ~= nil then return toggleImages[key] end local offsets = spec.stripOffsets if not (love.image and love.image.newImageData) or not offsets then toggleImages[key] = false return false end if stripData[spec.image] == nil then local ok, id = pcall(Assets.imageData, spec.image) stripData[spec.image] = ok and id or false end local strip = stripData[spec.image] if not strip then toggleImages[key] = false return false end local atlas = Assets.imageData(tilesetImagePath) local clone = love.image.newImageData(atlas:getWidth(), atlas:getHeight()) clone:paste(atlas, 0, 0, 0, 0, atlas:getWidth(), atlas:getHeight()) for id, offset in pairs(offsets) do local sx = offset * 8 local dx = (id % perRow) * 8 local dy = math.floor(id / perRow) * 8 for y = 0, 7 do for x = 0, 7 do local r, g, b, a = strip:getPixel(sx + x, y) clone:setPixel(dx + x, dy + y, r, g, b, a) end end end local img = love.graphics.newImage(clone) toggleImages[key] = img return img end -- a toggle entry names the predicate that decides whether its patch shows -- this frame; an unknown name (or none) is always on TileRenderer.GATES = { spinning = function() return TileRenderer.spinBlurActive() end, } function TileRenderer.registerGate(name, predicate) TileRenderer.GATES[name] = predicate end local function gateOpen(name) local predicate = TileRenderer.GATES[name] if not predicate then return true end return predicate() and true or false end -- The vanilla animation set as data: what the importer would write onto a -- tileset record derived from its `animation` string and its spinner-tile -- row. Consulted only when the record declares no animatedTiles of its -- own, so the vanilla frame is byte-for-byte what it always was. function TileRenderer.defaultAnimatedTiles(tileset) local out = {} local anim = tileset.animation if anim == "TILEANIM_WATER" or anim == "TILEANIM_WATER_FLOWER" then out[#out + 1] = { tile = WATER_TILE, kind = "hshift", period = ANIM_PERIOD, offsets = WATER_OFFSETS } end if anim == "TILEANIM_WATER_FLOWER" then out[#out + 1] = { tile = FLOWER_TILE, kind = "frames", period = ANIM_PERIOD, images = FLOWER_IMAGES, sequence = FLOWER_FRAMES } end local spinners = TileRenderer.SPINNER_ARROW_TILES[tileset.id] if spinners then out[#out + 1] = { tiles = spinners, kind = "toggle", image = SPINNER_STRIP, stripOffsets = SPINNER_STRIP_OFFSET[tileset.id], gate = "spinning" } end return out end -- one entry's runtime form: the tile ids it claims, the textures a step -- picks from, and either a step sequence (hshift/frames) or a gate -- (toggle). nil when the entry's pixels could not be built. -- -- gbc, when present (RED++ with a baked atlas -- see getGbcAtlas), recolors -- hshift/frames entries (water/flowers) the same way the atlas bakes their -- static tile, so they match their surroundings instead of showing raw -- grayscale over an otherwise fully-colored map. The "toggle" kind -- (spinner puzzle blur, gfx/overworld/spinners.png) is a whole-atlas clone -- built from the ORIGINAL grayscale atlas, not worth recoloring for a rare, -- gameplay-gated blur -- it is skipped under gbc, same as the buildAnim -- caller already does for a texture-build failure (the static, correctly- -- colored tile shows through unanimated). local function buildAnim(spec, tilesetImagePath, perRow, quads, gbc) local tiles = spec.tiles if not tiles then if spec.tile == nil then return nil end tiles = { spec.tile } end local period = spec.period or ANIM_PERIOD local colors if gbc then local group = PaletteFX.worldGroupAt(gbc.tilesetId, gbc.mapId, tiles[1]) colors = group and gbc.groupColors[group + 1] end if spec.kind == "hshift" then local offsets = spec.offsets if not offsets or #offsets == 0 then return nil end local textures = getShiftVariants(tilesetImagePath, perRow, tiles[1], colors, gbc and gbc.key) if not textures then return nil end local sequence = {} for i, offset in ipairs(offsets) do sequence[i] = offset + 1 end return { tiles = tiles, textures = textures, sequence = sequence, period = period } elseif spec.kind == "frames" then local sequence = spec.sequence if not (spec.images and sequence and #sequence > 0) then return nil end local textures = getFrameImages(spec.images, colors, gbc and gbc.key) if not textures then return nil end return { tiles = tiles, textures = textures, sequence = sequence, period = period } elseif spec.kind == "toggle" then if gbc then return nil end local image = getToggleImage(spec, tilesetImagePath, perRow) if not image then return nil end -- the patch texture is a whole-atlas clone, so each cell needs the -- quad of the tile it stands in rather than a single-tile image return { tiles = tiles, textures = { image }, gate = spec.gate, quadFor = function(tile) return quads[tile] end } end return nil end -- True GBC overworld coloring (COLORS=RED++): recolor the WHOLE tileset -- atlas once, per (tileset image, map), rather than trying to retrofit the -- SGB zone/shade-remap-shader post-process (built for a handful of coarse -- screen regions) into per-tile precision -- pokered-gbc's real model is -- "one of 8 four-color BG palettes baked per tile GRAPHIC" -- (color/loadpalettes.asm LoadTilesetPalette), which is exactly a -- recolored atlas, not a shader pass. Every existing draw path (batches, -- quads, border fill) then just works unmodified, with no shader at -- draw time; OverworldState.sgbWorldZones skips the shade-remap zone pass -- entirely when this is active (re-running it over already-true-color -- pixels would corrupt them), and SpriteRenderer's own OBP bake composites -- on top with ordinary alpha blending -- no trueColor exemption needed, -- because there is no shader left for it to be exempted from. -- -- Only the ROOF group (index 6, OVERWORLD/PLATEAU only) varies by town -- (LoadTownPalette); Route 6's mid-map Saffron-roof y<2 split is not -- reproduced (it would need a rebuild on crossing the boundary for two -- tile-rows of one route -- not worth the complexity), so it bakes with -- the route's own default roof (Vermilion's) throughout. local gbcAtlasCache = {} -- Cache suffix for a map's RED++ bake. A dark cave folds FadePal2 into the -- palette worldGroupColors hands the bake (#383), so the lit and dark bakes of -- one map are different images and must not share a key. local function gbcKeyFor(mapId) return "#gbc:" .. mapId .. PaletteFX.darkKey() end local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data) local key = imagePath .. gbcKeyFor(mapId) if gbcAtlasCache[key] ~= nil then return gbcAtlasCache[key] or nil end local img = false if love.image and love.image.newImageData then local groupColors = PaletteFX.worldGroupColors(data, tilesetId, mapId, nil) if groupColors then local src = Assets.imageData(imagePath) local iw, ih = src:getDimensions() local total = (iw / 8) * (ih / 8) local out = love.image.newImageData(iw, ih) local tileColors = {} for t = 0, total - 1 do local colors = tileColors[t] if colors == nil then local group = PaletteFX.worldGroupAt(tilesetId, mapId, t) colors = (group and groupColors[group + 1]) or false tileColors[t] = colors end local ox, oy = (t % perRow) * 8, math.floor(t / perRow) * 8 for py = 0, 7 do for px = 0, 7 do local sx, sy = ox + px, oy + py local r, g, b, a = src:getPixel(sx, sy) r, g, b, a = recolorSample(r, g, b, a, colors) out:setPixel(sx, sy, r, g, b, a) end end end -- duplicate-tile aliases: bake a copy of a shared tile graphic into -- a spare slot under a different palette group, so block cells that -- draw the alias can color apart from cells sharing the raw tile for _, al in ipairs(PaletteFX.TILE_ALIASES and PaletteFX.TILE_ALIASES[mapId] or {}) do if al.alias < total then local colors = groupColors[al.group + 1] local sxo = (al.tile % perRow) * 8 local syo = math.floor(al.tile / perRow) * 8 local dxo = (al.alias % perRow) * 8 local dyo = math.floor(al.alias / perRow) * 8 for py = 0, 7 do for px = 0, 7 do local r, g, b, a = src:getPixel(sxo + px, syo + py) r, g, b, a = recolorSample(r, g, b, a, colors) out:setPixel(dxo + px, dyo + py, r, g, b, a) end end end end img = love.graphics.newImage(out) end end gbcAtlasCache[key] = img return img or nil end -- data: Game.data (threaded through explicitly, not required lazily, so -- headless tests that build a map from a plain local table still work) function TileRenderer.new(map, data) local self = setmetatable({}, TileRenderer) self.map = map self.data = data self.image = getImage(map.tileset.image) local gbcCtx if data and PaletteFX.usesGbcPack() and PaletteFX.hasWorldTileset(map.tileset.id) then local gbc = getGbcAtlas(map.tileset.image, map.tileset.id, map.id, map.tileset.tilesPerRow, data) if gbc then self.image = gbc self.gbcAtlas = true -- also recolors the animated water/flower entries below, so they -- match the atlas's static tiles instead of showing raw grayscale gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = gbcKeyFor(map.id), groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) } -- ...and feeds the color-0-keyed single tiles the feet overdraw needs -- (see getKeyedTile): same source image and palette groups, so keep the -- context rather than re-deriving it per draw. gbcCtx.imagePath = map.tileset.image gbcCtx.perRow = map.tileset.tilesPerRow self.gbcCtx = gbcCtx self.gbcAtlasKey = map.tileset.image .. gbcCtx.key self.gbcKeyed = {} end end -- a full-color atlas colors everything it paints, ring and border fill -- included, so every draw entry point claims its rect out of the pass self.trueColor = map.tileset.trueColor or nil local iw, ih = self.image:getDimensions() self.quads = {} local perRow = map.tileset.tilesPerRow for t = 0, (iw / 8) * (ih / 8) - 1 do self.quads[t] = love.graphics.newQuad((t % perRow) * 8, math.floor(t / perRow) * 8, 8, 8, iw, ih) end local def = map.def -- The map body measured in 8px tiles (each block is 4x4 tiles). The tile -- layer is drawn windowed to the camera (see :ensureWindow) instead of -- baked into a whole-map SpriteBatch, so a map becoming visible -- a warp, -- a connection seam -- costs nothing to "build": there is no per-map batch -- construction that scales with map size, which is what stuttered. self.bodyTilesW = def.width * 4 self.bodyTilesH = def.height * 4 -- Animated tiles overdraw the static window each frame. Only the per-entry -- render spec (textures/sequence/gate) is kept here; the animated cells are -- gathered per camera window in :ensureWindow, so nothing here scales with -- map size either. Entry order decides which entry claims a tile listed -- twice (the vanilla water-then-flower-then-spinner precedence). local anims, claimedBy = {}, {} local declared = map.tileset.animatedTiles or TileRenderer.defaultAnimatedTiles(map.tileset) for _, spec in ipairs(declared) do local anim = buildAnim(spec, map.tileset.image, perRow, self.quads, gbcCtx) if anim then anims[#anims + 1] = anim for _, tile in ipairs(anim.tiles) do if claimedBy[tile] == nil then claimedBy[tile] = anim end end end end -- duplicate-tile alias remap (RED++ atlas only): [blockId][0-based cell] -- -> alias tile id (see PaletteFX.TILE_ALIASES / getGbcAtlas's bake) local aliasMap if gbcCtx then for _, al in ipairs(PaletteFX.TILE_ALIASES and PaletteFX.TILE_ALIASES[map.id] or {}) do aliasMap = aliasMap or {} local cells = aliasMap[al.block] or {} for ci in pairs(al.cells) do cells[ci] = al.alias end aliasMap[al.block] = cells end end self.aliasMap = aliasMap self.anims = anims self.claimedBy = claimedBy -- border-fill image is built lazily in :ensureBorderFill so a VOID FILL -- option change can swap trees/water/black without reloading the map self.borderFillMode = nil self.borderFill = nil return self end -- Bake a static repeating 32x32 of `block` into self.borderFill. local function bakeBorderFill(self, block) local border = self.map.tileset.blocks[block + 1] if not border then return end -- 32x32 real pixels: a DPI-scaled canvas would bake the border block at a -- fractional texel size and the repeat-wrapped image would then tile at -- non-square pixels (#208, see src/render/PixelCanvas.lua) local canvas = require("src.render.PixelCanvas").new(32, 32) love.graphics.push("all") love.graphics.setCanvas(canvas) love.graphics.clear(1, 1, 1, 1) for ty = 0, 3 do for tx = 0, 3 do local quad = self.quads[border[ty * 4 + tx + 1]] if quad then love.graphics.draw(self.image, quad, tx * 8, ty * 8) end end end love.graphics.setCanvas() love.graphics.pop() local img = love.graphics.newImage(canvas:newImageData()) img:setWrap("repeat", "repeat") img:setFilter("nearest", "nearest") self.borderFill = img end -- WATER void fill: the eight hshift frames of tile $14 (same cycle as map -- water), wrap-tiled so the void scrolls in lockstep with on-map water. local function ensureWaterBorderFill(self) if self.borderWaterTextures then return true end local map = self.map local perRow = map.tileset.tilesPerRow local colors, gbcKey if self.gbcAtlas and self.data then local group = PaletteFX.worldGroupAt(map.tileset.id, map.id, WATER_TILE) local groupColors = PaletteFX.worldGroupColors( self.data, map.tileset.id, map.id, nil) colors = group and groupColors and groupColors[group + 1] or nil gbcKey = gbcKeyFor(map.id) end local textures = getShiftVariants(map.tileset.image, perRow, WATER_TILE, colors, gbcKey) if not textures then return false end for _, img in ipairs(textures) do img:setWrap("repeat", "repeat") img:setFilter("nearest", "nearest") end self.borderWaterTextures = textures return true end -- (re)build the repeating border image when the VOID FILL mode or tileset -- choice changes. OVERWORLD "black" leaves borderFill nil and draws a -- solid clear; "water" keeps the live hshift textures instead of a bake. function TileRenderer:ensureBorderFill() local block = borderBlockFor(self.map) local mode = block == false and "black" or ((self.map.def.tileset == "OVERWORLD") and (TileRenderer.voidFill or "trees") or "map") local ready = (mode == "black") or (mode == "water" and self.borderWaterTextures) or (mode ~= "water" and mode ~= "black" and self.borderFill) if self.borderFillMode == mode and ready then return end if self.borderFill and self.borderFill.release then pcall(self.borderFill.release, self.borderFill) end self.borderFill = nil -- shared shift-variant cache: drop the reference only, never release self.borderWaterTextures = nil self.borderFillMode = mode if mode == "black" or block == false or block == nil then return end if mode == "water" then if ensureWaterBorderFill(self) then return end -- headless / missing pixels: fall back to the static water block bake end pcall(bakeBorderFill, self, block) end -- tile the border block across the whole view (world-aligned so it -- meshes seamlessly with the ring batch) function TileRenderer:drawBorderFill(camX, camY, vw, vh) self:ensureBorderFill() if self.borderFillMode == "black" then love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("fill", 0, 0, vw, vh) love.graphics.setColor(1, 1, 1, 1) return end if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end local x, y = math.floor(camX), math.floor(camY) -- one reused Quad per renderer, mutated in place: this runs every -- overworld frame, so allocating a fresh Quad here churned the GC local q = self.borderQuad if self.borderFillMode == "water" and self.borderWaterTextures then local step = math.floor(animFrame / ANIM_PERIOD) % #WATER_OFFSETS + 1 local tex = self.borderWaterTextures[WATER_OFFSETS[step] + 1] if not tex then return end if q then q:setViewport(x, y, vw, vh, 8, 8) else q = love.graphics.newQuad(x, y, vw, vh, 8, 8) self.borderQuad = q end love.graphics.draw(tex, q, 0, 0) return end if not self.borderFill then return end if q then q:setViewport(x, y, vw, vh, 32, 32) else q = love.graphics.newQuad(x, y, vw, vh, 32, 32) self.borderQuad = q end love.graphics.draw(self.borderFill, q, 0, 0) end -- GB OBJ-to-BG priority: sprites show through BG color 0 and hide under -- colors 1-3. Tall-grass overdraw needs the same rule, otherwise the -- tile's white gaps paint opaque boxes over the sprite's feet. local color0KeyShader -- false = unavailable local function getColor0KeyShader() if color0KeyShader ~= nil then return color0KeyShader or nil end local ok, sh = pcall(love.graphics.newShader, [[ vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { vec4 p = Texel(tex, tc) * color; // same shade-0 cutoff PaletteFX uses (DMG white / lightest gray) if (p.r > 0.83 && p.g > 0.83 && p.b > 0.83) p.a = 0.0; return p; } ]]) color0KeyShader = ok and sh or false return color0KeyShader or nil end -- RED++ (COLORS=ADVANCED): the color-0 key, BAKED instead of tested for. local function getKeyedTile(self, tile) local ctx = self.gbcCtx local cached = self.gbcKeyed[tile] if cached ~= nil then return cached or nil end local img = false if ctx.groupColors and love.image and love.image.newImageData then local group = PaletteFX.worldGroupAt(ctx.tilesetId, ctx.mapId, tile) local colors = group and ctx.groupColors[group + 1] local src = Assets.imageData(ctx.imagePath) local ox = (tile % ctx.perRow) * 8 local oy = math.floor(tile / ctx.perRow) * 8 local out = love.image.newImageData(8, 8) for py = 0, 7 do for px = 0, 7 do local r, g, b, a = src:getPixel(ox + px, oy + py) -- read shade 0 off the RAW sheet, on recolorSample's own cutoff, so -- the keyed pixels are exactly the ones the shader path keys local shade0 = r > 0.83 r, g, b, a = recolorSample(r, g, b, a, colors) out:setPixel(px, py, r, g, b, shade0 and 0 or a) end end img = love.graphics.newImage(out) end self.gbcKeyed[tile] = img return img or nil end function TileRenderer:drawCellBottomRaw(cx, cy, camX, camY) local ty = cy * 2 + 1 for i = 0, 1 do local tx = cx * 2 + i local tile = self.map:tileAt(tx, ty) local keyed = tile and self.gbcCtx and getKeyedTile(self, tile) if keyed then love.graphics.draw(keyed, tx * 8 - camX, ty * 8 - camY) else local quad = self.quads[tile] if quad then love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY) end end end end -- redraw a cell's bottom tile row (tall grass hides the lower half of -- sprites standing in it, like the GB sprite-priority trick) function TileRenderer:drawCellBottom(cx, cy, camX, camY) -- the RED++ path is pre-keyed; the white test would be a no-op there at -- best, and a false hit on some other group's near-white color 0 at worst local shader = not self.gbcCtx and getColor0KeyShader() or nil if shader then love.graphics.setShader(shader) end self:drawCellBottomRaw(cx, cy, camX, camY) if shader then love.graphics.setShader() end end -- queue the same bottom tile row for the post-zone sprite-redraw pass -- (GBC mode: OBP-baked sprites replay after the zone shader, so the -- grass patch that hides their feet must replay over them, colorized -- with the map's palette and color-0 keyed) function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors) local ty = cy * 2 + 1 for i = 0, 1 do local tx = cx * 2 + i local quad = self.quads[self.map:tileAt(tx, ty)] if quad then PaletteFX.markSpriteRedraw(self.image, quad, tx * 8 - math.floor(camX), ty * 8 - math.floor(camY), 1, colors, true) end end end local WINDOW_MARGIN = 8 -- tiles of slack kept around the view between refills function TileRenderer:ensureWindow(camX, camY, vw, vh) local W, H = self.bodyTilesW, self.bodyTilesH vw = vw or W * 8 -- a nil view (headless draw) means the whole body vh = vh or H * 8 -- visible body-tile range (8px tiles), clamped to the map body local tx0 = math.min(W, math.max(0, math.floor(camX / 8))) local ty0 = math.min(H, math.max(0, math.floor(camY / 8))) local tx1 = math.max(0, math.min(W, math.floor((camX + vw) / 8) + 1)) local ty1 = math.max(0, math.min(H, math.floor((camY + vh) / 8) + 1)) local win = self.win if win and tx0 >= win.tx0 and ty0 >= win.ty0 and tx1 <= win.tx1 and ty1 <= win.ty1 then return -- still inside the last fill end -- refill with margin so the next few scrolled pixels stay covered tx0 = math.max(0, tx0 - WINDOW_MARGIN) ty0 = math.max(0, ty0 - WINDOW_MARGIN) tx1 = math.min(W, tx1 + WINDOW_MARGIN) ty1 = math.min(H, ty1 + WINDOW_MARGIN) if not self.winBatch then self.winBatch = love.graphics.newSpriteBatch(self.image, 1024, "dynamic") end self.winBatch:clear() local anims = self.anims for _, anim in ipairs(anims) do if not anim.batch then anim.batch = love.graphics.newSpriteBatch(anim.textures[1], 256, "dynamic") end anim.batch:clear() end local map, quads = self.map, self.quads local claimedBy, aliasMap = self.claimedBy, self.aliasMap for ty = ty0, ty1 - 1 do local by = math.floor(ty / 4) local ty4 = ty % 4 for tx = tx0, tx1 - 1 do local blockId = map:blockAt(math.floor(tx / 4), by) local block = map.tileset.blocks[blockId + 1] if block then local ci = ty4 * 4 + (tx % 4) local tile = block[ci + 1] local remap = aliasMap and aliasMap[blockId] if remap and remap[ci] then tile = remap[ci] end local wx, wy = tx * 8, ty * 8 local quad = quads[tile] if quad then self.winBatch:add(quad, wx, wy) end local anim = claimedBy[tile] if anim then if anim.quadFor then anim.batch:add(anim.quadFor(tile), wx, wy) else anim.batch:add(wx, wy) end end end end end self.win = { tx0 = tx0, ty0 = ty0, tx1 = tx1, ty1 = ty1 } end -- draw the static tile window, then its animated overdraw, at the camera offset function TileRenderer:drawWindow(camX, camY, vw, vh) self:ensureWindow(camX, camY, vw, vh) if self.winBatch then love.graphics.draw(self.winBatch, -math.floor(camX), -math.floor(camY)) end self:drawAnimated(camX, camY) end -- animated overdraw at the current step, over the static window batch. The -- cells were gathered for the current camera window by :ensureWindow, so this -- only ever touches on-screen animated tiles. function TileRenderer:drawAnimated(camX, camY) local anims = self.anims if not anims then return end local x, y = -math.floor(camX), -math.floor(camY) for _, anim in ipairs(anims) do local batch = anim.batch if batch then if anim.gate then -- a gated entry has only the two frames the asm has (patch / -- restore-to-static); when the gate is shut draw nothing so the -- already-static window tile shows through unchanged if gateOpen(anim.gate) then love.graphics.draw(batch, x, y) end else local step = math.floor(animFrame / anim.period) % #anim.sequence + 1 batch:setTexture(anim.textures[anim.sequence[step]]) love.graphics.draw(batch, x, y) end end end end -- the drawn extent of one batch in world-canvas pixels; `blocks` is the -- ring width the batch reaches past the map body on every side function TileRenderer:markTrueColor(camX, camY, blocks) local def = self.map.def PaletteFX.markTrueColor(-math.floor(camX) - blocks * 32, -math.floor(camY) - blocks * 32, (def.width + 2 * blocks) * 32, (def.height + 2 * blocks) * 32) end function TileRenderer:draw(camX, camY, vw, vh) if self.trueColor then self:markTrueColor(camX, camY, BORDER_BLOCKS) end self:drawWindow(camX, camY, vw, vh) end -- body only, for connected-map strips. Identical to :draw now that the -- border ring is served by :drawBorderFill for the current map too -- the -- only remaining difference is the trueColor mark extent. function TileRenderer:drawMapOnly(camX, camY, vw, vh) if self.trueColor then self:markTrueColor(camX, camY, 0) end self:drawWindow(camX, camY, vw, vh) end local function safeRelease(o) if o and o.release then pcall(o.release, o) end end -- Release only the GPU objects this instance built and uniquely owns: the -- two SpriteBatches, the border-fill image and its quad, the per-tile -- quads, and the animated-overdraw batches. Deliberately leaves the -- tileset atlas (self.image, shared through Assets/imageCache) and the -- animation textures (shared module caches) alone -- other maps still use -- them. Used by :rebuild before it swaps in fresh batches, and by -- :release on eviction. function TileRenderer:releaseBatches() safeRelease(self.winBatch); self.winBatch = nil safeRelease(self.borderFill); self.borderFill = nil safeRelease(self.borderQuad); self.borderQuad = nil -- shared shift-variant cache; only drop the reference self.borderWaterTextures = nil self.borderFillMode = nil self.win = nil if self.quads then for _, q in pairs(self.quads) do safeRelease(q) end self.quads = nil end if self.anims then for _, a in ipairs(self.anims) do safeRelease(a.batch); a.batch = nil -- a.textures are shared, module-cached: never released here end end end -- Full teardown for eviction (MapLoader.evict): the owned batches, plus -- the RED++ per-map recolored atlas, which -- unlike the plain tileset -- atlas -- is unique to this map (gbcAtlasCache is keyed by map id). function TileRenderer:release() self:releaseBatches() if self.gbcKeyed then -- baked per instance, shared with nobody (see getKeyedTile) for _, img in pairs(self.gbcKeyed) do safeRelease(img) end self.gbcKeyed = nil self.gbcCtx = nil end if self.gbcAtlas and self.image then local key = self.gbcAtlasKey or (self.map.tileset.image .. gbcKeyFor(self.map.id)) if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end self.gbcAtlasKey = nil safeRelease(self.image) self.image = nil self.gbcAtlas = nil end self.anims = nil end -- rebuild after a block change (Cut trees, card-key doors). The tile layer -- is read live from the map on every window fill, so a block swap only needs -- the cached window dropped -- the next draw re-reads the changed blocks. No -- SpriteBatch is reconstructed; that is the whole point of the windowed draw. function TileRenderer:rebuild() self.win = nil end -- drop every atlas and every derived animation texture so the next -- TileRenderer.new re-resolves through the asset search path. Live -- instances keep the batches they already built; MapLoader.invalidateAll -- is what drops those (14 §cache-invalidation contract). function TileRenderer.invalidate() imageCache = {} shiftVariants = {} frameImages = {} toggleImages = {} stripData = {} end Assets.register(TileRenderer.invalidate) return TileRenderer