Add native Crystal exploration port and reference tooling
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-23 09:23:29 +02:00
1 parent 2127f5f459
commit 8b80d05ca2
56 files changed
+6910 -1

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
import type { GameRuntime } from "../app/runtime";
import type { Button } from "../input/types";
import { CrystalAssets } from "./rom/assets";
import { GameWorld } from "./world/world";
import type { Direction } from "./world/types";
import { WorldRenderer } from "./rendering/renderer";
export class NativeRuntime implements GameRuntime {
readonly id = "crystal-native-world-v1";
world?: GameWorld;
private renderer?: WorldRenderer;
private pressed = new Set<Button>();
private raf = 0;
private lastTime = 0;
private elapsed = 0;
private animation?: { x: number; y: number; elapsed: number };
private frame = 0;
constructor(
private onUpdate: (world: GameWorld) => void,
private onError: (error: unknown) => void,
private player: () => { name: string; avatar: "chris" | "kris" },
) {}
async load(bytes: Uint8Array, canvas: HTMLCanvasElement) {
this.dispose();
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.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.elapsed = 0;
this.animation = undefined;
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);
if (button === "a") {
this.animation = undefined;
this.world.interact();
this.publish();
}
if (button === "b" && this.world.dialogue) {
this.world.advanceDialogue();
this.publish();
}
if (button === "start" || button === "select") {
this.world.notice =
"Use Save session or Export backup below. Party, pack and Pokégear menus are not ported yet.";
this.onUpdate(this.world);
}
if (["up", "down", "left", "right"].includes(button) && !this.animation)
this.elapsed = 8 / 60;
}
private tick = (now: number) => {
try {
const world = this.world!;
const delta = this.lastTime
? Math.min((now - this.lastTime) / 1000, 0.05)
: 0;
this.lastTime = now;
this.elapsed += delta;
if (this.animation) {
this.animation.elapsed += delta;
if (this.animation.elapsed >= 8 / 60) this.animation = undefined;
}
const direction = [...this.pressed]
.reverse()
.find((button) => ["up", "down", "left", "right"].includes(button)) as
Direction | undefined;
if (
direction &&
!world.dialogue &&
this.elapsed >= 8 / 60 &&
!this.animation
) {
this.elapsed = 0;
const { x, y, mapId } = world;
if (world.move(direction) && world.mapId === mapId) {
this.animation = { x: x * 16, y: y * 16, elapsed: 0 };
this.frame = (this.frame + 1) % 4;
}
this.onUpdate(world);
}
const progress = this.animation ? this.animation.elapsed / (8 / 60) : 1;
const x = this.animation
? this.animation.x + (world.x * 16 - this.animation.x) * progress
: world.x * 16;
const y = this.animation
? this.animation.y + (world.y * 16 - this.animation.y) * progress
: world.y * 16;
this.renderer!.render(
world,
x,
y,
this.animation ? (this.frame % 2 ? 1 : 3) : 0,
);
this.raf = requestAnimationFrame(this.tick);
} catch (error) {
// Stop without rendering again: the renderer may be what failed.
cancelAnimationFrame(this.raf);
this.raf = 0;
this.lastTime = 0;
this.pressed.clear();
this.animation = undefined;
this.elapsed = 0;
this.onError(error);
}
};
private publish() {
if (this.world) {
this.renderer?.render(this.world);
this.onUpdate(this.world);
}
}
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.world.restore(
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
);
this.animation = undefined;
this.pressed.clear();
this.publish();
}
dispose() {
this.pause();
this.world = undefined;
this.renderer = undefined;
}
}