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