-- 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 with the solid tree wall -- (blockset $0F: four regular-tree metatiles, tiles $40/$41/$50/$51, -- the border block of ViridianCity/CeruleanCity/CeladonCity et al.), -- not each map's own border_block, which can be grass ($0B, the -- CutTreeBlockSwaps $0B->$0A cut-grass block) or water; other -- tilesets keep their designated border (interiors stay black/void) local TREE_WALL_BLOCK = 0x0F local function borderBlockFor(map) if map.def.tileset == "OVERWORLD" then return TREE_WALL_BLOCK end return map.def.borderBlock end TileRenderer.borderBlockFor = borderBlockFor 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 function TileRenderer.tick() animFrame = animFrame + 1 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 mapBatch/ringBatch 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. -- ------------------------------------------------------------------ -- the 8 shifted variants of one tile (built once per sheet + tile id) local shiftVariants = {} local function getShiftVariants(tilesetImagePath, perRow, tile) local key = tilesetImagePath .. "#" .. tile 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) 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) local key = table.concat(paths, "|") if frameImages[key] ~= nil then return frameImages[key] end local out = {} for i, path in ipairs(paths) do local ok, img = pcall(getImage, path) 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. local function buildAnim(spec, tilesetImagePath, perRow, quads) 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 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]) 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) if not textures then return nil end return { tiles = tiles, textures = textures, sequence = sequence, period = period } elseif spec.kind == "toggle" then 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 function TileRenderer.new(map) local self = setmetatable({}, TileRenderer) self.map = map self.image = getImage(map.tileset.image) -- 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 local wB, hB = def.width, def.height -- two batches: the border-block ring around the map, and the map body. -- Connected-map strips draw body-only on top of this map's ring. local total = (wB + 2 * BORDER_BLOCKS) * (hB + 2 * BORDER_BLOCKS) * 16 self.ringBatch = love.graphics.newSpriteBatch(self.image, total, "static") self.mapBatch = love.graphics.newSpriteBatch(self.image, wB * hB * 16, "static") -- animated tiles overdraw the static batches each frame. Entry order -- decides which one claims a tile listed twice, so the vanilla defaults -- keep the old 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) if anim then anim.cells = {} anims[#anims + 1] = anim for _, tile in ipairs(anim.tiles) do if claimedBy[tile] == nil then claimedBy[tile] = anim end end end end for by = -BORDER_BLOCKS, hB + BORDER_BLOCKS - 1 do for bx = -BORDER_BLOCKS, wB + BORDER_BLOCKS - 1 do local inside = bx >= 0 and by >= 0 and bx < wB and by < hB local batch = inside and self.mapBatch or self.ringBatch local block = map.tileset.blocks[map:blockAt(bx, by) + 1] for ty = 0, 3 do for tx = 0, 3 do local tile = block[ty * 4 + tx + 1] local quad = self.quads[tile] if quad then batch:add(quad, bx * 32 + tx * 8, by * 32 + ty * 8) end local anim = claimedBy[tile] if anim then local cells = anim.cells cells[#cells + 1] = { bx * 32 + tx * 8, by * 32 + ty * 8, inside, tile } end end end end end -- animated overdraw batches: the full set (ring + body) for the -- current map, and a body-only set for connected-map drawing -- -- a neighbor's water ring must never overdraw this map's tiles. -- `quadFor`, when given, looks up a per-entry quad (used by toggle -- entries, whose texture is a full tileset-atlas clone rather than a -- single-tile image like the hshift/frames variants). local function animBatches(entries, image, quadFor) if #entries == 0 then return nil, nil end local all = love.graphics.newSpriteBatch(image, #entries, "static") local body for _, c in ipairs(entries) do if quadFor then all:add(quadFor(c[4]), c[1], c[2]) else all:add(c[1], c[2]) end if c[3] then body = body or love.graphics.newSpriteBatch(image, #entries, "static") if quadFor then body:add(quadFor(c[4]), c[1], c[2]) else body:add(c[1], c[2]) end end end return all, body end for _, anim in ipairs(anims) do anim.batch, anim.bodyBatch = animBatches(anim.cells, anim.textures[1], anim.quadFor) anim.cells = nil end self.anims = anims -- a repeating 32x32 image of the border block, tiled behind -- everything the 3-block ring doesn't cover (the survey zoom sees -- far past the ring; interiors keep their black border this way) pcall(function() local border = map.tileset.blocks[borderBlockFor(map) + 1] if not border then return end local canvas = love.graphics.newCanvas(32, 32) love.graphics.push("all") love.graphics.setCanvas(canvas) love.graphics.clear(1, 1, 1, 1) for ty = 0, 3 do for tx = 0, 3 do local quad = self.quads[border[ty * 4 + tx + 1]] if quad then love.graphics.draw(self.image, quad, tx * 8, ty * 8) end end end love.graphics.setCanvas() love.graphics.pop() local img = love.graphics.newImage(canvas:newImageData()) img:setWrap("repeat", "repeat") img:setFilter("nearest", "nearest") self.borderFill = img end) return self end -- tile the border block across the whole view (world-aligned so it -- meshes seamlessly with the ring batch) function TileRenderer:drawBorderFill(camX, camY, vw, vh) if not self.borderFill then return end if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end local x, y = math.floor(camX), math.floor(camY) local quad = love.graphics.newQuad(x, y, vw, vh, 32, 32) love.graphics.draw(self.borderFill, quad, 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 -- draw a cell's bottom tile row without touching the shader (the caller -- owns it). drawCellBottom wraps this with the color-0 key; tilt mode's -- upright pass wraps it with a color-0-keyed palette shader instead -- (PaletteFX.keyedShader) so the feet patch is colorized like the ground. function TileRenderer:drawCellBottomRaw(cx, cy, camX, camY) 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 love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY) 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) local shader = getColor0KeyShader() if shader then love.graphics.setShader(shader) end self:drawCellBottomRaw(cx, cy, camX, camY) if shader then love.graphics.setShader() end end -- animated overdraw at the current step; bodyOnly skips the ring -- positions (connected maps draw body-only) function TileRenderer:drawAnimated(camX, camY, bodyOnly) 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 = bodyOnly and anim.bodyBatch or 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 mapBatch/ringBatch 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) if self.trueColor then self:markTrueColor(camX, camY, BORDER_BLOCKS) end love.graphics.draw(self.ringBatch, -math.floor(camX), -math.floor(camY)) love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY)) self:drawAnimated(camX, camY) end -- body only, for connected-map strips function TileRenderer:drawMapOnly(camX, camY) if self.trueColor then self:markTrueColor(camX, camY, 0) end love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY)) self:drawAnimated(camX, camY, true) end -- rebuild after a block change (Cut trees) function TileRenderer:rebuild() local fresh = TileRenderer.new(self.map) self.ringBatch = fresh.ringBatch self.mapBatch = fresh.mapBatch self.anims = fresh.anims self.borderFill = fresh.borderFill 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