Complete the overworld foundation
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>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-25 16:47:29 +02:00
1 parent 9ac111f262
commit 451464f7c6
38 files changed
+5600 -490

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ export class LocalMapView {
update(world: GameWorld) {
const map = world.map;
const key = `${map.id}:${world.timeOfDay}`;
const key = `${map.id}:${world.timeOfDay}:${world.blockRevision}`;
if (this.assets !== world.assets || this.mapKey !== key) {
this.buildTerrain(world);
this.assets = world.assets;
+14 -1
View File
@@ -10,7 +10,15 @@ export function drawTile(
x: number,
y: number,
palette: Palette,
options: { transparent?: boolean; flipX?: boolean; flipY?: boolean } = {},
options: {
transparent?: boolean;
flipX?: boolean;
flipY?: boolean;
/** Background color indices per pixel: a background tile records them;
* an OAM_PRIO sprite (`behind`) only draws over color 0. */
indices?: Uint8Array;
behind?: boolean;
} = {},
) {
const offset = tile * 16;
if (offset < 0 || offset + 16 > graphics.length) return;
@@ -25,6 +33,11 @@ export function drawTile(
const px = x + col,
py = y + row;
if (px < 0 || py < 0 || px >= width || py >= height) continue;
if (options.indices) {
if (options.behind) {
if (options.indices[py * width + px]) continue;
} else if (!options.transparent) options.indices[py * width + px] = index;
}
const destination = (py * width + px) * 4,
color = palette[index];
target[destination] = color[0];
+37 -8
View File
@@ -1,7 +1,9 @@
import { OW } from "../world/constants";
import { CrystalAssets } from "../rom/assets";
import { GameWorld } from "../world/world";
import type { Direction } from "../world/types";
import { drawTile } from "./pixels";
import { TilesetAnimator } from "./tileset-animation";
import {
BASE_VIEWPORT,
renderViewportForBox,
@@ -14,6 +16,11 @@ export class WorldRenderer {
private context: CanvasRenderingContext2D;
private image: ImageData;
private canvas: HTMLCanvasElement;
/** Background color indices of the current frame, for OAM priority. */
private indices = new Uint8Array(0);
/** The current tileset's animation, advanced two video frames per tick. */
private animator?: TilesetAnimator;
private animated = { key: "", ticks: 0 };
constructor(
private assets: CrystalAssets,
canvas: HTMLCanvasElement,
@@ -59,8 +66,19 @@ export class WorldRenderer {
}
render(world: GameWorld) {
const map = world.map,
tileset = this.assets.tileset(map),
palettes = this.assets.palettes(map, world.timeOfDay);
tileset = this.assets.tileset(map);
const key = `${map.tileset}:${map.group}`;
if (this.animated.key !== key) {
this.animator = new TilesetAnimator(this.assets, map, tileset.graphics);
this.animated = { key, ticks: world.ticks };
}
const animator = this.animator!;
// _AnimateTileset runs in VBlank: two video frames per overworld tick.
animator.advance(Math.min(240, (world.ticks - this.animated.ticks) * 2), world.timeOfDay === 3, world.liveState.flags.has(OW.ENGINE_FOREST_IS_RESTLESS));
this.animated.ticks = world.ticks;
const palettes = animator.palettes(this.assets.palettes(map, world.timeOfDay));
if (this.indices.length !== this.width * this.height)
this.indices = new Uint8Array(this.width * this.height);
const poses = world.poses();
const player = poses.find((pose) => pose.id === 0) ?? {
x: world.x * 16,
@@ -69,8 +87,9 @@ export class WorldRenderer {
// Keep the camera player-centered as the original engine does. Beyond the
// edges, connected maps are drawn like the original connection strips;
// elsewhere the map's own border block fills the view.
// An earthquake shakes the view vertically (StepFunction_ScreenShake).
const cameraX = Math.round(player.x - (this.width / 2 - 8)),
cameraY = Math.round(player.y - (this.height / 2 - 8));
cameraY = Math.round(player.y - (this.height / 2 - 8)) + world.screenShake;
this.image.data.fill(255);
for (
let tileY = Math.floor(cameraY / 8);
@@ -97,11 +116,12 @@ export class WorldRenderer {
this.image.data,
this.width,
this.height,
tileset.graphics,
animator.graphics,
tile,
tileX * 8 - cameraX,
tileY * 8 - cameraY,
palettes[attribute & 7],
{ indices: this.indices },
);
}
}
@@ -136,6 +156,8 @@ export class WorldRenderer {
pose.frame,
time,
pose.palette,
pose.facing,
pose.inGrass,
);
const emote =
world.emote && poses.find((pose) => pose.id === world.emote!.object);
@@ -165,6 +187,8 @@ export class WorldRenderer {
frame: number,
time: number,
paletteIndex?: number,
facing?: number,
inGrass = false,
) {
const sprite = this.assets.sprite(id);
if (!sprite) return;
@@ -172,10 +196,11 @@ export class WorldRenderer {
paletteIndex ?? sprite.palette,
time,
);
for (const piece of this.assets.facing(
direction,
sprite.walking ? frame : 0,
)) {
const pieces =
facing !== undefined
? this.assets.facingTiles(facing)
: this.assets.facing(direction, sprite.walking ? frame : 0);
for (const piece of pieces) {
const tile = piece.tile >= 0x80 ? piece.tile - 0x80 + 12 : piece.tile;
drawTile(
this.image.data,
@@ -190,6 +215,10 @@ export class WorldRenderer {
transparent: true,
flipX: Boolean(piece.attributes & 0x20),
flipY: Boolean(piece.attributes & 0x40),
// RELATIVE_ATTRIBUTES pieces (the lower half) take OAM_PRIO from
// IN_GRASS_F and hide behind the grass's colored pixels.
indices: this.indices,
behind: inGrass && Boolean(piece.attributes & 0x02),
},
);
}
+173
View File
@@ -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);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { GameWorld } from "../world/world";
import { OVERWORLD, SYMBOLS } from "../rom/profile";
import { decompress } from "../rom/lz";
import { drawTile } from "./pixels";
/** The ROM's Town Map, used by wall maps and the bedroom poster. */
export function drawTownMap(canvas: HTMLCanvasElement, world: GameWorld) {
const { assets } = world,
r = assets.reader;
const landmark = world.map.landmark;
const map = landmark >= 47 ? OVERWORLD.KantoMap : OVERWORLD.JohtoMap;
const graphics = decompress(r, OVERWORLD.TownMapGFX, 0x1000);
const palette =
world.avatar === "kris"
? OVERWORLD.FemalePokegearPals
: OVERWORLD.MalePokegearPals;
canvas.width = 160;
canvas.height = 144;
const context = canvas.getContext("2d")!;
const image = context.createImageData(160, 144);
image.data.fill(255);
for (let i = 0; i < 360; i++) {
const tile = r.byte(map + i);
const packed =
tile < 0x60 ? r.byte(OVERWORLD["TownMapPals.PalMap"] + (tile >> 1)) : 0;
const color = (tile & 1 ? packed >> 4 : packed) & 7;
drawTile(
image.data,
160,
144,
graphics,
tile,
(i % 20) * 8,
Math.floor(i / 20) * 8,
assets.palette(palette + color * 8),
);
}
context.putImageData(image, 0, 0);
// Landmark coordinates are the same ones TownMapPlayerIcon uses.
const p = SYMBOLS.landmarks + landmark * 4;
context.fillStyle = "#b32628";
context.fillRect(r.byte(p) - 10, r.byte(p + 1) - 18, 4, 4);
canvas.setAttribute("aria-label", `Town Map: ${world.map.location}`);
}