This commit is contained in:
1 parent
6a74f59e46
commit
3e92936cb9
20 files changed
+1417
-32
No files matched your search
+151
-24
@@ -5,6 +5,7 @@ import { calcStats, type PartyMon } from "../party/pokemon";
|
||||
import { readString, readTextEnd, type TextContext } from "../scripts/text";
|
||||
import { BattleData } from "./data";
|
||||
import { C, COMMAND } from "./constants";
|
||||
import { chooseTrainerMove, validateTrainerAI } from "./ai";
|
||||
|
||||
/** Raised for battle rules that have not been translated. The battle is
|
||||
* discarded, as if it never started. */
|
||||
@@ -91,7 +92,17 @@ export type BattleEffect =
|
||||
| { kind: "stats"; labels: string[]; values: number[] };
|
||||
export type BattleAction = { action: "fight"; move: number } | { action: "run" };
|
||||
export type BattleInput = number | BattleAction | undefined;
|
||||
export type BattleOutcome = { result: number };
|
||||
export type BattleOutcome = { result: number; prizeMoney?: number };
|
||||
|
||||
export interface BattleTrainer {
|
||||
name: string;
|
||||
className: string;
|
||||
party: PartyMon[];
|
||||
aiFlags: number;
|
||||
reward: number;
|
||||
winText?: number;
|
||||
lossText?: number;
|
||||
}
|
||||
|
||||
type Gen<T = void> = Generator<BattleEffect, T, BattleInput>;
|
||||
|
||||
@@ -104,7 +115,9 @@ export interface BattleOptions {
|
||||
/** wTimeOfDay: morning 0, day 1, night 2. */
|
||||
timeOfDay: number;
|
||||
random: () => number;
|
||||
wild: { species: number; level: number };
|
||||
wild?: { species: number; level: number };
|
||||
trainer?: BattleTrainer;
|
||||
canLose?: boolean;
|
||||
text: () => TextContext;
|
||||
}
|
||||
|
||||
@@ -214,9 +227,9 @@ export function applyTypeMatchups(
|
||||
return { damage, modifier: (modifier & C.STAB_DAMAGE) | matchup, matchup, missed };
|
||||
}
|
||||
|
||||
/** A native translation of Crystal's battle engine for wild battles.
|
||||
/** A native translation of Crystal's battle engine.
|
||||
* The move effect scripts are the ROM's own; each battle command is a
|
||||
* native translation. Link, trainer, contest and item paths are refused. */
|
||||
* native translation. Unsupported AI, switching, contest and item paths are refused. */
|
||||
export class Battle {
|
||||
readonly data: BattleData;
|
||||
readonly player = new Side();
|
||||
@@ -255,6 +268,7 @@ export class Battle {
|
||||
constructor(private options: BattleOptions) {
|
||||
this.data = new BattleData(options.data.reader);
|
||||
this.matchups = this.data.typeMatchups();
|
||||
if (options.trainer) this.battleMode = C.TRAINER_BATTLE;
|
||||
}
|
||||
private get reader(): RomReader {
|
||||
return this.options.data.reader;
|
||||
@@ -278,6 +292,9 @@ export class Battle {
|
||||
const base = this.options.text();
|
||||
return {
|
||||
...base,
|
||||
trainer: this.options.trainer
|
||||
? `${this.options.trainer.className} ${this.options.trainer.name}`.trim()
|
||||
: base.trainer,
|
||||
strings: {
|
||||
...base.strings,
|
||||
[RAM.battleMonNickname]: this.player.mon?.nickname ?? "",
|
||||
@@ -358,6 +375,36 @@ export class Battle {
|
||||
|
||||
/** LoadEnemyMon for a wild Pokémon from a grass or water table. */
|
||||
private loadEnemyMon() {
|
||||
const trainer = this.options.trainer;
|
||||
if (trainer) {
|
||||
const mon = trainer.party[0],
|
||||
base = this.options.data.baseData(mon.species);
|
||||
this.enemy.mon = {
|
||||
species: mon.species,
|
||||
item: mon.item,
|
||||
moves: [...mon.moves],
|
||||
dvs: mon.dvs,
|
||||
pp: [...mon.pp],
|
||||
happiness: mon.happiness,
|
||||
level: mon.level,
|
||||
status: mon.status,
|
||||
hp: mon.hp,
|
||||
maxHp: mon.stats[0],
|
||||
stats: mon.stats.slice(1),
|
||||
types: [...base.types],
|
||||
nickname: mon.nickname,
|
||||
};
|
||||
this.enemyBase = {
|
||||
stats: base.stats.slice(0, 5),
|
||||
catchRate: base.catchRate,
|
||||
exp: base.baseExp,
|
||||
};
|
||||
this.enemy.stats = [...this.enemy.mon.stats];
|
||||
this.applyStatusEffects(this.enemy);
|
||||
this.options.pokedex.seen.add(mon.species);
|
||||
return;
|
||||
}
|
||||
if (!this.options.wild) throw new UnsupportedBattle("Missing encounter.");
|
||||
const { species, level } = this.options.wild;
|
||||
if (species === C.UNOWN || species === C.MAGIKARP)
|
||||
throw new UnsupportedBattle("Unown and Magikarp encounters are not ported yet.");
|
||||
@@ -452,19 +499,65 @@ export class Battle {
|
||||
|
||||
// ---------------------------------------------------------------- flow
|
||||
|
||||
/** StartBattle through ExitBattle for a wild encounter. */
|
||||
/** StartBattle through ExitBattle. */
|
||||
*run(): Gen<BattleOutcome> {
|
||||
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 (
|
||||
!Number.isInteger(trainer.reward) ||
|
||||
trainer.reward < 0 ||
|
||||
trainer.reward > C.MAX_MONEY
|
||||
)
|
||||
throw new UnsupportedBattle("Invalid trainer reward.");
|
||||
}
|
||||
this.loadEnemyMon();
|
||||
this.curBattleMon = this.options.party.findIndex((mon) => mon.hp > 0);
|
||||
if (this.curBattleMon < 0) throw new UnsupportedBattle("No Pokémon can fight.");
|
||||
yield* this.say(BATTLE.WildPokemonAppearedText);
|
||||
if (this.curBattleMon < 0)
|
||||
throw new UnsupportedBattle("No Pokémon can fight.");
|
||||
if (trainer) {
|
||||
yield* this.say(BATTLE.WantsToBattleText);
|
||||
yield* this.say(BATTLE.BattleText_EnemySentOut);
|
||||
} else yield* this.say(BATTLE.WildPokemonAppearedText);
|
||||
this.participants = 1 << this.curBattleMon;
|
||||
this.initBattleMon();
|
||||
const amuletCoin =
|
||||
this.data.heldEffect(this.player.mon.item).effect === C.HELD_AMULET_COIN;
|
||||
this.newStatus(this.player);
|
||||
this.newStatus(this.enemy);
|
||||
yield* this.say(BATTLE.GoMonText);
|
||||
yield this.hud();
|
||||
yield* this.battleTurn();
|
||||
if (trainer && (this.battleResult & 0x3f) === C.WIN) {
|
||||
yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
|
||||
if (this.options.canLose) {
|
||||
for (const mon of this.options.party) {
|
||||
mon.hp = mon.stats[0];
|
||||
mon.status = 0;
|
||||
mon.pp = mon.moves.map((move, slot) => {
|
||||
if (!move) return 0;
|
||||
const ups = mon.pp[slot] >> 6,
|
||||
base = this.options.data.move(move).pp;
|
||||
return (
|
||||
(ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups)
|
||||
);
|
||||
}) as PartyMon["pp"];
|
||||
}
|
||||
this.initBattleMon();
|
||||
yield this.hud();
|
||||
}
|
||||
if (trainer.winText) yield* this.say(trainer.winText);
|
||||
const prizeMoney = trainer.reward * (amuletCoin ? 2 : 1);
|
||||
this.numbers[RAM.battleReward] = prizeMoney;
|
||||
yield* this.say(BATTLE.GotMoneyForWinningText);
|
||||
return { result: this.battleResult, prizeMoney };
|
||||
}
|
||||
if (trainer && this.options.canLose && trainer.lossText)
|
||||
yield* this.say(trainer.lossText);
|
||||
return { result: this.battleResult };
|
||||
}
|
||||
|
||||
@@ -474,6 +567,17 @@ export class Battle {
|
||||
this.player.justGotFrozen = 0;
|
||||
this.enemy.justGotFrozen = 0;
|
||||
this.updateBattleMonInParty();
|
||||
// AIChooseMove runs once before BattleMenu, even with exhausted PP.
|
||||
if (this.options.trainer && !this.checkEnemyLockedIn()) {
|
||||
const choice = chooseTrainerMove({
|
||||
weights: this.options.trainer.aiFlags,
|
||||
player: this.player, enemy: this.enemy,
|
||||
data: this.options.data, battleData: this.data, weather: this.weather,
|
||||
random: () => this.random(),
|
||||
});
|
||||
this.enemy.moveNum = choice.index;
|
||||
this.enemy.curMove = choice.move;
|
||||
}
|
||||
if (!this.checkPlayerLockedIn()) {
|
||||
for (;;) {
|
||||
const escaped = yield* this.battleMenu();
|
||||
@@ -574,27 +678,39 @@ export class Battle {
|
||||
this.parseEnemyAction();
|
||||
return true;
|
||||
}
|
||||
/** ParseEnemyAction for a wild Pokémon: a random usable move. */
|
||||
/** ParseEnemyAction: use the trainer AI selection or a random wild move. */
|
||||
private parseEnemyAction() {
|
||||
const enemy = this.enemy,
|
||||
mon = enemy.mon;
|
||||
if (enemy.has(SUB5, C.SUBSTATUS_ENCORED) || this.checkEnemyLockedIn())
|
||||
throw new UnsupportedBattle("Locked-in and encored wild moves are not ported yet.");
|
||||
throw new UnsupportedBattle(
|
||||
"Locked-in and encored enemy moves are not ported yet.",
|
||||
);
|
||||
let move: number = C.STRUGGLE;
|
||||
for (let slot = 0; slot < 4; slot++) {
|
||||
const candidate = mon.moves[slot];
|
||||
if (!candidate) break;
|
||||
if (candidate === enemy.disabledMove || !(mon.pp[slot] & C.PP_MASK)) continue;
|
||||
for (let guard = 0; guard < 4096; guard++) {
|
||||
const choice = this.random() & 3;
|
||||
if (((enemy.disableCount >> 4) & 0xf) - 1 === choice) continue;
|
||||
if (!mon.moves[choice] || !(mon.pp[choice] & C.PP_MASK)) continue;
|
||||
enemy.moveNum = choice;
|
||||
move = mon.moves[choice];
|
||||
const usable = mon.moves.some(
|
||||
(candidate, slot) =>
|
||||
candidate &&
|
||||
candidate !== enemy.disabledMove &&
|
||||
mon.pp[slot] & C.PP_MASK,
|
||||
);
|
||||
if (this.options.trainer && usable) {
|
||||
move = enemy.curMove;
|
||||
} else
|
||||
for (let slot = 0; slot < 4; slot++) {
|
||||
const candidate = mon.moves[slot];
|
||||
if (!candidate) break;
|
||||
if (candidate === enemy.disabledMove || !(mon.pp[slot] & C.PP_MASK))
|
||||
continue;
|
||||
for (let guard = 0; guard < 4096; guard++) {
|
||||
const choice = this.random() & 3;
|
||||
if (((enemy.disableCount >> 4) & 0xf) - 1 === choice) continue;
|
||||
if (!mon.moves[choice] || !(mon.pp[choice] & C.PP_MASK)) continue;
|
||||
enemy.moveNum = choice;
|
||||
move = mon.moves[choice];
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
enemy.curMove = move;
|
||||
this.turn = 1;
|
||||
this.updateMoveData();
|
||||
@@ -640,9 +756,16 @@ export class Battle {
|
||||
/** DetermineMoveOrder: true if the player moves first. */
|
||||
private determineMoveOrder(): boolean {
|
||||
if (this.battlePlayerAction) return true;
|
||||
const player = this.data.priority(this.options.data.move(this.player.curMove).effect, C.BASE_PRIORITY);
|
||||
const enemy = this.data.priority(this.options.data.move(this.enemy.curMove).effect, C.BASE_PRIORITY);
|
||||
const priority = (move: number, value: number) => (move === C.VITAL_THROW ? 0 : value);
|
||||
const player = this.data.priority(
|
||||
this.options.data.move(this.player.curMove).effect,
|
||||
C.BASE_PRIORITY,
|
||||
);
|
||||
const enemy = this.data.priority(
|
||||
this.options.data.move(this.enemy.curMove).effect,
|
||||
C.BASE_PRIORITY,
|
||||
);
|
||||
const priority = (move: number, value: number) =>
|
||||
move === C.VITAL_THROW ? 0 : value;
|
||||
const playerPriority = priority(this.player.curMove, player),
|
||||
enemyPriority = priority(this.enemy.curMove, enemy);
|
||||
if (playerPriority !== enemyPriority) return playerPriority > enemyPriority;
|
||||
@@ -732,6 +855,10 @@ export class Battle {
|
||||
|
||||
/** TryToRunAwayFromBattle for a wild battle. Returns true on escape. */
|
||||
private *tryToRunAway(): Gen<boolean> {
|
||||
if (this.options.trainer) {
|
||||
yield* this.say(BATTLE.BattleText_TheresNoEscapeFromTrainerBattle);
|
||||
return false;
|
||||
}
|
||||
if (this.enemy.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.player.wrapCount) {
|
||||
yield* this.say(BATTLE.BattleText_CantEscape);
|
||||
return false;
|
||||
|
||||
Reference in new issue
Block a user