import type { CrystalAssets } from "../rom/assets"; import { OVERWORLD, SYMBOLS } from "../rom/profile"; import type { GameMap, Palette } from "../world/types"; /** vTiles2 in VRAM bank 0: animations write tileset tiles from here. */ const VTILES2 = 0x9000; /** wTileAnimBuffer, the scratch tile that scrolling animations use. */ const TILE_ANIM_BUFFER = 0xcf41; /** Tile animation routines by ROM address (engine/tilesets/tileset_anims.asm). */ const R = OVERWORLD; /** _AnimateTileset (engine/tilesets/tileset_anims.asm): the tileset's own * list of (parameter, routine) pairs from data/tileset_anims.asm, run one * entry per video frame. Routines are selected by their ROM address and * translated natively; tiles are written to a private copy of the tileset * graphics, and the palette animations to color 0 of two palettes. */ export class TilesetAnimator { readonly graphics: Uint8Array; private readonly script: number; /** hTileAnimFrame, wTileAnimationTimer and hVBlankCounter. */ private frame = 0; private timer = 0; private vblank = 0; private buffer = new Uint8Array(16); /** Which of the water palette's colors 0-2 replaces color 0, and the * dark cave entrance's yellow color 0 or 1. */ private water = 0; private cave?: number; constructor( private assets: CrystalAssets, map: GameMap, graphics: Uint8Array, ) { const r = assets.reader; this.graphics = new Uint8Array(graphics); const tileset = SYMBOLS.tilesets + map.tileset * 15; this.script = r.near(tileset + 9, r.bank(R.WaitTileAnimation)); } /** Run `frames` video frames; `dark` is wTimeOfDayPalset's darkness. */ advance(frames: number, dark: boolean, restless = false) { for (let count = 0; count < frames; count++) { this.vblank = (this.vblank + 1) & 0xff; this.step(dark, restless); } } /** Apply the palette animations to the map's palettes. */ palettes(palettes: Palette[]): Palette[] { const result = [...palettes]; // AnimateWaterPalette: PAL_BG_WATER color 0 cycles through colors 0-2. if (this.water) result[3] = [palettes[3][this.water], ...palettes[3].slice(1)]; if (this.cave !== undefined) result[4] = [palettes[4][this.cave], ...palettes[4].slice(1)]; return result; } private step(dark: boolean, restless: boolean) { const r = this.assets.reader, entry = this.script + this.frame * 4; this.frame = (this.frame + 1) & 0xff; const parameter = r.word(entry), routine = r.address(r.bank(R.WaitTileAnimation), r.word(entry + 2)); const bank = r.bank(R.WaitTileAnimation); const at = (address: number) => r.address(bank, address); switch (routine) { case R.WaitTileAnimation: return; case R.DoneTileAnimation: this.frame = 0; return; case R.StandingTileFrame8: this.timer = (this.timer + 1) & 7; return; case R.StandingTileFrame: this.timer = (this.timer + 1) & 0xff; return; case R.ScrollTileRightLeft: this.timer = (this.timer + 1) & 7; return this.scroll(parameter, this.timer & 4 ? "left" : "right"); case R.ScrollTileUpDown: this.timer = (this.timer + 1) & 7; return this.scroll(parameter, this.timer & 4 ? "down" : "up"); case R.ScrollTileLeft: return this.scroll(parameter, "left"); case R.ScrollTileRight: return this.scroll(parameter, "right"); case R.ScrollTileUp: return this.scroll(parameter, "up"); case R.ScrollTileDown: return this.scroll(parameter, "down"); case R.ReadTileToAnimBuffer: this.buffer.set(this.memory(parameter)); return; case R.WriteTileFromAnimBuffer: this.memory(parameter).set(this.buffer); return; case R.AnimateWaterTile: // A cycle of 4 frames, updating every other tick. return this.write(parameter, R["AnimateWaterTile.WaterTileFrames"] + (this.timer & 6) * 8); case R.AnimateFountainTile: { const table = R["AnimateFountainTile.FountainTileFramePointers"]; return this.write(parameter, at(r.word(table + (this.timer & 7) * 2))); } case R.AnimateFlowerTile: // CGB flower frames follow the DMG ones in .FlowerTileFrames. return this.write(VTILES2 + 0x03 * 16, R["AnimateFlowerTile.FlowerTileFrames"] + ((this.timer & 2) + 1) * 16); case R.ForestTreeLeftAnimation: case R.ForestTreeLeftAnimation2: case R.ForestTreeRightAnimation: case R.ForestTreeRightAnimation2: { const right = routine === R.ForestTreeRightAnimation || routine === R.ForestTreeRightAnimation2; const alternate = routine === R.ForestTreeLeftAnimation2 || routine === R.ForestTreeRightAnimation2; // GetForestTreeFrame returns the odd frame for all timer values >= 7. const frame = restless ? ((this.timer >= 7 || (this.timer & 1) ? 1 : 0) ^ Number(alternate)) : 0; return this.write(VTILES2 + (right ? 0x0f : 0x0c) * 16, (right ? R.ForestTreeRightFrames : R.ForestTreeLeftFrames) + frame * 16); } case R.AnimateLavaBubbleTile1: return this.write(VTILES2 + 0x5b * 16, R.LavaBubbleTileFrames + ((((this.timer & 6) >> 1) + 2) & 3) * 16); case R.AnimateLavaBubbleTile2: return this.write(VTILES2 + 0x38 * 16, R.LavaBubbleTileFrames + (this.timer & 6) * 8); case R.AnimateTowerPillarTile: { const pointer = at(parameter); const offset = r.byte(R["AnimateTowerPillarTile.TowerPillarTileFrameOffsets"] + (this.timer & 7)); return this.write(r.word(pointer), at(r.word(pointer + 2)) + offset); } case R.AnimateWhirlpoolTile: { const pointer = at(parameter); return this.write(r.word(pointer), at(r.word(pointer + 2)) + (this.timer & 3) * 16); } case R.AnimateWaterPalette: // Only on even ticks: colors 0, 1, 2, 1. if (this.timer & 1) return; this.water = (this.timer & 6) === 0 ? 0 : (this.timer & 6) === 4 ? 2 : 1; return; case R.FlickeringCaveEntrancePalette: // Only in a dark cave: yellow color 0 alternates with color 1. this.cave = dark ? (this.vblank & 2 ? 1 : 0) : undefined; return; default: throw new Error("Tileset animation routine is not ported."); } } /** A tile in vTiles2 (bank 0), or wTileAnimBuffer. */ private memory(address: number): Uint8Array { if (address === TILE_ANIM_BUFFER) return this.buffer; const tile = (address - VTILES2) >> 4; if (address < VTILES2 || (address & 15) || tile >= 0x80) throw new Error("Tileset animation writes outside the tiles."); return this.graphics.subarray(tile * 16, tile * 16 + 16); } private write(destination: number, source: number) { this.memory(destination).set(this.assets.reader.slice(source, 16)); } /** ScrollTile*: bytes rotate for left/right; rows rotate for up/down. */ private scroll(address: number, direction: "left" | "right" | "up" | "down") { const tile = this.memory(address); if (direction === "left" || direction === "right") for (let i = 0; i < 16; i++) tile[i] = direction === "left" ? ((tile[i] << 1) | (tile[i] >> 7)) & 0xff : ((tile[i] >> 1) | (tile[i] << 7)) & 0xff; else { const rows = Array.from({ length: 8 }, (_, row) => tile.slice(row * 2, row * 2 + 2)); for (let row = 0; row < 8; row++) tile.set(rows[direction === "up" ? (row + 1) % 8 : (row + 7) % 8], row * 2); } } }