diff --git a/public/images/ui/friendship.png b/public/images/ui/friendship.png new file mode 100644 index 00000000000..073adcadc76 Binary files /dev/null and b/public/images/ui/friendship.png differ diff --git a/public/images/ui/friendship_overlay.png b/public/images/ui/friendship_overlay.png new file mode 100644 index 00000000000..4a4724fbdc9 Binary files /dev/null and b/public/images/ui/friendship_overlay.png differ diff --git a/public/images/ui/legacy/friendship.png b/public/images/ui/legacy/friendship.png new file mode 100644 index 00000000000..073adcadc76 Binary files /dev/null and b/public/images/ui/legacy/friendship.png differ diff --git a/public/images/ui/legacy/friendship_overlay.png b/public/images/ui/legacy/friendship_overlay.png new file mode 100644 index 00000000000..4a4724fbdc9 Binary files /dev/null and b/public/images/ui/legacy/friendship_overlay.png differ diff --git a/src/data/move.ts b/src/data/move.ts index 748f81cdd8b..13ab84e8898 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -2668,20 +2668,42 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr { } } +/** + * Attribute used for moves that change stat stages + * @param stats {@linkcode BattleStat} array of stats to be changed + * @param stages stages by which to change the stats, from -6 to 6 + * @param selfTarget whether the changes are applied to the user (true) or the target (false) + * @param condition {@linkcode MoveConditionFunc} optional condition to trigger the stat change + * @param firstHitOnly whether the stat change only applies on the first hit of a multi hit move + * @param moveEffectTrigger {@linkcode MoveEffectTrigger} the trigger for the effect to take place + * @param firstTargetOnly whether, if this is a multi target move, to only apply the effect after the first target is hit, rather than once for each target + * @param lastHitOnly whether the effect should only apply after the last hit of a multi hit move + * + * @extends MoveEffectAttr + * @see {@linkcode apply} + */ export class StatStageChangeAttr extends MoveEffectAttr { public stats: BattleStat[]; public stages: integer; private condition: MoveConditionFunc | null; private showMessage: boolean; - constructor(stats: BattleStat[], stages: integer, selfTarget?: boolean, condition?: MoveConditionFunc | null, showMessage: boolean = true, firstHitOnly: boolean = false, moveEffectTrigger: MoveEffectTrigger = MoveEffectTrigger.HIT, firstTargetOnly: boolean = false) { - super(selfTarget, moveEffectTrigger, firstHitOnly, false, firstTargetOnly); + constructor(stats: BattleStat[], stages: integer, selfTarget?: boolean, condition?: MoveConditionFunc | null, showMessage: boolean = true, firstHitOnly: boolean = false, moveEffectTrigger: MoveEffectTrigger = MoveEffectTrigger.HIT, firstTargetOnly: boolean = false, lastHitOnly: boolean = false) { + super(selfTarget, moveEffectTrigger, firstHitOnly, lastHitOnly, firstTargetOnly); this.stats = stats; this.stages = stages; this.condition = condition!; // TODO: is this bang correct? this.showMessage = showMessage; } + /** + * Attempts to change stats of the user or target (depending on value of selfTarget) if conditions are met + * @param user {@linkcode Pokemon} the user of the move + * @param target {@linkcode Pokemon} the target of the move + * @param move {@linkcode Move} the move + * @param args unused + * @returns whether stat stages were changed + */ apply(user: Pokemon, target: Pokemon, move: Move, args?: any[]): boolean | Promise { if (!super.apply(user, target, move, args) || (this.condition && !this.condition(user, target, move))) { return false; @@ -9154,11 +9176,10 @@ export function initMoves() { .attr(ClearTerrainAttr) .condition((user, target, move) => !!user.scene.arena.terrain), new AttackMove(Moves.SCALE_SHOT, Type.DRAGON, MoveCategory.PHYSICAL, 25, 90, 20, -1, 0, 8) - //.attr(StatStageChangeAttr, Stat.SPD, 1, true) // TODO: Have boosts only apply at end of move, not after every hit - //.attr(StatStageChangeAttr, Stat.DEF, -1, true) + .attr(StatStageChangeAttr, [Stat.SPD], 1, true, null, true, false, MoveEffectTrigger.HIT, false, true) + .attr(StatStageChangeAttr, [Stat.DEF], -1, true, null, true, false, MoveEffectTrigger.HIT, false, true) .attr(MultiHitAttr) - .makesContact(false) - .partial(), + .makesContact(false), new AttackMove(Moves.METEOR_BEAM, Type.ROCK, MoveCategory.SPECIAL, 120, 90, 10, 100, 0, 8) .attr(ChargeAttr, ChargeAnim.METEOR_BEAM_CHARGING, i18next.t("moveTriggers:isOverflowingWithSpacePower", {pokemonName: "{USER}"}), null, true) .attr(StatStageChangeAttr, [ Stat.SPATK ], 1, true) diff --git a/src/loading-scene.ts b/src/loading-scene.ts index e71082ca8f5..3d4f4d165e6 100644 --- a/src/loading-scene.ts +++ b/src/loading-scene.ts @@ -44,6 +44,8 @@ export class LoadingScene extends SceneBase { this.loadAtlas("prompt", "ui"); this.loadImage("candy", "ui"); this.loadImage("candy_overlay", "ui"); + this.loadImage("friendship", "ui"); + this.loadImage("friendship_overlay", "ui"); this.loadImage("cursor", "ui"); this.loadImage("cursor_reverse", "ui"); for (const wv of Utils.getEnumValues(WindowVariant)) { diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index ca1fb87654f..33f2394cd1e 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -4,7 +4,7 @@ import { applyPreAttackAbAttrs, AddSecondStrikeAbAttr, IgnoreMoveEffectsAbAttr, import { ArenaTagSide, ConditionalProtectTag } from "#app/data/arena-tag"; import { MoveAnim } from "#app/data/battle-anims"; import { BattlerTagLapseType, DamageProtectedTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag } from "#app/data/battler-tags"; -import { MoveTarget, applyMoveAttrs, OverrideMoveEffectAttr, MultiHitAttr, AttackMove, FixedDamageAttr, VariableTargetAttr, MissEffectAttr, MoveFlags, applyFilteredMoveAttrs, MoveAttr, MoveEffectAttr, MoveEffectTrigger, ChargeAttr, MoveCategory, NoEffectAttr, HitsTagAttr } from "#app/data/move"; +import { MoveTarget, applyMoveAttrs, OverrideMoveEffectAttr, MultiHitAttr, AttackMove, FixedDamageAttr, VariableTargetAttr, MissEffectAttr, MoveFlags, applyFilteredMoveAttrs, MoveAttr, MoveEffectAttr, MoveEffectTrigger, ChargeAttr, MoveCategory, NoEffectAttr, HitsTagAttr, ToxicAccuracyAttr } from "#app/data/move"; import { SpeciesFormChangePostMoveTrigger } from "#app/data/pokemon-forms"; import { BattlerTagType } from "#app/enums/battler-tag-type"; import { Moves } from "#app/enums/moves"; @@ -14,6 +14,7 @@ import { PokemonMultiHitModifier, FlinchChanceModifier, EnemyAttackStatusEffectC import i18next from "i18next"; import * as Utils from "#app/utils"; import { PokemonPhase } from "./pokemon-phase"; +import { Type } from "#app/data/type"; export class MoveEffectPhase extends PokemonPhase { public move: PokemonMove; @@ -404,7 +405,10 @@ export class MoveEffectPhase extends PokemonPhase { } const semiInvulnerableTag = target.getTag(SemiInvulnerableTag); - if (semiInvulnerableTag && !this.move.getMove().getAttrs(HitsTagAttr).some(hta => hta.tagType === semiInvulnerableTag.tagType)) { + if (semiInvulnerableTag + && !this.move.getMove().getAttrs(HitsTagAttr).some(hta => hta.tagType === semiInvulnerableTag.tagType) + && !(this.move.getMove().getAttrs(ToxicAccuracyAttr) && user.isOfType(Type.POISON)) + ) { return false; } diff --git a/src/phases/weather-effect-phase.ts b/src/phases/weather-effect-phase.ts index 73de44389d0..b48ee342780 100644 --- a/src/phases/weather-effect-phase.ts +++ b/src/phases/weather-effect-phase.ts @@ -44,7 +44,7 @@ export class WeatherEffectPhase extends CommonAnimPhase { return; } - const damage = Math.ceil(pokemon.getMaxHp() / 16); + const damage = Utils.toDmgValue(pokemon.getMaxHp() / 16); this.scene.queueMessage(getWeatherDamageMessage(this.weather?.weatherType!, pokemon)!); // TODO: are those bangs correct? pokemon.damageAndUpdate(damage, HitResult.EFFECTIVE, false, false, true); diff --git a/src/test/arena/weather_hail.test.ts b/src/test/arena/weather_hail.test.ts index 31d20be2ded..2070e40dbcc 100644 --- a/src/test/arena/weather_hail.test.ts +++ b/src/test/arena/weather_hail.test.ts @@ -39,7 +39,7 @@ describe("Weather - Hail", () => { await game.phaseInterceptor.to("TurnEndPhase"); game.scene.getField(true).forEach(pokemon => { - expect(pokemon.hp).toBeLessThan(pokemon.getMaxHp() - Math.floor(pokemon.getMaxHp() / 16)); + expect(pokemon.hp).toBe(pokemon.getMaxHp() - Math.max(Math.floor(pokemon.getMaxHp() / 16), 1)); }); }); @@ -56,6 +56,20 @@ describe("Weather - Hail", () => { const enemyPokemon = game.scene.getEnemyPokemon()!; expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp()); - expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp() - Math.floor(enemyPokemon.getMaxHp() / 16)); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp() - Math.max(Math.floor(enemyPokemon.getMaxHp() / 16), 1)); + }); + + it("does not inflict damage to Ice type Pokemon", async () => { + await game.classicMode.startBattle([Species.CLOYSTER]); + + game.move.select(Moves.SPLASH); + + await game.phaseInterceptor.to("TurnEndPhase"); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp()); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp() - Math.max(Math.floor(enemyPokemon.getMaxHp() / 16), 1)); }); }); diff --git a/src/test/arena/weather_sandstorm.test.ts b/src/test/arena/weather_sandstorm.test.ts index 91188de6985..2419ca11b70 100644 --- a/src/test/arena/weather_sandstorm.test.ts +++ b/src/test/arena/weather_sandstorm.test.ts @@ -1,4 +1,6 @@ import { WeatherType } from "#app/data/weather"; +import { Abilities } from "#app/enums/abilities"; +import { Stat } from "#app/enums/stat"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import GameManager from "#test/utils/gameManager"; @@ -37,7 +39,7 @@ describe("Weather - Sandstorm", () => { await game.phaseInterceptor.to("TurnEndPhase"); game.scene.getField(true).forEach(pokemon => { - expect(pokemon.hp).toBeLessThan(pokemon.getMaxHp() - Math.floor(pokemon.getMaxHp() / 16)); + expect(pokemon.hp).toBe(pokemon.getMaxHp() - Math.max(Math.floor(pokemon.getMaxHp() / 16), 1)); }); }); @@ -53,6 +55,37 @@ describe("Weather - Sandstorm", () => { const enemyPokemon = game.scene.getEnemyPokemon()!; expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp()); - expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp() - Math.floor(enemyPokemon.getMaxHp() / 16)); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp() - Math.max(Math.floor(enemyPokemon.getMaxHp() / 16), 1)); + }); + + it("does not inflict damage to Rock, Ground and Steel type Pokemon", async () => { + game.override + .battleType("double") + .enemySpecies(Species.SANDSHREW) + .ability(Abilities.BALL_FETCH) + .enemyAbility(Abilities.BALL_FETCH); + + await game.classicMode.startBattle([Species.ROCKRUFF, Species.KLINK]); + + game.move.select(Moves.SPLASH, 0); + game.move.select(Moves.SPLASH, 1); + + await game.phaseInterceptor.to("TurnEndPhase"); + + game.scene.getField(true).forEach(pokemon => { + expect(pokemon.hp).toBe(pokemon.getMaxHp()); + }); + }); + + it("increases Rock type Pokemon Sp.Def by 50%", async () => { + await game.classicMode.startBattle([Species.ROCKRUFF]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const playerSpdef = playerPokemon.getStat(Stat.SPDEF); + expect(playerPokemon.getEffectiveStat(Stat.SPDEF)).toBe(Math.floor(playerSpdef * 1.5)); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + const enemySpdef = enemyPokemon.getStat(Stat.SPDEF); + expect(enemyPokemon.getEffectiveStat(Stat.SPDEF)).toBe(enemySpdef); }); }); diff --git a/src/test/moves/scale_shot.test.ts b/src/test/moves/scale_shot.test.ts new file mode 100644 index 00000000000..412ce6687c6 --- /dev/null +++ b/src/test/moves/scale_shot.test.ts @@ -0,0 +1,74 @@ +import { DamagePhase } from "#app/phases/damage-phase"; +import { MoveEffectPhase } from "#app/phases/move-effect-phase"; +import { MoveEndPhase } from "#app/phases/move-end-phase"; +import { TurnEndPhase } from "#app/phases/turn-end-phase"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { Stat } from "#enums/stat"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, it, expect } from "vitest"; + +describe("Moves - Scale Shot", () => { + 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 + .moveset([Moves.SCALE_SHOT]) + .battleType("single") + .disableCrits() + .starterSpecies(Species.MINCCINO) + .ability(Abilities.NO_GUARD) + .passiveAbility(Abilities.SKILL_LINK) + .enemyAbility(Abilities.SHEER_FORCE) + .enemyPassiveAbility(Abilities.STALL) + .enemyMoveset(Moves.SKILL_SWAP) + .enemyLevel(5); + }); + + it("applies stat changes after last hit", async () => { + await game.classicMode.startBattle([Species.FORRETRESS]); + const minccino = game.scene.getPlayerPokemon()!; + game.move.select(Moves.SCALE_SHOT); + await game.phaseInterceptor.to(MoveEffectPhase); + await game.phaseInterceptor.to(DamagePhase); + await game.phaseInterceptor.to(MoveEffectPhase); + expect (minccino?.getStatStage(Stat.DEF)).toBe(0); + expect (minccino?.getStatStage(Stat.SPD)).toBe(0); + await game.phaseInterceptor.to(MoveEndPhase); + expect (minccino.getStatStage(Stat.DEF)).toBe(-1); + expect (minccino.getStatStage(Stat.SPD)).toBe(1); + }); + + it("unaffected by sheer force", async () => { + await game.classicMode.startBattle([Species.WOBBUFFET]); + const minccino = game.scene.getPlayerPokemon()!; + const wobbuffet = game.scene.getEnemyPokemon()!; + wobbuffet.setStat(Stat.HP, 100, true); + wobbuffet.hp = 100; + game.move.select(Moves.SCALE_SHOT); + await game.phaseInterceptor.to(TurnEndPhase); + const hpafter1 = wobbuffet.hp; + //effect not nullified by sheer force + expect (minccino.getStatStage(Stat.DEF)).toBe(-1); + expect (minccino.getStatStage(Stat.SPD)).toBe(1); + game.move.select(Moves.SCALE_SHOT); + await game.phaseInterceptor.to(MoveEndPhase); + const hpafter2 = wobbuffet.hp; + //check damage not boosted- make damage before sheer force a little lower than theoretical boosted sheer force damage + expect (100 - hpafter1).toBe(hpafter1 - hpafter2); + }); +}); diff --git a/src/test/moves/toxic.test.ts b/src/test/moves/toxic.test.ts new file mode 100644 index 00000000000..2d023c201c1 --- /dev/null +++ b/src/test/moves/toxic.test.ts @@ -0,0 +1,76 @@ +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { StatusEffect } from "#enums/status-effect"; +import { BattlerIndex } from "#app/battle"; +import { allMoves } from "#app/data/move"; + +describe("Moves - Toxic", () => { + 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 + .battleType("single") + .moveset(Moves.TOXIC) + .enemySpecies(Species.MAGIKARP) + .enemyMoveset(Moves.SPLASH); + }); + + it("should be guaranteed to hit if user is Poison-type", async () => { + vi.spyOn(allMoves[Moves.TOXIC], "accuracy", "get").mockReturnValue(0); + await game.classicMode.startBattle([Species.TOXAPEX]); + + game.move.select(Moves.TOXIC); + await game.phaseInterceptor.to("BerryPhase", false); + + expect(game.scene.getEnemyPokemon()!.status?.effect).toBe(StatusEffect.TOXIC); + }); + + it("may miss if user is not Poison-type", async () => { + vi.spyOn(allMoves[Moves.TOXIC], "accuracy", "get").mockReturnValue(0); + await game.classicMode.startBattle([Species.UMBREON]); + + game.move.select(Moves.TOXIC); + await game.phaseInterceptor.to("BerryPhase", false); + + expect(game.scene.getEnemyPokemon()!.status).toBeUndefined(); + }); + + it("should hit semi-invulnerable targets if user is Poison-type", async () => { + vi.spyOn(allMoves[Moves.TOXIC], "accuracy", "get").mockReturnValue(0); + game.override.enemyMoveset(Moves.FLY); + await game.classicMode.startBattle([Species.TOXAPEX]); + + game.move.select(Moves.TOXIC); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.phaseInterceptor.to("BerryPhase", false); + + expect(game.scene.getEnemyPokemon()!.status?.effect).toBe(StatusEffect.TOXIC); + }); + + it("should miss semi-invulnerable targets if user is not Poison-type", async () => { + vi.spyOn(allMoves[Moves.TOXIC], "accuracy", "get").mockReturnValue(-1); + game.override.enemyMoveset(Moves.FLY); + await game.classicMode.startBattle([Species.UMBREON]); + + game.move.select(Moves.TOXIC); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.phaseInterceptor.to("BerryPhase", false); + + expect(game.scene.getEnemyPokemon()!.status).toBeUndefined(); + }); +}); diff --git a/src/ui/summary-ui-handler.ts b/src/ui/summary-ui-handler.ts index 28c3ccdb2e4..d3985b225ff 100644 --- a/src/ui/summary-ui-handler.ts +++ b/src/ui/summary-ui-handler.ts @@ -87,6 +87,10 @@ export default class SummaryUiHandler extends UiHandler { private moveAccuracyText: Phaser.GameObjects.Text; private moveCategoryIcon: Phaser.GameObjects.Sprite; private summaryPageTransitionContainer: Phaser.GameObjects.Container; + private friendshipShadow: Phaser.GameObjects.Sprite; + private friendshipText: Phaser.GameObjects.Text; + private friendshipIcon: Phaser.GameObjects.Sprite; + private friendshipOverlay: Phaser.GameObjects.Sprite; private descriptionScrollTween: Phaser.Tweens.Tween | null; private moveCursorBlinkTimer: Phaser.Time.TimerEvent | null; @@ -187,6 +191,25 @@ export default class SummaryUiHandler extends UiHandler { this.candyCountText.setOrigin(0, 0); this.summaryContainer.add(this.candyCountText); + this.friendshipIcon = this.scene.add.sprite(13, -60, "friendship"); + this.friendshipIcon.setScale(0.8); + this.summaryContainer.add(this.friendshipIcon); + + this.friendshipOverlay = this.scene.add.sprite(13, -60, "friendship_overlay"); + this.friendshipOverlay.setScale(0.8); + this.summaryContainer.add(this.friendshipOverlay); + + this.friendshipShadow = this.scene.add.sprite(13, -60, "friendship"); + this.friendshipShadow.setTint(0x000000); + this.friendshipShadow.setAlpha(0.50); + this.friendshipShadow.setScale(0.8); + this.friendshipShadow.setInteractive(new Phaser.Geom.Rectangle(0, 0, 16, 16), Phaser.Geom.Rectangle.Contains); + this.summaryContainer.add(this.friendshipShadow); + + this.friendshipText = addTextObject(this.scene, 20, -66, "x0", TextStyle.WINDOW_ALT, { fontSize: "76px" }); + this.friendshipText.setOrigin(0, 0); + this.summaryContainer.add(this.friendshipText); + this.championRibbon = this.scene.add.image(88, -146, "champion_ribbon"); this.championRibbon.setOrigin(0, 0); //this.championRibbon.setScale(0.8); @@ -292,6 +315,7 @@ export default class SummaryUiHandler extends UiHandler { this.candyIcon.setTint(argbFromRgba(Utils.rgbHexToRgba(colorScheme[0]))); this.candyOverlay.setTint(argbFromRgba(Utils.rgbHexToRgba(colorScheme[1]))); + this.numberText.setText(Utils.padInt(this.pokemon.species.speciesId, 4)); this.numberText.setColor(this.getTextColor(!this.pokemon.isShiny() ? TextStyle.SUMMARY : TextStyle.SUMMARY_GOLD)); this.numberText.setShadowColor(this.getTextColor(!this.pokemon.isShiny() ? TextStyle.SUMMARY : TextStyle.SUMMARY_GOLD, true)); @@ -345,6 +369,15 @@ export default class SummaryUiHandler extends UiHandler { this.candyShadow.setCrop(0, 0, 16, candyCropY); + if (this.friendshipShadow.visible) { + this.friendshipShadow.on("pointerover", () => this.scene.ui.showTooltip("", `${i18next.t("pokemonSummary:friendship")}`, true)); + this.friendshipShadow.on("pointerout", () => this.scene.ui.hideTooltip()); + } + + this.friendshipText.setText(`${this.pokemon?.friendship || "0"} / 255`); + + this.friendshipShadow.setCrop(0, 0, 16, 16 - (16 * ((this.pokemon?.friendship || 0) / 255))); + const doubleShiny = isFusion && this.pokemon.shiny && this.pokemon.fusionShiny; const baseVariant = !doubleShiny ? this.pokemon.getVariant() : this.pokemon.variant;