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