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

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