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>
This commit is contained in:
1 parent
9ac111f262
commit
451464f7c6
38 files changed
+5600
-490
No files matched your search
@@ -0,0 +1,173 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user