pokerogue/src/phases/command-phase.ts

701 lines
24 KiB
TypeScript

import type { TurnCommand } from "#app/battle";
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
import { speciesStarterCosts } from "#balance/starters";
import { TrappedTag } from "#data/battler-tags";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattleType } from "#enums/battle-type";
import { BattlerTagType } from "#enums/battler-tag-type";
import { BiomeId } from "#enums/biome-id";
import { ChallengeType } from "#enums/challenge-type";
import { Command } from "#enums/command";
import { FieldPosition } from "#enums/field-position";
import { MoveId } from "#enums/move-id";
import { isIgnorePP, isVirtual, MoveUseMode } from "#enums/move-use-mode";
import { MysteryEncounterMode } from "#enums/mystery-encounter-mode";
import { PokeballType } from "#enums/pokeball";
import { UiMode } from "#enums/ui-mode";
import type { PlayerPokemon } from "#field/pokemon";
import type { MoveTargetSet } from "#moves/move";
import { getMoveTargets } from "#moves/move-utils";
import type { PokemonMove } from "#moves/pokemon-move";
import { FieldPhase } from "#phases/field-phase";
import type { TurnMove } from "#types/turn-move";
import { applyChallenges } from "#utils/challenge-utils";
import { BooleanHolder } from "#utils/common";
import i18next from "i18next";
export class CommandPhase extends FieldPhase {
public readonly phaseName = "CommandPhase";
protected fieldIndex: number;
/**
* Whether the command phase is handling a switch command
*/
private isSwitch = false;
constructor(fieldIndex: number) {
super();
this.fieldIndex = fieldIndex;
}
/**
* Resets the cursor to the position of {@linkcode Command.FIGHT} if any of the following are true
* - The setting to remember the last action is not enabled
* - This is the first turn of a mystery encounter, trainer battle, or the END biome
* - The cursor is currently on the POKEMON command
*/
private resetCursorIfNeeded(): void {
const commandUiHandler = globalScene.ui.handlers[UiMode.COMMAND];
const { arena, commandCursorMemory, currentBattle } = globalScene;
const { battleType, turn } = currentBattle;
const { biomeType } = arena;
// If one of these conditions is true, we always reset the cursor to Command.FIGHT
const cursorResetEvent =
battleType === BattleType.MYSTERY_ENCOUNTER || battleType === BattleType.TRAINER || biomeType === BiomeId.END;
if (!commandUiHandler) {
return;
}
if (
(turn === 1 && (!commandCursorMemory || cursorResetEvent)) ||
commandUiHandler.getCursor() === Command.POKEMON
) {
commandUiHandler.setCursor(Command.FIGHT);
}
}
/**
* Submethod of {@linkcode start} that validates field index logic for nonzero field indices.
* Must only be called if the field index is nonzero.
*/
private handleFieldIndexLogic(): void {
// If we somehow are attempting to check the right pokemon but there's only one pokemon out
// Switch back to the center pokemon. This can happen rarely in double battles with mid turn switching
// TODO: Prevent this from happening in the first place
if (globalScene.getPlayerField().filter(p => p.isActive()).length === 1) {
this.fieldIndex = FieldPosition.CENTER;
return;
}
const allyCommand = globalScene.currentBattle.turnCommands[this.fieldIndex - 1];
if (allyCommand?.command === Command.BALL || allyCommand?.command === Command.RUN) {
globalScene.currentBattle.turnCommands[this.fieldIndex] = {
command: allyCommand?.command,
skip: true,
};
}
}
/**
* Submethod of {@linkcode start} that sets the turn command to skip if this pokemon
* is commanding its ally via {@linkcode AbilityId.COMMANDER}.
*/
private checkCommander(): void {
// If the Pokemon has applied Commander's effects to its ally, skip this command
if (
globalScene.currentBattle?.double &&
this.getPokemon().getAlly()?.getTag(BattlerTagType.COMMANDED)?.getSourcePokemon() === this.getPokemon()
) {
globalScene.currentBattle.turnCommands[this.fieldIndex] = {
command: Command.FIGHT,
move: { move: MoveId.NONE, targets: [], useMode: MoveUseMode.NORMAL },
skip: true,
};
}
}
/**
* Clear out all unusable moves in front of the currently acting pokemon's move queue.
*/
// TODO: Refactor move queue handling to ensure that this method is not necessary.
private clearUnusableMoves(): void {
const playerPokemon = this.getPokemon();
const moveQueue = playerPokemon.getMoveQueue();
if (moveQueue.length === 0) {
return;
}
let entriesToDelete = 0;
const moveset = playerPokemon.getMoveset();
for (const queuedMove of moveQueue) {
const movesetQueuedMove = moveset.find(m => m.moveId === queuedMove.move);
if (
queuedMove.move !== MoveId.NONE &&
!isVirtual(queuedMove.useMode) &&
!movesetQueuedMove?.isUsable(playerPokemon, isIgnorePP(queuedMove.useMode))
) {
entriesToDelete++;
} else {
break;
}
}
if (entriesToDelete) {
moveQueue.splice(0, entriesToDelete);
}
}
/**
* Attempt to execute the first usable move in this Pokemon's move queue
* @returns Whether a queued move was successfully set to be executed.
*/
private tryExecuteQueuedMove(): boolean {
this.clearUnusableMoves();
const playerPokemon = globalScene.getPlayerField()[this.fieldIndex];
const moveQueue = playerPokemon.getMoveQueue();
if (moveQueue.length === 0) {
return false;
}
const queuedMove = moveQueue[0];
if (queuedMove.move === MoveId.NONE) {
this.handleCommand(Command.FIGHT, -1);
return true;
}
const moveIndex = playerPokemon.getMoveset().findIndex(m => m.moveId === queuedMove.move);
if (!isVirtual(queuedMove.useMode) && moveIndex === -1) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
} else {
this.handleCommand(Command.FIGHT, moveIndex, queuedMove.useMode, queuedMove);
}
return true;
}
public override start(): void {
super.start();
globalScene.updateGameInfo();
this.resetCursorIfNeeded();
if (this.fieldIndex) {
this.handleFieldIndexLogic();
}
this.checkCommander();
const playerPokemon = this.getPokemon();
// Note: It is OK to call this if the target is not under the effect of encore; it will simply do nothing.
playerPokemon.lapseTag(BattlerTagType.ENCORE);
if (globalScene.currentBattle.turnCommands[this.fieldIndex]?.skip) {
this.end();
return;
}
if (this.tryExecuteQueuedMove()) {
return;
}
if (
globalScene.currentBattle.isBattleMysteryEncounter() &&
globalScene.currentBattle.mysteryEncounter?.skipToFightInput
) {
globalScene.ui.clearText();
globalScene.ui.setMode(UiMode.FIGHT, this.fieldIndex);
} else {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
}
}
/**
* Submethod of {@linkcode handleFightCommand} responsible for queuing the appropriate
* error message when a move cannot be used.
* @param user - The pokemon using the move
* @param move - The move that cannot be used
*/
private queueFightErrorMessage(user: PlayerPokemon, move: PokemonMove) {
globalScene.ui.setMode(UiMode.MESSAGE);
// Set the translation key for why the move cannot be selected
let cannotSelectKey: string;
const moveStatus = new BooleanHolder(true);
applyChallenges(ChallengeType.POKEMON_MOVE, move.moveId, moveStatus);
if (!moveStatus.value) {
cannotSelectKey = "battle:moveCannotUseChallenge";
} else if (move.getPpRatio() === 0) {
cannotSelectKey = "battle:moveNoPP";
} else if (move.getName().endsWith(" (N)")) {
cannotSelectKey = "battle:moveNotImplemented";
} else if (user.isMoveRestricted(move.moveId, user)) {
cannotSelectKey = user.getRestrictingTag(move.moveId, user)!.selectionDeniedText(user, move.moveId);
} else {
// TODO: Consider a message that signals a being unusable for an unknown reason
cannotSelectKey = "";
}
const moveName = move.getName().replace(" (N)", ""); // Trims off the indicator
globalScene.ui.showText(
i18next.t(cannotSelectKey, { moveName: moveName }),
null,
() => {
globalScene.ui.clearText();
globalScene.ui.setMode(UiMode.FIGHT, this.fieldIndex);
},
null,
true,
);
}
/**
* Helper method for {@linkcode handleFightCommand} that returns the moveID for the phase
* based on the move passed in or the cursor.
*
* Does not check if the move is usable or not, that should be handled by the caller.
*/
private computeMoveId(playerPokemon: PlayerPokemon, cursor: number, move: TurnMove | undefined): MoveId {
return move?.move ?? (cursor > -1 ? playerPokemon.getMoveset()[cursor]?.moveId : MoveId.NONE);
}
/**
* Process the logic for executing a fight-related command
*
* @remarks
* - Validates whether the move can be used, using struggle if not
* - Constructs the turn command and inserts it into the battle's turn commands
*
* @param command - The command to handle (FIGHT or TERA)
* @param cursor - The index that the cursor is placed on, or -1 if no move can be selected.
* @param ignorePP - Whether to ignore PP when checking if the move can be used.
* @param move - The move to force the command to use, if any.
*/
private handleFightCommand(
command: Command.FIGHT | Command.TERA,
cursor: number,
useMode: MoveUseMode = MoveUseMode.NORMAL,
move?: TurnMove,
): boolean {
const playerPokemon = this.getPokemon();
const ignorePP = isIgnorePP(useMode);
let canUse = cursor === -1 || playerPokemon.trySelectMove(cursor, ignorePP);
const moveset = playerPokemon.getMoveset();
// Ternary here ensures we don't compute struggle conditions unless necessary
const useStruggle = canUse ? false : cursor > -1 && !moveset.some(m => m.isUsable(playerPokemon));
canUse ||= useStruggle;
if (!canUse) {
// Selected move *may* be undefined if the cursor is over a position that the mon does not have
const selectedMove: PokemonMove | undefined = moveset[cursor];
if (selectedMove) {
this.queueFightErrorMessage(playerPokemon, moveset[cursor]);
}
return false;
}
const moveId = useStruggle ? MoveId.STRUGGLE : this.computeMoveId(playerPokemon, cursor, move);
const turnCommand: TurnCommand = {
command: Command.FIGHT,
cursor,
move: { move: moveId, targets: [], useMode },
args: [useMode, move],
};
const preTurnCommand: TurnCommand = {
command,
targets: [this.fieldIndex],
skip: command === Command.FIGHT,
};
const moveTargets: MoveTargetSet =
move === undefined
? getMoveTargets(playerPokemon, moveId)
: {
targets: move.targets,
multiple: move.targets.length > 1,
};
if (moveId === MoveId.NONE) {
turnCommand.targets = [this.fieldIndex];
}
console.log(
"Move:",
MoveId[moveId],
"Move targets:",
moveTargets,
"\nPlayer Pokemon:",
getPokemonNameWithAffix(playerPokemon),
);
if (moveTargets.targets.length > 1 && moveTargets.multiple) {
globalScene.phaseManager.unshiftNew("SelectTargetPhase", this.fieldIndex);
}
if (turnCommand.move && (moveTargets.targets.length <= 1 || moveTargets.multiple)) {
turnCommand.move.targets = moveTargets.targets;
} else if (
turnCommand.move &&
playerPokemon.getTag(BattlerTagType.CHARGING) &&
playerPokemon.getMoveQueue().length >= 1
) {
turnCommand.move.targets = playerPokemon.getMoveQueue()[0].targets;
} else {
globalScene.phaseManager.unshiftNew("SelectTargetPhase", this.fieldIndex);
}
globalScene.currentBattle.preTurnCommands[this.fieldIndex] = preTurnCommand;
globalScene.currentBattle.turnCommands[this.fieldIndex] = turnCommand;
return true;
}
/**
* Set the mode in preparation to show the text, and then show the text.
* Only works for parameterless i18next keys.
* @param key - The i18next key for the text to show
*/
private queueShowText(key: string): void {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
globalScene.ui.setMode(UiMode.MESSAGE);
globalScene.ui.showText(
i18next.t(key),
null,
() => {
globalScene.ui.showText("", 0);
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
},
null,
true,
);
}
/**
* Helper method for {@linkcode handleBallCommand} that checks if a pokeball can be thrown
* and displays the appropriate error message.
*
* @remarks
* The pokeball may not be thrown if any of the following are true:
* - It is a trainer battle
* - The player is in the {@linkcode BiomeId.END | End} biome and
* - it is not classic mode; or
* - the player has not caught the target before and the player is still missing more than one starter
* - The player is in a mystery encounter that disallows catching the pokemon
* @returns Whether a pokeball can be thrown
*/
private checkCanUseBall(): boolean {
const { arena, currentBattle, gameData, gameMode } = globalScene;
const { battleType } = currentBattle;
const { biomeType } = arena;
const { isClassic, isEndless, isDaily } = gameMode;
const { dexData } = gameData;
const isClassicFinalBoss = gameMode.isBattleClassicFinalBoss(globalScene.currentBattle.waveIndex);
const isEndlessMinorBoss = gameMode.isEndlessMinorBoss(globalScene.currentBattle.waveIndex);
const isFullFreshStart = gameMode.isFullFreshStartChallenge();
const someUncaughtSpeciesOnField = globalScene
.getEnemyField()
.some(p => p.isActive() && !dexData[p.species.speciesId].caughtAttr);
const missingMultipleStarters =
gameData.getStarterCount(d => !!d.caughtAttr) < Object.keys(speciesStarterCosts).length - 1;
if (biomeType === BiomeId.END && battleType === BattleType.WILD) {
if (
(isClassic && !isClassicFinalBoss && someUncaughtSpeciesOnField) ||
(isFullFreshStart && !isClassicFinalBoss) ||
(isEndless && !isEndlessMinorBoss)
) {
// Uncatchable paradox mons in classic and endless
this.queueShowText("battle:noPokeballForce");
} else if (
(isClassic && isClassicFinalBoss && missingMultipleStarters) ||
(isFullFreshStart && isClassicFinalBoss) ||
(isEndless && isEndlessMinorBoss) ||
isDaily
) {
// Uncatchable final boss in classic, endless and daily
this.queueShowText("battle:noPokeballForceFinalBoss");
} else {
return true;
}
} else if (battleType === BattleType.TRAINER) {
this.queueShowText("battle:noPokeballTrainer");
} else if (currentBattle.isBattleMysteryEncounter() && !currentBattle.mysteryEncounter!.catchAllowed) {
this.queueShowText("battle:noPokeballMysteryEncounter");
} else {
return true;
}
return false;
}
/**
* Helper method for {@linkcode handleCommand} that handles the logic when the selected command is to use a pokeball.
*
* @param cursor - The index of the pokeball to use
* @returns Whether the command was successfully initiated
*/
private handleBallCommand(cursor: number): boolean {
const targets = globalScene
.getEnemyField()
.filter(p => p.isActive(true))
.map(p => p.getBattlerIndex());
if (!this.checkCanUseBall()) {
return false;
}
if (targets.length > 1) {
this.queueShowText("battle:noPokeballMulti");
return false;
}
const isChallengeActive = globalScene.gameMode.hasAnyChallenges();
const isFinalBoss = globalScene.gameMode.isBattleClassicFinalBoss(globalScene.currentBattle.waveIndex);
const numBallTypes = 5;
if (cursor < numBallTypes) {
const targetPokemon = globalScene.getEnemyPokemon(false);
if (
targetPokemon?.isBoss() &&
targetPokemon?.bossSegmentIndex >= 1 &&
// TODO: Decouple this hardcoded exception for wonder guard and just check the target...
!targetPokemon?.hasAbility(AbilityId.WONDER_GUARD, false, true)
) {
// When facing the final boss, it must be weakened unless a Master Ball is used AND no challenges are active.
// The message is customized for the final boss.
if (
isFinalBoss &&
(cursor < PokeballType.MASTER_BALL || (cursor === PokeballType.MASTER_BALL && isChallengeActive))
) {
this.queueShowText("battle:noPokeballForceFinalBossCatchable");
return false;
}
// When facing any other boss, Master Ball can always be used, and we use the standard message.
if (cursor < PokeballType.MASTER_BALL) {
this.queueShowText("battle:noPokeballStrong");
return false;
}
}
globalScene.currentBattle.turnCommands[this.fieldIndex] = {
command: Command.BALL,
cursor: cursor,
};
globalScene.currentBattle.turnCommands[this.fieldIndex]!.targets = targets;
if (this.fieldIndex) {
globalScene.currentBattle.turnCommands[this.fieldIndex - 1]!.skip = true;
}
return true;
}
return false;
}
/**
* Submethod of {@linkcode tryLeaveField} to handle the logic for effects that prevent the pokemon from leaving the field
* due to trapping abilities or effects.
*
* This method queues the proper messages in the case of trapping abilities or effects.
*
* @returns Whether the pokemon is currently trapped
*/
private handleTrap(): boolean {
const playerPokemon = this.getPokemon();
const trappedAbMessages: string[] = [];
const isSwitch = this.isSwitch;
if (!playerPokemon.isTrapped(trappedAbMessages)) {
return false;
}
if (trappedAbMessages.length > 0) {
if (isSwitch) {
globalScene.ui.setMode(UiMode.MESSAGE).then(() => {
globalScene.ui.showText(
trappedAbMessages[0],
null,
() => {
globalScene.ui.showText("", 0);
if (isSwitch) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
}
},
null,
true,
);
});
}
} else {
const trapTag = playerPokemon.getTag(TrappedTag);
const fairyLockTag = globalScene.arena.getTagOnSide(ArenaTagType.FAIRY_LOCK, ArenaTagSide.PLAYER);
if (!isSwitch) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
globalScene.ui.setMode(UiMode.MESSAGE);
}
if (trapTag) {
this.showNoEscapeText(trapTag, false);
} else if (fairyLockTag) {
this.showNoEscapeText(fairyLockTag, false);
}
}
return true;
}
/**
* Common helper method that attempts to have the pokemon leave the field.
* Checks for trapping abilities and effects.
*
* @param cursor - The index of the option that the cursor is on
* @returns Whether the pokemon is able to leave the field, indicating the command phase should end
*/
private tryLeaveField(cursor?: number, isBatonSwitch = false): boolean {
const currentBattle = globalScene.currentBattle;
if (isBatonSwitch || !this.handleTrap()) {
currentBattle.turnCommands[this.fieldIndex] = this.isSwitch
? {
command: Command.POKEMON,
cursor,
args: [isBatonSwitch],
}
: {
command: Command.RUN,
};
if (!this.isSwitch && this.fieldIndex) {
currentBattle.turnCommands[this.fieldIndex - 1]!.skip = true;
}
return true;
}
return false;
}
/**
* Helper method for {@linkcode handleCommand} that handles the logic when the selected command is RUN.
*
* @remarks
* Checks if the player is allowed to flee, and if not, queues the appropriate message.
*
* The player cannot flee if:
* - The player is in the {@linkcode BiomeId.END | End} biome
* - The player is in a trainer battle
* - The player is in a mystery encounter that disallows fleeing
* - The player's pokemon is trapped by an ability or effect
* @returns Whether the pokemon is able to leave the field, indicating the command phase should end
*/
private handleRunCommand(): boolean {
const { currentBattle, arena } = globalScene;
const mysteryEncounterFleeAllowed = currentBattle.mysteryEncounter?.fleeAllowed ?? true;
if (arena.biomeType === BiomeId.END || !mysteryEncounterFleeAllowed) {
this.queueShowText("battle:noEscapeForce");
return false;
}
if (
currentBattle.battleType === BattleType.TRAINER ||
currentBattle.mysteryEncounter?.encounterMode === MysteryEncounterMode.TRAINER_BATTLE
) {
this.queueShowText("battle:noEscapeTrainer");
return false;
}
const success = this.tryLeaveField();
return success;
}
/**
* Show a message indicating that the pokemon cannot escape, and then return to the command phase.
*/
private showNoEscapeText(tag: any, isSwitch: boolean): void {
globalScene.ui.showText(
i18next.t("battle:noEscapePokemon", {
pokemonName:
tag.sourceId && globalScene.getPokemonById(tag.sourceId)
? getPokemonNameWithAffix(globalScene.getPokemonById(tag.sourceId)!)
: "",
moveName: tag.getMoveName(),
escapeVerb: i18next.t(isSwitch ? "battle:escapeVerbSwitch" : "battle:escapeVerbFlee"),
}),
null,
() => {
globalScene.ui.showText("", 0);
if (!isSwitch) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
}
},
null,
true,
);
}
// Overloads for handleCommand to provide a more specific signature for the different options
/**
* Process the command phase logic based on the selected command
*
* @param command - The kind of command to handle
* @param cursor - The index of option that the cursor is on, or -1 if no option is selected
* @param useMode - The mode to use for the move, if applicable. For switches, a boolean that specifies whether the switch is a Baton switch.
* @param move - For {@linkcode Command.FIGHT}, the move to use
* @returns Whether the command was successful
*/
handleCommand(command: Command.FIGHT | Command.TERA, cursor: number, useMode?: MoveUseMode, move?: TurnMove): boolean;
handleCommand(command: Command.BALL, cursor: number): boolean;
handleCommand(command: Command.POKEMON, cursor: number, useBaton: boolean): boolean;
handleCommand(command: Command.RUN, cursor: number): boolean;
handleCommand(command: Command, cursor: number, useMode?: boolean | MoveUseMode, move?: TurnMove): boolean;
public handleCommand(
command: Command,
cursor: number,
useMode: boolean | MoveUseMode = false,
move?: TurnMove,
): boolean {
let success = false;
switch (command) {
case Command.TERA:
case Command.FIGHT:
success = this.handleFightCommand(command, cursor, typeof useMode === "boolean" ? undefined : useMode, move);
break;
case Command.BALL:
success = this.handleBallCommand(cursor);
break;
case Command.POKEMON:
this.isSwitch = true;
success = this.tryLeaveField(cursor, typeof useMode === "boolean" ? useMode : undefined);
this.isSwitch = false;
break;
case Command.RUN:
success = this.handleRunCommand();
}
if (success) {
this.end();
}
return success;
}
cancel() {
if (this.fieldIndex) {
globalScene.phaseManager.unshiftNew("CommandPhase", 0);
globalScene.phaseManager.unshiftNew("CommandPhase", 1);
this.end();
}
}
getFieldIndex(): number {
return this.fieldIndex;
}
getPokemon(): PlayerPokemon {
return globalScene.getPlayerField()[this.fieldIndex];
}
end() {
globalScene.ui.setMode(UiMode.MESSAGE).then(() => super.end());
}
}