Files
Crystal/src/game/runtime.ts
T
Sébastien QUEROLandClaude Opus 5.5 451464f7c6
Checks / web (push) Canceled after 0s
Complete the overworld foundation
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>
2026-09-25 16:47:29 +02:00

252 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { GameRuntime } from "../app/runtime";
import type { Button } from "../input/types";
import { CrystalAssets } from "./rom/assets";
import { GameWorld, TICKS_PER_SECOND } from "./world/world";
import type { BattleAction } from "./battle/battle";
import type { Direction } from "./world/types";
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;
private lastTime = 0;
/** Seconds not yet consumed by whole overworld ticks. */
private pending = 0;
private published = "";
private resizeObserver?: ResizeObserver;
constructor(
private onUpdate: (world: GameWorld) => void,
private onError: (error: unknown) => void,
private player: () => { name: string; avatar: "chris" | "kris" },
/** Presentation handles menu navigation; battle rules stay in the world. */
private battleInput?: (button: Button) => boolean,
) {}
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();
world.name = player.name;
world.avatar = player.avatar;
this.world = world;
this.renderer = new WorldRenderer(assets, canvas);
this.observeCanvas(canvas);
this.publish();
}
play() {
if (!this.world || this.raf) return;
this.lastTime = 0;
this.raf = requestAnimationFrame(this.tick);
}
pause() {
cancelAnimationFrame(this.raf);
this.raf = 0;
this.lastTime = 0;
this.pressed.clear();
this.pending = 0;
if (this.world && this.renderer) this.renderer.render(this.world);
}
setButton(button: Button, pressed: boolean) {
if (!pressed) {
this.pressed.delete(button);
return;
}
if (this.pressed.has(button) || !this.world) return;
this.pressed.add(button);
const world = this.world;
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")
)
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);
this.publish();
}
advanceDialogue() {
this.world?.advanceDialogue();
this.publish();
}
battleAction(action: BattleAction) {
this.world?.battleAction(action);
this.publish();
}
battleChoose(answer: number) {
this.world?.battleChoose(answer);
this.publish();
}
battleName(name: string) {
this.world?.battleName(name);
this.publish();
}
private tick = (now: number) => {
try {
const world = this.world!;
const delta = this.lastTime
? Math.min((now - this.lastTime) / 1000, 0.1)
: 0;
this.lastTime = now;
this.pending += delta;
// The original overworld advances once every two video frames.
const held = [...this.pressed]
.reverse()
.find((button) => ["up", "down", "left", "right"].includes(button)) as
Direction | undefined;
let ticks = 0;
while (this.pending >= 1 / TICKS_PER_SECOND && ticks < 6) {
this.pending -= 1 / TICKS_PER_SECOND;
if (!this.fieldMenuOpen) world.tick(held);
ticks++;
}
if (ticks === 6) this.pending = 0;
if (ticks) {
this.renderer!.render(world);
this.publishIfChanged();
}
this.raf = requestAnimationFrame(this.tick);
} catch (error) {
// Stop without rendering again: the renderer may be what failed.
this.fail(error);
}
};
/** Presentation changes that the DOM shows, without per-tick churn. */
private publishIfChanged() {
const world = this.world!;
const dialogue = world.dialogue;
const signature = JSON.stringify([
world.mapId,
world.x,
world.y,
world.tilesWalked,
world.notice,
world.activity,
world.picture,
world.battle && [
world.battle.message,
world.battle.prompt?.kind,
world.battle.selected,
world.battle.engine.player.mon?.hp,
world.battle.engine.enemy.mon?.hp,
world.battle.engine.player.mon?.status,
world.battle.engine.enemy.mon?.status,
world.battle.engine.player.mon?.level,
],
dialogue?.index,
dialogue?.pages,
// The whole choice, not just `selected`: mart screens replace one
// choice kind with another (menu -> list -> quantity) while keeping
// the same page and index, and `world.liveState.money` the mart UI
// reads directly is otherwise absent from this signature.
dialogue?.choice,
world.liveState.money,
dialogue?.holdTicks === undefined,
]);
if (signature === this.published) return;
this.published = signature;
this.onUpdate(world);
}
private publish() {
if (this.world) {
this.renderer?.render(this.world);
this.published = "";
this.publishIfChanged();
}
}
private observeCanvas(canvas: HTMLCanvasElement) {
if (typeof ResizeObserver === "undefined") return;
try {
const observer = new ResizeObserver((entries) => {
try {
if (this.resizeObserver !== observer) return;
const entry = entries.find(
(candidate) => candidate.target === canvas,
);
if (!entry || !this.renderer) return;
const resized = this.renderer.resize(
entry.contentRect.width,
entry.contentRect.height,
);
// A playing session redraws on its next animation frame using the
// current interpolated position. Paused sessions need an immediate
// redraw because no frame is scheduled.
if (resized && this.world && !this.raf) {
this.renderer.render(this.world);
}
} catch (error) {
this.fail(error);
}
});
observer.observe(canvas);
this.resizeObserver = observer;
} catch {
// Resize support is an enhancement; loading remains usable if an older
// browser or test double rejects observation.
this.resizeObserver = undefined;
}
}
private fail(error: unknown) {
this.disconnectResizeObserver();
cancelAnimationFrame(this.raf);
this.raf = 0;
this.lastTime = 0;
this.pressed.clear();
this.pending = 0;
try {
this.onError(error);
} catch {
// Host error UI must not create an unhandled frame or observer error.
}
}
private disconnectResizeObserver() {
try {
this.resizeObserver?.disconnect();
} catch {
// A broken observer should not prevent teardown.
}
this.resizeObserver = undefined;
}
captureState() {
if (!this.world) throw new Error("Load your ROM first.");
return new TextEncoder().encode(JSON.stringify(this.world.save()));
}
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)),
);
this.pressed.clear();
this.publish();
}
dispose() {
this.disconnectResizeObserver();
this.pause();
this.world = undefined;
this.renderer = undefined;
}
}