mirror of
https://github.com/pagefaultgames/pokerogue.git
synced 2025-07-03 23:12:20 +02:00
Completed run phase refactor implementation and updated tests
This commit is contained in:
parent
4e11759671
commit
05ba390ede
@ -800,21 +800,15 @@ export default class BattleScene extends SceneBase {
|
|||||||
// TODO: Add `undefined` to return type
|
// TODO: Add `undefined` to return type
|
||||||
/**
|
/**
|
||||||
* Returns an array of PlayerPokemon of length 1 or 2 depending on if in a double battle or not.
|
* Returns an array of PlayerPokemon of length 1 or 2 depending on if in a double battle or not.
|
||||||
* Does not actually check if the pokemon are on the field or not.
|
* @param active - If true, returns only the PlayerPokemon that are currently active
|
||||||
* @returns array of {@linkcode PlayerPokemon}
|
* @returns array of {@linkcode PlayerPokemon}
|
||||||
*/
|
*/
|
||||||
public getPlayerField(): PlayerPokemon[] {
|
public getPlayerField(active = false): PlayerPokemon[] {
|
||||||
const party = this.getPlayerParty();
|
const party = this.getPlayerParty();
|
||||||
return party.slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1));
|
if (active) {
|
||||||
|
return party.slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1)).filter(p => p.isActive());
|
||||||
}
|
}
|
||||||
|
return party.slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1));
|
||||||
/**
|
|
||||||
* Returns an array of PlayerPokemon of length 1 or 2 depending on if in a double battle or not.
|
|
||||||
* Does check if the pokemon are on the field or not.
|
|
||||||
* @returns array of {@linkcode PlayerPokemon}
|
|
||||||
*/
|
|
||||||
public getActivePlayerField(): PlayerPokemon[] {
|
|
||||||
return this.getPlayerField().filter(p => p.isActive());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getEnemyParty(): EnemyPokemon[] {
|
public getEnemyParty(): EnemyPokemon[] {
|
||||||
|
@ -94,6 +94,7 @@ export default class Battle {
|
|||||||
/** If the current battle is a Mystery Encounter, this will always be defined */
|
/** If the current battle is a Mystery Encounter, this will always be defined */
|
||||||
public mysteryEncounter?: MysteryEncounter;
|
public mysteryEncounter?: MysteryEncounter;
|
||||||
|
|
||||||
|
/**Tracks whether the last run attempt in battle failed*/
|
||||||
public failedRunAway = false;
|
public failedRunAway = false;
|
||||||
|
|
||||||
private rngCounter = 0;
|
private rngCounter = 0;
|
||||||
|
@ -1,13 +1,10 @@
|
|||||||
import { applyAbAttrs, applyPreLeaveFieldAbAttrs } from "#app/data/abilities/apply-ab-attrs";
|
import { applyAbAttrs, applyPreLeaveFieldAbAttrs } from "#app/data/abilities/apply-ab-attrs";
|
||||||
import { Stat } from "#enums/stat";
|
|
||||||
import { StatusEffect } from "#enums/status-effect";
|
import { StatusEffect } from "#enums/status-effect";
|
||||||
import type { PlayerPokemon, EnemyPokemon } from "#app/field/pokemon";
|
|
||||||
import type Pokemon from "#app/field/pokemon";
|
|
||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
import { NumberHolder, randSeedInt } from "#app/utils/common";
|
import { NumberHolder, randSeedInt } from "#app/utils/common";
|
||||||
import { PokemonPhase } from "./pokemon-phase";
|
import { PokemonPhase } from "./pokemon-phase";
|
||||||
import { globalScene } from "#app/global-scene";
|
import { globalScene } from "#app/global-scene";
|
||||||
|
import { calculateEscapeChance } from "#app/utils/run-utils";
|
||||||
export class AttemptRunPhase extends PokemonPhase {
|
export class AttemptRunPhase extends PokemonPhase {
|
||||||
public readonly phaseName = "AttemptRunPhase";
|
public readonly phaseName = "AttemptRunPhase";
|
||||||
/** For testing purposes: this is to force the pokemon to fail and escape */
|
/** For testing purposes: this is to force the pokemon to fail and escape */
|
||||||
@ -20,15 +17,16 @@ export class AttemptRunPhase extends PokemonPhase {
|
|||||||
start() {
|
start() {
|
||||||
super.start();
|
super.start();
|
||||||
|
|
||||||
//Attempting to run is a TEAM not PLAYER based action, we should not be referercing individual pokemon,
|
//Increment escape attempts count on entry
|
||||||
//we should instead be referring to the team as a whole
|
const currentAttempts = globalScene.currentBattle.escapeAttempts++;
|
||||||
const activePlayerField = globalScene.getActivePlayerField();
|
|
||||||
|
const activePlayerField = globalScene.getPlayerField(true);
|
||||||
const enemyField = globalScene.getEnemyField();
|
const enemyField = globalScene.getEnemyField();
|
||||||
|
|
||||||
const escapeChance = new NumberHolder(0);
|
|
||||||
const escapeRoll = this.getTeamRNG(100);
|
const escapeRoll = this.getTeamRNG(100);
|
||||||
|
const escapeChance = new NumberHolder(0);
|
||||||
|
|
||||||
this.attemptRunAway(activePlayerField, enemyField, escapeChance);
|
calculateEscapeChance(activePlayerField, enemyField, escapeChance, currentAttempts);
|
||||||
|
|
||||||
activePlayerField.forEach(p => {
|
activePlayerField.forEach(p => {
|
||||||
applyAbAttrs("RunSuccessAbAttr", p, null, false, escapeChance);
|
applyAbAttrs("RunSuccessAbAttr", p, null, false, escapeChance);
|
||||||
@ -73,58 +71,4 @@ export class AttemptRunPhase extends PokemonPhase {
|
|||||||
|
|
||||||
this.end();
|
this.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
attemptRunAway(playerField: PlayerPokemon[], enemyField: EnemyPokemon[], escapeChance: NumberHolder) {
|
|
||||||
/** Sum of the speed of all enemy pokemon on the field */
|
|
||||||
const enemySpeed = enemyField.reduce(
|
|
||||||
(total: number, enemyPokemon: Pokemon) => total + enemyPokemon.getStat(Stat.SPD),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
/** Sum of the speed of all player pokemon on the field */
|
|
||||||
const playerSpeed = playerField.reduce(
|
|
||||||
(total: number, playerPokemon: Pokemon) => total + playerPokemon.getStat(Stat.SPD),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
/* The way the escape chance works is by looking at the difference between your speed and the enemy field's average speed as a ratio. The higher this ratio, the higher your chance of success.
|
|
||||||
* However, there is a cap for the ratio of your speed vs enemy speed which beyond that point, you won't gain any advantage. It also looks at how many times you've tried to escape.
|
|
||||||
* Again, the more times you've tried to escape, the higher your odds of escaping. Bosses and non-bosses are calculated differently - bosses are harder to escape from vs non-bosses
|
|
||||||
* Finally, there's a minimum and maximum escape chance as well so that escapes aren't guaranteed, yet they are never 0 either.
|
|
||||||
* The percentage chance to escape from a pokemon for both bosses and non bosses is linear and based on the minimum and maximum chances, and the speed ratio cap.
|
|
||||||
*
|
|
||||||
* At the time of writing, these conditions should be met:
|
|
||||||
* - The minimum escape chance should be 5% for bosses and non bosses
|
|
||||||
* - Bosses should have a maximum escape chance of 25%, whereas non-bosses should be 95%
|
|
||||||
* - The bonus per previous escape attempt should be 2% for bosses and 10% for non-bosses
|
|
||||||
* - The speed ratio cap should be 6x for bosses and 4x for non-bosses
|
|
||||||
* - The "default" escape chance when your speed equals the enemy speed should be 8.33% for bosses and 27.5% for non-bosses
|
|
||||||
*
|
|
||||||
* From the above, we can calculate the below values
|
|
||||||
*/
|
|
||||||
|
|
||||||
let isBoss = false;
|
|
||||||
for (let e = 0; e < enemyField.length; e++) {
|
|
||||||
isBoss = isBoss || enemyField[e].isBoss(); // this line checks if any of the enemy pokemon on the field are bosses; if so, the calculation for escaping is different
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The ratio between the speed of your active pokemon and the speed of the enemy field */
|
|
||||||
const speedRatio = playerSpeed / enemySpeed;
|
|
||||||
/** The max ratio before escape chance stops increasing. Increased if there is a boss on the field */
|
|
||||||
const speedCap = isBoss ? 6 : 4;
|
|
||||||
/** Minimum percent chance to escape */
|
|
||||||
const minChance = 5;
|
|
||||||
/** Maximum percent chance to escape. Decreased if a boss is on the field */
|
|
||||||
const maxChance = isBoss ? 25 : 95;
|
|
||||||
/** How much each escape attempt increases the chance of the next attempt. Decreased if a boss is on the field */
|
|
||||||
const escapeBonus = isBoss ? 2 : 10;
|
|
||||||
/** Slope of the escape chance curve */
|
|
||||||
const escapeSlope = (maxChance - minChance) / speedCap;
|
|
||||||
|
|
||||||
// This will calculate the escape chance given all of the above and clamp it to the range of [`minChance`, `maxChance`]
|
|
||||||
escapeChance.value = Phaser.Math.Clamp(
|
|
||||||
Math.round(escapeSlope * speedRatio + minChance + escapeBonus * globalScene.currentBattle.escapeAttempts++),
|
|
||||||
minChance,
|
|
||||||
maxChance,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -5,12 +5,13 @@ import { Phase } from "#app/phase";
|
|||||||
export abstract class BattlePhase extends Phase {
|
export abstract class BattlePhase extends Phase {
|
||||||
start() {
|
start() {
|
||||||
if (globalScene.currentBattle.failedRunAway) {
|
if (globalScene.currentBattle.failedRunAway) {
|
||||||
const activePlayerField = globalScene.getActivePlayerField();
|
const activePlayerField = globalScene.getPlayerField(true);
|
||||||
|
|
||||||
activePlayerField.forEach(p => {
|
activePlayerField.forEach(p => {
|
||||||
p.turnData.failedRunAway = true;
|
p.turnData.failedRunAway = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//Reset flag for future run attempts
|
||||||
globalScene.currentBattle.failedRunAway = false;
|
globalScene.currentBattle.failedRunAway = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
71
src/utils/run-utils.ts
Normal file
71
src/utils/run-utils.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import type { PlayerPokemon, EnemyPokemon } from "#app/field/pokemon";
|
||||||
|
import type { NumberHolder } from "#app/utils/common";
|
||||||
|
import type Pokemon from "#app/field/pokemon";
|
||||||
|
import { Stat } from "#enums/stat";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the chance for the player's team to successfully run away from battle.
|
||||||
|
*
|
||||||
|
* @param playerField The player's currently active Pokémon
|
||||||
|
* @param enemyField The enemy team's Pokémon
|
||||||
|
* @param escapeChance A mutable NumberHolder to be assigned the final escape chance value
|
||||||
|
* @param escapeAttempts The number of previous escape attempts made in the battle
|
||||||
|
*/
|
||||||
|
export function calculateEscapeChance(
|
||||||
|
playerField: PlayerPokemon[],
|
||||||
|
enemyField: EnemyPokemon[],
|
||||||
|
escapeChance: NumberHolder,
|
||||||
|
escapeAttempts: number,
|
||||||
|
) {
|
||||||
|
/** Sum of the speed of all enemy pokemon on the field */
|
||||||
|
const enemySpeed = enemyField.reduce(
|
||||||
|
(total: number, enemyPokemon: Pokemon) => total + enemyPokemon.getStat(Stat.SPD),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
/** Sum of the speed of all player pokemon on the field */
|
||||||
|
const playerSpeed = playerField.reduce(
|
||||||
|
(total: number, playerPokemon: Pokemon) => total + playerPokemon.getStat(Stat.SPD),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
/* The way the escape chance works is by looking at the difference between your speed and the enemy field's average speed as a ratio. The higher this ratio, the higher your chance of success.
|
||||||
|
* However, there is a cap for the ratio of your speed vs enemy speed which beyond that point, you won't gain any advantage. It also looks at how many times you've tried to escape.
|
||||||
|
* Again, the more times you've tried to escape, the higher your odds of escaping. Bosses and non-bosses are calculated differently - bosses are harder to escape from vs non-bosses
|
||||||
|
* Finally, there's a minimum and maximum escape chance as well so that escapes aren't guaranteed, yet they are never 0 either.
|
||||||
|
* The percentage chance to escape from a pokemon for both bosses and non bosses is linear and based on the minimum and maximum chances, and the speed ratio cap.
|
||||||
|
*
|
||||||
|
* At the time of writing, these conditions should be met:
|
||||||
|
* - The minimum escape chance should be 5% for bosses and non bosses
|
||||||
|
* - Bosses should have a maximum escape chance of 25%, whereas non-bosses should be 95%
|
||||||
|
* - The bonus per previous escape attempt should be 2% for bosses and 10% for non-bosses
|
||||||
|
* - The speed ratio cap should be 6x for bosses and 4x for non-bosses
|
||||||
|
* - The "default" escape chance when your speed equals the enemy speed should be 8.33% for bosses and 27.5% for non-bosses
|
||||||
|
*
|
||||||
|
* From the above, we can calculate the below values
|
||||||
|
*/
|
||||||
|
|
||||||
|
let isBoss = false;
|
||||||
|
for (let e = 0; e < enemyField.length; e++) {
|
||||||
|
isBoss = isBoss || enemyField[e].isBoss(); // this line checks if any of the enemy pokemon on the field are bosses; if so, the calculation for escaping is different
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ratio between the speed of your active pokemon and the speed of the enemy field */
|
||||||
|
const speedRatio = playerSpeed / enemySpeed;
|
||||||
|
/** The max ratio before escape chance stops increasing. Increased if there is a boss on the field */
|
||||||
|
const speedCap = isBoss ? 6 : 4;
|
||||||
|
/** Minimum percent chance to escape */
|
||||||
|
const minChance = 5;
|
||||||
|
/** Maximum percent chance to escape. Decreased if a boss is on the field */
|
||||||
|
const maxChance = isBoss ? 25 : 95;
|
||||||
|
/** How much each escape attempt increases the chance of the next attempt. Decreased if a boss is on the field */
|
||||||
|
const escapeBonus = isBoss ? 2 : 10;
|
||||||
|
/** Slope of the escape chance curve */
|
||||||
|
const escapeSlope = (maxChance - minChance) / speedCap;
|
||||||
|
|
||||||
|
// This will calculate the escape chance given all of the above and clamp it to the range of [`minChance`, `maxChance`]
|
||||||
|
escapeChance.value = Phaser.Math.Clamp(
|
||||||
|
Math.round(escapeSlope * speedRatio + minChance + escapeBonus * escapeAttempts),
|
||||||
|
minChance,
|
||||||
|
maxChance,
|
||||||
|
);
|
||||||
|
}
|
@ -7,6 +7,7 @@ import { SpeciesId } from "#enums/species-id";
|
|||||||
import GameManager from "#test/testUtils/gameManager";
|
import GameManager from "#test/testUtils/gameManager";
|
||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { calculateEscapeChance } from "#app/utils/run-utils";
|
||||||
|
|
||||||
describe("Escape chance calculations", () => {
|
describe("Escape chance calculations", () => {
|
||||||
let phaserGame: Phaser.Game;
|
let phaserGame: Phaser.Game;
|
||||||
@ -44,7 +45,6 @@ describe("Escape chance calculations", () => {
|
|||||||
commandPhase.handleCommand(Command.RUN, 0);
|
commandPhase.handleCommand(Command.RUN, 0);
|
||||||
|
|
||||||
await game.phaseInterceptor.to(AttemptRunPhase, false);
|
await game.phaseInterceptor.to(AttemptRunPhase, false);
|
||||||
const phase = game.scene.phaseManager.getCurrentPhase() as AttemptRunPhase;
|
|
||||||
const escapePercentage = new NumberHolder(0);
|
const escapePercentage = new NumberHolder(0);
|
||||||
|
|
||||||
// this sets up an object for multiple attempts. The pokemonSpeedRatio is your speed divided by the enemy speed, the escapeAttempts are the number of escape attempts and the expectedEscapeChance is the chance it should be escaping
|
// this sets up an object for multiple attempts. The pokemonSpeedRatio is your speed divided by the enemy speed, the escapeAttempts are the number of escape attempts and the expectedEscapeChance is the chance it should be escaping
|
||||||
@ -91,7 +91,7 @@ describe("Escape chance calculations", () => {
|
|||||||
20,
|
20,
|
||||||
escapeChances[i].pokemonSpeedRatio * enemySpeed,
|
escapeChances[i].pokemonSpeedRatio * enemySpeed,
|
||||||
]);
|
]);
|
||||||
phase.attemptRunAway(playerPokemon, enemyField, escapePercentage);
|
calculateEscapeChance(playerPokemon, enemyField, escapePercentage, game.scene.currentBattle.escapeAttempts);
|
||||||
expect(escapePercentage.value).toBe(escapeChances[i].expectedEscapeChance);
|
expect(escapePercentage.value).toBe(escapeChances[i].expectedEscapeChance);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -117,7 +117,6 @@ describe("Escape chance calculations", () => {
|
|||||||
commandPhase.handleCommand(Command.RUN, 0);
|
commandPhase.handleCommand(Command.RUN, 0);
|
||||||
|
|
||||||
await game.phaseInterceptor.to(AttemptRunPhase, false);
|
await game.phaseInterceptor.to(AttemptRunPhase, false);
|
||||||
const phase = game.scene.phaseManager.getCurrentPhase() as AttemptRunPhase;
|
|
||||||
const escapePercentage = new NumberHolder(0);
|
const escapePercentage = new NumberHolder(0);
|
||||||
|
|
||||||
// this sets up an object for multiple attempts. The pokemonSpeedRatio is your speed divided by the enemy speed, the escapeAttempts are the number of escape attempts and the expectedEscapeChance is the chance it should be escaping
|
// this sets up an object for multiple attempts. The pokemonSpeedRatio is your speed divided by the enemy speed, the escapeAttempts are the number of escape attempts and the expectedEscapeChance is the chance it should be escaping
|
||||||
@ -172,7 +171,7 @@ describe("Escape chance calculations", () => {
|
|||||||
20,
|
20,
|
||||||
escapeChances[i].pokemonSpeedRatio * totalEnemySpeed - playerPokemon[0].stats[5],
|
escapeChances[i].pokemonSpeedRatio * totalEnemySpeed - playerPokemon[0].stats[5],
|
||||||
]);
|
]);
|
||||||
phase.attemptRunAway(playerPokemon, enemyField, escapePercentage);
|
calculateEscapeChance(playerPokemon, enemyField, escapePercentage, game.scene.currentBattle.escapeAttempts);
|
||||||
// checks to make sure the escape values are the same
|
// checks to make sure the escape values are the same
|
||||||
expect(escapePercentage.value).toBe(escapeChances[i].expectedEscapeChance);
|
expect(escapePercentage.value).toBe(escapeChances[i].expectedEscapeChance);
|
||||||
// checks to make sure the sum of the player's speed for all pokemon is equal to the appropriate ratio of the total enemy speed
|
// checks to make sure the sum of the player's speed for all pokemon is equal to the appropriate ratio of the total enemy speed
|
||||||
@ -196,7 +195,6 @@ describe("Escape chance calculations", () => {
|
|||||||
commandPhase.handleCommand(Command.RUN, 0);
|
commandPhase.handleCommand(Command.RUN, 0);
|
||||||
|
|
||||||
await game.phaseInterceptor.to(AttemptRunPhase, false);
|
await game.phaseInterceptor.to(AttemptRunPhase, false);
|
||||||
const phase = game.scene.phaseManager.getCurrentPhase() as AttemptRunPhase;
|
|
||||||
const escapePercentage = new NumberHolder(0);
|
const escapePercentage = new NumberHolder(0);
|
||||||
|
|
||||||
// this sets up an object for multiple attempts. The pokemonSpeedRatio is your speed divided by the enemy speed, the escapeAttempts are the number of escape attempts and the expectedEscapeChance is the chance it should be escaping
|
// this sets up an object for multiple attempts. The pokemonSpeedRatio is your speed divided by the enemy speed, the escapeAttempts are the number of escape attempts and the expectedEscapeChance is the chance it should be escaping
|
||||||
@ -256,7 +254,7 @@ describe("Escape chance calculations", () => {
|
|||||||
20,
|
20,
|
||||||
escapeChances[i].pokemonSpeedRatio * enemySpeed,
|
escapeChances[i].pokemonSpeedRatio * enemySpeed,
|
||||||
]);
|
]);
|
||||||
phase.attemptRunAway(playerPokemon, enemyField, escapePercentage);
|
calculateEscapeChance(playerPokemon, enemyField, escapePercentage, game.scene.currentBattle.escapeAttempts);
|
||||||
expect(escapePercentage.value).toBe(escapeChances[i].expectedEscapeChance);
|
expect(escapePercentage.value).toBe(escapeChances[i].expectedEscapeChance);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -282,7 +280,7 @@ describe("Escape chance calculations", () => {
|
|||||||
commandPhase.handleCommand(Command.RUN, 0);
|
commandPhase.handleCommand(Command.RUN, 0);
|
||||||
|
|
||||||
await game.phaseInterceptor.to(AttemptRunPhase, false);
|
await game.phaseInterceptor.to(AttemptRunPhase, false);
|
||||||
const phase = game.scene.phaseManager.getCurrentPhase() as AttemptRunPhase;
|
const _phase = game.scene.phaseManager.getCurrentPhase() as AttemptRunPhase;
|
||||||
const escapePercentage = new NumberHolder(0);
|
const escapePercentage = new NumberHolder(0);
|
||||||
|
|
||||||
// this sets up an object for multiple attempts. The pokemonSpeedRatio is your speed divided by the enemy speed, the escapeAttempts are the number of escape attempts and the expectedEscapeChance is the chance it should be escaping
|
// this sets up an object for multiple attempts. The pokemonSpeedRatio is your speed divided by the enemy speed, the escapeAttempts are the number of escape attempts and the expectedEscapeChance is the chance it should be escaping
|
||||||
@ -349,7 +347,7 @@ describe("Escape chance calculations", () => {
|
|||||||
20,
|
20,
|
||||||
escapeChances[i].pokemonSpeedRatio * totalEnemySpeed - playerPokemon[0].stats[5],
|
escapeChances[i].pokemonSpeedRatio * totalEnemySpeed - playerPokemon[0].stats[5],
|
||||||
]);
|
]);
|
||||||
phase.attemptRunAway(playerPokemon, enemyField, escapePercentage);
|
calculateEscapeChance(playerPokemon, enemyField, escapePercentage, game.scene.currentBattle.escapeAttempts);
|
||||||
// checks to make sure the escape values are the same
|
// checks to make sure the escape values are the same
|
||||||
expect(escapePercentage.value).toBe(escapeChances[i].expectedEscapeChance);
|
expect(escapePercentage.value).toBe(escapeChances[i].expectedEscapeChance);
|
||||||
// checks to make sure the sum of the player's speed for all pokemon is equal to the appropriate ratio of the total enemy speed
|
// checks to make sure the sum of the player's speed for all pokemon is equal to the appropriate ratio of the total enemy speed
|
||||||
|
Loading…
Reference in New Issue
Block a user