Implement native opening scenes, battle foundation and overworld movement
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-23 16:09:22 +02:00
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
+294
View File
@@ -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;
+116
View File
@@ -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;
}
}
+133
View File
@@ -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;
}
+190
View File
@@ -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;
}
}
+108
View File
@@ -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
View File
@@ -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(
+36
View File
@@ -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
View File
@@ -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) => {
+253
View File
@@ -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
View File
@@ -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
View File
@@ -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;
File diff suppressed because it is too large. Load diff
+75
View File
@@ -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
View File
@@ -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.");
}
+5
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+313 -13
View File
@@ -1,7 +1,13 @@
import "./ui/styles.css";
import { renderShell } from "./ui/shell";
import { bindTabs } from "./ui/tabs";
import { NativeRuntime } from "./game/runtime";
import type { GameWorld } from "./game/world/world";
import { LocalMapView } from "./game/rendering/minimap";
import type { Choice, GameWorld } from "./game/world/world";
import { drawTile } from "./game/rendering/pixels";
import { PokemonData } from "./game/rom/pokemon";
import type { Palette } from "./game/world/types";
import { NICKNAME_PATTERN } from "./game/party/pokemon";
import { GameSession } from "./app/session";
import { InputController } from "./input/controller";
import { bindControls } from "./input/bindings";
@@ -14,33 +20,315 @@ renderShell(root);
const get = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T;
const canvas = get<HTMLCanvasElement>("game-screen");
const mapView = new LocalMapView(
get<HTMLCanvasElement>("minimap"),
get<HTMLCanvasElement>("world-map"),
);
const status = get("status");
const saveStatus = get("save-status");
let busy = false;
let lockRelease: (() => void) | undefined;
let saving: Promise<void> | undefined;
function showWorld(world: GameWorld) {
mapView.update(world);
get("map-location").textContent =
world.map.name === world.map.location
? world.map.name
: `${world.map.name} · ${world.map.location}`;
get("map-position").textContent =
`You are at ${world.x}, ${world.y}. Red marks your position.`;
get("minimap-position").textContent = `${world.x}, ${world.y}`;
get("map-empty").hidden = true;
get<HTMLInputElement>("player-name").value = world.name;
get<HTMLSelectElement>("player-avatar").value = world.avatar;
get("location").textContent = world.map.name;
get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`;
get("game-notice").textContent = world.notice;
get("native-dialogue").hidden = !world.dialogue;
get("dialogue-text").textContent =
world.dialogue?.pages[world.dialogue.index] ?? "";
get("dialogue-page").textContent = world.dialogue
? `${world.dialogue.index + 1} / ${world.dialogue.pages.length}`
: "";
showBattle(world);
const dialogue = world.dialogue;
showPicture(world);
// A page without text is a button wait while a picture is shown.
const pictureWait = Boolean(
dialogue && !dialogue.choice && dialogue.pages[dialogue.index] === "",
);
get("native-dialogue").hidden = !dialogue || pictureWait;
get("pokepic-next").hidden = !pictureWait;
get("dialogue-text").textContent = dialogue?.pages[dialogue.index] ?? "";
get("dialogue-page").textContent =
dialogue && dialogue.pages.length > 1
? `${dialogue.index + 1} / ${dialogue.pages.length}`
: "";
const last = dialogue && dialogue.index === dialogue.pages.length - 1;
const choice = last ? dialogue.choice : undefined;
// Timed pages advance themselves; choices replace Continue.
get("dialogue-next").hidden = Boolean(
choice || (last && dialogue.holdTicks !== undefined),
);
showChoice(choice);
// Coordinates provide a stable accessible description and a test observation
// point, without exposing mutable game state or a production debug API.
canvas.dataset.map = world.mapId;
canvas.dataset.x = String(world.x);
canvas.dataset.y = String(world.y);
canvas.dataset.activity = world.activity;
canvas.setAttribute(
"aria-label",
`${world.map.name}. Position ${world.x}, ${world.y}. Arrow keys move. X interacts. Z continues dialogue.`,
);
}
function showChoice(choice: Choice | undefined) {
const container = get("dialogue-choice");
container.hidden = !choice;
const key = choice ? JSON.stringify(choice) : "";
if (container.dataset.choice === key) return;
container.dataset.choice = key;
const button = (
label: string,
pressed: boolean | undefined,
action: () => void,
) => {
const element = document.createElement("button");
element.type = "button";
element.textContent = label;
if (pressed !== undefined)
element.setAttribute("aria-pressed", String(pressed));
element.addEventListener("click", () => {
if (session.state !== "playing") return;
action();
canvas.focus({ preventScroll: true });
});
return element;
};
if (!choice) container.replaceChildren();
else if (choice.kind === "yesno")
container.replaceChildren(
button("YES", choice.selected === 1, () => runtime.choose(1)),
button("NO", choice.selected === 0, () => runtime.choose(0)),
);
else if (choice.kind === "weekday") {
// The original weekday box: Up for the next day, Down for the previous.
const day = document.createElement("output");
day.textContent = choice.names[choice.selected];
day.setAttribute("aria-live", "polite");
container.replaceChildren(
button("▼", undefined, () => {
runtime.world?.moveCursor("down");
if (runtime.world) showWorld(runtime.world);
}),
day,
button("▲", undefined, () => {
runtime.world?.moveCursor("up");
if (runtime.world) showWorld(runtime.world);
}),
button("OK", true, () => runtime.choose(choice.selected)),
);
container.firstElementChild?.setAttribute("aria-label", "Previous day");
container.children[2]?.setAttribute("aria-label", "Next day");
} else {
// The naming screen: an empty name keeps the current one.
const input = document.createElement("input");
input.maxLength = choice.max;
input.placeholder = choice.current;
input.setAttribute("aria-label", `Nickname for ${choice.current}`);
input.autocomplete = "off";
input.spellcheck = false;
const submit = () => {
const name = input.value.trim();
if (name && !NICKNAME_PATTERN.test(name)) {
input.setCustomValidity(
"Use letters, digits, spaces and simple punctuation.",
);
input.reportValidity();
return;
}
runtime.choose(name);
canvas.focus({ preventScroll: true });
};
input.addEventListener("input", () => input.setCustomValidity(""));
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") submit();
});
const ok = document.createElement("button");
ok.type = "button";
ok.textContent = "OK";
ok.addEventListener("click", () => {
if (session.state === "playing") submit();
});
container.replaceChildren(input, ok);
queueMicrotask(() => input.focus({ preventScroll: true }));
}
}
const STATUS_NAMES: [number, string][] = [
[0x08, "PSN"],
[0x10, "BRN"],
[0x20, "FRZ"],
[0x40, "PAR"],
];
function statusName(status: number) {
if (status & 7) return "SLP";
return STATUS_NAMES.find(([bit]) => status & bit)?.[1] ?? "";
}
function drawPic(target: HTMLCanvasElement, tiles: Uint8Array, size: number, palette: Palette, flip = false) {
const context = target.getContext("2d")!;
const pixels = size * 8;
const image = context.createImageData(pixels, pixels);
for (let tile = 0; tile < size * size; tile++)
drawTile(image.data, pixels, pixels, tiles, tile, Math.floor(tile / size) * 8, (tile % size) * 8, palette, { flipX: flip });
context.putImageData(image, 0, 0);
}
const battlePics = { foe: "", player: "" };
let battleMenuKey = "";
let showMoves = false;
/** The battle screen: pictures, HUDs, messages and the current choice. */
function showBattle(world: GameWorld) {
const view = world.battle;
const screen = get("battle");
screen.hidden = !view;
if (!view) {
battlePics.foe = battlePics.player = battleMenuKey = "";
showMoves = false;
return;
}
const data = world.assets.pokemon;
const { player, enemy } = view.engine;
const foe = enemy.mon,
own = player.mon;
if (foe) {
const key = `${foe.species}:${foe.dvs}`;
if (battlePics.foe !== key) {
battlePics.foe = key;
drawPic(get("battle-foe-pic"), data.frontPic(foe.species), 7, data.palette(foe.species, PokemonData.shiny(foe.dvs)));
get("battle-foe-pic").setAttribute("aria-label", foe.nickname);
}
get("battle-foe-name").textContent = foe.nickname;
get("battle-foe-level").textContent = `:L${foe.level}`;
get("battle-foe-status").textContent = statusName(foe.status);
setHp(get("battle-foe-hp"), foe.hp, foe.maxHp);
}
get("battle-player").hidden = !own;
if (own) {
const key = `${own.species}:${own.dvs}`;
if (battlePics.player !== key) {
battlePics.player = key;
drawPic(get("battle-player-pic"), data.backPic(own.species), 6, data.palette(own.species, PokemonData.shiny(own.dvs)));
get("battle-player-pic").setAttribute("aria-label", own.nickname);
}
get("battle-player-name").textContent = own.nickname;
get("battle-player-level").textContent = `:L${own.level}`;
get("battle-player-status").textContent = statusName(own.status);
get("battle-player-hp-text").textContent = `${own.hp}/ ${own.maxHp}`;
setHp(get("battle-player-hp"), own.hp, own.maxHp);
}
get("battle-text").textContent = view.message?.text ?? "";
const prompt = view.prompt;
const key = JSON.stringify([prompt, view.selected, showMoves, own?.pp, view.message?.prompt]);
if (key === battleMenuKey) return;
battleMenuKey = key;
const menu = get("battle-menu");
const button = (label: string, action: () => void, options: { detail?: string; disabled?: string; wide?: boolean; pressed?: boolean } = {}) => {
const element = document.createElement("button");
element.type = "button";
element.textContent = label;
if (options.detail) {
const small = document.createElement("small");
small.textContent = options.detail;
element.append(small);
}
if (options.disabled) {
element.disabled = true;
element.title = options.disabled;
}
if (options.wide) element.classList.add("wide");
if (options.pressed !== undefined) element.setAttribute("aria-pressed", String(options.pressed));
element.addEventListener("click", () => {
if (session.state !== "playing") return;
action();
canvas.focus({ preventScroll: true });
});
return element;
};
const buttons: HTMLButtonElement[] = [];
if (prompt?.kind === "action" && own) {
if (showMoves) {
own.moves.forEach((move, index) => {
if (!move) return;
buttons.push(
button(data.moveName(move), () => {
showMoves = false;
runtime.battleAction({ action: "fight", move: index });
}, { detail: `PP ${own.pp[index] & 63}/${data.move(move).pp}` }),
);
});
buttons.push(button("BACK", () => {
showMoves = false;
showWorld(world);
}, { wide: true }));
} else {
buttons.push(
button("FIGHT", () => {
showMoves = true;
showWorld(world);
}),
button("PKMN", () => {}, { disabled: "Switching Pokémon is not ported yet." }),
button("PACK", () => {}, { disabled: "Using items in battle is not ported yet." }),
button("RUN", () => runtime.battleAction({ action: "run" })),
);
}
} else if (prompt?.kind === "yesno") {
buttons.push(
button("YES", () => runtime.battleChoose(1), { pressed: view.selected === 1 }),
button("NO", () => runtime.battleChoose(0), { pressed: view.selected === 0 }),
);
} else if (prompt?.kind === "forget") {
prompt.moves.forEach((move, index) =>
buttons.push(button(data.moveName(move), () => runtime.battleChoose(index))),
);
buttons.push(button("CANCEL", () => runtime.battleChoose(4), { wide: true }));
} else if (prompt?.kind === "stats") {
prompt.labels.forEach((label, index) =>
buttons.push(button(`${label} ${prompt.values[index]}`, () => {}, { disabled: " " })),
);
buttons.push(button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }));
} else if (view.message?.prompt) {
buttons.push(button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }));
}
menu.replaceChildren(...buttons);
}
function setHp(bar: HTMLElement, hp: number, max: number) {
const fraction = max ? hp / max : 0;
bar.style.width = `${Math.round(fraction * 100)}%`;
bar.classList.toggle("low", fraction <= 0.5 && fraction > 0.2);
bar.classList.toggle("critical", fraction <= 0.2);
}
let shownPicture: number | undefined;
/** Draw a pokepic from the ROM's front picture tiles and palette. */
function showPicture(world: GameWorld) {
const box = get("pokepic");
box.hidden = world.picture === undefined;
if (world.picture === shownPicture) return;
shownPicture = world.picture;
if (world.picture === undefined) return;
const data = world.assets.pokemon;
const picture = get<HTMLCanvasElement>("pokepic-canvas");
const context = picture.getContext("2d")!;
const image = context.createImageData(56, 56);
const tiles = data.frontPic(world.picture),
palette = data.palette(world.picture);
// Picture tiles are stored column by column.
for (let tile = 0; tile < 49; tile++)
drawTile(
image.data,
56,
56,
tiles,
tile,
Math.floor(tile / 7) * 8,
(tile % 7) * 8,
palette,
);
context.putImageData(image, 0, 0);
picture.setAttribute("aria-label", data.speciesName(world.picture));
}
const runtime = new NativeRuntime(
showWorld,
(error) => {
@@ -71,10 +359,15 @@ const input = new InputController((button, pressed) =>
const controls = bindControls(
root,
input,
() => session.state === "playing" && !busy,
() => session.state === "playing" && !busy && tabs.active() === "game",
() => {},
);
const ready = () => ["paused", "playing"].includes(session.state);
const tabs = bindTabs(root, () => {
pause();
controls.release();
update();
});
function showError(error: unknown) {
status.textContent =
@@ -106,6 +399,9 @@ function update() {
: "STANDBY";
get("power-light").classList.toggle("on", playing);
get("screen-empty").hidden = ready();
get("minimap-position").hidden = !ready();
get("world-map").hidden = !ready();
get("map-empty").hidden = ready();
get("pause-overlay").hidden = session.state !== "paused";
get<HTMLInputElement>("rom-file").disabled = busy;
get("rom-label").classList.toggle("disabled", busy);
@@ -149,11 +445,10 @@ function pause() {
}
function play() {
if (busy) return;
tabs.select("game");
session.play();
canvas.focus({ preventScroll: true });
message(
"Native exploration is running. Story progression and battles are not ported yet.",
);
message("Press X or A to talk and read. Up and Down change choices.");
update();
}
@@ -177,6 +472,7 @@ get<HTMLInputElement>("rom-file").addEventListener("change", async (event) => {
await save();
}
const restored = await session.load(rom, canvas);
tabs.select("game");
message(
restored
? "Your saved session is ready. Press Play to continue."
@@ -194,10 +490,14 @@ get("play").addEventListener("click", () =>
session.state === "playing" ? pause() : play(),
);
get("screen-resume").addEventListener("click", play);
get("pokepic-next").addEventListener("click", () => {
if (session.state !== "playing") return;
runtime.advanceDialogue();
canvas.focus({ preventScroll: true });
});
get("dialogue-next").addEventListener("click", () => {
if (session.state !== "playing") return;
runtime.world?.advanceDialogue();
if (runtime.world) showWorld(runtime.world);
runtime.advanceDialogue();
canvas.focus({ preventScroll: true });
});
get("save").addEventListener("click", () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import "../ui/styles.css";
import "../ui/reference.css";
import { renderShell } from "./shell";
import { BinjgbEmulator } from "../emulator/binjgb";
import { GameSession } from "../app/session";
+931
View File
@@ -0,0 +1,931 @@
:root {
font-family:
Inter,
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
color: #183d36;
background: #f4f4ec;
font-synthesis: none;
font-weight: 400;
font-size: 15px;
line-height: 1.5;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
.player-setup {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin: 12px 0;
grid-column: 1 / -1;
}
.player-setup label {
font-size: 10px;
color: #647962;
}
.player-setup input,
.player-setup select {
display: block;
width: 100%;
min-width: 0;
min-height: 44px;
border: 1px solid #d5ddd1;
border-radius: 6px;
background: #fff;
padding: 8px;
color: #234e40;
font: inherit;
font-size: 12px;
}
.player-setup small {
grid-column: 1 / -1;
font-size: 9px;
color: #7a8b7b;
}
.world-status {
display: flex;
justify-content: space-between;
font-size: 9px;
color: #73816f;
gap: 8px;
}
.game-notice {
color: #765b38;
font-size: 11px;
text-align: center;
margin: 0;
}
.game-notice:empty {
display: none;
}
.native-dialogue {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
z-index: 1;
padding: 12px;
color: #17382b;
background: #fffdf0;
border: 2px solid #284c3c;
border-radius: 8px;
box-shadow: 0 3px 15px #102b2a55;
}
.native-dialogue p {
font-size: clamp(11px, 2.8vw, 15px);
line-height: 1.45;
white-space: pre-line;
margin: 0;
max-height: 125px;
overflow: auto;
}
.native-dialogue > div {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 9px;
}
.native-dialogue button {
background: transparent;
padding: 8px 0 0 12px;
font-size: 11px;
}
.pause-overlay {
z-index: 2;
}
body {
margin: 0;
padding: env(safe-area-inset-top) env(safe-area-inset-right)
env(safe-area-inset-bottom) env(safe-area-inset-left);
}
button,
input {
font: inherit;
}
button,
a,
label {
-webkit-tap-highlight-color: transparent;
}
button {
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.42;
}
button:focus-visible,
a:focus-visible,
summary:focus-visible,
canvas:focus-visible {
outline: 3px solid #ca713a;
outline-offset: 4px;
}
button {
color: inherit;
}
button:disabled {
pointer-events: none;
}
a {
color: inherit;
}
button,
.file-button {
min-height: 44px;
}
button {
border: 0;
}
button:hover:not(:disabled) {
filter: brightness(0.94);
}
[hidden] {
display: none !important;
}
.site-header {
max-width: 1280px;
margin: auto;
padding: 30px 54px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #dce1d6;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-size: 28px;
letter-spacing: -1.5px;
font-weight: 750;
text-decoration: none;
}
.brand span {
font-size: 9px;
letter-spacing: 1.7px;
font-weight: 600;
margin-left: 13px;
padding-left: 20px;
border-left: 1px solid #c8d0c6;
}
.text-button {
background: none;
font-size: 12px;
}
.text-button span {
margin-left: 16px;
}
main {
max-width: 1180px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 490px;
gap: 85px;
padding: 62px 40px 45px;
}
.intro {
padding-top: 24px;
}
.eyebrow {
font-size: 10px;
letter-spacing: 2px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
}
.small-star {
font-size: 23px;
color: #6d8d72;
}
h1 {
font-size: clamp(38px, 4.8vw, 62px);
line-height: 1.08;
letter-spacing: -3px;
font-weight: 500;
margin: 26px 0;
}
h1 em {
font-family: Georgia, serif;
font-weight: 400;
color: #759378;
}
.intro-copy {
font-size: 15px;
line-height: 1.85;
color: #6a7b6e;
margin-bottom: 30px;
}
.load-card {
background: #fbfcf7;
border: 1px solid #dce2d6;
border-radius: 15px;
padding: 24px;
max-width: 350px;
box-shadow: 0 5px 18px #20352a03;
}
.card-heading {
display: flex;
gap: 12px;
align-items: center;
}
.step-number {
font-size: 10px;
color: #738876;
border: 1px solid #d9e1d5;
border-radius: 50%;
height: 26px;
width: 26px;
display: grid;
place-items: center;
}
.load-card h2 {
font-size: 14px;
margin: 0;
font-weight: 600;
}
.load-card > p {
color: #73816f;
font-size: 12px;
margin: 18px 0;
}
.load-card strong {
font-weight: 500;
color: #2e5246;
}
.primary-button {
background: #234e40;
color: #fff;
border: 1px solid #234e40;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 15px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.file-input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.load-card:focus-within .file-button {
outline: 3px solid #ca713a;
outline-offset: 3px;
}
.disabled {
opacity: 0.5;
pointer-events: none;
}
.load-card .privacy-note {
font-size: 10px;
margin: 13px 0 0;
text-align: center;
color: #7a8b7b;
}
.privacy-note span {
margin-right: 4px;
}
.project-note {
display: flex;
gap: 12px;
margin-top: 30px;
align-items: center;
max-width: 350px;
}
.note-line {
width: 3px;
align-self: stretch;
background: #d4ddce;
margin: 12px 0;
}
.project-note p {
font-size: 11px;
color: #576d5b;
line-height: 1.8;
}
.project-note p span {
color: #8b9789;
}
.console {
background: #133532;
border-radius: 25px 25px 60px 25px;
padding: 22px 25px 18px;
box-shadow:
0 23px 45px #1335321c,
inset 0 1px 1px #ffffff28;
color: #a7c6b8;
border: 1px solid #244b42;
}
.console-top {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 9px;
letter-spacing: 2px;
padding: 0 3px 17px;
}
.power {
display: flex;
align-items: center;
gap: 6px;
font-size: 8px;
letter-spacing: 1px;
}
.power i {
height: 5px;
width: 5px;
border-radius: 50%;
background: #6f887a;
}
.power i.on {
background: #b2e6a5;
box-shadow: 0 0 7px #b2e6a5;
}
.screen-bezel {
padding: 15px 16px 9px;
background: #0a2423;
border-radius: 9px 9px 25px 9px;
border: 1px solid #315249;
}
.screen {
position: relative;
aspect-ratio: 160/144;
background: #dae6c7;
overflow: hidden;
border-radius: 3px;
}
canvas {
display: block;
width: 100%;
height: 100%;
image-rendering: pixelated;
}
.screen-empty {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: #526c49;
background: radial-gradient(ellipse at center, #e6ecd6, #ccdcbc);
}
.crystal-mark {
font-size: 84px;
line-height: 1.05;
font-family: Georgia, serif;
font-weight: 400;
color: #64885d;
text-shadow: 6px 0 #b6cbaa;
margin-top: -8px;
}
.screen-empty h2 {
font-family: Georgia, serif;
font-size: 25px;
line-height: 1.2;
font-weight: 400;
letter-spacing: -0.5px;
margin: 16px 0 10px;
}
.screen-empty p {
font-size: 10px;
margin: 0;
color: #718565;
}
.screen-dots {
margin-top: 12px;
font-size: 20px;
letter-spacing: 5px;
color: #8da17b;
}
.screen-caption {
display: flex;
justify-content: space-between;
padding: 9px 0 0;
letter-spacing: 2px;
font-size: 7px;
color: #6f8e7f;
}
.controls {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
padding: 22px 8px 4px;
gap: 15px;
}
.dpad {
display: grid;
grid-template-columns: repeat(3, 44px);
grid-template-rows: repeat(3, 44px);
filter: drop-shadow(0 5px 1px #061918);
}
.game-key {
min-width: 44px;
flex-shrink: 0;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.dpad .game-key,
.dpad-center {
background: #254d43;
box-shadow: inset 0 1px #527363;
color: #8fb09a;
min-height: 44px;
font-size: 14px;
}
.dpad .game-key {
border-radius: 4px;
}
.dpad [data-game-button="up"] {
grid-column: 2;
grid-row: 1;
}
.dpad [data-game-button="left"] {
grid-column: 1;
grid-row: 2;
}
.dpad-center {
grid-column: 2;
grid-row: 2;
box-shadow: none;
}
.dpad-center:after {
content: "";
display: block;
width: 14px;
height: 14px;
border: 1px solid #365e50;
border-radius: 50%;
margin: 15px;
}
.dpad [data-game-button="right"] {
grid-column: 3;
grid-row: 2;
}
.dpad [data-game-button="down"] {
grid-column: 2;
grid-row: 3;
}
.action-keys {
display: flex;
gap: 17px;
justify-content: flex-end;
transform: rotate(-20deg);
}
.action-keys button {
width: 59px;
height: 59px;
border-radius: 50%;
background: #d8e7c0;
color: #35573e;
font-size: 20px;
font-weight: 650;
border: 3px solid #b3caa0;
box-shadow: 0 5px #081f1c;
transform: rotate(20deg);
}
.game-key:disabled {
opacity: 0.7;
}
.game-key.pressed {
filter: brightness(1.3);
translate: 0 3px;
box-shadow: none;
}
.system-keys {
display: flex;
gap: 15px;
grid-column: 1/3;
justify-content: center;
margin-top: -4px;
}
.system-keys button {
background: none;
position: relative;
font-size: 8px;
letter-spacing: 1px;
padding-top: 22px;
color: #86a696;
}
.system-keys button:before {
content: "";
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%) rotate(-10deg);
width: 33px;
height: 9px;
background: #416356;
border: 1px solid #517463;
border-radius: 7px;
box-shadow: 0 3px #071f1c;
}
.speaker {
display: none;
}
.console-bottom {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 7px;
letter-spacing: 1.7px;
color: #54776a;
margin: 13px 5px 0;
}
.console-bottom > span:last-child {
font-size: 18px;
}
.player-toolbar {
display: flex;
justify-content: center;
gap: 5px;
padding-top: 20px;
flex-wrap: wrap;
}
.player-toolbar button,
.save-tools button {
background: transparent;
font-size: 11px;
padding: 8px 11px;
border: 1px solid #d5ddd1;
border-radius: 7px;
}
.status {
font-size: 11px;
text-align: center;
color: #647962;
min-height: 17px;
margin: 14px 0 0;
overflow-wrap: anywhere;
}
.save-status {
text-align: center;
font-size: 10px;
color: #8b9788;
margin: 6px 0;
}
.error {
color: #a03e29 !important;
}
.save-tools {
font-size: 11px;
color: #71816c;
text-align: center;
margin-top: 16px;
}
.save-tools summary {
cursor: pointer;
min-height: 44px;
padding: 12px;
}
.save-tools > div {
padding: 12px;
border: 1px solid #d5ddd1;
border-radius: 8px;
text-align: left;
}
.save-tools button {
margin: 0 5px 5px 0;
}
.save-tools p {
font-size: 11px;
line-height: 1.7;
}
.pause-overlay {
position: absolute;
inset: 0;
background: #0a2423b8;
display: flex;
flex-direction: column;
gap: 20px;
align-items: center;
justify-content: center;
color: #e7efdb;
backdrop-filter: blur(2px);
}
.pause-overlay span {
font-size: 10px;
letter-spacing: 4px;
}
.pause-overlay button {
background: #d8e7c0;
color: #21483b;
border-radius: 7px;
padding: 10px 18px;
font-size: 12px;
}
footer {
max-width: 1172px;
margin: auto;
border-top: 1px solid #dce1d6;
padding: 22px 0 28px;
display: flex;
justify-content: space-between;
gap: 15px;
color: #8a9886;
font-size: 10px;
}
dialog {
border: 1px solid #d2ddca;
background: #f4f4ec;
color: #244c3e;
border-radius: 18px;
padding: 32px;
max-width: 460px;
width: calc(100% - 32px);
}
dialog::backdrop {
background: #0a242399;
backdrop-filter: blur(3px);
}
dialog h2 {
font-family: Georgia, serif;
font-weight: 400;
font-size: 30px;
}
dialog p {
font-size: 13px;
line-height: 1.7;
}
dialog dl {
display: grid;
grid-template-columns: 1fr 1fr;
font-size: 13px;
gap: 10px;
}
dialog dd {
margin: 0;
}
.dialog-close {
float: right;
background: none;
font-size: 25px;
}
@media (min-width: 1300px) {
main {
gap: 100px;
padding-top: 75px;
padding-bottom: 60px;
}
.intro {
padding-top: 37px;
}
}
@media (max-width: 1000px) {
main {
gap: 35px;
grid-template-columns: minmax(0, 1fr) minmax(340px, 440px);
padding: 40px 30px;
}
.site-header {
padding: 22px 30px;
}
h1 {
font-size: 45px;
}
.console {
padding: 20px;
}
.screen-empty h2 {
font-size: 23px;
}
footer {
margin: 0 30px;
}
}
@media (max-width: 740px) {
.site-header {
padding: 17px 22px;
}
.brand {
font-size: 25px;
}
.brand img {
width: 30px;
height: 30px;
}
.brand span {
display: none;
}
main {
display: flex;
flex-direction: column;
gap: 25px;
padding: 20px 18px 26px;
max-width: 480px;
}
.intro {
padding: 0;
}
.eyebrow {
font-size: 8px;
letter-spacing: 1.6px;
margin: 10px 0;
}
h1 {
font-size: 36px;
letter-spacing: -1.6px;
margin: 15px 0 13px;
}
h1 br {
display: block;
}
h1 em {
white-space: nowrap;
}
.intro-copy {
font-size: 12px;
line-height: 1.7;
margin: 0 0 18px;
}
.intro-copy br {
display: none;
}
.intro-copy br:after {
content: " ";
}
#app.has-game .intro {
display: none;
}
#app.has-game main {
padding-top: 12px;
}
.load-card {
max-width: none;
padding: 15px 18px;
display: grid;
grid-template-columns: 1fr 140px;
column-gap: 10px;
align-items: center;
}
.card-heading h2 {
font-size: 12px;
}
.step-number {
display: none;
}
.load-card > p {
margin: 5px 0;
font-size: 10px;
grid-column: 1;
}
.load-card .primary-button {
grid-column: 2;
grid-row: 1/3;
font-size: 11px;
padding: 10px;
gap: 5px;
}
.load-card .privacy-note {
grid-column: 1/3;
margin: 10px 0 0;
font-size: 9px;
}
.project-note {
display: none;
}
.console {
border-radius: 22px 22px 50px 22px;
padding: 20px;
}
.console-top {
font-size: 8px;
}
.screen-bezel {
padding: 12px 12px 9px;
}
.controls {
padding: 22px 4px 0;
}
.action-keys button {
width: 55px;
height: 55px;
}
.action-keys {
gap: 14px;
}
.player-toolbar {
padding-top: 17px;
}
.player-toolbar button {
font-size: 10px;
padding: 8px 10px;
}
.status {
font-size: 10px;
}
footer {
padding: 18px 0 25px;
margin: 0 22px;
flex-direction: column;
font-size: 9px;
gap: 5px;
}
}
@media (max-width: 360px) {
.load-card {
grid-template-columns: 1fr 114px;
padding: 13px;
}
.load-card .primary-button {
font-size: 10px;
}
.console {
padding: 15px;
}
.screen-bezel {
padding: 9px;
}
.screen-empty h2 {
font-size: 21px;
}
.screen-empty .crystal-mark {
font-size: 62px;
}
.action-keys {
gap: 8px;
}
.action-keys button {
width: 48px;
height: 48px;
}
.dpad {
grid-template-columns: repeat(3, 44px);
grid-template-rows: repeat(3, 44px);
}
.controls {
padding-left: 0;
padding-right: 0;
gap: 8px;
}
.system-keys {
margin-top: 0;
}
}
@media (max-height: 540px) and (orientation: landscape) {
#app.has-game
:is(
.site-header,
.intro,
footer,
.console-top,
.console-bottom,
.screen-caption,
.save-tools,
.save-status
) {
display: none;
}
#app.has-game main {
display: block;
max-width: 850px;
padding: 12px 20px;
}
#app.has-game .console {
position: relative;
padding: 12px;
border-radius: 20px;
min-height: 270px;
}
#app.has-game .screen-bezel {
width: min(43%, 340px);
margin: auto;
padding: 8px;
}
#app.has-game .controls {
position: absolute;
inset: 0;
display: flex;
justify-content: space-between;
pointer-events: none;
padding: 25px;
}
#app.has-game .controls > * {
pointer-events: auto;
}
#app.has-game .system-keys {
position: absolute;
bottom: 18px;
left: 30px;
}
#app.has-game .player-toolbar {
padding-top: 8px;
}
#app.has-game .status {
margin-top: 6px;
}
}
+55 -36
View File
@@ -1,55 +1,74 @@
const key = (name: string, label: string, className = "") =>
`<button class="game-key ${className}" data-game-button="${name}" aria-label="${name === "a" || name === "b" ? name.toUpperCase() : name}" aria-pressed="false" disabled>${label}</button>`;
const key = (name: string, label: string) =>
`<button class="game-key" data-game-button="${name}" aria-label="${name === "a" || name === "b" ? name.toUpperCase() : name}" aria-pressed="false" disabled>${label}</button>`;
const tab = (id: string, label: string, selected = false) =>
`<button id="tab-${id}" role="tab" aria-selected="${selected}" aria-controls="panel-${id}" tabindex="${selected ? "0" : "-1"}" data-tab="${id}">${selected ? '<span aria-hidden="true">▶</span>' : ""}${label}</button>`;
export function renderShell(root: HTMLElement) {
root.innerHTML = `
<header class="site-header">
<a class="brand" href="./" aria-label="Crystal home"><img src="${import.meta.env.BASE_URL}icon.svg" alt="" width="36" height="36">crystal<span>NATIVE WEB PORT</span></a>
<button id="help" class="text-button">How to play <span aria-hidden="true">↗</span></button>
<a class="brand" href="./" aria-label="Crystal home">CRYSTAL</a>
<div class="header-state"><span class="power"><i id="power-light"></i><span id="power-label">STANDBY</span></span><button id="help" class="text-button">Help</button></div>
</header>
<main>
<section class="intro" aria-labelledby="page-title">
<p class="eyebrow"><span class="small-star">✧</span> YOUR CARTRIDGE. A NEW HOME.</p>
<h1 id="page-title">A little adventure.<br><em>Anywhere.</em></h1>
<p class="intro-copy">Crystal, rebuilt for the web.<br> Start exploring New Bark Town in the first native milestone.</p>
<div class="load-card">
<div class="card-heading"><span class="step-number">01</span><h2>Bring your cartridge</h2></div>
<p>Pokémon Crystal<br><strong>English US/Europe · Revision 1.1</strong></p>
<div class="player-setup"><label>Name <input id="player-name" value="CHRIS" maxlength="7" pattern="[A-Za-z0-9 -]{1,7}" autocomplete="off"></label><label>Character <select id="player-avatar"><option value="chris">Chris</option><option value="kris">Kris</option></select></label><small>Used for your first session. Saved sessions keep their character.</small></div>
<label class="primary-button file-button" id="rom-label" for="rom-file"><span aria-hidden="true">+</span> Choose ROM <span aria-hidden="true">→</span></label>
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream" />
<p class="privacy-note"><span aria-hidden="true">◇</span> Opened locally. Never uploaded.</p>
</div>
<div class="project-note"><span class="note-line"></span><p>An exploration build, not the full game yet.<br><span>Move, enter buildings, read signs and try interactions. Story progression, battles and audio are next.</span></p></div>
</section>
<section class="play-area" aria-label="Game player">
<main class="app-shell">
<nav class="tabs" role="tablist" aria-label="Crystal sections">
${tab("game", "Game", true)}${tab("saves", "Savegames")}${tab("map", "Map")}${tab("pokedex", "Pokédex")}${tab("link", "Link")}${tab("friends", "Friends")}
</nav>
<section id="panel-game" class="tab-panel game-panel" role="tabpanel" aria-labelledby="tab-game" data-panel="game">
<div class="console">
<div class="console-top"><span>NATIVE / EXPLORATION</span><span class="power"><i id="power-light"></i><span id="power-label">STANDBY</span></span></div>
<div class="screen-bezel">
<div class="screen">
<canvas id="game-screen" width="160" height="144" tabindex="0" aria-label="Game screen. Arrow keys move, X is A, Z is B, Enter is Start, Shift is Select."></canvas>
<div id="native-dialogue" class="native-dialogue" aria-label="Dialogue" hidden><p id="dialogue-text" aria-live="polite"></p><div><span id="dialogue-page"></span><button id="dialogue-next">Continue <span aria-hidden="true">▸</span></button></div></div>
<div id="screen-empty" class="screen-empty"><div class="crystal-mark" aria-hidden="true">◇</div><h2>Your next chapter<br>starts here.</h2><p>Load your Crystal ROM to begin.</p><span class="screen-dots" aria-hidden="true">· · ·</span></div>
<div id="pause-overlay" class="pause-overlay" hidden><span>PAUSED</span><button id="screen-resume">Continue playing →</button></div>
<div id="screen-empty" class="screen-empty">
<form class="load-card" onsubmit="return false">
<p class="menu-title">NEW SESSION</p><h1>Load your Crystal ROM</h1><p>English US/Europe · Revision 1.1</p>
<div class="player-setup">
<label>Name <input id="player-name" value="CHRIS" maxlength="7" pattern="[A-Za-z0-9 -]{1,7}" autocomplete="off"></label>
<label>Character <select id="player-avatar"><option value="chris">Chris</option><option value="kris">Kris</option></select></label>
</div>
<label class="primary-button file-button" id="rom-label" for="rom-file"><span aria-hidden="true">▶</span> Choose ROM</label>
<p class="privacy-note">Opened locally. Never uploaded.</p>
</form>
</div>
<div id="battle" class="battle" hidden aria-label="Battle">
<div class="battle-foe"><div class="battle-hud"><div><strong id="battle-foe-name"></strong><span id="battle-foe-level"></span></div><div class="hp-row"><span>HP</span><span class="hp-bar"><i id="battle-foe-hp"></i></span></div><span id="battle-foe-status" class="battle-status"></span></div><canvas id="battle-foe-pic" width="56" height="56" role="img"></canvas></div>
<div id="battle-player" class="battle-player"><canvas id="battle-player-pic" width="48" height="48" role="img"></canvas><div class="battle-hud"><div><strong id="battle-player-name"></strong><span id="battle-player-level"></span></div><div class="hp-row"><span>HP</span><span class="hp-bar"><i id="battle-player-hp"></i></span></div><div class="battle-numbers"><span id="battle-player-status" class="battle-status"></span><span id="battle-player-hp-text"></span></div></div></div>
<div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div>
</div>
<div id="pokepic" class="pokepic" hidden><canvas id="pokepic-canvas" width="56" height="56" role="img"></canvas><button id="pokepic-next" hidden>Continue <span aria-hidden="true">▶</span></button></div>
<div id="native-dialogue" class="native-dialogue" aria-label="Dialogue" hidden><p id="dialogue-text" aria-live="polite"></p><div id="dialogue-choice" class="dialogue-choice" hidden></div><div><span id="dialogue-page"></span><button id="dialogue-next">Continue <span aria-hidden="true">▶</span></button></div></div>
<div id="pause-overlay" class="pause-overlay" hidden><span>PAUSED</span><button id="screen-resume">Continue playing</button></div>
<aside class="minimap-overlay" aria-label="Current area map"><canvas id="minimap" width="160" height="128"></canvas><p id="minimap-position" hidden>Map unavailable</p></aside>
</div>
<div class="screen-caption"><span id="location">YOUR WORLD, REBUILT</span><span>160 × 144</span></div>
<div class="screen-caption"><span id="location">YOUR WORLD</span><span id="tiles-walked">0 tiles walked</span></div>
</div>
<div class="controls">
<div class="controls" aria-label="Game controls">
<div class="dpad" aria-label="Directional controls">${key("up", "▲")}${key("left", "◀")}<span class="dpad-center" aria-hidden="true"></span>${key("right", "▶")}${key("down", "▼")}</div>
<div class="action-keys">${key("b", "B")}${key("a", "A")}</div>
<div class="system-keys">${key("select", "SELECT")}${key("start", "START")}</div>
<div class="speaker" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
</div>
<div class="console-bottom"><span>MADE FOR THE WAY YOU PLAY</span><span>✧</span></div>
</div>
<div class="player-toolbar" aria-label="Player actions"><button id="play" disabled>Play</button><button id="save" disabled>Save session</button><button id="backup" disabled>Export backup</button></div>
<p class="world-status"><span id="tiles-walked">0 tiles walked</span><span>Exploration build · Audio pending</span></p>
<p id="game-notice" role="status" class="game-notice"></p>
<p id="status" class="status" role="status" aria-live="polite">Ready when you are. Choose your ROM to get started.</p>
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
<details class="save-tools"><summary>Save files & compatibility</summary><div><button id="import-save" disabled>Import native backup</button><input id="save-file" class="file-input" type="file" accept=".json" /><p>Native saves keep your character, position and completed interactions. They are separate from emulator snapshots and cartridge .sav files, which cannot be imported yet.</p><p>Time-of-day colors follow your device clock. The tile counter measures in-game movement, not real-world steps. Export a backup before clearing browser data.</p></div></details>
</section>
<section id="panel-saves" class="tab-panel menu-panel" role="tabpanel" aria-labelledby="tab-saves" data-panel="saves" hidden>
<header><p class="menu-title">SAVEGAMES</p><h2>Your local session</h2></header>
<div class="save-actions"><button id="save" disabled>Save session</button><button id="backup" disabled>Export backup</button><button id="import-save" disabled>Import native backup</button><input id="save-file" class="file-input" type="file" accept=".json"></div>
<div class="save-copy"><p>Native backups include your character, position and completed interactions. They are separate from cartridge .sav files.</p><p>Export before clearing browser data.</p></div>
<div class="replace-rom"><h3>Cartridge</h3><p>Select a ROM again after reloading, or choose another compatible copy.</p><label class="menu-button file-button" for="rom-file"><span aria-hidden="true">▶</span> Choose or replace ROM</label></div>
</section>
<section id="panel-map" class="tab-panel menu-panel map-panel" role="tabpanel" aria-labelledby="tab-map" data-panel="map" hidden>
<header><p class="menu-title">MAP</p><h2 id="map-location">No area loaded</h2><p id="map-position">Choose a ROM to see your position.</p></header>
<div class="world-map-frame"><canvas id="world-map" width="160" height="128"></canvas><p id="map-empty">Your current area will appear here after loading a game. A wider Johto and Kanto atlas is planned.</p></div>
</section>
<section id="panel-pokedex" class="tab-panel menu-panel planned-panel" role="tabpanel" aria-labelledby="tab-pokedex" data-panel="pokedex" hidden><p class="menu-title">POKÉDEX</p><h2>Planned</h2><p>Track the Pokémon you have seen and caught. Available after encounters and catching arrive.</p></section>
<section id="panel-link" class="tab-panel menu-panel planned-panel" role="tabpanel" aria-labelledby="tab-link" data-panel="link" hidden><p class="menu-title">LINK</p><h2>Planned</h2><p>Trade and battle with other players. Online Link is planned.</p></section>
<section id="panel-friends" class="tab-panel menu-panel planned-panel" role="tabpanel" aria-labelledby="tab-friends" data-panel="friends" hidden><p class="menu-title">FRIENDS</p><h2>Planned</h2><p>Keep your friends together and send private Link invites. Friends are planned.</p></section>
</main>
<footer><span>A personal cartridge, a pocket-sized world.</span><span>Independent project · No game assets included</span></footer>
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="eyebrow">NATIVE PORT · FIRST MILESTONE</p><h2>Make yourself at home.</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM. Select it again after a reload to resume.</p><p>Explore your bedroom, downstairs, New Bark Town and its buildings. Use A to read signs, the radio and supported conversations. An unfinished interaction shows a message and leaves progress unchanged.</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>Interact / Continue</dt><dd>X / Z or A / B</dd></dl><p>Sessions save every 10 seconds and when paused. Download a backup before clearing browser data. Switching apps pauses play.</p><p>This is an exploration milestone. Opening scenes, story progression, battles, NPC movement, room decorations, audio and routes beyond town are not ported yet. Online Link and real-world steps come after the original game systems.</p></dialog>`;
<div class="global-bar">
<button id="play" class="play-button" disabled>Play</button>
<p id="game-notice" role="status" class="game-notice"></p>
<p id="status" class="status" role="status" aria-live="polite">Ready when you are. Choose your ROM to get started.</p>
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
</div>
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream">
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2>Explore New Bark Town</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>Interact / Continue</dt><dd>X / Z or A / B</dd></dl><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.</p><p>This milestone includes your home, New Bark Town and Professor Elm's lab, with the original scenes, choices and your first Pokémon. Battles, routes, menus and audio are still planned.</p><dl><dt>Choose</dt><dd>Up / Down, then X or A. Z or B answers No.</dd></dl><p>An optional walking companion may support egg progress after party, eggs and hatching are complete.</p></dialog>`;
}
+786 -778
View File
File diff suppressed because it is too large. Load diff
+49
View File
@@ -0,0 +1,49 @@
const tabIds = ["game", "saves", "map", "pokedex", "link", "friends"] as const;
type TabId = (typeof tabIds)[number];
export function bindTabs(root: HTMLElement, onLeaveGame: () => void) {
const tabs = tabIds.map((id) =>
root.querySelector<HTMLButtonElement>(`#tab-${id}`),
);
const panels = tabIds.map((id) =>
root.querySelector<HTMLElement>(`#panel-${id}`),
);
if (tabs.some((item) => !item) || panels.some((item) => !item))
throw new Error("Crystal tabs require every tab and panel.");
let current: TabId = "game";
const select = (candidate: string) => {
if (!tabIds.includes(candidate as TabId)) return;
const next = candidate as TabId;
if (current === "game" && next !== "game") onLeaveGame();
current = next;
tabIds.forEach((id, index) => {
const selected = id === current;
const button = tabs[index]!;
button.setAttribute("aria-selected", String(selected));
button.tabIndex = selected ? 0 : -1;
button.innerHTML = `${selected ? '<span aria-hidden="true">▶</span>' : ""}${button.textContent?.replace(/^▶/, "").trim() ?? ""}`;
panels[index]!.hidden = !selected;
});
};
tabs.forEach((tab, index) => {
tab!.addEventListener("click", () => select(tabIds[index]));
tab!.addEventListener("keydown", (event) => {
let next = index;
if (event.key === "ArrowRight" || event.key === "ArrowDown")
next = (index + 1) % tabs.length;
else if (event.key === "ArrowLeft" || event.key === "ArrowUp")
next = (index - 1 + tabs.length) % tabs.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = tabs.length - 1;
else return;
event.preventDefault();
tabs[next]!.focus();
select(tabIds[next]);
});
});
select(current);
return { select, active: () => current };
}