Implement native opening scenes, battle foundation and overworld movement
Checks / web (push) Canceled after 0s
Checks / web (push) Canceled after 0s
This commit is contained in:
1 parent
8b80d05ca2
commit
c08d250cf6
39 files changed
+10727
-1524
No files matched your search
+136
-64
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user