Checks / web (push) Canceled after 0s
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>
174 lines
7.4 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|