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
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,294 @@
|
||||
/** Battle constants under their PRET names (constants/*.asm and
|
||||
* macros/scripts/battle_commands.asm). `npm run check:reference` compares
|
||||
* every entry with the pinned source, so a wrong value cannot ship. */
|
||||
export const C = {
|
||||
// Move effects (constants/move_effect_constants.asm)
|
||||
EFFECT_NORMAL_HIT: 0,
|
||||
EFFECT_LEECH_HIT: 3,
|
||||
EFFECT_SELFDESTRUCT: 7,
|
||||
EFFECT_DREAM_EATER: 8,
|
||||
EFFECT_ALWAYS_HIT: 17,
|
||||
EFFECT_ATTACK_DOWN: 18,
|
||||
EFFECT_EVASION_DOWN: 24,
|
||||
EFFECT_BIDE: 26,
|
||||
EFFECT_RAMPAGE: 27,
|
||||
EFFECT_MULTI_HIT: 29,
|
||||
EFFECT_CONVERSION: 30,
|
||||
EFFECT_TOXIC: 33,
|
||||
EFFECT_RAZOR_WIND: 39,
|
||||
EFFECT_DOUBLE_HIT: 44,
|
||||
EFFECT_JUMP_KICK: 45,
|
||||
EFFECT_ATTACK_DOWN_2: 58,
|
||||
EFFECT_EVASION_DOWN_2: 64,
|
||||
EFFECT_ATTACK_DOWN_HIT: 68,
|
||||
EFFECT_ACCURACY_DOWN_HIT: 73,
|
||||
EFFECT_EVASION_DOWN_HIT: 74,
|
||||
EFFECT_SKY_ATTACK: 75,
|
||||
EFFECT_CONFUSE_HIT: 76,
|
||||
EFFECT_POISON_MULTI_HIT: 77,
|
||||
EFFECT_RAGE: 81,
|
||||
EFFECT_SPLASH: 85,
|
||||
EFFECT_SNORE: 92,
|
||||
EFFECT_PRIORITY_HIT: 103,
|
||||
EFFECT_TRIPLE_KICK: 104,
|
||||
EFFECT_PROTECT: 111,
|
||||
EFFECT_ENDURE: 116,
|
||||
EFFECT_ROLLOUT: 117,
|
||||
EFFECT_SWAGGER: 118,
|
||||
EFFECT_FURY_CUTTER: 119,
|
||||
EFFECT_SKULL_BASH: 145,
|
||||
EFFECT_FUTURE_SIGHT: 148,
|
||||
EFFECT_SOLARBEAM: 151,
|
||||
EFFECT_THUNDER: 152,
|
||||
EFFECT_BEAT_UP: 154,
|
||||
EFFECT_FLY: 155,
|
||||
|
||||
// Moves (constants/move_constants.asm)
|
||||
GUST: 16,
|
||||
WHIRLWIND: 18,
|
||||
FLY: 19,
|
||||
THUNDER: 87,
|
||||
EARTHQUAKE: 89,
|
||||
FISSURE: 90,
|
||||
DIG: 91,
|
||||
MIMIC: 102,
|
||||
MINIMIZE: 107,
|
||||
SUBSTITUTE: 164,
|
||||
STRUGGLE: 165,
|
||||
FLAME_WHEEL: 172,
|
||||
SNORE: 173,
|
||||
SLEEP_TALK: 214,
|
||||
SACRED_FIRE: 221,
|
||||
MAGNITUDE: 222,
|
||||
VITAL_THROW: 233,
|
||||
TWISTER: 239,
|
||||
|
||||
// Types (constants/type_constants.asm)
|
||||
NORMAL: 0,
|
||||
FLYING: 2,
|
||||
POISON: 3,
|
||||
FIRE: 20,
|
||||
SPECIAL: 20,
|
||||
ICE: 25,
|
||||
|
||||
// Species and items
|
||||
PIKACHU: 25,
|
||||
FARFETCH_D: 83,
|
||||
CUBONE: 104,
|
||||
MAROWAK: 105,
|
||||
CHANSEY: 113,
|
||||
MAGIKARP: 129,
|
||||
DITTO: 132,
|
||||
UNOWN: 201,
|
||||
NO_ITEM: 0,
|
||||
LUCKY_PUNCH: 30,
|
||||
METAL_POWDER: 35,
|
||||
STICK: 105,
|
||||
THICK_CLUB: 118,
|
||||
EXP_SHARE: 57,
|
||||
CLEANSE_TAG: 94,
|
||||
LUCKY_EGG: 126,
|
||||
LIGHT_BALL: 163,
|
||||
|
||||
// Held item effects (constants/item_data_constants.asm)
|
||||
HELD_BERRY: 1,
|
||||
HELD_LEFTOVERS: 3,
|
||||
HELD_RESTORE_PP: 6,
|
||||
HELD_HEAL_POISON: 10,
|
||||
HELD_HEAL_PARALYZE: 14,
|
||||
HELD_HEAL_STATUS: 15,
|
||||
HELD_HEAL_CONFUSION: 16,
|
||||
HELD_PREVENT_POISON: 20,
|
||||
HELD_PREVENT_BURN: 21,
|
||||
HELD_PREVENT_FREEZE: 22,
|
||||
HELD_PREVENT_SLEEP: 23,
|
||||
HELD_PREVENT_PARALYZE: 24,
|
||||
HELD_PREVENT_CONFUSE: 25,
|
||||
HELD_ESCAPE: 72,
|
||||
HELD_CRITICAL_UP: 73,
|
||||
HELD_QUICK_CLAW: 74,
|
||||
HELD_FLINCH: 75,
|
||||
HELD_ATTACK_UP: 31,
|
||||
HELD_EVASION_UP: 37,
|
||||
HELD_BRIGHTPOWDER: 77,
|
||||
HELD_FOCUS_BAND: 79,
|
||||
|
||||
// Stats and levels
|
||||
ATTACK: 0,
|
||||
DEFENSE: 1,
|
||||
SPEED: 2,
|
||||
SP_ATTACK: 3,
|
||||
SP_DEFENSE: 4,
|
||||
ACCURACY: 5,
|
||||
EVASION: 6,
|
||||
BASE_STAT_LEVEL: 7,
|
||||
MAX_STAT_LEVEL: 13,
|
||||
MAX_LEVEL: 100,
|
||||
PP_MASK: 63,
|
||||
NUM_MOVES: 4,
|
||||
BASE_HAPPINESS: 70,
|
||||
START_MONEY: 3000,
|
||||
MAX_MONEY: 999999,
|
||||
|
||||
// Status bits and masks
|
||||
SLP_MASK: 7,
|
||||
PSN: 3,
|
||||
BRN: 4,
|
||||
FRZ: 5,
|
||||
PAR: 6,
|
||||
|
||||
// Substatus bits, by byte: 1 (Nightmare..In Love), 2 (Curled),
|
||||
// 3 (Bide..Confused), 4 (X Accuracy..Leech Seed), 5 (Toxic..Can't Run)
|
||||
SUBSTATUS_NIGHTMARE: 0,
|
||||
SUBSTATUS_CURSE: 1,
|
||||
SUBSTATUS_PROTECT: 2,
|
||||
SUBSTATUS_IDENTIFIED: 3,
|
||||
SUBSTATUS_PERISH: 4,
|
||||
SUBSTATUS_ENDURE: 5,
|
||||
SUBSTATUS_ROLLOUT: 6,
|
||||
SUBSTATUS_IN_LOVE: 7,
|
||||
SUBSTATUS_CURLED: 0,
|
||||
SUBSTATUS_BIDE: 0,
|
||||
SUBSTATUS_RAMPAGE: 1,
|
||||
SUBSTATUS_IN_LOOP: 2,
|
||||
SUBSTATUS_FLINCHED: 3,
|
||||
SUBSTATUS_CHARGED: 4,
|
||||
SUBSTATUS_UNDERGROUND: 5,
|
||||
SUBSTATUS_FLYING: 6,
|
||||
SUBSTATUS_CONFUSED: 7,
|
||||
SUBSTATUS_X_ACCURACY: 0,
|
||||
SUBSTATUS_MIST: 1,
|
||||
SUBSTATUS_FOCUS_ENERGY: 2,
|
||||
SUBSTATUS_SUBSTITUTE: 4,
|
||||
SUBSTATUS_RECHARGE: 5,
|
||||
SUBSTATUS_RAGE: 6,
|
||||
SUBSTATUS_LEECH_SEED: 7,
|
||||
SUBSTATUS_TOXIC: 0,
|
||||
SUBSTATUS_TRANSFORMED: 3,
|
||||
SUBSTATUS_ENCORED: 4,
|
||||
SUBSTATUS_LOCK_ON: 5,
|
||||
SUBSTATUS_DESTINY_BOND: 6,
|
||||
SUBSTATUS_CANT_RUN: 7,
|
||||
|
||||
// Screens, weather and effectiveness
|
||||
SCREENS_SPIKES: 0,
|
||||
SCREENS_SAFEGUARD: 2,
|
||||
SCREENS_LIGHT_SCREEN: 3,
|
||||
SCREENS_REFLECT: 4,
|
||||
WEATHER_NONE: 0,
|
||||
WEATHER_RAIN: 1,
|
||||
WEATHER_SUN: 2,
|
||||
NO_EFFECT: 0,
|
||||
NOT_VERY_EFFECTIVE: 5,
|
||||
EFFECTIVE: 10,
|
||||
SUPER_EFFECTIVE: 20,
|
||||
STAB_DAMAGE: 128,
|
||||
EFFECTIVENESS_MASK: 127,
|
||||
BASE_PRIORITY: 1,
|
||||
|
||||
// Battle modes, types and results
|
||||
WILD_BATTLE: 1,
|
||||
TRAINER_BATTLE: 2,
|
||||
BATTLETYPE_NORMAL: 0,
|
||||
BATTLETYPE_DEBUG: 2,
|
||||
BATTLETYPE_TUTORIAL: 3,
|
||||
BATTLETYPE_ROAMING: 5,
|
||||
BATTLETYPE_CONTEST: 6,
|
||||
BATTLETYPE_FORCESHINY: 7,
|
||||
BATTLETYPE_TRAP: 9,
|
||||
BATTLETYPE_FORCEITEM: 10,
|
||||
BATTLETYPE_CELEBI: 11,
|
||||
BATTLETYPE_SUICUNE: 12,
|
||||
WIN: 0,
|
||||
LOSE: 1,
|
||||
DRAW: 2,
|
||||
MORN_F: 0,
|
||||
DAY_F: 1,
|
||||
NITE_F: 2,
|
||||
} as const;
|
||||
|
||||
/** Battle command IDs (macros/scripts/battle_commands.asm). */
|
||||
export const COMMAND = {
|
||||
checkturn: 1,
|
||||
checkobedience: 2,
|
||||
usedmovetext: 3,
|
||||
doturn: 4,
|
||||
critical: 5,
|
||||
damagestats: 6,
|
||||
stab: 7,
|
||||
damagevariation: 8,
|
||||
checkhit: 9,
|
||||
lowersub: 10,
|
||||
moveanimnosub: 11,
|
||||
raisesub: 12,
|
||||
failuretext: 13,
|
||||
applydamage: 14,
|
||||
criticaltext: 15,
|
||||
supereffectivetext: 16,
|
||||
checkfaint: 17,
|
||||
buildopponentrage: 18,
|
||||
poisontarget: 19,
|
||||
sleeptarget: 20,
|
||||
draintarget: 21,
|
||||
burntarget: 23,
|
||||
freezetarget: 24,
|
||||
paralyzetarget: 25,
|
||||
flinchtarget: 37,
|
||||
confuse: 42,
|
||||
confusetarget: 43,
|
||||
poison: 47,
|
||||
paralyze: 48,
|
||||
splash: 54,
|
||||
kingsrock: 77,
|
||||
foresight: 87,
|
||||
damagecalc: 98,
|
||||
checksafeguard: 101,
|
||||
healmorn: 106,
|
||||
healday: 107,
|
||||
healnite: 108,
|
||||
attackup: 112,
|
||||
defenseup: 113,
|
||||
speedup: 114,
|
||||
specialattackup: 115,
|
||||
specialdefenseup: 116,
|
||||
accuracyup: 117,
|
||||
evasionup: 118,
|
||||
attackup2: 119,
|
||||
defenseup2: 120,
|
||||
speedup2: 121,
|
||||
specialattackup2: 122,
|
||||
specialdefenseup2: 123,
|
||||
accuracyup2: 124,
|
||||
evasionup2: 125,
|
||||
attackdown: 126,
|
||||
defensedown: 127,
|
||||
speeddown: 128,
|
||||
specialattackdown: 129,
|
||||
specialdefensedown: 130,
|
||||
accuracydown: 131,
|
||||
evasiondown: 132,
|
||||
attackdown2: 133,
|
||||
defensedown2: 134,
|
||||
speeddown2: 135,
|
||||
specialattackdown2: 136,
|
||||
specialdefensedown2: 137,
|
||||
accuracydown2: 138,
|
||||
evasiondown2: 139,
|
||||
statupmessage: 140,
|
||||
statdownmessage: 141,
|
||||
statupfailtext: 142,
|
||||
statdownfailtext: 143,
|
||||
effectchance: 144,
|
||||
statdownanim: 145,
|
||||
statupanim: 146,
|
||||
switchturn: 147,
|
||||
rage: 151,
|
||||
doubleflyingdamage: 152,
|
||||
ragedamage: 162,
|
||||
clearmissdamage: 169,
|
||||
movedelay: 170,
|
||||
moveanim: 171,
|
||||
curl: 175,
|
||||
endmove: 0xff,
|
||||
endturn: 0xfe,
|
||||
} as const;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { RomReader } from "../rom/reader";
|
||||
import { BATTLE } from "../rom/profile";
|
||||
|
||||
/** Readers for the battle engine's ROM tables. */
|
||||
export class BattleData {
|
||||
constructor(readonly reader: RomReader) {}
|
||||
|
||||
/** The effect script bytes for a move effect, up to endmove. */
|
||||
effectScript(effect: number): number[] {
|
||||
const r = this.reader;
|
||||
let p = r.near(
|
||||
BATTLE.MoveEffectsPointers + effect * 2,
|
||||
r.bank(BATTLE.MoveEffectsPointers),
|
||||
);
|
||||
const script: number[] = [];
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const command = r.byte(p++);
|
||||
script.push(command);
|
||||
if (command === 0xff) return script;
|
||||
}
|
||||
throw new Error("Move effect script is unterminated.");
|
||||
}
|
||||
|
||||
/** TypeMatchups rows: attacking type, defending type, multiplier. The
|
||||
* row with attacker 0xfe marks where Foresight stops the lookup. */
|
||||
typeMatchups(): { attacker: number; defender: number; multiplier: number }[] {
|
||||
const r = this.reader,
|
||||
rows = [];
|
||||
for (let p = BATTLE.TypeMatchups; rows.length < 256; ) {
|
||||
const attacker = r.byte(p++);
|
||||
if (attacker === 0xff) return rows;
|
||||
if (attacker === 0xfe) {
|
||||
rows.push({ attacker, defender: 0, multiplier: 0 });
|
||||
continue;
|
||||
}
|
||||
rows.push({ attacker, defender: r.byte(p), multiplier: r.byte(p + 1) });
|
||||
p += 2;
|
||||
}
|
||||
throw new Error("Type matchups are unterminated.");
|
||||
}
|
||||
|
||||
/** Numerator and denominator pairs for stat levels 1-13. */
|
||||
statMultiplier(level: number): [number, number] {
|
||||
const p = BATTLE.StatLevelMultipliers + (level - 1) * 2;
|
||||
return [this.reader.byte(p), this.reader.byte(p + 1)];
|
||||
}
|
||||
accuracyMultiplier(level: number): [number, number] {
|
||||
const p = BATTLE.AccuracyLevelMultipliers + (level - 1) * 2;
|
||||
return [this.reader.byte(p), this.reader.byte(p + 1)];
|
||||
}
|
||||
criticalChance(stage: number): number {
|
||||
return this.reader.byte(BATTLE.CriticalHitChances + Math.min(stage, 6));
|
||||
}
|
||||
/** A -1 terminated byte list, such as CriticalHitMoves. */
|
||||
byteList(address: number): number[] {
|
||||
const values: number[] = [];
|
||||
for (let p = address; values.length < 256; p++) {
|
||||
const value = this.reader.byte(p);
|
||||
if (value === 0xff) return values;
|
||||
values.push(value);
|
||||
}
|
||||
throw new Error("List is unterminated.");
|
||||
}
|
||||
/** MoveEffectPriorities: effect, priority pairs. */
|
||||
priority(effect: number, basePriority: number): number {
|
||||
const r = this.reader;
|
||||
for (let p = BATTLE.MoveEffectPriorities; ; p += 2) {
|
||||
const entry = r.byte(p);
|
||||
if (entry === effect) return r.byte(p + 1);
|
||||
if (entry === 0xff) return basePriority;
|
||||
}
|
||||
}
|
||||
/** TypeBoostItems: held effect, type pairs. */
|
||||
typeBoost(heldEffect: number): number | undefined {
|
||||
const r = this.reader;
|
||||
for (let p = BATTLE.TypeBoostItems; ; p += 2) {
|
||||
const entry = r.byte(p);
|
||||
if (entry === 0xff) return undefined;
|
||||
if (entry === heldEffect) return r.byte(p + 1);
|
||||
}
|
||||
}
|
||||
/** GetItemHeldEffect: the held effect and its parameter. */
|
||||
heldEffect(item: number): { effect: number; parameter: number } {
|
||||
if (!item) return { effect: 0, parameter: 0 };
|
||||
const p = BATTLE.ItemAttributes + (item - 1) * 7 + 2;
|
||||
return { effect: this.reader.byte(p), parameter: this.reader.byte(p + 1) };
|
||||
}
|
||||
/** HeldStatusHealingEffects: the statuses a held item cures. */
|
||||
heldStatusHealing(effect: number): number | undefined {
|
||||
const r = this.reader;
|
||||
for (let p = BATTLE.HeldStatusHealingEffects; ; p += 2) {
|
||||
const entry = r.byte(p);
|
||||
if (entry === 0xff) return undefined;
|
||||
if (entry === effect) return r.byte(p + 1);
|
||||
}
|
||||
}
|
||||
/** GetMoveGrammar: the grammar set a move's "used" text follows. */
|
||||
moveGrammar(move: number): number {
|
||||
const r = this.reader;
|
||||
// Moves missing from every set use the set after the last one.
|
||||
let set = 0;
|
||||
for (let p = BATTLE.MoveGrammar; set < 8; ) {
|
||||
const entry = r.byte(p++);
|
||||
if (entry === 0xff || entry === move) return set;
|
||||
if (entry === 0) set++;
|
||||
}
|
||||
throw new Error("Move grammar table is unterminated.");
|
||||
}
|
||||
/** GetStatName: the n-th "@"-terminated name in StatNames. */
|
||||
statNameAddress(stat: number): number {
|
||||
const r = this.reader;
|
||||
let p = BATTLE.StatNames;
|
||||
for (let skipped = 0; skipped < stat; skipped++) while (r.byte(p++) !== 0x50);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { PokemonData, Pocket } from "../rom/pokemon";
|
||||
|
||||
/** The player's pack, with the original pocket capacities. */
|
||||
export interface Bag {
|
||||
items: { item: number; quantity: number }[];
|
||||
balls: { item: number; quantity: number }[];
|
||||
keyItems: number[];
|
||||
}
|
||||
export const emptyBag = (): Bag => ({ items: [], balls: [], keyItems: [] });
|
||||
export const copyBag = (bag: Bag): Bag => ({
|
||||
items: bag.items.map((slot) => ({ ...slot })),
|
||||
balls: bag.balls.map((slot) => ({ ...slot })),
|
||||
keyItems: [...bag.keyItems],
|
||||
});
|
||||
|
||||
const MAX_ITEMS = 20,
|
||||
MAX_BALLS = 12,
|
||||
MAX_KEY_ITEMS = 25,
|
||||
MAX_ITEM_STACK = 99;
|
||||
|
||||
/** PutItemInPocket: room exists if matching stacks can absorb the quantity
|
||||
* or a new slot is free; stacks then fill to 99 before a new one starts. */
|
||||
function putInPocket(
|
||||
pocket: { item: number; quantity: number }[],
|
||||
capacity: number,
|
||||
item: number,
|
||||
quantity: number,
|
||||
): boolean {
|
||||
let room = 0;
|
||||
const fits = pocket.some((slot) => {
|
||||
if (slot.item !== item) return false;
|
||||
room += MAX_ITEM_STACK - slot.quantity;
|
||||
return quantity <= room;
|
||||
});
|
||||
if (!fits && pocket.length >= capacity) return false;
|
||||
let remaining = quantity;
|
||||
for (const slot of pocket) {
|
||||
if (slot.item !== item) continue;
|
||||
const total = slot.quantity + remaining;
|
||||
if (total <= MAX_ITEM_STACK) {
|
||||
slot.quantity = total;
|
||||
return true;
|
||||
}
|
||||
slot.quantity = MAX_ITEM_STACK;
|
||||
remaining = total - MAX_ITEM_STACK;
|
||||
}
|
||||
pocket.push({ item, quantity: remaining });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** ReceiveItem into the pocket that ItemAttributes names. */
|
||||
export function receiveItem(
|
||||
data: PokemonData,
|
||||
bag: Bag,
|
||||
item: number,
|
||||
quantity: number,
|
||||
): boolean {
|
||||
if (!Number.isInteger(quantity) || quantity < 1 || quantity > MAX_ITEM_STACK)
|
||||
throw new Error("Invalid item quantity.");
|
||||
const pocket: Pocket = data.itemPocket(item);
|
||||
if (pocket === "item")
|
||||
return putInPocket(bag.items, MAX_ITEMS, item, quantity);
|
||||
if (pocket === "ball")
|
||||
return putInPocket(bag.balls, MAX_BALLS, item, quantity);
|
||||
if (pocket === "key") {
|
||||
// ReceiveKeyItem appends one entry regardless of the quantity.
|
||||
if (bag.keyItems.length >= MAX_KEY_ITEMS) return false;
|
||||
bag.keyItems.push(item);
|
||||
return true;
|
||||
}
|
||||
throw new Error("TM and HM items are not ported yet.");
|
||||
}
|
||||
|
||||
/** CheckItem across the pockets. */
|
||||
export function hasItem(bag: Bag, item: number): boolean {
|
||||
return (
|
||||
bag.items.some((slot) => slot.item === item) ||
|
||||
bag.balls.some((slot) => slot.item === item) ||
|
||||
bag.keyItems.includes(item)
|
||||
);
|
||||
}
|
||||
|
||||
export function validBag(data: PokemonData, value: unknown): value is Bag {
|
||||
const bag = value as Bag;
|
||||
const slots = (list: unknown, capacity: number, pocket: Pocket) =>
|
||||
Array.isArray(list) &&
|
||||
list.length <= capacity &&
|
||||
list.every(
|
||||
(slot) =>
|
||||
slot &&
|
||||
Number.isInteger(slot.item) &&
|
||||
Number.isInteger(slot.quantity) &&
|
||||
slot.quantity >= 1 &&
|
||||
slot.quantity <= MAX_ITEM_STACK &&
|
||||
itemIn(data, slot.item, pocket),
|
||||
);
|
||||
return (
|
||||
Boolean(bag) &&
|
||||
typeof bag === "object" &&
|
||||
slots(bag.items, MAX_ITEMS, "item") &&
|
||||
slots(bag.balls, MAX_BALLS, "ball") &&
|
||||
Array.isArray(bag.keyItems) &&
|
||||
bag.keyItems.length <= MAX_KEY_ITEMS &&
|
||||
bag.keyItems.every((item) => itemIn(data, item, "key"))
|
||||
);
|
||||
}
|
||||
function itemIn(data: PokemonData, item: unknown, pocket: Pocket) {
|
||||
try {
|
||||
return Number.isInteger(item) && data.itemPocket(Number(item)) === pocket;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** TossItem as `takeitem` uses it: only the first matching stack is used,
|
||||
* and taking more than it holds fails. */
|
||||
export function tossItem(bag: Bag, item: number, quantity: number): boolean {
|
||||
const key = bag.keyItems.indexOf(item);
|
||||
if (key >= 0) {
|
||||
bag.keyItems.splice(key, 1);
|
||||
return true;
|
||||
}
|
||||
for (const pocket of [bag.items, bag.balls]) {
|
||||
const index = pocket.findIndex((slot) => slot.item === item);
|
||||
if (index < 0) continue;
|
||||
const slot = pocket[index];
|
||||
if (quantity > slot.quantity) return false;
|
||||
slot.quantity -= quantity;
|
||||
if (!slot.quantity) pocket.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
|
||||
/** A party Pokémon with the fields of the original party_struct. */
|
||||
export interface PartyMon {
|
||||
species: number;
|
||||
item: number;
|
||||
/** Move IDs; 0 marks an empty slot. */
|
||||
moves: [number, number, number, number];
|
||||
otId: number;
|
||||
ot: string;
|
||||
nickname: string;
|
||||
exp: number;
|
||||
/** HP, Attack, Defense, Speed, Special stat experience. */
|
||||
statExp: [number, number, number, number, number];
|
||||
/** Attack, Defense, Speed, Special DVs as four nibbles. */
|
||||
dvs: number;
|
||||
pp: [number, number, number, number];
|
||||
happiness: number;
|
||||
pokerus: number;
|
||||
/** Time of day and level, then gender and landmark (SetCaughtData). */
|
||||
caughtData: number;
|
||||
level: number;
|
||||
status: number;
|
||||
hp: number;
|
||||
/** Max HP, Attack, Defense, Speed, Sp. Atk, Sp. Def. */
|
||||
stats: [number, number, number, number, number, number];
|
||||
}
|
||||
|
||||
export const PARTY_LENGTH = 6;
|
||||
/** Characters a nickname can hold: the naming screen's letters and
|
||||
* symbols, plus the digits and apostrophes of default species names. */
|
||||
export const NICKNAME_PATTERN = /^[A-Za-z0-9 ()":;[\]?!♂♀/.,×'é-]{1,10}$/u;
|
||||
/** BASE_HAPPINESS */
|
||||
const BASE_HAPPINESS = 70;
|
||||
const MAX_STAT_VALUE = 999;
|
||||
|
||||
/** GetSquareRoot: the first of 1..255 whose square is not below `value`. */
|
||||
function squareRoot(value: number) {
|
||||
for (let root = 1; root < 255; root++) if (root * root >= value) return root;
|
||||
return 255;
|
||||
}
|
||||
|
||||
/** DVs per stat; the HP DV is built from the low bits of the others. */
|
||||
export function statDv(dvs: number, stat: number): number {
|
||||
const attack = (dvs >> 12) & 15,
|
||||
defense = (dvs >> 8) & 15,
|
||||
speed = (dvs >> 4) & 15,
|
||||
special = dvs & 15;
|
||||
if (stat === 0)
|
||||
return (
|
||||
((attack & 1) << 3) |
|
||||
((defense & 1) << 2) |
|
||||
((speed & 1) << 1) |
|
||||
(special & 1)
|
||||
);
|
||||
return [attack, defense, speed, special, special][stat - 1];
|
||||
}
|
||||
|
||||
/** CalcMonStatC for stat 0-5 (HP, Atk, Def, Spd, SpAtk, SpDef). */
|
||||
export function calcStat(
|
||||
base: number,
|
||||
dvs: number,
|
||||
stat: number,
|
||||
level: number,
|
||||
statExp: readonly number[] | undefined,
|
||||
): number {
|
||||
// Special attack and defense share the Special stat experience.
|
||||
const experience = statExp ? statExp[Math.min(stat, 4)] : 0;
|
||||
const bonus = statExp ? Math.floor(squareRoot(experience) / 4) : 0;
|
||||
const value = Math.floor(
|
||||
(((base + statDv(dvs, stat)) * 2 + bonus) * level) / 100,
|
||||
);
|
||||
return Math.min(MAX_STAT_VALUE, value + (stat === 0 ? level + 10 : 5));
|
||||
}
|
||||
|
||||
export function calcStats(
|
||||
data: PokemonData,
|
||||
species: number,
|
||||
dvs: number,
|
||||
level: number,
|
||||
statExp?: readonly number[],
|
||||
): PartyMon["stats"] {
|
||||
const base = data.baseData(species).stats;
|
||||
return base.map((value, stat) =>
|
||||
calcStat(value, dvs, stat, level, statExp),
|
||||
) as PartyMon["stats"];
|
||||
}
|
||||
|
||||
/** FillMoves for a new Pokémon: learn every move up to its level, skipping
|
||||
* repeats and shifting out the oldest move when all slots are full. */
|
||||
export function initialMoves(
|
||||
data: PokemonData,
|
||||
species: number,
|
||||
level: number,
|
||||
): PartyMon["moves"] {
|
||||
const moves: number[] = [];
|
||||
for (const learned of data.learnset(species)) {
|
||||
if (learned.level > level) break;
|
||||
if (moves.includes(learned.move)) continue;
|
||||
if (moves.length === 4) moves.shift();
|
||||
moves.push(learned.move);
|
||||
}
|
||||
while (moves.length < 4) moves.push(0);
|
||||
return moves as PartyMon["moves"];
|
||||
}
|
||||
|
||||
/** GeneratePartyMonStats for a Pokémon given by a script (not a wild or
|
||||
* trainer battle): random DVs, level-up moves, full PP and HP. */
|
||||
export function generatePartyMon(
|
||||
data: PokemonData,
|
||||
options: {
|
||||
species: number;
|
||||
level: number;
|
||||
item: number;
|
||||
otId: number;
|
||||
ot: string;
|
||||
dvs: number;
|
||||
caughtData: number;
|
||||
},
|
||||
): PartyMon {
|
||||
const { species, level } = options;
|
||||
if (!Number.isInteger(level) || level < 1 || level > 100)
|
||||
throw new Error("Invalid level.");
|
||||
const moves = initialMoves(data, species, level);
|
||||
const stats = calcStats(data, species, options.dvs, level);
|
||||
return {
|
||||
species,
|
||||
item: options.item,
|
||||
moves,
|
||||
otId: options.otId,
|
||||
ot: options.ot,
|
||||
nickname: data.speciesName(species),
|
||||
exp: data.expAtLevel(data.baseData(species).growthRate, level),
|
||||
statExp: [0, 0, 0, 0, 0],
|
||||
dvs: options.dvs,
|
||||
pp: moves.map((move) => (move ? data.move(move).pp : 0)) as PartyMon["pp"],
|
||||
happiness: BASE_HAPPINESS,
|
||||
pokerus: 0,
|
||||
caughtData: options.caughtData,
|
||||
level,
|
||||
status: 0,
|
||||
hp: stats[0],
|
||||
stats,
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate a stored Pokémon against the ROM's rules before restoring it. */
|
||||
export function validPartyMon(
|
||||
data: PokemonData,
|
||||
value: unknown,
|
||||
): value is PartyMon {
|
||||
const mon = value as PartyMon;
|
||||
const integer = (value: unknown, min: number, max: number): value is number =>
|
||||
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
|
||||
const tuple = (value: unknown, length: number, max: number) =>
|
||||
Array.isArray(value) &&
|
||||
value.length === length &&
|
||||
value.every((item) => integer(item, 0, max));
|
||||
try {
|
||||
if (
|
||||
!mon ||
|
||||
typeof mon !== "object" ||
|
||||
!integer(mon.species, 1, 251) ||
|
||||
!integer(mon.item, 0, 255) ||
|
||||
!tuple(mon.moves, 4, 251) ||
|
||||
!integer(mon.otId, 0, 0xffff) ||
|
||||
typeof mon.ot !== "string" ||
|
||||
!/^[A-Za-z0-9 -]{1,7}$/.test(mon.ot) ||
|
||||
typeof mon.nickname !== "string" ||
|
||||
!NICKNAME_PATTERN.test(mon.nickname) ||
|
||||
!integer(mon.exp, 0, 0xffffff) ||
|
||||
!tuple(mon.statExp, 5, 0xffff) ||
|
||||
!integer(mon.dvs, 0, 0xffff) ||
|
||||
!tuple(mon.pp, 4, 255) ||
|
||||
!integer(mon.happiness, 0, 255) ||
|
||||
!integer(mon.pokerus, 0, 255) ||
|
||||
!integer(mon.caughtData, 0, 0xffff) ||
|
||||
!integer(mon.level, 1, 100) ||
|
||||
!integer(mon.status, 0, 255) ||
|
||||
!tuple(mon.stats, 6, MAX_STAT_VALUE) ||
|
||||
!integer(mon.hp, 0, mon.stats[0])
|
||||
)
|
||||
return false;
|
||||
// Stored stats must be the ones the game would calculate.
|
||||
const stats = calcStats(data, mon.species, mon.dvs, mon.level, mon.statExp);
|
||||
return stats.every((stat, index) => stat === mon.stats[index]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { CrystalAssets } from "../rom/assets";
|
||||
import type { GameWorld } from "../world/world";
|
||||
import { drawTile } from "./pixels";
|
||||
|
||||
/** A local-area atlas decoded in memory from the same tiles as the world. */
|
||||
export class LocalMapView {
|
||||
private terrain = document.createElement("canvas");
|
||||
private assets?: CrystalAssets;
|
||||
private mapKey = "";
|
||||
|
||||
constructor(
|
||||
private mini: HTMLCanvasElement,
|
||||
private full: HTMLCanvasElement,
|
||||
) {}
|
||||
|
||||
update(world: GameWorld) {
|
||||
const map = world.map;
|
||||
const key = `${map.id}:${world.timeOfDay}`;
|
||||
if (this.assets !== world.assets || this.mapKey !== key) {
|
||||
this.buildTerrain(world);
|
||||
this.assets = world.assets;
|
||||
this.mapKey = key;
|
||||
}
|
||||
this.draw(this.mini, world, 160, 128);
|
||||
this.draw(
|
||||
this.full,
|
||||
world,
|
||||
this.terrain.width + 16,
|
||||
this.terrain.height + 16,
|
||||
);
|
||||
}
|
||||
|
||||
private buildTerrain(world: GameWorld) {
|
||||
const { map, assets } = world;
|
||||
// The supported maps are small; reject unbounded buffers if profiles grow.
|
||||
const width = map.width * 16;
|
||||
const height = map.height * 16;
|
||||
if (width < 1 || height < 1 || width > 2048 || height > 2048)
|
||||
throw new Error("This area is too large for the local map.");
|
||||
this.terrain.width = width;
|
||||
this.terrain.height = height;
|
||||
const context = this.terrain.getContext("2d", { alpha: false });
|
||||
if (!context) throw new Error("Map rendering is unavailable.");
|
||||
const image = context.createImageData(width, height);
|
||||
const tileset = assets.tileset(map);
|
||||
const palettes = assets.palettes(map, world.timeOfDay);
|
||||
for (let y = 0; y < height / 8; y++) {
|
||||
for (let x = 0; x < width / 8; x++) {
|
||||
const block = assets.block(map, Math.floor(x / 2), Math.floor(y / 2));
|
||||
const encoded = tileset.metatiles[block * 16 + (y % 4) * 4 + (x % 4)];
|
||||
const packed = tileset.paletteMap[encoded >> 1];
|
||||
const attribute = encoded & 1 ? packed >> 4 : packed & 15;
|
||||
drawTile(
|
||||
image.data,
|
||||
width,
|
||||
height,
|
||||
tileset.graphics,
|
||||
(encoded & 0x7f) + (attribute & 8 ? 0x60 : 0),
|
||||
x * 8,
|
||||
y * 8,
|
||||
palettes[attribute & 7],
|
||||
);
|
||||
}
|
||||
}
|
||||
context.putImageData(image, 0, 0);
|
||||
}
|
||||
|
||||
private draw(
|
||||
canvas: HTMLCanvasElement,
|
||||
world: GameWorld,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
if (canvas.width !== width) canvas.width = width;
|
||||
if (canvas.height !== height) canvas.height = height;
|
||||
const context = canvas.getContext("2d", { alpha: false });
|
||||
if (!context) throw new Error("Map rendering is unavailable.");
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.fillStyle = "#f8f4df";
|
||||
context.fillRect(0, 0, width, height);
|
||||
const scale = Math.min(
|
||||
(width - 16) / this.terrain.width,
|
||||
(height - 16) / this.terrain.height,
|
||||
);
|
||||
const mapWidth = this.terrain.width * scale;
|
||||
const mapHeight = this.terrain.height * scale;
|
||||
const left = (width - mapWidth) / 2;
|
||||
const top = (height - mapHeight) / 2;
|
||||
context.drawImage(this.terrain, left, top, mapWidth, mapHeight);
|
||||
const x = Math.round(left + (world.x * 16 + 8) * scale);
|
||||
const y = Math.round(top + (world.y * 16 + 8) * scale);
|
||||
// An outlined marker remains readable over both bright and dark terrain.
|
||||
context.fillStyle = "#fffdf4";
|
||||
context.fillRect(x - 4, y - 4, 9, 9);
|
||||
context.fillStyle = "#24283d";
|
||||
context.fillRect(x - 3, y - 3, 7, 7);
|
||||
context.fillStyle = "#d33b49";
|
||||
context.fillRect(x - 2, y - 2, 5, 5);
|
||||
canvas.dataset.map = world.mapId;
|
||||
canvas.dataset.x = String(world.x);
|
||||
canvas.dataset.y = String(world.y);
|
||||
canvas.setAttribute("role", "img");
|
||||
canvas.setAttribute(
|
||||
"aria-label",
|
||||
`${world.map.name}. You are at ${world.x}, ${world.y}, shown in red.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+100
-40
@@ -2,16 +2,26 @@ import { CrystalAssets } from "../rom/assets";
|
||||
import { GameWorld } from "../world/world";
|
||||
import type { Direction } from "../world/types";
|
||||
import { drawTile } from "./pixels";
|
||||
import {
|
||||
BASE_VIEWPORT,
|
||||
renderViewportForBox,
|
||||
type RenderViewport,
|
||||
} from "./viewport";
|
||||
|
||||
export class WorldRenderer {
|
||||
readonly width = 160;
|
||||
readonly height = 144;
|
||||
width: number = BASE_VIEWPORT.width;
|
||||
height: number = BASE_VIEWPORT.height;
|
||||
private context: CanvasRenderingContext2D;
|
||||
private image: ImageData;
|
||||
private canvas: HTMLCanvasElement;
|
||||
constructor(
|
||||
private assets: CrystalAssets,
|
||||
canvas: HTMLCanvasElement,
|
||||
) {
|
||||
this.canvas = canvas;
|
||||
const viewport = this.viewportFromCanvas();
|
||||
this.width = viewport.width;
|
||||
this.height = viewport.height;
|
||||
canvas.width = this.width;
|
||||
canvas.height = this.height;
|
||||
const context = canvas.getContext("2d", { alpha: false });
|
||||
@@ -19,19 +29,48 @@ export class WorldRenderer {
|
||||
this.context = context;
|
||||
this.image = context.createImageData(this.width, this.height);
|
||||
}
|
||||
render(
|
||||
world: GameWorld,
|
||||
playerX = world.x * 16,
|
||||
playerY = world.y * 16,
|
||||
frame = 0,
|
||||
) {
|
||||
resize(cssWidth: number, cssHeight: number) {
|
||||
// ResizeObserver reports zero-sized boxes while an ancestor is hidden.
|
||||
// Retaining the last usable viewport avoids destroying the current frame.
|
||||
if (
|
||||
!Number.isFinite(cssWidth) ||
|
||||
!Number.isFinite(cssHeight) ||
|
||||
cssWidth <= 0 ||
|
||||
cssHeight <= 0
|
||||
)
|
||||
return false;
|
||||
const viewport = renderViewportForBox(cssWidth, cssHeight);
|
||||
if (viewport.width === this.width && viewport.height === this.height)
|
||||
return false;
|
||||
this.width = viewport.width;
|
||||
this.height = viewport.height;
|
||||
this.canvas.width = this.width;
|
||||
this.canvas.height = this.height;
|
||||
this.image = this.context.createImageData(this.width, this.height);
|
||||
return true;
|
||||
}
|
||||
private viewportFromCanvas(): RenderViewport {
|
||||
try {
|
||||
const box = this.canvas.getBoundingClientRect();
|
||||
return renderViewportForBox(box.width, box.height);
|
||||
} catch {
|
||||
return { ...BASE_VIEWPORT, pixelScale: 3 };
|
||||
}
|
||||
}
|
||||
render(world: GameWorld) {
|
||||
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);
|
||||
const poses = world.poses();
|
||||
const player = poses.find((pose) => pose.id === 0) ?? {
|
||||
x: world.x * 16,
|
||||
y: world.y * 16,
|
||||
};
|
||||
// Keep the camera player-centered as the original engine does. Beyond the
|
||||
// edges, connected maps are drawn like the original connection strips;
|
||||
// elsewhere the map's own border block fills the view.
|
||||
const cameraX = Math.round(player.x - (this.width / 2 - 8)),
|
||||
cameraY = Math.round(player.y - (this.height / 2 - 8));
|
||||
this.image.data.fill(255);
|
||||
for (
|
||||
let tileY = Math.floor(cameraY / 8);
|
||||
@@ -43,7 +82,7 @@ export class WorldRenderer {
|
||||
tileX < Math.ceil((cameraX + this.width) / 8);
|
||||
tileX++
|
||||
) {
|
||||
const block = this.assets.block(
|
||||
const block = this.assets.visibleBlock(
|
||||
map,
|
||||
Math.floor(tileX / 2),
|
||||
Math.floor(tileY / 2),
|
||||
@@ -66,35 +105,56 @@ export class WorldRenderer {
|
||||
);
|
||||
}
|
||||
}
|
||||
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)
|
||||
const time = world.timeOfDay;
|
||||
// Temporary effect objects use PAL_OW_EMOTE.
|
||||
const effectPalette = this.assets.objectPalette(5, time);
|
||||
for (const pose of poses) {
|
||||
if (pose.shadow === undefined) continue;
|
||||
const shadow = this.assets.emote(8);
|
||||
for (const [x, flipX] of [
|
||||
[0, false],
|
||||
[8, true],
|
||||
] as const)
|
||||
drawTile(
|
||||
this.image.data,
|
||||
this.width,
|
||||
this.height,
|
||||
shadow,
|
||||
0,
|
||||
pose.x - cameraX + x,
|
||||
pose.y - cameraY - 4 + pose.shadow,
|
||||
effectPalette,
|
||||
{ transparent: true, flipX },
|
||||
);
|
||||
}
|
||||
for (const pose of [...poses].sort((a, b) => a.y - b.y))
|
||||
this.actor(
|
||||
actor.sprite,
|
||||
Math.round(actor.x - cameraX),
|
||||
Math.round(actor.y - cameraY - 4),
|
||||
actor.direction,
|
||||
actor.frame,
|
||||
world.timeOfDay,
|
||||
actor.palette,
|
||||
pose.sprite,
|
||||
pose.x - cameraX,
|
||||
pose.y - cameraY - 4 + (pose.lift ?? 0),
|
||||
pose.direction,
|
||||
pose.frame,
|
||||
time,
|
||||
pose.palette,
|
||||
);
|
||||
const emote =
|
||||
world.emote && poses.find((pose) => pose.id === world.emote!.object);
|
||||
if (emote) {
|
||||
// FacingEmote: a 2x2 bubble 16 pixels above the object.
|
||||
const graphics = this.assets.emote(world.emote!.emote);
|
||||
for (const piece of this.assets.facingTiles(0x14))
|
||||
drawTile(
|
||||
this.image.data,
|
||||
this.width,
|
||||
this.height,
|
||||
graphics,
|
||||
piece.tile - 0xf8,
|
||||
emote.x - cameraX + piece.x,
|
||||
emote.y - cameraY - 4 - 16 + piece.y,
|
||||
effectPalette,
|
||||
{ transparent: true },
|
||||
);
|
||||
}
|
||||
this.context.putImageData(this.image, 0, 0);
|
||||
}
|
||||
private actor(
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export const BASE_VIEWPORT = { width: 160, height: 144 } as const;
|
||||
export const MAX_VIEWPORT = { width: 480, height: 432 } as const;
|
||||
|
||||
export type RenderViewport = {
|
||||
width: number;
|
||||
height: number;
|
||||
pixelScale: number;
|
||||
};
|
||||
|
||||
const finitePositive = (value: number) => Number.isFinite(value) && value > 0;
|
||||
|
||||
/**
|
||||
* Selects a bounded low-resolution drawing buffer for a measured CSS box.
|
||||
* The same integer divisor is used on both axes so world pixels retain their
|
||||
* proportions. Detached and test canvases keep the original Game Boy view.
|
||||
*/
|
||||
export function renderViewportForBox(
|
||||
cssWidth: number,
|
||||
cssHeight: number,
|
||||
): RenderViewport {
|
||||
if (!finitePositive(cssWidth) || !finitePositive(cssHeight)) {
|
||||
return { ...BASE_VIEWPORT, pixelScale: 3 };
|
||||
}
|
||||
|
||||
const nominalScale = cssWidth < 600 ? 2 : 3;
|
||||
const pixelScale = Math.max(
|
||||
nominalScale,
|
||||
Math.ceil(cssWidth / MAX_VIEWPORT.width),
|
||||
Math.ceil(cssHeight / MAX_VIEWPORT.height),
|
||||
);
|
||||
return {
|
||||
width: Math.max(1, Math.round(cssWidth / pixelScale)),
|
||||
height: Math.max(1, Math.round(cssHeight / pixelScale)),
|
||||
pixelScale,
|
||||
};
|
||||
}
|
||||
+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) => {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { RomReader } from "./reader";
|
||||
import { decompress } from "./lz";
|
||||
import { NUM_ITEMS, NUM_POKEMON, SYMBOLS } from "./profile";
|
||||
import { defaultTextContext, readString } from "../scripts/text";
|
||||
import type { Palette } from "../world/types";
|
||||
|
||||
/** data/pokemon/base_stats: one 32-byte record per species. */
|
||||
export interface BaseData {
|
||||
species: number;
|
||||
/** HP, Attack, Defense, Speed, Sp. Atk, Sp. Def. */
|
||||
stats: readonly [number, number, number, number, number, number];
|
||||
types: readonly [number, number];
|
||||
catchRate: number;
|
||||
baseExp: number;
|
||||
items: readonly [number, number];
|
||||
genderRatio: number;
|
||||
eggCycles: number;
|
||||
/** Front picture width and height in tiles (5, 6 or 7). */
|
||||
picSize: number;
|
||||
growthRate: number;
|
||||
eggGroups: number;
|
||||
}
|
||||
export interface MoveData {
|
||||
id: number;
|
||||
effect: number;
|
||||
power: number;
|
||||
type: number;
|
||||
accuracy: number;
|
||||
pp: number;
|
||||
chance: number;
|
||||
}
|
||||
/** Item types, which select a bag pocket (constants/item_data_constants.asm). */
|
||||
export type Pocket = "item" | "key" | "ball" | "tm";
|
||||
const POCKETS: readonly Pocket[] = ["item", "key", "ball", "tm"];
|
||||
|
||||
/** Decoders for species, moves and items. All data is read from the
|
||||
* user's ROM on demand and cached in memory only. */
|
||||
export class PokemonData {
|
||||
private base = new Map<number, BaseData>();
|
||||
private names = new Map<string, string>();
|
||||
constructor(readonly reader: RomReader) {}
|
||||
|
||||
private checkSpecies(species: number) {
|
||||
if (!Number.isInteger(species) || species < 1 || species > NUM_POKEMON)
|
||||
throw new Error("Unknown Pokémon species.");
|
||||
}
|
||||
baseData(species: number): BaseData {
|
||||
this.checkSpecies(species);
|
||||
const cached = this.base.get(species);
|
||||
if (cached) return cached;
|
||||
const r = this.reader,
|
||||
p = SYMBOLS.baseData + (species - 1) * 32;
|
||||
const data: BaseData = {
|
||||
species: r.byte(p),
|
||||
stats: [1, 2, 3, 4, 5, 6].map((i) =>
|
||||
r.byte(p + i),
|
||||
) as unknown as BaseData["stats"],
|
||||
types: [r.byte(p + 7), r.byte(p + 8)],
|
||||
catchRate: r.byte(p + 9),
|
||||
baseExp: r.byte(p + 10),
|
||||
items: [r.byte(p + 11), r.byte(p + 12)],
|
||||
genderRatio: r.byte(p + 13),
|
||||
eggCycles: r.byte(p + 15),
|
||||
picSize: r.byte(p + 17) & 15,
|
||||
growthRate: r.byte(p + 22),
|
||||
eggGroups: r.byte(p + 23),
|
||||
};
|
||||
this.base.set(species, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Fixed-length names are padded with "@"; a full-length name has none. */
|
||||
speciesName(species: number): string {
|
||||
this.checkSpecies(species);
|
||||
return this.cachedName(`mon:${species}`, () => {
|
||||
const r = this.reader,
|
||||
p = SYMBOLS.pokemonNames + (species - 1) * 10;
|
||||
let bytes = 0;
|
||||
while (bytes < 10 && r.byte(p + bytes) !== 0x50) bytes++;
|
||||
const copy = new Uint8Array(bytes + 1);
|
||||
copy.set(r.slice(p, bytes));
|
||||
copy[bytes] = 0x50;
|
||||
return readString(new RomReader(copy), 0, defaultTextContext(""));
|
||||
});
|
||||
}
|
||||
/** Variable-length name lists are read like GetName: skip n - 1 names. */
|
||||
private listName(table: number, index: number): string {
|
||||
const r = this.reader;
|
||||
let p = table;
|
||||
for (let skipped = 1; skipped < index; skipped++) {
|
||||
let length = 0;
|
||||
while (r.byte(p++) !== 0x50)
|
||||
if (++length > 32) throw new Error("Invalid name list.");
|
||||
}
|
||||
return readString(r, p, defaultTextContext(""));
|
||||
}
|
||||
moveName(move: number): string {
|
||||
if (!Number.isInteger(move) || move < 1 || move > 251)
|
||||
throw new Error("Unknown move.");
|
||||
return this.cachedName(`move:${move}`, () =>
|
||||
this.listName(SYMBOLS.moveNames, move),
|
||||
);
|
||||
}
|
||||
itemName(item: number): string {
|
||||
this.checkItem(item);
|
||||
return this.cachedName(`item:${item}`, () =>
|
||||
this.listName(SYMBOLS.itemNames, item),
|
||||
);
|
||||
}
|
||||
private cachedName(key: string, read: () => string) {
|
||||
let name = this.names.get(key);
|
||||
if (name === undefined) {
|
||||
name = read();
|
||||
this.names.set(key, name);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
private checkItem(item: number) {
|
||||
if (!Number.isInteger(item) || item < 1 || item > NUM_ITEMS)
|
||||
throw new Error("Unknown item.");
|
||||
}
|
||||
/** ItemAttributes: price, held effect, parameter, property, pocket, menus. */
|
||||
itemPocket(item: number): Pocket {
|
||||
this.checkItem(item);
|
||||
const pocket =
|
||||
POCKETS[
|
||||
this.reader.byte(SYMBOLS.itemAttributes + (item - 1) * 7 + 5) - 1
|
||||
];
|
||||
if (!pocket) throw new Error("Item has no pocket.");
|
||||
return pocket;
|
||||
}
|
||||
pocketName(pocket: Pocket): string {
|
||||
const r = this.reader,
|
||||
bank = r.bank(SYMBOLS.itemPocketNames);
|
||||
return readString(
|
||||
r,
|
||||
r.near(SYMBOLS.itemPocketNames + POCKETS.indexOf(pocket) * 2, bank),
|
||||
defaultTextContext(""),
|
||||
);
|
||||
}
|
||||
|
||||
move(move: number): MoveData {
|
||||
if (!Number.isInteger(move) || move < 1 || move > 251)
|
||||
throw new Error("Unknown move.");
|
||||
const r = this.reader,
|
||||
p = SYMBOLS.moves + (move - 1) * 7;
|
||||
return {
|
||||
id: r.byte(p),
|
||||
effect: r.byte(p + 1),
|
||||
power: r.byte(p + 2),
|
||||
type: r.byte(p + 3),
|
||||
accuracy: r.byte(p + 4),
|
||||
pp: r.byte(p + 5),
|
||||
chance: r.byte(p + 6),
|
||||
};
|
||||
}
|
||||
|
||||
/** Level-up moves (data/pokemon/evos_attacks.asm). Like FillMoves, the
|
||||
* evolution list is skipped up to its terminating zero byte. */
|
||||
learnset(species: number): { level: number; move: number }[] {
|
||||
this.checkSpecies(species);
|
||||
const r = this.reader;
|
||||
let p = r.near(
|
||||
SYMBOLS.evosAttacks + (species - 1) * 2,
|
||||
r.bank(SYMBOLS.evosAttacks),
|
||||
);
|
||||
for (let guard = 0; r.byte(p++) !== 0; guard++)
|
||||
if (guard > 64) throw new Error("Invalid evolution data.");
|
||||
const moves: { level: number; move: number }[] = [];
|
||||
for (let level = r.byte(p); level !== 0; level = r.byte(p)) {
|
||||
moves.push({ level, move: r.byte(p + 1) });
|
||||
p += 2;
|
||||
if (moves.length > 64) throw new Error("Invalid learnset.");
|
||||
}
|
||||
return moves;
|
||||
}
|
||||
|
||||
/** CalcExpAtLevel: (a/b)n^3 + c n^2 + d n - e in 24-bit arithmetic,
|
||||
* including the original underflow for Medium Slow at level 1. */
|
||||
expAtLevel(growthRate: number, level: number): number {
|
||||
if (!Number.isInteger(growthRate) || growthRate < 0 || growthRate > 5)
|
||||
throw new Error("Unknown growth rate.");
|
||||
const r = this.reader,
|
||||
p = SYMBOLS.growthRates + growthRate * 4;
|
||||
const ab = r.byte(p),
|
||||
c = r.byte(p + 1),
|
||||
d = r.byte(p + 2),
|
||||
e = r.byte(p + 3);
|
||||
const cubic = Math.floor(((ab >> 4) * level ** 3) / (ab & 15)) & 0xffffff;
|
||||
const square = ((c & 0x7f) * level ** 2) & 0xffffff;
|
||||
const linear = (d * level - e) & 0xffffff;
|
||||
const quadratic = c & 0x80 ? linear - square : linear + square;
|
||||
return ((quadratic & 0xffffff) + cubic) & 0xffffff;
|
||||
}
|
||||
|
||||
/** The decompressed front picture: base frame, then animation tiles. */
|
||||
frontPicData(species: number): Uint8Array {
|
||||
this.checkSpecies(species);
|
||||
const r = this.reader,
|
||||
entry = SYMBOLS.pokemonPics + (species - 1) * 6;
|
||||
// FixPicBank: stored banks index a table whose first entry is "Pics 1".
|
||||
const pics1 = r.byte(SYMBOLS.picsBanks);
|
||||
const bank = r.byte(SYMBOLS.picsBanks + r.byte(entry) - (pics1 - 0x36));
|
||||
return decompress(r, r.address(bank, r.word(entry + 1)), 0x2000);
|
||||
}
|
||||
/** Front picture tiles, padded into the 7x7 box as PadFrontpic does:
|
||||
* column-major, bottom-aligned, with a blank left column for 5 and 6. */
|
||||
frontPic(species: number): Uint8Array {
|
||||
const size = this.baseData(species).picSize;
|
||||
if (size < 5 || size > 7) throw new Error("Invalid picture size.");
|
||||
const tiles = this.frontPicData(species);
|
||||
if (tiles.length < size * size * 16)
|
||||
throw new Error("Picture data is truncated.");
|
||||
const padded = new Uint8Array(49 * 16);
|
||||
const left = size === 7 ? 0 : 1;
|
||||
for (let column = 0; column < size; column++)
|
||||
padded.set(
|
||||
tiles.slice(column * size * 16, (column + 1) * size * 16),
|
||||
((column + left) * 7 + (7 - size)) * 16,
|
||||
);
|
||||
return padded;
|
||||
}
|
||||
/** The back picture: 6x6 tiles, column by column (GetMonBackpic). */
|
||||
backPic(species: number): Uint8Array {
|
||||
this.checkSpecies(species);
|
||||
const r = this.reader,
|
||||
entry = SYMBOLS.pokemonPics + (species - 1) * 6 + 3;
|
||||
const pics1 = r.byte(SYMBOLS.picsBanks);
|
||||
const bank = r.byte(SYMBOLS.picsBanks + r.byte(entry) - (pics1 - 0x36));
|
||||
const tiles = decompress(r, r.address(bank, r.word(entry + 1)), 0x1000);
|
||||
if (tiles.length < 36 * 16) throw new Error("Picture data is truncated.");
|
||||
return tiles.slice(0, 36 * 16);
|
||||
}
|
||||
/** Shininess from DVs (CheckShininess): Defense, Speed and Special 10,
|
||||
* and an Attack DV with bit 1 set. */
|
||||
static shiny(dvs: number): boolean {
|
||||
const attack = (dvs >> 12) & 15;
|
||||
return (dvs & 0x0fff) === 0x0aaa && Boolean(attack & 2);
|
||||
}
|
||||
/** Normal colors from PokemonPalettes, between white and black. */
|
||||
palette(species: number, shiny = false): Palette {
|
||||
this.checkSpecies(species);
|
||||
const r = this.reader,
|
||||
p = SYMBOLS.pokemonPalettes + species * 8 + (shiny ? 4 : 0);
|
||||
const color = (value: number) =>
|
||||
[
|
||||
Math.round(((value & 31) * 255) / 31),
|
||||
Math.round((((value >> 5) & 31) * 255) / 31),
|
||||
Math.round((((value >> 10) & 31) * 255) / 31),
|
||||
] as const;
|
||||
return [color(0x7fff), color(r.word(p)), color(r.word(p + 2)), color(0)];
|
||||
}
|
||||
}
|
||||
+205
-1
@@ -16,9 +16,213 @@ export const SYMBOLS = {
|
||||
sprites: 0x14736,
|
||||
facings: 0x4049,
|
||||
standardScripts: 0xbc000,
|
||||
mapScenes: 0x4d01e,
|
||||
specials: 0xc029,
|
||||
emotes: 0x1444d,
|
||||
landmarks: 0x1ca8c3,
|
||||
weekdayNames: 0x909f2,
|
||||
whatDayText: 0x90a3f,
|
||||
isItDayText: 0x90a4f,
|
||||
dstConfirmText: 0x90a83,
|
||||
timeConfirmText: 0x90ab7,
|
||||
baseData: 0x51424,
|
||||
pokemonNames: 0x53384,
|
||||
pokemonPics: 0x120000,
|
||||
picsBanks: 0x511d4,
|
||||
pokemonPalettes: 0xa8ce,
|
||||
growthRates: 0x50efa,
|
||||
evosAttacks: 0x425b1,
|
||||
moves: 0x41afb,
|
||||
moveNames: 0x1c9f29,
|
||||
itemNames: 0x1c8000,
|
||||
itemAttributes: 0x67c1,
|
||||
itemPocketNames: 0x9701c,
|
||||
giveItemScript: 0x96f77,
|
||||
putItemInPocketText: 0x9705b,
|
||||
pocketIsFullText: 0x97060,
|
||||
caughtAskNicknameText: 0x4db44,
|
||||
nicknameStrings: 0x11780,
|
||||
wildBattleScript: 0x97cf9,
|
||||
battleWhiteoutScript: 0x124c1,
|
||||
spawnPoints: 0x152ab,
|
||||
johtoGrassWildMons: 0x2a5e9,
|
||||
grassMonProbTable: 0x2a1cb,
|
||||
grassCollisions: 0x149ea,
|
||||
} as const;
|
||||
|
||||
/** The currently ported area. Story callbacks are not executed yet. */
|
||||
/** Assembly routines that scripts `callasm` but that do nothing. */
|
||||
export const EMPTY_ROUTINES = {
|
||||
giveItemDummy: 0x96f76,
|
||||
battleBGMap: 0x1250a,
|
||||
} as const;
|
||||
|
||||
/** Assembly routines that scripts `callasm`, translated natively. */
|
||||
export const NATIVE_ROUTINES = {
|
||||
halveMoney: 0x12513,
|
||||
getWhiteoutSpawn: 0x12527,
|
||||
} as const;
|
||||
|
||||
/** Native handlers are selected by the routine a special index points to,
|
||||
* so the ROM's own special table remains the source of truth. */
|
||||
export const SPECIAL_ROUTINES = {
|
||||
restartMapMusic: 0x3d47,
|
||||
setDayOfWeek: 0x90913,
|
||||
initialSetDst: 0x90a54,
|
||||
initialClearDst: 0x90a88,
|
||||
toggleDecorationsVisibility: 0x27043,
|
||||
toggleMaptileDecorations: 0x26feb,
|
||||
fadeOutToWhite: 0x8c084,
|
||||
healParty: 0xc658,
|
||||
warpToSpawnPoint: 0x97c28,
|
||||
} as const;
|
||||
|
||||
/** Work RAM addresses that the event and text formats name as operands. */
|
||||
export const RAM = {
|
||||
stringBuffer1: 0xd073,
|
||||
stringBuffer2: 0xd086,
|
||||
stringBuffer3: 0xd099,
|
||||
stringBuffer4: 0xd0ac,
|
||||
stringBuffer5: 0xd0bf,
|
||||
playerName: 0xd47d,
|
||||
momsName: 0xd488,
|
||||
rivalName: 0xd493,
|
||||
battleMonNickname: 0xc621,
|
||||
enemyMonNickname: 0xc616,
|
||||
curPartyLevel: 0xd143,
|
||||
monOrItemNameBuffer: 0xd050,
|
||||
} as const;
|
||||
|
||||
/** Event constants named directly by translated special routines. */
|
||||
export const EVENTS = {
|
||||
playersRoomPoster: 716,
|
||||
playersHouseConsole: 1857,
|
||||
playersHouseDoll1: 1858,
|
||||
playersHouseDoll2: 1859,
|
||||
playersHouseBigDoll: 1860,
|
||||
} as const;
|
||||
|
||||
/** Battle engine tables and texts, keyed by their PRET labels so that
|
||||
* \`npm run check:reference\` can verify each address by name. */
|
||||
export const BATTLE = {
|
||||
AccuracyLevelMultipliers: 0x34eb2,
|
||||
AlreadyAsleepText: 0x811c1,
|
||||
AlreadyConfusedText: 0x80dcc,
|
||||
AlreadyParalyzedText: 0x81592,
|
||||
AlreadyPoisonedText: 0x811fa,
|
||||
AlwaysFleeMons: 0x3c5b1,
|
||||
AskForgetMoveText: 0x667f,
|
||||
AttackMissed2Text: 0x81033,
|
||||
AttackMissedText: 0x81020,
|
||||
"BattleCommand_StatDownMessage.BattleStatFellText": 0x36415,
|
||||
"BattleCommand_StatDownMessage.BattleStatSharplyFellText": 0x36410,
|
||||
"BattleCommand_StatDownMessage.stat": 0x363fe,
|
||||
"BattleCommand_StatUpMessage.BattleStatWentUpText": 0x363e4,
|
||||
"BattleCommand_StatUpMessage.BattleStatWentWayUpText": 0x363df,
|
||||
"BattleCommand_StatUpMessage.stat": 0x363cd,
|
||||
BattleMonNicknameText: 0x3f2ef,
|
||||
BattleText_CantEscape: 0x80ba0,
|
||||
BattleText_CantEscape2: 0x80b3b,
|
||||
BattleText_EnemyMonFainted: 0x809a8,
|
||||
BattleText_GotAwaySafely: 0x80b77,
|
||||
BattleText_ItemHealedConfusion: 0x80dab,
|
||||
BattleText_MonFainted: 0x80a75,
|
||||
BattleText_MonHasNoMovesLeft: 0x80c72,
|
||||
BattleText_StringBuffer1GrewToLevel: 0x80c9c,
|
||||
BattleText_TargetRecoveredWithItem: 0x80880,
|
||||
BattleText_TheMoveIsDisabled: 0x80c5b,
|
||||
BattleText_TheresNoPPLeftForThisMove: 0x80c39,
|
||||
BattleText_WildFled: 0x807bd,
|
||||
BecameConfusedText: 0x80d97,
|
||||
BoostedExpPointsText: 0x3f12c,
|
||||
ButItFailedText: 0x814f0,
|
||||
ConfusedNoMoreText: 0x80d81,
|
||||
ConsumableEffects: 0x271de,
|
||||
CriticalHitChances: 0x346ab,
|
||||
CriticalHitMoves: 0x346a3,
|
||||
CriticalHitText: 0x81086,
|
||||
DefrostedOpponentText: 0x8124b,
|
||||
DidNotLearnMoveText: 0x667a,
|
||||
DidntAffect1Text: 0x8150c,
|
||||
DidntAffect2Text: 0x81520,
|
||||
DisabledMoveText: 0x80f02,
|
||||
DisabledNoMoreText: 0x80d39,
|
||||
DoesntAffectText: 0x81071,
|
||||
EnemyText: 0x1295,
|
||||
ExpPointsText: 0x3f131,
|
||||
FastAsleepText: 0x80ce7,
|
||||
FellAsleepText: 0x811b1,
|
||||
FlinchedText: 0x80d1a,
|
||||
FrozenSolidText: 0x80d06,
|
||||
FullyParalyzedText: 0x8157d,
|
||||
GoMonText: 0x3f2d1,
|
||||
HasNoPPLeftText: 0x80fba,
|
||||
HeldStatusHealingEffects: 0x3de44,
|
||||
HPIsFullText: 0x81534,
|
||||
HungOnText: 0x80e99,
|
||||
HurtByBurnText: 0x807f8,
|
||||
HurtByPoisonText: 0x807e2,
|
||||
HurtItselfText: 0x80d5f,
|
||||
IdentifiedText: 0x816b1,
|
||||
IsConfusedText: 0x80d4f,
|
||||
"IsHMMove.HMMoves": 0x34f0,
|
||||
ItemAttributes: 0x67c1,
|
||||
ItFailedText: 0x81500,
|
||||
LearnedMoveText: 0x666b,
|
||||
MoveAskForgetText: 0x6670,
|
||||
MoveCantForgetHMText: 0x669a,
|
||||
MoveEffectPriorities: 0x3c5df,
|
||||
MoveEffectsPointers: 0x271f4,
|
||||
MoveGrammar: 0x105e7a,
|
||||
MoveNameText: 0x105e23,
|
||||
"MoveNameText.endusedmovetexts": 0x105e39,
|
||||
MustRechargeText: 0x80d27,
|
||||
NoPPLeftText: 0x80f99,
|
||||
NothingHappenedText: 0x814d9,
|
||||
NotVeryEffectiveText: 0x810c1,
|
||||
OftenFleeMons: 0x3c5a8,
|
||||
OneHitKOText: 0x81097,
|
||||
ParalyzedText: 0x81558,
|
||||
"PrintTempMonStats.StatNames": 0x50bb5,
|
||||
ProtectedByMistText: 0x81321,
|
||||
ProtectedByText: 0x815a9,
|
||||
ProtectingItselfText: 0x8167a,
|
||||
RageBuildingText: 0x810f3,
|
||||
RecoveredUsingText: 0x80bc2,
|
||||
RegainedHealthText: 0x8100c,
|
||||
SafeguardProtectText: 0x81733,
|
||||
SometimesFleeMons: 0x3c59a,
|
||||
StatLevelMultipliers: 0x364e6,
|
||||
StatNames: 0x364a6,
|
||||
StopLearningMoveText: 0x6675,
|
||||
SuckedHealthText: 0x81210,
|
||||
SuperEffectiveText: 0x810aa,
|
||||
Text_1_2_and_Poof: 0x6684,
|
||||
"Text_1_2_and_Poof.MoveForgotText": 0x6695,
|
||||
Text_MonGainedExpPoint: 0x3f11b,
|
||||
TypeBoostItems: 0x35703,
|
||||
TypeMatchups: 0x34bb1,
|
||||
UnaffectedText: 0x81061,
|
||||
UsedMove1Text: 0x105e04,
|
||||
UsedMove2Text: 0x105e0b,
|
||||
UsedMoveText: 0x105db9,
|
||||
"UsedMoveText_CheckObedience.UsedInsteadText": 0x105e1a,
|
||||
WasBurnedText: 0x8123c,
|
||||
WasPoisonedText: 0x811d5,
|
||||
WildPokemonAppearedText: 0x80746,
|
||||
WokeUpText: 0x80cfa,
|
||||
WontDropAnymoreText: 0x8128f,
|
||||
WontRiseAnymoreText: 0x81272,
|
||||
} as const;
|
||||
|
||||
export const MAP_GROUP_COUNT = 26;
|
||||
export const NUM_POKEMON = 251;
|
||||
export const NUM_ITEMS = 190;
|
||||
/** The last group has no following pointer to bound it. */
|
||||
export const LAST_MAP_GROUP_SIZE = 11;
|
||||
|
||||
/** Areas whose rules, scripts and presentation are ported. Other decoded
|
||||
* maps remain unavailable; the label only distinguishes interiors that share
|
||||
* a ROM landmark name. */
|
||||
export const NATIVE_MAPS: Record<string, string> = {
|
||||
"24:4": "New Bark Town",
|
||||
"24:5": "Elm’s lab",
|
||||
|
||||
+136
-64
@@ -1,7 +1,8 @@
|
||||
import type { GameRuntime } from "../app/runtime";
|
||||
import type { Button } from "../input/types";
|
||||
import { CrystalAssets } from "./rom/assets";
|
||||
import { GameWorld } from "./world/world";
|
||||
import { GameWorld, TICKS_PER_SECOND } from "./world/world";
|
||||
import type { BattleAction } from "./battle/battle";
|
||||
import type { Direction } from "./world/types";
|
||||
import { WorldRenderer } from "./rendering/renderer";
|
||||
|
||||
@@ -12,9 +13,10 @@ export class NativeRuntime implements GameRuntime {
|
||||
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;
|
||||
/** Seconds not yet consumed by whole overworld ticks. */
|
||||
private pending = 0;
|
||||
private published = "";
|
||||
private resizeObserver?: ResizeObserver;
|
||||
constructor(
|
||||
private onUpdate: (world: GameWorld) => void,
|
||||
private onError: (error: unknown) => void,
|
||||
@@ -29,6 +31,7 @@ export class NativeRuntime implements GameRuntime {
|
||||
world.avatar = player.avatar;
|
||||
this.world = world;
|
||||
this.renderer = new WorldRenderer(assets, canvas);
|
||||
this.observeCanvas(canvas);
|
||||
this.publish();
|
||||
}
|
||||
play() {
|
||||
@@ -41,8 +44,7 @@ export class NativeRuntime implements GameRuntime {
|
||||
this.raf = 0;
|
||||
this.lastTime = 0;
|
||||
this.pressed.clear();
|
||||
this.elapsed = 0;
|
||||
this.animation = undefined;
|
||||
this.pending = 0;
|
||||
if (this.world && this.renderer) this.renderer.render(this.world);
|
||||
}
|
||||
setButton(button: Button, pressed: boolean) {
|
||||
@@ -52,84 +54,154 @@ export class NativeRuntime implements GameRuntime {
|
||||
}
|
||||
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;
|
||||
const world = this.world;
|
||||
if (button === "a") world.interact();
|
||||
if (button === "b") world.advanceDialogue();
|
||||
if ((button === "up" || button === "down") && world.dialogue?.choice)
|
||||
world.moveCursor(button);
|
||||
if ((button === "start" || button === "select") && !world.busy)
|
||||
world.notice =
|
||||
"Use the Savegames tab to save, export or import. Party, pack and Pokégear menus are not ported yet.";
|
||||
this.publish();
|
||||
}
|
||||
/** Answer a choice from a pointer or keyboard control outside the canvas. */
|
||||
choose(answer: number | string) {
|
||||
this.world?.choose(answer);
|
||||
this.publish();
|
||||
}
|
||||
advanceDialogue() {
|
||||
this.world?.advanceDialogue();
|
||||
this.publish();
|
||||
}
|
||||
battleAction(action: BattleAction) {
|
||||
this.world?.battleAction(action);
|
||||
this.publish();
|
||||
}
|
||||
battleChoose(answer: number) {
|
||||
this.world?.battleChoose(answer);
|
||||
this.publish();
|
||||
}
|
||||
private tick = (now: number) => {
|
||||
try {
|
||||
const world = this.world!;
|
||||
const delta = this.lastTime
|
||||
? Math.min((now - this.lastTime) / 1000, 0.05)
|
||||
? Math.min((now - this.lastTime) / 1000, 0.1)
|
||||
: 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]
|
||||
this.pending += delta;
|
||||
// The original overworld advances once every two video frames.
|
||||
const held = [...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);
|
||||
let ticks = 0;
|
||||
while (this.pending >= 1 / TICKS_PER_SECOND && ticks < 6) {
|
||||
this.pending -= 1 / TICKS_PER_SECOND;
|
||||
world.tick(held);
|
||||
ticks++;
|
||||
}
|
||||
if (ticks === 6) this.pending = 0;
|
||||
if (ticks) {
|
||||
this.renderer!.render(world);
|
||||
this.publishIfChanged();
|
||||
}
|
||||
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);
|
||||
this.fail(error);
|
||||
}
|
||||
};
|
||||
/** Presentation changes that the DOM shows, without per-tick churn. */
|
||||
private publishIfChanged() {
|
||||
const world = this.world!;
|
||||
const dialogue = world.dialogue;
|
||||
const signature = JSON.stringify([
|
||||
world.mapId,
|
||||
world.x,
|
||||
world.y,
|
||||
world.tilesWalked,
|
||||
world.notice,
|
||||
world.activity,
|
||||
world.picture,
|
||||
world.battle && [
|
||||
world.battle.message,
|
||||
world.battle.prompt?.kind,
|
||||
world.battle.selected,
|
||||
world.battle.engine.player.mon?.hp,
|
||||
world.battle.engine.enemy.mon?.hp,
|
||||
world.battle.engine.player.mon?.status,
|
||||
world.battle.engine.enemy.mon?.status,
|
||||
world.battle.engine.player.mon?.level,
|
||||
],
|
||||
dialogue?.index,
|
||||
dialogue?.pages,
|
||||
dialogue?.choice?.selected,
|
||||
dialogue?.holdTicks === undefined,
|
||||
]);
|
||||
if (signature === this.published) return;
|
||||
this.published = signature;
|
||||
this.onUpdate(world);
|
||||
}
|
||||
private publish() {
|
||||
if (this.world) {
|
||||
this.renderer?.render(this.world);
|
||||
this.onUpdate(this.world);
|
||||
this.published = "";
|
||||
this.publishIfChanged();
|
||||
}
|
||||
}
|
||||
private observeCanvas(canvas: HTMLCanvasElement) {
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
try {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
try {
|
||||
if (this.resizeObserver !== observer) return;
|
||||
const entry = entries.find(
|
||||
(candidate) => candidate.target === canvas,
|
||||
);
|
||||
if (!entry || !this.renderer) return;
|
||||
const resized = this.renderer.resize(
|
||||
entry.contentRect.width,
|
||||
entry.contentRect.height,
|
||||
);
|
||||
// A playing session redraws on its next animation frame using the
|
||||
// current interpolated position. Paused sessions need an immediate
|
||||
// redraw because no frame is scheduled.
|
||||
if (resized && this.world && !this.raf) {
|
||||
this.renderer.render(this.world);
|
||||
}
|
||||
} catch (error) {
|
||||
this.fail(error);
|
||||
}
|
||||
});
|
||||
observer.observe(canvas);
|
||||
this.resizeObserver = observer;
|
||||
} catch {
|
||||
// Resize support is an enhancement; loading remains usable if an older
|
||||
// browser or test double rejects observation.
|
||||
this.resizeObserver = undefined;
|
||||
}
|
||||
}
|
||||
private fail(error: unknown) {
|
||||
this.disconnectResizeObserver();
|
||||
cancelAnimationFrame(this.raf);
|
||||
this.raf = 0;
|
||||
this.lastTime = 0;
|
||||
this.pressed.clear();
|
||||
this.pending = 0;
|
||||
try {
|
||||
this.onError(error);
|
||||
} catch {
|
||||
// Host error UI must not create an unhandled frame or observer error.
|
||||
}
|
||||
}
|
||||
private disconnectResizeObserver() {
|
||||
try {
|
||||
this.resizeObserver?.disconnect();
|
||||
} catch {
|
||||
// A broken observer should not prevent teardown.
|
||||
}
|
||||
this.resizeObserver = undefined;
|
||||
}
|
||||
captureState() {
|
||||
if (!this.world) throw new Error("Load your ROM first.");
|
||||
return new TextEncoder().encode(JSON.stringify(this.world.save()));
|
||||
@@ -140,11 +212,11 @@ export class NativeRuntime implements GameRuntime {
|
||||
this.world.restore(
|
||||
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
|
||||
);
|
||||
this.animation = undefined;
|
||||
this.pressed.clear();
|
||||
this.publish();
|
||||
}
|
||||
dispose() {
|
||||
this.disconnectResizeObserver();
|
||||
this.pause();
|
||||
this.world = undefined;
|
||||
this.renderer = undefined;
|
||||
|
||||
+870
-182
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,75 @@
|
||||
import { RomReader } from "../rom/reader";
|
||||
import { DIRECTION_ORDER, type Direction } from "../world/types";
|
||||
|
||||
/** One command from the game's own movement data (macros/scripts/movement.asm).
|
||||
* Durations are overworld ticks: the original loop advances objects once per
|
||||
* two video frames. Commands without a duration take effect immediately. */
|
||||
export type MovementStep =
|
||||
| { kind: "turn"; direction: Direction; ticks: 1 }
|
||||
| { kind: "step"; direction: Direction; ticks: number; animate: boolean }
|
||||
/** Two tiles in two phases of `ticks` each, with a shadow and arc. */
|
||||
| { kind: "jump"; direction: Direction; ticks: number }
|
||||
| { kind: "sleep"; ticks: number }
|
||||
| { kind: "fix-facing"; fixed: boolean }
|
||||
| { kind: "visibility"; hidden: boolean }
|
||||
| { kind: "emote"; shown: boolean };
|
||||
|
||||
export class UnsupportedMovement extends Error {
|
||||
constructor(
|
||||
readonly offset: number,
|
||||
readonly command: number,
|
||||
) {
|
||||
super(
|
||||
`Movement command 0x${command.toString(16)} at 0x${offset.toString(16)} is not ported.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const STEP_TICKS = [16, 8, 4] as const; // slow, walk, big (bike speed)
|
||||
|
||||
/** Decode movement data until step_end. Unknown commands abort the script
|
||||
* rather than approximating a jump, slide or special effect. */
|
||||
export function readMovement(reader: RomReader, start: number): MovementStep[] {
|
||||
const steps: MovementStep[] = [];
|
||||
let p = start;
|
||||
for (let count = 0; count < 256; count++) {
|
||||
const offset = p,
|
||||
command = reader.byte(p++);
|
||||
const direction = DIRECTION_ORDER[command & 3];
|
||||
if (command < 0x04) steps.push({ kind: "turn", direction, ticks: 1 });
|
||||
else if (command >= 0x08 && command < 0x14)
|
||||
steps.push({
|
||||
kind: "step",
|
||||
direction,
|
||||
ticks: STEP_TICKS[(command - 0x08) >> 2],
|
||||
animate: true,
|
||||
});
|
||||
else if (command >= 0x14 && command < 0x20)
|
||||
// Slides move without the walking animation (e.g. being pushed).
|
||||
steps.push({
|
||||
kind: "step",
|
||||
direction,
|
||||
ticks: STEP_TICKS[(command - 0x14) >> 2],
|
||||
animate: false,
|
||||
});
|
||||
else if (command >= 0x2c && command < 0x38)
|
||||
steps.push({
|
||||
kind: "jump",
|
||||
direction,
|
||||
ticks: STEP_TICKS[(command - 0x2c) >> 2],
|
||||
});
|
||||
else if (command === 0x3a || command === 0x3b)
|
||||
steps.push({ kind: "fix-facing", fixed: command === 0x3b });
|
||||
else if (command === 0x3c || command === 0x3d)
|
||||
steps.push({ kind: "visibility", hidden: command === 0x3d });
|
||||
else if (command >= 0x3e && command <= 0x45)
|
||||
steps.push({ kind: "sleep", ticks: command - 0x3d });
|
||||
else if (command === 0x46)
|
||||
steps.push({ kind: "sleep", ticks: reader.byte(p++) });
|
||||
else if (command === 0x47) return steps;
|
||||
else if (command === 0x53 || command === 0x54)
|
||||
steps.push({ kind: "emote", shown: command === 0x54 });
|
||||
else throw new UnsupportedMovement(offset, command);
|
||||
}
|
||||
throw new Error("Movement data is unterminated.");
|
||||
}
|
||||
+185
-32
@@ -1,17 +1,16 @@
|
||||
import { RomReader } from "../rom/reader";
|
||||
import { RAM } from "../rom/profile";
|
||||
|
||||
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: "……",
|
||||
@@ -68,6 +67,42 @@ const CHARACTERS: Record<number, string> = {
|
||||
0xf5: "♀",
|
||||
};
|
||||
|
||||
/** Values that original text reads from work RAM, supplied by native state. */
|
||||
export interface TextContext {
|
||||
player: string;
|
||||
rival: string;
|
||||
mom: string;
|
||||
/** Native strings stored at the RAM addresses named by text_ram. */
|
||||
strings: Readonly<Record<number, string>>;
|
||||
weekday: string;
|
||||
/** Numbers that text_decimal reads from RAM, by address. */
|
||||
numbers?: Readonly<Record<number, number>>;
|
||||
/** Battle names for <USER> and <TARGET>: whose turn it is, and the
|
||||
* enemy prefix the game prints before the enemy's nickname. */
|
||||
battle?: { turn: number; player: string; enemy: string; enemyPrefix: string };
|
||||
/** Native handlers for text_asm, keyed by the command's ROM offset. They
|
||||
* return where text continues, as the original routine returns in hl. */
|
||||
asm?: (offset: number) => number;
|
||||
}
|
||||
|
||||
export const defaultTextContext = (player: string): TextContext => ({
|
||||
player,
|
||||
// New games initialize these names before the player can choose them.
|
||||
rival: "???",
|
||||
mom: "MOM",
|
||||
strings: {},
|
||||
weekday: "",
|
||||
});
|
||||
|
||||
/** text_buffer indices select these buffers (data/text_buffers.asm). */
|
||||
const STRING_BUFFERS = [
|
||||
RAM.stringBuffer3,
|
||||
RAM.stringBuffer4,
|
||||
RAM.stringBuffer5,
|
||||
RAM.stringBuffer2,
|
||||
RAM.stringBuffer1,
|
||||
];
|
||||
|
||||
export class UnsupportedText extends Error {
|
||||
constructor(
|
||||
readonly offset: number,
|
||||
@@ -79,49 +114,167 @@ export class UnsupportedText extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function battler(context: TextContext, enemy: boolean): string | undefined {
|
||||
const names = context.battle;
|
||||
if (!names) return undefined;
|
||||
return enemy ? names.enemyPrefix + names.enemy : names.player;
|
||||
}
|
||||
|
||||
function character(code: number, context: TextContext): string | undefined {
|
||||
// PlaceMoveTargetsName / PlaceMoveUsersName
|
||||
if (code === 0x59) return battler(context, context.battle?.turn === 0);
|
||||
if (code === 0x5a) return battler(context, context.battle?.turn === 1);
|
||||
if (code === 0x52 || code === 0x14) return context.player;
|
||||
if (code === 0x53) return context.rival;
|
||||
if (code === 0x49) return context.mom;
|
||||
if (code >= 0x80 && code <= 0x99)
|
||||
return String.fromCharCode(65 + code - 0x80);
|
||||
if (code >= 0xa0 && code <= 0xb9)
|
||||
return String.fromCharCode(97 + code - 0xa0);
|
||||
if (code >= 0xf6) return String(code - 0xf6);
|
||||
return CHARACTERS[code];
|
||||
}
|
||||
|
||||
/** Decode a plain "@"-terminated string such as a name or item label. */
|
||||
export function readString(
|
||||
reader: RomReader,
|
||||
start: number,
|
||||
context: TextContext,
|
||||
limit = 32,
|
||||
): string {
|
||||
let output = "";
|
||||
for (let p = start; p < start + limit; p++) {
|
||||
const code = reader.byte(p);
|
||||
if (code === 0x50) return output;
|
||||
const value = character(code, context);
|
||||
if (value === undefined) throw new UnsupportedText(p, code);
|
||||
output += value;
|
||||
}
|
||||
throw new Error("Unterminated string.");
|
||||
}
|
||||
|
||||
export function readText(
|
||||
reader: RomReader,
|
||||
start: number,
|
||||
playerName: string,
|
||||
context: TextContext,
|
||||
depth = 0,
|
||||
): string {
|
||||
return readTextEnd(reader, start, context, depth).text;
|
||||
}
|
||||
|
||||
/** Read text and report whether it ended with `prompt`, which waits for a
|
||||
* button in battle, rather than `done` or `text_end`. */
|
||||
export function readTextEnd(
|
||||
reader: RomReader,
|
||||
start: number,
|
||||
context: TextContext,
|
||||
depth = 0,
|
||||
): { text: string; prompt: boolean } {
|
||||
if (depth > 8) throw new Error("Text calls exceed the recursion limit.");
|
||||
let p = start,
|
||||
output = "";
|
||||
output = "",
|
||||
prompt = false;
|
||||
// Only the outermost text is trimmed; nested fragments keep their spacing.
|
||||
const finish = (value: string) => (depth === 0 ? value.trim() : value);
|
||||
const ram = (address: number) => {
|
||||
const value = context.strings[address];
|
||||
if (value === undefined)
|
||||
throw new Error(
|
||||
`Text reads RAM 0x${address.toString(16)}, which has no native value.`,
|
||||
);
|
||||
return value;
|
||||
};
|
||||
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;
|
||||
switch (command) {
|
||||
case 0x50:
|
||||
return { text: finish(output), prompt };
|
||||
case 0x00: {
|
||||
let ended = false;
|
||||
for (let length = 0; length < 8192; length++) {
|
||||
const offset = p,
|
||||
code = reader.byte(p++);
|
||||
if (code === 0x57 || code === 0x58)
|
||||
return { text: finish(output), prompt: code === 0x58 };
|
||||
if (code === 0x50) {
|
||||
ended = true;
|
||||
break;
|
||||
}
|
||||
const value = character(code, context);
|
||||
if (value === undefined) throw new UnsupportedText(offset, code);
|
||||
output += value;
|
||||
}
|
||||
if (!ended) throw new Error("Unterminated text.");
|
||||
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);
|
||||
case 0x01:
|
||||
output += ram(reader.word(p));
|
||||
p += 2;
|
||||
break;
|
||||
case 0x06:
|
||||
case 0x0d:
|
||||
// Button waits inside one message become page boundaries in the DOM.
|
||||
output = output.trimEnd() + "\n\n";
|
||||
break;
|
||||
case 0x07:
|
||||
output += "\n";
|
||||
break;
|
||||
case 0x08: {
|
||||
const next = context.asm?.(commandOffset);
|
||||
if (next === undefined) throw new UnsupportedText(commandOffset, command);
|
||||
p = next;
|
||||
break;
|
||||
}
|
||||
case 0x09: {
|
||||
const address = reader.word(p);
|
||||
p += 3; // address, then bytes and digits (left-aligned printing)
|
||||
const value = context.numbers?.[address];
|
||||
if (value === undefined)
|
||||
throw new Error(
|
||||
`Text reads number 0x${address.toString(16)}, which has no native value.`,
|
||||
);
|
||||
output += String(value);
|
||||
break;
|
||||
}
|
||||
case 0x0a:
|
||||
case 0x0b:
|
||||
case 0x0e:
|
||||
case 0x0f:
|
||||
case 0x10:
|
||||
case 0x11:
|
||||
case 0x12:
|
||||
case 0x13:
|
||||
break; // Timed pauses and sound cues belong to presentation/audio.
|
||||
case 0x0c:
|
||||
output += "…".repeat(reader.byte(p++));
|
||||
break;
|
||||
case 0x14: {
|
||||
const index = reader.byte(p++);
|
||||
const address = STRING_BUFFERS[index];
|
||||
if (address === undefined)
|
||||
throw new UnsupportedText(commandOffset, command);
|
||||
output += ram(address);
|
||||
break;
|
||||
}
|
||||
case 0x15:
|
||||
output += context.weekday;
|
||||
break;
|
||||
case 0x16: {
|
||||
const nested = readTextEnd(
|
||||
reader,
|
||||
reader.near(p, reader.byte(p + 2)),
|
||||
context,
|
||||
depth + 1,
|
||||
);
|
||||
output += nested.text;
|
||||
prompt = nested.prompt;
|
||||
p += 3;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new UnsupportedText(commandOffset, command);
|
||||
}
|
||||
if (!ended) throw new Error("Unterminated text.");
|
||||
}
|
||||
throw new Error("Text command limit exceeded.");
|
||||
}
|
||||
@@ -41,7 +41,12 @@ export interface GameMap {
|
||||
id: string;
|
||||
group: number;
|
||||
number: number;
|
||||
/** Ported-area label, or the ROM location name. */
|
||||
name: string;
|
||||
/** The ROM landmark name shown by the game's own map. */
|
||||
location: string;
|
||||
/** The landmark index (caught data and GetWorldMapLocation). */
|
||||
landmark: number;
|
||||
width: number;
|
||||
height: number;
|
||||
border: number;
|
||||
|
||||
+1435
-221
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user