Open Route 30 with map trainers and multi-Pokémon trainer battles

Map trainers run the ROM's TalkToTrainerScript and SeenByTrainerScript.
_CheckTrainerBattle runs before tile events at the end of each step and
on idle ticks; TrainerWalkToPlayer builds wMovementBuffer natively, and
writeobjectxy keeps the trainer where it stopped. The interpreter gains
the trainer header commands, wRunningTrainerBattleScript and hidden
items (HiddenItemScript).

Trainers with several Pokémon send out the next one through
EnemyPartyMonEntrance and FindMonInOTPartyToSwitchIntoBattle, including
its BattleRandom fallback. AI_SwitchOrTryItem, CheckAbleToSwitch and
AI_Switch are translated with their original bugs, and wPlayerUsedMoves
is recorded. The Shift-mode switch offer, trainer items, Pursuit on a
switch, Perish Song and Spikes remain refused.

Route 30 and its Berry house are enabled; Mr. Pokémon's house and
Route 31 stay closed. A refused trainer sighting does not restart until
the player moves, so a rollback cannot trap the player in view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-24 14:36:54 +02:00
1 parent 0d1bedb3b1
commit e00dfab42e
18 files changed
+1156 -62

No files matched your search

+404 -16
View File
@@ -97,8 +97,12 @@ export type BattleOutcome = { result: number; prizeMoney?: number };
export interface BattleTrainer {
name: string;
className: string;
/** wOTPartyMons: HP and status change only when a Pokémon faints or is
* withdrawn, as in the original. */
party: PartyMon[];
aiFlags: number;
/** TrainerClassAttributes' AI item/switch word (SWITCH_* in the low byte). */
switchFlags: number;
reward: number;
winText?: number;
lossText?: number;
@@ -128,6 +132,18 @@ const SUB1 = 1,
SUB5 = 5;
/** "n percent" in the original: n * $ff / 100, rounded down. */
const percent = (n: number) => Math.floor((n * 0xff) / 100);
/** TrainerClassAttributes AI_ITEM_SWITCH bits (constants/trainer_data_constants.asm). */
const SWITCH_OFTEN = 1 << 0,
SWITCH_RARELY = 1 << 1,
SWITCH_SOMETIMES = 1 << 2;
/** SwitchOften/SwitchRarely/SwitchSometimes: for each wEnemySwitchMonParam
* tier ($10, $20, $30), a Random threshold and whether a roll below it
* switches (true) or keeps the Pokémon in (false). */
const SWITCH_CHANCES: readonly (readonly [number, boolean])[][] = [
[[percent(50) + 1, true], [percent(79) - 1, true], [percent(4), false]],
[[percent(8), true], [percent(12), true], [percent(79) - 1, false]],
[[percent(20) - 1, true], [percent(50) + 1, true], [percent(20) - 1, false]],
];
export interface TypeMatchup {
attacker: number;
@@ -248,6 +264,13 @@ export class Battle {
/** wEnemyMonBaseStats: five base stats, catch rate and base exp. */
enemyBase = { stats: [0, 0, 0, 0, 0], catchRate: 0, exp: 0 };
weather: number = C.WEATHER_NONE;
/** wCurOTMon: the trainer's party index in battle. */
curOTMon = 0;
/** wEnemySwitchMonIndex: the AI's chosen party index plus one, or 0. */
private enemySwitchMonIndex = 0;
private battleHasJustStarted = 0;
/** wPlayerUsedMoves: the player's distinct moves since sending out. */
private playerUsedMoves = [0, 0, 0, 0];
private curDamage = 0;
private criticalHit = 0;
private attackMissed = 0;
@@ -323,6 +346,7 @@ export class Battle {
switch (offset) {
case at("UsedMoveText"): {
// Updates the last move, then picks the grammar for "used".
if (this.turn === 0) this.updateUsedMoves(this.player.move.anim);
const user = this.user;
if (!user.charging) {
user.lastCounterMove = user.move.anim;
@@ -373,11 +397,12 @@ export class Battle {
// --------------------------------------------------------------- setup
/** LoadEnemyMon for a wild Pokémon from a grass or water table. */
/** LoadEnemyMon: the trainer's wCurOTMon, or a wild Pokémon from a grass
* or water table. */
private loadEnemyMon() {
const trainer = this.options.trainer;
if (trainer) {
const mon = trainer.party[0],
const mon = trainer.party[this.curOTMon],
base = this.options.data.baseData(mon.species);
this.enemy.mon = {
species: mon.species,
@@ -476,6 +501,7 @@ export class Battle {
if (mon.status & (1 << C.BRN)) mon.stats[C.ATTACK] = Math.max(1, mon.stats[C.ATTACK] >> 1);
}
private newStatus(side: Side) {
if (side === this.player) this.playerUsedMoves = [0, 0, 0, 0];
side.sub = [0, 0, 0, 0, 0, 0];
side.levels = [7, 7, 7, 7, 7, 7, 7];
side.lastMove = 0;
@@ -504,10 +530,8 @@ export class Battle {
const trainer = this.options.trainer;
if (trainer) {
validateTrainerAI(trainer.aiFlags);
if (trainer.party.length !== 1)
throw new UnsupportedBattle(
"Trainer party switching is not ported yet.",
);
if (trainer.party.length < 1 || trainer.party.length > 6)
throw new UnsupportedBattle("Invalid trainer party.");
if (
!Number.isInteger(trainer.reward) ||
trainer.reward < 0 ||
@@ -858,16 +882,20 @@ export class Battle {
private *battlePlayerFirst(): Gen {
this.enemyGoesFirst = 0;
this.turn = 1;
const switched = yield* this.aiSwitchOrTryItem();
yield* this.doTurnFor(0);
if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint();
if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint();
this.turn = 0;
if (!(yield* this.residualDamage())) return yield* this.handlePlayerMonFaint();
yield this.hud();
if (this.tryEnemyFlee()) return yield* this.wildFled();
yield* this.doTurnFor(1);
if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint();
if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint();
if (!switched) {
if (this.tryEnemyFlee()) return yield* this.wildFled();
yield* this.doTurnFor(1);
if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint();
if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint();
}
this.turn = 1;
if (!(yield* this.residualDamage())) return yield* this.handleEnemyMonFaint();
yield this.hud();
@@ -875,10 +903,13 @@ export class Battle {
}
private *battleEnemyFirst(): Gen {
if (this.tryEnemyFlee()) return yield* this.wildFled();
this.turn = 1;
this.enemyGoesFirst = 1;
yield* this.doTurnFor(1);
if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint();
if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint();
if (!(yield* this.aiSwitchOrTryItem())) {
yield* this.doTurnFor(1);
if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint();
if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint();
}
this.turn = 1;
if (!(yield* this.residualDamage())) return yield* this.handleEnemyMonFaint();
yield this.hud();
@@ -1991,7 +2022,358 @@ export class Battle {
yield* this.updateStateAndExperienceAfterEnemyFaint(false);
if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle();
yield this.hud();
this.battleEnded = true;
// A wild battle ends; so does a trainer's with no Pokémon left (WinTrainerBattle).
if (!this.options.trainer || this.enemyTrainerDefeated()) {
this.battleEnded = true;
return;
}
// AskUseNextPokemon and ForcePlayerMonChoice.
if (!this.player.mon.hp) throw new UnsupportedBattle("Switching Pokémon is not ported yet.");
// HandleEnemySwitch: the player's Pokémon stays in.
yield* this.enemyPartyMonEntrance();
this.battlePlayerAction = 0;
}
/** CheckEnemyTrainerDefeated: every party HP is zero. */
private enemyTrainerDefeated() {
return this.options.trainer!.party.every((mon) => mon.hp === 0);
}
/** EnemyPartyMonEntrance after a faint, while the player's Pokémon stands. */
private *enemyPartyMonEntrance(): Gen {
this.enemySwitchMonIndex = 0;
this.newEnemyMonStatus();
this.enemy.levels = [7, 7, 7, 7, 7, 7, 7]; // ResetEnemyStatLevels
this.breakAttraction();
yield* this.enemySwitch();
this.participants = 1 << this.curBattleMon; // ResetBattleParticipants
this.turn = 1;
if (this.enemy.screens & (1 << C.SCREENS_SPIKES))
throw new UnsupportedBattle("Spikes are not ported yet.");
this.enemy.move.anim = 0;
}
/** EnemySwitch and EnemySwitch_SetMode. The Shift-mode offer to switch
* too (CheckWhetherToAskSwitch) needs player switching and is refused. */
private *enemySwitch(): Gen {
if (!this.battleHasJustStarted && this.options.party.length > 1 && this.partyMon.hp)
throw new UnsupportedBattle("Switching Pokémon is not ported yet.");
this.resetEnemyBattleVars();
// CheckWhetherSwitchmonIsPredetermined, else the type-matchup choice.
const index = this.enemySwitchMonIndex
? this.enemySwitchMonIndex - 1
: this.battleHasJustStarted
? 0
: this.findMonInOTPartyToSwitchIntoBattle();
if (!this.options.trainer!.party[index]?.hp)
throw new UnsupportedBattle("Invalid trainer switch.");
// LoadEnemyMonToSwitchTo: LoadEnemyMon records wCurOTMon.
this.curOTMon = index;
this.loadEnemyMon();
yield* this.say(BATTLE.BattleText_EnemySentOut);
yield this.hud();
}
/** ResetEnemyBattleVars. wEnemyItemState is unused while trainer items are refused. */
private resetEnemyBattleVars() {
this.player.lastCounterMove = 0;
this.enemy.lastCounterMove = 0;
this.enemy.lastMove = 0;
this.enemy.curMove = 0;
this.player.wrapCount = 0;
}
/** NewEnemyMonStatus: also clears the player's counter move, both wrap
* counts and the player's inability to run. */
private newEnemyMonStatus() {
const enemy = this.enemy;
this.player.lastCounterMove = 0;
enemy.lastCounterMove = 0;
enemy.lastMove = 0;
enemy.sub = [0, 0, 0, 0, 0, 0];
enemy.disableCount = 0;
enemy.furyCutterCount = 0;
enemy.protectCount = 0;
enemy.rageCounter = 0;
enemy.disabledMove = 0;
enemy.minimized = 0;
this.player.wrapCount = 0;
enemy.wrapCount = 0;
enemy.turnsTaken = 0;
this.player.reset(SUB5, C.SUBSTATUS_CANT_RUN);
}
private breakAttraction() {
this.player.reset(SUB1, C.SUBSTATUS_IN_LOVE);
this.enemy.reset(SUB1, C.SUBSTATUS_IN_LOVE);
}
/** UpdateUsedMoves: append a new move, dropping the oldest when full. */
private updateUsedMoves(move: number) {
const used = this.playerUsedMoves;
if (used.includes(move) && move) return;
const empty = used.indexOf(0);
if (empty >= 0) used[empty] = move;
else this.playerUsedMoves = [used[1], used[2], used[3], move];
}
/** CheckTypeMatchup's wTypeMatchup (EFFECTIVE is 10). */
private typeMatchup(moveType: number, types: readonly number[], identified: boolean) {
return applyTypeMatchups(this.matchups, {
damage: 0,
modifier: 0,
moveType,
userTypes: [],
targetTypes: types,
identified,
}).matchup;
}
/** FindMonInOTPartyToSwitchIntoBattle and ScoreMonTypeMatchups: prefer
* the first Pokémon with a super-effective move that the player's types
* don't also threaten, then the first unthreatened one, else a random
* standing one (drawn with BattleRandom). */
private findMonInOTPartyToSwitchIntoBattle(): number {
const party = this.options.trainer!.party,
data = this.options.data,
count = party.length;
// wEnemyEffectivenessVsPlayerMons / wPlayerEffectivenessVsEnemyMons,
// each shifted left once per Pokémon from a sentinel bit.
let enemyBits = 1,
playerBits = 1;
for (let index = 0; index < count; index++) {
enemyBits = (enemyBits << 1) & 0xff;
playerBits = (playerBits << 1) & 0xff;
const mon = party[index];
if (index === this.curOTMon || !mon.hp) {
playerBits |= 1;
continue;
}
// LookUpTheEffectivenessOfEveryMove, on the enemy's turn.
for (const move of mon.moves) {
if (!move) break;
const identified = this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED);
if (this.typeMatchup(data.move(move).type, this.player.mon.types, identified) > C.EFFECTIVE) {
enemyBits |= 1;
break;
}
}
// IsThePlayerMonTypesEffectiveAgainstOTMon, on the player's turn.
const types = data.baseData(mon.species).types,
identified = this.enemy.has(SUB1, C.SUBSTATUS_IDENTIFIED);
if (this.player.mon.types.some((type) => this.typeMatchup(type, types, identified) > C.EFFECTIVE)) {
if (enemyBits & 1) enemyBits &= ~1;
else playerBits |= 1;
}
}
enemyBits = (enemyBits << 1) & 0xff;
playerBits = (playerBits << 1) & 0xff;
// Align the first Pokémon to bit 7 by shifting the sentinel out.
for (;;) {
const carry = playerBits & 0x80;
enemyBits = (enemyBits << 1) & 0xff;
playerBits = (playerBits << 1) & 0xff;
if (carry) break;
}
let discouraged = 0;
while (discouraged < count && playerBits & (0x80 >> discouraged)) discouraged++;
if (discouraged < count) {
const bits = enemyBits || (~playerBits & 0xff);
for (let index = 0; index < 8; index++) if (bits & (0x80 >> index)) return index;
}
for (let guard = 0; guard < 4096; guard++) {
const index = this.random() & 7;
if (index < count && index !== this.curOTMon && party[index].hp) return index;
}
throw new UnsupportedBattle("The trainer has no Pokémon to send out.");
}
/** AI_SwitchOrTryItem (engine/battle/ai/items.asm). Returns true when the
* enemy spent its turn. Trainers holding items are refused before battle,
* so AI_TryItem finds none. */
private *aiSwitchOrTryItem(): Gen<boolean> {
const trainer = this.options.trainer;
if (!trainer || this.checkEnemyLockedIn()) return false;
if (this.player.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.enemy.wrapCount) return false;
const style = [SWITCH_OFTEN, SWITCH_RARELY, SWITCH_SOMETIMES].findIndex(
(bit) => trainer.switchFlags & bit,
);
if (style < 0) return false;
const param = this.checkAbleToSwitch(),
tier = param & 0xf0;
if (!tier) return false;
const [threshold, below] = SWITCH_CHANCES[style][Math.min(tier >> 4, 3) - 1];
if (this.random() < threshold !== below) return false;
this.enemySwitchMonIndex = (param & 0x0f) + 1;
// AI_TrySwitch: the withdrawn Pokémon's stale party HP still counts.
if (trainer.party.filter((mon) => mon.hp).length < 2) return false;
yield* this.aiSwitch();
return true;
}
/** AI_Switch: withdraw the enemy Pokémon and send out the chosen one. */
private *aiSwitch(): Gen {
const trainer = this.options.trainer!;
this.enemyGoesFirst = 1;
this.enemy.reset(SUB4, C.SUBSTATUS_RAGE);
this.turn = 0;
// PursuitSwitch reads wCurPlayerMove's effect.
if (this.player.curMove && this.options.data.move(this.player.curMove).effect === C.EFFECT_PURSUIT)
throw new UnsupportedBattle("Pursuit on a switching Pokémon is not ported yet.");
// Status and HP go back to the party (MON_STATUS through MON_HP).
const outgoing = trainer.party[this.curOTMon];
outgoing.status = this.enemy.mon.status;
outgoing.hp = this.enemy.mon.hp;
yield* this.say(BATTLE.EnemyWithdrewText);
this.battleHasJustStarted = 1;
this.newEnemyMonStatus();
this.enemy.levels = [7, 7, 7, 7, 7, 7, 7];
this.player.reset(SUB1, C.SUBSTATUS_IN_LOVE);
yield* this.enemySwitch();
this.participants = 1 << this.curBattleMon;
this.battleHasJustStarted = 0;
}
/** CheckAbleToSwitch (engine/battle/ai/switch.asm): wEnemySwitchMonParam,
* a chance tier in the high nibble and a party index in the low one.
* CheckTypeMatchup reads BATTLE_VARS_MOVE_TYPE rather than the type its
* callers load, so with hBattleTurn on the enemy every matchup here uses
* the enemy's chosen move type (docs/bugs_and_glitches.md). */
private checkAbleToSwitch(): number {
const party = this.options.trainer!.party,
data = this.options.data;
const matchup = (types: readonly number[]) =>
this.typeMatchup(this.enemy.move.type, types, this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED));
const bit = (index: number) => 0x20 >> index;
const damaging = (move: number) => data.move(move).power !== 0;
const baseTypes = (index: number) => data.baseData(party[index].species).types;
// FindAliveEnemyMons: the others still standing.
const aliveOthers = () => {
let mask = 0;
if (party.length >= 2)
party.forEach((mon, index) => {
if (index !== this.curOTMon && mon.hp) mask |= bit(index);
});
return mask;
};
// FindEnemyMonsWithAtLeastQuarterMaxHP. BUG: srl c / rl b rotate the low
// HP byte into the high byte instead of multiplying the HP by four.
const quarterHP = (mask: number) => {
let chosen = 0;
party.forEach((mon, index) => {
const high = mon.hp >> 8,
low = mon.hp & 0xff;
const rotated = ((((high << 2) | ((low & 1) << 1) | ((low >> 1) & 1)) & 0xff) << 8) | (low >> 2);
if (rotated > mon.stats[0]) chosen |= bit(index);
});
return mask & chosen;
};
// FindEnemyMonsThatResistPlayer: every branch reduces to one matchup.
const resisting = (mask: number) => {
let chosen = 0;
party.forEach((_, index) => {
if (matchup(baseTypes(index)) <= C.EFFECTIVE) chosen |= bit(index);
});
return mask & chosen;
};
// FindEnemyMonsWithASuperEffectiveMove. `e` is not reset between
// Pokémon, so one neutral move encourages every later candidate.
const superEffective = (mask: number) => {
let e = 0,
encouraged = 0;
for (let index = 0; index < 6; index++) {
if (!(mask & bit(index))) continue;
for (const move of party[index].moves) {
if (!move) break;
if (!damaging(move)) continue;
const value = matchup(this.player.mon.types);
if (value < C.EFFECTIVE) continue;
e = 1;
if (value === C.EFFECTIVE) continue;
e = 2;
break;
}
if (e === 2) return { e, score: index };
if (e === 1) encouraged |= bit(index);
}
for (let index = 0; index < 6; index++) if (encouraged & bit(index)) return { e, score: index };
return { e, score: 0xff };
};
// FindAliveEnemyMonsWithASuperEffectiveMove: standing, including the
// current Pokémon.
const standingSuperEffective = (mask: number) => {
let standing = 0;
party.forEach((mon, index) => {
if (mon.hp) standing |= bit(index);
});
return superEffective(mask & standing);
};
if (!aliveOthers()) return 0;
if (this.enemy.has(SUB1, C.SUBSTATUS_PERISH))
throw new UnsupportedBattle("Perish Song is not ported yet.");
if (this.checkPlayerMoveTypeMatchups() > C.EFFECTIVE) return 0;
const lastCounter = this.player.lastCounterMove;
if (lastCounter) {
// FindEnemyMonsImmuneToLastCounterMove.
let immune = 0;
party.forEach((mon, index) => {
if (index === this.curOTMon || !mon.hp || !damaging(lastCounter)) return;
if (matchup(baseTypes(index)) === 0) immune |= bit(index);
});
if (immune) {
const { e, score } = superEffective(immune);
if (score === 0xff) return 0;
if (e !== 2) {
if (this.checkPlayerMoveTypeMatchups() >= C.EFFECTIVE) return 0;
return score + 0x10;
}
return score + (this.checkPlayerMoveTypeMatchups() < C.EFFECTIVE ? 0x20 : 0x10);
}
}
if (this.checkPlayerMoveTypeMatchups() >= C.EFFECTIVE) return 0;
const { e, score } = standingSuperEffective(resisting(quarterHP(aliveOthers())));
return e === 2 ? score + 0x10 : 0;
}
/** CheckPlayerMoveTypeMatchups: wEnemyAISwitchScore from BASE_AI_SWITCH_SCORE
* (10); lower means the enemy is worse off. Matchups use the enemy's move
* type (see checkAbleToSwitch). */
private checkPlayerMoveTypeMatchups(): number {
const data = this.options.data,
identified = this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED),
type = this.enemy.move.type;
const vsEnemy = () => this.typeMatchup(type, this.enemy.mon.types, identified),
vsPlayer = () => this.typeMatchup(type, this.player.mon.types, identified);
let score = 10;
const used = this.playerUsedMoves;
if (!used[0]) {
if (vsEnemy() > C.EFFECTIVE) score--;
if (this.player.mon.types[1] !== this.player.mon.types[0] && vsEnemy() > C.EFFECTIVE) score--;
} else {
let e = 0,
superEffective = false;
for (const move of used) {
if (!move) break;
if (!data.move(move).power) continue;
const value = vsEnemy();
if (value > C.EFFECTIVE) {
score--;
superEffective = true;
break;
}
if (!value) continue;
if (value === C.EFFECTIVE) e = 2;
else if (e < 1) e = 1;
}
if (!superEffective && e !== 2) score += e ? 1 : 2;
}
// .CheckEnemyMoveMatchups
let c = 0;
for (const move of this.enemy.mon.moves) {
if (!move) break;
if (!data.move(move).power) continue;
const value = vsPlayer();
if (!value) continue;
c++;
if (value < C.EFFECTIVE) continue;
c += 5;
if (value > C.EFFECTIVE) c = 100;
}
if (!c) score -= 2;
else if (c < 5) score--;
else if (c >= 100) score++;
return score & 0xff;
}
private *faintYourPokemon(): Gen {
yield* this.say(BATTLE.BattleText_MonFainted);
@@ -2003,8 +2385,10 @@ export class Battle {
if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle();
if (!this.enemy.mon.hp) {
yield* this.updateStateAndExperienceAfterEnemyFaint(true);
this.battleEnded = true;
return;
if (!this.options.trainer || this.enemyTrainerDefeated()) {
this.battleEnded = true;
return;
}
}
throw new UnsupportedBattle("Switching Pokémon is not ported yet.");
}
@@ -2035,8 +2419,12 @@ export class Battle {
/** UpdateBattleStateAndExperienceAfterEnemyFaint and GiveExperiencePoints. */
private *updateStateAndExperienceAfterEnemyFaint(playerFaintedFirst: boolean): Gen {
this.updateBattleMonInParty();
const trainer = this.options.trainer;
if (trainer) trainer.party[this.curOTMon].hp = 0;
this.player.reset(SUB3, C.SUBSTATUS_IN_LOOP);
this.enemy.damageTaken = 0;
this.newEnemyMonStatus();
this.breakAttraction();
if (!this.player.mon.hp && !playerFaintedFirst) this.updateFaintedPlayerMon();
if (!this.options.party.some((mon) => mon.hp > 0)) return;
this.battleResult &= 0xc0; // WIN
+1
View File
@@ -81,6 +81,7 @@ export const C = {
EFFECT_EVASION_UP: 16,
EFFECT_ATTACK_UP_2: 50,
EFFECT_EVASION_UP_2: 56,
EFFECT_PURSUIT: 128,
// Moves (constants/move_constants.asm)
GUST: 16,
+11
View File
@@ -52,6 +52,9 @@ export const SYMBOLS = {
findItemInBallScript: 0x122ce,
caughtAskNicknameText: 0x4db44,
nicknameStrings: 0x11780,
hiddenItemScript: 0x13625,
talkToTrainerScript: 0xbe66a,
seenByTrainerScript: 0xbe675,
wildBattleScript: 0x97cf9,
battleWhiteoutScript: 0x124c1,
spawnPoints: 0x152ab,
@@ -92,6 +95,8 @@ export const NATIVE_ROUTINES = {
tryResetFruitTrees: 0x4404c,
checkFruitTree: 0x44055,
pickedFruitTree: 0x4405f,
trainerWalkToPlayer: 0x831e,
setMemEvent: 0x1364f,
} as const;
/** Native handlers are selected by the routine a special index points to,
@@ -130,6 +135,9 @@ export const RAM = {
dailyFlags1: 0xdc1e,
unusedDailyFlag: 0xdc21,
itemQuantityChange: 0xd10c,
movementBuffer: 0xd007,
hiddenItemID: 0xd040,
runningTrainerBattleScript: 0xd04d,
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */
moneyTemp: 0xffc3,
} as const;
@@ -200,6 +208,7 @@ export const BATTLE = {
DisabledNoMoreText: 0x80d39,
DoesntAffectText: 0x81071,
EnemyText: 0x1295,
EnemyWithdrewText: 0x384d0,
ExpPointsText: 0x3f131,
FastAsleepText: 0x80ce7,
FellAsleepText: 0x811b1,
@@ -289,11 +298,13 @@ export const NATIVE_MAPS: Record<string, string> = {
"24:7": "Your room",
"24:8": "Neighbor’s house",
"24:9": "Elm’s house",
"26:1": "Route 30",
"26:3": "Cherrygrove City",
"26:4": "Poké Mart",
"26:5": "Pokémon Center",
"26:6": "Gym speech house",
"26:7": "Guide Gent’s house",
"26:8": "Evolution speech house",
"26:9": "Berry house",
};
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
+155 -5
View File
@@ -25,6 +25,7 @@ import {
generatePartyMon,
type PartyMon,
} from "../party/pokemon";
import { TrainerData, type TrainerHeader } from "../rom/trainers";
import { DIRECTION_ORDER, type Direction } from "../world/types";
import { readMovement, type MovementStep } from "./movement";
import {
@@ -184,6 +185,8 @@ export interface ScriptHost {
/** The player's facing and position, for readvar. */
facing(): number;
position(): { x: number; y: number };
/** OBJECT_MAP_X/Y of a visible object (script numbering), if any. */
objectPosition(id: number): { x: number; y: number } | undefined;
}
export class UnsupportedScript extends Error {
@@ -202,6 +205,43 @@ type Input = number | string | undefined;
/** ITEM_FROM_MEM: take the item from wScriptVar. */
const ITEM_FROM_MEM = 0xff;
/** TrainerWalkToPlayer and ComputePathToWalkToPlayer (home/movement.asm):
* a one-tick sleep, then normal steps to the player's tile, dropping the
* last step so the trainer stops beside the player. Returns the raw
* wMovementBuffer bytes. */
export function trainerWalkBuffer(
trainer: { x: number; y: number },
player: { x: number; y: number },
distance: number,
): number[] {
const SLEEP_1 = 0x3e,
STEP = 0x0c,
END = 0x47;
const [DOWN, UP, LEFT, RIGHT] = [0, 1, 2, 3];
const buffer = [SLEEP_1];
if ((distance - 1) & 0xff) {
let x = (trainer.x - player.x) & 0xff,
h = LEFT;
if (trainer.x < player.x) {
x = (player.x - trainer.x) & 0xff;
h = RIGHT;
}
let y = (trainer.y - player.y) & 0xff,
v = UP;
if (trainer.y < player.y) {
y = (player.y - trainer.y) & 0xff;
v = DOWN;
}
// Swapped only when the y distance is the smaller one.
const path: [number, number][] = y < x ? [[v, y], [h, x]] : [[h, x], [v, y]];
for (const [direction, count] of path)
for (let i = 0; i < count; i++) buffer.push(STEP | direction);
buffer.pop(); // DecrementMovementBufferCount
}
buffer.push(END);
return buffer;
}
/** A native interpreter for the game's own event format, NOT GB assembly.
* Execution is resumable: presentation effects are yielded and the caller
* resumes with button, choice or animation completion. All state changes are
@@ -222,6 +262,13 @@ export class ScriptRun {
/** wCurFruitTree and wCurFruit, set by fruittree and GetCurTreeFruit. */
private fruitTree = 0;
private fruit = 0;
/** wTempTrainer, copied from a map trainer's header by LoadTrainer_continue. */
private tempTrainer?: TrainerHeader;
/** wRunningTrainerBattleScript: zeroed by LoadTrainer_continue, set to -1
* after the battle so the trainer's after-script ends at once. */
private runningTrainerBattleScript = 0;
/** wMovementBuffer, filled by TrainerWalkToPlayer. */
private movementBuffer?: number[];
private iterator: Generator<ScriptEffect, void, Input>;
private instructions = 0;
@@ -237,10 +284,17 @@ export class ScriptRun {
/** wItemBallItemID/wItemBallQuantity, copied from the itemball data the
* facing object's script pointer names. */
itemBall?: { item: number; quantity: number };
/** A map trainer (TalkToTrainer or _CheckTrainerBattle): its header
* address and wSeenTrainerDistance. */
trainer?: { header: number; distance: number };
/** wHiddenItemData, copied from a BGEVENT_ITEM's hiddenitem data. */
hiddenItem?: { event: number; item: number };
} = {},
) {
this.state = copyScriptState(state);
this.lastTalked = host.lastTalked;
if (options.trainer)
this.tempTrainer = new TrainerData(reader).header(options.trainer.header);
this.iterator = this.execute(start);
}
@@ -273,8 +327,15 @@ export class ScriptRun {
spawn = 0;
let wild = this.options.wild;
let trainer: { classId: number; id: number } | undefined;
let winText: number | undefined, lossText: number | undefined;
// LoadTrainer_continue's copy fills wWinTextPointer/wLossTextPointer too.
let winText = this.tempTrainer?.beatenText || undefined,
lossText = this.tempTrainer?.lostScript || undefined;
let canLose = false;
const temp = (offset: number, opcode: number) => {
if (!this.tempTrainer)
throw new UnsupportedScript(offset, opcode, " (no map trainer)");
return this.tempTrainer;
};
// One array for the whole run: specials append to and clear it in place.
const pages: string[] = [];
const byte = () => r.byte(p++);
@@ -441,6 +502,18 @@ export class ScriptRun {
this.state.fruitTrees.add(this.fruitTree);
else if (routine === NATIVE_ROUTINES.getWhiteoutSpawn)
spawn = findSpawn(this.state.lastSpawnMap);
else if (routine === NATIVE_ROUTINES.setMemEvent) {
// SetMemEvent: the hidden item's wHiddenItemEvent flag.
const hidden = this.options.hiddenItem;
if (!hidden) throw new UnsupportedScript(offset, opcode, " (no hidden item)");
this.state.events.add(hidden.event);
} else if (routine === NATIVE_ROUTINES.trainerWalkToPlayer) {
const distance = this.options.trainer?.distance,
from = this.host.objectPosition(this.lastTalked);
if (distance === undefined || !from || this.lastTalked === PLAYER)
throw new UnsupportedScript(offset, opcode, " (no seen trainer)");
this.movementBuffer = trainerWalkBuffer(from, this.host.position(), distance);
}
else if (routine === NATIVE_ROUTINES.tryReceiveItem) {
// FindItemInBallScript.TryReceiveItem: give the ball's item and
// name it for FoundItemText (wStringBuffer3), matching GetItemName
@@ -459,9 +532,22 @@ export class ScriptRun {
break;
}
case 0x19: {
// readmem: only the fruit a tree bears is readable so far.
if (word() !== RAM.curFruit) throw new UnsupportedScript(offset, opcode);
value = this.fruit;
// readmem: the fruit a tree bears, or a hidden item's ID.
const address = word();
if (address === RAM.curFruit) value = this.fruit;
else if (address === RAM.hiddenItemID && this.options.hiddenItem)
value = this.options.hiddenItem.item;
else throw new UnsupportedScript(offset, opcode);
break;
}
case 0x1b: {
// loadmem: only the trainer script's wRunningTrainerBattleScript.
const address = word(),
stored = byte();
if (address !== RAM.runningTrainerBattleScript)
throw new UnsupportedScript(offset, opcode);
temp(offset, opcode);
this.runningTrainerBattleScript = stored;
break;
}
case 0x9b: {
@@ -492,6 +578,53 @@ export class ScriptRun {
trainer = { classId: byte(), id: byte() };
wild = undefined;
break;
case 0x5c: {
// loadtemptrainer: wOtherTrainerClass/ID from wTempTrainer.
const header = temp(offset, opcode);
trainer = { classId: header.classId, id: header.id };
wild = undefined;
break;
}
case 0x62: {
// trainertext: seen, beaten or loss text from wTempTrainer.
const header = temp(offset, opcode),
text = [header.seenText, header.beatenText, header.lostScript][byte()];
if (!text) throw new UnsupportedScript(offset, opcode, " (text)");
write(text);
break;
}
case 0x63: {
// trainerflagaction on wTempTrainerEventFlag. FlagAction leaves a
// nonzero mask after setting or clearing, so wScriptVar is TRUE.
const flag = temp(offset, opcode).eventFlag,
action = byte();
if (action === 0) this.state.events.delete(flag);
else if (action === 1) this.state.events.add(flag);
else if (action !== 2)
throw new UnsupportedScript(offset, opcode, " (flag action)");
value = action === 2 ? Number(this.state.events.has(flag)) : 1;
break;
}
case 0x65: {
const after = temp(offset, opcode).afterScript;
if (!after) throw new UnsupportedScript(offset, opcode, " (no script)");
p = after;
break;
}
case 0x66:
// endifjustbattled: Script_end, which also returns from a call.
temp(offset, opcode);
if (!this.runningTrainerBattleScript) break;
if (stack.length) {
p = stack.pop()!;
break;
}
yield* flush();
return;
case 0x67:
temp(offset, opcode);
value = Number(this.runningTrainerBattleScript !== 0);
break;
case 0x64: {
// Null pointers request the battle engine's default end text.
const bank = r.bank(p), won = word(), lost = word();
@@ -767,7 +900,13 @@ export class ScriptRun {
case 0x69:
case 0x6a: {
const target = opcode === 0x69 ? object(byte()) : this.lastTalked;
const steps = readMovement(r, near());
let steps: MovementStep[];
if (r.word(p) === RAM.movementBuffer) {
word();
const buffer = this.movementBuffer;
if (!buffer) throw new UnsupportedScript(offset, opcode, " (movement buffer)");
steps = readMovement({ byte: (index) => buffer[index] }, 0);
} else steps = readMovement(r, near());
yield* flush();
yield synchronous({ kind: "move", object: target, steps });
break;
@@ -814,6 +953,15 @@ export class ScriptRun {
yield synchronous({ kind: "moveobject", object: target, x, y });
break;
}
case 0x73: {
// writeobjectxy: the object's current tile becomes its map position.
const target = object(byte()),
position = this.host.objectPosition(target);
if (target === PLAYER || !position)
throw new UnsupportedScript(offset, opcode, " (object)");
yield synchronous({ kind: "moveobject", object: target, ...position });
break;
}
case 0x75: {
const emote = byte(),
target = byte(),
@@ -844,6 +992,7 @@ export class ScriptRun {
word();
byte();
break;
case 0x80: // encountermusic
case 0x82:
case 0x83:
case 0x86:
@@ -1226,6 +1375,7 @@ export function runScript(
landmark: () => 0,
facing: () => 0,
position: () => ({ x: 0, y: 0 }),
objectPosition: () => undefined,
});
const pages: string[] = [];
for (let effect = run.resume(); effect; effect = run.resume()) {
+6 -2
View File
@@ -28,8 +28,12 @@ export class UnsupportedMovement extends Error {
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[] {
* rather than approximating a jump, slide or special effect. The source is
* usually the ROM; wMovementBuffer data built natively decodes the same way. */
export function readMovement(
reader: Pick<RomReader, "byte">,
start: number,
): MovementStep[] {
const steps: MovementStep[] = [];
let p = start;
for (let count = 0; count < 256; count++) {
+103 -16
View File
@@ -188,6 +188,8 @@ interface ActiveScript {
scene: boolean;
/** newloadmap: entered after the script commits. */
warp?: { map: string; x: number; y: number };
/** The trainer whose sight started this script, if any. */
sighted?: number;
}
const OPPOSITE: Record<Direction, Direction> = {
@@ -250,6 +252,7 @@ const WONT_DELETE = 1 << 1,
MOVE_ANYWHERE = 1 << 5,
NOCLIP_OBJS = 1 << 6,
SWIMMING = 1 << 5;
const OBJECTTYPE_TRAINER = 2;
/** STEP_SLOW: random walkers take 16 ticks per tile. */
const NPC_STEP_TICKS = 16;
const isTimed = (effect: ScriptEffect | undefined) =>
@@ -296,6 +299,10 @@ export class GameWorld {
/** Scene scripts are re-evaluated only after the map or state changes. */
private sceneCheck = true;
private dailyFlags?: number[];
/** A trainer sighting whose script was refused. Rollback leaves the player
* in view; without this the trainer would restart the same refused
* script every tick until the player could never move away. */
private refusedSighting?: { map: string; id: number; x: number; y: number };
constructor(
readonly assets: CrystalAssets,
@@ -663,6 +670,9 @@ export class GameWorld {
this.applyMap(entry);
return;
}
// PlayerEvents: CheckTrainerEvent precedes CheckTileEvent, and a seen
// player's warp, coordinate event and step count are then skipped.
if (this.checkTrainers()) return;
const warp = map.warps.find(
(warp) => warp.x === player.x && warp.y === player.y,
);
@@ -678,7 +688,7 @@ export class GameWorld {
event.x === player.x &&
event.y === player.y,
);
if (event) this.startScript(event.script, PLAYER, previous);
if (event) this.startScript(event.script, PLAYER, { committedPosition: previous });
else this.randomEncounter(previous);
}
@@ -712,7 +722,7 @@ export class GameWorld {
if (!chosen.species || chosen.species > 251 || chosen.species === C.UNOWN)
return;
if (!this.state.party.some((mon) => mon.hp > 0)) return;
this.startScript(SYMBOLS.wildBattleScript, PLAYER, previous, false, chosen);
this.startScript(SYMBOLS.wildBattleScript, PLAYER, { committedPosition: previous, wild: chosen });
}
// ------------------------------------------------------------ map entry
@@ -823,6 +833,13 @@ export class GameWorld {
landmark: () => this.assets.map(mapId).landmark,
facing: () => DIRECTION_ORDER.indexOf(this.player.direction),
position: () => ({ x: this.player.x, y: this.player.y }),
objectPosition: (id) => {
if (id === PLAYER) return { x: this.player.x, y: this.player.y };
const object = this.map.objects.find((candidate) => candidate.id === id);
if (!object || !this.spawned.has(id)) return undefined;
const actor = this.actor(object);
return { x: actor.x, y: actor.y };
},
};
}
private weekdayName(day: number) {
@@ -837,10 +854,23 @@ export class GameWorld {
private startScript(
start: number,
lastTalked: number,
committedPosition?: { x: number; y: number },
scene = false,
wild?: { species: number; level: number },
itemBall?: { item: number; quantity: number },
{
committedPosition,
scene = false,
wild,
itemBall,
trainer,
hiddenItem,
sighted,
}: {
committedPosition?: { x: number; y: number };
scene?: boolean;
wild?: { species: number; level: number };
itemBall?: { item: number; quantity: number };
trainer?: { header: number; distance: number };
hiddenItem?: { event: number; item: number };
sighted?: number;
} = {},
) {
const committed = this.save();
if (committedPosition) {
@@ -858,6 +888,8 @@ export class GameWorld {
{
wild,
itemBall,
trainer,
hiddenItem,
},
),
committed,
@@ -873,6 +905,7 @@ export class GameWorld {
waitTicks: 0,
moving: new Map(),
scene,
sighted,
};
this.pump();
}
@@ -1016,6 +1049,7 @@ export class GameWorld {
className: opponent.className,
party: trainers.createParty(opponent, { otId: this.playerId, ot: this.name }),
aiFlags: opponent.attributes.aiMoveFlags,
switchFlags: opponent.attributes.aiItemSwitchFlags,
reward: opponent.reward,
winText: effect.trainer?.winText,
lossText: effect.trainer?.lossText,
@@ -1226,6 +1260,13 @@ export class GameWorld {
this.dialogue = undefined;
this.emote = undefined;
this.picture = undefined;
if (script.sighted !== undefined)
this.refusedSighting = {
map: this.mapId,
id: script.sighted,
x: this.player.x,
y: this.player.y,
};
this.refuse(error);
this.notice =
"This interaction needs game logic that has not been ported yet. Your progress is unchanged.";
@@ -1461,6 +1502,41 @@ export class GameWorld {
);
}
/** _CheckTrainerBattle (home/trainers.asm): the first map trainer, in
* object order, that faces the player in a straight line within its sight
* range and whose event flag is clear starts SeenByTrainerScript. Walls and
* objects do not block sight. Sight ranges (at most 5) never reach beyond
* the object window, so an in-range trainer always has an object struct. */
private checkTrainers(): boolean {
const player = this.player,
r = this.assets.reader,
refused = this.refusedSighting;
if (refused && (refused.map !== this.mapId || refused.x !== player.x || refused.y !== player.y))
this.refusedSighting = undefined;
for (const object of this.map.objects) {
if (object.kind !== OBJECTTYPE_TRAINER || !this.spawned.has(object.id)) continue;
if (this.refusedSighting?.id === object.id) continue;
const actor = this.actor(object);
// FacingPlayerDistance: same row or column, facing the player.
let distance: number, facing: Direction;
if (actor.x === player.x && actor.y !== player.y) {
distance = Math.abs(player.y - actor.y);
facing = player.y > actor.y ? "down" : "up";
} else if (actor.y === player.y && actor.x !== player.x) {
distance = Math.abs(player.x - actor.x);
facing = player.x > actor.x ? "right" : "left";
} else continue;
if (actor.direction !== facing || distance > (object.sightRange ?? 0)) continue;
if (this.state.events.has(r.word(object.script))) continue;
this.startScript(SYMBOLS.seenByTrainerScript, object.id, {
trainer: { header: object.script, distance },
sighted: object.id,
});
return true;
}
return false;
}
/** Scene scripts run while the player is idle (RunSceneScript). */
private checkScene() {
if (!this.sceneCheck || this.busy) return;
@@ -1468,7 +1544,7 @@ export class GameWorld {
const owner = this.assets.sceneOwner(this.mapId);
if (!owner) return;
const script = this.map.sceneScripts[this.state.scenes[owner] ?? 0];
if (script !== undefined) this.startScript(script, PLAYER, undefined, true);
if (script !== undefined) this.startScript(script, PLAYER, { scene: true });
}
// ---------------------------------------------------------------- input
@@ -1531,6 +1607,7 @@ export class GameWorld {
return;
}
if (this.dialogue) return;
if (this.checkTrainers()) return;
this.checkScene();
if (this.busy) return;
this.checkDailyReset();
@@ -1563,6 +1640,8 @@ export class GameWorld {
}
if (this.busy) return;
this.clearNotice();
// CheckTrainerEvent precedes OWPlayerInput in PlayerEvents.
if (this.checkTrainers()) return;
const [dx, dy] = VECTORS[this.player.direction];
let x = this.player.x + dx,
y = this.player.y + dy;
@@ -1582,14 +1661,16 @@ export class GameWorld {
// macro's item, quantity), not a script; FindItemInBallScript always
// runs, with hLastTalked left on this object as TryObjectEvent does.
const r = this.assets.reader;
this.startScript(
SYMBOLS.findItemInBallScript,
object.id,
undefined,
false,
undefined,
{ item: r.byte(object.script), quantity: r.byte(object.script + 1) },
);
this.startScript(SYMBOLS.findItemInBallScript, object.id, {
itemBall: { item: r.byte(object.script), quantity: r.byte(object.script + 1) },
});
return;
}
if (object.kind === OBJECTTYPE_TRAINER) {
// TalkToTrainer: wSeenTrainerDistance 1, then TalkToTrainerScript.
this.startScript(SYMBOLS.talkToTrainerScript, object.id, {
trainer: { header: object.script, distance: 1 },
});
return;
}
if (object.kind !== 0) {
@@ -1614,7 +1695,13 @@ export class GameWorld {
if (set !== (event.kind === 5)) return;
script = r.near(event.script + 2, r.bank(event.script));
} else if (event.kind === 0) script = event.script;
else {
else if (event.kind === 7) {
// BGEVENT_ITEM: hiddenitem data (event flag, item) for HiddenItemScript.
const hidden = { event: r.word(event.script), item: r.byte(event.script + 2) };
if (this.state.events.has(hidden.event)) return;
this.startScript(SYMBOLS.hiddenItemScript, PLAYER, { hiddenItem: hidden });
return;
} else {
this.notice = "This interaction is not ported yet.";
return;
}