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:
1 parent
0d1bedb3b1
commit
e00dfab42e
18 files changed
+1156
-62
No files matched your search
@@ -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()) {
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
Reference in new issue
Block a user