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

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