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