Implement native opening scenes, battle foundation and overworld movement
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-23 16:09:22 +02:00
1 parent 8b80d05ca2
commit c08d250cf6
39 files changed
+10727 -1524

No files matched your search

+136 -64
View File
@@ -1,7 +1,8 @@
import type { GameRuntime } from "../app/runtime";
import type { Button } from "../input/types";
import { CrystalAssets } from "./rom/assets";
import { GameWorld } from "./world/world";
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";
@@ -12,9 +13,10 @@ export class NativeRuntime implements GameRuntime {
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;
/** 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,
@@ -29,6 +31,7 @@ export class NativeRuntime implements GameRuntime {
world.avatar = player.avatar;
this.world = world;
this.renderer = new WorldRenderer(assets, canvas);
this.observeCanvas(canvas);
this.publish();
}
play() {
@@ -41,8 +44,7 @@ export class NativeRuntime implements GameRuntime {
this.raf = 0;
this.lastTime = 0;
this.pressed.clear();
this.elapsed = 0;
this.animation = undefined;
this.pending = 0;
if (this.world && this.renderer) this.renderer.render(this.world);
}
setButton(button: Button, pressed: boolean) {
@@ -52,84 +54,154 @@ export class NativeRuntime implements GameRuntime {
}
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;
const world = this.world;
if (button === "a") world.interact();
if (button === "b") world.advanceDialogue();
if ((button === "up" || button === "down") && world.dialogue?.choice)
world.moveCursor(button);
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();
}
/** 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();
}
private tick = (now: number) => {
try {
const world = this.world!;
const delta = this.lastTime
? Math.min((now - this.lastTime) / 1000, 0.05)
? Math.min((now - this.lastTime) / 1000, 0.1)
: 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]
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;
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);
let ticks = 0;
while (this.pending >= 1 / TICKS_PER_SECOND && ticks < 6) {
this.pending -= 1 / TICKS_PER_SECOND;
world.tick(held);
ticks++;
}
if (ticks === 6) this.pending = 0;
if (ticks) {
this.renderer!.render(world);
this.publishIfChanged();
}
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);
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,
dialogue?.choice?.selected,
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.onUpdate(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()));
@@ -140,11 +212,11 @@ export class NativeRuntime implements GameRuntime {
this.world.restore(
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
);
this.animation = undefined;
this.pressed.clear();
this.publish();
}
dispose() {
this.disconnectResizeObserver();
this.pause();
this.world = undefined;
this.renderer = undefined;