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

+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;
}
}