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>
252 lines
8.0 KiB
TypeScript
252 lines
8.0 KiB
TypeScript
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;
|
||
}
|
||
}
|