This commit is contained in:
1 parent
2127f5f459
commit
8b80d05ca2
56 files changed
+6910
-1
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const BUTTONS = [
|
||||
"up",
|
||||
"down",
|
||||
"left",
|
||||
"right",
|
||||
"a",
|
||||
"b",
|
||||
"start",
|
||||
"select",
|
||||
] as const;
|
||||
export type Button = (typeof BUTTONS)[number];
|
||||
+278
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user