Implement native opening scenes, battle foundation and overworld movement
Checks / web (push) Canceled after 0s
Checks / web (push) Canceled after 0s
This commit is contained in:
1 parent
8b80d05ca2
commit
c08d250cf6
39 files changed
+10727
-1524
No files matched your search
+173
-9
@@ -1,6 +1,13 @@
|
||||
import { RomReader } from "./reader";
|
||||
import { decompress } from "./lz";
|
||||
import { NATIVE_MAPS, SYMBOLS } from "./profile";
|
||||
import {
|
||||
LAST_MAP_GROUP_SIZE,
|
||||
MAP_GROUP_COUNT,
|
||||
NATIVE_MAPS,
|
||||
SYMBOLS,
|
||||
} from "./profile";
|
||||
import { defaultTextContext, readString } from "../scripts/text";
|
||||
import { PokemonData } from "./pokemon";
|
||||
import type {
|
||||
Direction,
|
||||
GameMap,
|
||||
@@ -14,23 +21,83 @@ import type {
|
||||
* decoded from the supplied ROM, on demand, and cached only in memory. */
|
||||
export class CrystalAssets {
|
||||
readonly reader: RomReader;
|
||||
readonly pokemon: PokemonData;
|
||||
private maps = new Map<string, GameMap>();
|
||||
private tilesets = new Map<string, Tileset>();
|
||||
private sprites = new Map<number, Sprite>();
|
||||
private sceneOwners?: Map<string, string>;
|
||||
constructor(bytes: Uint8Array) {
|
||||
this.reader = new RomReader(bytes);
|
||||
this.pokemon = new PokemonData(this.reader);
|
||||
}
|
||||
|
||||
/** Areas whose rules and scripts are ported. Other maps can be decoded,
|
||||
* for example to draw a connected border, but cannot be entered. */
|
||||
isPorted(id: string): boolean {
|
||||
return Object.hasOwn(NATIVE_MAPS, id);
|
||||
}
|
||||
|
||||
/** Header offset for a map ID, bounded by the ROM's own group table. */
|
||||
private header(id: string): number {
|
||||
const match = /^(\d{1,2}):(\d{1,3})$/.exec(id);
|
||||
const group = Number(match?.[1]),
|
||||
number = Number(match?.[2]);
|
||||
if (!match || group < 1 || group > MAP_GROUP_COUNT || number < 1)
|
||||
throw new Error("Unknown map.");
|
||||
const r = this.reader,
|
||||
bank = r.bank(SYMBOLS.mapGroups);
|
||||
const first = r.near(SYMBOLS.mapGroups + (group - 1) * 2, bank);
|
||||
const count =
|
||||
group === MAP_GROUP_COUNT
|
||||
? LAST_MAP_GROUP_SIZE
|
||||
: (r.near(SYMBOLS.mapGroups + group * 2, bank) - first) / 9;
|
||||
if (number > count) throw new Error("Unknown map.");
|
||||
return first + (number - 1) * 9;
|
||||
}
|
||||
|
||||
/** The location name the game shows for a landmark, from the ROM. */
|
||||
landmark(index: number): string {
|
||||
const r = this.reader,
|
||||
entry = SYMBOLS.landmarks + index * 4;
|
||||
const name = readString(
|
||||
r,
|
||||
r.near(entry + 2, r.bank(SYMBOLS.landmarks)),
|
||||
defaultTextContext(""),
|
||||
);
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/(^|[\s.-])(\p{L})/gu,
|
||||
(_, gap, letter) => gap + letter.toUpperCase(),
|
||||
);
|
||||
}
|
||||
|
||||
/** The map that owns a map's scene variable (data/maps/scenes.asm). */
|
||||
sceneOwner(id: string): string | undefined {
|
||||
if (!this.sceneOwners) {
|
||||
const owners = new Map<string, string>(),
|
||||
byAddress = new Map<number, string>();
|
||||
const r = this.reader;
|
||||
for (let p = SYMBOLS.mapScenes, i = 0; i < 256; i++, p += 4) {
|
||||
const group = r.byte(p);
|
||||
if (group === 0xff) break;
|
||||
const map = `${group}:${r.byte(p + 1)}`,
|
||||
address = r.word(p + 2);
|
||||
const owner = byAddress.get(address) ?? map;
|
||||
byAddress.set(address, owner);
|
||||
owners.set(map, owner);
|
||||
}
|
||||
this.sceneOwners = owners;
|
||||
}
|
||||
return this.sceneOwners.get(id);
|
||||
}
|
||||
|
||||
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 header = this.header(id);
|
||||
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);
|
||||
@@ -38,11 +105,14 @@ export class CrystalAssets {
|
||||
throw new Error("Invalid map dimensions.");
|
||||
const scriptBank = r.byte(attributes + 6);
|
||||
let p = r.near(attributes + 9, scriptBank) + 2;
|
||||
const location = this.landmark(r.byte(header + 5));
|
||||
const map: GameMap = {
|
||||
id,
|
||||
group,
|
||||
number,
|
||||
name: NATIVE_MAPS[id],
|
||||
name: NATIVE_MAPS[id] ?? location,
|
||||
location,
|
||||
landmark: r.byte(header + 5),
|
||||
width: blockWidth * 2,
|
||||
height: blockHeight * 2,
|
||||
border: r.byte(attributes),
|
||||
@@ -165,6 +235,54 @@ export class CrystalAssets {
|
||||
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)];
|
||||
}
|
||||
/** Where a step coordinate outside `map` lands in a connected map. */
|
||||
connected(
|
||||
map: GameMap,
|
||||
x: number,
|
||||
y: number,
|
||||
): { map: GameMap; x: number; y: number } | undefined {
|
||||
const direction =
|
||||
y < 0
|
||||
? "up"
|
||||
: y >= map.height
|
||||
? "down"
|
||||
: x < 0
|
||||
? "left"
|
||||
: x >= map.width
|
||||
? "right"
|
||||
: undefined;
|
||||
// Corners are outside every connection strip and show border blocks.
|
||||
if (!direction || ((x < 0 || x >= map.width) && (y < 0 || y >= map.height)))
|
||||
return undefined;
|
||||
const connection = map.connections.find((c) => c.direction === direction);
|
||||
if (!connection) return undefined;
|
||||
const target = this.map(connection.targetMap);
|
||||
// Offsets are the coordinates EnterMapConnection assigns on crossing.
|
||||
const position =
|
||||
direction === "up"
|
||||
? { x: x + connection.x, y: y + 1 + connection.y }
|
||||
: direction === "down"
|
||||
? { x: x + connection.x, y: y - map.height + connection.y }
|
||||
: direction === "left"
|
||||
? { x: x + 1 + connection.x, y: y + connection.y }
|
||||
: { x: x - map.width + connection.x, y: y + connection.y };
|
||||
if (
|
||||
position.x < 0 ||
|
||||
position.y < 0 ||
|
||||
position.x >= target.width ||
|
||||
position.y >= target.height
|
||||
)
|
||||
return undefined;
|
||||
return { map: target, ...position };
|
||||
}
|
||||
/** A visible block, drawing connected maps beyond the edges as the
|
||||
* original connection strips do, with the current map's tileset. */
|
||||
visibleBlock(map: GameMap, x: number, y: number): number {
|
||||
if (x >= 0 && y >= 0 && x < map.width && y < map.height)
|
||||
return this.block(map, x, y);
|
||||
const target = this.connected(map, x, y);
|
||||
return target ? this.block(target.map, target.x, target.y) : map.border;
|
||||
}
|
||||
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);
|
||||
@@ -210,6 +328,48 @@ export class CrystalAssets {
|
||||
objectPalette(index: number, time: number): Palette {
|
||||
return this.palette(SYMBOLS.objectPalettes + time * 64 + (index & 7) * 8);
|
||||
}
|
||||
/** Collisions that allow wild encounters (CheckGrassCollision). */
|
||||
grassCollisions(): number[] {
|
||||
const r = this.reader,
|
||||
values: number[] = [];
|
||||
for (let p = SYMBOLS.grassCollisions; values.length < 32; p++) {
|
||||
const value = r.byte(p);
|
||||
if (value === 0xff) return values;
|
||||
values.push(value);
|
||||
}
|
||||
throw new Error("Grass collision list is unterminated.");
|
||||
}
|
||||
/** JohtoGrassWildMons for a map: encounter rates and seven slots per
|
||||
* time of day (data/wild/johto_grass.asm). */
|
||||
grassWildMons(
|
||||
id: string,
|
||||
): { rates: number[]; slots: { level: number; species: number }[][] } | undefined {
|
||||
const r = this.reader;
|
||||
for (let p = SYMBOLS.johtoGrassWildMons, i = 0; i < 256; i++, p += 47) {
|
||||
const group = r.byte(p);
|
||||
if (group === 0xff) return undefined;
|
||||
if (`${group}:${r.byte(p + 1)}` !== id) continue;
|
||||
const rates = [r.byte(p + 2), r.byte(p + 3), r.byte(p + 4)];
|
||||
const slots = [0, 1, 2].map((time) =>
|
||||
Array.from({ length: 7 }, (_, slot) => {
|
||||
const entry = p + 5 + time * 14 + slot * 2;
|
||||
return { level: r.byte(entry), species: r.byte(entry + 1) };
|
||||
}),
|
||||
);
|
||||
return { rates, slots };
|
||||
}
|
||||
throw new Error("Wild encounter data is unterminated.");
|
||||
}
|
||||
/** Emote bubble graphics (data/sprites/emotes.asm). */
|
||||
emote(index: number): Uint8Array {
|
||||
const r = this.reader,
|
||||
entry = SYMBOLS.emotes + index * 6;
|
||||
if (index < 0 || index > 11) throw new Error("Unknown emote.");
|
||||
return r.slice(
|
||||
r.address(r.byte(entry + 3), r.word(entry)),
|
||||
r.byte(entry + 2),
|
||||
);
|
||||
}
|
||||
sprite(id: number): Sprite | undefined {
|
||||
// Variable decorations and Pokémon icons need separate decoders.
|
||||
if (id < 1 || id >= 0xa0) return undefined;
|
||||
@@ -228,8 +388,12 @@ export class CrystalAssets {
|
||||
return sprite;
|
||||
}
|
||||
facing(direction: Direction, frame: number): FacingTile[] {
|
||||
const facing =
|
||||
["down", "up", "left", "right"].indexOf(direction) * 4 + (frame % 4);
|
||||
return this.facingTiles(
|
||||
["down", "up", "left", "right"].indexOf(direction) * 4 + (frame % 4),
|
||||
);
|
||||
}
|
||||
/** FACING_* layouts; 0x14 is the emote bubble. */
|
||||
facingTiles(facing: number): FacingTile[] {
|
||||
const r = this.reader,
|
||||
pointer = r.near(SYMBOLS.facings + facing * 2, r.bank(SYMBOLS.facings));
|
||||
return Array.from({ length: r.byte(pointer) }, (_, i) => {
|
||||
|
||||
Reference in new issue
Block a user