Merge beta

This commit is contained in:
xsn34kzx 2025-07-31 23:05:26 -04:00
commit 8976b0d3a2
122 changed files with 5378 additions and 2189 deletions

View File

@ -11,6 +11,7 @@ on:
- beta
merge_group:
types: [checks_requested]
workflow_dispatch:
jobs:
check-path-change-filter:

View File

@ -11,14 +11,14 @@ _cfg: &cfg
ls:
<<: *cfg
src:
src: &src
<<: *cfg
.dir: kebab-case | regex:@types
.js: exists:0
src/system/version-migration/versions:
.ts: snake_case
<<: *cfg
test: *src
ignore:
- node_modules
- .vscode

View File

@ -177,9 +177,10 @@
}
},
// Overrides to prevent unused import removal inside `overrides.ts` and enums files (for TSDoc linkcodes)
// Overrides to prevent unused import removal inside `overrides.ts` and enums files (for TSDoc linkcodes),
// as well as in all TS files in `scripts/` (which are assumed to be boilerplate templates).
{
"includes": ["**/src/overrides.ts", "**/src/enums/**/*"],
"includes": ["**/src/overrides.ts", "**/src/enums/**/*", "**/scripts/**/*.ts"],
"linter": {
"rules": {
"correctness": {
@ -189,7 +190,7 @@
}
},
{
"includes": ["**/src/overrides.ts"],
"includes": ["**/src/overrides.ts", "**/scripts/**/*.ts"],
"linter": {
"rules": {
"style": {

View File

@ -17,7 +17,7 @@
"typecheck": "tsc --noEmit",
"eslint": "eslint --fix .",
"eslint-ci": "eslint .",
"biome": "biome check --write --changed --no-errors-on-unmatched",
"biome": "biome check --write --changed --no-errors-on-unmatched --diagnostic-level=error",
"biome-ci": "biome ci --diagnostic-level=error --reporter=github --no-errors-on-unmatched",
"docs": "typedoc",
"depcruise": "depcruise src test",
@ -30,18 +30,19 @@
"@biomejs/biome": "2.0.0",
"@ls-lint/ls-lint": "2.3.1",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.16.3",
"@types/node": "^22.16.5",
"@vitest/coverage-istanbul": "^3.2.4",
"@vitest/expect": "^3.2.4",
"chalk": "^5.4.1",
"dependency-cruiser": "^16.10.4",
"inquirer": "^12.7.0",
"inquirer": "^12.8.2",
"jsdom": "^26.1.0",
"lefthook": "^1.12.2",
"msw": "^2.10.4",
"phaser3spectorjs": "^0.0.8",
"typedoc": "^0.28.7",
"typedoc": "^0.28.8",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vite": "^7.0.6",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4",
"vitest-canvas-mock": "^0.3.3"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,188 @@
{
"textures": [
{
"image": "statuses_tl.png",
"format": "RGBA8888",
"size": {
"w": 22,
"h": 64
},
"scale": 1,
"frames": [
{
"filename": "pokerus",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 22,
"h": 8
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 22,
"h": 8
},
"frame": {
"x": 0,
"y": 0,
"w": 22,
"h": 8
}
},
{
"filename": "burn",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 20,
"h": 8
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 20,
"h": 8
},
"frame": {
"x": 0,
"y": 8,
"w": 20,
"h": 8
}
},
{
"filename": "faint",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 20,
"h": 8
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 20,
"h": 8
},
"frame": {
"x": 0,
"y": 16,
"w": 20,
"h": 8
}
},
{
"filename": "freeze",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 20,
"h": 8
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 20,
"h": 8
},
"frame": {
"x": 0,
"y": 24,
"w": 20,
"h": 8
}
},
{
"filename": "paralysis",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 20,
"h": 8
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 20,
"h": 8
},
"frame": {
"x": 0,
"y": 32,
"w": 20,
"h": 8
}
},
{
"filename": "poison",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 20,
"h": 8
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 20,
"h": 8
},
"frame": {
"x": 0,
"y": 40,
"w": 20,
"h": 8
}
},
{
"filename": "sleep",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 20,
"h": 8
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 20,
"h": 8
},
"frame": {
"x": 0,
"y": 48,
"w": 20,
"h": 8
}
},
{
"filename": "toxic",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 20,
"h": 8
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 20,
"h": 8
},
"frame": {
"x": 0,
"y": 56,
"w": 20,
"h": 8
}
}
]
}
],
"meta": {
"app": "https://www.codeandweb.com/texturepacker",
"version": "3.0",
"smartupdate": "$TexturePacker:SmartUpdate:37686e85605d17b806f22d43081c1139:70535ffee63ba61b3397d8470c2c8982:e6649238c018d3630e55681417c698ca$"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

440
public/images/types_tl.json Normal file
View File

@ -0,0 +1,440 @@
{
"textures": [
{
"image": "types_tl.png",
"format": "RGBA8888",
"size": {
"w": 32,
"h": 280
},
"scale": 1,
"frames": [
{
"filename": "unknown",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
}
},
{
"filename": "bug",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 14,
"w": 32,
"h": 14
}
},
{
"filename": "dark",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 28,
"w": 32,
"h": 14
}
},
{
"filename": "dragon",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 42,
"w": 32,
"h": 14
}
},
{
"filename": "electric",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 56,
"w": 32,
"h": 14
}
},
{
"filename": "fairy",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 70,
"w": 32,
"h": 14
}
},
{
"filename": "fighting",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 84,
"w": 32,
"h": 14
}
},
{
"filename": "fire",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 98,
"w": 32,
"h": 14
}
},
{
"filename": "flying",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 112,
"w": 32,
"h": 14
}
},
{
"filename": "ghost",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 126,
"w": 32,
"h": 14
}
},
{
"filename": "grass",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 140,
"w": 32,
"h": 14
}
},
{
"filename": "ground",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 154,
"w": 32,
"h": 14
}
},
{
"filename": "ice",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 168,
"w": 32,
"h": 14
}
},
{
"filename": "normal",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 182,
"w": 32,
"h": 14
}
},
{
"filename": "poison",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 196,
"w": 32,
"h": 14
}
},
{
"filename": "psychic",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 210,
"w": 32,
"h": 14
}
},
{
"filename": "rock",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 224,
"w": 32,
"h": 14
}
},
{
"filename": "steel",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 238,
"w": 32,
"h": 14
}
},
{
"filename": "water",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 252,
"w": 32,
"h": 14
}
},
{
"filename": "stellar",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 32,
"h": 14
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 14
},
"frame": {
"x": 0,
"y": 266,
"w": 32,
"h": 14
}
}
]
}
],
"meta": {
"app": "https://www.codeandweb.com/texturepacker",
"version": "3.0",
"smartupdate": "$TexturePacker:SmartUpdate:f14cf47d9a8f1d40c8e03aa6ba00fff3:6fc4227b57a95d429a1faad4280f7ec8:5961efbfbf4c56b8745347e7a663a32f$"
}
}

BIN
public/images/types_tl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

View File

