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

+15 -4
View File
@@ -9,6 +9,7 @@ import { WorldRenderer } from "./rendering/renderer";
export class NativeRuntime implements GameRuntime {
readonly id = "crystal-native-world-v1";
world?: GameWorld;
fieldMenuOpen = false;
private renderer?: WorldRenderer;
private pressed = new Set<Button>();
private raf = 0;
@@ -26,6 +27,7 @@ export class NativeRuntime implements GameRuntime {
) {}
async load(bytes: Uint8Array, canvas: HTMLCanvasElement) {
this.dispose();
this.fieldMenuOpen = false;
const assets = new CrystalAssets(bytes);
const world = new GameWorld(assets);
const player = this.player();
@@ -57,20 +59,28 @@ export class NativeRuntime implements GameRuntime {
if (this.pressed.has(button) || !this.world) return;
this.pressed.add(button);
const world = this.world;
if (world.battle && this.battleInput?.(button)) {
if (this.battleInput?.(button)) {
this.publish();
return;
}
if (button === "a") world.interact();
if (button === "b") world.advanceDialogue();
if (["up", "down", "left", "right"].includes(button) &&
(world.dialogue?.choice || world.battle?.prompt?.kind === "yesno"))
if (
["up", "down", "left", "right"].includes(button) &&
(world.dialogue?.choice || world.battle?.prompt?.kind === "yesno")
)
world.moveCursor(button as Direction);
if ((button === "start" || button === "select") && !world.busy)
world.notice =
"Use the Savegames tab to save, export or import. Party, pack and Pokégear menus are not ported yet.";
this.publish();
}
useBicycle() {
this.fieldMenuOpen = false;
if (this.world && !this.world.useBicycle())
this.world.notice = "You can’t use the BICYCLE here.";
this.publish();
}
/** Answer a choice from a pointer or keyboard control outside the canvas. */
choose(answer: number | string) {
this.world?.choose(answer);
@@ -108,7 +118,7 @@ export class NativeRuntime implements GameRuntime {
let ticks = 0;
while (this.pending >= 1 / TICKS_PER_SECOND && ticks < 6) {
this.pending -= 1 / TICKS_PER_SECOND;
world.tick(held);
if (!this.fieldMenuOpen) world.tick(held);
ticks++;
}
if (ticks === 6) this.pending = 0;
@@ -225,6 +235,7 @@ export class NativeRuntime implements GameRuntime {
restoreState(bytes: Uint8Array) {
if (!this.world || bytes.length > 64_000)
throw new Error("Invalid native save size.");
this.fieldMenuOpen = false;
this.world.restore(
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
);