Files
Crystal/src/game/rendering/tileset-animation.ts
T
Sébastien QUEROLandClaude Opus 5.5 451464f7c6
Checks / web (push) Canceled after 0s
Complete the overworld foundation
Port forced movement (turn delays, bumps, ice, currents, pits), dynamic
return warps, elevators, Surf, Cut, Strength and cycling rules, working
map blocks with changeblock/changemapblocks, and the remaining NPC
movement types and movement commands.

Add tileset animation from the ROM's command tables, grass sprite
priority and special map palettes; room decorations with ownership, the
bedroom PC and save migration; and Key Items access to the Bicycle.

Fourteen original-engine traces compare positions, facing, actors, event
flags and scripted poses. Tile animation matches original VRAM for four
tilesets, and LoadMapPals matches 192 palette cases. skyfall_top now
keeps the $60 sprite offset its step function leaves behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:47:29 +02:00

174 lines
7.4 KiB
TypeScript

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);
}
}
}