This commit is contained in:
1 parent
2127f5f459
commit
8b80d05ca2
56 files changed
+6910
-1
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user