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

+13
View File
@@ -0,0 +1,13 @@
import type { Button } from "../input/types";
/** Application lifecycle contract, independent of emulation and hardware. */
export interface GameRuntime {
readonly id: string;
load(rom: Uint8Array, canvas: HTMLCanvasElement): Promise<void>;
play(): void;
pause(): void;
setButton(button: Button, pressed: boolean): void;
captureState(): Uint8Array;
restoreState(state: Uint8Array): void;
dispose(): void;
}
+90
View File
@@ -0,0 +1,90 @@
import type { GameRuntime } from "./runtime";
import type { ValidatedRom } from "../rom/validate";
import {
decodeSave,
type SaveRecord,
type SaveRepository,
} from "../storage/saves";
export type SessionState = "empty" | "loading" | "paused" | "playing" | "error";
export class GameSession {
state: SessionState = "empty";
private rom?: ValidatedRom;
// Queue snapshots in capture order; a slow write cannot overwrite newer progress.
private writes: Promise<void> = Promise.resolve();
constructor(
readonly runtime: GameRuntime,
private saves: SaveRepository,
) {}
async load(rom: ValidatedRom, canvas: HTMLCanvasElement) {
this.state = "loading";
this.rom = undefined;
try {
const saved = await this.saves.read(rom.sha1);
if (saved && (saved.version !== 1 || saved.coreId !== this.runtime.id)) {
throw new Error(
"The stored session needs migration before this player can open it. Your save has been kept.",
);
}
await this.runtime.load(rom.bytes, canvas);
if (saved) this.runtime.restoreState(saved.state);
// Never retain ROM bytes in storage or the session after loading.
this.rom = { ...rom, bytes: new Uint8Array() };
this.state = "paused";
return Boolean(saved);
} catch (error) {
this.runtime.dispose();
this.state = "error";
throw error;
}
}
play() {
if (this.state !== "paused") return;
this.runtime.play();
this.state = "playing";
}
pause() {
if (this.state !== "playing") return;
this.runtime.pause();
this.state = "paused";
}
snapshot(): SaveRecord {
if (!this.rom || !["paused", "playing"].includes(this.state))
throw new Error("Load a ROM before saving.");
return {
version: 1,
romHash: this.rom.sha1,
coreId: this.runtime.id,
savedAt: new Date().toISOString(),
state: this.runtime.captureState(),
};
}
save() {
const record = this.snapshot();
const operation = this.writes
.catch(() => {})
.then(() => this.saves.write(record));
this.writes = operation;
return operation.then(() => record.savedAt);
}
async importSave(text: string) {
if (!this.rom) throw new Error("Load your ROM before importing a backup.");
const record = decodeSave(text, this.rom.sha1, this.runtime.id);
this.pause();
const previous = this.runtime.captureState();
try {
this.runtime.restoreState(record.state);
} catch (error) {
this.runtime.restoreState(previous);
throw error;
}
try {
await this.save();
} catch (error) {
this.runtime.restoreState(previous);
throw error;
}
}
}
+332
View File
@@ -0,0 +1,332 @@
import { BUTTONS, type Button, type Emulator } from "./types";
type NativeCall = (...args: number[]) => number;
interface BinjgbModule {
HEAPU8: Uint8Array;
[name: `_${string}`]: NativeCall;
}
declare global {
interface Window {
Binjgb?: (options: {
locateFile: (path: string) => string;
}) => Promise<BinjgbModule>;
}
}
export const CORE_ID = "binjgb-c60e138da5a795ebb55e56b11b7e90024e41112c";
let scriptReady: Promise<void> | undefined;
async function createModule() {
const base = `${import.meta.env.BASE_URL}${import.meta.env.DEV && import.meta.env.MODE !== "reference" ? "reference/public/" : ""}vendor/binjgb/`;
if (!window.Binjgb) {
scriptReady ??= new Promise<void>((resolve, reject) => {
const script = document.createElement("script");
script.src = `${base}binjgb.js`;
script.onload = () => resolve();
script.onerror = () => {
script.remove();
scriptReady = undefined;
reject(new Error("Could not load the emulator. Reload and try again."));
};
document.head.append(script);
});
await scriptReady;
}
if (!window.Binjgb) throw new Error("Emulator initialization failed.");
return window.Binjgb({ locateFile: (path) => `${base}${path}` });
}
/** Vendor-specific exports, timing, video and audio stay behind Emulator. */
export class BinjgbEmulator implements Emulator {
readonly id = CORE_ID;
private module?: BinjgbModule;
private handle = 0;
private romPointer = 0;
private joypadPointer = 0;
private canvas?: CanvasRenderingContext2D;
private frame?: ImageData;
private audio?: AudioContext;
private gain?: GainNode;
private sources = new Set<AudioBufferSourceNode>();
private nextAudioTime = 0;
private raf = 0;
private lastTime = 0;
private muted = true;
constructor(private onError: (error: unknown) => void = () => {}) {}
async load(rom: Uint8Array, canvas: HTMLCanvasElement) {
this.dispose();
try {
this.module = await createModule();
// Audio is optional. Failure must not block a silent game.
try {
this.audio = new AudioContext();
this.gain = this.audio.createGain();
this.gain.gain.value = this.muted ? 0 : 0.4;
this.gain.connect(this.audio.destination);
} catch {
this.audio = undefined;
}
this.romPointer = this.module._malloc(rom.length);
if (!this.romPointer) throw new Error("Could not allocate ROM memory.");
this.module.HEAPU8.set(rom, this.romPointer);
this.handle = this.module._emulator_new_simple(
this.romPointer,
rom.length,
this.audio?.sampleRate ?? 44100,
2048,
2,
);
if (!this.handle)
throw new Error("The emulator could not open this ROM.");
this.joypadPointer = this.module._joypad_new();
if (!this.joypadPointer)
throw new Error("Could not initialize controls.");
this.module._emulator_set_default_joypad_callback(
this.handle,
this.joypadPointer,
);
canvas.width = 160;
canvas.height = 144;
this.canvas = canvas.getContext("2d", { alpha: false }) ?? undefined;
if (!this.canvas) throw new Error("Canvas rendering is unavailable.");
this.frame = this.canvas.createImageData(160, 144);
} catch (error) {
this.dispose();
throw error;
}
}
play() {
if (!this.handle || this.raf) return;
this.lastTime = 0;
this.raf = requestAnimationFrame(this.tick);
this.unlockAudio();
}
pause() {
cancelAnimationFrame(this.raf);
this.raf = 0;
this.lastTime = 0;
for (const button of BUTTONS) this.setButton(button, false);
for (const source of this.sources) source.stop();
this.sources.clear();
this.nextAudioTime = 0;
}
private tick = (now: number) => {
try {
const module = this.module!;
const elapsed = this.lastTime
? Math.min((now - this.lastTime) / 1000, 0.05)
: 1 / 60;
this.lastTime = now;
const target =
module._emulator_get_ticks_f64(this.handle) + elapsed * 4194304;
// Bound iterations so a broken core cannot freeze the page forever.
let complete = false;
for (let iteration = 0; iteration < 100; iteration++) {
const event = module._emulator_run_until_f64(this.handle, target);
if (event & 1) {
const pointer = module._get_frame_buffer_ptr(this.handle);
this.frame!.data.set(
module.HEAPU8.subarray(pointer, pointer + 160 * 144 * 4),
);
this.canvas!.putImageData(this.frame!, 0, 0);
}
if (event & 2) this.queueAudio();
if (event & 4) {
complete = true;
break;
}
}
if (!complete)
throw new Error(
"Emulation stopped unexpectedly. Reload your ROM to recover.",
);
this.raf = requestAnimationFrame(this.tick);
} catch (error) {
this.pause();
this.onError(error);
}
};
private queueAudio() {
const audio = this.audio;
if (!audio || audio.state !== "running" || this.muted || !this.gain) return;
const frames = 2048;
const pointer = this.module!._get_audio_buffer_ptr(this.handle);
const samples = this.module!.HEAPU8.subarray(pointer, pointer + frames * 2);
if (this.nextAudioTime > audio.currentTime + 0.25) return;
this.nextAudioTime = Math.max(
this.nextAudioTime,
audio.currentTime + 0.025,
);
const buffer = audio.createBuffer(2, frames, audio.sampleRate);
for (let channel = 0; channel < 2; channel++) {
const data = buffer.getChannelData(channel);
for (let i = 0; i < frames; i++) data[i] = samples[i * 2 + channel] / 255;
}
const source = audio.createBufferSource();
source.buffer = buffer;
source.connect(this.gain);
source.onended = () => {
this.sources.delete(source);
source.disconnect();
};
this.sources.add(source);
source.start(this.nextAudioTime);
this.nextAudioTime += frames / audio.sampleRate;
}
setButton(button: Button, pressed: boolean) {
if (!this.handle) return;
const nativeName =
button === "a" || button === "b" ? button.toUpperCase() : button;
this.module![`_set_joyp_${nativeName}`](this.handle, Number(pressed));
}
setMuted(muted: boolean) {
this.muted = muted;
if (this.gain) this.gain.gain.value = muted ? 0 : 0.4;
if (!muted) this.unlockAudio();
}
unlockAudio() {
if (this.audio?.state === "suspended")
void this.audio.resume().catch(() => {});
}
/** Development-only observation API. This adapter is not in the native
* production bundle. Used to compare translated routines with original ASM. */
readMemory(address: number): number {
if (
!this.handle ||
!Number.isInteger(address) ||
address < 0 ||
address > 0xffff
)
throw new Error("Invalid reference memory read.");
return this.module!._emulator_read_mem(this.handle, address);
}
writeMemory(address: number, value: number) {
if (
!this.handle ||
!Number.isInteger(address) ||
address < 0 ||
address > 0xffff ||
!Number.isInteger(value) ||
value < 0 ||
value > 255
)
throw new Error("Invalid reference memory write.");
this.module!._emulator_write_mem(this.handle, address, value);
}
setProgramCounter(address: number) {
if (
!this.handle ||
this.raf ||
!Number.isInteger(address) ||
address < 0 ||
address > 0xffff
)
throw new Error(
"Pause the reference before changing its program counter.",
);
this.module!._emulator_set_PC(this.handle, address);
}
advanceFrames(frames: number) {
if (
!this.handle ||
this.raf ||
!Number.isInteger(frames) ||
frames < 1 ||
frames > 3600
)
throw new Error("Pause the reference before bounded frame stepping.");
const module = this.module!;
for (let frame = 0; frame < frames; frame++) {
const target = module._emulator_get_ticks_f64(this.handle) + 70224;
let complete = false;
for (let events = 0; events < 100; events++) {
const event = module._emulator_run_until_f64(this.handle, target);
if (event & 1) {
const pointer = module._get_frame_buffer_ptr(this.handle);
this.frame!.data.set(
module.HEAPU8.subarray(pointer, pointer + 160 * 144 * 4),
);
}
if (event & 4) {
complete = true;
break;
}
}
if (!complete) throw new Error("Reference frame budget exceeded.");
}
this.canvas!.putImageData(this.frame!, 0, 0);
}
private fileData(
kind: "state" | "ext_ram",
action: (pointer: number, bytes: Uint8Array) => void,
) {
if (!this.handle || !this.module) throw new Error("Load a ROM first.");
const module = this.module;
const pointer = module[`_${kind}_file_data_new`](this.handle);
if (!pointer) throw new Error("Could not allocate save memory.");
try {
const start = module._get_file_data_ptr(pointer);
const length = module._get_file_data_size(pointer);
action(pointer, module.HEAPU8.subarray(start, start + length));
} finally {
module._file_data_delete(pointer);
}
}
captureState() {
let state!: Uint8Array;
this.fileData("state", (pointer, bytes) => {
if (this.module!._emulator_write_state(this.handle, pointer) !== 0)
throw new Error("Could not capture the session.");
state = bytes.slice();
});
return state;
}
restoreState(state: Uint8Array) {
const current = this.captureState();
// Pinned EmulatorState: header at 0..3, cartridge-info index at 8.
// Refuse an incompatible header/index before passing untrusted bytes to C.
if (
state.length !== current.length ||
state[8] !== current[8] ||
!state.subarray(0, 4).every((byte, index) => byte === current[index])
) {
throw new Error("This backup has an invalid state header or size.");
}
this.fileData("state", (pointer, bytes) => {
if (state.length !== bytes.length)
throw new Error("This backup has an invalid state size.");
bytes.set(state);
if (this.module!._emulator_read_state(this.handle, pointer) !== 0)
throw new Error("This backup contains an invalid state.");
});
for (const button of BUTTONS) this.setButton(button, false);
}
exportBattery() {
let battery!: Uint8Array;
this.fileData("ext_ram", (pointer, bytes) => {
if (this.module!._emulator_write_ext_ram(this.handle, pointer) !== 0)
throw new Error("Could not export the in-game save.");
battery = bytes.slice();
});
return battery;
}
dispose() {
this.pause();
if (this.handle) this.module!._emulator_delete(this.handle);
if (this.joypadPointer) this.module!._joypad_delete(this.joypadPointer);
if (this.romPointer) this.module!._free(this.romPointer);
this.handle = 0;
this.joypadPointer = 0;
this.romPointer = 0;
void this.audio?.close().catch(() => {});
this.audio = undefined;
this.gain = undefined;
this.module = undefined;
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { Button } from "../input/types";
export { BUTTONS, type Button } from "../input/types";
/** The UI never accesses WASM memory or a vendor API. */
export interface Emulator {
readonly id: string;
load(rom: Uint8Array, canvas: HTMLCanvasElement): Promise<void>;
play(): void;
pause(): void;
setButton(button: Button, pressed: boolean): void;
setMuted(muted: boolean): void;
unlockAudio(): void;
captureState(): Uint8Array;
restoreState(state: Uint8Array): void;
exportBattery(): Uint8Array;
dispose(): void;
}
+36
View File
@@ -0,0 +1,36 @@
import type { Palette } from "../world/types";
/** Decode Game Boy planar pixels directly to RGBA; no display hardware emulation. */
export function drawTile(
target: Uint8ClampedArray,
width: number,
height: number,
graphics: Uint8Array,
tile: number,
x: number,
y: number,
palette: Palette,
options: { transparent?: boolean; flipX?: boolean; flipY?: boolean } = {},
) {
const offset = tile * 16;
if (offset < 0 || offset + 16 > graphics.length) return;
for (let row = 0; row < 8; row++) {
const sourceRow = options.flipY ? 7 - row : row;
const lo = graphics[offset + sourceRow * 2],
hi = graphics[offset + sourceRow * 2 + 1];
for (let col = 0; col < 8; col++) {
const bit = options.flipX ? col : 7 - col;
const index = ((lo >> bit) & 1) | (((hi >> bit) & 1) << 1);
if (options.transparent && index === 0) continue;
const px = x + col,
py = y + row;
if (px < 0 || py < 0 || px >= width || py >= height) continue;
const destination = (py * width + px) * 4,
color = palette[index];
target[destination] = color[0];
target[destination + 1] = color[1];
target[destination + 2] = color[2];
target[destination + 3] = 255;
}
}
}
+137
View File
@@ -0,0 +1,137 @@
import { CrystalAssets } from "../rom/assets";
import { GameWorld } from "../world/world";
import type { Direction } from "../world/types";
import { drawTile } from "./pixels";
export class WorldRenderer {
readonly width = 160;
readonly height = 144;
private context: CanvasRenderingContext2D;
private image: ImageData;
constructor(
private assets: CrystalAssets,
canvas: HTMLCanvasElement,
) {
canvas.width = this.width;
canvas.height = this.height;
const context = canvas.getContext("2d", { alpha: false });
if (!context) throw new Error("Canvas rendering is unavailable.");
this.context = context;
this.image = context.createImageData(this.width, this.height);
}
render(
world: GameWorld,
playerX = world.x * 16,
playerY = world.y * 16,
frame = 0,
) {
const map = world.map,
tileset = this.assets.tileset(map),
palettes = this.assets.palettes(map, world.timeOfDay);
// Keep the camera player-centered as the original engine does. Out-of-map
// pixels use the map's own border block, including small room padding.
const cameraX = Math.round(playerX - 72),
cameraY = Math.round(playerY - 64);
this.image.data.fill(255);
for (
let tileY = Math.floor(cameraY / 8);
tileY < Math.ceil((cameraY + this.height) / 8);
tileY++
) {
for (
let tileX = Math.floor(cameraX / 8);
tileX < Math.ceil((cameraX + this.width) / 8);
tileX++
) {
const block = this.assets.block(
map,
Math.floor(tileX / 2),
Math.floor(tileY / 2),
);
const localX = ((tileX % 4) + 4) % 4,
localY = ((tileY % 4) + 4) % 4;
const encoded = tileset.metatiles[block * 16 + localY * 4 + localX];
const packed = tileset.paletteMap[encoded >> 1],
attribute = encoded & 1 ? packed >> 4 : packed & 15;
const tile = (encoded & 0x7f) + (attribute & 8 ? 0x60 : 0);
drawTile(
this.image.data,
this.width,
this.height,
tileset.graphics,
tile,
tileX * 8 - cameraX,
tileY * 8 - cameraY,
palettes[attribute & 7],
);
}
}
const actors = world
.objects()
.map((object) => ({
x: object.x * 16,
y: object.y * 16,
sprite: object.sprite,
direction: world.objectDirection(object),
palette: object.palette ? object.palette - 1 : undefined,
frame: 0,
}));
actors.push({
x: playerX,
y: playerY,
sprite: world.avatar === "kris" ? 0x60 : 1,
direction: world.direction,
palette: undefined,
frame,
});
actors.sort((a, b) => a.y - b.y);
for (const actor of actors)
this.actor(
actor.sprite,
Math.round(actor.x - cameraX),
Math.round(actor.y - cameraY - 4),
actor.direction,
actor.frame,
world.timeOfDay,
actor.palette,
);
this.context.putImageData(this.image, 0, 0);
}
private actor(
id: number,
x: number,
y: number,
direction: Direction,
frame: number,
time: number,
paletteIndex?: number,
) {
const sprite = this.assets.sprite(id);
if (!sprite) return;
const palette = this.assets.objectPalette(
paletteIndex ?? sprite.palette,
time,
);
for (const piece of this.assets.facing(
direction,
sprite.walking ? frame : 0,
)) {
const tile = piece.tile >= 0x80 ? piece.tile - 0x80 + 12 : piece.tile;
drawTile(
this.image.data,
this.width,
this.height,
sprite.graphics,
tile,
x + piece.x,
y + piece.y,
palette,
{
transparent: true,
flipX: Boolean(piece.attributes & 0x20),
flipY: Boolean(piece.attributes & 0x40),
},
);
}
}
}
+245
View File
@@ -0,0 +1,245 @@
import { RomReader } from "./reader";
import { decompress } from "./lz";
import { NATIVE_MAPS, SYMBOLS } from "./profile";
import type {
Direction,
GameMap,
Tileset,
Palette,
Sprite,
FacingTile,
} from "../world/types";
/** The only layer that knows where Crystal stores its data. Everything is
* decoded from the supplied ROM, on demand, and cached only in memory. */
export class CrystalAssets {
readonly reader: RomReader;
private maps = new Map<string, GameMap>();
private tilesets = new Map<string, Tileset>();
private sprites = new Map<number, Sprite>();
constructor(bytes: Uint8Array) {
this.reader = new RomReader(bytes);
}
map(id: string): GameMap {
const cached = this.maps.get(id);
if (cached) return cached;
if (!Object.hasOwn(NATIVE_MAPS, id))
throw new Error("This area has not been ported yet.");
const [group, number] = id.split(":").map(Number);
const r = this.reader;
const header =
r.near(SYMBOLS.mapGroups + (group - 1) * 2, r.bank(SYMBOLS.mapGroups)) +
(number - 1) * 9;
const attributes = r.near(header + 3, r.byte(header));
const blockWidth = r.byte(attributes + 2),
blockHeight = r.byte(attributes + 1);
if (!blockWidth || !blockHeight || blockWidth > 64 || blockHeight > 64)
throw new Error("Invalid map dimensions.");
const scriptBank = r.byte(attributes + 6);
let p = r.near(attributes + 9, scriptBank) + 2;
const map: GameMap = {
id,
group,
number,
name: NATIVE_MAPS[id],
width: blockWidth * 2,
height: blockHeight * 2,
border: r.byte(attributes),
tileset: r.byte(header + 1),
environment: r.byte(header + 2),
paletteMode: r.byte(header + 7) & 15,
blocks: r.slice(r.far(attributes + 3), blockWidth * blockHeight),
warps: [],
backgroundEvents: [],
objects: [],
connections: [],
sceneScripts: [],
callbacks: [],
coordinateEvents: [],
};
const warpCount = r.byte(p++);
for (let i = 0; i < warpCount; i++, p += 5) {
map.warps.push({
y: r.byte(p),
x: r.byte(p + 1),
targetWarp: r.byte(p + 2),
targetMap: `${r.byte(p + 3)}:${r.byte(p + 4)}`,
});
}
const coordinateCount = r.byte(p++);
for (let i = 0; i < coordinateCount; i++, p += 8) {
map.coordinateEvents.push({
scene: r.byte(p),
y: r.byte(p + 1),
x: r.byte(p + 2),
script: r.near(p + 4, scriptBank),
});
}
const backgroundCount = r.byte(p++);
for (let i = 0; i < backgroundCount; i++, p += 5) {
map.backgroundEvents.push({
y: r.byte(p),
x: r.byte(p + 1),
kind: r.byte(p + 2),
script: r.near(p + 3, scriptBank),
});
}
const objectCount = r.byte(p++);
for (let i = 0; i < objectCount; i++, p += 13) {
map.objects.push({
id: i + 2,
sprite: r.byte(p),
y: r.byte(p + 1) - 4,
x: r.byte(p + 2) - 4,
movement: r.byte(p + 3),
hour1: r.byte(p + 5),
hour2: r.byte(p + 6),
palette: r.byte(p + 7) >> 4,
kind: r.byte(p + 7) & 15,
script: r.near(p + 9, scriptBank),
eventFlag: r.word(p + 11),
});
}
p = r.near(attributes + 7, scriptBank);
const sceneCount = r.byte(p++);
for (let i = 0; i < sceneCount; i++, p += 4)
map.sceneScripts.push(r.near(p, scriptBank));
const callbackCount = r.byte(p++);
for (let i = 0; i < callbackCount; i++, p += 3)
map.callbacks.push({
kind: r.byte(p),
script: r.near(p + 1, scriptBank),
});
p = attributes + 12;
for (const [direction, bit] of [
["up", 8],
["down", 4],
["left", 2],
["right", 1],
] as const) {
if (!(r.byte(attributes + 11) & bit)) continue;
const signed = (v: number) => (v > 127 ? v - 256 : v);
map.connections.push({
direction,
targetMap: `${r.byte(p)}:${r.byte(p + 1)}`,
y: signed(r.byte(p + 8)),
x: signed(r.byte(p + 9)),
});
p += 12;
}
this.maps.set(id, map);
return map;
}
tileset(map: GameMap): Tileset {
const key = `${map.tileset}:${map.group}`;
const cached = this.tilesets.get(key);
if (cached) return cached;
const r = this.reader,
base = SYMBOLS.tilesets + map.tileset * 15;
const graphics = decompress(r, r.far(base), 0x4000);
const meta = r.far(base + 3),
collision = r.far(base + 6);
// Each tileset contains 128 metatiles (4x4 8px tiles), each with four
// quadrant permissions for the 16px movement grid.
const result: Tileset = {
graphics,
metatiles: r.slice(meta, 128 * 16),
collision: r.slice(collision, 128 * 4),
paletteMap: r.slice(r.near(base + 13, r.bank(SYMBOLS.tilesets)), 128),
};
if ([1, 2, 4].includes(map.tileset)) {
const roof = r.byte(SYMBOLS.roofTypes + map.group);
if (roof !== 255)
result.graphics.set(
r.slice(SYMBOLS.roofs + roof * 9 * 16, 9 * 16),
0x0a * 16,
);
}
this.tilesets.set(key, result);
return result;
}
block(map: GameMap, x: number, y: number): number {
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return map.border;
return map.blocks[Math.floor(y / 2) * (map.width / 2) + Math.floor(x / 2)];
}
collision(map: GameMap, x: number, y: number): number {
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return 7;
const block = this.block(map, x, y);
if (block >= 128) throw new Error("Map contains an invalid block.");
return this.tileset(map).collision[block * 4 + (y % 2) * 2 + (x % 2)];
}
permission(collision: number): number {
return this.reader.byte(SYMBOLS.collisions + collision) & 15;
}
palette(offset: number): Palette {
return Array.from({ length: 4 }, (_, i) => {
const color = this.reader.word(offset + i * 2);
return [
Math.round(((color & 31) * 255) / 31),
Math.round((((color >> 5) & 31) * 255) / 31),
Math.round((((color >> 10) & 31) * 255) / 31),
];
});
}
palettes(map: GameMap, time: number): Palette[] {
const r = this.reader;
const group =
r.near(
SYMBOLS.environmentColors + map.environment * 2,
r.bank(SYMBOLS.environmentColors),
) +
time * 8;
const palettes = Array.from({ length: 8 }, (_, i) =>
this.palette(
map.tileset === 5
? SYMBOLS.housePalettes + i * 8
: SYMBOLS.backgroundPalettes + r.byte(group + i) * 8,
),
);
if (map.environment === 1 || map.environment === 2) {
const roof = SYMBOLS.roofPalettes + map.group * 8 + (time >= 2 ? 4 : 0);
const colors = this.palette(roof);
palettes[6] = [palettes[6][0], colors[0], colors[1], palettes[6][3]];
}
return palettes;
}
objectPalette(index: number, time: number): Palette {
return this.palette(SYMBOLS.objectPalettes + time * 64 + (index & 7) * 8);
}
sprite(id: number): Sprite | undefined {
// Variable decorations and Pokémon icons need separate decoders.
if (id < 1 || id >= 0xa0) return undefined;
const cached = this.sprites.get(id);
if (cached) return cached;
const r = this.reader,
p = SYMBOLS.sprites + (id - 1) * 6;
const length = r.byte(p + 2),
walking = r.byte(p + 4) === 1;
const sprite = {
graphics: r.slice(r.near(p, r.byte(p + 3)), length * (walking ? 2 : 1)),
palette: r.byte(p + 5),
walking,
};
this.sprites.set(id, sprite);
return sprite;
}
facing(direction: Direction, frame: number): FacingTile[] {
const facing =
["down", "up", "left", "right"].indexOf(direction) * 4 + (frame % 4);
const r = this.reader,
pointer = r.near(SYMBOLS.facings + facing * 2, r.bank(SYMBOLS.facings));
return Array.from({ length: r.byte(pointer) }, (_, i) => {
const p = pointer + 1 + i * 4;
return {
y: r.byte(p),
x: r.byte(p + 1),
attributes: r.byte(p + 2),
tile: r.byte(p + 3),
};
});
}
}
+57
View File
@@ -0,0 +1,57 @@
import { RomReader } from "./reader";
/** Native translation of Crystal's LZ data format (home/decompress.asm).
* Copy commands may overlap their own output. Addresses are relative to the
* decompressed stream, never arbitrary memory. */
export function decompress(
reader: RomReader,
offset: number,
limit = 0x10000,
): Uint8Array {
const output: number[] = [];
let cursor = offset;
const read = () => reader.byte(cursor++);
for (let commandCount = 0; commandCount < limit; commandCount++) {
const header = read();
if (header === 0xff) return Uint8Array.from(output);
let command = header >> 5;
let length = (header & 31) + 1;
if (command === 7) {
command = (header >> 2) & 7;
length = (((header & 3) << 8) | read()) + 1;
}
if (command === 7 || output.length + length > limit)
throw new Error("Invalid or oversized compressed graphic.");
if (command === 0) {
for (let i = 0; i < length; i++) output.push(read());
} else if (command === 1) {
const value = read();
for (let i = 0; i < length; i++) output.push(value);
} else if (command === 2) {
const a = read(),
b = read();
for (let i = 0; i < length; i++) output.push(i % 2 ? b : a);
} else if (command === 3) {
for (let i = 0; i < length; i++) output.push(0);
} else {
const first = read();
let source =
first & 0x80
? output.length - (first & 0x7f) - 1
: (first << 8) | read();
for (let i = 0; i < length; i++) {
if (source < 0 || source >= output.length)
throw new Error("Invalid compressed graphic back-reference.");
let value = output[source];
if (command === 5) {
value = ((value & 0x55) << 1) | ((value >> 1) & 0x55);
value = ((value & 0x33) << 2) | ((value >> 2) & 0x33);
value = ((value & 0x0f) << 4) | (value >> 4);
}
output.push(value);
source += command === 6 ? -1 : 1;
}
}
}
throw new Error("Compressed graphic has no terminator.");
}
+30
View File
@@ -0,0 +1,30 @@
/** Addresses only, from PRET 7a7881d0d62e0ddbd82dcf10e7116807487ac651.
* RGBDS 1.0.3 crystal11 build was byte-identical to the user-supplied ROM.
* No original graphics, maps, dialogue, music or ROM instructions live here.
* See docs/NATIVE_PORT.md for source references and verification procedure. */
export const SYMBOLS = {
mapGroups: 0x94000,
tilesets: 0x4d596,
collisions: 0x4ce1f,
environmentColors: 0xb279,
backgroundPalettes: 0xb319,
objectPalettes: 0xb469,
roofPalettes: 0xb569,
roofTypes: 0x1c021,
roofs: 0x1c03c,
housePalettes: 0x495ee,
sprites: 0x14736,
facings: 0x4049,
standardScripts: 0xbc000,
} as const;
/** The currently ported area. Story callbacks are not executed yet. */
export const NATIVE_MAPS: Record<string, string> = {
"24:4": "New Bark Town",
"24:5": "Elm’s lab",
"24:6": "Your home",
"24:7": "Your room",
"24:8": "Neighbor’s house",
"24:9": "Elm’s house",
};
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
+54
View File
@@ -0,0 +1,54 @@
/** Bounds-checked access to the user's cartridge. No CPU or hardware state. */
export class RomReader {
constructor(readonly bytes: Uint8Array) {}
byte(offset: number): number {
this.check(offset, 1);
return this.bytes[offset];
}
word(offset: number): number {
return this.byte(offset) | (this.byte(offset + 1) << 8);
}
slice(offset: number, length: number): Uint8Array {
this.check(offset, length);
return this.bytes.slice(offset, offset + length);
}
address(bank: number, address: number): number {
if (
!Number.isInteger(bank) ||
bank < 0 ||
bank >= this.bytes.length / 0x4000 ||
!Number.isInteger(address) ||
address < 0 ||
address >= 0x8000
) {
throw new Error("Invalid ROM bank or address.");
}
const offset =
address < 0x4000 ? address : bank * 0x4000 + address - 0x4000;
this.check(offset, 1);
return offset;
}
near(pointer: number, bank: number): number {
return this.address(bank, this.word(pointer));
}
far(pointer: number): number {
return this.near(pointer + 1, this.byte(pointer));
}
bank(offset: number): number {
this.check(offset, 1);
return Math.floor(offset / 0x4000);
}
private check(offset: number, length: number) {
if (
!Number.isInteger(offset) ||
!Number.isInteger(length) ||
offset < 0 ||
length < 0 ||
offset + length > this.bytes.length
) {
throw new Error(
"Cartridge data is truncated or contains an invalid pointer.",
);
}
}
}
+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;
}
}
+208
View File
@@ -0,0 +1,208 @@
import { RomReader } from "../rom/reader";
import { SYMBOLS } from "../rom/profile";
import { readText } from "./text";
export interface ScriptState {
events: Set<number>;
flags: Set<number>;
variables: Record<number, number>;
}
export interface ScriptResult extends ScriptState {
pages: string[];
facePlayer: boolean;
turns: { object: number; direction: number }[];
}
export class UnsupportedScript extends Error {
constructor(
readonly offset: number,
readonly opcode: number,
) {
super(
`Script command 0x${opcode.toString(16)} at 0x${offset.toString(16)} is not ported.`,
);
}
}
/** A native interpreter for the game's own event format, NOT GB assembly.
* Evaluate on a private draft. Unknown commands abort the whole interaction;
* callers commit only after successful completion and dialogue acknowledgement. */
export function runScript(
reader: RomReader,
start: number,
state: ScriptState,
name: string,
): ScriptResult {
const result: ScriptResult = {
events: new Set(state.events),
flags: new Set(state.flags),
variables: { ...state.variables },
pages: [],
facePlayer: false,
turns: [],
};
const stack: number[] = [];
let p = start,
value = 0;
const byte = () => reader.byte(p++);
const word = () => {
const value = reader.word(p);
p += 2;
return value;
};
const near = () => {
const value = reader.near(p, reader.bank(p));
p += 2;
return value;
};
const far = () => {
const value = reader.far(p);
p += 3;
return value;
};
const text = (address: number) => {
const content = readText(reader, address, name);
if (content) result.pages.push(...content.split("\n\n"));
if (result.pages.length > 64) throw new Error("Too many dialogue pages.");
};
for (let steps = 0; steps < 2048; steps++) {
const offset = p,
opcode = byte();
switch (opcode) {
case 0x00: {
const address = near();
stack.push(p);
p = address;
break;
}
case 0x01: {
const address = far();
stack.push(p);
p = address;
break;
}
case 0x03:
p = near();
break;
case 0x04:
p = far();
break;
case 0x06:
case 0x07:
case 0x0a:
case 0x0b: {
const compared = byte(),
address = near();
const match =
opcode === 6
? value === compared
: opcode === 7
? value !== compared
: opcode === 10
? value > compared
: value < compared;
if (match) p = address;
break;
}
case 0x08:
case 0x09: {
const address = near();
if (Boolean(value) === (opcode === 9)) p = address;
break;
}
case 0x0c:
case 0x0d: {
const index = word();
if (index >= 52) throw new Error("Invalid standard script.");
if (opcode === 0x0d) stack.push(p);
p = reader.far(SYMBOLS.standardScripts + index * 3);
break;
}
case 0x15:
value = byte();
break;
case 0x16:
value = (value + byte()) & 255;
break;
case 0x31:
value = Number(result.events.has(word()));
break;
case 0x32:
result.events.delete(word());
break;
case 0x33:
result.events.add(word());
break;
case 0x34:
value = Number(result.flags.has(word()));
break;
case 0x35:
result.flags.delete(word());
break;
case 0x36:
result.flags.add(word());
break;
case 0x47:
case 0x49:
case 0x54:
case 0x55:
break; // handled by native dialogue presentation
case 0x4b:
text(far());
break;
case 0x4c:
text(near());
break;
case 0x51:
result.facePlayer = true;
text(near());
return result;
case 0x52:
text(far());
return result;
case 0x53:
text(near());
return result;
case 0x6b:
result.facePlayer = true;
break;
case 0x6d: {
const sprite = byte();
result.variables[sprite] = byte();
break;
}
case 0x76:
result.turns.push({ object: byte(), direction: byte() });
break;
// Audio and delays are presentation events. Native sound sequencing is a
// separate pending subsystem; consuming their operands keeps data aligned.
case 0x7f:
case 0x85:
word();
break;
case 0x81:
word();
byte();
break;
case 0x82:
case 0x83:
case 0x86:
case 0x87:
case 0x88:
break;
case 0x8b:
byte();
break;
case 0x90:
case 0x91:
if (!stack.length) return result;
p = stack.pop()!;
break;
case 0x93:
return result;
default:
throw new UnsupportedScript(offset, opcode);
}
if (stack.length > 16) throw new Error("Script call depth exceeded.");
}
throw new Error("Script instruction budget exceeded.");
}
+127
View File
@@ -0,0 +1,127 @@
import { RomReader } from "../rom/reader";
const CHARACTERS: Record<number, string> = {
0x1f: " ",
0x22: "\n",
0x25: "",
0x49: "MOM",
0x4a: "Pokémon",
0x4b: "\n",
0x4c: "\n",
0x4e: "\n",
0x4f: "\n",
0x51: "\n\n",
0x53: "???",
0x54: "POKé",
0x55: "\n",
0x56: "……",
0x5b: "PC",
0x5c: "TM",
0x5d: "TRAINER",
0x5e: "ROCKET",
0x70: "PO",
0x71: "Ké",
0x72: "“",
0x73: "”",
0x74: "·",
0x75: "…",
0x7f: " ",
0x9a: "(",
0x9b: ")",
0x9c: ":",
0x9d: ";",
0x9e: "[",
0x9f: "]",
0xc0: "Ä",
0xc1: "Ö",
0xc2: "Ü",
0xc3: "ä",
0xc4: "ö",
0xc5: "ü",
0xd0: "'d",
0xd1: "'l",
0xd2: "'m",
0xd3: "'r",
0xd4: "'s",
0xd5: "'t",
0xd6: "'v",
0xdf: "←",
0xe0: "'",
0xe1: "POKé",
0xe2: "MON",
0xe3: "-",
0xe6: "?",
0xe7: "!",
0xe8: ".",
0xe9: "&",
0xea: "é",
0xeb: "→",
0xec: "▷",
0xed: "▶",
0xee: "▼",
0xef: "♂",
0xf0: "¥",
0xf1: "×",
0xf2: ".",
0xf3: "/",
0xf4: ",",
0xf5: "♀",
};
export class UnsupportedText extends Error {
constructor(
readonly offset: number,
readonly opcode: number,
) {
super(
`Text command 0x${opcode.toString(16)} at 0x${offset.toString(16)} is not ported.`,
);
}
}
export function readText(
reader: RomReader,
start: number,
playerName: string,
depth = 0,
): string {
if (depth > 8) throw new Error("Text calls exceed the recursion limit.");
let p = start,
output = "";
for (let count = 0; count < 8192; count++) {
const commandOffset = p,
command = reader.byte(p++);
if (command === 0x50) return output.trim();
if (command === 0x16) {
output += readText(
reader,
reader.near(p, reader.byte(p + 2)),
playerName,
depth + 1,
);
p += 3;
continue;
}
if (command !== 0) throw new UnsupportedText(commandOffset, command);
let ended = false;
for (let length = 0; length < 8192; length++) {
const offset = p,
code = reader.byte(p++);
if (code === 0x57 || code === 0x58) return output.trim();
if (code === 0x50) {
ended = true;
break;
}
if (code === 0x52 || code === 0x14) output += playerName;
else if (code >= 0x80 && code <= 0x99)
output += String.fromCharCode(65 + code - 0x80);
else if (code >= 0xa0 && code <= 0xb9)
output += String.fromCharCode(97 + code - 0xa0);
else if (code >= 0xf6) output += String(code - 0xf6);
else if (CHARACTERS[code] !== undefined) output += CHARACTERS[code];
else throw new UnsupportedText(offset, code);
}
if (!ended) throw new Error("Unterminated text.");
}
throw new Error("Text command limit exceeded.");
}
+77
View File
@@ -0,0 +1,77 @@
export type Direction = "down" | "up" | "left" | "right";
export const VECTORS: Record<Direction, readonly [number, number]> = {
down: [0, 1],
up: [0, -1],
left: [-1, 0],
right: [1, 0],
};
export const DIRECTION_ORDER: Direction[] = ["down", "up", "left", "right"];
export interface Warp {
x: number;
y: number;
targetMap: string;
targetWarp: number;
}
export interface BackgroundEvent {
x: number;
y: number;
kind: number;
script: number;
}
export interface MapObject {
id: number;
sprite: number;
x: number;
y: number;
movement: number;
palette: number;
kind: number;
script: number;
eventFlag: number;
hour1: number;
hour2: number;
}
export interface MapConnection {
direction: Direction;
targetMap: string;
x: number;
y: number;
}
export interface GameMap {
id: string;
group: number;
number: number;
name: string;
width: number;
height: number;
border: number;
tileset: number;
environment: number;
paletteMode: number;
blocks: Uint8Array;
warps: Warp[];
backgroundEvents: BackgroundEvent[];
objects: MapObject[];
connections: MapConnection[];
sceneScripts: number[];
callbacks: { kind: number; script: number }[];
coordinateEvents: { scene: number; x: number; y: number; script: number }[];
}
export interface Tileset {
graphics: Uint8Array;
metatiles: Uint8Array;
collision: Uint8Array;
paletteMap: Uint8Array;
}
export type Palette = readonly (readonly [number, number, number])[];
export interface Sprite {
graphics: Uint8Array;
palette: number;
walking: boolean;
}
export interface FacingTile {
x: number;
y: number;
attributes: number;
tile: number;
}
+370
View File
@@ -0,0 +1,370 @@
import { CrystalAssets } from "../rom/assets";
import { INITIAL_POSITION, NATIVE_MAPS, SYMBOLS } from "../rom/profile";
import {
runScript,
type ScriptResult,
type ScriptState,
} from "../scripts/interpreter";
import {
DIRECTION_ORDER,
VECTORS,
type Direction,
type MapObject,
} from "./types";
export interface NativeSave {
format: "crystal-native";
version: 1;
map: string;
x: number;
y: number;
direction: Direction;
name: string;
avatar: "chris" | "kris";
tilesWalked: number;
events: number[];
flags: number[];
variables: Record<number, number>;
}
/** Deterministic native world rules. Rendering, browser APIs and emulator
* registers are deliberately absent; callers provide time and input. */
export class GameWorld {
mapId: string = INITIAL_POSITION.map;
x: number = INITIAL_POSITION.x;
y: number = INITIAL_POSITION.y;
direction: Direction = "down";
name = "CHRIS";
avatar: "chris" | "kris" = "chris";
tilesWalked = 0;
state: ScriptState = { events: new Set(), flags: new Set(), variables: {} };
dialogue:
{ pages: string[]; index: number; result: ScriptResult } | undefined;
notice = "";
diagnostic = "";
private objectDirections = new Map<number, Direction>();
constructor(
readonly assets: CrystalAssets,
private now: () => Date = () => new Date(),
) {
// Port the original initialization event table through the script engine;
// this supplies NPC visibility flags without distributing a copied table.
const initial = assets.reader.far(SYMBOLS.standardScripts + 24 * 3);
this.state = runScript(assets.reader, initial, this.state, this.name);
assets.map(this.mapId);
}
get map() {
return this.assets.map(this.mapId);
}
get timeOfDay(): number {
const mode = this.map.paletteMode;
if (mode === 1) return 1;
if (mode === 2) return 2;
if (mode === 3) return 0;
if (mode === 4) return 3;
const hour = this.now().getHours();
return hour >= 4 && hour < 10 ? 0 : hour >= 10 && hour < 18 ? 1 : 2;
}
objects(): MapObject[] {
const hour = this.now().getHours(),
timeMask = 1 << this.timeOfDay;
return this.map.objects.filter((object) => {
if (
object.eventFlag !== 0xffff &&
this.state.events.has(object.eventFlag)
)
return false;
if (!this.assets.sprite(object.sprite)) return false;
if (object.hour1 === 0xff)
return object.hour2 === 0xff || Boolean(object.hour2 & timeMask);
if (object.hour1 === object.hour2) return true;
return object.hour1 < object.hour2
? hour >= object.hour1 && hour < object.hour2
: hour >= object.hour1 || hour < object.hour2;
});
}
objectDirection(object: MapObject): Direction {
return (
this.objectDirections.get(object.id) ??
DIRECTION_ORDER[object.movement - 6] ??
"down"
);
}
private clearNotice() {
this.notice = "";
this.diagnostic = "";
}
private canStand(x: number, y: number): boolean {
if (x < 0 || y < 0 || x >= this.map.width || y >= this.map.height)
return false;
if (this.assets.permission(this.assets.collision(this.map, x, y)) !== 0)
return false;
return !this.objects().some((object) => object.x === x && object.y === y);
}
move(direction: Direction): boolean {
if (this.dialogue) return false;
this.clearNotice();
this.direction = direction;
const [dx, dy] = VECTORS[direction],
x = this.x + dx,
y = this.y + dy;
if (x < 0 || y < 0 || x >= this.map.width || y >= this.map.height) {
if (
this.map.connections.some(
(connection) => connection.direction === direction,
)
)
this.notice = "Routes beyond New Bark Town are not ported yet.";
return false;
}
const collision = this.assets.collision(this.map, x, y);
// Forced movement, surfing and ledges need their own translated rules.
// Refuse them rather than treating a land permission as ordinary walking.
if ([0x30, 0x40, 0x50, 0x60, 0xa0].includes(collision & 0xf0)) {
this.notice = "This movement type is not ported yet.";
return false;
}
if (!this.canStand(x, y)) return false;
// Directional edge walls: up/down/left/right encoded as hardware direction IDs.
const edges: Record<number, Direction[]> = {
0xb0: ["right"],
0xb1: ["left"],
0xb2: ["up"],
0xb3: ["down"],
0xb4: ["down", "right"],
0xb5: ["down", "left"],
0xb6: ["up", "right"],
0xb7: ["up", "left"],
};
const source = this.assets.collision(this.map, this.x, this.y);
const opposite: Record<Direction, Direction> = {
down: "up",
up: "down",
left: "right",
right: "left",
};
if (
edges[source]?.includes(direction) ||
edges[collision]?.includes(opposite[direction])
)
return false;
const warp = this.map.warps.find((warp) => warp.x === x && warp.y === y);
if (warp) {
if (
!Object.hasOwn(NATIVE_MAPS, warp.targetMap) ||
warp.targetWarp === 0 ||
warp.targetWarp === 255
) {
this.notice = "This destination is not ported yet.";
return false;
}
const target = this.assets.map(warp.targetMap),
arrival = target.warps[warp.targetWarp - 1];
if (!arrival) throw new Error("Warp destination is invalid.");
this.mapId = target.id;
this.x = arrival.x;
this.y = arrival.y;
this.objectDirections.clear();
this.direction = "down";
const arrivalCollision = this.assets.collision(target, this.x, this.y);
const exitY =
arrivalCollision === 0x70
? this.y - 1
: arrivalCollision === 0x71
? this.y + 1
: this.y;
if (exitY !== this.y && this.canStand(this.x, exitY)) {
this.direction = exitY < this.y ? "up" : "down";
this.y = exitY;
}
} else {
this.x = x;
this.y = y;
}
this.tilesWalked++;
return true;
}
interact() {
if (this.dialogue) {
this.advanceDialogue();
return;
}
this.clearNotice();
const [dx, dy] = VECTORS[this.direction];
let x = this.x + dx,
y = this.y + dy;
const collision = this.assets.collision(this.map, x, y);
if (collision === 0x90) {
x += dx;
y += dy;
}
const object = this.objects().find(
(object) => object.x === x && object.y === y,
);
let script: number | undefined;
if (object) {
if (object.kind !== 0) {
this.notice = "This interaction is not ported yet.";
return;
}
script = object.script;
} else {
const event = this.map.backgroundEvents.find(
(event) => event.x === x && event.y === y,
);
if (event) {
if (event.kind >= 5) {
this.notice = "This conditional interaction is not ported yet.";
return;
}
const requiredDirection = ["up", "down", "right", "left"][
event.kind - 1
];
if (event.kind && requiredDirection !== this.direction) return;
script = event.script;
} else {
const standard: Record<number, number> = {
0x91: 2,
0x97: 9,
0x9d: 8,
0x96: 6,
0x9f: 5,
};
if (standard[collision] !== undefined && this.direction === "up")
script = this.assets.reader.far(
SYMBOLS.standardScripts + standard[collision] * 3,
);
}
}
if (script === undefined) {
this.notice = "Nothing to interact with here.";
return;
}
try {
const result = runScript(
this.assets.reader,
script,
this.state,
this.name,
);
if (result.facePlayer && object) {
const opposite: Record<Direction, Direction> = {
down: "up",
up: "down",
left: "right",
right: "left",
};
this.objectDirections.set(object.id, opposite[this.direction]);
}
if (result.pages.length)
this.dialogue = { pages: result.pages, index: 0, result };
else this.commit(result);
} catch (error) {
this.notice =
"This interaction needs game logic that has not been ported yet. Your progress is unchanged.";
this.diagnostic = error instanceof Error ? error.message : String(error);
}
}
advanceDialogue() {
if (!this.dialogue) return;
if (++this.dialogue.index < this.dialogue.pages.length) return;
this.commit(this.dialogue.result);
this.dialogue = undefined;
}
private commit(result: ScriptResult) {
this.state = {
events: result.events,
flags: result.flags,
variables: result.variables,
};
for (const turn of result.turns) {
if (DIRECTION_ORDER[turn.direction])
this.objectDirections.set(turn.object, DIRECTION_ORDER[turn.direction]);
}
}
save(): NativeSave {
// Incomplete interactions are replayed after reload; no partial quest writes.
return {
format: "crystal-native",
version: 1,
map: this.mapId,
x: this.x,
y: this.y,
direction: this.direction,
name: this.name,
avatar: this.avatar,
tilesWalked: this.tilesWalked,
events: [...this.state.events].sort((a, b) => a - b),
flags: [...this.state.flags].sort((a, b) => a - b),
variables: { ...this.state.variables },
};
}
restore(value: unknown) {
const save = value as NativeSave;
const integer = (
value: unknown,
min: number,
max: number,
): value is number =>
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
const ids = (value: unknown): value is number[] =>
Array.isArray(value) &&
value.length <= 2048 &&
value.every((value) => integer(value, 0, 0xffff)) &&
new Set(value).size === value.length;
if (
!save ||
save.format !== "crystal-native" ||
save.version !== 1 ||
!Object.hasOwn(NATIVE_MAPS, save.map) ||
!integer(save.x, 0, 127) ||
!integer(save.y, 0, 127) ||
!DIRECTION_ORDER.includes(save.direction) ||
typeof save.name !== "string" ||
!/^[A-Za-z0-9 -]{1,7}$/.test(save.name) ||
!["chris", "kris"].includes(save.avatar) ||
!integer(save.tilesWalked, 0, Number.MAX_SAFE_INTEGER) ||
!ids(save.events) ||
!ids(save.flags) ||
!save.variables ||
typeof save.variables !== "object" ||
Array.isArray(save.variables) ||
Object.entries(save.variables).some(
([id, value]) =>
!/^\d+$/.test(id) ||
!integer(Number(id), 0, 255) ||
!integer(value, 0, 255),
)
) {
throw new Error(
"Invalid native save. Emulator snapshots cannot be imported into this port.",
);
}
const map = this.assets.map(save.map);
if (
save.x >= map.width ||
save.y >= map.height ||
this.assets.permission(this.assets.collision(map, save.x, save.y)) !== 0
)
throw new Error("The saved position is outside the walkable map.");
// Validate completely before changing live state.
this.mapId = save.map;
this.x = save.x;
this.y = save.y;
this.direction = save.direction;
this.name = save.name;
this.avatar = save.avatar;
this.tilesWalked = save.tilesWalked;
this.state = {
events: new Set(save.events),
flags: new Set(save.flags),
variables: { ...save.variables },
};
this.dialogue = undefined;
this.objectDirections.clear();
this.clearNotice();
}
}
+165
View File
@@ -0,0 +1,165 @@
import { type Button } from "./types";
import { InputController } from "./controller";
const KEYS: Record<string, Button> = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
KeyX: "a",
KeyZ: "b",
Enter: "start",
ShiftLeft: "select",
ShiftRight: "select",
};
export function bindControls(
root: HTMLElement,
input: InputController,
active: () => boolean,
unlock: () => void,
) {
const abort = new AbortController();
const options = { signal: abort.signal };
const buttons = [
...root.querySelectorAll<HTMLButtonElement>("[data-game-button]"),
];
const refresh = () =>
buttons.forEach((el) => {
const pressed = input.isPressed(el.dataset.gameButton as Button);
el.classList.toggle("pressed", pressed);
el.setAttribute("aria-pressed", String(pressed));
});
const release = () => {
input.releaseAll();
refresh();
};
for (const el of buttons) {
const focusedSource = `focused:${el.dataset.gameButton}`;
el.addEventListener(
"keydown",
(event) => {
if (!active() || !["Space", "Enter"].includes(event.code)) return;
event.preventDefault();
unlock();
input.press(focusedSource, el.dataset.gameButton as Button);
refresh();
},
options,
);
el.addEventListener(
"keyup",
(event) => {
if (!["Space", "Enter"].includes(event.code)) return;
event.preventDefault();
input.release(focusedSource);
refresh();
},
options,
);
el.addEventListener(
"blur",
() => {
input.release(focusedSource);
refresh();
},
options,
);
el.addEventListener(
"pointerdown",
(event) => {
if (!active() || (event.pointerType === "mouse" && event.button !== 0))
return;
event.preventDefault();
unlock();
el.setPointerCapture(event.pointerId);
input.press(
`pointer:${event.pointerId}`,
el.dataset.gameButton as Button,
);
refresh();
},
options,
);
// Slide between D-pad directions without lifting the finger.
el.addEventListener(
"pointermove",
(event) => {
if (!el.hasPointerCapture(event.pointerId) || !el.closest(".dpad"))
return;
const target = document
.elementFromPoint(event.clientX, event.clientY)
?.closest<HTMLElement>("[data-game-button]");
if (target?.closest(".dpad"))
input.press(
`pointer:${event.pointerId}`,
target.dataset.gameButton as Button,
);
else input.release(`pointer:${event.pointerId}`);
refresh();
},
options,
);
for (const name of [
"pointerup",
"pointercancel",
"lostpointercapture",
] as const) {
el.addEventListener(
name,
(event) => {
input.release(`pointer:${event.pointerId}`);
refresh();
},
options,
);
}
el.addEventListener(
"contextmenu",
(event) => event.preventDefault(),
options,
);
}
window.addEventListener(
"keydown",
(event) => {
if (!active() || event.ctrlKey || event.metaKey || event.altKey) return;
if (
(event.target as HTMLElement)?.closest(
"input, textarea, select, button, a, [contenteditable]",
)
)
return;
const button = KEYS[event.code];
if (!button) return;
event.preventDefault();
unlock();
input.press(`key:${event.code}`, button);
refresh();
},
options,
);
window.addEventListener(
"keyup",
(event) => {
input.release(`key:${event.code}`);
refresh();
},
options,
);
window.addEventListener("blur", release, options);
document.addEventListener(
"visibilitychange",
() => {
if (document.hidden) release();
},
options,
);
return {
release,
dispose: () => {
release();
abort.abort();
},
};
}
+30
View File
@@ -0,0 +1,30 @@
import { BUTTONS, type Button } from "./types";
/** Multiple fingers/keys can hold a button independently. */
export class InputController {
private sources = new Map<string, Button>();
constructor(private send: (button: Button, pressed: boolean) => void) {}
press(source: string, button: Button) {
if (this.sources.get(source) === button) return;
this.release(source);
const alreadyPressed = this.isPressed(button);
this.sources.set(source, button);
if (!alreadyPressed) this.send(button, true);
}
release(source: string) {
const button = this.sources.get(source);
this.sources.delete(source);
if (button && !this.isPressed(button)) this.send(button, false);
}
isPressed(button: Button) {
return [...this.sources.values()].includes(button);
}
releaseAll() {
this.sources.clear();
for (const button of BUTTONS) this.send(button, false);
}
}
+11
View File
@@ -0,0 +1,11 @@
export const BUTTONS = [
"up",
"down",
"left",
"right",
"a",
"b",
"start",
"select",
] as const;
export type Button = (typeof BUTTONS)[number];
+278
View File
@@ -0,0 +1,278 @@
import "./ui/styles.css";
import { renderShell } from "./ui/shell";
import { NativeRuntime } from "./game/runtime";
import type { GameWorld } from "./game/world/world";
import { GameSession } from "./app/session";
import { InputController } from "./input/controller";
import { bindControls } from "./input/bindings";
import { validateRom } from "./rom/validate";
import { BrowserSaveRepository, encodeSave } from "./storage/saves";
import { acquireSessionLock } from "./platform/session-lock";
const root = document.querySelector<HTMLElement>("#app")!;
renderShell(root);
const get = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T;
const canvas = get<HTMLCanvasElement>("game-screen");
const status = get("status");
const saveStatus = get("save-status");
let busy = false;
let lockRelease: (() => void) | undefined;
let saving: Promise<void> | undefined;
function showWorld(world: GameWorld) {
get<HTMLInputElement>("player-name").value = world.name;
get<HTMLSelectElement>("player-avatar").value = world.avatar;
get("location").textContent = world.map.name;
get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`;
get("game-notice").textContent = world.notice;
get("native-dialogue").hidden = !world.dialogue;
get("dialogue-text").textContent =
world.dialogue?.pages[world.dialogue.index] ?? "";
get("dialogue-page").textContent = world.dialogue
? `${world.dialogue.index + 1} / ${world.dialogue.pages.length}`
: "";
// Coordinates provide a stable accessible description and a test observation
// point, without exposing mutable game state or a production debug API.
canvas.dataset.map = world.mapId;
canvas.dataset.x = String(world.x);
canvas.dataset.y = String(world.y);
canvas.setAttribute(
"aria-label",
`${world.map.name}. Position ${world.x}, ${world.y}. Arrow keys move. X interacts. Z continues dialogue.`,
);
}
const runtime = new NativeRuntime(
showWorld,
(error) => {
session.state = "error";
controls.release();
showError(error);
update();
},
() => ({
name:
get<HTMLInputElement>("player-name")
.value.trim()
.replace(/[^A-Za-z0-9 -]/g, "")
.slice(0, 7) || "CHRIS",
avatar:
get<HTMLSelectElement>("player-avatar").value === "kris"
? "kris"
: "chris",
}),
);
const session = new GameSession(
runtime,
new BrowserSaveRepository("crystal-native-port"),
);
const input = new InputController((button, pressed) =>
runtime.setButton(button, pressed),
);
const controls = bindControls(
root,
input,
() => session.state === "playing" && !busy,
() => {},
);
const ready = () => ["paused", "playing"].includes(session.state);
function showError(error: unknown) {
status.textContent =
error instanceof Error
? error.message
: "Something went wrong. Please try again.";
status.classList.add("error");
}
function message(text: string) {
status.textContent = text;
status.classList.remove("error");
}
function update() {
const playing = session.state === "playing";
root.classList.toggle("has-game", ready());
for (const id of ["play", "save", "backup", "import-save", "screen-resume"]) {
get<HTMLButtonElement>(id).disabled = busy || !ready();
}
root
.querySelectorAll<HTMLButtonElement>("[data-game-button]")
.forEach((button) => {
button.disabled = !playing || busy;
});
get<HTMLButtonElement>("play").textContent = playing ? "Pause" : "Play";
get("power-label").textContent = playing
? "PLAYING"
: ready()
? "PAUSED"
: "STANDBY";
get("power-light").classList.toggle("on", playing);
get("screen-empty").hidden = ready();
get("pause-overlay").hidden = session.state !== "paused";
get<HTMLInputElement>("rom-file").disabled = busy;
get("rom-label").classList.toggle("disabled", busy);
get<HTMLInputElement>("player-name").disabled = busy || ready();
get<HTMLSelectElement>("player-avatar").disabled = busy || ready();
get<HTMLButtonElement>("dialogue-next").disabled =
busy || session.state !== "playing";
}
async function save() {
if (!ready()) return;
// Capture again after an earlier write, so pausing never drops the last frame.
if (saving) {
await saving.catch(() => {});
return save();
}
saving = session
.save()
.then((time) => {
saveStatus.textContent = `Session saved on this device · ${new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
saveStatus.classList.remove("error");
})
.catch((error) => {
saveStatus.textContent =
"Session could not be saved. Export a backup to keep your progress.";
saveStatus.classList.add("error");
throw error;
})
.finally(() => {
saving = undefined;
});
return saving;
}
function pause() {
if (session.state !== "playing") return;
session.pause();
controls.release();
update();
void save().catch(showError);
}
function play() {
if (busy) return;
session.play();
canvas.focus({ preventScroll: true });
message(
"Native exploration is running. Story progression and battles are not ported yet.",
);
update();
}
get<HTMLInputElement>("rom-file").addEventListener("change", async (event) => {
const field = event.target as HTMLInputElement;
const file = field.files?.[0];
field.value = "";
if (!file || busy) return;
busy = true;
controls.release();
update();
message("Checking your cartridge locally…");
try {
const rom = await validateRom(file);
if (!lockRelease)
lockRelease = await acquireSessionLock("crystal-native-session");
if (ready()) {
session.pause();
controls.release();
await saving;
await save();
}
const restored = await session.load(rom, canvas);
message(
restored
? "Your saved session is ready. Press Play to continue."
: "Cartridge verified. Press Play to explore your room and New Bark Town.",
);
} catch (error) {
showError(error);
} finally {
busy = false;
update();
}
});
get("play").addEventListener("click", () =>
session.state === "playing" ? pause() : play(),
);
get("screen-resume").addEventListener("click", play);
get("dialogue-next").addEventListener("click", () => {
if (session.state !== "playing") return;
runtime.world?.advanceDialogue();
if (runtime.world) showWorld(runtime.world);
canvas.focus({ preventScroll: true });
});
get("save").addEventListener("click", () => {
void save().catch(showError);
});
function download(contents: BlobPart, filename: string, type: string) {
const url = URL.createObjectURL(new Blob([contents], { type }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
get("backup").addEventListener("click", () => {
try {
download(
encodeSave(session.snapshot()),
`crystal-${new Date().toISOString().replace(/[:.]/g, "-")}.crystal-save.json`,
"application/json",
);
message("Session backup exported. Keep it somewhere safe.");
} catch (error) {
showError(error);
}
});
get("import-save").addEventListener("click", () => {
pause();
get<HTMLInputElement>("save-file").click();
});
get<HTMLInputElement>("save-file").addEventListener("change", async (event) => {
const field = event.target as HTMLInputElement;
const file = field.files?.[0];
field.value = "";
if (!file || busy) return;
if (file.size > 2_000_000) {
showError(new Error("This backup is too large."));
return;
}
if (
!window.confirm(
"Replace your current session with this backup? Export your current session first if you want to keep it.",
)
)
return;
busy = true;
update();
try {
await saving;
await session.importSave(await file.text());
message("Backup restored. Press Play to continue.");
saveStatus.textContent = "Imported session saved on this device.";
} catch (error) {
showError(error);
} finally {
busy = false;
update();
}
});
get("help").addEventListener("click", () => {
pause();
get<HTMLDialogElement>("help-dialog").showModal();
});
get("close-help").addEventListener("click", () =>
get<HTMLDialogElement>("help-dialog").close(),
);
window.addEventListener("blur", pause);
document.addEventListener("visibilitychange", () => {
if (document.hidden) pause();
});
window.addEventListener("pagehide", () => {
pause();
});
setInterval(() => {
if (ready() && !busy) void save().catch(() => {});
}, 10_000);
update();
+24
View File
@@ -0,0 +1,24 @@
/** Only one tab may write this player's save slot. Fail closed if unavailable. */
export async function acquireSessionLock(
name = "crystal-player-session",
): Promise<() => void> {
if (!navigator.locks)
throw new Error(
"This browser needs Web Locks support to protect your saves. Use a recent browser over HTTPS or localhost.",
);
return new Promise((resolve, reject) => {
void navigator.locks
.request(name, { ifAvailable: true }, (lock) => {
if (!lock) {
reject(
new Error(
"Crystal is already open in another tab. Close that tab and try again.",
),
);
return;
}
return new Promise<void>((release) => resolve(release));
})
.catch(reject);
});
}
+251
View File
@@ -0,0 +1,251 @@
import "../ui/styles.css";
import { renderShell } from "./shell";
import { BinjgbEmulator } from "../emulator/binjgb";
import { GameSession } from "../app/session";
import { InputController } from "../input/controller";
import { bindControls } from "../input/bindings";
import { validateRom } from "../rom/validate";
import { BrowserSaveRepository, encodeSave } from "../storage/saves";
import { acquireSessionLock } from "../platform/session-lock";
const root = document.querySelector<HTMLElement>("#app")!;
renderShell(root);
const get = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T;
const canvas = get<HTMLCanvasElement>("game-screen");
const status = get("status");
const saveStatus = get("save-status");
let busy = false;
let muted = true;
let lockRelease: (() => void) | undefined;
let saving: Promise<void> | undefined;
const emulator = new BinjgbEmulator((error) => {
session.state = "error";
controls.release();
showError(error);
update();
});
const session = new GameSession(emulator, new BrowserSaveRepository());
const input = new InputController((button, pressed) =>
emulator.setButton(button, pressed),
);
const controls = bindControls(
root,
input,
() => session.state === "playing" && !busy,
() => emulator.unlockAudio(),
);
const ready = () => ["paused", "playing"].includes(session.state);
function showError(error: unknown) {
status.textContent =
error instanceof Error
? error.message
: "Something went wrong. Please try again.";
status.classList.add("error");
}
function message(text: string) {
status.textContent = text;
status.classList.remove("error");
}
function update() {
const playing = session.state === "playing";
root.classList.toggle("has-game", ready());
for (const id of [
"play",
"sound",
"save",
"backup",
"import-save",
"export-battery",
"screen-resume",
]) {
get<HTMLButtonElement>(id).disabled = busy || !ready();
}
root
.querySelectorAll<HTMLButtonElement>("[data-game-button]")
.forEach((button) => {
button.disabled = !playing || busy;
});
get<HTMLButtonElement>("play").textContent = playing ? "Pause" : "Play";
get("power-label").textContent = playing
? "PLAYING"
: ready()
? "PAUSED"
: "STANDBY";
get("power-light").classList.toggle("on", playing);
get("screen-empty").hidden = ready();
get("pause-overlay").hidden = session.state !== "paused";
get<HTMLInputElement>("rom-file").disabled = busy;
get("rom-label").classList.toggle("disabled", busy);
}
async function save() {
if (!ready()) return;
// Capture again after an earlier write, so pausing never drops the last frame.
if (saving) {
await saving.catch(() => {});
return save();
}
saving = session
.save()
.then((time) => {
saveStatus.textContent = `Session saved on this device · ${new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
saveStatus.classList.remove("error");
})
.catch((error) => {
saveStatus.textContent =
"Session could not be saved. Export a backup to keep your progress.";
saveStatus.classList.add("error");
throw error;
})
.finally(() => {
saving = undefined;
});
return saving;
}
function pause() {
if (session.state !== "playing") return;
session.pause();
controls.release();
update();
void save().catch(showError);
}
function play() {
if (busy) return;
session.play();
canvas.focus({ preventScroll: true });
message("Your adventure is running.");
update();
}
get<HTMLInputElement>("rom-file").addEventListener("change", async (event) => {
const field = event.target as HTMLInputElement;
const file = field.files?.[0];
field.value = "";
if (!file || busy) return;
busy = true;
controls.release();
update();
message("Checking your cartridge locally…");
try {
const rom = await validateRom(file);
if (!lockRelease) lockRelease = await acquireSessionLock();
if (ready()) {
session.pause();
controls.release();
await saving;
await save();
}
const restored = await session.load(rom, canvas);
message(
restored
? "Your saved session is ready. Press Play to continue."
: "Cartridge verified. Press Play to begin.",
);
} catch (error) {
showError(error);
} finally {
busy = false;
update();
}
});
get("play").addEventListener("click", () =>
session.state === "playing" ? pause() : play(),
);
get("screen-resume").addEventListener("click", play);
get("sound").addEventListener("click", () => {
muted = !muted;
emulator.setMuted(muted);
get("sound").textContent = muted ? "Sound off" : "Sound on";
get("sound").setAttribute("aria-pressed", String(!muted));
});
get("save").addEventListener("click", () => {
void save().catch(showError);
});
function download(contents: BlobPart, filename: string, type: string) {
const url = URL.createObjectURL(new Blob([contents], { type }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
get("backup").addEventListener("click", () => {
try {
download(
encodeSave(session.snapshot()),
`crystal-${new Date().toISOString().replace(/[:.]/g, "-")}.crystal-save.json`,
"application/json",
);
message("Session backup exported. Keep it somewhere safe.");
} catch (error) {
showError(error);
}
});
get("export-battery").addEventListener("click", () => {
try {
download(
new Uint8Array(emulator.exportBattery()).buffer,
"crystal.sav",
"application/octet-stream",
);
} catch (error) {
showError(error);
}
});
get("import-save").addEventListener("click", () => {
pause();
get<HTMLInputElement>("save-file").click();
});
get<HTMLInputElement>("save-file").addEventListener("change", async (event) => {
const field = event.target as HTMLInputElement;
const file = field.files?.[0];
field.value = "";
if (!file || busy) return;
if (file.size > 2_000_000) {
showError(new Error("This backup is too large."));
return;
}
if (
!window.confirm(
"Replace your current session with this backup? Export your current session first if you want to keep it.",
)
)
return;
busy = true;
update();
try {
await saving;
await session.importSave(await file.text());
message("Backup restored. Press Play to continue.");
saveStatus.textContent = "Imported session saved on this device.";
} catch (error) {
showError(error);
} finally {
busy = false;
update();
}
});
get("help").addEventListener("click", () => {
pause();
get<HTMLDialogElement>("help-dialog").showModal();
});
get("close-help").addEventListener("click", () =>
get<HTMLDialogElement>("help-dialog").close(),
);
window.addEventListener("blur", pause);
document.addEventListener("visibilitychange", () => {
if (document.hidden) pause();
});
window.addEventListener("pagehide", () => {
pause();
});
setInterval(() => {
if (ready() && !busy) void save().catch(() => {});
}, 10_000);
update();
+51
View File
@@ -0,0 +1,51 @@
const key = (name: string, label: string, className = "") =>
`<button class="game-key ${className}" data-game-button="${name}" aria-label="${name === "a" || name === "b" ? name.toUpperCase() : name}" aria-pressed="false" disabled>${label}</button>`;
export function renderShell(root: HTMLElement) {
root.innerHTML = `
<header class="site-header">
<a class="brand" href="./" aria-label="Crystal home"><img src="${import.meta.env.BASE_URL}icon.svg" alt="" width="36" height="36">crystal<span>REFERENCE EMULATOR</span></a>
<button id="help" class="text-button">How to play <span aria-hidden="true">↗</span></button>
</header>
<main>
<section class="intro" aria-labelledby="page-title">
<p class="eyebrow"><span class="small-star">✧</span> YOUR CARTRIDGE. A NEW HOME.</p>
<h1 id="page-title">Original game.<br><em>Reference only.</em></h1>
<p class="intro-copy">Development reference for the native port.<br> Bring your own ROM. Pick up where you left off.</p>
<div class="load-card">
<div class="card-heading"><span class="step-number">01</span><h2>Bring your cartridge</h2></div>
<p>Pokémon Crystal<br><strong>English US/Europe · Revision 1.1</strong></p>
<label class="primary-button file-button" id="rom-label" for="rom-file"><span aria-hidden="true">+</span> Choose ROM <span aria-hidden="true">→</span></label>
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream" />
<p class="privacy-note"><span aria-hidden="true">◇</span> Opened locally. Never uploaded.</p>
</div>
<div class="project-note"><span class="note-line"></span><p>A small beginning, built for more.<br><span>Touch menus, online linking, and walking features are planned.</span></p></div>
</section>
<section class="play-area" aria-label="Game player">
<div class="console">
<div class="console-top"><span>CRYSTAL / 01</span><span class="power"><i id="power-light"></i><span id="power-label">STANDBY</span></span></div>
<div class="screen-bezel">
<div class="screen">
<canvas id="game-screen" width="160" height="144" tabindex="0" aria-label="Game screen. Arrow keys move, X is A, Z is B, Enter is Start, Shift is Select."></canvas>
<div id="screen-empty" class="screen-empty"><div class="crystal-mark" aria-hidden="true">◇</div><h2>Your next chapter<br>starts here.</h2><p>Load your Crystal ROM to begin.</p><span class="screen-dots" aria-hidden="true">· · ·</span></div>
<div id="pause-overlay" class="pause-overlay" hidden><span>PAUSED</span><button id="screen-resume">Continue playing →</button></div>
</div>
<div class="screen-caption"><span>GAME BOY COLOR</span><span>160 × 144</span></div>
</div>
<div class="controls">
<div class="dpad" aria-label="Directional controls">${key("up", "▲")}${key("left", "◀")}<span class="dpad-center" aria-hidden="true"></span>${key("right", "▶")}${key("down", "▼")}</div>
<div class="action-keys">${key("b", "B")}${key("a", "A")}</div>
<div class="system-keys">${key("select", "SELECT")}${key("start", "START")}</div>
<div class="speaker" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
</div>
<div class="console-bottom"><span>MADE FOR THE WAY YOU PLAY</span><span>✧</span></div>
</div>
<div class="player-toolbar" aria-label="Player actions"><button id="play" disabled>Play</button><button id="sound" aria-pressed="false" disabled>Sound off</button><button id="save" disabled>Save session</button><button id="backup" disabled>Export backup</button></div>
<p id="status" class="status" role="status" aria-live="polite">Ready when you are. Choose your ROM to get started.</p>
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
<details class="save-tools"><summary>Save files & compatibility</summary><div><button id="import-save" disabled>Import session backup</button><button id="export-battery" disabled>Export in-game .sav</button><input id="save-file" class="file-input" type="file" accept=".json" /><p>Use the game’s SAVE menu before exporting a .sav. Session backups also preserve your current screen and clock state.</p><p>The game clock currently advances only while playing. Time while paused or closed is not added. Export a backup before clearing browser data.</p></div></details>
</section>
</main>
<footer><span>A personal cartridge, a pocket-sized world.</span><span>Independent project · No game assets included</span></footer>
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="eyebrow">GETTING STARTED</p><h2>Make yourself at home.</h2><p>Choose your own uncompressed English US/Europe revision 1.1 ROM, then press Play. ROMs stay in memory and must be selected again after a reload.</p><p>Use the onscreen controls, or focus the game screen and use:</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>A / B</dt><dd>X / Z</dd><dt>Start / Select</dt><dd>Enter / Shift</dd></dl><p>Sessions save every 10 seconds and when paused. Keep a downloaded backup: browser storage can be cleared. Switching apps pauses the game; tap Play to resume.</p><p>The original game menus remain intact for this first version. Internet Link and step counting are not available yet.</p></dialog>`;
}
+35
View File
@@ -0,0 +1,35 @@
export const CRYSTAL_ROM = {
name: "Pokémon Crystal · English US/Europe 1.1",
sha1: "f2f52230b536214ef7c9924f483392993e226cfb",
size: 2 * 1024 * 1024,
} as const;
export interface ValidatedRom {
bytes: Uint8Array;
sha1: string;
name: string;
}
export async function validateRom(file: File): Promise<ValidatedRom> {
if (file.size !== CRYSTAL_ROM.size) {
throw new Error(
"Choose an uncompressed Pokémon Crystal English US/Europe 1.1 ROM (.gbc, 2 MiB).",
);
}
if (!globalThis.crypto?.subtle) {
throw new Error(
"ROM verification needs HTTPS or localhost. Open this player over a secure connection.",
);
}
const buffer = await file.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-1", buffer);
const sha1 = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
if (sha1 !== CRYSTAL_ROM.sha1) {
throw new Error(
"This ROM is not the supported English US/Europe revision 1.1. Other languages, revisions, and modified ROMs are not supported yet.",
);
}
return { bytes: new Uint8Array(buffer), sha1, name: CRYSTAL_ROM.name };
}
+90
View File
@@ -0,0 +1,90 @@
export interface SaveRecord {
version: 1;
romHash: string;
/** Historical envelope field. Native saves put their runtime/schema ID here. */
coreId: string;
savedAt: string;
state: Uint8Array;
}
export interface SaveRepository {
read(romHash: string): Promise<SaveRecord | undefined>;
write(record: SaveRecord): Promise<void>;
}
export class BrowserSaveRepository implements SaveRepository {
private database?: Promise<IDBDatabase>;
constructor(private databaseName = "crystal-player") {}
private open() {
return (this.database ??= new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(this.databaseName, 1);
request.onupgradeneeded = () =>
request.result.createObjectStore("saves", { keyPath: "romHash" });
request.onsuccess = () => resolve(request.result);
request.onerror = () => {
this.database = undefined;
reject(request.error);
};
request.onblocked = () =>
reject(
new Error("Close other Crystal tabs, then reload to enable saves."),
);
}));
}
async read(romHash: string) {
const db = await this.open();
return new Promise<SaveRecord | undefined>((resolve, reject) => {
const request = db.transaction("saves").objectStore("saves").get(romHash);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async write(record: SaveRecord) {
const db = await this.open();
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction("saves", "readwrite");
transaction.objectStore("saves").put(record);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () =>
reject(transaction.error ?? new Error("Save was interrupted."));
});
}
}
export function encodeSave(record: SaveRecord): string {
let binary = "";
for (const byte of record.state) binary += String.fromCharCode(byte);
return JSON.stringify({ ...record, state: btoa(binary) });
}
export function decodeSave(
text: string,
romHash: string,
coreId: string,
): SaveRecord {
if (text.length > 2_000_000) throw new Error("The save file is too large.");
const value = JSON.parse(text);
if (
!value ||
value.version !== 1 ||
value.romHash !== romHash ||
value.coreId !== coreId ||
typeof value.savedAt !== "string" ||
!Number.isFinite(Date.parse(value.savedAt)) ||
typeof value.state !== "string"
) {
throw new Error(
"This backup is not compatible with this ROM and player version.",
);
}
if (
!/^[A-Za-z0-9+/]+={0,2}$/.test(value.state) ||
value.state.length % 4 !== 0
)
throw new Error("The backup is damaged.");
const state = Uint8Array.from(atob(value.state), (char) =>
char.charCodeAt(0),
);
return { version: 1, romHash, coreId, savedAt: value.savedAt, state };
}
+55
View File
@@ -0,0 +1,55 @@
const key = (name: string, label: string, className = "") =>
`<button class="game-key ${className}" data-game-button="${name}" aria-label="${name === "a" || name === "b" ? name.toUpperCase() : name}" aria-pressed="false" disabled>${label}</button>`;
export function renderShell(root: HTMLElement) {
root.innerHTML = `
<header class="site-header">
<a class="brand" href="./" aria-label="Crystal home"><img src="${import.meta.env.BASE_URL}icon.svg" alt="" width="36" height="36">crystal<span>NATIVE WEB PORT</span></a>
<button id="help" class="text-button">How to play <span aria-hidden="true">↗</span></button>
</header>
<main>
<section class="intro" aria-labelledby="page-title">
<p class="eyebrow"><span class="small-star">✧</span> YOUR CARTRIDGE. A NEW HOME.</p>
<h1 id="page-title">A little adventure.<br><em>Anywhere.</em></h1>
<p class="intro-copy">Crystal, rebuilt for the web.<br> Start exploring New Bark Town in the first native milestone.</p>
<div class="load-card">
<div class="card-heading"><span class="step-number">01</span><h2>Bring your cartridge</h2></div>
<p>Pokémon Crystal<br><strong>English US/Europe · Revision 1.1</strong></p>
<div class="player-setup"><label>Name <input id="player-name" value="CHRIS" maxlength="7" pattern="[A-Za-z0-9 -]{1,7}" autocomplete="off"></label><label>Character <select id="player-avatar"><option value="chris">Chris</option><option value="kris">Kris</option></select></label><small>Used for your first session. Saved sessions keep their character.</small></div>
<label class="primary-button file-button" id="rom-label" for="rom-file"><span aria-hidden="true">+</span> Choose ROM <span aria-hidden="true">→</span></label>
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream" />
<p class="privacy-note"><span aria-hidden="true">◇</span> Opened locally. Never uploaded.</p>
</div>
<div class="project-note"><span class="note-line"></span><p>An exploration build, not the full game yet.<br><span>Move, enter buildings, read signs and try interactions. Story progression, battles and audio are next.</span></p></div>
</section>
<section class="play-area" aria-label="Game player">
<div class="console">
<div class="console-top"><span>NATIVE / EXPLORATION</span><span class="power"><i id="power-light"></i><span id="power-label">STANDBY</span></span></div>
<div class="screen-bezel">
<div class="screen">
<canvas id="game-screen" width="160" height="144" tabindex="0" aria-label="Game screen. Arrow keys move, X is A, Z is B, Enter is Start, Shift is Select."></canvas>
<div id="native-dialogue" class="native-dialogue" aria-label="Dialogue" hidden><p id="dialogue-text" aria-live="polite"></p><div><span id="dialogue-page"></span><button id="dialogue-next">Continue <span aria-hidden="true">▸</span></button></div></div>
<div id="screen-empty" class="screen-empty"><div class="crystal-mark" aria-hidden="true">◇</div><h2>Your next chapter<br>starts here.</h2><p>Load your Crystal ROM to begin.</p><span class="screen-dots" aria-hidden="true">· · ·</span></div>
<div id="pause-overlay" class="pause-overlay" hidden><span>PAUSED</span><button id="screen-resume">Continue playing →</button></div>
</div>
<div class="screen-caption"><span id="location">YOUR WORLD, REBUILT</span><span>160 × 144</span></div>
</div>
<div class="controls">
<div class="dpad" aria-label="Directional controls">${key("up", "▲")}${key("left", "◀")}<span class="dpad-center" aria-hidden="true"></span>${key("right", "▶")}${key("down", "▼")}</div>
<div class="action-keys">${key("b", "B")}${key("a", "A")}</div>
<div class="system-keys">${key("select", "SELECT")}${key("start", "START")}</div>
<div class="speaker" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
</div>
<div class="console-bottom"><span>MADE FOR THE WAY YOU PLAY</span><span>✧</span></div>
</div>
<div class="player-toolbar" aria-label="Player actions"><button id="play" disabled>Play</button><button id="save" disabled>Save session</button><button id="backup" disabled>Export backup</button></div>
<p class="world-status"><span id="tiles-walked">0 tiles walked</span><span>Exploration build · Audio pending</span></p>
<p id="game-notice" role="status" class="game-notice"></p>
<p id="status" class="status" role="status" aria-live="polite">Ready when you are. Choose your ROM to get started.</p>
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
<details class="save-tools"><summary>Save files & compatibility</summary><div><button id="import-save" disabled>Import native backup</button><input id="save-file" class="file-input" type="file" accept=".json" /><p>Native saves keep your character, position and completed interactions. They are separate from emulator snapshots and cartridge .sav files, which cannot be imported yet.</p><p>Time-of-day colors follow your device clock. The tile counter measures in-game movement, not real-world steps. Export a backup before clearing browser data.</p></div></details>
</section>
</main>
<footer><span>A personal cartridge, a pocket-sized world.</span><span>Independent project · No game assets included</span></footer>
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="eyebrow">NATIVE PORT · FIRST MILESTONE</p><h2>Make yourself at home.</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM. Select it again after a reload to resume.</p><p>Explore your bedroom, downstairs, New Bark Town and its buildings. Use A to read signs, the radio and supported conversations. An unfinished interaction shows a message and leaves progress unchanged.</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>Interact / Continue</dt><dd>X / Z or A / B</dd></dl><p>Sessions save every 10 seconds and when paused. Download a backup before clearing browser data. Switching apps pauses play.</p><p>This is an exploration milestone. Opening scenes, story progression, battles, NPC movement, room decorations, audio and routes beyond town are not ported yet. Online Link and real-world steps come after the original game systems.</p></dialog>`;
}
+931
View File
@@ -0,0 +1,931 @@
:root {
font-family:
Inter,
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
color: #183d36;
background: #f4f4ec;
font-synthesis: none;
font-weight: 400;
font-size: 15px;
line-height: 1.5;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
.player-setup {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin: 12px 0;
grid-column: 1 / -1;
}
.player-setup label {
font-size: 10px;
color: #647962;
}
.player-setup input,
.player-setup select {
display: block;
width: 100%;
min-width: 0;
min-height: 44px;
border: 1px solid #d5ddd1;
border-radius: 6px;
background: #fff;
padding: 8px;
color: #234e40;
font: inherit;
font-size: 12px;
}
.player-setup small {
grid-column: 1 / -1;
font-size: 9px;
color: #7a8b7b;
}
.world-status {
display: flex;
justify-content: space-between;
font-size: 9px;
color: #73816f;
gap: 8px;
}
.game-notice {
color: #765b38;
font-size: 11px;
text-align: center;
margin: 0;
}
.game-notice:empty {
display: none;
}
.native-dialogue {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
z-index: 1;
padding: 12px;
color: #17382b;
background: #fffdf0;
border: 2px solid #284c3c;
border-radius: 8px;
box-shadow: 0 3px 15px #102b2a55;
}
.native-dialogue p {
font-size: clamp(11px, 2.8vw, 15px);
line-height: 1.45;
white-space: pre-line;
margin: 0;
max-height: 125px;
overflow: auto;
}
.native-dialogue > div {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 9px;
}
.native-dialogue button {
background: transparent;
padding: 8px 0 0 12px;
font-size: 11px;
}
.pause-overlay {
z-index: 2;
}
body {
margin: 0;
padding: env(safe-area-inset-top) env(safe-area-inset-right)
env(safe-area-inset-bottom) env(safe-area-inset-left);
}
button,
input {
font: inherit;
}
button,
a,
label {
-webkit-tap-highlight-color: transparent;
}
button {
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.42;
}
button:focus-visible,
a:focus-visible,
summary:focus-visible,
canvas:focus-visible {
outline: 3px solid #ca713a;
outline-offset: 4px;
}
button {
color: inherit;
}
button:disabled {
pointer-events: none;
}
a {
color: inherit;
}
button,
.file-button {
min-height: 44px;
}
button {
border: 0;
}
button:hover:not(:disabled) {
filter: brightness(0.94);
}
[hidden] {
display: none !important;
}
.site-header {
max-width: 1280px;
margin: auto;
padding: 30px 54px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #dce1d6;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-size: 28px;
letter-spacing: -1.5px;
font-weight: 750;
text-decoration: none;
}
.brand span {
font-size: 9px;
letter-spacing: 1.7px;
font-weight: 600;
margin-left: 13px;
padding-left: 20px;
border-left: 1px solid #c8d0c6;
}
.text-button {
background: none;
font-size: 12px;
}
.text-button span {
margin-left: 16px;
}
main {
max-width: 1180px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 490px;
gap: 85px;
padding: 62px 40px 45px;
}
.intro {
padding-top: 24px;
}
.eyebrow {
font-size: 10px;
letter-spacing: 2px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
}
.small-star {
font-size: 23px;
color: #6d8d72;
}
h1 {
font-size: clamp(38px, 4.8vw, 62px);
line-height: 1.08;
letter-spacing: -3px;
font-weight: 500;
margin: 26px 0;
}
h1 em {
font-family: Georgia, serif;
font-weight: 400;
color: #759378;
}
.intro-copy {
font-size: 15px;
line-height: 1.85;
color: #6a7b6e;
margin-bottom: 30px;
}
.load-card {
background: #fbfcf7;
border: 1px solid #dce2d6;
border-radius: 15px;
padding: 24px;
max-width: 350px;
box-shadow: 0 5px 18px #20352a03;
}
.card-heading {
display: flex;
gap: 12px;
align-items: center;
}
.step-number {
font-size: 10px;
color: #738876;
border: 1px solid #d9e1d5;
border-radius: 50%;
height: 26px;
width: 26px;
display: grid;
place-items: center;
}
.load-card h2 {
font-size: 14px;
margin: 0;
font-weight: 600;
}
.load-card > p {
color: #73816f;
font-size: 12px;
margin: 18px 0;
}
.load-card strong {
font-weight: 500;
color: #2e5246;
}
.primary-button {
background: #234e40;
color: #fff;
border: 1px solid #234e40;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 15px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.file-input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.load-card:focus-within .file-button {
outline: 3px solid #ca713a;
outline-offset: 3px;
}
.disabled {
opacity: 0.5;
pointer-events: none;
}
.load-card .privacy-note {
font-size: 10px;
margin: 13px 0 0;
text-align: center;
color: #7a8b7b;
}
.privacy-note span {
margin-right: 4px;
}
.project-note {
display: flex;
gap: 12px;
margin-top: 30px;
align-items: center;
max-width: 350px;
}
.note-line {
width: 3px;
align-self: stretch;
background: #d4ddce;
margin: 12px 0;
}
.project-note p {
font-size: 11px;
color: #576d5b;
line-height: 1.8;
}
.project-note p span {
color: #8b9789;
}
.console {
background: #133532;
border-radius: 25px 25px 60px 25px;
padding: 22px 25px 18px;
box-shadow:
0 23px 45px #1335321c,
inset 0 1px 1px #ffffff28;
color: #a7c6b8;
border: 1px solid #244b42;
}
.console-top {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 9px;
letter-spacing: 2px;
padding: 0 3px 17px;
}
.power {
display: flex;
align-items: center;
gap: 6px;
font-size: 8px;
letter-spacing: 1px;
}
.power i {
height: 5px;
width: 5px;
border-radius: 50%;
background: #6f887a;
}
.power i.on {
background: #b2e6a5;
box-shadow: 0 0 7px #b2e6a5;
}
.screen-bezel {
padding: 15px 16px 9px;
background: #0a2423;
border-radius: 9px 9px 25px 9px;
border: 1px solid #315249;
}
.screen {
position: relative;
aspect-ratio: 160/144;
background: #dae6c7;
overflow: hidden;
border-radius: 3px;
}
canvas {
display: block;
width: 100%;
height: 100%;
image-rendering: pixelated;
}
.screen-empty {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: #526c49;
background: radial-gradient(ellipse at center, #e6ecd6, #ccdcbc);
}
.crystal-mark {
font-size: 84px;
line-height: 1.05;
font-family: Georgia, serif;
font-weight: 400;
color: #64885d;
text-shadow: 6px 0 #b6cbaa;
margin-top: -8px;
}
.screen-empty h2 {
font-family: Georgia, serif;
font-size: 25px;
line-height: 1.2;
font-weight: 400;
letter-spacing: -0.5px;
margin: 16px 0 10px;
}
.screen-empty p {
font-size: 10px;
margin: 0;
color: #718565;
}
.screen-dots {
margin-top: 12px;
font-size: 20px;
letter-spacing: 5px;
color: #8da17b;
}
.screen-caption {
display: flex;
justify-content: space-between;
padding: 9px 0 0;
letter-spacing: 2px;
font-size: 7px;
color: #6f8e7f;
}
.controls {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
padding: 22px 8px 4px;
gap: 15px;
}
.dpad {
display: grid;
grid-template-columns: repeat(3, 44px);
grid-template-rows: repeat(3, 44px);
filter: drop-shadow(0 5px 1px #061918);
}
.game-key {
min-width: 44px;
flex-shrink: 0;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.dpad .game-key,
.dpad-center {
background: #254d43;
box-shadow: inset 0 1px #527363;
color: #8fb09a;
min-height: 44px;
font-size: 14px;
}
.dpad .game-key {
border-radius: 4px;
}
.dpad [data-game-button="up"] {
grid-column: 2;
grid-row: 1;
}
.dpad [data-game-button="left"] {
grid-column: 1;
grid-row: 2;
}
.dpad-center {
grid-column: 2;
grid-row: 2;
box-shadow: none;
}
.dpad-center:after {
content: "";
display: block;
width: 14px;
height: 14px;
border: 1px solid #365e50;
border-radius: 50%;
margin: 15px;
}
.dpad [data-game-button="right"] {
grid-column: 3;
grid-row: 2;
}
.dpad [data-game-button="down"] {
grid-column: 2;
grid-row: 3;
}
.action-keys {
display: flex;
gap: 17px;
justify-content: flex-end;
transform: rotate(-20deg);
}
.action-keys button {
width: 59px;
height: 59px;
border-radius: 50%;
background: #d8e7c0;
color: #35573e;
font-size: 20px;
font-weight: 650;
border: 3px solid #b3caa0;
box-shadow: 0 5px #081f1c;
transform: rotate(20deg);
}
.game-key:disabled {
opacity: 0.7;
}
.game-key.pressed {
filter: brightness(1.3);
translate: 0 3px;
box-shadow: none;
}
.system-keys {
display: flex;
gap: 15px;
grid-column: 1/3;
justify-content: center;
margin-top: -4px;
}
.system-keys button {
background: none;
position: relative;
font-size: 8px;
letter-spacing: 1px;
padding-top: 22px;
color: #86a696;
}
.system-keys button:before {
content: "";
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%) rotate(-10deg);
width: 33px;
height: 9px;
background: #416356;
border: 1px solid #517463;
border-radius: 7px;
box-shadow: 0 3px #071f1c;
}
.speaker {
display: none;
}
.console-bottom {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 7px;
letter-spacing: 1.7px;
color: #54776a;
margin: 13px 5px 0;
}
.console-bottom > span:last-child {
font-size: 18px;
}
.player-toolbar {
display: flex;
justify-content: center;
gap: 5px;
padding-top: 20px;
flex-wrap: wrap;
}
.player-toolbar button,
.save-tools button {
background: transparent;
font-size: 11px;
padding: 8px 11px;
border: 1px solid #d5ddd1;
border-radius: 7px;
}
.status {
font-size: 11px;
text-align: center;
color: #647962;
min-height: 17px;
margin: 14px 0 0;
overflow-wrap: anywhere;
}
.save-status {
text-align: center;
font-size: 10px;
color: #8b9788;
margin: 6px 0;
}
.error {
color: #a03e29 !important;
}
.save-tools {
font-size: 11px;
color: #71816c;
text-align: center;
margin-top: 16px;
}
.save-tools summary {
cursor: pointer;
min-height: 44px;
padding: 12px;
}
.save-tools > div {
padding: 12px;
border: 1px solid #d5ddd1;
border-radius: 8px;
text-align: left;
}
.save-tools button {
margin: 0 5px 5px 0;
}
.save-tools p {
font-size: 11px;
line-height: 1.7;
}
.pause-overlay {
position: absolute;
inset: 0;
background: #0a2423b8;
display: flex;
flex-direction: column;
gap: 20px;
align-items: center;
justify-content: center;
color: #e7efdb;
backdrop-filter: blur(2px);
}
.pause-overlay span {
font-size: 10px;
letter-spacing: 4px;
}
.pause-overlay button {
background: #d8e7c0;
color: #21483b;
border-radius: 7px;
padding: 10px 18px;
font-size: 12px;
}
footer {
max-width: 1172px;
margin: auto;
border-top: 1px solid #dce1d6;
padding: 22px 0 28px;
display: flex;
justify-content: space-between;
gap: 15px;
color: #8a9886;
font-size: 10px;
}
dialog {
border: 1px solid #d2ddca;
background: #f4f4ec;
color: #244c3e;
border-radius: 18px;
padding: 32px;
max-width: 460px;
width: calc(100% - 32px);
}
dialog::backdrop {
background: #0a242399;
backdrop-filter: blur(3px);
}
dialog h2 {
font-family: Georgia, serif;
font-weight: 400;
font-size: 30px;
}
dialog p {
font-size: 13px;
line-height: 1.7;
}
dialog dl {
display: grid;
grid-template-columns: 1fr 1fr;
font-size: 13px;
gap: 10px;
}
dialog dd {
margin: 0;
}
.dialog-close {
float: right;
background: none;
font-size: 25px;
}
@media (min-width: 1300px) {
main {
gap: 100px;
padding-top: 75px;
padding-bottom: 60px;
}
.intro {
padding-top: 37px;
}
}
@media (max-width: 1000px) {
main {
gap: 35px;
grid-template-columns: minmax(0, 1fr) minmax(340px, 440px);
padding: 40px 30px;
}
.site-header {
padding: 22px 30px;
}
h1 {
font-size: 45px;
}
.console {
padding: 20px;
}
.screen-empty h2 {
font-size: 23px;
}
footer {
margin: 0 30px;
}
}
@media (max-width: 740px) {
.site-header {
padding: 17px 22px;
}
.brand {
font-size: 25px;
}
.brand img {
width: 30px;
height: 30px;
}
.brand span {
display: none;
}
main {
display: flex;
flex-direction: column;
gap: 25px;
padding: 20px 18px 26px;
max-width: 480px;
}
.intro {
padding: 0;
}
.eyebrow {
font-size: 8px;
letter-spacing: 1.6px;
margin: 10px 0;
}
h1 {
font-size: 36px;
letter-spacing: -1.6px;
margin: 15px 0 13px;
}
h1 br {
display: block;
}
h1 em {
white-space: nowrap;
}
.intro-copy {
font-size: 12px;
line-height: 1.7;
margin: 0 0 18px;
}
.intro-copy br {
display: none;
}
.intro-copy br:after {
content: " ";
}
#app.has-game .intro {
display: none;
}
#app.has-game main {
padding-top: 12px;
}
.load-card {
max-width: none;
padding: 15px 18px;
display: grid;
grid-template-columns: 1fr 140px;
column-gap: 10px;
align-items: center;
}
.card-heading h2 {
font-size: 12px;
}
.step-number {
display: none;
}
.load-card > p {
margin: 5px 0;
font-size: 10px;
grid-column: 1;
}
.load-card .primary-button {
grid-column: 2;
grid-row: 1/3;
font-size: 11px;
padding: 10px;
gap: 5px;
}
.load-card .privacy-note {
grid-column: 1/3;
margin: 10px 0 0;
font-size: 9px;
}
.project-note {
display: none;
}
.console {
border-radius: 22px 22px 50px 22px;
padding: 20px;
}
.console-top {
font-size: 8px;
}
.screen-bezel {
padding: 12px 12px 9px;
}
.controls {
padding: 22px 4px 0;
}
.action-keys button {
width: 55px;
height: 55px;
}
.action-keys {
gap: 14px;
}
.player-toolbar {
padding-top: 17px;
}
.player-toolbar button {
font-size: 10px;
padding: 8px 10px;
}
.status {
font-size: 10px;
}
footer {
padding: 18px 0 25px;
margin: 0 22px;
flex-direction: column;
font-size: 9px;
gap: 5px;
}
}
@media (max-width: 360px) {
.load-card {
grid-template-columns: 1fr 114px;
padding: 13px;
}
.load-card .primary-button {
font-size: 10px;
}
.console {
padding: 15px;
}
.screen-bezel {
padding: 9px;
}
.screen-empty h2 {
font-size: 21px;
}
.screen-empty .crystal-mark {
font-size: 62px;
}
.action-keys {
gap: 8px;
}
.action-keys button {
width: 48px;
height: 48px;
}
.dpad {
grid-template-columns: repeat(3, 44px);
grid-template-rows: repeat(3, 44px);
}
.controls {
padding-left: 0;
padding-right: 0;
gap: 8px;
}
.system-keys {
margin-top: 0;
}
}
@media (max-height: 540px) and (orientation: landscape) {
#app.has-game
:is(
.site-header,
.intro,
footer,
.console-top,
.console-bottom,
.screen-caption,
.save-tools,
.save-status
) {
display: none;
}
#app.has-game main {
display: block;
max-width: 850px;
padding: 12px 20px;
}
#app.has-game .console {
position: relative;
padding: 12px;
border-radius: 20px;
min-height: 270px;
}
#app.has-game .screen-bezel {
width: min(43%, 340px);
margin: auto;
padding: 8px;
}
#app.has-game .controls {
position: absolute;
inset: 0;
display: flex;
justify-content: space-between;
pointer-events: none;
padding: 25px;
}
#app.has-game .controls > * {
pointer-events: auto;
}
#app.has-game .system-keys {
position: absolute;
bottom: 18px;
left: 30px;
}
#app.has-game .player-toolbar {
padding-top: 8px;
}
#app.has-game .status {
margin-top: 6px;
}
}