371 lines
11 KiB
TypeScript
371 lines
11 KiB
TypeScript
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();
|
|
}
|
|
}
|