Add native Crystal exploration port and reference tooling
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-23 09:23:29 +02:00
1 parent 2127f5f459
commit 8b80d05ca2
56 files changed
+6910 -1

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
export type Direction = "down" | "up" | "left" | "right";
export const VECTORS: Record<Direction, readonly [number, number]> = {
down: [0, 1],
up: [0, -1],
left: [-1, 0],
right: [1, 0],
};
export const DIRECTION_ORDER: Direction[] = ["down", "up", "left", "right"];
export interface Warp {
x: number;
y: number;
targetMap: string;
targetWarp: number;
}
export interface BackgroundEvent {
x: number;
y: number;
kind: number;
script: number;
}
export interface MapObject {
id: number;
sprite: number;
x: number;
y: number;
movement: number;
palette: number;
kind: number;
script: number;
eventFlag: number;
hour1: number;
hour2: number;
}
export interface MapConnection {
direction: Direction;
targetMap: string;
x: number;
y: number;
}
export interface GameMap {
id: string;
group: number;
number: number;
name: string;
width: number;
height: number;
border: number;
tileset: number;
environment: number;
paletteMode: number;
blocks: Uint8Array;
warps: Warp[];
backgroundEvents: BackgroundEvent[];
objects: MapObject[];
connections: MapConnection[];
sceneScripts: number[];
callbacks: { kind: number; script: number }[];
coordinateEvents: { scene: number; x: number; y: number; script: number }[];
}
export interface Tileset {
graphics: Uint8Array;
metatiles: Uint8Array;
collision: Uint8Array;
paletteMap: Uint8Array;
}
export type Palette = readonly (readonly [number, number, number])[];
export interface Sprite {
graphics: Uint8Array;
palette: number;
walking: boolean;
}
export interface FacingTile {
x: number;
y: number;
attributes: number;
tile: number;
}
+370
View File
@@ -0,0 +1,370 @@
import { CrystalAssets } from "../rom/assets";
import { INITIAL_POSITION, NATIVE_MAPS, SYMBOLS } from "../rom/profile";
import {
runScript,
type ScriptResult,
type ScriptState,
} from "../scripts/interpreter";
import {
DIRECTION_ORDER,
VECTORS,
type Direction,
type MapObject,
} from "./types";
export interface NativeSave {
format: "crystal-native";
version: 1;
map: string;
x: number;
y: number;
direction: Direction;
name: string;
avatar: "chris" | "kris";
tilesWalked: number;
events: number[];
flags: number[];
variables: Record<number, number>;
}
/** Deterministic native world rules. Rendering, browser APIs and emulator
* registers are deliberately absent; callers provide time and input. */
export class GameWorld {
mapId: string = INITIAL_POSITION.map;
x: number = INITIAL_POSITION.x;
y: number = INITIAL_POSITION.y;
direction: Direction = "down";
name = "CHRIS";
avatar: "chris" | "kris" = "chris";
tilesWalked = 0;
state: ScriptState = { events: new Set(), flags: new Set(), variables: {} };
dialogue:
{ pages: string[]; index: number; result: ScriptResult } | undefined;
notice = "";
diagnostic = "";
private objectDirections = new Map<number, Direction>();
constructor(
readonly assets: CrystalAssets,
private now: () => Date = () => new Date(),
) {
// Port the original initialization event table through the script engine;
// this supplies NPC visibility flags without distributing a copied table.
const initial = assets.reader.far(SYMBOLS.standardScripts + 24 * 3);
this.state = runScript(assets.reader, initial, this.state, this.name);
assets.map(this.mapId);
}
get map() {
return this.assets.map(this.mapId);
}
get timeOfDay(): number {
const mode = this.map.paletteMode;
if (mode === 1) return 1;
if (mode === 2) return 2;
if (mode === 3) return 0;
if (mode === 4) return 3;
const hour = this.now().getHours();
return hour >= 4 && hour < 10 ? 0 : hour >= 10 && hour < 18 ? 1 : 2;
}
objects(): MapObject[] {
const hour = this.now().getHours(),
timeMask = 1 << this.timeOfDay;
return this.map.objects.filter((object) => {
if (
object.eventFlag !== 0xffff &&
this.state.events.has(object.eventFlag)
)
return false;
if (!this.assets.sprite(object.sprite)) return false;
if (object.hour1 === 0xff)
return object.hour2 === 0xff || Boolean(object.hour2 & timeMask);
if (object.hour1 === object.hour2) return true;
return object.hour1 < object.hour2
? hour >= object.hour1 && hour < object.hour2
: hour >= object.hour1 || hour < object.hour2;
});
}
objectDirection(object: MapObject): Direction {
return (
this.objectDirections.get(object.id) ??
DIRECTION_ORDER[object.movement - 6] ??
"down"
);
}
private clearNotice() {
this.notice = "";
this.diagnostic = "";
}
private canStand(x: number, y: number): boolean {
if (x < 0 || y < 0 || x >= this.map.width || y >= this.map.height)
return false;
if (this.assets.permission(this.assets.collision(this.map, x, y)) !== 0)
return false;
return !this.objects().some((object) => object.x === x && object.y === y);
}
move(direction: Direction): boolean {
if (this.dialogue) return false;
this.clearNotice();
this.direction = direction;
const [dx, dy] = VECTORS[direction],
x = this.x + dx,
y = this.y + dy;
if (x < 0 || y < 0 || x >= this.map.width || y >= this.map.height) {
if (
this.map.connections.some(
(connection) => connection.direction === direction,
)
)
this.notice = "Routes beyond New Bark Town are not ported yet.";
return false;
}
const collision = this.assets.collision(this.map, x, y);
// Forced movement, surfing and ledges need their own translated rules.
// Refuse them rather than treating a land permission as ordinary walking.
if ([0x30, 0x40, 0x50, 0x60, 0xa0].includes(collision & 0xf0)) {
this.notice = "This movement type is not ported yet.";
return false;
}
if (!this.canStand(x, y)) return false;
// Directional edge walls: up/down/left/right encoded as hardware direction IDs.
const edges: Record<number, Direction[]> = {
0xb0: ["right"],
0xb1: ["left"],
0xb2: ["up"],
0xb3: ["down"],
0xb4: ["down", "right"],
0xb5: ["down", "left"],
0xb6: ["up", "right"],
0xb7: ["up", "left"],
};
const source = this.assets.collision(this.map, this.x, this.y);
const opposite: Record<Direction, Direction> = {
down: "up",
up: "down",
left: "right",
right: "left",
};
if (
edges[source]?.includes(direction) ||
edges[collision]?.includes(opposite[direction])
)
return false;
const warp = this.map.warps.find((warp) => warp.x === x && warp.y === y);
if (warp) {
if (
!Object.hasOwn(NATIVE_MAPS, warp.targetMap) ||
warp.targetWarp === 0 ||
warp.targetWarp === 255
) {
this.notice = "This destination is not ported yet.";
return false;
}
const target = this.assets.map(warp.targetMap),
arrival = target.warps[warp.targetWarp - 1];
if (!arrival) throw new Error("Warp destination is invalid.");
this.mapId = target.id;
this.x = arrival.x;
this.y = arrival.y;
this.objectDirections.clear();
this.direction = "down";
const arrivalCollision = this.assets.collision(target, this.x, this.y);
const exitY =
arrivalCollision === 0x70
? this.y - 1
: arrivalCollision === 0x71
? this.y + 1
: this.y;
if (exitY !== this.y && this.canStand(this.x, exitY)) {
this.direction = exitY < this.y ? "up" : "down";
this.y = exitY;
}
} else {
this.x = x;
this.y = y;
}
this.tilesWalked++;
return true;
}
interact() {
if (this.dialogue) {
this.advanceDialogue();
return;
}
this.clearNotice();
const [dx, dy] = VECTORS[this.direction];
let x = this.x + dx,
y = this.y + dy;
const collision = this.assets.collision(this.map, x, y);
if (collision === 0x90) {
x += dx;
y += dy;
}
const object = this.objects().find(
(object) => object.x === x && object.y === y,
);
let script: number | undefined;
if (object) {
if (object.kind !== 0) {
this.notice = "This interaction is not ported yet.";
return;
}
script = object.script;
} else {
const event = this.map.backgroundEvents.find(
(event) => event.x === x && event.y === y,
);
if (event) {
if (event.kind >= 5) {
this.notice = "This conditional interaction is not ported yet.";
return;
}
const requiredDirection = ["up", "down", "right", "left"][
event.kind - 1
];
if (event.kind && requiredDirection !== this.direction) return;
script = event.script;
} else {
const standard: Record<number, number> = {
0x91: 2,
0x97: 9,
0x9d: 8,
0x96: 6,
0x9f: 5,
};
if (standard[collision] !== undefined && this.direction === "up")
script = this.assets.reader.far(
SYMBOLS.standardScripts + standard[collision] * 3,
);
}
}
if (script === undefined) {
this.notice = "Nothing to interact with here.";
return;
}
try {
const result = runScript(
this.assets.reader,
script,
this.state,
this.name,
);
if (result.facePlayer && object) {
const opposite: Record<Direction, Direction> = {
down: "up",
up: "down",
left: "right",
right: "left",
};
this.objectDirections.set(object.id, opposite[this.direction]);
}
if (result.pages.length)
this.dialogue = { pages: result.pages, index: 0, result };
else this.commit(result);
} catch (error) {
this.notice =
"This interaction needs game logic that has not been ported yet. Your progress is unchanged.";
this.diagnostic = error instanceof Error ? error.message : String(error);
}
}
advanceDialogue() {
if (!this.dialogue) return;
if (++this.dialogue.index < this.dialogue.pages.length) return;
this.commit(this.dialogue.result);
this.dialogue = undefined;
}
private commit(result: ScriptResult) {
this.state = {
events: result.events,
flags: result.flags,
variables: result.variables,
};
for (const turn of result.turns) {
if (DIRECTION_ORDER[turn.direction])
this.objectDirections.set(turn.object, DIRECTION_ORDER[turn.direction]);
}
}
save(): NativeSave {
// Incomplete interactions are replayed after reload; no partial quest writes.
return {
format: "crystal-native",
version: 1,
map: this.mapId,
x: this.x,
y: this.y,
direction: this.direction,
name: this.name,
avatar: this.avatar,
tilesWalked: this.tilesWalked,
events: [...this.state.events].sort((a, b) => a - b),
flags: [...this.state.flags].sort((a, b) => a - b),
variables: { ...this.state.variables },
};
}
restore(value: unknown) {
const save = value as NativeSave;
const integer = (
value: unknown,
min: number,
max: number,
): value is number =>
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
const ids = (value: unknown): value is number[] =>
Array.isArray(value) &&
value.length <= 2048 &&
value.every((value) => integer(value, 0, 0xffff)) &&
new Set(value).size === value.length;
if (
!save ||
save.format !== "crystal-native" ||
save.version !== 1 ||
!Object.hasOwn(NATIVE_MAPS, save.map) ||
!integer(save.x, 0, 127) ||
!integer(save.y, 0, 127) ||
!DIRECTION_ORDER.includes(save.direction) ||
typeof save.name !== "string" ||
!/^[A-Za-z0-9 -]{1,7}$/.test(save.name) ||
!["chris", "kris"].includes(save.avatar) ||
!integer(save.tilesWalked, 0, Number.MAX_SAFE_INTEGER) ||
!ids(save.events) ||
!ids(save.flags) ||
!save.variables ||
typeof save.variables !== "object" ||
Array.isArray(save.variables) ||
Object.entries(save.variables).some(
([id, value]) =>
!/^\d+$/.test(id) ||
!integer(Number(id), 0, 255) ||
!integer(value, 0, 255),
)
) {
throw new Error(
"Invalid native save. Emulator snapshots cannot be imported into this port.",
);
}
const map = this.assets.map(save.map);
if (
save.x >= map.width ||
save.y >= map.height ||
this.assets.permission(this.assets.collision(map, save.x, save.y)) !== 0
)
throw new Error("The saved position is outside the walkable map.");
// Validate completely before changing live state.
this.mapId = save.map;
this.x = save.x;
this.y = save.y;
this.direction = save.direction;
this.name = save.name;
this.avatar = save.avatar;
this.tilesWalked = save.tilesWalked;
this.state = {
events: new Set(save.events),
flags: new Set(save.flags),
variables: { ...save.variables },
};
this.dialogue = undefined;
this.objectDirections.clear();
this.clearNotice();
}
}