@ -0,0 +1,62 @@
{
"textures": [
{
"image": "party_discard.png",
"format": "RGBA8888",
"size": {
"w": 75,
"h": 50
},
"scale": 1,
"frames": [
{
"filename": "normal",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 75,
"h": 25
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 75,
"h": 25
},
"frame": {
"x": 0,
"y": 0,
"w": 75,
"h": 25
}
},
{
"filename": "selected",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 75,
"h": 25
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 75,
"h": 25
},
"frame": {
"x": 0,
"y": 25,
"w": 75,
"h": 25
}
}
]
}
],
"meta": {
"app": "https://www.codeandweb.com/texturepacker",
"version": "3.0",
"smartupdate": "$TexturePacker:SmartUpdate:17219773dfffd6b1204d988fea3f9462:1127ad21d64bc7ebb9df4fc28f3d2d39:7ad46e8fb4648c3d3d84a746ecb371ea$"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

View File

@ -0,0 +1,62 @@
{
"textures": [
{
"image": "party_transfer.png",
"format": "RGBA8888",
"size": {
"w": 75,
"h": 50
},
"scale": 1,
"frames": [
{
"filename": "normal",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 75,
"h": 25
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 75,
"h": 25
},
"frame": {
"x": 0,
"y": 0,
"w": 75,
"h": 25
}
},
{
"filename": "selected",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 75,
"h": 25
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 75,
"h": 25
},
"frame": {
"x": 0,
"y": 25,
"w": 75,
"h": 25
}
}
]
}
],
"meta": {
"app": "https://www.codeandweb.com/texturepacker",
"version": "3.0",
"smartupdate": "$TexturePacker:SmartUpdate:17219773dfffd6b1204d988fea3f9462:1127ad21d64bc7ebb9df4fc28f3d2d39:7ad46e8fb4648c3d3d84a746ecb371ea$"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 B

View File

@ -0,0 +1,62 @@
{
"textures": [
{
"image": "party_discard.png",
"format": "RGBA8888",
"size": {
"w": 75,
"h": 50
},
"scale": 1,
"frames": [
{
"filename": "normal",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 75,
"h": 25
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 75,
"h": 25
},
"frame": {
"x": 0,
"y": 0,
"w": 75,
"h": 25
}
},
{
"filename": "selected",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 75,
"h": 25
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 75,
"h": 25
},
"frame": {
"x": 0,
"y": 25,
"w": 75,
"h": 25
}
}
]
}
],
"meta": {
"app": "https://www.codeandweb.com/texturepacker",
"version": "3.0",
"smartupdate": "$TexturePacker:SmartUpdate:17219773dfffd6b1204d988fea3f9462:1127ad21d64bc7ebb9df4fc28f3d2d39:7ad46e8fb4648c3d3d84a746ecb371ea$"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 B

View File

@ -0,0 +1,62 @@
{
"textures": [
{
"image": "party_transfer.png",
"format": "RGBA8888",
"size": {
"w": 75,
"h": 50
},
"scale": 1,
"frames": [
{
"filename": "normal",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 75,
"h": 25
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 75,
"h": 25
},
"frame": {
"x": 0,
"y": 0,
"w": 75,
"h": 25
}
},
{
"filename": "selected",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 75,
"h": 25
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 75,
"h": 25
},
"frame": {
"x": 0,
"y": 25,
"w": 75,
"h": 25
}
}
]
}
],
"meta": {
"app": "https://www.codeandweb.com/texturepacker",
"version": "3.0",
"smartupdate": "$TexturePacker:SmartUpdate:17219773dfffd6b1204d988fea3f9462:1127ad21d64bc7ebb9df4fc28f3d2d39:7ad46e8fb4648c3d3d84a746ecb371ea$"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 B

@ -1 +1 @@
Subproject commit e2fbba17ea7a96068970ea98a8a84ed3e25b6f07
Subproject commit 7898c0018a70601a6ead76c9dd497ff966cc2e2a

View File

@ -17,15 +17,20 @@ const version = "2.0.1";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.join(__dirname, "..", "..");
const boilerplateFilePath = path.join(__dirname, "test-boilerplate.ts");
const choices = [
{ label: "Move", dir: "moves" },
{ label: "Ability", dir: "abilities" },
{ label: "Item", dir: "items" },
{ label: "Mystery Encounter", dir: "mystery-encounter/encounters" },
{ label: "Utils", dir: "utils" },
{ label: "UI", dir: "ui" },
];
const choices = /** @type {const} */ (["Move", "Ability", "Item", "Reward", "Mystery Encounter", "Utils", "UI"]);
/** @typedef {choices[number]} choiceType */
/** @satisfies {{[k in choiceType]: string}} */
const choicesToDirs = /** @type {const} */ ({
Move: "moves",
Ability: "abilities",
Item: "items",
Reward: "rewards",
"Mystery Encounter": "mystery-encounter/encounters",
Utils: "utils",
UI: "ui",
});
//#endregion
//#region Functions
@ -41,46 +46,47 @@ function getTestFolderPath(...folders) {
/**
* Prompts the user to select a type via list.
* @returns {Promise<{selectedOption: {label: string, dir: string}}>} the selected type
* @returns {Promise<choiceType>} the selected type
*/
async function promptTestType() {
const typeAnswer = await inquirer.prompt([
{
type: "list",
name: "selectedOption",
message: "What type of test would you like to create?",
choices: [...choices.map(choice => ({ name: choice.label, value: choice })), "EXIT"],
},
]);
/** @type {choiceType | "EXIT"} */
const choice = await inquirer
.prompt([
{
type: "list",
name: "selectedOption",
message: "What type of test would you like to create?",
choices: [...choices, "EXIT"],
},
])
.then(ta => ta.selectedOption);
if (typeAnswer.selectedOption === "EXIT") {
if (choice === "EXIT") {
console.log("Exiting...");
return process.exit();
}
if (!choices.some(choice => choice.dir === typeAnswer.selectedOption.dir)) {
console.error(`Please provide a valid type: (${choices.map(choice => choice.label).join(", ")})!`);
return await promptTestType();
return process.exit(0);
}
return typeAnswer;
return choice;
}
/**
* Prompts the user to provide a file name.
* @param {string} selectedType
* @returns {Promise<{userInput: string}>} the selected file name
* @param {choiceType} selectedType The chosen string (used to display console logs)
* @returns {Promise<string>} the selected file name
*/
async function promptFileName(selectedType) {
/** @type {{userInput: string}} */
const fileNameAnswer = await inquirer.prompt([
{
type: "input",
name: "userInput",
message: `Please provide the name of the ${selectedType}:`,
},
]);
/** @type {string} */
const fileNameAnswer = await inquirer
.prompt([
{
type: "input",
name: "userInput",
message: `Please provide the name of the ${selectedType}.`,
},
])
.then(fa => fa.userInput);
if (!fileNameAnswer.userInput || fileNameAnswer.userInput.trim().length === 0) {
if (fileNameAnswer.trim().length === 0) {
console.error("Please provide a valid file name!");
return await promptFileName(selectedType);
}
@ -88,51 +94,66 @@ async function promptFileName(selectedType) {
return fileNameAnswer;
}
/**
* Obtain the path to the boilerplate file based on the current option.
* @param {choiceType} choiceType The choice selected
* @returns {string} The path to the boilerplate file
*/
function getBoilerplatePath(choiceType) {
switch (choiceType) {
// case "Reward":
// return path.join(__dirname, "boilerplates/reward.ts");
default:
return path.join(__dirname, "boilerplates/default.ts");
}
}
/**
* Runs the interactive test:create "CLI"
* @returns {Promise<void>}
*/
async function runInteractive() {
console.group(chalk.grey(`Create Test - v${version}\n`));
console.group(chalk.grey(`🧪 Create Test - v${version}\n`));
try {
const typeAnswer = await promptTestType();
const fileNameAnswer = await promptFileName(typeAnswer.selectedOption.label);
const choice = await promptTestType();
const fileNameAnswer = await promptFileName(choice);
const type = typeAnswer.selectedOption;
// Convert fileName from snake_case or camelCase to kebab-case
const fileName = fileNameAnswer.userInput
const fileName = fileNameAnswer
.replace(/_+/g, "-") // Convert snake_case (underscore) to kebab-case (dashes)
.replace(/([a-z])([A-Z])/g, "$1-$2") // Convert camelCase to kebab-case
.replace(/\s+/g, "-") // Replace spaces with dashes
.toLowerCase(); // Ensure all lowercase
// Format the description for the test case
// Format the description for the test case in Title Case
const formattedName = fileName.replace(/-/g, " ").replace(/\b\w/g, char => char.toUpperCase());
const description = `${choice} - ${formattedName}`;
// Determine the directory based on the type
const dir = getTestFolderPath(type.dir);
const description = `${type.label} - ${formattedName}`;
const localDir = choicesToDirs[choice];
const absoluteDir = getTestFolderPath(localDir);
// Define the content template
const content = fs.readFileSync(boilerplateFilePath, "utf8").replace("{{description}}", description);
const content = fs.readFileSync(getBoilerplatePath(choice), "utf8").replace("{{description}}", description);
// Ensure the directory exists
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
if (!fs.existsSync(absoluteDir)) {
fs.mkdirSync(absoluteDir, { recursive: true });
}
// Create the file with the given name
const filePath = path.join(dir, `${fileName}.test.ts`);
const filePath = path.join(absoluteDir, `${fileName}.test.ts`);
if (fs.existsSync(filePath)) {
console.error(chalk.red.bold(`\n✗ File "${fileName}.test.ts" already exists!\n`));
console.error(chalk.red.bold(`✗ File "${fileName}.test.ts" already exists!\n`));
process.exit(1);
}
// Write the template content to the file
fs.writeFileSync(filePath, content, "utf8");
console.log(chalk.green.bold(`\n✔ File created at: test/${type.dir}/${fileName}.test.ts\n`));
console.log(chalk.green.bold(`✔ File created at: test/${localDir}/${fileName}.test.ts\n`));
console.groupEnd();
} catch (err) {
console.error(chalk.red("✗ Error: ", err.message));

View File

@ -1,6 +1,5 @@
import type { ArenaTagTypeMap } from "#data/arena-tag";
import type { ArenaTagType } from "#enums/arena-tag-type";
import type { NonFunctionProperties } from "./type-helpers";
/** Subset of {@linkcode ArenaTagType}s that apply some negative effect to pokemon that switch in ({@link https://bulbapedia.bulbagarden.net/wiki/List_of_moves_that_cause_entry_hazards#List_of_traps | entry hazards} and Imprison. */
export type ArenaTrapTagType =
@ -10,9 +9,6 @@ export type ArenaTrapTagType =
| ArenaTagType.STEALTH_ROCK
| ArenaTagType.IMPRISON;
/** Subset of {@linkcode ArenaTagType}s that are considered delayed attacks */
export type ArenaDelayedAttackTagType = ArenaTagType.FUTURE_SIGHT | ArenaTagType.DOOM_DESIRE;
/** Subset of {@linkcode ArenaTagType}s that create {@link https://bulbapedia.bulbagarden.net/wiki/Category:Screen-creating_moves | screens}. */
export type ArenaScreenTagType = ArenaTagType.REFLECT | ArenaTagType.LIGHT_SCREEN | ArenaTagType.AURORA_VEIL;
@ -30,13 +26,13 @@ export type NonSerializableArenaTagType = ArenaTagType.NONE | TurnProtectArenaTa
export type SerializableArenaTagType = Exclude<ArenaTagType, NonSerializableArenaTagType>;
/**
* Type-safe representation of the serializable data of an ArenaTag
* Type-safe representation of an arbitrary, serialized Arena Tag
*/
export type ArenaTagTypeData = NonFunctionProperties<
export type ArenaTagTypeData = Parameters<
ArenaTagTypeMap[keyof {
[K in keyof ArenaTagTypeMap as K extends SerializableArenaTagType ? K : never]: ArenaTagTypeMap[K];
}]
>;
}]["loadTag"]
>[0];
/** Dummy, typescript-only declaration to ensure that
* {@linkcode ArenaTagTypeMap} has a map for all ArenaTagTypes.

128
src/@types/battler-tags.ts Normal file
View File

@ -0,0 +1,128 @@
// biome-ignore-start lint/correctness/noUnusedImports: Used in a TSDoc comment
import type { AbilityBattlerTag, BattlerTagTypeMap, SerializableBattlerTag, TypeBoostTag } from "#data/battler-tags";
import type { AbilityId } from "#enums/ability-id";
// biome-ignore-end lint/correctness/noUnusedImports: end
import type { BattlerTagType } from "#enums/battler-tag-type";
/**
* Subset of {@linkcode BattlerTagType}s that restrict the use of moves.
*/
export type MoveRestrictionBattlerTagType =
| BattlerTagType.THROAT_CHOPPED
| BattlerTagType.TORMENT
| BattlerTagType.TAUNT
| BattlerTagType.IMPRISON
| BattlerTagType.HEAL_BLOCK
| BattlerTagType.ENCORE
| BattlerTagType.DISABLED
| BattlerTagType.GORILLA_TACTICS;
/**
* Subset of {@linkcode BattlerTagType}s that block damage from moves.
*/
export type FormBlockDamageBattlerTagType = BattlerTagType.ICE_FACE | BattlerTagType.DISGUISE;
/**
* Subset of {@linkcode BattlerTagType}s that are related to trapping effects.
*/
export type TrappingBattlerTagType =
| BattlerTagType.BIND
| BattlerTagType.WRAP
| BattlerTagType.FIRE_SPIN
| BattlerTagType.WHIRLPOOL
| BattlerTagType.CLAMP
| BattlerTagType.SAND_TOMB
| BattlerTagType.MAGMA_STORM
| BattlerTagType.SNAP_TRAP
| BattlerTagType.THUNDER_CAGE
| BattlerTagType.INFESTATION
| BattlerTagType.INGRAIN
| BattlerTagType.OCTOLOCK
| BattlerTagType.NO_RETREAT;
/**
* Subset of {@linkcode BattlerTagType}s that are related to protection effects.
*/
export type ProtectionBattlerTagType = BattlerTagType.PROTECTED | BattlerTagType.SPIKY_SHIELD | DamageProtectedTagType;
/**
* Subset of {@linkcode BattlerTagType}s related to protection effects that block damage but not status moves.
*/
export type DamageProtectedTagType = ContactSetStatusProtectedTagType | ContactStatStageChangeProtectedTagType;
/**
* Subset of {@linkcode BattlerTagType}s related to protection effects that set a status effect on the attacker.
*/
export type ContactSetStatusProtectedTagType = BattlerTagType.BANEFUL_BUNKER | BattlerTagType.BURNING_BULWARK;
/**
* Subset of {@linkcode BattlerTagType}s related to protection effects that change stat stages of the attacker.
*/
export type ContactStatStageChangeProtectedTagType =
| BattlerTagType.KINGS_SHIELD
| BattlerTagType.SILK_TRAP
| BattlerTagType.OBSTRUCT;
/** Subset of {@linkcode BattlerTagType}s that provide the Endure effect */
export type EndureTagType = BattlerTagType.ENDURE_TOKEN | BattlerTagType.ENDURING;
/**
* Subset of {@linkcode BattlerTagType}s that are related to semi-invulnerable states.
*/
export type SemiInvulnerableTagType =
| BattlerTagType.FLYING
| BattlerTagType.UNDERGROUND
| BattlerTagType.UNDERWATER
| BattlerTagType.HIDDEN;
/**
* Subset of {@linkcode BattlerTagType}s corresponding to {@linkcode AbilityBattlerTag}s
*
* @remarks
* {@linkcode AbilityId.FLASH_FIRE | Flash Fire}'s {@linkcode BattlerTagType.FIRE_BOOST} is not included as it
* subclasses {@linkcode TypeBoostTag} and not `AbilityBattlerTag`.
*/
export type AbilityBattlerTagType =
| BattlerTagType.PROTOSYNTHESIS
| BattlerTagType.QUARK_DRIVE
| BattlerTagType.UNBURDEN
| BattlerTagType.SLOW_START
| BattlerTagType.TRUANT;
/**
* Subset of {@linkcode BattlerTagType}s related to abilities that boost the highest stat.
*/
export type HighestStatBoostTagType =
| BattlerTagType.QUARK_DRIVE // formatting
| BattlerTagType.PROTOSYNTHESIS;
/**
* Subset of {@linkcode BattlerTagType}s that are able to persist between turns and should therefore be serialized
*/
export type SerializableBattlerTagType = keyof {
[K in keyof BattlerTagTypeMap as BattlerTagTypeMap[K] extends SerializableBattlerTag
? K
: never]: BattlerTagTypeMap[K];
};
/**
* Subset of {@linkcode BattlerTagType}s that are not able to persist across waves and should therefore not be serialized
*/
export type NonSerializableBattlerTagType = Exclude<BattlerTagType, SerializableBattlerTagType>;
/**
* Type-safe representation of an arbitrary, serialized Battler Tag
*/
export type BattlerTagTypeData = Parameters<
BattlerTagTypeMap[keyof {
[K in keyof BattlerTagTypeMap as K extends SerializableBattlerTagType ? K : never]: BattlerTagTypeMap[K];
}]["loadTag"]
>[0];
/**
* Dummy, typescript-only declaration to ensure that
* {@linkcode BattlerTagTypeMap} has an entry for all `BattlerTagType`s.
*
* If a battler tag is missing from the map, Typescript will throw an error on this statement.
*
* Does not actually exist at runtime, so it must not be used!
*/
declare const EnsureAllBattlerTagTypesAreMapped: BattlerTagTypeMap[BattlerTagType] & never;

View File

@ -1,18 +1,14 @@
import type { ObjectValues } from "#types/type-helpers";
/** Union type accepting any TS Enum or `const object`, with or without reverse mapping. */
export type EnumOrObject = Record<string | number, string | number>;
/**
* Utility type to extract the enum values from a `const object`,
* or convert an `enum` interface produced by `typeof Enum` into the union type representing its values.
*/
export type EnumValues<E> = E[keyof E];
/**
* Generic type constraint representing a TS numeric enum with reverse mappings.
* @example
* TSNumericEnum<typeof WeatherType>
*/
export type TSNumericEnum<T extends EnumOrObject> = number extends EnumValues<T> ? T : never;
export type TSNumericEnum<T extends EnumOrObject> = number extends ObjectValues<T> ? T : never;
/** Generic type constraint representing a non reverse-mapped TS enum or `const object`. */
export type NormalEnum<T extends EnumOrObject> = Exclude<T, TSNumericEnum<T>>;

View File

@ -6,8 +6,6 @@
import type { AbAttr } from "#abilities/ability";
// biome-ignore-end lint/correctness/noUnusedImports: Used in a tsdoc comment
import type { EnumValues } from "#types/enum-types";
/**
* Exactly matches the type of the argument, preventing adding additional properties.
*
@ -37,16 +35,25 @@ export type Mutable<T> = {
};
/**
* Type helper to obtain the keys associated with a given value inside a `const object`.
* Type helper to obtain the keys associated with a given value inside an object.
* @typeParam O - The type of the object
* @typeParam V - The type of one of O's values
*/
export type InferKeys<O extends Record<keyof any, unknown>, V extends EnumValues<O>> = {
export type InferKeys<O extends object, V extends ObjectValues<O>> = {
[K in keyof O]: O[K] extends V ? K : never;
}[keyof O];
/**
* Type helper that matches any `Function` type. Equivalent to `Function`, but will not raise a warning from Biome.
* Utility type to obtain the values of a given object. \
* Functions similar to `keyof E`, except producing the values instead of the keys.
* @remarks
* This can be used to convert an `enum` interface produced by `typeof Enum` into the union type representing its members.
*/
export type ObjectValues<E extends object> = E[keyof E];
/**
* Type helper that matches any `Function` type.
* Equivalent to `Function`, but will not raise a warning from Biome.
*/
export type AnyFn = (...args: any[]) => any;
@ -65,6 +72,7 @@ export type NonFunctionProperties<T> = {
/**
* Type helper to extract out non-function properties from a type, recursively applying to nested properties.
* This can be used to mimic the effects of JSON serialization and de-serialization on a given type.
*/
export type NonFunctionPropertiesRecursive<Class> = {
[K in keyof Class as Class[K] extends AnyFn ? never : K]: Class[K] extends Array<infer U>
@ -75,3 +83,14 @@ export type NonFunctionPropertiesRecursive<Class> = {
};
export type AbstractConstructor<T> = abstract new (...args: any[]) => T;
/**
* Type helper that iterates through the fields of the type and coerces any `null` properties to `undefined` (including in union types).
*
* @remarks
* This is primarily useful when an object with nullable properties wants to be serialized and have its `null`
* properties coerced to `undefined`.
*/
export type CoerceNullPropertiesToUndefined<T extends object> = {
[K in keyof T]: null extends T[K] ? Exclude<T[K], null> | undefined : T[K];
};

View File

@ -11,7 +11,7 @@ export interface IllusionData {
/** The name of pokemon featured in the illusion */
name: string;
/** The nickname of the pokemon featured in the illusion */
nickname: string;
nickname?: string;
/** Whether the pokemon featured in the illusion is shiny or not */
shiny: boolean;
/** The variant of the pokemon featured in the illusion */

View File

@ -3,6 +3,7 @@
import type { Pokemon } from "#field/pokemon";
import type { ModifierConstructorMap } from "#modifiers/modifier";
import type { ModifierType, WeightedModifierType } from "#modifiers/modifier-type";
import type { ObjectValues } from "#types/type-helpers";
export type ModifierTypeFunc = () => ModifierType;
export type WeightedModifierTypeWeightFunc = (party: Pokemon[], rerollCount?: number) => number;
@ -19,7 +20,7 @@ export type ModifierInstanceMap = {
/**
* Union type of all modifier constructors.
*/
export type ModifierClass = ModifierConstructorMap[keyof ModifierConstructorMap];
export type ModifierClass = ObjectValues<ModifierConstructorMap>;
/**
* Union type of all modifier names as strings.

View File

@ -1,4 +1,5 @@
import type { PhaseConstructorMap } from "#app/phase-manager";
import type { ObjectValues } from "#types/type-helpers";
// Intentionally export the types of everything in phase-manager, as this file is meant to be
// the centralized place for type definitions for the phase system.
@ -17,7 +18,7 @@ export type PhaseMap = {
/**
* Union type of all phase constructors.
*/
export type PhaseClass = PhaseConstructorMap[keyof PhaseConstructorMap];
export type PhaseClass = ObjectValues<PhaseConstructorMap>;
/**
* Union type of all phase names as strings.

View File

@ -657,9 +657,7 @@ export class BattleScene extends SceneBase {
).then(() => loadMoveAnimAssets(defaultMoves, true)),
this.initStarterColors(),
]).then(() => {
this.phaseManager.pushNew("LoginPhase");
this.phaseManager.pushNew("TitlePhase");
this.phaseManager.toTitleScreen(true);
this.phaseManager.shiftPhase();
});
}
@ -1269,13 +1267,12 @@ export class BattleScene extends SceneBase {
duration: 250,
ease: "Sine.easeInOut",
onComplete: () => {
this.phaseManager.clearPhaseQueue();
this.ui.freeUIData();
this.uiContainer.remove(this.ui, true);
this.uiContainer.destroy();
this.children.removeAll(true);
this.game.domContainer.innerHTML = "";
// TODO: `launchBattle` calls `reset(false, false, true)`
this.launchBattle();
},
});
@ -2848,6 +2845,23 @@ export class BattleScene extends SceneBase {
}
return false;
}
/**
* Attempt to discard one or more copies of a held item.
* @param itemModifier - The {@linkcode PokemonHeldItemModifier} being discarded
* @param discardQuantity - The number of copies to remove (up to the amount currently held); default `1`
* @returns Whether the item was successfully discarded.
* Removing fewer items than requested is still considered a success.
*/
tryDiscardHeldItemModifier(itemModifier: PokemonHeldItemModifier, discardQuantity = 1): boolean {
const countTaken = Math.min(discardQuantity, itemModifier.stackCount);
itemModifier.stackCount -= countTaken;
if (itemModifier.stackCount > 0) {
return true;
}
return this.removeModifier(itemModifier);
}
canTransferHeldItemModifier(itemModifier: PokemonHeldItemModifier, target: Pokemon, transferQuantity = 1): boolean {
const mod = itemModifier.clone() as PokemonHeldItemModifier;

View File

@ -1,3 +1,7 @@
/** biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports */
import type { BattlerTag } from "#app/data/battler-tags";
/** biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports */
import { applyAbAttrs, applyOnGainAbAttrs, applyOnLoseAbAttrs } from "#abilities/apply-ab-attrs";
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
@ -6,58 +10,72 @@ import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type";
import type { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { HitResult } from "#enums/hit-result";
import { CommonAnim } from "#enums/move-anims-common";
import { MoveCategory } from "#enums/move-category";
import { MoveId } from "#enums/move-id";
import { MoveTarget } from "#enums/move-target";
import { MoveUseMode } from "#enums/move-use-mode";
import { PokemonType } from "#enums/pokemon-type";
import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
import type { Arena } from "#field/arena";
import type { Pokemon } from "#field/pokemon";
import type {
ArenaDelayedAttackTagType,
ArenaScreenTagType,
ArenaTagTypeData,
ArenaTrapTagType,
SerializableArenaTagType,
} from "#types/arena-tags";
import type { Mutable, NonFunctionProperties } from "#types/type-helpers";
import { BooleanHolder, isNullOrUndefined, NumberHolder, toDmgValue } from "#utils/common";
import type { Mutable } from "#types/type-helpers";
import { BooleanHolder, NumberHolder, toDmgValue } from "#utils/common";
import i18next from "i18next";
/*
ArenaTags are are meant for effects that are tied to the arena (as opposed to a specific pokemon).
Examples include (but are not limited to)
- Cross-turn effects that persist even if the user/target switches out, such as Wish, Future Sight, and Happy Hour
- Effects that are applied to a specific side of the field, such as Crafty Shield, Reflect, and Spikes
- Field-Effects, like Gravity and Trick Room
Any arena tag that persists across turns *must* extend from `SerializableArenaTag` in the class definition signature.
Serializable ArenaTags have strict rules for their fields.
These rules ensure that only the data necessary to reconstruct the tag is serialized, and that the
session loader is able to deserialize saved tags correctly.
If the data is static (i.e. it is always the same for all instances of the class, such as the
type that is weakened by Mud Sport/Water Sport), then it must not be defined as a field, and must
instead be defined as a getter.
A static property is also acceptable, though static properties are less ergonomic with inheritance.
If the data is mutable (i.e. it can change over the course of the tag's lifetime), then it *must*
be defined as a field, and it must be set in the `loadTag` method.
Such fields cannot be marked as `private/protected`, as if they were, typescript would omit them from
types that are based off of the class, namely, `ArenaTagTypeData`. It is preferrable to trade the
type-safety of private/protected fields for the type safety when deserializing arena tags from save data.
For data that is mutable only within a turn (e.g. SuppressAbilitiesTag's beingRemoved field),
where it does not make sense to be serialized, the field should use ES2020's private field syntax (a `#` prepended to the field name).
If the field should be accessible outside of the class, then a public getter should be used.
*/
/**
* @module
* ArenaTags are are meant for effects that are tied to the arena (as opposed to a specific pokemon).
* Examples include (but are not limited to)
* - Cross-turn effects that persist even if the user/target switches out, such as Happy Hour
* - Effects that are applied to a specific side of the field, such as Crafty Shield, Reflect, and Spikes
* - Field-Effects, like Gravity and Trick Room
*
* Any arena tag that persists across turns *must* extend from `SerializableArenaTag` in the class definition signature.
*
* Serializable ArenaTags have strict rules for their fields.
* These rules ensure that only the data necessary to reconstruct the tag is serialized, and that the
* session loader is able to deserialize saved tags correctly.
*
* If the data is static (i.e. it is always the same for all instances of the class, such as the
* type that is weakened by Mud Sport/Water Sport), then it must not be defined as a field, and must
* instead be defined as a getter.
* A static property is also acceptable, though static properties are less ergonomic with inheritance.
*
* If the data is mutable (i.e. it can change over the course of the tag's lifetime), then it *must*
* be defined as a field, and it must be set in the `loadTag` method.
* Such fields cannot be marked as `private`/`protected`; if they were, Typescript would omit them from
* types that are based off of the class, namely, `ArenaTagTypeData`. It is preferrable to trade the
* type-safety of private/protected fields for the type safety when deserializing arena tags from save data.
*
* For data that is mutable only within a turn (e.g. SuppressAbilitiesTag's beingRemoved field),
* where it does not make sense to be serialized, the field should use ES2020's
* [private field syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_elements#private_fields).
* If the field should be accessible outside of the class, then a public getter should be used.
*
* If any new serializable fields *are* added, then the class *must* override the
* `loadTag` method to set the new fields. Its signature *must* match the example below,
* ```
* class ExampleTag extends SerializableArenaTag {
* // Example, if we add 2 new fields that should be serialized:
* public a: string;
* public b: number;
* // Then we must also define a loadTag method with one of the following signatures
* public override loadTag(source: BaseArenaTag & Pick<ExampleTag, "tagType" | "a" | "b"): void;
* public override loadTag<const T extends this>(source: BaseArenaTag & Pick<T, "tagType" | "a" | "b">): void;
* }
* ```
* Notes
* - If the class has any subclasses, then the second form of `loadTag` *must* be used.
*/
/** Interface containing the serializable fields of ArenaTagData. */
interface BaseArenaTag {
@ -141,9 +159,9 @@ export abstract class ArenaTag implements BaseArenaTag {
/**
* When given a arena tag or json representing one, load the data for it.
* This is meant to be inherited from by any arena tag with custom attributes
* @param source - The {@linkcode BaseArenaTag} being loaded
* @param source - The arena tag being loaded
*/
loadTag(source: BaseArenaTag): void {
loadTag<const T extends this>(source: BaseArenaTag & Pick<T, "tagType">): void {
this.turnCount = source.turnCount;
this.sourceMove = source.sourceMove;
this.sourceId = source.sourceId;
@ -604,56 +622,6 @@ export class NoCritTag extends SerializableArenaTag {
}
}
/**
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Wish_(move) | Wish}.
* Heals the Pokémon in the user's position the turn after Wish is used.
*/
class WishTag extends SerializableArenaTag {
// The following fields are meant to be inwardly mutable, but outwardly immutable.
readonly battlerIndex: BattlerIndex;
readonly healHp: number;
readonly sourceName: string;
// End inwardly mutable fields
public readonly tagType = ArenaTagType.WISH;
constructor(turnCount: number, sourceId: number | undefined, side: ArenaTagSide) {
super(turnCount, MoveId.WISH, sourceId, side);
}
onAdd(_arena: Arena): void {
const source = this.getSourcePokemon();
if (!source) {
console.warn(`Failed to get source Pokemon for WishTag on add message; id: ${this.sourceId}`);
return;
}
(this as Mutable<this>).sourceName = getPokemonNameWithAffix(source);
(this as Mutable<this>).healHp = toDmgValue(source.getMaxHp() / 2);
(this as Mutable<this>).battlerIndex = source.getBattlerIndex();
}
onRemove(_arena: Arena): void {
const target = globalScene.getField()[this.battlerIndex];
if (target?.isActive(true)) {
globalScene.phaseManager.queueMessage(
// TODO: Rename key as it triggers on activation
i18next.t("arenaTag:wishTagOnAdd", {
pokemonNameWithAffix: this.sourceName,
}),
);
globalScene.phaseManager.unshiftNew("PokemonHealPhase", target.getBattlerIndex(), this.healHp, null, true, false);
}
}
override loadTag(source: NonFunctionProperties<WishTag>): void {
super.loadTag(source);
(this as Mutable<this>).battlerIndex = source.battlerIndex;
(this as Mutable<this>).healHp = source.healHp;
(this as Mutable<this>).sourceName = source.sourceName;
}
}
/**
* Abstract class to implement weakened moves of a specific type.
*/
@ -813,7 +781,7 @@ export abstract class ArenaTrapTag extends SerializableArenaTag {
: Phaser.Math.Linear(0, 1 / Math.pow(2, this.layers), Math.min(pokemon.getHpRatio(), 0.5) * 2);
}
loadTag(source: NonFunctionProperties<ArenaTrapTag>): void {
public loadTag<T extends this>(source: BaseArenaTag & Pick<T, "tagType" | "layers" | "maxLayers">): void {
super.loadTag(source);
this.layers = source.layers;
this.maxLayers = source.maxLayers;
@ -1126,48 +1094,6 @@ class StickyWebTag extends ArenaTrapTag {
}
}
/**
* Arena Tag class for delayed attacks, such as {@linkcode MoveId.FUTURE_SIGHT} or {@linkcode MoveId.DOOM_DESIRE}.
* Delays the attack's effect by a set amount of turns, usually 3 (including the turn the move is used),
* and deals damage after the turn count is reached.
*/
export class DelayedAttackTag extends SerializableArenaTag {
public targetIndex: BattlerIndex;
public readonly tagType: ArenaDelayedAttackTagType;
constructor(
tagType: ArenaTagType.DOOM_DESIRE | ArenaTagType.FUTURE_SIGHT,
sourceMove: MoveId | undefined,
sourceId: number | undefined,
targetIndex: BattlerIndex,
side: ArenaTagSide = ArenaTagSide.BOTH,
) {
super(3, sourceMove, sourceId, side);
this.tagType = tagType;
this.targetIndex = targetIndex;
this.side = side;
}
lapse(arena: Arena): boolean {
const ret = super.lapse(arena);
if (!ret) {
// TODO: This should not add to move history (for Spite)
globalScene.phaseManager.unshiftNew(
"MoveEffectPhase",
this.sourceId!,
[this.targetIndex],
allMoves[this.sourceMove!],
MoveUseMode.FOLLOW_UP,
); // TODO: are those bangs correct?
}
return ret;
}
onRemove(_arena: Arena): void {}
}
/**
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Trick_Room_(move) Trick Room}.
* Reverses the Speed stats for all Pokémon on the field as long as this arena tag is up,
@ -1581,7 +1507,7 @@ export class SuppressAbilitiesTag extends SerializableArenaTag {
this.#beingRemoved = false;
}
public override loadTag(source: NonFunctionProperties<SuppressAbilitiesTag>): void {
public override loadTag(source: BaseArenaTag & Pick<SuppressAbilitiesTag, "tagType" | "sourceCount">): void {
super.loadTag(source);
(this as Mutable<this>).sourceCount = source.sourceCount;
}
@ -1663,7 +1589,6 @@ export function getArenaTag(
turnCount: number,
sourceMove: MoveId | undefined,
sourceId: number | undefined,
targetIndex?: BattlerIndex,
side: ArenaTagSide = ArenaTagSide.BOTH,
): ArenaTag | null {
switch (tagType) {
@ -1689,14 +1614,6 @@ export function getArenaTag(
return new SpikesTag(sourceId, side);
case ArenaTagType.TOXIC_SPIKES:
return new ToxicSpikesTag(sourceId, side);
case ArenaTagType.FUTURE_SIGHT:
case ArenaTagType.DOOM_DESIRE:
if (isNullOrUndefined(targetIndex)) {
return null; // If missing target index, no tag is created
}
return new DelayedAttackTag(tagType, sourceMove, sourceId, targetIndex, side);
case ArenaTagType.WISH:
return new WishTag(turnCount, sourceId, side);
case ArenaTagType.STEALTH_ROCK:
return new StealthRockTag(sourceId, side);
case ArenaTagType.STICKY_WEB:
@ -1739,16 +1656,12 @@ export function getArenaTag(
* @param source - An arena tag
* @returns The valid arena tag
*/
export function loadArenaTag(source: (ArenaTag | ArenaTagTypeData) & { targetIndex?: BattlerIndex }): ArenaTag {
export function loadArenaTag(source: ArenaTag | ArenaTagTypeData | { tagType: ArenaTagType.NONE }): ArenaTag {
if (source.tagType === ArenaTagType.NONE) {
return new NoneTag();
}
const tag =
getArenaTag(
source.tagType,
source.turnCount,
source.sourceMove,
source.sourceId,
source.targetIndex,
source.side,
) ?? new NoneTag();
getArenaTag(source.tagType, source.turnCount, source.sourceMove, source.sourceId, source.side) ?? new NoneTag();
tag.loadTag(source);
return tag;
}
@ -1765,9 +1678,6 @@ export type ArenaTagTypeMap = {
[ArenaTagType.CRAFTY_SHIELD]: CraftyShieldTag;
[ArenaTagType.NO_CRIT]: NoCritTag;
[ArenaTagType.TOXIC_SPIKES]: ToxicSpikesTag;
[ArenaTagType.FUTURE_SIGHT]: DelayedAttackTag;
[ArenaTagType.DOOM_DESIRE]: DelayedAttackTag;
[ArenaTagType.WISH]: WishTag;
[ArenaTagType.STEALTH_ROCK]: StealthRockTag;
[ArenaTagType.STICKY_WEB]: StickyWebTag;
[ArenaTagType.TRICK_ROOM]: TrickRoomTag;

View File

@ -1,8 +1,8 @@
import { allMoves } from "#data/data-lists";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { toReadableString } from "#utils/common";
import { getEnumKeys, getEnumValues } from "#utils/enums";
import { toTitleCase } from "#utils/strings";
export const speciesEggMoves = {
[SpeciesId.BULBASAUR]: [ MoveId.SAPPY_SEED, MoveId.MALIGNANT_CHAIN, MoveId.EARTH_POWER, MoveId.MATCHA_GOTCHA ],
@ -617,7 +617,7 @@ function parseEggMoves(content: string): void {
}
if (eggMoves.every(m => m === MoveId.NONE)) {
console.warn(`Species ${toReadableString(SpeciesId[species])} could not be parsed, excluding from output...`)
console.warn(`Species ${toTitleCase(SpeciesId[species])} could not be parsed, excluding from output...`)
} else {
output += `[SpeciesId.${SpeciesId[species]}]: [ ${eggMoves.map(m => `MoveId.${MoveId[m]}`).join(", ")} ],\n`;
}

View File

@ -7,8 +7,9 @@ import { AnimBlendType, AnimFocus, AnimFrameTarget, ChargeAnim, CommonAnim } fro
import { MoveFlags } from "#enums/move-flags";
import { MoveId } from "#enums/move-id";
import type { Pokemon } from "#field/pokemon";
import { animationFileName, coerceArray, getFrameMs, isNullOrUndefined, type nil } from "#utils/common";
import { coerceArray, getFrameMs, isNullOrUndefined, type nil } from "#utils/common";
import { getEnumKeys, getEnumValues } from "#utils/enums";
import { toKebabCase } from "#utils/strings";
import Phaser from "phaser";
export class AnimConfig {
@ -412,7 +413,7 @@ export function initCommonAnims(): Promise<void> {
const commonAnimId = commonAnimIds[ca];
commonAnimFetches.push(
globalScene
.cachedFetch(`./battle-anims/common-${commonAnimNames[ca].toLowerCase().replace(/_/g, "-")}.json`)
.cachedFetch(`./battle-anims/common-${toKebabCase(commonAnimNames[ca])}.json`)
.then(response => response.json())
.then(cas => commonAnims.set(commonAnimId, new AnimConfig(cas))),
);
@ -450,7 +451,7 @@ export function initMoveAnim(move: MoveId): Promise<void> {
const fetchAnimAndResolve = (move: MoveId) => {
globalScene
.cachedFetch(`./battle-anims/${animationFileName(move)}.json`)
.cachedFetch(`./battle-anims/${toKebabCase(MoveId[move])}.json`)
.then(response => {
const contentType = response.headers.get("content-type");
if (!response.ok || contentType?.indexOf("application/json") === -1) {
@ -506,7 +507,7 @@ function useDefaultAnim(move: MoveId, defaultMoveAnim: MoveId) {
* @remarks use {@linkcode useDefaultAnim} to use a default animation
*/
function logMissingMoveAnim(move: MoveId, ...optionalParams: any[]) {
const moveName = animationFileName(move);
const moveName = toKebabCase(MoveId[move]);
console.warn(`Could not load animation file for move '${moveName}'`, ...optionalParams);
}
@ -524,7 +525,7 @@ export async function initEncounterAnims(encounterAnim: EncounterAnim | Encounte
}
encounterAnimFetches.push(
globalScene
.cachedFetch(`./battle-anims/encounter-${encounterAnimNames[anim].toLowerCase().replace(/_/g, "-")}.json`)
.cachedFetch(`./battle-anims/encounter-${toKebabCase(encounterAnimNames[anim])}.json`)
.then(response => response.json())
.then(cas => encounterAnims.set(anim, new AnimConfig(cas))),
);
@ -548,7 +549,7 @@ export function initMoveChargeAnim(chargeAnim: ChargeAnim): Promise<void> {
} else {
chargeAnims.set(chargeAnim, null);
globalScene
.cachedFetch(`./battle-anims/${ChargeAnim[chargeAnim].toLowerCase().replace(/_/g, "-")}.json`)
.cachedFetch(`./battle-anims/${toKebabCase(ChargeAnim[chargeAnim])}.json`)
.then(response => response.json())
.then(ca => {
if (Array.isArray(ca)) {
@ -1405,7 +1406,9 @@ export async function populateAnims() {
const chargeAnimIds = getEnumValues(ChargeAnim);
const commonNamePattern = /name: (?:Common:)?(Opp )?(.*)/;
const moveNameToId = {};
// Exclude MoveId.NONE;
for (const move of getEnumValues(MoveId).slice(1)) {
// KARATE_CHOP => KARATECHOP
const moveName = MoveId[move].toUpperCase().replace(/_/g, "");
moveNameToId[moveName] = move;
}

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ import { defaultStarterSpecies } from "#app/constants";
import { globalScene } from "#app/global-scene";
import { pokemonEvolutions } from "#balance/pokemon-evolutions";
import { speciesStarterCosts } from "#balance/starters";
import { getEggTierForSpecies } from "#data/egg";
import { pokemonFormChanges } from "#data/pokemon-forms";
import type { PokemonSpecies } from "#data/pokemon-species";
import { getPokemonSpeciesForm } from "#data/pokemon-species";
@ -11,6 +12,7 @@ import { BattleType } from "#enums/battle-type";
import { ChallengeType } from "#enums/challenge-type";
import { Challenges } from "#enums/challenges";
import { TypeColor, TypeShadow } from "#enums/color";
import { EggTier } from "#enums/egg-type";
import { ClassicFixedBossWaves } from "#enums/fixed-boss-waves";
import { ModifierTier } from "#enums/modifier-tier";
import { MoveId } from "#enums/move-id";
@ -28,6 +30,7 @@ import type { DexAttrProps, GameData } from "#system/game-data";
import { BooleanHolder, type NumberHolder, randSeedItem } from "#utils/common";
import { deepCopy } from "#utils/data";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { toCamelCase, toSnakeCase } from "#utils/strings";
import i18next from "i18next";
/** A constant for the default max cost of the starting party before a run */
@ -68,14 +71,11 @@ export abstract class Challenge {
}
/**
* Gets the localisation key for the challenge
* @returns {@link string} The i18n key for this challenge
* Gets the localization key for the challenge
* @returns The i18n key for this challenge as camel case.
*/
geti18nKey(): string {
return Challenges[this.id]
.split("_")
.map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()))
.join("");
return toCamelCase(Challenges[this.id]);
}
/**
@ -106,23 +106,22 @@ export abstract class Challenge {
}
/**
* Returns the textual representation of a challenge's current value.
* @param overrideValue {@link number} The value to check for. If undefined, gets the current value.
* @returns {@link string} The localised name for the current value.
* Return the textual representation of a challenge's current value.
* @param overrideValue - The value to check for; default {@linkcode this.value}
* @returns The localised text for the current value.
*/
getValue(overrideValue?: number): string {
const value = overrideValue ?? this.value;
return i18next.t(`challenges:${this.geti18nKey()}.value.${value}`);
getValue(overrideValue: number = this.value): string {
return i18next.t(`challenges:${this.geti18nKey()}.value.${overrideValue}`);
}
/**
* Returns the description of a challenge's current value.
* @param overrideValue {@link number} The value to check for. If undefined, gets the current value.
* @returns {@link string} The localised description for the current value.
* Return the description of a challenge's current value.
* @param overrideValue - The value to check for; default {@linkcode this.value}
* @returns The localised description for the current value.
*/
getDescription(overrideValue?: number): string {
const value = overrideValue ?? this.value;
return `${i18next.t([`challenges:${this.geti18nKey()}.desc.${value}`, `challenges:${this.geti18nKey()}.desc`])}`;
// TODO: Do we need an override value here? it's currently unused
getDescription(overrideValue: number = this.value): string {
return `${i18next.t([`challenges:${this.geti18nKey()}.desc.${overrideValue}`, `challenges:${this.geti18nKey()}.desc`])}`;
}
/**
@ -649,31 +648,19 @@ export class SingleGenerationChallenge extends Challenge {
return this.value > 0 ? 1 : 0;
}
/**
* Returns the textual representation of a challenge's current value.
* @param {value} overrideValue The value to check for. If undefined, gets the current value.
* @returns {string} The localised name for the current value.
*/
getValue(overrideValue?: number): string {
const value = overrideValue ?? this.value;
if (value === 0) {
getValue(overrideValue: number = this.value): string {
if (overrideValue === 0) {
return i18next.t("settings:off");
}
return i18next.t(`starterSelectUiHandler:gen${value}`);
return i18next.t(`starterSelectUiHandler:gen${overrideValue}`);
}
/**
* Returns the description of a challenge's current value.
* @param {value} overrideValue The value to check for. If undefined, gets the current value.
* @returns {string} The localised description for the current value.
*/
getDescription(overrideValue?: number): string {
const value = overrideValue ?? this.value;
if (value === 0) {
getDescription(overrideValue: number = this.value): string {
if (overrideValue === 0) {
return i18next.t("challenges:singleGeneration.desc_default");
}
return i18next.t("challenges:singleGeneration.desc", {
gen: i18next.t(`challenges:singleGeneration.gen_${value}`),
gen: i18next.t(`challenges:singleGeneration.gen_${overrideValue}`),
});
}
@ -741,29 +728,13 @@ export class SingleTypeChallenge extends Challenge {
return this.value > 0 ? 1 : 0;
}
/**
* Returns the textual representation of a challenge's current value.
* @param {value} overrideValue The value to check for. If undefined, gets the current value.
* @returns {string} The localised name for the current value.
*/
getValue(overrideValue?: number): string {
if (overrideValue === undefined) {
overrideValue = this.value;
}
return PokemonType[this.value - 1].toLowerCase();
getValue(overrideValue: number = this.value): string {
return toSnakeCase(PokemonType[overrideValue - 1]);
}
/**
* Returns the description of a challenge's current value.
* @param {value} overrideValue The value to check for. If undefined, gets the current value.
* @returns {string} The localised description for the current value.
*/
getDescription(overrideValue?: number): string {
if (overrideValue === undefined) {
overrideValue = this.value;
}
const type = i18next.t(`pokemonInfo:Type.${PokemonType[this.value - 1]}`);
const typeColor = `[color=${TypeColor[PokemonType[this.value - 1]]}][shadow=${TypeShadow[PokemonType[this.value - 1]]}]${type}[/shadow][/color]`;
getDescription(overrideValue: number = this.value): string {
const type = i18next.t(`pokemonInfo:Type.${PokemonType[overrideValue - 1]}`);
const typeColor = `[color=${TypeColor[PokemonType[overrideValue - 1]]}][shadow=${TypeShadow[PokemonType[this.value - 1]]}]${type}[/shadow][/color]`;
const defaultDesc = i18next.t("challenges:singleType.desc_default");
const typeDesc = i18next.t("challenges:singleType.desc", {
type: typeColor,
@ -784,11 +755,14 @@ export class SingleTypeChallenge extends Challenge {
*/
export class FreshStartChallenge extends Challenge {
constructor() {
super(Challenges.FRESH_START, 1);
super(Challenges.FRESH_START, 3);
}
applyStarterChoice(pokemon: PokemonSpecies, valid: BooleanHolder): boolean {
if (!defaultStarterSpecies.includes(pokemon.speciesId)) {
if (
(this.value === 1 && !defaultStarterSpecies.includes(pokemon.speciesId)) ||
(this.value === 2 && getEggTierForSpecies(pokemon) >= EggTier.EPIC)
) {
valid.value = false;
return true;
}
@ -796,15 +770,12 @@ export class FreshStartChallenge extends Challenge {
}
applyStarterCost(species: SpeciesId, cost: NumberHolder): boolean {
if (defaultStarterSpecies.includes(species)) {
cost.value = speciesStarterCosts[species];
return true;
}
return false;
cost.value = speciesStarterCosts[species];
return true;
}
applyStarterModify(pokemon: Pokemon): boolean {
pokemon.abilityIndex = 0; // Always base ability, not hidden ability
pokemon.abilityIndex = pokemon.abilityIndex % 2; // Always base ability, if you set it to hidden it wraps to first ability
pokemon.passive = false; // Passive isn't unlocked
pokemon.nature = Nature.HARDY; // Neutral nature
pokemon.moveset = pokemon.species
@ -816,7 +787,22 @@ export class FreshStartChallenge extends Challenge {
pokemon.luck = 0; // No luck
pokemon.shiny = false; // Not shiny
pokemon.variant = 0; // Not shiny
pokemon.formIndex = 0; // Froakie should be base form
if (pokemon.species.speciesId === SpeciesId.ZYGARDE && pokemon.formIndex >= 2) {
pokemon.formIndex -= 2; // Sets 10%-PC to 10%-AB and 50%-PC to 50%-AB
} else if (
pokemon.formIndex > 0 &&
[
SpeciesId.PIKACHU,
SpeciesId.EEVEE,
SpeciesId.PICHU,
SpeciesId.ROTOM,
SpeciesId.MELOETTA,
SpeciesId.FROAKIE,
SpeciesId.ROCKRUFF,
].includes(pokemon.species.speciesId)
) {
pokemon.formIndex = 0; // These mons are set to form 0 because they're meant to be unlocks or mid-run form changes
}
pokemon.ivs = [15, 15, 15, 15, 15, 15]; // Default IVs of 15 for all stats (Updated to 15 from 10 in 1.2.0)
pokemon.teraType = pokemon.species.type1; // Always primary tera type
return true;
@ -902,13 +888,7 @@ export class LowerStarterMaxCostChallenge extends Challenge {
super(Challenges.LOWER_MAX_STARTER_COST, 9);
}
/**
* @override
*/
getValue(overrideValue?: number): string {
if (overrideValue === undefined) {
overrideValue = this.value;
}
getValue(overrideValue: number = this.value): string {
return (DEFAULT_PARTY_MAX_COST - overrideValue).toString();
}
@ -936,13 +916,7 @@ export class LowerStarterPointsChallenge extends Challenge {
super(Challenges.LOWER_STARTER_POINTS, 9);
}
/**
* @override
*/
getValue(overrideValue?: number): string {
if (overrideValue === undefined) {
overrideValue = this.value;
}
getValue(overrideValue: number = this.value): string {
return (DEFAULT_PARTY_MAX_COST - overrideValue).toString();
}

View File

@ -1,6 +1,7 @@
import { BattleSpec } from "#enums/battle-spec";
import { TrainerType } from "#enums/trainer-type";
import { trainerConfigs } from "#trainers/trainer-config";
import { capitalizeFirstLetter } from "#utils/strings";
export interface TrainerTypeMessages {
encounter?: string | string[];
@ -1755,8 +1756,7 @@ export function initTrainerTypeDialogue(): void {
trainerConfigs[trainerType][`${messageType}Messages`] = messages[0][messageType];
}
if (messages.length > 1) {
trainerConfigs[trainerType][`female${messageType.slice(0, 1).toUpperCase()}${messageType.slice(1)}Messages`] =
messages[1][messageType];
trainerConfigs[trainerType][`female${capitalizeFirstLetter(messageType)}Messages`] = messages[1][messageType];
}
} else {
trainerConfigs[trainerType][`${messageType}Messages`] = messages[messageType];

View File

@ -25,6 +25,7 @@ import { getBerryEffectFunc } from "#data/berry";
import { applyChallenges } from "#data/challenge";
import { allAbilities, allMoves } from "#data/data-lists";
import { SpeciesFormChangeRevertWeatherFormTrigger } from "#data/form-change-triggers";
import { DelayedAttackTag } from "#data/positional-tags/positional-tag";
import {
getNonVolatileStatusEffects,
getStatusEffectHealText,
@ -54,6 +55,7 @@ import { MoveFlags } from "#enums/move-flags";
import { MoveTarget } from "#enums/move-target";
import { MultiHitType } from "#enums/multi-hit-type";
import { PokemonType } from "#enums/pokemon-type";
import { PositionalTagType } from "#enums/positional-tag-type";
import { SpeciesId } from "#enums/species-id";
import {
BATTLE_STATS,
@ -87,8 +89,9 @@ import type { AttackMoveResult } from "#types/attack-move-result";
import type { Localizable } from "#types/locales";
import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString } from "#types/move-types";
import type { TurnMove } from "#types/turn-move";
import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue, toReadableString } from "#utils/common";
import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { toTitleCase } from "#utils/strings";
import i18next from "i18next";
/**
@ -422,9 +425,8 @@ export abstract class Move implements Localizable {
/**
* Sets the {@linkcode MoveFlags.MAKES_CONTACT} flag for the calling Move
* @param setFlag Default `true`, set to `false` if the move doesn't make contact
* @see {@linkcode AbilityId.STATIC}
* @returns The {@linkcode Move} that called this function
* @param setFlag - Whether the move should make contact; default `true`
* @returns `this`
*/
makesContact(setFlag: boolean = true): this {
this.setFlag(MoveFlags.MAKES_CONTACT, setFlag);
@ -3122,54 +3124,110 @@ export class OverrideMoveEffectAttr extends MoveAttr {
* Its sole purpose is to ensure that typescript is able to properly narrow when the `is` method is called.
*/
declare private _: never;
override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
/**
* Apply the move attribute to override other effects of this move.
* @param user - The {@linkcode Pokemon} using the move
* @param target - The {@linkcode Pokemon} targeted by the move
* @param move - The {@linkcode Move} being used
* @param args -
* `[0]`: A {@linkcode BooleanHolder} containing whether move effects were successfully overridden; should be set to `true` on success \
* `[1]`: The {@linkcode MoveUseMode} dictating how this move was used.
* @returns `true` if the move effect was successfully overridden.
*/
public override apply(_user: Pokemon, _target: Pokemon, _move: Move, _args: [overridden: BooleanHolder, useMode: MoveUseMode]): boolean {
return true;
}
}
/** Abstract class for moves that add {@linkcode PositionalTag}s to the field. */
abstract class AddPositionalTagAttr extends OverrideMoveEffectAttr {
protected abstract readonly tagType: PositionalTagType;
public override getCondition(): MoveConditionFunc {
// Check the arena if another similar positional tag is active and affecting the same slot
return (_user, target, move) => globalScene.arena.positionalTagManager.canAddTag(this.tagType, target.getBattlerIndex())
}
}
/**
* Attack Move that doesn't hit the turn it is played and doesn't allow for multiple
* uses on the same target. Examples are Future Sight or Doom Desire.
* @extends OverrideMoveEffectAttr
* @param tagType The {@linkcode ArenaTagType} that will be placed on the field when the move is used
* @param chargeAnim The {@linkcode ChargeAnim | Charging Animation} used for the move
* @param chargeText The text to display when the move is used
* Attribute to implement delayed attacks, such as {@linkcode MoveId.FUTURE_SIGHT} or {@linkcode MoveId.DOOM_DESIRE}.
* Delays the attack's effect with a {@linkcode DelayedAttackTag},
* activating against the given slot after the given turn count has elapsed.
*/
export class DelayedAttackAttr extends OverrideMoveEffectAttr {
public tagType: ArenaTagType;
public chargeAnim: ChargeAnim;
private chargeText: string;
constructor(tagType: ArenaTagType, chargeAnim: ChargeAnim, chargeText: string) {
/**
* @param chargeAnim - The {@linkcode ChargeAnim | charging animation} used for the move's charging phase.
* @param chargeKey - The `i18next` locales **key** to show when the delayed attack is used.
* In the displayed text, `{{pokemonName}}` will be populated with the user's name.
*/
constructor(chargeAnim: ChargeAnim, chargeKey: string) {
super();
this.tagType = tagType;
this.chargeAnim = chargeAnim;
this.chargeText = chargeText;
this.chargeText = chargeKey;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
// Edge case for the move applied on a pokemon that has fainted
if (!target) {
return true;
public override apply(user: Pokemon, target: Pokemon, move: Move, args: [overridden: BooleanHolder, useMode: MoveUseMode]): boolean {
const useMode = args[1];
if (useMode === MoveUseMode.DELAYED_ATTACK) {
// don't trigger if already queueing an indirect attack
return false;
}
const overridden = args[0] as BooleanHolder;
const virtual = args[1] as boolean;
const overridden = args[0];
overridden.value = true;
if (!virtual) {
overridden.value = true;
globalScene.phaseManager.unshiftNew("MoveAnimPhase", new MoveChargeAnim(this.chargeAnim, move.id, user));
globalScene.phaseManager.queueMessage(this.chargeText.replace("{TARGET}", getPokemonNameWithAffix(target)).replace("{USER}", getPokemonNameWithAffix(user)));
user.pushMoveHistory({ move: move.id, targets: [ target.getBattlerIndex() ], result: MoveResult.OTHER, useMode: MoveUseMode.NORMAL });
const side = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
globalScene.arena.addTag(this.tagType, 3, move.id, user.id, side, false, target.getBattlerIndex());
} else {
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:tookMoveAttack", { pokemonName: getPokemonNameWithAffix(globalScene.getPokemonById(target.id) ?? undefined), moveName: move.name }));
}
// Display the move animation to foresee an attack
globalScene.phaseManager.unshiftNew("MoveAnimPhase", new MoveChargeAnim(this.chargeAnim, move.id, user));
globalScene.phaseManager.queueMessage(
i18next.t(
this.chargeText,
{ pokemonName: getPokemonNameWithAffix(user) }
)
)
user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode, turn: globalScene.currentBattle.turn})
user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode, turn: globalScene.currentBattle.turn})
// Queue up an attack on the given slot.
globalScene.arena.positionalTagManager.addTag<PositionalTagType.DELAYED_ATTACK>({
tagType: PositionalTagType.DELAYED_ATTACK,
sourceId: user.id,
targetIndex: target.getBattlerIndex(),
sourceMove: move.id,
turnCount: 3
})
return true;
}
public override getCondition(): MoveConditionFunc {
// Check the arena if another similar attack is active and affecting the same slot
return (_user, target) => globalScene.arena.positionalTagManager.canAddTag(PositionalTagType.DELAYED_ATTACK, target.getBattlerIndex())
}
}
/**
* Attribute to queue a {@linkcode WishTag} to activate in 2 turns.
* The tag whill heal
*/
export class WishAttr extends MoveEffectAttr {
public override apply(user: Pokemon, target: Pokemon, _move: Move): boolean {
globalScene.arena.positionalTagManager.addTag<PositionalTagType.WISH>({
tagType: PositionalTagType.WISH,
healHp: toDmgValue(user.getMaxHp() / 2),
targetIndex: target.getBattlerIndex(),
turnCount: 2,
pokemonName: getPokemonNameWithAffix(user),
});
return true;
}
public override getCondition(): MoveConditionFunc {
// Check the arena if another wish is active and affecting the same slot
return (_user, target) => globalScene.arena.positionalTagManager.canAddTag(PositionalTagType.WISH, target.getBattlerIndex())
}
}
/**
@ -3187,8 +3245,8 @@ export class AwaitCombinedPledgeAttr extends OverrideMoveEffectAttr {
* @param user the {@linkcode Pokemon} using this move
* @param target n/a
* @param move the {@linkcode Move} being used
* @param args
* - [0] a {@linkcode BooleanHolder} indicating whether the move's base
* @param args -
* `[0]`: A {@linkcode BooleanHolder} indicating whether the move's base
* effects should be overridden this turn.
* @returns `true` if base move effects were overridden; `false` otherwise
*/
@ -3575,8 +3633,7 @@ export class CutHpStatStageBoostAttr extends StatStageChangeAttr {
/**
* Attribute implementing the stat boosting effect of {@link https://bulbapedia.bulbagarden.net/wiki/Order_Up_(move) | Order Up}.
* If the user has a Pokemon with {@link https://bulbapedia.bulbagarden.net/wiki/Commander_(Ability) | Commander} in their mouth,
* one of the user's stats are increased by 1 stage, depending on the "commanding" Pokemon's form. This effect does not respect
* effect chance, but Order Up itself may be boosted by Sheer Force.
* one of the user's stats are increased by 1 stage, depending on the "commanding" Pokemon's form.
*/
export class OrderUpStatBoostAttr extends MoveEffectAttr {
constructor() {
@ -8137,7 +8194,7 @@ export class ResistLastMoveTypeAttr extends MoveEffectAttr {
}
const type = validTypes[user.randBattleSeedInt(validTypes.length)];
user.summonData.types = [ type ];
globalScene.phaseManager.queueMessage(i18next.t("battle:transformedIntoType", { pokemonName: getPokemonNameWithAffix(user), type: toReadableString(PokemonType[type]) }));
globalScene.phaseManager.queueMessage(i18next.t("battle:transformedIntoType", { pokemonName: getPokemonNameWithAffix(user), type: toTitleCase(PokemonType[type]) }));
user.updateInfo();
return true;
@ -9204,9 +9261,12 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.SPDEF ], -1)
.ballBombMove(),
new AttackMove(MoveId.FUTURE_SIGHT, PokemonType.PSYCHIC, MoveCategory.SPECIAL, 120, 100, 10, -1, 0, 2)
.partial() // cannot be used on multiple Pokemon on the same side in a double battle, hits immediately when called by Metronome/etc, should not apply abilities or held items if user is off the field
.attr(DelayedAttackAttr, ChargeAnim.FUTURE_SIGHT_CHARGING, "moveTriggers:foresawAnAttack")
.ignoresProtect()
.attr(DelayedAttackAttr, ArenaTagType.FUTURE_SIGHT, ChargeAnim.FUTURE_SIGHT_CHARGING, i18next.t("moveTriggers:foresawAnAttack", { pokemonName: "{USER}" })),
/*
* Should not apply abilities or held items if user is off the field
*/
.edgeCase(),
new AttackMove(MoveId.ROCK_SMASH, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 40, 100, 15, 50, 0, 2)
.attr(StatStageChangeAttr, [ Stat.DEF ], -1),
new AttackMove(MoveId.WHIRLPOOL, PokemonType.WATER, MoveCategory.SPECIAL, 35, 85, 15, -1, 0, 2)
@ -9227,7 +9287,7 @@ export function initMoves() {
new SelfStatusMove(MoveId.STOCKPILE, PokemonType.NORMAL, -1, 20, -1, 0, 3)
.condition(user => (user.getTag(StockpilingTag)?.stockpiledCount ?? 0) < 3)
.attr(AddBattlerTagAttr, BattlerTagType.STOCKPILING, true),
new AttackMove(MoveId.SPIT_UP, PokemonType.NORMAL, MoveCategory.SPECIAL, -1, -1, 10, -1, 0, 3)
new AttackMove(MoveId.SPIT_UP, PokemonType.NORMAL, MoveCategory.SPECIAL, -1, 100, 10, -1, 0, 3)
.condition(hasStockpileStacksCondition)
.attr(SpitUpPowerAttr, 100)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true),
@ -9292,8 +9352,8 @@ export function initMoves() {
.ignoresSubstitute()
.attr(AbilityCopyAttr),
new SelfStatusMove(MoveId.WISH, PokemonType.NORMAL, -1, 10, -1, 0, 3)
.triageMove()
.attr(AddArenaTagAttr, ArenaTagType.WISH, 2, true),
.attr(WishAttr)
.triageMove(),
new SelfStatusMove(MoveId.ASSIST, PokemonType.NORMAL, -1, 20, -1, 0, 3)
.attr(RandomMovesetMoveAttr, invalidAssistMoves, true),
new SelfStatusMove(MoveId.INGRAIN, PokemonType.GRASS, -1, 20, -1, 0, 3)
@ -9470,7 +9530,7 @@ export function initMoves() {
new AttackMove(MoveId.SAND_TOMB, PokemonType.GROUND, MoveCategory.PHYSICAL, 35, 85, 15, -1, 0, 3)
.attr(TrapAttr, BattlerTagType.SAND_TOMB)
.makesContact(false),
new AttackMove(MoveId.SHEER_COLD, PokemonType.ICE, MoveCategory.SPECIAL, 200, 20, 5, -1, 0, 3)
new AttackMove(MoveId.SHEER_COLD, PokemonType.ICE, MoveCategory.SPECIAL, 200, 30, 5, -1, 0, 3)
.attr(IceNoEffectTypeAttr)
.attr(OneHitKOAttr)
.attr(SheerColdAccuracyAttr),
@ -9542,9 +9602,12 @@ export function initMoves() {
.attr(ConfuseAttr)
.pulseMove(),
new AttackMove(MoveId.DOOM_DESIRE, PokemonType.STEEL, MoveCategory.SPECIAL, 140, 100, 5, -1, 0, 3)
.partial() // cannot be used on multiple Pokemon on the same side in a double battle, hits immediately when called by Metronome/etc, should not apply abilities or held items if user is off the field
.attr(DelayedAttackAttr, ChargeAnim.DOOM_DESIRE_CHARGING, "moveTriggers:choseDoomDesireAsDestiny")
.ignoresProtect()
.attr(DelayedAttackAttr, ArenaTagType.DOOM_DESIRE, ChargeAnim.DOOM_DESIRE_CHARGING, i18next.t("moveTriggers:choseDoomDesireAsDestiny", { pokemonName: "{USER}" })),
/*
* Should not apply abilities or held items if user is off the field
*/
.edgeCase(),
new AttackMove(MoveId.PSYCHO_BOOST, PokemonType.PSYCHIC, MoveCategory.SPECIAL, 140, 90, 5, -1, 0, 3)
.attr(StatStageChangeAttr, [ Stat.SPATK ], -2, true),
new SelfStatusMove(MoveId.ROOST, PokemonType.FLYING, -1, 5, -1, 0, 4)
@ -10392,7 +10455,7 @@ export function initMoves() {
.attr(RemoveBattlerTagAttr, [ BattlerTagType.FLYING, BattlerTagType.FLOATING, BattlerTagType.TELEKINESIS ])
.makesContact(false)
.target(MoveTarget.ALL_NEAR_ENEMIES),
new AttackMove(MoveId.THOUSAND_WAVES, PokemonType.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6)
new AttackMove(MoveId.THOUSAND_WAVES, PokemonType.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, 100, 0, 6)
.attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, false, 1, 1, true)
.makesContact(false)
.target(MoveTarget.ALL_NEAR_ENEMIES),
@ -10799,7 +10862,7 @@ export function initMoves() {
new SelfStatusMove(MoveId.NO_RETREAT, PokemonType.FIGHTING, -1, 5, -1, 0, 8)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD ], 1, true)
.attr(AddBattlerTagAttr, BattlerTagType.NO_RETREAT, true, false)
.condition((user, target, move) => user.getTag(TrappedTag)?.sourceMove !== MoveId.NO_RETREAT), // fails if the user is currently trapped by No Retreat
.condition((user, target, move) => user.getTag(TrappedTag)?.tagType !== BattlerTagType.NO_RETREAT), // fails if the user is currently trapped by No Retreat
new StatusMove(MoveId.TAR_SHOT, PokemonType.ROCK, 100, 15, -1, 0, 8)
.attr(StatStageChangeAttr, [ Stat.SPD ], -1)
.attr(AddBattlerTagAttr, BattlerTagType.TAR_SHOT, false)
@ -10927,7 +10990,8 @@ export function initMoves() {
new StatusMove(MoveId.LIFE_DEW, PokemonType.WATER, -1, 10, -1, 0, 8)
.attr(HealAttr, 0.25, true, false)
.target(MoveTarget.USER_AND_ALLIES)
.ignoresProtect(),
.ignoresProtect()
.triageMove(),
new SelfStatusMove(MoveId.OBSTRUCT, PokemonType.DARK, 100, 10, -1, 4, 8)
.attr(ProtectAttr, BattlerTagType.OBSTRUCT)
.condition(failIfLastCondition),
@ -11005,7 +11069,8 @@ export function initMoves() {
new StatusMove(MoveId.JUNGLE_HEALING, PokemonType.GRASS, -1, 10, -1, 0, 8)
.attr(HealAttr, 0.25, true, false)
.attr(HealStatusEffectAttr, false, getNonVolatileStatusEffects())
.target(MoveTarget.USER_AND_ALLIES),
.target(MoveTarget.USER_AND_ALLIES)
.triageMove(),
new AttackMove(MoveId.WICKED_BLOW, PokemonType.DARK, MoveCategory.PHYSICAL, 75, 100, 5, -1, 0, 8)
.attr(CritOnlyAttr)
.punchingMove(),
@ -11233,7 +11298,7 @@ export function initMoves() {
.makesContact(false),
new AttackMove(MoveId.LUMINA_CRASH, PokemonType.PSYCHIC, MoveCategory.SPECIAL, 80, 100, 10, 100, 0, 9)
.attr(StatStageChangeAttr, [ Stat.SPDEF ], -2),
new AttackMove(MoveId.ORDER_UP, PokemonType.DRAGON, MoveCategory.PHYSICAL, 80, 100, 10, 100, 0, 9)
new AttackMove(MoveId.ORDER_UP, PokemonType.DRAGON, MoveCategory.PHYSICAL, 80, 100, 10, -1, 0, 9)
.attr(OrderUpStatBoostAttr)
.makesContact(false),
new AttackMove(MoveId.JET_PUNCH, PokemonType.WATER, MoveCategory.PHYSICAL, 60, 100, 15, -1, 1, 9)
@ -11416,7 +11481,7 @@ export function initMoves() {
.attr(IvyCudgelTypeAttr)
.attr(HighCritAttr)
.makesContact(false),
new ChargingAttackMove(MoveId.ELECTRO_SHOT, PokemonType.ELECTRIC, MoveCategory.SPECIAL, 130, 100, 10, 100, 0, 9)
new ChargingAttackMove(MoveId.ELECTRO_SHOT, PokemonType.ELECTRIC, MoveCategory.SPECIAL, 130, 100, 10, -1, 0, 9)
.chargeText(i18next.t("moveTriggers:absorbedElectricity", { pokemonName: "{USER}" }))
.chargeAttr(StatStageChangeAttr, [ Stat.SPATK ], 1, true)
.chargeAttr(WeatherInstantChargeAttr, [ WeatherType.RAIN, WeatherType.HEAVY_RAIN ]),

View File

@ -39,6 +39,7 @@ import { addPokemonDataToDexAndValidateAchievements } from "#mystery-encounters/
import type { MysteryEncounter } from "#mystery-encounters/mystery-encounter";
import { MysteryEncounterBuilder } from "#mystery-encounters/mystery-encounter";
import { MysteryEncounterOptionBuilder } from "#mystery-encounters/mystery-encounter-option";
import { PartySizeRequirement } from "#mystery-encounters/mystery-encounter-requirements";
import { PokemonData } from "#system/pokemon-data";
import { MusicPreference } from "#system/settings";
import type { OptionSelectItem } from "#ui/abstact-option-select-ui-handler";
@ -151,7 +152,8 @@ export const GlobalTradeSystemEncounter: MysteryEncounter = MysteryEncounterBuil
return true;
})
.withOption(
MysteryEncounterOptionBuilder.newOptionWithMode(MysteryEncounterOptionMode.DEFAULT)
MysteryEncounterOptionBuilder.newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT)
.withSceneRequirement(new PartySizeRequirement([2, 6], true)) // Requires 2 valid party members
.withHasDexProgress(true)
.withDialogue({
buttonLabel: `${namespace}:option.1.label`,
@ -257,7 +259,8 @@ export const GlobalTradeSystemEncounter: MysteryEncounter = MysteryEncounterBuil
.build(),
)
.withOption(
MysteryEncounterOptionBuilder.newOptionWithMode(MysteryEncounterOptionMode.DEFAULT)
MysteryEncounterOptionBuilder.newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT)
.withSceneRequirement(new PartySizeRequirement([2, 6], true)) // Requires 2 valid party members
.withHasDexProgress(true)
.withDialogue({
buttonLabel: `${namespace}:option.2.label`,

View File

@ -25,7 +25,8 @@ import {
StatusEffectRequirement,
WaveRangeRequirement,
} from "#mystery-encounters/mystery-encounter-requirements";
import { capitalizeFirstLetter, coerceArray, isNullOrUndefined, randSeedInt } from "#utils/common";
import { coerceArray, isNullOrUndefined, randSeedInt } from "#utils/common";
import { capitalizeFirstLetter } from "#utils/strings";
export interface EncounterStartOfBattleEffect {
sourcePokemon?: Pokemon;

View File

@ -3,7 +3,7 @@ import { EFFECTIVE_STATS, getShortenedStatKey, Stat } from "#enums/stat";
import { TextStyle } from "#enums/text-style";
import { UiTheme } from "#enums/ui-theme";
import { getBBCodeFrag } from "#ui/text";
import { toReadableString } from "#utils/common";
import { toTitleCase } from "#utils/strings";
import i18next from "i18next";
export function getNatureName(
@ -13,7 +13,7 @@ export function getNatureName(
ignoreBBCode = false,
uiTheme: UiTheme = UiTheme.DEFAULT,
): string {
let ret = toReadableString(Nature[nature]);
let ret = toTitleCase(Nature[nature]);
//Translating nature
if (i18next.exists(`nature:${ret}`)) {
ret = i18next.t(`nature:${ret}` as any);

View File

@ -29,15 +29,9 @@ import type { Variant, VariantSet } from "#sprites/variant";
import { populateVariantColorCache, variantColorCache, variantData } from "#sprites/variant";
import type { StarterMoveset } from "#system/game-data";
import type { Localizable } from "#types/locales";
import {
capitalizeString,
isNullOrUndefined,
randSeedFloat,
randSeedGauss,
randSeedInt,
randSeedItem,
} from "#utils/common";
import { isNullOrUndefined, randSeedFloat, randSeedGauss, randSeedInt, randSeedItem } from "#utils/common";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { toCamelCase, toPascalCase } from "#utils/strings";
import { argbFromRgba, QuantizerCelebi, rgbaFromArgb } from "@material/material-color-utilities";
import i18next from "i18next";
@ -91,6 +85,7 @@ export function getPokemonSpeciesForm(species: SpeciesId, formIndex: number): Po
return retSpecies;
}
// TODO: Clean this up and seriously review alternate means of fusion naming
export function getFusedSpeciesName(speciesAName: string, speciesBName: string): string {
const fragAPattern = /([a-z]{2}.*?[aeiou(?:y$)\-']+)(.*?)$/i;
const fragBPattern = /([a-z]{2}.*?[aeiou(?:y$)\-'])(.*?)$/i;
@ -904,14 +899,14 @@ export class PokemonSpecies extends PokemonSpeciesForm implements Localizable {
* @returns the pokemon-form locale key for the single form name ("Alolan Form", "Eternal Flower" etc)
*/
getFormNameToDisplay(formIndex = 0, append = false): string {
const formKey = this.forms?.[formIndex!]?.formKey;
const formText = capitalizeString(formKey, "-", false, false) || "";
const speciesName = capitalizeString(SpeciesId[this.speciesId], "_", true, false);
const formKey = this.forms[formIndex]?.formKey ?? "";
const formText = toPascalCase(formKey);
const speciesName = toCamelCase(SpeciesId[this.speciesId]);
let ret = "";
const region = this.getRegion();
if (this.speciesId === SpeciesId.ARCEUS) {
ret = i18next.t(`pokemonInfo:Type.${formText?.toUpperCase()}`);
ret = i18next.t(`pokemonInfo:Type.${formText.toUpperCase()}`);
} else if (
[
SpeciesFormKey.MEGA,
@ -937,7 +932,7 @@ export class PokemonSpecies extends PokemonSpeciesForm implements Localizable {
if (i18next.exists(i18key)) {
ret = i18next.t(i18key);
} else {
const rootSpeciesName = capitalizeString(SpeciesId[this.getRootSpeciesId()], "_", true, false);
const rootSpeciesName = toCamelCase(SpeciesId[this.getRootSpeciesId()]);
const i18RootKey = `pokemonForm:${rootSpeciesName}${formText}`;
ret = i18next.exists(i18RootKey) ? i18next.t(i18RootKey) : formText;
}

View File

@ -1,18 +1,30 @@
import { type BattlerTag, loadBattlerTag } from "#data/battler-tags";
import type { BattlerTag } from "#data/battler-tags";
import { loadBattlerTag, SerializableBattlerTag } from "#data/battler-tags";
import { allSpecies } from "#data/data-lists";
import type { Gender } from "#data/gender";
import { PokemonMove } from "#data/moves/pokemon-move";
import type { PokemonSpeciesForm } from "#data/pokemon-species";
import { getPokemonSpeciesForm, type PokemonSpeciesForm } from "#data/pokemon-species";
import type { TypeDamageMultiplier } from "#data/type";
import type { AbilityId } from "#enums/ability-id";
import type { BerryType } from "#enums/berry-type";
import type { MoveId } from "#enums/move-id";
import type { Nature } from "#enums/nature";
import type { PokemonType } from "#enums/pokemon-type";
import type { SpeciesId } from "#enums/species-id";
import type { AttackMoveResult } from "#types/attack-move-result";
import type { IllusionData } from "#types/illusion-data";
import type { TurnMove } from "#types/turn-move";
import type { CoerceNullPropertiesToUndefined } from "#types/type-helpers";
import { isNullOrUndefined } from "#utils/common";
/**
* The type that {@linkcode PokemonSpeciesForm} is converted to when an object containing it serializes it.
*/
type SerializedSpeciesForm = {
id: SpeciesId;
formIdx: number;
};
/**
* Permanent data that can customize a Pokemon in non-standard ways from its Species.
* Includes abilities, nature, changed types, etc.
@ -41,9 +53,59 @@ export class CustomPokemonData {
}
}
/**
* Deserialize a pokemon species form from an object containing `id` and `formIdx` properties.
* @param value - The value to deserialize
* @returns The `PokemonSpeciesForm` or `null` if the fields could not be properly discerned
*/
function deserializePokemonSpeciesForm(value: SerializedSpeciesForm | PokemonSpeciesForm): PokemonSpeciesForm | null {
// @ts-expect-error: We may be deserializing a PokemonSpeciesForm, but we catch later on
let { id, formIdx } = value;
if (isNullOrUndefined(id) || isNullOrUndefined(formIdx)) {
// @ts-expect-error: Typescript doesn't know that in block, `value` must be a PokemonSpeciesForm
id = value.speciesId;
// @ts-expect-error: Same as above (plus we are accessing a protected property)
formIdx = value._formIndex;
}
// If for some reason either of these fields are null/undefined, we cannot reconstruct the species form
if (isNullOrUndefined(id) || isNullOrUndefined(formIdx)) {
return null;
}
return getPokemonSpeciesForm(id, formIdx);
}
interface SerializedIllusionData extends Omit<IllusionData, "fusionSpecies"> {
/** The id of the illusioned fusion species, or `undefined` if not a fusion */
fusionSpecies?: SpeciesId;
}
interface SerializedPokemonSummonData {
statStages: number[];
moveQueue: TurnMove[];
tags: BattlerTag[];
abilitySuppressed: boolean;
speciesForm?: SerializedSpeciesForm;
fusionSpeciesForm?: SerializedSpeciesForm;
ability?: AbilityId;
passiveAbility?: AbilityId;
gender?: Gender;
fusionGender?: Gender;
stats: number[];
moveset?: PokemonMove[];
types: PokemonType[];
addedType?: PokemonType;
illusion?: SerializedIllusionData;
illusionBroken: boolean;
berriesEatenLast: BerryType[];
moveHistory: TurnMove[];
}
/**
* Persistent in-battle data for a {@linkcode Pokemon}.
* Resets on switch or new battle.
*
* @sealed
*/
export class PokemonSummonData {
/** [Atk, Def, SpAtk, SpDef, Spd, Acc, Eva] */
@ -86,7 +148,7 @@ export class PokemonSummonData {
*/
public moveHistory: TurnMove[] = [];
constructor(source?: PokemonSummonData | Partial<PokemonSummonData>) {
constructor(source?: PokemonSummonData | SerializedPokemonSummonData) {
if (isNullOrUndefined(source)) {
return;
}
@ -97,19 +159,88 @@ export class PokemonSummonData {
continue;
}
if (key === "speciesForm" || key === "fusionSpeciesForm") {
this[key] = deserializePokemonSpeciesForm(value);
}
if (key === "illusion" && typeof value === "object") {
// Make a copy so as not to mutate provided value
const illusionData = {
...value,
};
if (!isNullOrUndefined(illusionData.fusionSpecies)) {
switch (typeof illusionData.fusionSpecies) {
case "object":
illusionData.fusionSpecies = allSpecies[illusionData.fusionSpecies.speciesId];
break;
case "number":
illusionData.fusionSpecies = allSpecies[illusionData.fusionSpecies];
break;
default:
illusionData.fusionSpecies = undefined;
}
}
this[key] = illusionData as IllusionData;
}
if (key === "moveset") {
this.moveset = value?.map((m: any) => PokemonMove.loadMove(m));
continue;
}
if (key === "tags") {
// load battler tags
this.tags = value.map((t: BattlerTag) => loadBattlerTag(t));
if (key === "tags" && Array.isArray(value)) {
// load battler tags, discarding any that are not serializable
this.tags = value
.map((t: SerializableBattlerTag) => loadBattlerTag(t))
.filter((t): t is SerializableBattlerTag => t instanceof SerializableBattlerTag);
continue;
}
this[key] = value;
}
}
/**
* Serialize this PokemonSummonData to JSON, converting {@linkcode PokemonSpeciesForm} and {@linkcode IllusionData.fusionSpecies}
* into simpler types instead of serializing all of their fields.
*
* @remarks
* - `IllusionData.fusionSpecies` is serialized as just the species ID
* - `PokemonSpeciesForm` and `PokemonSpeciesForm.fusionSpeciesForm` are converted into {@linkcode SerializedSpeciesForm} objects
*/
public toJSON(): SerializedPokemonSummonData {
// Pokemon species forms are never saved, only the species ID.
const illusion = this.illusion;
const speciesForm = this.speciesForm;
const fusionSpeciesForm = this.fusionSpeciesForm;
const illusionSpeciesForm = illusion?.fusionSpecies;
const t = {
// the "as omit" is required to avoid TS resolving the overwritten properties to "never"
// We coerce null to undefined in the type, as the for loop below replaces `null` with `undefined`
...(this as Omit<
CoerceNullPropertiesToUndefined<PokemonSummonData>,
"speciesForm" | "fusionSpeciesForm" | "illusion"
>),
speciesForm: isNullOrUndefined(speciesForm)
? undefined
: { id: speciesForm.speciesId, formIdx: speciesForm.formIndex },
fusionSpeciesForm: isNullOrUndefined(fusionSpeciesForm)
? undefined
: { id: fusionSpeciesForm.speciesId, formIdx: fusionSpeciesForm.formIndex },
illusion: isNullOrUndefined(illusion)
? undefined
: {
...(this.illusion as Omit<typeof illusion, "fusionSpecies">),
fusionSpecies: illusionSpeciesForm?.speciesId,
},
};
// Replace `null` with `undefined`, as `undefined` never gets serialized
for (const [key, value] of Object.entries(t)) {
if (value === null) {
t[key] = undefined;
}
}
return t;
}
}
// TODO: Merge this inside `summmonData` but exclude from save if/when a save data serializer is added

View File

@ -0,0 +1,70 @@
import { DelayedAttackTag, type PositionalTag, WishTag } from "#data/positional-tags/positional-tag";
import { PositionalTagType } from "#enums/positional-tag-type";
import type { ObjectValues } from "#types/type-helpers";
import type { Constructor } from "#utils/common";
/**
* Load the attributes of a {@linkcode PositionalTag}.
* @param tagType - The {@linkcode PositionalTagType} to create
* @param args - The arguments needed to instantize the given tag
* @returns The newly created tag.
* @remarks
* This function does not perform any checking if the added tag is valid.
*/
export function loadPositionalTag<T extends PositionalTagType>({
tagType,
...args
}: serializedPosTagMap[T]): posTagInstanceMap[T];
/**
* Load the attributes of a {@linkcode PositionalTag}.
* @param tag - The {@linkcode SerializedPositionalTag} to instantiate
* @returns The newly created tag.
* @remarks
* This function does not perform any checking if the added tag is valid.
*/
export function loadPositionalTag(tag: SerializedPositionalTag): PositionalTag;
export function loadPositionalTag<T extends PositionalTagType>({
tagType,
...rest
}: serializedPosTagMap[T]): posTagInstanceMap[T] {
// Note: We need 2 type assertions here:
// 1 because TS doesn't narrow the type of TagClass correctly based on `T`.
// It converts it into `new (DelayedAttackTag | WishTag) => DelayedAttackTag & WishTag`
const tagClass = posTagConstructorMap[tagType] as new (args: posTagParamMap[T]) => posTagInstanceMap[T];
// 2 because TS doesn't narrow the type of `rest` correctly
// (from `Omit<serializedPosTagParamMap[T], "tagType"> into `posTagParamMap[T]`)
return new tagClass(rest as unknown as posTagParamMap[T]);
}
/** Const object mapping tag types to their constructors. */
const posTagConstructorMap = Object.freeze({
[PositionalTagType.DELAYED_ATTACK]: DelayedAttackTag,
[PositionalTagType.WISH]: WishTag,
}) satisfies {
// NB: This `satisfies` block ensures that all tag types have corresponding entries in the map.
[k in PositionalTagType]: Constructor<PositionalTag & { tagType: k }>;
};
/** Type mapping positional tag types to their constructors. */
type posTagMap = typeof posTagConstructorMap;
/** Type mapping all positional tag types to their instances. */
type posTagInstanceMap = {
[k in PositionalTagType]: InstanceType<posTagMap[k]>;
};
/** Type mapping all positional tag types to their constructors' parameters. */
type posTagParamMap = {
[k in PositionalTagType]: ConstructorParameters<posTagMap[k]>[0];
};
/**
* Type mapping all positional tag types to their constructors' parameters, alongside the `tagType` selector.
* Equivalent to their serialized representations.
*/
export type serializedPosTagMap = {
[k in PositionalTagType]: posTagParamMap[k] & { tagType: k };
};
/** Union type containing all serialized {@linkcode PositionalTag}s. */
export type SerializedPositionalTag = ObjectValues<serializedPosTagMap>;

View File

@ -0,0 +1,55 @@
import { loadPositionalTag } from "#data/positional-tags/load-positional-tag";
import type { PositionalTag } from "#data/positional-tags/positional-tag";
import type { BattlerIndex } from "#enums/battler-index";
import type { PositionalTagType } from "#enums/positional-tag-type";
/** A manager for the {@linkcode PositionalTag}s in the arena. */
export class PositionalTagManager {
/**
* Array containing all pending unactivated {@linkcode PositionalTag}s,
* sorted by order of creation (oldest first).
*/
public tags: PositionalTag[] = [];
/**
* Add a new {@linkcode PositionalTag} to the arena.
* @remarks
* This function does not perform any checking if the added tag is valid.
*/
public addTag<T extends PositionalTagType = never>(tag: Parameters<typeof loadPositionalTag<T>>[0]): void {
this.tags.push(loadPositionalTag(tag));
}
/**
* Check whether a new {@linkcode PositionalTag} can be added to the battlefield.
* @param tagType - The {@linkcode PositionalTagType} being created
* @param targetIndex - The {@linkcode BattlerIndex} being targeted
* @returns Whether the tag can be added.
*/
public canAddTag(tagType: PositionalTagType, targetIndex: BattlerIndex): boolean {
return !this.tags.some(t => t.tagType === tagType && t.targetIndex === targetIndex);
}
/**
* Decrement turn counts of and trigger all pending {@linkcode PositionalTag}s on field.
* @remarks
* If multiple tags trigger simultaneously, they will activate in order of **initial creation**, regardless of current speed order.
* (Source: [Smogon](<https://www.smogon.com/forums/threads/sword-shield-battle-mechanics-research.3655528/page-64#post-9244179>))
*/
public activateAllTags(): void {
const leftoverTags: PositionalTag[] = [];
for (const tag of this.tags) {
// Check for silent removal, immediately removing invalid tags.
if (--tag.turnCount > 0) {
// tag still cooking
leftoverTags.push(tag);
continue;
}
if (tag.shouldTrigger()) {
tag.trigger();
}
}
this.tags = leftoverTags;
}
}

View File

@ -0,0 +1,174 @@
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
// biome-ignore-start lint/correctness/noUnusedImports: TSDoc
import type { ArenaTag } from "#data/arena-tag";
// biome-ignore-end lint/correctness/noUnusedImports: TSDoc
import { allMoves } from "#data/data-lists";
import type { BattlerIndex } from "#enums/battler-index";
import type { MoveId } from "#enums/move-id";
import { MoveUseMode } from "#enums/move-use-mode";
import { PositionalTagType } from "#enums/positional-tag-type";
import type { Pokemon } from "#field/pokemon";
import i18next from "i18next";
/**
* Baseline arguments used to construct all {@linkcode PositionalTag}s,
* the contents of which are serialized and used to construct new tags. \
* Does not contain the `tagType` parameter (which is used to select the proper class constructor during tag loading).
* @privateRemarks
* All {@linkcode PositionalTag}s are intended to implement a sub-interface of this containing their respective parameters,
* and should refrain from adding extra serializable fields not contained in said interface.
* This ensures that all tags truly "become" their respective interfaces when converted to and from JSON.
*/
export interface PositionalTagBaseArgs {
/**
* The number of turns remaining until this tag's activation. \
* Decremented by 1 at the end of each turn until reaching 0, at which point it will
* {@linkcode PositionalTag.trigger | trigger} the tag's effects and be removed.
*/
turnCount: number;
/**
* The {@linkcode BattlerIndex} targeted by this effect.
*/
targetIndex: BattlerIndex;
}
/**
* A {@linkcode PositionalTag} is a variant of an {@linkcode ArenaTag} that targets a single *slot* of the battlefield.
* Each tag can last one or more turns, triggering various effects on removal.
* Multiple tags of the same kind can stack with one another, provided they are affecting different targets.
*/
export abstract class PositionalTag implements PositionalTagBaseArgs {
/** This tag's {@linkcode PositionalTagType | type} */
public abstract readonly tagType: PositionalTagType;
// These arguments have to be public to implement the interface, but are functionally private
// outside this and the tag manager.
// Left undocumented to inherit doc comments from the interface
public turnCount: number;
public readonly targetIndex: BattlerIndex;
constructor({ turnCount, targetIndex }: PositionalTagBaseArgs) {
this.turnCount = turnCount;
this.targetIndex = targetIndex;
}
/** Trigger this tag's effects prior to removal. */
public abstract trigger(): void;
/**
* Check whether this tag should be allowed to {@linkcode trigger} and activate its effects
* upon its duration elapsing.
* @returns Whether this tag should be allowed to trigger prior to being removed.
*/
public abstract shouldTrigger(): boolean;
/**
* Get the {@linkcode Pokemon} currently targeted by this tag.
* @returns The {@linkcode Pokemon} located in this tag's target position, or `undefined` if none exist in it.
*/
protected getTarget(): Pokemon | undefined {
return globalScene.getField()[this.targetIndex];
}
}
/** Interface containing additional properties used to construct a {@linkcode DelayedAttackTag}. */
interface DelayedAttackArgs extends PositionalTagBaseArgs {
/**
* The {@linkcode Pokemon.id | PID} of the {@linkcode Pokemon} having created this effect.
*/
sourceId: number;
/** The {@linkcode MoveId} that created this attack. */
sourceMove: MoveId;
}
/**
* Tag to manage execution of delayed attacks, such as {@linkcode MoveId.FUTURE_SIGHT} or {@linkcode MoveId.DOOM_DESIRE}. \
* Delayed attacks do nothing for the first several turns after use (including the turn the move is used),
* triggering against a certain slot after the turn count has elapsed.
*/
export class DelayedAttackTag extends PositionalTag implements DelayedAttackArgs {
public override readonly tagType = PositionalTagType.DELAYED_ATTACK;
public readonly sourceMove: MoveId;
public readonly sourceId: number;
constructor({ sourceId, turnCount, targetIndex, sourceMove }: DelayedAttackArgs) {
super({ turnCount, targetIndex });
this.sourceId = sourceId;
this.sourceMove = sourceMove;
}
public override trigger(): void {
// Bangs are justified as the `shouldTrigger` method will queue the tag for removal
// if the source or target no longer exist
const source = globalScene.getPokemonById(this.sourceId)!;
const target = this.getTarget()!;
source.turnData.extraTurns++;
globalScene.phaseManager.queueMessage(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(target),
moveName: allMoves[this.sourceMove].name,
}),
);
globalScene.phaseManager.unshiftNew(
"MoveEffectPhase",
this.sourceId, // TODO: Find an alternate method of passing the source pokemon without a source ID
[this.targetIndex],
allMoves[this.sourceMove],
MoveUseMode.DELAYED_ATTACK,
);
}
public override shouldTrigger(): boolean {
const source = globalScene.getPokemonById(this.sourceId);
const target = this.getTarget();
// Silently disappear if either source or target are missing or happen to be the same pokemon
// (i.e. targeting oneself)
// We also need to check for fainted targets as they don't technically leave the field until _after_ the turn ends
return !!source && !!target && source !== target && !target.isFainted();
}
}
/** Interface containing arguments used to construct a {@linkcode WishTag}. */
interface WishArgs extends PositionalTagBaseArgs {
/** The amount of {@linkcode Stat.HP | HP} to heal; set to 50% of the user's max HP during move usage. */
healHp: number;
/** The name of the {@linkcode Pokemon} having created the tag. */
pokemonName: string;
}
/**
* Tag to implement {@linkcode MoveId.WISH | Wish}.
*/
export class WishTag extends PositionalTag implements WishArgs {
public override readonly tagType = PositionalTagType.WISH;
public readonly pokemonName: string;
public readonly healHp: number;
constructor({ turnCount, targetIndex, healHp, pokemonName }: WishArgs) {
super({ turnCount, targetIndex });
this.healHp = healHp;
this.pokemonName = pokemonName;
}
public override trigger(): void {
// TODO: Rename this locales key - wish shows a message on REMOVAL, not addition
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:wishTagOnAdd", {
pokemonNameWithAffix: this.pokemonName,
}),
);
globalScene.phaseManager.unshiftNew("PokemonHealPhase", this.targetIndex, this.healHp, null, true, false);
}
public override shouldTrigger(): boolean {
// Disappear if no target or target is fainted.
// The source need not exist at the time of activation (since all we need is a simple message)
// TODO: Verify whether Wish shows a message if the Pokemon it would affect is KO'd on the turn of activation
const target = this.getTarget();
return !!target && !target.isFainted();
}
}

View File

@ -1,12 +1,12 @@
import { TrainerType } from "#enums/trainer-type";
import { toReadableString } from "#utils/common";
import { toPascalSnakeCase } from "#utils/strings";
class TrainerNameConfig {
public urls: string[];
public femaleUrls: string[] | null;
constructor(type: TrainerType, ...urls: string[]) {
this.urls = urls.length ? urls : [toReadableString(TrainerType[type]).replace(/ /g, "_")];
this.urls = urls.length ? urls : [toPascalSnakeCase(TrainerType[type])];
}
hasGenderVariant(...femaleUrls: string[]): TrainerNameConfig {

View File

@ -41,15 +41,9 @@ import type {
TrainerConfigs,
TrainerTierPools,
} from "#types/trainer-funcs";
import {
coerceArray,
isNullOrUndefined,
randSeedInt,
randSeedIntRange,
randSeedItem,
toReadableString,
} from "#utils/common";
import { coerceArray, isNullOrUndefined, randSeedInt, randSeedIntRange, randSeedItem } from "#utils/common";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { toSnakeCase, toTitleCase } from "#utils/strings";
import i18next from "i18next";
/** Minimum BST for Pokemon generated onto the Elite Four's teams */
@ -140,7 +134,7 @@ export class TrainerConfig {
constructor(trainerType: TrainerType, allowLegendaries?: boolean) {
this.trainerType = trainerType;
this.trainerAI = new TrainerAI();
this.name = toReadableString(TrainerType[this.getDerivedType()]);
this.name = toTitleCase(TrainerType[this.getDerivedType()]);
this.battleBgm = "battle_trainer";
this.mixedBattleBgm = "battle_trainer";
this.victoryBgm = "victory_trainer";
@ -734,7 +728,7 @@ export class TrainerConfig {
}
// Localize the trainer's name by converting it to lowercase and replacing spaces with underscores.
const nameForCall = this.name.toLowerCase().replace(/\s/g, "_");
const nameForCall = toSnakeCase(this.name);
this.name = i18next.t(`trainerNames:${nameForCall}`);
// Set the title to "elite_four". (this is the key in the i18n file)

View File

@ -1,3 +1,5 @@
import type { ObjectValues } from "#types/type-helpers";
/**
* Not to be confused with an Ability Attribute.
* This is an object literal storing the slot that an ability can occupy.
@ -8,4 +10,4 @@ export const AbilityAttr = Object.freeze({
ABILITY_HIDDEN: 4,
});
export type AbilityAttr = typeof AbilityAttr[keyof typeof AbilityAttr];
export type AbilityAttr = ObjectValues<typeof AbilityAttr>;

View File

@ -15,9 +15,6 @@ export enum ArenaTagType {
SPIKES = "SPIKES",
TOXIC_SPIKES = "TOXIC_SPIKES",
MIST = "MIST",
FUTURE_SIGHT = "FUTURE_SIGHT",
DOOM_DESIRE = "DOOM_DESIRE",
WISH = "WISH",
STEALTH_ROCK = "STEALTH_ROCK",
STICKY_WEB = "STICKY_WEB",
TRICK_ROOM = "TRICK_ROOM",

View File

@ -1,5 +1,4 @@
export enum BattlerTagType {
NONE = "NONE",
RECHARGING = "RECHARGING",
FLINCHED = "FLINCHED",
INTERRUPTED = "INTERRUPTED",

View File

@ -1,3 +1,5 @@
import type { ObjectValues } from "#types/type-helpers";
export const DexAttr = Object.freeze({
NON_SHINY: 1n,
SHINY: 2n,
@ -8,4 +10,4 @@ export const DexAttr = Object.freeze({
VARIANT_3: 64n,
DEFAULT_FORM: 128n,
});
export type DexAttr = typeof DexAttr[keyof typeof DexAttr];
export type DexAttr = ObjectValues<typeof DexAttr>;

View File

@ -1,6 +1,7 @@
/**
* Enum representation of the phase types held by implementations of {@linkcode PhasePriorityQueue}
* Enum representation of the phase types held by implementations of {@linkcode PhasePriorityQueue}.
*/
// TODO: We currently assume these are in order
export enum DynamicPhaseType {
POST_SUMMON
}

View File

@ -1,7 +1,9 @@
import type { ObjectValues } from "#types/type-helpers";
export const GachaType = Object.freeze({
MOVE: 0,
LEGENDARY: 1,
SHINY: 2
});
export type GachaType = typeof GachaType[keyof typeof GachaType];
export type GachaType = ObjectValues<typeof GachaType>;

View File

@ -1,3 +1,5 @@
import type { ObjectValues } from "#types/type-helpers";
/** The result of a hit check calculation */
export const HitCheckResult = {
/** Hit checks haven't been evaluated yet in this pass */
@ -20,4 +22,4 @@ export const HitCheckResult = {
ERROR: 8,
} as const;
export type HitCheckResult = typeof HitCheckResult[keyof typeof HitCheckResult];
export type HitCheckResult = ObjectValues<typeof HitCheckResult>;

View File

@ -4,15 +4,19 @@
*/
export enum MoveFlags {
NONE = 0,
/**
* Whether the move makes contact.
* Set by default on all contact moves, and unset by default on all special moves.
*/
MAKES_CONTACT = 1 << 0,
IGNORE_PROTECT = 1 << 1,
/**
* Sound-based moves have the following effects:
* - Pokemon with the {@linkcode AbilityId.SOUNDPROOF Soundproof Ability} are unaffected by other Pokemon's sound-based moves.
* - Pokemon affected by {@linkcode MoveId.THROAT_CHOP Throat Chop} cannot use sound-based moves for two turns.
* - Sound-based moves used by a Pokemon with {@linkcode AbilityId.LIQUID_VOICE Liquid Voice} become Water-type moves.
* - Sound-based moves used by a Pokemon with {@linkcode AbilityId.PUNK_ROCK Punk Rock} are boosted by 30%. Pokemon with Punk Rock also take half damage from sound-based moves.
* - All sound-based moves (except Howl) can hit Pokemon behind an active {@linkcode MoveId.SUBSTITUTE Substitute}.
* - Pokemon with the {@linkcode AbilityId.SOUNDPROOF | Soundproof} Ability are unaffected by other Pokemon's sound-based moves.
* - Pokemon affected by {@linkcode MoveId.THROAT_CHOP | Throat Chop} cannot use sound-based moves for two turns.
* - Sound-based moves used by a Pokemon with {@linkcode AbilityId.LIQUID_VOICE | Liquid Voice} become Water-type moves.
* - Sound-based moves used by a Pokemon with {@linkcode AbilityId.PUNK_ROCK | Punk Rock} are boosted by 30%. Pokemon with Punk Rock also take half damage from sound-based moves.
* - All sound-based moves (except Howl) can hit Pokemon behind an active {@linkcode MoveId.SUBSTITUTE | Substitute}.
*
* cf https://bulbapedia.bulbagarden.net/wiki/Sound-based_move
*/

View File

@ -1,5 +1,7 @@
import type { PostDancingMoveAbAttr } from "#abilities/ability";
import type { DelayedAttackAttr } from "#app/@types/move-types";
import type { BattlerTagLapseType } from "#enums/battler-tag-lapse-type";
import type { ObjectValues } from "#types/type-helpers";
/**
* Enum representing all the possible means through which a given move can be executed.
@ -59,11 +61,20 @@ export const MoveUseMode = {
* and retain the same copy prevention as {@linkcode MoveUseMode.FOLLOW_UP}, but additionally
* **cannot be reflected by other reflecting effects**.
*/
REFLECTED: 5
// TODO: Add use type TRANSPARENT for Future Sight and Doom Desire to prevent move history pushing
REFLECTED: 5,
/**
* This "move" was created by a transparent effect that **does not count as using a move**,
* such as {@linkcode DelayedAttackAttr | Future Sight/Doom Desire}.
*
* In addition to inheriting the cancellation ignores and copy prevention from {@linkcode MoveUseMode.REFLECTED},
* transparent moves are ignored by **all forms of move usage checks** due to **not pushing to move history**.
* @todo Consider other means of implementing FS/DD than this - we currently only use it
* to prevent pushing to move history and avoid re-delaying the attack portion
*/
DELAYED_ATTACK: 6
} as const;
export type MoveUseMode = (typeof MoveUseMode)[keyof typeof MoveUseMode];
export type MoveUseMode = ObjectValues<typeof MoveUseMode>;
// # HELPER FUNCTIONS
// Please update the markdown tables if any new `MoveUseMode`s get added.
@ -75,13 +86,14 @@ export type MoveUseMode = (typeof MoveUseMode)[keyof typeof MoveUseMode];
* @remarks
* This function is equivalent to the following truth table:
*
* | Use Type | Returns |
* |------------------------------------|---------|
* | {@linkcode MoveUseMode.NORMAL} | `false` |
* | {@linkcode MoveUseMode.IGNORE_PP} | `false` |
* | {@linkcode MoveUseMode.INDIRECT} | `true` |
* | {@linkcode MoveUseMode.FOLLOW_UP} | `true` |
* | {@linkcode MoveUseMode.REFLECTED} | `true` |
* | Use Type | Returns |
* |----------------------------------------|---------|
* | {@linkcode MoveUseMode.NORMAL} | `false` |
* | {@linkcode MoveUseMode.IGNORE_PP} | `false` |
* | {@linkcode MoveUseMode.INDIRECT} | `true` |
* | {@linkcode MoveUseMode.FOLLOW_UP} | `true` |
* | {@linkcode MoveUseMode.REFLECTED} | `true` |
* | {@linkcode MoveUseMode.DELAYED_ATTACK} | `true` |
*/
export function isVirtual(useMode: MoveUseMode): boolean {
return useMode >= MoveUseMode.INDIRECT
@ -95,13 +107,14 @@ export function isVirtual(useMode: MoveUseMode): boolean {
* @remarks
* This function is equivalent to the following truth table:
*
* | Use Type | Returns |
* |------------------------------------|---------|
* | {@linkcode MoveUseMode.NORMAL} | `false` |
* | {@linkcode MoveUseMode.IGNORE_PP} | `false` |
* | {@linkcode MoveUseMode.INDIRECT} | `false` |
* | {@linkcode MoveUseMode.FOLLOW_UP} | `true` |
* | {@linkcode MoveUseMode.REFLECTED} | `true` |
* | Use Type | Returns |
* |----------------------------------------|---------|
* | {@linkcode MoveUseMode.NORMAL} | `false` |
* | {@linkcode MoveUseMode.IGNORE_PP} | `false` |
* | {@linkcode MoveUseMode.INDIRECT} | `false` |
* | {@linkcode MoveUseMode.FOLLOW_UP} | `true` |
* | {@linkcode MoveUseMode.REFLECTED} | `true` |
* | {@linkcode MoveUseMode.DELAYED_ATTACK} | `true` |
*/
export function isIgnoreStatus(useMode: MoveUseMode): boolean {
return useMode >= MoveUseMode.FOLLOW_UP;
@ -115,13 +128,14 @@ export function isIgnoreStatus(useMode: MoveUseMode): boolean {
* @remarks
* This function is equivalent to the following truth table:
*
* | Use Type | Returns |
* |------------------------------------|---------|
* | {@linkcode MoveUseMode.NORMAL} | `false` |
* | {@linkcode MoveUseMode.IGNORE_PP} | `true` |
* | {@linkcode MoveUseMode.INDIRECT} | `true` |
* | {@linkcode MoveUseMode.FOLLOW_UP} | `true` |
* | {@linkcode MoveUseMode.REFLECTED} | `true` |
* | Use Type | Returns |
* |----------------------------------------|---------|
* | {@linkcode MoveUseMode.NORMAL} | `false` |
* | {@linkcode MoveUseMode.IGNORE_PP} | `true` |
* | {@linkcode MoveUseMode.INDIRECT} | `true` |
* | {@linkcode MoveUseMode.FOLLOW_UP} | `true` |
* | {@linkcode MoveUseMode.REFLECTED} | `true` |
* | {@linkcode MoveUseMode.DELAYED_ATTACK} | `true` |
*/
export function isIgnorePP(useMode: MoveUseMode): boolean {
return useMode >= MoveUseMode.IGNORE_PP;
@ -136,14 +150,15 @@ export function isIgnorePP(useMode: MoveUseMode): boolean {
* @remarks
* This function is equivalent to the following truth table:
*
* | Use Type | Returns |
* |------------------------------------|---------|
* | {@linkcode MoveUseMode.NORMAL} | `false` |
* | {@linkcode MoveUseMode.IGNORE_PP} | `false` |
* | {@linkcode MoveUseMode.INDIRECT} | `false` |
* | {@linkcode MoveUseMode.FOLLOW_UP} | `false` |
* | {@linkcode MoveUseMode.REFLECTED} | `true` |
* | Use Type | Returns |
* |----------------------------------------|---------|
* | {@linkcode MoveUseMode.NORMAL} | `false` |
* | {@linkcode MoveUseMode.IGNORE_PP} | `false` |
* | {@linkcode MoveUseMode.INDIRECT} | `false` |
* | {@linkcode MoveUseMode.FOLLOW_UP} | `false` |
* | {@linkcode MoveUseMode.REFLECTED} | `true` |
* | {@linkcode MoveUseMode.DELAYED_ATTACK} | `false` |
*/
export function isReflected(useMode: MoveUseMode): boolean {
return useMode === MoveUseMode.REFLECTED;
}
}

View File

@ -0,0 +1,10 @@
/**
* Enum representing all positional tag types.
* @privateRemarks
* When adding new tag types, please update `positionalTagConstructorMap` in `src/data/positionalTags`
* with the new tag type.
*/
export enum PositionalTagType {
DELAYED_ATTACK = "DELAYED_ATTACK",
WISH = "WISH",
}

View File

@ -1,3 +1,7 @@
// biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports
import type { PositionalTag } from "#data/positional-tags/positional-tag";
// biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports
import { applyAbAttrs } from "#abilities/apply-ab-attrs";
import { globalScene } from "#app/global-scene";
import Overrides from "#app/overrides";
@ -7,6 +11,7 @@ import type { ArenaTag } from "#data/arena-tag";
import { ArenaTrapTag, getArenaTag } from "#data/arena-tag";
import { SpeciesFormChangeRevertWeatherFormTrigger, SpeciesFormChangeWeatherTrigger } from "#data/form-change-triggers";
import type { PokemonSpecies } from "#data/pokemon-species";
import { PositionalTagManager } from "#data/positional-tags/positional-tag-manager";
import { getTerrainClearMessage, getTerrainStartMessage, Terrain, TerrainType } from "#data/terrain";
import {
getLegendaryWeatherContinuesMessage,
@ -38,7 +43,14 @@ export class Arena {
public biomeType: BiomeId;
public weather: Weather | null;
public terrain: Terrain | null;
public tags: ArenaTag[];
/** All currently-active {@linkcode ArenaTag}s on both sides of the field. */
public tags: ArenaTag[] = [];
/**
* All currently-active {@linkcode PositionalTag}s on both sides of the field,
* sorted by tag type.
*/
public positionalTagManager: PositionalTagManager = new PositionalTagManager();
public bgm: string;
public ignoreAbilities: boolean;
public ignoringEffectSource: BattlerIndex | null;
@ -58,7 +70,6 @@ export class Arena {
constructor(biome: BiomeId, bgm: string, playerFaints = 0) {
this.biomeType = biome;
this.tags = [];
this.bgm = bgm;
this.trainerPool = biomeTrainerPools[biome];
this.updatePoolsForTimeOfDay();
@ -676,15 +687,15 @@ export class Arena {
}
/**
* Adds a new tag to the arena
* @param tagType {@linkcode ArenaTagType} the tag being added
* @param turnCount How many turns the tag lasts
* @param sourceMove {@linkcode MoveId} the move the tag came from, or `undefined` if not from a move
* @param sourceId The ID of the pokemon in play the tag came from (see {@linkcode BattleScene.getPokemonById})
* @param side {@linkcode ArenaTagSide} which side(s) the tag applies to
* @param quiet If a message should be queued on screen to announce the tag being added
* @param targetIndex The {@linkcode BattlerIndex} of the target pokemon
* @returns `false` if there already exists a tag of this type in the Arena
* Add a new {@linkcode ArenaTag} to the arena, triggering overlap effects on existing tags as applicable.
* @param tagType - The {@linkcode ArenaTagType} of the tag to add.
* @param turnCount - The number of turns the newly-added tag should last.
* @param sourceId - The {@linkcode Pokemon.id | PID} of the Pokemon creating the tag.
* @param sourceMove - The {@linkcode MoveId} of the move creating the tag, or `undefined` if not from a move.
* @param side - The {@linkcode ArenaTagSide}(s) to which the tag should apply; default `ArenaTagSide.BOTH`.
* @param quiet - Whether to suppress messages produced by tag addition; default `false`.
* @returns `true` if the tag was successfully added without overlapping.
// TODO: Do we need the return value here? literally nothing uses it
*/
addTag(
tagType: ArenaTagType,
@ -693,7 +704,6 @@ export class Arena {
sourceId: number,
side: ArenaTagSide = ArenaTagSide.BOTH,
quiet = false,
targetIndex?: BattlerIndex,
): boolean {
const existingTag = this.getTagOnSide(tagType, side);
if (existingTag) {
@ -708,7 +718,7 @@ export class Arena {
}
// creates a new tag object
const newTag = getArenaTag(tagType, turnCount || 0, sourceMove, sourceId, targetIndex, side);
const newTag = getArenaTag(tagType, turnCount || 0, sourceMove, sourceId, side);
if (newTag) {
newTag.onAdd(this, quiet);
this.tags.push(newTag);
@ -724,10 +734,19 @@ export class Arena {
}
/**
* Attempts to get a tag from the Arena via {@linkcode getTagOnSide} that applies to both sides
* @param tagType The {@linkcode ArenaTagType} or {@linkcode ArenaTag} to get
* @returns either the {@linkcode ArenaTag}, or `undefined` if it isn't there
* Attempt to get a tag from the Arena via {@linkcode getTagOnSide} that applies to both sides
* @param tagType - The {@linkcode ArenaTagType} to retrieve
* @returns The existing {@linkcode ArenaTag}, or `undefined` if not present.
* @overload
*/
getTag(tagType: ArenaTagType): ArenaTag | undefined;
/**
* Attempt to get a tag from the Arena via {@linkcode getTagOnSide} that applies to both sides
* @param tagType - The constructor of the {@linkcode ArenaTag} to retrieve
* @returns The existing {@linkcode ArenaTag}, or `undefined` if not present.
* @overload
*/
getTag<T extends ArenaTag>(tagType: Constructor<T> | AbstractConstructor<T>): T | undefined;
getTag(tagType: ArenaTagType | Constructor<ArenaTag> | AbstractConstructor<ArenaTag>): ArenaTag | undefined {
return this.getTagOnSide(tagType, ArenaTagSide.BOTH);
}

View File

@ -213,8 +213,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* TODO: Stop treating this like a unique ID and stop treating 0 as no pokemon
*/
public id: number;
public name: string;
public nickname: string;
/**
* The Pokemon's current nickname, or `undefined` if it currently lacks one.
* If omitted, references to this should refer to the default name for this Pokemon's species.
*/
public nickname?: string;
public species: PokemonSpecies;
public formIndex: number;
public abilityIndex: number;
@ -444,7 +447,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
getNameToRender(useIllusion = true) {
const illusion = this.summonData.illusion;
const name = useIllusion ? (illusion?.name ?? this.name) : this.name;
const nickname: string = useIllusion ? (illusion?.nickname ?? this.nickname) : this.nickname;
const nickname: string | undefined = useIllusion ? illusion?.nickname : this.nickname;
try {
if (nickname) {
return decodeURIComponent(escape(atob(nickname))); // TODO: Remove `atob` and `escape`... eventually...
@ -5664,7 +5667,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
}
export class PlayerPokemon extends Pokemon {
protected battleInfo: PlayerBattleInfo;
protected declare battleInfo: PlayerBattleInfo;
public compatibleTms: MoveId[];
constructor(
@ -6193,7 +6196,7 @@ export class PlayerPokemon extends Pokemon {
}
export class EnemyPokemon extends Pokemon {
protected battleInfo: EnemyBattleInfo;
protected declare battleInfo: EnemyBattleInfo;
public trainerSlot: TrainerSlot;
public aiType: AiType;
public bossSegments: number;

View File

@ -23,13 +23,13 @@ import {
} from "#trainers/trainer-party-template";
import { randSeedInt, randSeedItem, randSeedWeightedItem } from "#utils/common";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { toSnakeCase } from "#utils/strings";
import i18next from "i18next";
export class Trainer extends Phaser.GameObjects.Container {
public config: TrainerConfig;
public variant: TrainerVariant;
public partyTemplateIndex: number;
public name: string;
public partnerName: string;
public nameKey: string;
public partnerNameKey: string | undefined;
@ -170,7 +170,7 @@ export class Trainer extends Phaser.GameObjects.Container {
const evilTeamTitles = ["grunt"];
if (this.name === "" && evilTeamTitles.some(t => name.toLocaleLowerCase().includes(t))) {
// This is a evil team grunt so we localize it by only using the "name" as the title
title = i18next.t(`trainerClasses:${name.toLowerCase().replace(/\s/g, "_")}`);
title = i18next.t(`trainerClasses:${toSnakeCase(name)}`);
console.log("Localized grunt name: " + title);
// Since grunts are not named we can just return the title
return title;
@ -187,7 +187,7 @@ export class Trainer extends Phaser.GameObjects.Container {
}
// Get the localized trainer class name from the i18n file and set it as the title.
// This is used for trainer class names, not titles like "Elite Four, Champion, etc."
title = i18next.t(`trainerClasses:${name.toLowerCase().replace(/\s/g, "_")}`);
title = i18next.t(`trainerClasses:${toSnakeCase(name)}`);
}
// If no specific trainer slot is set.
@ -208,7 +208,7 @@ export class Trainer extends Phaser.GameObjects.Container {
if (this.config.titleDouble && this.variant === TrainerVariant.DOUBLE && !this.config.doubleOnly) {
title = this.config.titleDouble;
name = i18next.t(`trainerNames:${this.config.nameDouble.toLowerCase().replace(/\s/g, "_")}`);
name = i18next.t(`trainerNames:${toSnakeCase(this.config.nameDouble)}`);
}
console.log(title ? `${title} ${name}` : name);

View File

@ -119,6 +119,7 @@ export class LoadingScene extends SceneBase {
this.loadImage("party_bg", "ui");
this.loadImage("party_bg_double", "ui");
this.loadImage("party_bg_double_manage", "ui");
this.loadAtlas("party_slot_main", "ui");
this.loadAtlas("party_slot", "ui");
this.loadImage("party_slot_overlay_lv", "ui");
@ -126,6 +127,8 @@ export class LoadingScene extends SceneBase {
this.loadAtlas("party_slot_hp_overlay", "ui");
this.loadAtlas("party_pb", "ui");
this.loadAtlas("party_cancel", "ui");
this.loadAtlas("party_discard", "ui");
this.loadAtlas("party_transfer", "ui");
this.loadImage("summary_bg", "ui");
this.loadImage("summary_overlay_shiny", "ui");

View File

@ -462,7 +462,7 @@ export abstract class LapsingPersistentModifier extends PersistentModifier {
* @see {@linkcode apply}
*/
export class DoubleBattleChanceBoosterModifier extends LapsingPersistentModifier {
public override type: DoubleBattleChanceBoosterModifierType;
public declare type: DoubleBattleChanceBoosterModifierType;
match(modifier: Modifier): boolean {
return modifier instanceof DoubleBattleChanceBoosterModifier && modifier.getMaxBattles() === this.getMaxBattles();
@ -936,7 +936,7 @@ export class EvoTrackerModifier extends PokemonHeldItemModifier {
* Currently used by Shuckle Juice item
*/
export class PokemonBaseStatTotalModifier extends PokemonHeldItemModifier {
public override type: PokemonBaseStatTotalModifierType;
public declare type: PokemonBaseStatTotalModifierType;
public isTransferable = false;
public statModifier: 10 | -15;
@ -2074,7 +2074,7 @@ export abstract class ConsumablePokemonModifier extends ConsumableModifier {
}
export class TerrastalizeModifier extends ConsumablePokemonModifier {
public override type: TerastallizeModifierType;
public declare type: TerastallizeModifierType;
public teraType: PokemonType;
constructor(type: TerastallizeModifierType, pokemonId: number, teraType: PokemonType) {
@ -2318,7 +2318,7 @@ export class PokemonLevelIncrementModifier extends ConsumablePokemonModifier {
}
export class TmModifier extends ConsumablePokemonModifier {
public override type: TmModifierType;
public declare type: TmModifierType;
/**
* Applies {@linkcode TmModifier}
@ -2365,7 +2365,7 @@ export class RememberMoveModifier extends ConsumablePokemonModifier {
}
export class EvolutionItemModifier extends ConsumablePokemonModifier {
public override type: EvolutionItemModifierType;
public declare type: EvolutionItemModifierType;
/**
* Applies {@linkcode EvolutionItemModifier}
* @param playerPokemon The {@linkcode PlayerPokemon} that should evolve via item
@ -2530,7 +2530,7 @@ export class ExpBoosterModifier extends PersistentModifier {
}
export class PokemonExpBoosterModifier extends PokemonHeldItemModifier {
public override type: PokemonExpBoosterModifierType;
public declare type: PokemonExpBoosterModifierType;
private boostMultiplier: number;
@ -2627,7 +2627,7 @@ export class ExpBalanceModifier extends PersistentModifier {
}
export class PokemonFriendshipBoosterModifier extends PokemonHeldItemModifier {
public override type: PokemonFriendshipBoosterModifierType;
public declare type: PokemonFriendshipBoosterModifierType;
matchType(modifier: Modifier): boolean {
return modifier instanceof PokemonFriendshipBoosterModifier;
@ -2684,7 +2684,7 @@ export class PokemonNatureWeightModifier extends PokemonHeldItemModifier {
}
export class PokemonMoveAccuracyBoosterModifier extends PokemonHeldItemModifier {
public override type: PokemonMoveAccuracyBoosterModifierType;
public declare type: PokemonMoveAccuracyBoosterModifierType;
private accuracyAmount: number;
constructor(type: PokemonMoveAccuracyBoosterModifierType, pokemonId: number, accuracy: number, stackCount?: number) {
@ -2736,7 +2736,7 @@ export class PokemonMoveAccuracyBoosterModifier extends PokemonHeldItemModifier
}
export class PokemonMultiHitModifier extends PokemonHeldItemModifier {
public override type: PokemonMultiHitModifierType;
public declare type: PokemonMultiHitModifierType;
matchType(modifier: Modifier): boolean {
return modifier instanceof PokemonMultiHitModifier;
@ -2817,7 +2817,7 @@ export class PokemonMultiHitModifier extends PokemonHeldItemModifier {
}
export class PokemonFormChangeItemModifier extends PokemonHeldItemModifier {
public override type: FormChangeItemModifierType;
public declare type: FormChangeItemModifierType;
public formChangeItem: FormChangeItem;
public active: boolean;
public isTransferable = false;

View File

@ -9,6 +9,7 @@ import { AttemptCapturePhase } from "#phases/attempt-capture-phase";
import { AttemptRunPhase } from "#phases/attempt-run-phase";
import { BattleEndPhase } from "#phases/battle-end-phase";
import { BerryPhase } from "#phases/berry-phase";
import { CheckInterludePhase } from "#phases/check-interlude-phase";
import { CheckStatusEffectPhase } from "#phases/check-status-effect-phase";
import { CheckSwitchPhase } from "#phases/check-switch-phase";
import { CommandPhase } from "#phases/command-phase";
@ -60,6 +61,7 @@ import { PartyHealPhase } from "#phases/party-heal-phase";
import { PokemonAnimPhase } from "#phases/pokemon-anim-phase";
import { PokemonHealPhase } from "#phases/pokemon-heal-phase";
import { PokemonTransformPhase } from "#phases/pokemon-transform-phase";
import { PositionalTagPhase } from "#phases/positional-tag-phase";
import { PostGameOverPhase } from "#phases/post-game-over-phase";
import { PostSummonPhase } from "#phases/post-summon-phase";
import { PostTurnStatusEffectPhase } from "#phases/post-turn-status-effect-phase";
@ -121,6 +123,7 @@ const PHASES = Object.freeze({
AttemptRunPhase,
BattleEndPhase,
BerryPhase,
CheckInterludePhase,
CheckStatusEffectPhase,
CheckSwitchPhase,
CommandPhase,
@ -170,6 +173,7 @@ const PHASES = Object.freeze({
PokemonAnimPhase,
PokemonHealPhase,
PokemonTransformPhase,
PositionalTagPhase,
PostGameOverPhase,
PostSummonPhase,
PostTurnStatusEffectPhase,
@ -240,6 +244,21 @@ export class PhaseManager {
this.dynamicPhaseTypes = [PostSummonPhase];
}
/**
* Clear all previously set phases, then add a new {@linkcode TitlePhase} to transition to the title screen.
* @param addLogin - Whether to add a new {@linkcode LoginPhase} before the {@linkcode TitlePhase}
* (but reset everything else).
* Default `false`
*/
public toTitleScreen(addLogin = false): void {
this.clearAllPhases();
if (addLogin) {
this.unshiftNew("LoginPhase");
}
this.unshiftNew("TitlePhase");
}
/* Phase Functions */
getCurrentPhase(): Phase | null {
return this.currentPhase;
@ -665,4 +684,15 @@ export class PhaseManager {
): void {
this.startDynamicPhase(this.create(phase, ...args));
}
/** Prevents end of turn effects from triggering when transitioning to a new biome on a X0 wave */
public onInterlude(): void {
const phasesToRemove = ["WeatherEffectPhase", "BerryPhase", "CheckStatusEffectPhase"];
this.phaseQueue = this.phaseQueue.filter(p => !phasesToRemove.includes(p.phaseName));
const turnEndPhase = this.findPhase<TurnEndPhase>(p => p.phaseName === "TurnEndPhase");
if (turnEndPhase) {
turnEndPhase.upcomingInterlude = true;
}
}
}

View File

@ -0,0 +1,18 @@
import { globalScene } from "#app/global-scene";
import { Phase } from "#app/phase";
export class CheckInterludePhase extends Phase {
public override readonly phaseName = "CheckInterludePhase";
public override start(): void {
super.start();
const { phaseManager } = globalScene;
const { waveIndex } = globalScene.currentBattle;
if (waveIndex % 10 === 0 && globalScene.getEnemyParty().every(p => p.isFainted())) {
phaseManager.onInterlude();
}
this.end();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,7 @@ import { MoveFlags } from "#enums/move-flags";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { MoveTarget } from "#enums/move-target";
import { isReflected, isVirtual, MoveUseMode } from "#enums/move-use-mode";
import { isReflected, MoveUseMode } from "#enums/move-use-mode";
import { PokemonType } from "#enums/pokemon-type";
import type { Pokemon } from "#field/pokemon";
import {
@ -244,43 +244,19 @@ export class MoveEffectPhase extends PokemonPhase {
globalScene.currentBattle.lastPlayerInvolved = this.fieldIndex;
}
const isDelayedAttack = this.move.hasAttr("DelayedAttackAttr");
/** If the user was somehow removed from the field and it's not a delayed attack, end this phase */
if (!user.isOnField()) {
if (!isDelayedAttack) {
super.end();
return;
}
if (!user.scene) {
/*
* This happens if the Pokemon that used the delayed attack gets caught and released
* on the turn the attack would have triggered. Having access to the global scene
* in the future may solve this entirely, so for now we just cancel the hit
*/
super.end();
return;
}
}
const move = this.move;
/**
* Does an effect from this move override other effects on this turn?
* e.g. Charging moves (Fly, etc.) on their first turn of use.
*/
const overridden = new BooleanHolder(false);
const move = this.move;
// Apply effects to override a move effect.
// Assuming single target here works as this is (currently)
// only used for Future Sight, calling and Pledge moves.
// TODO: change if any other move effect overrides are introduced
applyMoveAttrs(
"OverrideMoveEffectAttr",
user,
this.getFirstTarget() ?? null,
move,
overridden,
isVirtual(this.useMode),
);
applyMoveAttrs("OverrideMoveEffectAttr", user, this.getFirstTarget() ?? null, move, overridden, this.useMode);
// If other effects were overriden, stop this phase before they can be applied
if (overridden.value) {
@ -355,7 +331,7 @@ export class MoveEffectPhase extends PokemonPhase {
*/
private postAnimCallback(user: Pokemon, targets: Pokemon[]) {
// Add to the move history entry
if (this.firstHit) {
if (this.firstHit && this.useMode !== MoveUseMode.DELAYED_ATTACK) {
user.pushMoveHistory(this.moveHistoryEntry);
applyAbAttrs("ExecutedMoveAbAttr", { pokemon: user });
}
@ -663,6 +639,7 @@ export class MoveEffectPhase extends PokemonPhase {
/** @returns The {@linkcode Pokemon} using this phase's invoked move */
public getUserPokemon(): Pokemon | null {
// TODO: Make this purely a battler index
if (this.battlerIndex > BattlerIndex.ENEMY_2) {
return globalScene.getPokemonById(this.battlerIndex);
}

View File

@ -2,14 +2,12 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs";
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
import Overrides from "#app/overrides";
import type { DelayedAttackTag } from "#data/arena-tag";
import { CenterOfAttentionTag } from "#data/battler-tags";
import { SpeciesFormChangePreMoveTrigger } from "#data/form-change-triggers";
import { getStatusEffectActivationText, getStatusEffectHealText } from "#data/status-effect";
import { getTerrainBlockMessage } from "#data/terrain";
import { getWeatherBlockMessage } from "#data/weather";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type";
import { BattlerTagType } from "#enums/battler-tag-type";
@ -297,21 +295,6 @@ export class MovePhase extends BattlePhase {
// form changes happen even before we know that the move wll execute.
globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger);
// Check the player side arena if another delayed attack is active and hitting the same slot.
if (move.hasAttr("DelayedAttackAttr")) {
const currentTargetIndex = targets[0].getBattlerIndex();
const delayedAttackHittingSameSlot = globalScene.arena.tags.some(
tag =>
(tag.tagType === ArenaTagType.FUTURE_SIGHT || tag.tagType === ArenaTagType.DOOM_DESIRE) &&
(tag as DelayedAttackTag).targetIndex === currentTargetIndex,
);
if (delayedAttackHittingSameSlot) {
this.failMove(true);
return;
}
}
// Check if the move has any attributes that can interrupt its own use **before** displaying text.
// TODO: This should not rely on direct return values
let failed = move.getAttrs("PreUseInterruptAttr").some(attr => attr.apply(this.pokemon, targets[0], move));

View File

@ -0,0 +1,21 @@
// biome-ignore-start lint/correctness/noUnusedImports: TSDocs
import type { PositionalTag } from "#data/positional-tags/positional-tag";
import type { TurnEndPhase } from "#phases/turn-end-phase";
// biome-ignore-end lint/correctness/noUnusedImports: TSDocs
import { globalScene } from "#app/global-scene";
import { Phase } from "#app/phase";
/**
* Phase to trigger all pending post-turn {@linkcode PositionalTag}s.
* Occurs before {@linkcode TurnEndPhase} to allow for proper electrify timing.
*/
export class PositionalTagPhase extends Phase {
public readonly phaseName = "PositionalTagPhase";
public override start(): void {
globalScene.arena.positionalTagManager.activateAllTags();
super.end();
return;
}
}

View File

@ -24,10 +24,11 @@ export class SelectStarterPhase extends Phase {
globalScene.ui.setMode(UiMode.STARTER_SELECT, (starters: Starter[]) => {
globalScene.ui.clearText();
globalScene.ui.setMode(UiMode.SAVE_SLOT, SaveSlotUiMode.SAVE, (slotId: number) => {
// If clicking cancel, back out to title screen
if (slotId === -1) {
globalScene.phaseManager.clearPhaseQueue();
globalScene.phaseManager.pushNew("TitlePhase");
return this.end();
globalScene.phaseManager.toTitleScreen();
this.end();
return;
}
globalScene.sessionSlotId = slotId;
this.initBattle(starters);

View File

@ -114,11 +114,11 @@ export class TitlePhase extends Phase {
});
}
}
// Cancel button = back to title
options.push({
label: i18next.t("menu:cancel"),
handler: () => {
globalScene.phaseManager.clearPhaseQueue();
globalScene.phaseManager.pushNew("TitlePhase");
globalScene.phaseManager.toTitleScreen();
super.end();
return true;
},
@ -191,11 +191,12 @@ export class TitlePhase extends Phase {
initDailyRun(): void {
globalScene.ui.clearText();
globalScene.ui.setMode(UiMode.SAVE_SLOT, SaveSlotUiMode.SAVE, (slotId: number) => {
globalScene.phaseManager.clearPhaseQueue();
if (slotId === -1) {
globalScene.phaseManager.pushNew("TitlePhase");
return super.end();
globalScene.phaseManager.toTitleScreen();
super.end();
return;
}
globalScene.phaseManager.clearPhaseQueue();
globalScene.sessionSlotId = slotId;
const generateDaily = (seed: string) => {

View File

@ -18,6 +18,8 @@ import i18next from "i18next";
export class TurnEndPhase extends FieldPhase {
public readonly phaseName = "TurnEndPhase";
public upcomingInterlude = false;
start() {
super.start();
@ -59,9 +61,11 @@ export class TurnEndPhase extends FieldPhase {
pokemon.tempSummonData.waveTurnCount++;
};
this.executeForAll(handlePokemon);
if (!this.upcomingInterlude) {
this.executeForAll(handlePokemon);
globalScene.arena.lapseTags();
globalScene.arena.lapseTags();
}
if (globalScene.arena.weather && !globalScene.arena.weather.lapse()) {
globalScene.arena.trySetWeather(WeatherType.NONE);

View File

@ -218,6 +218,10 @@ export class TurnStartPhase extends FieldPhase {
break;
}
}
phaseManager.pushNew("CheckInterludePhase");
// TODO: Re-order these phases to be consistent with mainline turn order:
// https://www.smogon.com/forums/threads/sword-shield-battle-mechanics-research.3655528/page-64#post-9244179
phaseManager.pushNew("WeatherEffectPhase");
phaseManager.pushNew("BerryPhase");
@ -225,12 +229,13 @@ export class TurnStartPhase extends FieldPhase {
/** Add a new phase to check who should be taking status damage */
phaseManager.pushNew("CheckStatusEffectPhase", moveOrder);
phaseManager.pushNew("PositionalTagPhase");
phaseManager.pushNew("TurnEndPhase");
/**
* this.end() will call shiftPhase(), which dumps everything from PrependQueue (aka everything that is unshifted()) to the front
* of the queue and dequeues to start the next phase
* this is important since stuff like SwitchSummon, AttemptRun, AttemptCapture Phases break the "flow" and should take precedence
/*
* `this.end()` will call `PhaseManager#shiftPhase()`, which dumps everything from `phaseQueuePrepend`
* (aka everything that is queued via `unshift()`) to the front of the queue and dequeues to start the next phase.
* This is important since stuff like `SwitchSummonPhase`, `AttemptRunPhase`, and `AttemptCapturePhase` break the "flow" and should take precedence
*/
this.end();
}

View File

@ -1,5 +1,5 @@
import pkg from "#package.json";
import { camelCaseToKebabCase } from "#utils/common";
import { toKebabCase } from "#utils/strings";
import i18next from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import HttpBackend from "i18next-http-backend";
@ -79,13 +79,13 @@ const fonts: Array<LoadingFontFaceProperty> = [
face: new FontFace("emerald", "url(./fonts/pokemon-bw.ttf)", {
unicodeRange: rangesByLanguage.japanese,
}),
only: ["en", "es", "fr", "it", "de", "pt", "ko", "ja", "ca", "da", "tr", "ro", "ru"],
only: ["en", "es", "fr", "it", "de", "pt", "ko", "ja", "ca", "da", "tr", "ro", "ru", "tl"],
},
{
face: new FontFace("pkmnems", "url(./fonts/pokemon-bw.ttf)", {
unicodeRange: rangesByLanguage.japanese,
}),
only: ["en", "es", "fr", "it", "de", "pt", "ko", "ja", "ca", "da", "tr", "ro", "ru"],
only: ["en", "es", "fr", "it", "de", "pt", "ko", "ja", "ca", "da", "tr", "ro", "ru", "tl"],
},
];
@ -191,17 +191,20 @@ export async function initI18n(): Promise<void> {
"tr",
"ro",
"ru",
"tl",
],
backend: {
loadPath(lng: string, [ns]: string[]) {
// Use namespace maps where required
let fileName: string;
if (namespaceMap[ns]) {
fileName = namespaceMap[ns];
} else if (ns.startsWith("mysteryEncounters/")) {
fileName = camelCaseToKebabCase(ns + "Dialogue");
fileName = toKebabCase(ns + "-dialogue"); // mystery-encounters/a-trainers-test-dialogue
} else {
fileName = camelCaseToKebabCase(ns);
fileName = toKebabCase(ns);
}
// ex: "./locales/en/move-anims"
return `./locales/${lng}/${fileName}.json?v=${pkg.version}`;
},
},

View File

@ -890,7 +890,7 @@ export const achvs = {
100,
c =>
c instanceof FreshStartChallenge &&
c.value > 0 &&
c.value === 1 &&
!globalScene.gameMode.challenges.some(
c => [Challenges.INVERSE_BATTLE, Challenges.FLIP_STAT].includes(c.id) && c.value > 0,
),

View File

@ -1,5 +1,6 @@
import type { ArenaTag } from "#data/arena-tag";
import { loadArenaTag, SerializableArenaTag } from "#data/arena-tag";
import type { SerializedPositionalTag } from "#data/positional-tags/load-positional-tag";
import { Terrain } from "#data/terrain";
import { Weather } from "#data/weather";
import type { BiomeId } from "#enums/biome-id";
@ -12,6 +13,7 @@ export interface SerializedArenaData {
weather: NonFunctionProperties<Weather> | null;
terrain: NonFunctionProperties<Terrain> | null;
tags?: ArenaTagTypeData[];
positionalTags: SerializedPositionalTag[];
playerTerasUsed?: number;
}
@ -20,6 +22,7 @@ export class ArenaData {
public weather: Weather | null;
public terrain: Terrain | null;
public tags: ArenaTag[];
public positionalTags: SerializedPositionalTag[] = [];
public playerTerasUsed: number;
constructor(source: Arena | SerializedArenaData) {
@ -37,11 +40,15 @@ export class ArenaData {
this.biome = source.biomeType;
this.weather = source.weather;
this.terrain = source.terrain;
// The assertion here is ok - we ensure that all tags are inside the `posTagConstructorMap` map,
// and that all `PositionalTags` will become their respective interfaces when serialized and de-serialized.
this.positionalTags = (source.positionalTagManager.tags as unknown as SerializedPositionalTag[]) ?? [];
return;
}
this.biome = source.biome;
this.weather = source.weather ? new Weather(source.weather.weatherType, source.weather.turnsLeft) : null;
this.terrain = source.terrain ? new Terrain(source.terrain.terrainType, source.terrain.turnsLeft) : null;
this.positionalTags = source.positionalTags ?? [];
}
}

View File

@ -16,6 +16,7 @@ import { allMoves, allSpecies } from "#data/data-lists";
import type { Egg } from "#data/egg";
import { pokemonFormChanges } from "#data/pokemon-forms";
import type { PokemonSpecies } from "#data/pokemon-species";
import { loadPositionalTag } from "#data/positional-tags/load-positional-tag";
import { TerrainType } from "#data/terrain";
import { AbilityAttr } from "#enums/ability-attr";
import { BattleType } from "#enums/battle-type";
@ -1096,6 +1097,10 @@ export class GameData {
}
}
globalScene.arena.positionalTagManager.tags = sessionData.arena.positionalTags.map(tag =>
loadPositionalTag(tag),
);
if (globalScene.modifiers.length) {
console.warn("Existing modifiers not cleared on session load, deleting...");
globalScene.modifiers = [];
@ -1454,11 +1459,10 @@ export class GameData {
reader.onload = (_ => {
return e => {
let dataName: string;
let dataName = GameDataType[dataType].toLowerCase();
let dataStr = AES.decrypt(e.target?.result?.toString()!, saveKey).toString(enc.Utf8); // TODO: is this bang correct?
let valid = false;
try {
dataName = GameDataType[dataType].toLowerCase();
switch (dataType) {
case GameDataType.SYSTEM: {
dataStr = this.convertSystemDataStr(dataStr);
@ -1493,7 +1497,6 @@ export class GameData {
const displayError = (error: string) =>
globalScene.ui.showText(error, null, () => globalScene.ui.showText("", 0), fixedInt(1500));
dataName = dataName!; // tell TS compiler that dataName is defined!
if (!valid) {
return globalScene.ui.showText(

View File

@ -981,6 +981,10 @@ export function setSetting(setting: string, value: number): boolean {
label: "Română (Needs Help)",
handler: () => changeLocaleHandler("ro"),
},
{
label: "Tagalog (Needs Help)",
handler: () => changeLocaleHandler("tl"),
},
{
label: i18next.t("settings:back"),
handler: () => cancelHandler(),

View File

@ -6,7 +6,7 @@ import { UiMode } from "#enums/ui-mode";
import type { InputFieldConfig } from "#ui/form-modal-ui-handler";
import { FormModalUiHandler } from "#ui/form-modal-ui-handler";
import type { ModalConfig } from "#ui/modal-ui-handler";
import { formatText } from "#utils/common";
import { toTitleCase } from "#utils/strings";
type AdminUiHandlerService = "discord" | "google";
type AdminUiHandlerServiceMode = "Link" | "Unlink";
@ -21,9 +21,9 @@ export class AdminUiHandler extends FormModalUiHandler {
private readonly httpUserNotFoundErrorCode: number = 404;
private readonly ERR_REQUIRED_FIELD = (field: string) => {
if (field === "username") {
return `${formatText(field)} is required`;
return `${toTitleCase(field)} is required`;
}
return `${formatText(field)} Id is required`;
return `${toTitleCase(field)} Id is required`;
};
// returns a string saying whether a username has been successfully linked/unlinked to discord/google
private readonly SUCCESS_SERVICE_MODE = (service: string, mode: string) => {

View File

@ -18,7 +18,8 @@ import { BattleSceneEventType } from "#events/battle-scene";
import { addTextObject } from "#ui/text";
import { TimeOfDayWidget } from "#ui/time-of-day-widget";
import { addWindow, WindowVariant } from "#ui/ui-theme";
import { fixedInt, formatText, toCamelCaseString } from "#utils/common";
import { fixedInt } from "#utils/common";
import { toCamelCase, toTitleCase } from "#utils/strings";
import type { ParseKeys } from "i18next";
import i18next from "i18next";
@ -49,10 +50,10 @@ export function getFieldEffectText(arenaTagType: string): string {
if (!arenaTagType || arenaTagType === ArenaTagType.NONE) {
return arenaTagType;
}
const effectName = toCamelCaseString(arenaTagType);
const effectName = toCamelCase(arenaTagType);
const i18nKey = `arenaFlyout:${effectName}` as ParseKeys;
const resultName = i18next.t(i18nKey);
return !resultName || resultName === i18nKey ? formatText(arenaTagType) : resultName;
return !resultName || resultName === i18nKey ? toTitleCase(arenaTagType) : resultName;
}
export class ArenaFlyout extends Phaser.GameObjects.Container {

View File

@ -1,7 +1,7 @@
import { globalScene } from "#app/global-scene";
import { TextStyle } from "#enums/text-style";
import { addTextObject } from "#ui/text";
import { formatText } from "#utils/common";
import { toTitleCase } from "#utils/strings";
import i18next from "i18next";
const hiddenX = -150;
@ -101,7 +101,7 @@ export class BgmBar extends Phaser.GameObjects.Container {
getRealBgmName(bgmName: string): string {
return i18next.t([`bgmName:${bgmName}`, "bgmName:missing_entries"], {
name: formatText(bgmName),
name: toTitleCase(bgmName),
});
}
}

View File

@ -382,8 +382,7 @@ export class GameChallengesUiHandler extends UiHandler {
this.cursorObj?.setVisible(true);
this.updateChallengeArrows(this.startCursor.visible);
} else {
globalScene.phaseManager.clearPhaseQueue();
globalScene.phaseManager.pushNew("TitlePhase");
globalScene.phaseManager.toTitleScreen();
globalScene.phaseManager.getCurrentPhase()?.end();
}
success = true;

View File

@ -72,6 +72,10 @@ export abstract class FormModalUiHandler extends ModalUiHandler {
(hasTitle ? 31 : 5) + 20 * (config.length - 1) + 16 + this.getButtonTopMargin(),
"",
TextStyle.TOOLTIP_CONTENT,
{
fontSize: "42px",
wordWrap: { width: 850 },
},
);
this.errorMessage.setColor(this.getTextColor(TextStyle.SUMMARY_PINK));
this.errorMessage.setShadowColor(this.getTextColor(TextStyle.SUMMARY_PINK, true));
@ -84,20 +88,28 @@ export abstract class FormModalUiHandler extends ModalUiHandler {
this.inputs = [];
this.formLabels = [];
fieldsConfig.forEach((config, f) => {
const label = addTextObject(10, (hasTitle ? 31 : 5) + 20 * f, config.label, TextStyle.TOOLTIP_CONTENT);
// The Pokédex Scan Window uses width `300` instead of `160` like the other forms
// Therefore, the label does not need to be shortened
const label = addTextObject(
10,
(hasTitle ? 31 : 5) + 20 * f,
config.label.length > 25 && this.getWidth() < 200 ? config.label.slice(0, 20) + "..." : config.label,
TextStyle.TOOLTIP_CONTENT,
);
label.name = "formLabel" + f;
this.formLabels.push(label);
this.modalContainer.add(this.formLabels[this.formLabels.length - 1]);
const inputContainer = globalScene.add.container(70, (hasTitle ? 28 : 2) + 20 * f);
const inputWidth = label.width < 320 ? 80 : 80 - (label.width - 320) / 5.5;
const inputContainer = globalScene.add.container(70 + (80 - inputWidth), (hasTitle ? 28 : 2) + 20 * f);
inputContainer.setVisible(false);
const inputBg = addWindow(0, 0, 80, 16, false, false, 0, 0, WindowVariant.XTHIN);
const inputBg = addWindow(0, 0, inputWidth, 16, false, false, 0, 0, WindowVariant.XTHIN);
const isPassword = config?.isPassword;
const isReadOnly = config?.isReadOnly;
const input = addTextInputObject(4, -2, 440, 116, TextStyle.TOOLTIP_CONTENT, {
const input = addTextInputObject(4, -2, inputWidth * 5.5, 116, TextStyle.TOOLTIP_CONTENT, {
type: isPassword ? "password" : "text",
maxLength: isPassword ? 64 : 20,
readOnly: isReadOnly,

View File

@ -8,7 +8,8 @@ import type { GameData } from "#system/game-data";
import { addTextObject } from "#ui/text";
import { UiHandler } from "#ui/ui-handler";
import { addWindow } from "#ui/ui-theme";
import { formatFancyLargeNumber, getPlayTimeString, toReadableString } from "#utils/common";
import { formatFancyLargeNumber, getPlayTimeString } from "#utils/common";
import { toTitleCase } from "#utils/strings";
import i18next from "i18next";
import Phaser from "phaser";
@ -502,11 +503,9 @@ export function initStatsKeys() {
sourceFunc: gameData => gameData.gameStats[key].toString(),
};
}
if (!(displayStats[key] as DisplayStat).label_key) {
if (!displayStats[key].label_key) {
const splittableKey = key.replace(/([a-z]{2,})([A-Z]{1}(?:[^A-Z]|$))/g, "$1_$2");
(displayStats[key] as DisplayStat).label_key = toReadableString(
`${splittableKey[0].toUpperCase()}${splittableKey.slice(1)}`,
);
displayStats[key].label_key = toTitleCase(splittableKey);
}
}
}

View File

@ -152,7 +152,12 @@ export abstract class ModalUiHandler extends UiHandler {
updateContainer(config?: ModalConfig): void {
const [marginTop, marginRight, marginBottom, marginLeft] = this.getMargin(config);
const [width, height] = [this.getWidth(config), this.getHeight(config)];
/**
* If the total amount of characters for the 2 buttons exceeds ~30 characters,
* the width in `registration-form-ui-handler.ts` and `login-form-ui-handler.ts` needs to be increased.
*/
const width = this.getWidth(config);
const height = this.getHeight(config);
this.modalContainer.setPosition(
(globalScene.game.canvas.width / 6 - (width + (marginRight - marginLeft))) / 2,
(-globalScene.game.canvas.height / 6 - (height + (marginBottom - marginTop))) / 2,
@ -166,10 +171,14 @@ export abstract class ModalUiHandler extends UiHandler {
this.titleText.setX(width / 2);
this.titleText.setVisible(!!title);
for (let b = 0; b < this.buttonContainers.length; b++) {
const sliceWidth = width / (this.buttonContainers.length + 1);
this.buttonContainers[b].setPosition(sliceWidth * (b + 1), this.modalBg.height - (this.buttonBgs[b].height + 8));
if (this.buttonContainers.length > 0) {
const spacing = 12;
const totalWidth = this.buttonBgs.reduce((sum, bg) => sum + bg.width, 0) + spacing * (this.buttonBgs.length - 1);
let x = (this.modalBg.width - totalWidth) / 2;
this.buttonContainers.forEach((container, i) => {
container.setPosition(x + this.buttonBgs[i].width / 2, this.modalBg.height - (this.buttonBgs[i].height + 8));
x += this.buttonBgs[i].width + spacing;
});
}
}

View File

@ -69,7 +69,7 @@ export class ModifierSelectUiHandler extends AwaitableUiHandler {
if (context) {
context.font = styleOptions.fontSize + "px " + styleOptions.fontFamily;
this.transferButtonWidth = context.measureText(i18next.t("modifierSelectUiHandler:transfer")).width;
this.transferButtonWidth = context.measureText(i18next.t("modifierSelectUiHandler:manageItems")).width;
this.checkButtonWidth = context.measureText(i18next.t("modifierSelectUiHandler:checkTeam")).width;
}
@ -81,7 +81,7 @@ export class ModifierSelectUiHandler extends AwaitableUiHandler {
this.transferButtonContainer.setVisible(false);
ui.add(this.transferButtonContainer);
const transferButtonText = addTextObject(-4, -2, i18next.t("modifierSelectUiHandler:transfer"), TextStyle.PARTY);
const transferButtonText = addTextObject(-4, -2, i18next.t("modifierSelectUiHandler:manageItems"), TextStyle.PARTY);
transferButtonText.setName("text-transfer-btn");
transferButtonText.setOrigin(1, 0);
this.transferButtonContainer.add(transferButtonText);
@ -601,7 +601,7 @@ export class ModifierSelectUiHandler extends AwaitableUiHandler {
(globalScene.game.canvas.width - this.transferButtonWidth - this.checkButtonWidth) / 6 - 30,
OPTION_BUTTON_YPOSITION + 4,
);
ui.showText(i18next.t("modifierSelectUiHandler:transferDesc"));
ui.showText(i18next.t("modifierSelectUiHandler:manageItemsDesc"));
} else if (cursor === 2) {
this.cursorObj.setPosition(
(globalScene.game.canvas.width - this.checkButtonWidth) / 6 - 10,

View File

@ -26,7 +26,8 @@ import { MoveInfoOverlay } from "#ui/move-info-overlay";
import { PokemonIconAnimHandler, PokemonIconAnimMode } from "#ui/pokemon-icon-anim-handler";
import { addBBCodeTextObject, addTextObject, getTextColor } from "#ui/text";
import { addWindow } from "#ui/ui-theme";
import { BooleanHolder, getLocalizedSpriteKey, randInt, toReadableString } from "#utils/common";
import { BooleanHolder, getLocalizedSpriteKey, randInt } from "#utils/common";
import { toTitleCase } from "#utils/strings";
import i18next from "i18next";
import type BBCodeText from "phaser3-rex-plugins/plugins/bbcodetext";
@ -102,6 +103,11 @@ export enum PartyUiMode {
* This is generally used in for Mystery Encounter or special effects that require the player to select a Pokemon
*/
SELECT,
/**
* Indicates that the party UI is open to select a party member from which items will be discarded.
* This type of selection can be cancelled.
*/
DISCARD,
}
export enum PartyOption {
@ -120,6 +126,7 @@ export enum PartyOption {
RELEASE,
RENAME,
SELECT,
DISCARD,
SCROLL_UP = 1000,
SCROLL_DOWN = 1001,
FORM_CHANGE_ITEM = 2000,
@ -154,6 +161,7 @@ export class PartyUiHandler extends MessageUiHandler {
private partySlotsContainer: Phaser.GameObjects.Container;
private partySlots: PartySlot[];
private partyCancelButton: PartyCancelButton;
private partyDiscardModeButton: PartyDiscardModeButton;
private partyMessageBox: Phaser.GameObjects.NineSlice;
private moveInfoOverlay: MoveInfoOverlay;
@ -179,6 +187,8 @@ export class PartyUiHandler extends MessageUiHandler {
private transferAll: boolean;
private lastCursor = 0;
private lastLeftPokemonCursor = 0;
private lastRightPokemonCursor = 0;
private selectCallback: PartySelectCallback | PartyModifierTransferSelectCallback | null;
private selectFilter: PokemonSelectFilter | PokemonModifierTransferSelectFilter;
private moveSelectFilter: PokemonMoveSelectFilter;
@ -307,6 +317,12 @@ export class PartyUiHandler extends MessageUiHandler {
this.iconAnimHandler = new PokemonIconAnimHandler();
this.iconAnimHandler.setup();
const partyDiscardModeButton = new PartyDiscardModeButton(60, -globalScene.game.canvas.height / 15 - 1, this);
partyContainer.add(partyDiscardModeButton);
this.partyDiscardModeButton = partyDiscardModeButton;
// prepare move overlay. in case it appears to be too big, set the overlayScale to .5
const overlayScale = 1;
this.moveInfoOverlay = new MoveInfoOverlay({
@ -348,8 +364,18 @@ export class PartyUiHandler extends MessageUiHandler {
this.showMovePp = args.length > 6 && args[6];
this.partyContainer.setVisible(true);
this.partyBg.setTexture(`party_bg${globalScene.currentBattle.double ? "_double" : ""}`);
if (this.isItemManageMode()) {
this.partyBg.setTexture(`party_bg${globalScene.currentBattle.double ? "_double_manage" : ""}`);
} else {
this.partyBg.setTexture(`party_bg${globalScene.currentBattle.double ? "_double" : ""}`);
}
this.populatePartySlots();
// If we are currently transferring items, set the icon to its proper state and reveal the button.
if (this.isItemManageMode()) {
this.partyDiscardModeButton.toggleIcon(this.partyUiMode as PartyUiMode.MODIFIER_TRANSFER | PartyUiMode.DISCARD);
}
this.showPartyText();
this.setCursor(0);
return true;
@ -594,7 +620,7 @@ export class PartyUiHandler extends MessageUiHandler {
const option = this.options[this.optionsCursor];
if (button === Button.LEFT) {
/** Decrease quantity for the current item and update UI */
if (this.partyUiMode === PartyUiMode.MODIFIER_TRANSFER) {
if (this.isItemManageMode()) {
this.transferQuantities[option] =
this.transferQuantities[option] === 1
? this.transferQuantitiesMax[option]
@ -608,7 +634,7 @@ export class PartyUiHandler extends MessageUiHandler {
if (button === Button.RIGHT) {
/** Increase quantity for the current item and update UI */
if (this.partyUiMode === PartyUiMode.MODIFIER_TRANSFER) {
if (this.isItemManageMode()) {
this.transferQuantities[option] =
this.transferQuantities[option] === this.transferQuantitiesMax[option]
? 1
@ -638,6 +664,45 @@ export class PartyUiHandler extends MessageUiHandler {
return success;
}
private processDiscardMenuInput(pokemon: PlayerPokemon) {
const ui = this.getUi();
const option = this.options[this.optionsCursor];
this.clearOptions();
this.blockInput = true;
this.showText(i18next.t("partyUiHandler:discardConfirmation"), null, () => {
this.blockInput = false;
ui.setModeWithoutClear(
UiMode.CONFIRM,
() => {
ui.setMode(UiMode.PARTY);
this.doDiscard(option, pokemon);
},
() => {
ui.setMode(UiMode.PARTY);
this.showPartyText();
},
);
});
return true;
}
private doDiscard(option: PartyOption, pokemon: PlayerPokemon) {
const itemModifiers = this.getTransferrableItemsFromPokemon(pokemon);
this.clearOptions();
if (option === PartyOption.ALL) {
// Discard all currently held items
for (let i = 0; i < itemModifiers.length; i++) {
globalScene.tryDiscardHeldItemModifier(itemModifiers[i], this.transferQuantities[i]);
}
} else {
// Discard the currently selected item
globalScene.tryDiscardHeldItemModifier(itemModifiers[option], this.transferQuantities[option]);
}
}
private moveOptionCursor(button: Button.UP | Button.DOWN): boolean {
if (button === Button.UP) {
return this.setCursor(this.optionsCursor ? this.optionsCursor - 1 : this.options.length - 1);
@ -724,6 +789,10 @@ export class PartyUiHandler extends MessageUiHandler {
return this.processModifierTransferModeInput(pokemon);
}
if (this.partyUiMode === PartyUiMode.DISCARD) {
return this.processDiscardMenuInput(pokemon);
}
// options specific to the mode (moves)
if (this.partyUiMode === PartyUiMode.REMEMBER_MOVE_MODIFIER) {
return this.processRememberMoveModeInput(pokemon);
@ -863,7 +932,7 @@ export class PartyUiHandler extends MessageUiHandler {
}
if (button === Button.LEFT || button === Button.RIGHT) {
if (this.partyUiMode === PartyUiMode.MODIFIER_TRANSFER) {
if (this.isItemManageMode()) {
return this.processModifierTransferModeLeftRightInput(button);
}
}
@ -918,10 +987,22 @@ export class PartyUiHandler extends MessageUiHandler {
return !(this.partyUiMode === PartyUiMode.FAINT_SWITCH || this.partyUiMode === PartyUiMode.REVIVAL_BLESSING);
}
/**
* Return whether this UI handler is responsible for managing items.
* Used to ensure proper placement of mode toggle buttons in the UI, etc.
* @returns Whether the current handler is responsible for managing items.
*/
private isItemManageMode(): boolean {
return this.partyUiMode === PartyUiMode.MODIFIER_TRANSFER || this.partyUiMode === PartyUiMode.DISCARD;
}
private processPartyActionInput(): boolean {
const ui = this.getUi();
if (this.cursor < 6) {
if (this.partyUiMode === PartyUiMode.MODIFIER_TRANSFER && !this.transferMode) {
if (
(this.partyUiMode === PartyUiMode.MODIFIER_TRANSFER && !this.transferMode) ||
this.partyUiMode === PartyUiMode.DISCARD
) {
/** Initialize item quantities for the selected Pokemon */
const itemModifiers = globalScene.findModifiers(
m =>
@ -935,6 +1016,25 @@ export class PartyUiHandler extends MessageUiHandler {
this.showOptions();
ui.playSelect();
}
// Toggle item transfer mode to discard items or vice versa
if (this.cursor === 7) {
switch (this.partyUiMode) {
case PartyUiMode.DISCARD:
this.partyUiMode = PartyUiMode.MODIFIER_TRANSFER;
break;
case PartyUiMode.MODIFIER_TRANSFER:
this.partyUiMode = PartyUiMode.DISCARD;
break;
default:
ui.playError();
return false;
}
this.partyDiscardModeButton.toggleIcon(this.partyUiMode);
ui.playSelect();
return true;
}
// Pressing return button
if (this.cursor === 6) {
if (!this.allowCancel()) {
@ -955,6 +1055,7 @@ export class PartyUiHandler extends MessageUiHandler {
this.clearTransfer();
ui.playSelect();
} else if (this.allowCancel()) {
this.partyDiscardModeButton.clear();
if (this.selectCallback) {
const selectCallback = this.selectCallback;
this.selectCallback = null;
@ -973,30 +1074,74 @@ export class PartyUiHandler extends MessageUiHandler {
const slotCount = this.partySlots.length;
const battlerCount = globalScene.currentBattle.getBattlerCount();
if (this.lastCursor < battlerCount) {
this.lastLeftPokemonCursor = this.lastCursor;
}
if (this.lastCursor >= battlerCount && this.lastCursor < 6) {
this.lastRightPokemonCursor = this.lastCursor;
}
let success = false;
switch (button) {
// Item manage mode adds an extra 8th "toggle mode" button to the UI, located *below* both active party members.
// The following logic serves to ensure its menu behaviour matches its in-game position,
// being selected when scrolling up from the first inactive party member or down from the last active one.
case Button.UP:
if (this.isItemManageMode()) {
if (this.cursor === 1) {
success = this.setCursor(globalScene.currentBattle.double ? 0 : 7);
break;
}
if (this.cursor === 2) {
success = this.setCursor(globalScene.currentBattle.double ? 7 : 1);
break;
}
if (this.cursor === 6) {
success = this.setCursor(slotCount <= globalScene.currentBattle.getBattlerCount() ? 7 : slotCount - 1);
break;
}
if (this.cursor === 7) {
success = this.setCursor(globalScene.currentBattle.double && slotCount > 1 ? 1 : 0);
break;
}
}
success = this.setCursor(this.cursor ? (this.cursor < 6 ? this.cursor - 1 : slotCount - 1) : 6);
break;
case Button.DOWN:
if (this.isItemManageMode()) {
if (this.cursor === 0) {
success = this.setCursor(globalScene.currentBattle.double && slotCount > 1 ? 1 : 7);
break;
}
if (this.cursor === 1) {
success = this.setCursor(globalScene.currentBattle.double ? 7 : slotCount > 2 ? 2 : 6);
break;
}
if (this.cursor === 7) {
success = this.setCursor(
slotCount > globalScene.currentBattle.getBattlerCount() ? globalScene.currentBattle.getBattlerCount() : 6,
);
break;
}
}
success = this.setCursor(this.cursor < 6 ? (this.cursor < slotCount - 1 ? this.cursor + 1 : 6) : 0);
break;
case Button.LEFT:
if (this.cursor >= battlerCount && this.cursor <= 6) {
success = this.setCursor(0);
if (this.cursor === 6) {
success = this.setCursor(this.isItemManageMode() ? 7 : this.lastLeftPokemonCursor);
}
if (this.cursor >= battlerCount && this.cursor < 6) {
success = this.setCursor(this.lastLeftPokemonCursor);
}
break;
case Button.RIGHT:
if (slotCount === battlerCount) {
// Scrolling right from item transfer button or with no backup party members goes to cancel
if (this.cursor === 7 || slotCount <= battlerCount) {
success = this.setCursor(6);
break;
}
if (battlerCount >= 2 && slotCount > battlerCount && this.getCursor() === 0 && this.lastCursor === 1) {
success = this.setCursor(2);
break;
}
if (slotCount > battlerCount && this.cursor < battlerCount) {
success = this.setCursor(this.lastCursor < 6 ? this.lastCursor || battlerCount : battlerCount);
if (this.cursor < battlerCount) {
success = this.setCursor(this.lastRightPokemonCursor || battlerCount);
break;
}
}
@ -1043,11 +1188,15 @@ export class PartyUiHandler extends MessageUiHandler {
this.partySlots[this.lastCursor].deselect();
} else if (this.lastCursor === 6) {
this.partyCancelButton.deselect();
} else if (this.lastCursor === 7) {
this.partyDiscardModeButton.deselect();
}
if (cursor < 6) {
this.partySlots[cursor].select();
} else if (cursor === 6) {
this.partyCancelButton.select();
} else if (cursor === 7) {
this.partyDiscardModeButton.select();
}
}
return changed;
@ -1142,14 +1291,16 @@ export class PartyUiHandler extends MessageUiHandler {
optionsMessage = i18next.t("partyUiHandler:selectAnotherPokemonToSplice");
}
break;
case PartyUiMode.DISCARD:
optionsMessage = i18next.t("partyUiHandler:changeQuantityDiscard");
}
this.showText(optionsMessage, 0);
this.updateOptions();
/** When an item is being selected for transfer, the message box is taller as the message occupies two lines */
if (this.partyUiMode === PartyUiMode.MODIFIER_TRANSFER) {
/** When an item is being selected for transfer or discard, the message box is taller as the message occupies two lines */
if (this.isItemManageMode()) {
this.partyMessageBox.setSize(262 - Math.max(this.optionsBg.displayWidth - 56, 0), 42);
} else {
this.partyMessageBox.setSize(262 - Math.max(this.optionsBg.displayWidth - 56, 0), 30);
@ -1158,6 +1309,20 @@ export class PartyUiHandler extends MessageUiHandler {
this.setCursor(0);
}
showPartyText() {
switch (this.partyUiMode) {
case PartyUiMode.MODIFIER_TRANSFER:
this.showText(i18next.t("partyUiHandler:partyTransfer"));
break;
case PartyUiMode.DISCARD:
this.showText(i18next.t("partyUiHandler:partyDiscard"));
break;
default:
this.showText("", 0);
break;
}
}
private allowBatonModifierSwitch(): boolean {
return !!(
this.partyUiMode !== PartyUiMode.FAINT_SWITCH &&
@ -1275,6 +1440,9 @@ export class PartyUiHandler extends MessageUiHandler {
this.addCommonOptions(pokemon);
}
break;
case PartyUiMode.DISCARD:
this.updateOptionsWithModifierTransferMode(pokemon);
break;
// TODO: This still needs to be broken up.
// It could use a rework differentiating different kind of switches
// to treat baton passing separately from switching on faint.
@ -1380,7 +1548,8 @@ export class PartyUiHandler extends MessageUiHandler {
optionName = "↓";
} else if (
(this.partyUiMode !== PartyUiMode.REMEMBER_MOVE_MODIFIER &&
(this.partyUiMode !== PartyUiMode.MODIFIER_TRANSFER || this.transferMode)) ||
(this.partyUiMode !== PartyUiMode.MODIFIER_TRANSFER || this.transferMode) &&
this.partyUiMode !== PartyUiMode.DISCARD) ||
option === PartyOption.CANCEL
) {
switch (option) {
@ -1409,7 +1578,7 @@ export class PartyUiHandler extends MessageUiHandler {
if (this.localizedOptions.includes(option)) {
optionName = i18next.t(`partyUiHandler:${PartyOption[option]}`);
} else {
optionName = toReadableString(PartyOption[option]);
optionName = toTitleCase(PartyOption[option]);
}
}
break;
@ -1443,7 +1612,7 @@ export class PartyUiHandler extends MessageUiHandler {
const itemModifiers = this.getItemModifiers(pokemon);
const itemModifier = itemModifiers[option];
if (
this.partyUiMode === PartyUiMode.MODIFIER_TRANSFER &&
this.isItemManageMode() &&
this.transferQuantitiesMax[option] > 1 &&
!this.transferMode &&
itemModifier !== undefined &&
@ -1473,7 +1642,6 @@ export class PartyUiHandler extends MessageUiHandler {
optionText.x = 15 - this.optionsBg.width;
}
}
startTransfer(): void {
this.transferMode = true;
this.transferCursor = this.cursor;
@ -1607,7 +1775,7 @@ export class PartyUiHandler extends MessageUiHandler {
this.eraseOptionsCursor();
this.partyMessageBox.setSize(262, 30);
this.showText("", 0);
this.showPartyText();
}
eraseOptionsCursor() {
@ -1662,7 +1830,9 @@ class PartySlot extends Phaser.GameObjects.Container {
? -184 +
(globalScene.currentBattle.double ? -40 : 0) +
(28 + (globalScene.currentBattle.double ? 8 : 0)) * slotIndex
: -124 + (globalScene.currentBattle.double ? -8 : 0) + slotIndex * 64,
: partyUiMode === PartyUiMode.MODIFIER_TRANSFER
? -124 + (globalScene.currentBattle.double ? -20 : 0) + slotIndex * 55
: -124 + (globalScene.currentBattle.double ? -8 : 0) + slotIndex * 64,
);
this.slotIndex = slotIndex;
@ -1917,7 +2087,6 @@ class PartySlot extends Phaser.GameObjects.Container {
class PartyCancelButton extends Phaser.GameObjects.Container {
private selected: boolean;
private partyCancelBg: Phaser.GameObjects.Sprite;
private partyCancelPb: Phaser.GameObjects.Sprite;
@ -1964,3 +2133,96 @@ class PartyCancelButton extends Phaser.GameObjects.Container {
this.partyCancelPb.setFrame("party_pb");
}
}
class PartyDiscardModeButton extends Phaser.GameObjects.Container {
private selected: boolean;
private transferIcon: Phaser.GameObjects.Sprite;
private discardIcon: Phaser.GameObjects.Sprite;
private textBox: Phaser.GameObjects.Text;
private party: PartyUiHandler;
constructor(x: number, y: number, party: PartyUiHandler) {
super(globalScene, x, y);
this.setup(party);
}
setup(party: PartyUiHandler) {
this.transferIcon = globalScene.add.sprite(0, 0, "party_transfer");
this.discardIcon = globalScene.add.sprite(0, 0, "party_discard");
this.textBox = addTextObject(-8, -7, i18next.t("partyUiHandler:TRANSFER"), TextStyle.PARTY);
this.party = party;
this.add(this.transferIcon);
this.add(this.discardIcon);
this.add(this.textBox);
this.clear();
}
select() {
if (this.selected) {
return;
}
this.selected = true;
this.party.showText(i18next.t("partyUiHandler:changeMode"));
this.transferIcon.setFrame("selected");
this.discardIcon.setFrame("selected");
}
deselect() {
if (!this.selected) {
return;
}
this.selected = false;
this.party.showPartyText();
this.transferIcon.setFrame("normal");
this.discardIcon.setFrame("normal");
}
/**
* If the current mode deals with transferring items, toggle the discard items button's name and assets.
* @param partyMode - The current {@linkcode PartyUiMode}
* @remarks
* This will also reveal the button if it is currently hidden.
*/
public toggleIcon(partyMode: PartyUiMode.MODIFIER_TRANSFER | PartyUiMode.DISCARD): void {
this.setActive(true).setVisible(true);
switch (partyMode) {
case PartyUiMode.MODIFIER_TRANSFER:
this.transferIcon.setVisible(true);
this.discardIcon.setVisible(false);
this.textBox.setVisible(true);
this.textBox.setText(i18next.t("partyUiHandler:TRANSFER"));
this.setPosition(
globalScene.currentBattle.double ? 64 : 60,
globalScene.currentBattle.double ? -48 : -globalScene.game.canvas.height / 15 - 1,
);
this.transferIcon.displayWidth = this.textBox.text.length * 9 + 3;
break;
case PartyUiMode.DISCARD:
this.transferIcon.setVisible(false);
this.discardIcon.setVisible(true);
this.textBox.setVisible(true);
this.textBox.setText(i18next.t("partyUiHandler:DISCARD"));
this.setPosition(
globalScene.currentBattle.double ? 64 : 60,
globalScene.currentBattle.double ? -48 : -globalScene.game.canvas.height / 15 - 1,
);
this.discardIcon.displayWidth = this.textBox.text.length * 9 + 3;
break;
}
}
clear() {
this.setActive(false).setVisible(false);
this.transferIcon.setVisible(false);
this.discardIcon.setVisible(false);
this.textBox.setVisible(false);
}
}

View File

@ -54,16 +54,10 @@ import { PokedexInfoOverlay } from "#ui/pokedex-info-overlay";
import { StatsContainer } from "#ui/stats-container";
import { addBBCodeTextObject, addTextObject, getTextColor, getTextStyleOptions } from "#ui/text";
import { addWindow } from "#ui/ui-theme";
import {
BooleanHolder,
getLocalizedSpriteKey,
isNullOrUndefined,
padInt,
rgbHexToRgba,
toReadableString,
} from "#utils/common";
import { BooleanHolder, getLocalizedSpriteKey, isNullOrUndefined, padInt, rgbHexToRgba } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { toTitleCase } from "#utils/strings";
import { argbFromRgba } from "@material/material-color-utilities";
import i18next from "i18next";
import type BBCodeText from "phaser3-rex-plugins/plugins/gameobjects/tagtext/bbcodetext/BBCodeText";
@ -2620,7 +2614,7 @@ export class PokedexPageUiHandler extends MessageUiHandler {
// Setting growth rate text
if (isFormCaught) {
let growthReadable = toReadableString(GrowthRate[species.growthRate]);
let growthReadable = toTitleCase(GrowthRate[species.growthRate]);
const growthAux = growthReadable.replace(" ", "_");
if (i18next.exists("growth:" + growthAux)) {
growthReadable = i18next.t(("growth:" + growthAux) as any);

View File

@ -158,8 +158,11 @@ export class PokedexScanUiHandler extends FormModalUiHandler {
if (super.show(args)) {
const config = args[0] as ModalConfig;
this.inputs[0].resize(1150, 116);
this.inputContainers[0].list[0].width = 200;
const label = this.formLabels[0];
const inputWidth = label.width < 420 ? 200 : 200 - (label.width - 420) / 5.75;
this.inputs[0].resize(inputWidth * 5.75, 116);
this.inputContainers[0].list[0].width = inputWidth;
if (args[1] && typeof (args[1] as PlayerPokemon).getNameToRender === "function") {
this.inputs[0].text = (args[1] as PlayerPokemon).getNameToRender();
} else {

View File

@ -8,19 +8,6 @@ import type { ModalConfig } from "#ui/modal-ui-handler";
import { addTextObject } from "#ui/text";
import i18next from "i18next";
interface LanguageSetting {
inputFieldFontSize?: string;
warningMessageFontSize?: string;
errorMessageFontSize?: string;
}
const languageSettings: { [key: string]: LanguageSetting } = {
"es-ES": {
inputFieldFontSize: "50px",
errorMessageFontSize: "40px",
},
};
export class RegistrationFormUiHandler extends FormModalUiHandler {
getModalTitle(_config?: ModalConfig): string {
return i18next.t("menu:register");
@ -35,7 +22,7 @@ export class RegistrationFormUiHandler extends FormModalUiHandler {
}
getButtonTopMargin(): number {
return 8;
return 12;
}
getButtonLabels(_config?: ModalConfig): string[] {
@ -76,18 +63,9 @@ export class RegistrationFormUiHandler extends FormModalUiHandler {
setup(): void {
super.setup();
this.modalContainer.list.forEach((child: Phaser.GameObjects.GameObject) => {
if (child instanceof Phaser.GameObjects.Text && child !== this.titleText) {
const inputFieldFontSize = languageSettings[i18next.resolvedLanguage!]?.inputFieldFontSize;
if (inputFieldFontSize) {
child.setFontSize(inputFieldFontSize);
}
}
});
const warningMessageFontSize = languageSettings[i18next.resolvedLanguage!]?.warningMessageFontSize ?? "42px";
const label = addTextObject(10, 87, i18next.t("menu:registrationAgeWarning"), TextStyle.TOOLTIP_CONTENT, {
fontSize: warningMessageFontSize,
fontSize: "42px",
wordWrap: { width: 850 },
});
this.modalContainer.add(label);
@ -107,10 +85,6 @@ export class RegistrationFormUiHandler extends FormModalUiHandler {
const onFail = error => {
globalScene.ui.setMode(UiMode.REGISTRATION_FORM, Object.assign(config, { errorMessage: error?.trim() }));
globalScene.ui.playError();
const errorMessageFontSize = languageSettings[i18next.resolvedLanguage!]?.errorMessageFontSize;
if (errorMessageFontSize) {
this.errorMessage.setFontSize(errorMessageFontSize);
}
};
if (!this.inputs[0].text) {
return onFail(i18next.t("menu:emptyUsername"));

View File

@ -10,6 +10,7 @@ import { ScrollBar } from "#ui/scroll-bar";
import { addTextObject } from "#ui/text";
import { UiHandler } from "#ui/ui-handler";
import { addWindow } from "#ui/ui-theme";
import { toCamelCase } from "#utils/strings";
import i18next from "i18next";
export interface InputsIcons {
@ -88,12 +89,6 @@ export abstract class AbstractControlSettingsUiHandler extends UiHandler {
return settings;
}
private camelize(string: string): string {
return string
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => (index === 0 ? word.toLowerCase() : word.toUpperCase()))
.replace(/\s+/g, "");
}
/**
* Setup UI elements.
*/
@ -210,14 +205,15 @@ export abstract class AbstractControlSettingsUiHandler extends UiHandler {
settingFiltered.forEach((setting, s) => {
// Convert the setting key from format 'Key_Name' to 'Key name' for display.
const settingName = setting.replace(/_/g, " ");
// TODO: IDK if this can be followed by both an underscore and a space, so leaving it as a regex matching both for now
const i18nKey = toCamelCase(setting.replace(/Alt(_| )/, ""));
// Create and add a text object for the setting name to the scene.
const isLock = this.settingBlacklisted.includes(this.setting[setting]);
const labelStyle = isLock ? TextStyle.SETTINGS_LOCKED : TextStyle.SETTINGS_LABEL;
const isAlt = setting.includes("Alt");
let labelText: string;
const i18nKey = this.camelize(settingName.replace("Alt ", ""));
if (settingName.toLowerCase().includes("alt")) {
if (isAlt) {
labelText = `${i18next.t(`settings:${i18nKey}`)}${i18next.t("settings:alt")}`;
} else {
labelText = i18next.t(`settings:${i18nKey}`);

View File

@ -117,6 +117,12 @@ export class SettingsDisplayUiHandler extends AbstractSettingsUiHandler {
label: "Română (Needs Help)",
};
break;
case "tl":
this.settings[languageIndex].options[0] = {
value: "Tagalog",
label: "Tagalog (Needs Help)",
};
break;
default:
this.settings[languageIndex].options[0] = {
value: "English",

View File

@ -15,7 +15,8 @@ import {
import { AbstractControlSettingsUiHandler } from "#ui/abstract-control-settings-ui-handler";
import { NavigationManager } from "#ui/navigation-menu";
import { addTextObject } from "#ui/text";
import { reverseValueToKeySetting, truncateString } from "#utils/common";
import { truncateString } from "#utils/common";
import { toPascalSnakeCase } from "#utils/strings";
import i18next from "i18next";
/**
@ -101,7 +102,7 @@ export class SettingsKeyboardUiHandler extends AbstractControlSettingsUiHandler
}
const cursor = this.cursor + this.scrollCursor; // Calculate the absolute cursor position.
const selection = this.settingLabels[cursor].text;
const key = reverseValueToKeySetting(selection);
const key = toPascalSnakeCase(selection);
const settingName = SettingKeyboard[key];
const activeConfig = this.getActiveConfig();
const success = deleteBind(this.getActiveConfig(), settingName);

View File

@ -69,10 +69,10 @@ import {
padInt,
randIntRange,
rgbHexToRgba,
toReadableString,
} from "#utils/common";
import type { StarterPreferences } from "#utils/data";
import { loadStarterPreferences, saveStarterPreferences } from "#utils/data";
import { toTitleCase } from "#utils/strings";
import { argbFromRgba } from "@material/material-color-utilities";
import i18next from "i18next";
import type { GameObjects } from "phaser";
@ -176,6 +176,10 @@ const languageSettings: { [key: string]: LanguageSetting } = {
starterInfoYOffset: 0.5,
starterInfoXPos: 26,
},
tl: {
starterInfoTextSize: "56px",
instructionTextSize: "38px",
},
};
const valueReductionMax = 2;
@ -3527,7 +3531,7 @@ export class StarterSelectUiHandler extends MessageUiHandler {
this.pokemonLuckLabelText.setVisible(this.pokemonLuckText.visible);
//Growth translate
let growthReadable = toReadableString(GrowthRate[species.growthRate]);
let growthReadable = toTitleCase(GrowthRate[species.growthRate]);
const growthAux = growthReadable.replace(" ", "_");
if (i18next.exists("growth:" + growthAux)) {
growthReadable = i18next.t(("growth:" + growthAux) as any);
@ -4303,7 +4307,10 @@ export class StarterSelectUiHandler extends MessageUiHandler {
return true;
}
tryExit(): boolean {
/**
* Attempt to back out of the starter selection screen into the appropriate parent modal
*/
tryExit(): void {
this.blockInput = true;
const ui = this.getUi();
@ -4317,12 +4324,13 @@ export class StarterSelectUiHandler extends MessageUiHandler {
UiMode.CONFIRM,
() => {
ui.setMode(UiMode.STARTER_SELECT);
globalScene.phaseManager.clearPhaseQueue();
if (globalScene.gameMode.isChallenge) {
// Non-challenge modes go directly back to title, while challenge modes go to the selection screen.
if (!globalScene.gameMode.isChallenge) {
globalScene.phaseManager.toTitleScreen();
} else {
globalScene.phaseManager.clearPhaseQueue();
globalScene.phaseManager.pushNew("SelectChallengePhase");
globalScene.phaseManager.pushNew("EncounterPhase");
} else {
globalScene.phaseManager.pushNew("TitlePhase");
}
this.clearText();
globalScene.phaseManager.getCurrentPhase()?.end();
@ -4333,8 +4341,6 @@ export class StarterSelectUiHandler extends MessageUiHandler {
19,
);
});
return true;
}
tryStart(manualTrigger = false): boolean {

View File

@ -35,9 +35,9 @@ import {
isNullOrUndefined,
padInt,
rgbHexToRgba,
toReadableString,
} from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { toTitleCase } from "#utils/strings";
import { argbFromRgba } from "@material/material-color-utilities";
import i18next from "i18next";
@ -962,8 +962,8 @@ export class SummaryUiHandler extends UiHandler {
this.passiveContainer?.descriptionText?.setVisible(false);
const closeFragment = getBBCodeFrag("", TextStyle.WINDOW_ALT);
const rawNature = toReadableString(Nature[this.pokemon?.getNature()!]); // TODO: is this bang correct?
const nature = `${getBBCodeFrag(toReadableString(getNatureName(this.pokemon?.getNature()!)), TextStyle.SUMMARY_RED)}${closeFragment}`; // TODO: is this bang correct?
const rawNature = toTitleCase(Nature[this.pokemon?.getNature()!]); // TODO: is this bang correct?
const nature = `${getBBCodeFrag(toTitleCase(getNatureName(this.pokemon?.getNature()!)), TextStyle.SUMMARY_RED)}${closeFragment}`; // TODO: is this bang correct?
const memoString = i18next.t("pokemonSummary:memoString", {
metFragment: i18next.t(

View File

@ -1,6 +1,5 @@
import { pokerogueApi } from "#api/pokerogue-api";
import { MoneyFormat } from "#enums/money-format";
import { MoveId } from "#enums/move-id";
import type { Variant } from "#sprites/variant";
import i18next from "i18next";
@ -10,19 +9,6 @@ export const MissingTextureKey = "__MISSING";
// TODO: Draft tests for these utility functions
// TODO: Break up this file
/**
* Convert a `snake_case` string in any capitalization (such as one from an enum reverse mapping)
* into a readable `Title Case` version.
* @param str - The snake case string to be converted.
* @returns The result of converting `str` into title case.
*/
export function toReadableString(str: string): string {
return str
.replace(/_/g, " ")
.split(" ")
.map(s => capitalizeFirstLetter(s.toLowerCase()))
.join(" ");
}
export function randomString(length: number, seeded = false) {
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
@ -278,7 +264,7 @@ export function formatMoney(format: MoneyFormat, amount: number) {
}
export function formatStat(stat: number, forHp = false): string {
return formatLargeNumber(stat, forHp ? 100000 : 1000000);
return formatLargeNumber(stat, forHp ? 100_000 : 1_000_000);
}
export function executeIf<T>(condition: boolean, promiseFunc: () => Promise<T>): Promise<T | null> {
@ -359,31 +345,6 @@ export function fixedInt(value: number): number {
return new FixedInt(value) as unknown as number;
}
/**
* Formats a string to title case
* @param unformattedText Text to be formatted
* @returns the formatted string
*/
export function formatText(unformattedText: string): string {
const text = unformattedText.split("_");
for (let i = 0; i < text.length; i++) {
text[i] = text[i].charAt(0).toUpperCase() + text[i].substring(1).toLowerCase();
}
return text.join(" ");
}
export function toCamelCaseString(unformattedText: string): string {
if (!unformattedText) {
return "";
}
return unformattedText
.split(/[_ ]/)
.filter(f => f)
.map((f, i) => (i ? `${f[0].toUpperCase()}${f.slice(1).toLowerCase()}` : f.toLowerCase()))
.join("");
}
export function rgbToHsv(r: number, g: number, b: number) {
const v = Math.max(r, g, b);
const c = v - Math.min(r, g, b);
@ -475,6 +436,7 @@ export function hasAllLocalizedSprites(lang?: string): boolean {
case "ja":
case "ca":
case "ru":
case "tl":
return true;
default:
return false;
@ -510,41 +472,6 @@ export function truncateString(str: string, maxLength = 10) {
return str;
}
/**
* Convert a space-separated string into a capitalized and underscored string.
* @param input - The string to be converted.
* @returns The converted string with words capitalized and separated by underscores.
*/
export function reverseValueToKeySetting(input: string) {
// Split the input string into an array of words
const words = input.split(" ");
// Capitalize the first letter of each word and convert the rest to lowercase
const capitalizedWords = words.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
// Join the capitalized words with underscores and return the result
return capitalizedWords.join("_");
}
/**
* Capitalize a string.
* @param str - The string to be capitalized.
* @param sep - The separator between the words of the string.
* @param lowerFirstChar - Whether the first character of the string should be lowercase or not.
* @param returnWithSpaces - Whether the returned string should have spaces between the words or not.
* @returns The capitalized string.
*/
export function capitalizeString(str: string, sep: string, lowerFirstChar = true, returnWithSpaces = false) {
if (str) {
const splitedStr = str.toLowerCase().split(sep);
for (let i = +lowerFirstChar; i < splitedStr?.length; i++) {
splitedStr[i] = splitedStr[i].charAt(0).toUpperCase() + splitedStr[i].substring(1);
}
return returnWithSpaces ? splitedStr.join(" ") : splitedStr.join("");
}
return null;
}
/**
* Report whether a given value is nullish (`null`/`undefined`).
* @param val - The value whose nullishness is being checked
@ -554,15 +481,6 @@ export function isNullOrUndefined(val: any): val is null | undefined {
return val === null || val === undefined;
}
/**
* Capitalize the first letter of a string.
* @param str - The string whose first letter is being capitalized
* @return The original string with its first letter capitalized
*/
export function capitalizeFirstLetter(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* This function is used in the context of a Pokémon battle game to calculate the actual integer damage value from a float result.
* Many damage calculation formulas involve various parameters and result in float values.
@ -597,26 +515,6 @@ export function isBetween(num: number, min: number, max: number): boolean {
return min <= num && num <= max;
}
/**
* Helper method to return the animation filename for a given move
*
* @param move the move for which the animation filename is needed
*/
export function animationFileName(move: MoveId): string {
return MoveId[move].toLowerCase().replace(/_/g, "-");
}
/**
* Transforms a camelCase string into a kebab-case string
* @param str The camelCase string
* @returns A kebab-case string
*
* @source {@link https://stackoverflow.com/a/67243723/}
*/
export function camelCaseToKebabCase(str: string): string {
return str.replace(/[A-Z]+(?![a-z])|[A-Z]/g, (s, o) => (o ? "-" : "") + s.toLowerCase());
}
/** Get the localized shiny descriptor for the provided variant
* @param variant - The variant to get the shiny descriptor for
* @returns The localized shiny descriptor

View File

@ -1,5 +1,5 @@
import type { EnumOrObject, EnumValues, NormalEnum, TSNumericEnum } from "#app/@types/enum-types";
import type { InferKeys } from "#app/@types/type-helpers";
import type { EnumOrObject, NormalEnum, TSNumericEnum } from "#types/enum-types";
import type { InferKeys, ObjectValues } from "#types/type-helpers";
/**
* Return the string keys of an Enum object, excluding reverse-mapped numbers.
@ -61,7 +61,7 @@ export function getEnumValues<E extends EnumOrObject>(enumType: TSNumericEnum<E>
* If multiple keys map to the same value, the first one (in insertion order) will be retrieved,
* but the return type will be the union of ALL their corresponding keys.
*/
export function enumValueToKey<T extends EnumOrObject, V extends EnumValues<T>>(
export function enumValueToKey<T extends EnumOrObject, V extends ObjectValues<T>>(
object: NormalEnum<T>,
val: V,
): InferKeys<T, V> {

181
src/utils/strings.ts Normal file
View File

@ -0,0 +1,181 @@
// TODO: Standardize file and path casing to remove the need for all these different casing methods
// #region Split string code
// Regexps involved with splitting words in various case formats.
// Sourced from https://www.npmjs.com/package/change-case (with slight tweaking here and there)
/** Regex to split at word boundaries.*/
const SPLIT_LOWER_UPPER_RE = /([\p{Ll}\d])(\p{Lu})/gu;
/** Regex to split around single-letter uppercase words.*/
const SPLIT_UPPER_UPPER_RE = /(\p{Lu})([\p{Lu}][\p{Ll}])/gu;
/** Regexp involved with stripping non-word delimiters from the result. */
const DELIM_STRIP_REGEXP = /[-_ ]+/giu;
// The replacement value for splits.
const SPLIT_REPLACE_VALUE = "$1\0$2";
/**
* Split any cased string into an array of its constituent words.
* @param string - The string to be split
* @returns The new string, delimited at each instance of one or more spaces, underscores, hyphens
* or lower-to-upper boundaries.
* @remarks
* **DO NOT USE THIS FUNCTION!**
* Exported only to allow for testing.
* @todo Consider tests into [in-source testing](https://vitest.dev/guide/in-source.html) and converting this to unexported
*/
export function splitWords(value: string): string[] {
let result = value.trim();
result = result.replace(SPLIT_LOWER_UPPER_RE, SPLIT_REPLACE_VALUE).replace(SPLIT_UPPER_UPPER_RE, SPLIT_REPLACE_VALUE);
result = result.replace(DELIM_STRIP_REGEXP, "\0");
// Trim the delimiter from around the output string
return trimFromStartAndEnd(result, "\0").split(/\0/g);
}
/**
* Helper function to remove one or more sequences of characters from either end of a string.
* @param str - The string to replace
* @param charToTrim - The string to remove
* @returns The result of removing all instances of {@linkcode charsToTrim} from either end of {@linkcode str}.
*/
function trimFromStartAndEnd(str: string, charToTrim: string): string {
let start = 0;
let end = str.length;
const blockLength = charToTrim.length;
while (str.startsWith(charToTrim, start)) {
start += blockLength;
}
if (start - end === blockLength) {
// Occurs if the ENTIRE string is made up of charToTrim (at which point we return nothing)
return "";
}
while (str.endsWith(charToTrim, end)) {
end -= blockLength;
}
return str.slice(start, end);
}
// #endregion Split String code
/**
* Capitalize the first letter of a string.
* @param str - The string whose first letter is to be capitalized
* @return The original string with its first letter capitalized.
* @example
* ```ts
* console.log(capitalizeFirstLetter("consectetur adipiscing elit")); // returns "Consectetur adipiscing elit"
* ```
*/
export function capitalizeFirstLetter(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Helper method to convert a string into `Title Case` (such as one used for console logs).
* @param str - The string being converted
* @returns The result of converting `str` into title case.
* @example
* ```ts
* console.log(toTitleCase("lorem ipsum dolor sit amet")); // returns "Lorem Ipsum Dolor Sit Amet"
* ```
*/
export function toTitleCase(str: string): string {
return splitWords(str)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
/**
* Helper method to convert a string into `camelCase` (such as one used for i18n keys).
* @param str - The string being converted
* @returns The result of converting `str` into camel case.
* @example
* ```ts
* console.log(toCamelCase("BIG_ANGRY_TRAINER")); // returns "bigAngryTrainer"
* ```
*/
export function toCamelCase(str: string) {
return splitWords(str)
.map((word, index) =>
index === 0 ? word.toLowerCase() : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
)
.join("");
}
/**
* Helper method to convert a string into `PascalCase`.
* @param str - The string being converted
* @returns The result of converting `str` into pascal case.
* @example
* ```ts
* console.log(toPascalCase("hi how was your day")); // returns "HiHowWasYourDay"
* ```
* @remarks
*/
export function toPascalCase(str: string) {
return splitWords(str)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("");
}
/**
* Helper method to convert a string into `kebab-case` (such as one used for filenames).
* @param str - The string being converted
* @returns The result of converting `str` into kebab case.
* @example
* ```ts
* console.log(toKebabCase("not_kebab-caSe String")); // returns "not-kebab-case-string"
* ```
*/
export function toKebabCase(str: string): string {
return splitWords(str)
.map(word => word.toLowerCase())
.join("-");
}
/**
* Helper method to convert a string into `snake_case` (such as one used for filenames).
* @param str - The string being converted
* @returns The result of converting `str` into snake case.
* @example
* ```ts
* console.log(toSnakeCase("not-in snake_CaSe")); // returns "not_in_snake_case"
* ```
*/
export function toSnakeCase(str: string) {
return splitWords(str)
.map(word => word.toLowerCase())
.join("_");
}
/**
* Helper method to convert a string into `UPPER_SNAKE_CASE`.
* @param str - The string being converted
* @returns The result of converting `str` into upper snake case.
* @example
* ```ts
* console.log(toUpperSnakeCase("apples bananas_oranGes-PearS")); // returns "APPLES_BANANAS_ORANGES_PEARS"
* ```
*/
export function toUpperSnakeCase(str: string) {
return splitWords(str)
.map(word => word.toUpperCase())
.join("_");
}
/**
* Helper method to convert a string into `Pascal_Snake_Case`.
* @param str - The string being converted
* @returns The result of converting `str` into pascal snake case.
* @example
* ```ts
* console.log(toPascalSnakeCase("apples-bananas_oranGes Pears")); // returns "Apples_Bananas_Oranges_Pears"
* ```
*/
export function toPascalSnakeCase(str: string) {
return splitWords(str)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("_");
}

26
test/@types/vitest.d.ts vendored Normal file
View File

@ -0,0 +1,26 @@
import type { Pokemon } from "#field/pokemon";
import type { PokemonType } from "#enums/pokemon-type";
import type { expect } from "vitest";
import type { toHaveTypesOptions } from "#test/test-utils/matchers/to-have-types";
declare module "vitest" {
interface Assertion {
/**
* Matcher to check if an array contains EXACTLY the given items (in any order).
*
* Different from {@linkcode expect.arrayContaining} as the latter only requires the array contain
* _at least_ the listed items.
*
* @param expected - The expected contents of the array, in any order.
* @see {@linkcode expect.arrayContaining}
*/
toEqualArrayUnsorted<E>(expected: E[]): void;
/**
* Matcher to check if a {@linkcode Pokemon}'s current typing includes the given types.
*
* @param expected - The expected types (in any order).
* @param options - The options passed to the matcher.
*/
toHaveTypes(expected: PokemonType[], options?: toHaveTypesOptions): void;
}
}

View File

@ -0,0 +1,72 @@
import { getPokemonNameWithAffix } from "#app/messages";
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Ability - Truant", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.criticalHits(false)
.moveset([MoveId.SPLASH, MoveId.TACKLE])
.ability(AbilityId.TRUANT)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
});
it("should loaf around and prevent using moves every other turn", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
// Turn 1: Splash succeeds
game.move.select(MoveId.SPLASH);
await game.toNextTurn();
expect(player.getLastXMoves(1)[0]).toEqual(
expect.objectContaining({ move: MoveId.SPLASH, result: MoveResult.SUCCESS }),
);
// Turn 2: Truant activates, cancelling tackle and displaying message
game.move.select(MoveId.TACKLE);
await game.toNextTurn();
expect(player.getLastXMoves(1)[0]).toEqual(expect.objectContaining({ move: MoveId.NONE, result: MoveResult.FAIL }));
expect(enemy.hp).toBe(enemy.getMaxHp());
expect(game.textInterceptor.logs).toContain(
i18next.t("battlerTags:truantLapse", {
pokemonNameWithAffix: getPokemonNameWithAffix(player),
}),
);
// Turn 3: Truant didn't activate, tackle worked
game.move.select(MoveId.TACKLE);
await game.toNextTurn();
expect(player.getLastXMoves(1)[0]).toEqual(
expect.objectContaining({ move: MoveId.TACKLE, result: MoveResult.SUCCESS }),
);
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
});
});

13
test/matchers.setup.ts Normal file
View File

@ -0,0 +1,13 @@
import { toEqualArrayUnsorted } from "#test/test-utils/matchers/to-equal-array-unsorted";
import { toHaveTypes } from "#test/test-utils/matchers/to-have-types";
import { expect } from "vitest";
/*
* Setup file for custom matchers.
* Make sure to define the call signatures in `test/@types/vitest.d.ts` too!
*/
expect.extend({
toEqualArrayUnsorted,
toHaveTypes,
});

Some files were not shown because too many files have changed in this diff Show More