diff --git a/public/audio/bgm/mystery_encounter_fun_and_games.mp3 b/public/audio/bgm/mystery_encounter_fun_and_games.mp3 new file mode 100644 index 000000000000..a9660d75e900 Binary files /dev/null and b/public/audio/bgm/mystery_encounter_fun_and_games.mp3 differ diff --git a/public/audio/bgm/mystery_encounter_gen_5_gts.mp3 b/public/audio/bgm/mystery_encounter_gen_5_gts.mp3 new file mode 100644 index 000000000000..989a7f9c5980 Binary files /dev/null and b/public/audio/bgm/mystery_encounter_gen_5_gts.mp3 differ diff --git a/public/audio/bgm/mystery_encounter_gen_6_gts.mp3 b/public/audio/bgm/mystery_encounter_gen_6_gts.mp3 new file mode 100644 index 000000000000..2c574da66ae9 Binary files /dev/null and b/public/audio/bgm/mystery_encounter_gen_6_gts.mp3 differ diff --git a/public/audio/bgm/mystery_encounter_weird_dream.mp3 b/public/audio/bgm/mystery_encounter_weird_dream.mp3 new file mode 100644 index 000000000000..a630fe549db8 Binary files /dev/null and b/public/audio/bgm/mystery_encounter_weird_dream.mp3 differ diff --git a/public/battle-anims/encounter-dance.json b/public/battle-anims/encounter-dance.json new file mode 100644 index 000000000000..4be7f0756ee6 --- /dev/null +++ b/public/battle-anims/encounter-dance.json @@ -0,0 +1,951 @@ +{ + "id": 686, + "graphic": "PRAS- Dragon Dance", + "frames": [ + [ + { + "x": 4, + "y": -8, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + }, + { + "x": 12, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 70, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + }, + { + "x": -12, + "y": -0.5, + "zoomX": 100, + "zoomY": 100, + "mirror": true, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 70, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + } + ], + [ + { + "x": 12, + "y": -12, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + }, + { + "x": 16, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 155, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + }, + { + "x": -16, + "y": -0.5, + "zoomX": 100, + "zoomY": 100, + "mirror": true, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 155, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + } + ], + [ + { + "x": 24, + "y": -12, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + }, + { + "x": 20, + "y": 0, + "zoomX": 108, + "zoomY": 100, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 155, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + }, + { + "x": -20, + "y": -0.5, + "zoomX": 108, + "zoomY": 100, + "mirror": true, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 155, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + } + ], + [ + { + "x": 32, + "y": -8, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + }, + { + "x": 24, + "y": 0, + "zoomX": 108, + "zoomY": 100, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 155, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + }, + { + "x": -24, + "y": -0.5, + "zoomX": 108, + "zoomY": 100, + "mirror": true, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 155, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + } + ], + [ + { + "x": 36, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + }, + { + "x": 28, + "y": 0, + "zoomX": 108, + "zoomY": 100, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 70, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + }, + { + "x": -28, + "y": -0.5, + "zoomX": 108, + "zoomY": 100, + "mirror": true, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 70, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + } + ], + [ + { + "x": 36, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 36, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 32, + "y": -8, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 24, + "y": -12, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 12, + "y": -12, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 4, + "y": -8, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": -4, + "y": -8, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + }, + { + "x": 12, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 70, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + }, + { + "x": -12, + "y": -0.5, + "zoomX": 100, + "zoomY": 100, + "mirror": true, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 70, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + } + ], + [ + { + "x": -12, + "y": -12, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + }, + { + "x": 16, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 155, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + }, + { + "x": -16, + "y": -0.5, + "zoomX": 100, + "zoomY": 100, + "mirror": true, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 155, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + } + ], + [ + { + "x": -24, + "y": -12, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + }, + { + "x": 20, + "y": 0, + "zoomX": 108, + "zoomY": 100, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 155, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + }, + { + "x": -20, + "y": -0.5, + "zoomX": 108, + "zoomY": 100, + "mirror": true, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 155, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + } + ], + [ + { + "x": -32, + "y": -8, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + }, + { + "x": 24, + "y": 0, + "zoomX": 108, + "zoomY": 100, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 155, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + }, + { + "x": -24, + "y": -0.5, + "zoomX": 108, + "zoomY": 100, + "mirror": true, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 155, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + } + ], + [ + { + "x": -36, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + }, + { + "x": 28, + "y": 0, + "zoomX": 108, + "zoomY": 100, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 70, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + }, + { + "x": -28, + "y": -0.5, + "zoomX": 108, + "zoomY": 100, + "mirror": true, + "visible": true, + "blendType": 1, + "target": 2, + "graphicFrame": 0, + "opacity": 70, + "tone": [ + 0, + 0, + 0, + 255 + ], + "priority": 1, + "focus": 3 + } + ], + [ + { + "x": -36, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": -36, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": -32, + "y": -8, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": -24, + "y": -12, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": -12, + "y": -12, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": -4, + "y": -8, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 2 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 2 + } + ] + ], + "frameTimedEvents": { + "0": [ + { + "frameIndex": 0, + "resourceName": "PRSFX- Attract.wav", + "volume": 100, + "pitch": 100, + "eventType": "AnimTimedSoundEvent" + } + ], + "1": [ + { + "frameIndex": 0, + "resourceName": "PRSFX- Ally Switch.wav", + "volume": 80, + "pitch": 100, + "eventType": "AnimTimedSoundEvent" + } + ] + }, + "position": 4, + "hue": 0 +} \ No newline at end of file diff --git a/public/battle-anims/encounter-magma-bg.json b/public/battle-anims/encounter-magma-bg.json new file mode 100644 index 000000000000..bb22f721d9a4 --- /dev/null +++ b/public/battle-anims/encounter-magma-bg.json @@ -0,0 +1,66 @@ +{ + "frames": [ + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [] + ], + "frameTimedEvents": { + "0": [ + { + "frameIndex": 0, + "resourceName": "PRAS- Fire BG", + "bgX": 0, + "bgY": 0, + "opacity": 0, + "duration": 35, + "eventType": "AnimTimedAddBgEvent" + }, + { + "frameIndex": 0, + "resourceName": "", + "bgX": 0, + "bgY": 0, + "opacity": 255, + "duration": 12, + "eventType": "AnimTimedUpdateBgEvent" + } + ], + "25": [ + { + "frameIndex": 25, + "resourceName": "", + "bgX": 0, + "bgY": 0, + "opacity": 0, + "duration": 8, + "eventType": "AnimTimedUpdateBgEvent" + } + ] + }, + "position": 1, + "hue": 0 +} \ No newline at end of file diff --git a/public/battle-anims/encounter-magma-spout.json b/public/battle-anims/encounter-magma-spout.json new file mode 100644 index 000000000000..21f3bec585f0 --- /dev/null +++ b/public/battle-anims/encounter-magma-spout.json @@ -0,0 +1,902 @@ +{ + "graphic": "PRAS- Magma Storm", + "frames": [ + [ + { + "x": 101, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 0, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 152, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 0, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 124.5, + "y": -78.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 0, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 101, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 1, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 152, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 1, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 124.5, + "y": -78.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 1, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 101, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 2, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 152, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 2, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 124.5, + "y": -78.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 2, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 101, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 3, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 152, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 3, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 124.5, + "y": -78.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 3, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 101, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 152, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 124.5, + "y": -78.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 101, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 5, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 152, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 5, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 124.5, + "y": -78.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 5, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 101, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 6, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 152, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 6, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 124.5, + "y": -78.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 6, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 101, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 7, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 152, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 7, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 124.5, + "y": -78.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 7, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 120, + "y": -56, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 8, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 144, + "y": -84, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 8, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 100, + "y": -86.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 8, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 140, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 9, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 136, + "y": -92, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 9, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 108, + "y": -78.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 9, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 152, + "y": -76, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 10, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 116, + "y": -88, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 10, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 128, + "y": -62.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 10, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 136, + "y": -96, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 7, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 100, + "y": -76, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 7, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 148, + "y": -66.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 7, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 108, + "y": -92, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 8, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 120, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 8, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 144, + "y": -86.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 8, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 100, + "y": -76, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 9, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 136, + "y": -68, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 9, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 128, + "y": -94.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 9, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 100.5, + "y": -70, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 10, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 144, + "y": -66, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 10, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 126, + "y": -86.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 10, + "opacity": 255, + "priority": 4, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 101, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 6, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 152, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 6, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 124.5, + "y": -78.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 6, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 101, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 5, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 152, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 5, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 124.5, + "y": -78.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 5, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 101, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 152, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 124.5, + "y": -78.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 101, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 3, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 152, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 3, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 124.5, + "y": -78.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 3, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 101, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 2, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 152, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 2, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 124.5, + "y": -78.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 2, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 101, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 1, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 152, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 1, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 124.5, + "y": -78.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 1, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 101, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 0, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 152, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 0, + "opacity": 255, + "priority": 4, + "focus": 1 + }, + { + "x": 124.5, + "y": -78.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 0, + "opacity": 255, + "priority": 4, + "focus": 1 + } + ], + [ + { + "x": 101, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 0, + "opacity": 130, + "priority": 4, + "focus": 1 + }, + { + "x": 152, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 0, + "opacity": 130, + "priority": 4, + "focus": 1 + }, + { + "x": 124.5, + "y": -78.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 0, + "opacity": 140, + "priority": 4, + "focus": 1 + } + ] + ], + "frameTimedEvents": { + "0": [ + { + "frameIndex": 0, + "resourceName": "PRSFX- Magma Storm1.wav", + "volume": 100, + "pitch": 100, + "eventType": "AnimTimedSoundEvent" + } + ], + "8": [ + { + "frameIndex": 8, + "resourceName": "PRSFX- Magma Storm2.wav", + "volume": 100, + "pitch": 100, + "eventType": "AnimTimedSoundEvent" + } + ] + }, + "position": 1, + "hue": 0 +} \ No newline at end of file diff --git a/public/battle-anims/encounter-smokescreen.json b/public/battle-anims/encounter-smokescreen.json new file mode 100644 index 000000000000..286cbe13a035 --- /dev/null +++ b/public/battle-anims/encounter-smokescreen.json @@ -0,0 +1,822 @@ +{ + "graphic": "PRAS- Smokescreen", + "frames": [ + [ + { + "x": 15.5, + "y": 12.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 0, + "opacity": 100, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": 15.5, + "y": 8.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 0, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 8, + "opacity": 50, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": 15.5, + "y": 0.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 1, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 0, + "y": -4, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 8, + "opacity": 100, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": 15.5, + "y": -3.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 2, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 0, + "y": -4, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 8, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": -11, + "y": 21.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 0, + "opacity": 50, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": 15.5, + "y": -7.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 2, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 0, + "y": -8, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 7, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": -11, + "y": 17.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 0, + "opacity": 100, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": 15.5, + "y": -11.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 3, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 0, + "y": -12, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 6, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": -11, + "y": 13.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 0, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 11, + "y": 21, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 8, + "opacity": 50, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": 15.5, + "y": -15.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 0, + "y": -16, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 6, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": -11, + "y": 5.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 1, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 11, + "y": 17, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 8, + "opacity": 100, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": 15.5, + "y": -19.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 100, + "priority": 4, + "focus": 2 + }, + { + "x": 0, + "y": -20, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 5, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": -11, + "y": 0.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 2, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 11, + "y": 13, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 8, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": -12.5, + "y": 8.5, + "zoomX": 100, + "zoomY": 100, + "mirror": true, + "visible": true, + "target": 2, + "graphicFrame": 8, + "opacity": 50, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": 15.5, + "y": -23.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 50, + "priority": 4, + "focus": 2 + }, + { + "x": 0, + "y": -24, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 5, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": -11, + "y": -2.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 2, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 11, + "y": 9, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 7, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": -12.5, + "y": 4.5, + "zoomX": 100, + "zoomY": 100, + "mirror": true, + "visible": true, + "target": 2, + "graphicFrame": 8, + "opacity": 100, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": -11, + "y": -6.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 3, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 0, + "y": -28, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 11, + "y": 5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 6, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": -12.5, + "y": 0.5, + "zoomX": 100, + "zoomY": 100, + "mirror": true, + "visible": true, + "target": 2, + "graphicFrame": 8, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 4.5, + "y": 23, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 0, + "opacity": 50, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": -11, + "y": -10.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 0, + "y": -32, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 100, + "priority": 4, + "focus": 2 + }, + { + "x": 11, + "y": 1, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 6, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": -12.5, + "y": -3.5, + "zoomX": 100, + "zoomY": 100, + "mirror": true, + "visible": true, + "target": 2, + "graphicFrame": 7, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 4.5, + "y": 19, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 0, + "opacity": 100, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": -11, + "y": -14.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 100, + "priority": 4, + "focus": 2 + }, + { + "x": 0, + "y": -36, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 50, + "priority": 4, + "focus": 2 + }, + { + "x": 11, + "y": -3, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 5, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": -12.5, + "y": -7.5, + "zoomX": 100, + "zoomY": 100, + "mirror": true, + "visible": true, + "target": 2, + "graphicFrame": 6, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 4.5, + "y": 15, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 0, + "opacity": 150, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": -11, + "y": -18.5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 50, + "priority": 4, + "focus": 2 + }, + { + "x": 11, + "y": -7, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": -12.5, + "y": -11.5, + "zoomX": 100, + "zoomY": 100, + "mirror": true, + "visible": true, + "target": 2, + "graphicFrame": 6, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 4.5, + "y": 7, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 1, + "opacity": 150, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": -12.5, + "y": -15.5, + "zoomX": 100, + "zoomY": 100, + "mirror": true, + "visible": true, + "target": 2, + "graphicFrame": 5, + "opacity": 150, + "priority": 4, + "focus": 2 + }, + { + "x": 11, + "y": -11, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 100, + "priority": 4, + "focus": 2 + }, + { + "x": 4.5, + "y": 3, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 2, + "opacity": 150, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": -12.5, + "y": -19.5, + "zoomX": 100, + "zoomY": 100, + "mirror": true, + "visible": true, + "target": 2, + "graphicFrame": 5, + "opacity": 100, + "priority": 4, + "focus": 2 + }, + { + "x": 11, + "y": -15, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 50, + "priority": 4, + "focus": 2 + }, + { + "x": 4.5, + "y": -1, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 2, + "opacity": 150, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": -12.5, + "y": -23.5, + "zoomX": 100, + "zoomY": 100, + "mirror": true, + "visible": true, + "target": 2, + "graphicFrame": 5, + "opacity": 50, + "priority": 4, + "focus": 2 + }, + { + "x": 4.5, + "y": -5, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 3, + "opacity": 150, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": 4.5, + "y": -9, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 150, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": 4.5, + "y": -13, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 100, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": 4.5, + "y": -17, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 2, + "graphicFrame": 4, + "opacity": 50, + "priority": 4, + "focus": 2 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 4, + "focus": 3 + }, + { + "x": 128, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 1, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 4, + "focus": 3 + } + ], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [] + ], + "frameTimedEvents": { + "0": [ + { + "frameIndex": 0, + "resourceName": "PRSFX- Haze.wav", + "volume": 100, + "pitch": 85, + "eventType": "AnimTimedSoundEvent" + }, + { + "frameIndex": 0, + "resourceName": "Explosion1.m4a", + "volume": 100, + "pitch": 85, + "eventType": "AnimTimedSoundEvent" + } + ] + }, + "position": 2, + "hue": 0 +} \ No newline at end of file diff --git a/public/images/items.json b/public/images/items.json index 442b93d657b5..dd0cf6837bec 100644 --- a/public/images/items.json +++ b/public/images/items.json @@ -4,8 +4,8 @@ "image": "items.png", "format": "RGBA8888", "size": { - "w": 426, - "h": 426 + "w": 428, + "h": 428 }, "scale": 1, "frames": [ @@ -387,6 +387,27 @@ "h": 28 } }, + { + "filename": "ability_capsule", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 4, + "y": 9, + "w": 24, + "h": 14 + }, + "frame": { + "x": 0, + "y": 414, + "w": 24, + "h": 14 + } + }, { "filename": "ability_charm", "rotated": false, @@ -619,7 +640,7 @@ } }, { - "filename": "lock_capsule", + "filename": "catching_charm", "rotated": false, "trimmed": true, "sourceSize": { @@ -627,16 +648,16 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, - "y": 5, - "w": 19, - "h": 22 + "x": 5, + "y": 4, + "w": 21, + "h": 24 }, "frame": { "x": 407, "y": 0, - "w": 19, - "h": 22 + "w": 21, + "h": 24 } }, { @@ -892,7 +913,7 @@ } }, { - "filename": "mega_bracelet", + "filename": "choice_specs", "rotated": false, "trimmed": true, "sourceSize": { @@ -900,37 +921,16 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, + "x": 4, "y": 8, - "w": 20, - "h": 16 - }, - "frame": { - "x": 22, - "y": 410, - "w": 20, - "h": 16 - } - }, - { - "filename": "relic_band", - "rotated": false, - "trimmed": true, - "sourceSize": { - "w": 32, - "h": 32 - }, - "spriteSourceSize": { - "x": 7, - "y": 9, - "w": 17, - "h": 16 + "w": 24, + "h": 18 }, "frame": { - "x": 42, + "x": 24, "y": 410, - "w": 17, - "h": 16 + "w": 24, + "h": 18 } }, { @@ -955,7 +955,7 @@ } }, { - "filename": "abomasite", + "filename": "mega_bracelet", "rotated": false, "trimmed": true, "sourceSize": { @@ -963,20 +963,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, + "x": 6, "y": 8, - "w": 16, + "w": 20, "h": 16 }, "frame": { "x": 28, "y": 70, - "w": 16, + "w": 20, "h": 16 } }, { - "filename": "absolite", + "filename": "calcium", "rotated": false, "trimmed": true, "sourceSize": { @@ -985,40 +985,19 @@ }, "spriteSourceSize": { "x": 8, - "y": 8, - "w": 16, - "h": 16 - }, - "frame": { - "x": 44, - "y": 70, - "w": 16, - "h": 16 - } - }, - { - "filename": "catching_charm", - "rotated": false, - "trimmed": true, - "sourceSize": { - "w": 32, - "h": 32 - }, - "spriteSourceSize": { - "x": 5, "y": 4, - "w": 21, + "w": 16, "h": 24 }, "frame": { "x": 39, "y": 86, - "w": 21, + "w": 16, "h": 24 } }, { - "filename": "earth_plate", + "filename": "carbos", "rotated": false, "trimmed": true, "sourceSize": { @@ -1026,20 +1005,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, + "x": 8, "y": 4, - "w": 24, + "w": 16, "h": 24 }, "frame": { "x": 39, "y": 110, - "w": 24, + "w": 16, "h": 24 } }, { - "filename": "fist_plate", + "filename": "earth_plate", "rotated": false, "trimmed": true, "sourceSize": { @@ -1060,7 +1039,7 @@ } }, { - "filename": "flame_plate", + "filename": "fist_plate", "rotated": false, "trimmed": true, "sourceSize": { @@ -1081,7 +1060,7 @@ } }, { - "filename": "focus_band", + "filename": "flame_plate", "rotated": false, "trimmed": true, "sourceSize": { @@ -1102,7 +1081,7 @@ } }, { - "filename": "golden_punch", + "filename": "focus_band", "rotated": false, "trimmed": true, "sourceSize": { @@ -1123,7 +1102,7 @@ } }, { - "filename": "gracidea", + "filename": "golden_punch", "rotated": false, "trimmed": true, "sourceSize": { @@ -1144,7 +1123,7 @@ } }, { - "filename": "grip_claw", + "filename": "gracidea", "rotated": false, "trimmed": true, "sourceSize": { @@ -1165,7 +1144,7 @@ } }, { - "filename": "icicle_plate", + "filename": "grip_claw", "rotated": false, "trimmed": true, "sourceSize": { @@ -1186,7 +1165,7 @@ } }, { - "filename": "insect_plate", + "filename": "icicle_plate", "rotated": false, "trimmed": true, "sourceSize": { @@ -1207,7 +1186,7 @@ } }, { - "filename": "iron_plate", + "filename": "insect_plate", "rotated": false, "trimmed": true, "sourceSize": { @@ -1228,7 +1207,7 @@ } }, { - "filename": "lucky_punch", + "filename": "iron_plate", "rotated": false, "trimmed": true, "sourceSize": { @@ -1249,7 +1228,7 @@ } }, { - "filename": "lucky_punch_great", + "filename": "lucky_punch", "rotated": false, "trimmed": true, "sourceSize": { @@ -1270,7 +1249,7 @@ } }, { - "filename": "ability_capsule", + "filename": "abomasite", "rotated": false, "trimmed": true, "sourceSize": { @@ -1278,20 +1257,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, - "y": 9, - "w": 24, - "h": 14 + "x": 8, + "y": 8, + "w": 16, + "h": 16 }, "frame": { - "x": 135, - "y": 22, - "w": 24, - "h": 14 + "x": 48, + "y": 70, + "w": 16, + "h": 16 } }, { - "filename": "calcium", + "filename": "kings_rock", "rotated": false, "trimmed": true, "sourceSize": { @@ -1299,20 +1278,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, + "x": 5, "y": 4, - "w": 16, + "w": 23, "h": 24 }, "frame": { - "x": 59, - "y": 27, - "w": 16, + "x": 48, + "y": 398, + "w": 23, "h": 24 } }, { - "filename": "lucky_punch_master", + "filename": "elixir", "rotated": false, "trimmed": true, "sourceSize": { @@ -1320,20 +1299,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, + "x": 7, "y": 4, - "w": 24, + "w": 18, "h": 24 }, "frame": { - "x": 75, - "y": 26, - "w": 24, + "x": 55, + "y": 86, + "w": 18, "h": 24 } }, { - "filename": "lucky_punch_ultra", + "filename": "ether", "rotated": false, "trimmed": true, "sourceSize": { @@ -1341,20 +1320,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, + "x": 7, "y": 4, - "w": 24, + "w": 18, "h": 24 }, "frame": { - "x": 99, - "y": 26, - "w": 24, + "x": 55, + "y": 110, + "w": 18, "h": 24 } }, { - "filename": "revive", + "filename": "full_restore", "rotated": false, "trimmed": true, "sourceSize": { @@ -1362,20 +1341,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 10, - "y": 8, - "w": 12, - "h": 17 + "x": 7, + "y": 4, + "w": 18, + "h": 24 }, "frame": { - "x": 123, - "y": 26, - "w": 12, - "h": 17 + "x": 63, + "y": 134, + "w": 18, + "h": 24 } }, { - "filename": "big_mushroom", + "filename": "lucky_punch_great", "rotated": false, "trimmed": true, "sourceSize": { @@ -1383,20 +1362,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 6, - "w": 19, - "h": 19 + "x": 4, + "y": 4, + "w": 24, + "h": 24 }, "frame": { - "x": 59, - "y": 51, - "w": 19, - "h": 19 + "x": 63, + "y": 158, + "w": 24, + "h": 24 } }, { - "filename": "clefairy_doll", + "filename": "lucky_punch_master", "rotated": false, "trimmed": true, "sourceSize": { @@ -1405,19 +1384,19 @@ }, "spriteSourceSize": { "x": 4, - "y": 5, + "y": 4, "w": 24, - "h": 23 + "h": 24 }, "frame": { - "x": 78, - "y": 50, + "x": 63, + "y": 182, "w": 24, - "h": 23 + "h": 24 } }, { - "filename": "elixir", + "filename": "lucky_punch_ultra", "rotated": false, "trimmed": true, "sourceSize": { @@ -1425,20 +1404,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, + "x": 4, "y": 4, - "w": 18, + "w": 24, "h": 24 }, "frame": { - "x": 60, - "y": 70, - "w": 18, + "x": 68, + "y": 206, + "w": 24, "h": 24 } }, { - "filename": "coin_case", + "filename": "lustrous_globe", "rotated": false, "trimmed": true, "sourceSize": { @@ -1447,19 +1426,19 @@ }, "spriteSourceSize": { "x": 4, - "y": 5, + "y": 4, "w": 24, - "h": 23 + "h": 24 }, "frame": { - "x": 78, - "y": 73, + "x": 68, + "y": 230, "w": 24, - "h": 23 + "h": 24 } }, { - "filename": "kings_rock", + "filename": "meadow_plate", "rotated": false, "trimmed": true, "sourceSize": { @@ -1467,20 +1446,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, + "x": 4, "y": 4, - "w": 23, + "w": 24, "h": 24 }, "frame": { - "x": 102, - "y": 50, - "w": 23, + "x": 68, + "y": 254, + "w": 24, "h": 24 } }, { - "filename": "berry_pouch", + "filename": "mind_plate", "rotated": false, "trimmed": true, "sourceSize": { @@ -1489,40 +1468,19 @@ }, "spriteSourceSize": { "x": 4, - "y": 5, - "w": 23, - "h": 23 - }, - "frame": { - "x": 102, - "y": 74, - "w": 23, - "h": 23 - } - }, - { - "filename": "aerodactylite", - "rotated": false, - "trimmed": true, - "sourceSize": { - "w": 32, - "h": 32 - }, - "spriteSourceSize": { - "x": 8, - "y": 8, - "w": 16, - "h": 16 + "y": 4, + "w": 24, + "h": 24 }, "frame": { - "x": 60, - "y": 94, - "w": 16, - "h": 16 + "x": 69, + "y": 278, + "w": 24, + "h": 24 } }, { - "filename": "carbos", + "filename": "muscle_band", "rotated": false, "trimmed": true, "sourceSize": { @@ -1530,20 +1488,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, + "x": 4, "y": 4, - "w": 16, + "w": 24, "h": 24 }, "frame": { - "x": 63, - "y": 110, - "w": 16, + "x": 70, + "y": 302, + "w": 24, "h": 24 } }, { - "filename": "ether", + "filename": "pixie_plate", "rotated": false, "trimmed": true, "sourceSize": { @@ -1551,20 +1509,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, + "x": 4, "y": 4, - "w": 18, + "w": 24, "h": 24 }, "frame": { - "x": 63, - "y": 134, - "w": 18, + "x": 70, + "y": 326, + "w": 24, "h": 24 } }, { - "filename": "full_restore", + "filename": "salac_berry", "rotated": false, "trimmed": true, "sourceSize": { @@ -1572,20 +1530,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, + "x": 4, "y": 4, - "w": 18, + "w": 24, "h": 24 }, "frame": { - "x": 63, - "y": 158, - "w": 18, + "x": 70, + "y": 350, + "w": 24, "h": 24 } }, { - "filename": "lustrous_globe", + "filename": "scanner", "rotated": false, "trimmed": true, "sourceSize": { @@ -1599,14 +1557,14 @@ "h": 24 }, "frame": { - "x": 63, - "y": 182, + "x": 70, + "y": 374, "w": 24, "h": 24 } }, { - "filename": "max_revive", + "filename": "reveal_glass", "rotated": false, "trimmed": true, "sourceSize": { @@ -1614,20 +1572,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, + "x": 4, "y": 4, - "w": 22, + "w": 23, "h": 24 }, "frame": { - "x": 68, - "y": 206, - "w": 22, + "x": 71, + "y": 398, + "w": 23, "h": 24 } }, { - "filename": "meadow_plate", + "filename": "clefairy_doll", "rotated": false, "trimmed": true, "sourceSize": { @@ -1636,19 +1594,19 @@ }, "spriteSourceSize": { "x": 4, - "y": 4, + "y": 5, "w": 24, - "h": 24 + "h": 23 }, "frame": { - "x": 68, - "y": 230, + "x": 135, + "y": 22, "w": 24, - "h": 24 + "h": 23 } }, { - "filename": "mind_plate", + "filename": "berry_pouch", "rotated": false, "trimmed": true, "sourceSize": { @@ -1657,19 +1615,19 @@ }, "spriteSourceSize": { "x": 4, - "y": 4, - "w": 24, - "h": 24 + "y": 5, + "w": 23, + "h": 23 }, "frame": { - "x": 68, - "y": 254, - "w": 24, - "h": 24 + "x": 159, + "y": 22, + "w": 23, + "h": 23 } }, { - "filename": "muscle_band", + "filename": "silk_scarf", "rotated": false, "trimmed": true, "sourceSize": { @@ -1683,14 +1641,14 @@ "h": 24 }, "frame": { - "x": 69, - "y": 278, + "x": 182, + "y": 21, "w": 24, "h": 24 } }, { - "filename": "pixie_plate", + "filename": "sky_plate", "rotated": false, "trimmed": true, "sourceSize": { @@ -1704,14 +1662,14 @@ "h": 24 }, "frame": { - "x": 70, - "y": 302, + "x": 206, + "y": 21, "w": 24, "h": 24 } }, { - "filename": "salac_berry", + "filename": "splash_plate", "rotated": false, "trimmed": true, "sourceSize": { @@ -1725,14 +1683,14 @@ "h": 24 }, "frame": { - "x": 70, - "y": 326, + "x": 230, + "y": 21, "w": 24, "h": 24 } }, { - "filename": "scanner", + "filename": "spooky_plate", "rotated": false, "trimmed": true, "sourceSize": { @@ -1746,14 +1704,14 @@ "h": 24 }, "frame": { - "x": 70, - "y": 350, + "x": 254, + "y": 21, "w": 24, "h": 24 } }, { - "filename": "silk_scarf", + "filename": "stone_plate", "rotated": false, "trimmed": true, "sourceSize": { @@ -1767,14 +1725,14 @@ "h": 24 }, "frame": { - "x": 70, - "y": 374, + "x": 278, + "y": 21, "w": 24, "h": 24 } }, { - "filename": "sky_plate", + "filename": "sun_stone", "rotated": false, "trimmed": true, "sourceSize": { @@ -1788,8 +1746,8 @@ "h": 24 }, "frame": { - "x": 59, - "y": 398, + "x": 302, + "y": 21, "w": 24, "h": 24 } @@ -1809,14 +1767,14 @@ "h": 24 }, "frame": { - "x": 83, - "y": 398, + "x": 326, + "y": 21, "w": 16, "h": 24 } }, { - "filename": "reveal_glass", + "filename": "toxic_plate", "rotated": false, "trimmed": true, "sourceSize": { @@ -1826,18 +1784,18 @@ "spriteSourceSize": { "x": 4, "y": 4, - "w": 23, + "w": 24, "h": 24 }, "frame": { - "x": 79, - "y": 96, - "w": 23, + "x": 342, + "y": 20, + "w": 24, "h": 24 } }, { - "filename": "dynamax_band", + "filename": "zap_plate", "rotated": false, "trimmed": true, "sourceSize": { @@ -1847,18 +1805,18 @@ "spriteSourceSize": { "x": 4, "y": 4, - "w": 23, - "h": 23 + "w": 24, + "h": 24 }, "frame": { - "x": 102, - "y": 97, - "w": 23, - "h": 23 + "x": 366, + "y": 20, + "w": 24, + "h": 24 } }, { - "filename": "splash_plate", + "filename": "lure", "rotated": false, "trimmed": true, "sourceSize": { @@ -1866,20 +1824,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, + "x": 8, "y": 4, - "w": 24, + "w": 17, "h": 24 }, "frame": { - "x": 81, - "y": 120, - "w": 24, + "x": 390, + "y": 20, + "w": 17, "h": 24 } }, { - "filename": "spooky_plate", + "filename": "oval_charm", "rotated": false, "trimmed": true, "sourceSize": { @@ -1887,20 +1845,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, + "x": 6, "y": 4, - "w": 24, + "w": 21, "h": 24 }, "frame": { - "x": 81, - "y": 144, - "w": 24, + "x": 407, + "y": 24, + "w": 21, "h": 24 } }, { - "filename": "oval_charm", + "filename": "berry_pot", "rotated": false, "trimmed": true, "sourceSize": { @@ -1908,20 +1866,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 4, - "w": 21, - "h": 24 + "x": 7, + "y": 5, + "w": 18, + "h": 22 }, "frame": { - "x": 105, - "y": 120, - "w": 21, - "h": 24 + "x": 59, + "y": 48, + "w": 18, + "h": 22 } }, { - "filename": "shiny_charm", + "filename": "adamant_crystal", "rotated": false, "trimmed": true, "sourceSize": { @@ -1929,20 +1887,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 4, - "w": 21, - "h": 24 + "x": 4, + "y": 6, + "w": 23, + "h": 21 }, "frame": { - "x": 105, - "y": 144, - "w": 21, - "h": 24 + "x": 59, + "y": 27, + "w": 23, + "h": 21 } }, { - "filename": "stone_plate", + "filename": "coin_case", "rotated": false, "trimmed": true, "sourceSize": { @@ -1951,19 +1909,19 @@ }, "spriteSourceSize": { "x": 4, - "y": 4, + "y": 5, "w": 24, - "h": 24 + "h": 23 }, "frame": { - "x": 87, - "y": 168, + "x": 82, + "y": 26, "w": 24, - "h": 24 + "h": 23 } }, { - "filename": "iron", + "filename": "expert_belt", "rotated": false, "trimmed": true, "sourceSize": { @@ -1971,20 +1929,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, + "x": 4, "y": 4, - "w": 16, - "h": 24 + "w": 24, + "h": 23 }, "frame": { - "x": 111, - "y": 168, - "w": 16, - "h": 24 + "x": 106, + "y": 26, + "w": 24, + "h": 23 } }, { - "filename": "sun_stone", + "filename": "exp_balance", "rotated": false, "trimmed": true, "sourceSize": { @@ -1993,40 +1951,19 @@ }, "spriteSourceSize": { "x": 4, - "y": 4, + "y": 5, "w": 24, - "h": 24 + "h": 22 }, "frame": { - "x": 90, - "y": 192, + "x": 77, + "y": 49, "w": 24, - "h": 24 + "h": 22 } }, { - "filename": "lure", - "rotated": false, - "trimmed": true, - "sourceSize": { - "w": 32, - "h": 32 - }, - "spriteSourceSize": { - "x": 8, - "y": 4, - "w": 17, - "h": 24 - }, - "frame": { - "x": 114, - "y": 192, - "w": 17, - "h": 24 - } - }, - { - "filename": "toxic_plate", + "filename": "exp_share", "rotated": false, "trimmed": true, "sourceSize": { @@ -2035,19 +1972,19 @@ }, "spriteSourceSize": { "x": 4, - "y": 4, + "y": 5, "w": 24, - "h": 24 + "h": 22 }, "frame": { - "x": 92, - "y": 216, + "x": 101, + "y": 49, "w": 24, - "h": 24 + "h": 22 } }, { - "filename": "zap_plate", + "filename": "silver_powder", "rotated": false, "trimmed": true, "sourceSize": { @@ -2056,19 +1993,19 @@ }, "spriteSourceSize": { "x": 4, - "y": 4, + "y": 11, "w": 24, - "h": 24 + "h": 15 }, "frame": { - "x": 92, - "y": 240, + "x": 64, + "y": 71, "w": 24, - "h": 24 + "h": 15 } }, { - "filename": "max_elixir", + "filename": "dragon_scale", "rotated": false, "trimmed": true, "sourceSize": { @@ -2076,20 +2013,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, - "y": 4, - "w": 18, - "h": 24 + "x": 4, + "y": 8, + "w": 24, + "h": 18 }, "frame": { - "x": 116, - "y": 216, - "w": 18, - "h": 24 + "x": 88, + "y": 71, + "w": 24, + "h": 18 } }, { - "filename": "max_ether", + "filename": "full_heal", "rotated": false, "trimmed": true, "sourceSize": { @@ -2097,20 +2034,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, + "x": 9, "y": 4, - "w": 18, - "h": 24 + "w": 15, + "h": 23 }, "frame": { - "x": 116, - "y": 240, - "w": 18, - "h": 24 + "x": 73, + "y": 86, + "w": 15, + "h": 23 } }, { - "filename": "expert_belt", + "filename": "golden_net", "rotated": false, "trimmed": true, "sourceSize": { @@ -2119,19 +2056,19 @@ }, "spriteSourceSize": { "x": 4, - "y": 4, + "y": 5, "w": 24, - "h": 23 + "h": 21 }, "frame": { - "x": 93, - "y": 264, + "x": 88, + "y": 89, "w": 24, - "h": 23 + "h": 21 } }, { - "filename": "black_belt", + "filename": "max_revive", "rotated": false, "trimmed": true, "sourceSize": { @@ -2142,17 +2079,17 @@ "x": 5, "y": 4, "w": 22, - "h": 23 + "h": 24 }, "frame": { - "x": 117, - "y": 264, + "x": 73, + "y": 110, "w": 22, - "h": 23 + "h": 24 } }, { - "filename": "silver_powder", + "filename": "iron", "rotated": false, "trimmed": true, "sourceSize": { @@ -2160,20 +2097,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, - "y": 11, - "w": 24, - "h": 15 + "x": 8, + "y": 4, + "w": 16, + "h": 24 }, "frame": { - "x": 93, - "y": 287, - "w": 24, - "h": 15 + "x": 81, + "y": 134, + "w": 16, + "h": 24 } }, { - "filename": "griseous_core", + "filename": "max_elixir", "rotated": false, "trimmed": true, "sourceSize": { @@ -2181,20 +2118,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, - "y": 5, - "w": 23, - "h": 23 + "x": 7, + "y": 4, + "w": 18, + "h": 24 }, "frame": { - "x": 94, - "y": 302, - "w": 23, - "h": 23 + "x": 95, + "y": 110, + "w": 18, + "h": 24 } }, { - "filename": "hearthflame_mask", + "filename": "max_ether", "rotated": false, "trimmed": true, "sourceSize": { @@ -2202,20 +2139,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, + "x": 7, "y": 4, - "w": 24, - "h": 23 + "w": 18, + "h": 24 }, "frame": { - "x": 94, - "y": 325, - "w": 24, - "h": 23 + "x": 97, + "y": 134, + "w": 18, + "h": 24 } }, { - "filename": "leppa_berry", + "filename": "hearthflame_mask", "rotated": false, "trimmed": true, "sourceSize": { @@ -2224,19 +2161,19 @@ }, "spriteSourceSize": { "x": 4, - "y": 5, + "y": 4, "w": 24, "h": 23 }, "frame": { - "x": 94, - "y": 348, + "x": 87, + "y": 158, "w": 24, "h": 23 } }, { - "filename": "scope_lens", + "filename": "leppa_berry", "rotated": false, "trimmed": true, "sourceSize": { @@ -2250,33 +2187,12 @@ "h": 23 }, "frame": { - "x": 94, - "y": 371, + "x": 87, + "y": 181, "w": 24, "h": 23 } }, - { - "filename": "bug_tera_shard", - "rotated": false, - "trimmed": true, - "sourceSize": { - "w": 32, - "h": 32 - }, - "spriteSourceSize": { - "x": 6, - "y": 4, - "w": 22, - "h": 23 - }, - "frame": { - "x": 117, - "y": 287, - "w": 22, - "h": 23 - } - }, { "filename": "red_orb", "rotated": false, @@ -2292,14 +2208,14 @@ "h": 24 }, "frame": { - "x": 99, - "y": 394, + "x": 92, + "y": 204, "w": 20, "h": 24 } }, { - "filename": "candy_overlay", + "filename": "shiny_charm", "rotated": false, "trimmed": true, "sourceSize": { @@ -2307,20 +2223,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, - "y": 12, - "w": 16, - "h": 15 + "x": 6, + "y": 4, + "w": 21, + "h": 24 }, "frame": { - "x": 117, - "y": 310, - "w": 16, - "h": 15 + "x": 92, + "y": 228, + "w": 21, + "h": 24 } }, { - "filename": "max_lure", + "filename": "black_belt", "rotated": false, "trimmed": true, "sourceSize": { @@ -2328,20 +2244,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, + "x": 5, "y": 4, - "w": 17, - "h": 24 + "w": 22, + "h": 23 }, "frame": { - "x": 118, - "y": 325, - "w": 17, - "h": 24 + "x": 92, + "y": 252, + "w": 22, + "h": 23 } }, { - "filename": "max_potion", + "filename": "bug_tera_shard", "rotated": false, "trimmed": true, "sourceSize": { @@ -2349,20 +2265,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, + "x": 6, "y": 4, - "w": 18, - "h": 24 + "w": 22, + "h": 23 }, "frame": { - "x": 118, - "y": 349, - "w": 18, - "h": 24 + "x": 93, + "y": 275, + "w": 22, + "h": 23 } }, { - "filename": "adamant_crystal", + "filename": "dark_tera_shard", "rotated": false, "trimmed": true, "sourceSize": { @@ -2370,20 +2286,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, - "y": 6, - "w": 23, - "h": 21 + "x": 6, + "y": 4, + "w": 22, + "h": 23 }, "frame": { - "x": 118, - "y": 373, - "w": 23, - "h": 21 + "x": 94, + "y": 298, + "w": 22, + "h": 23 } }, { - "filename": "dark_tera_shard", + "filename": "dragon_tera_shard", "rotated": false, "trimmed": true, "sourceSize": { @@ -2397,14 +2313,14 @@ "h": 23 }, "frame": { - "x": 119, - "y": 394, + "x": 94, + "y": 321, "w": 22, "h": 23 } }, { - "filename": "choice_specs", + "filename": "dynamax_band", "rotated": false, "trimmed": true, "sourceSize": { @@ -2413,19 +2329,19 @@ }, "spriteSourceSize": { "x": 4, - "y": 8, - "w": 24, - "h": 18 + "y": 4, + "w": 23, + "h": 23 }, "frame": { - "x": 135, - "y": 36, - "w": 24, - "h": 18 + "x": 94, + "y": 344, + "w": 23, + "h": 23 } }, { - "filename": "twisted_spoon", + "filename": "griseous_core", "rotated": false, "trimmed": true, "sourceSize": { @@ -2433,20 +2349,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, + "x": 5, "y": 5, - "w": 24, + "w": 23, "h": 23 }, "frame": { - "x": 125, - "y": 54, - "w": 24, + "x": 94, + "y": 367, + "w": 23, "h": 23 } }, { - "filename": "exp_balance", + "filename": "leek", "rotated": false, "trimmed": true, "sourceSize": { @@ -2456,60 +2372,18 @@ "spriteSourceSize": { "x": 4, "y": 5, - "w": 24, - "h": 22 - }, - "frame": { - "x": 125, - "y": 77, - "w": 24, - "h": 22 - } - }, - { - "filename": "amulet_coin", - "rotated": false, - "trimmed": true, - "sourceSize": { - "w": 32, - "h": 32 - }, - "spriteSourceSize": { - "x": 6, - "y": 5, - "w": 23, - "h": 21 - }, - "frame": { - "x": 125, - "y": 99, "w": 23, - "h": 21 - } - }, - { - "filename": "dragon_tera_shard", - "rotated": false, - "trimmed": true, - "sourceSize": { - "w": 32, - "h": 32 - }, - "spriteSourceSize": { - "x": 6, - "y": 4, - "w": 22, "h": 23 }, "frame": { - "x": 126, - "y": 120, - "w": 22, + "x": 94, + "y": 390, + "w": 23, "h": 23 } }, { - "filename": "electric_tera_shard", + "filename": "candy_overlay", "rotated": false, "trimmed": true, "sourceSize": { @@ -2517,20 +2391,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 4, - "w": 22, - "h": 23 + "x": 8, + "y": 12, + "w": 16, + "h": 15 }, "frame": { - "x": 126, - "y": 143, - "w": 22, - "h": 23 + "x": 94, + "y": 413, + "w": 16, + "h": 15 } }, { - "filename": "dragon_fang", + "filename": "eviolite", "rotated": false, "trimmed": true, "sourceSize": { @@ -2538,20 +2412,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, - "y": 5, - "w": 21, - "h": 23 + "x": 8, + "y": 8, + "w": 15, + "h": 15 }, "frame": { - "x": 127, - "y": 166, - "w": 21, - "h": 23 + "x": 110, + "y": 413, + "w": 15, + "h": 15 } }, { - "filename": "super_lure", + "filename": "max_lure", "rotated": false, "trimmed": true, "sourceSize": { @@ -2565,14 +2439,14 @@ "h": 24 }, "frame": { - "x": 131, - "y": 189, + "x": 112, + "y": 71, "w": 17, "h": 24 } }, { - "filename": "max_repel", + "filename": "bug_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -2580,20 +2454,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, - "y": 4, - "w": 16, - "h": 24 + "x": 5, + "y": 5, + "w": 22, + "h": 22 }, "frame": { - "x": 134, - "y": 213, - "w": 16, - "h": 24 + "x": 125, + "y": 49, + "w": 22, + "h": 22 } }, { - "filename": "pp_max", + "filename": "scope_lens", "rotated": false, "trimmed": true, "sourceSize": { @@ -2601,20 +2475,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, - "y": 4, - "w": 16, - "h": 24 + "x": 4, + "y": 5, + "w": 24, + "h": 23 }, "frame": { - "x": 134, - "y": 237, - "w": 16, - "h": 24 + "x": 147, + "y": 45, + "w": 24, + "h": 23 } }, { - "filename": "pp_up", + "filename": "twisted_spoon", "rotated": false, "trimmed": true, "sourceSize": { @@ -2622,20 +2496,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, - "y": 4, - "w": 16, - "h": 24 + "x": 4, + "y": 5, + "w": 24, + "h": 23 }, "frame": { - "x": 149, - "y": 54, - "w": 16, - "h": 24 + "x": 171, + "y": 45, + "w": 24, + "h": 23 } }, { - "filename": "auspicious_armor", + "filename": "macho_brace", "rotated": false, "trimmed": true, "sourceSize": { @@ -2646,17 +2520,17 @@ "x": 4, "y": 5, "w": 23, - "h": 21 + "h": 23 }, "frame": { - "x": 149, - "y": 78, + "x": 195, + "y": 45, "w": 23, - "h": 21 + "h": 23 } }, { - "filename": "exp_share", + "filename": "peat_block", "rotated": false, "trimmed": true, "sourceSize": { @@ -2670,14 +2544,14 @@ "h": 22 }, "frame": { - "x": 148, - "y": 99, + "x": 218, + "y": 45, "w": 24, "h": 22 } }, { - "filename": "leek", + "filename": "healing_charm", "rotated": false, "trimmed": true, "sourceSize": { @@ -2685,16 +2559,16 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, + "x": 5, "y": 5, "w": 23, - "h": 23 + "h": 22 }, "frame": { - "x": 148, - "y": 121, + "x": 242, + "y": 45, "w": 23, - "h": 23 + "h": 22 } }, { @@ -2712,8 +2586,8 @@ "h": 23 }, "frame": { - "x": 148, - "y": 144, + "x": 265, + "y": 45, "w": 23, "h": 23 } @@ -2733,14 +2607,14 @@ "h": 23 }, "frame": { - "x": 148, - "y": 167, + "x": 288, + "y": 45, "w": 23, "h": 23 } }, { - "filename": "fairy_tera_shard", + "filename": "electric_tera_shard", "rotated": false, "trimmed": true, "sourceSize": { @@ -2754,14 +2628,35 @@ "h": 23 }, "frame": { - "x": 148, - "y": 190, + "x": 311, + "y": 45, "w": 22, "h": 23 } }, { - "filename": "fighting_tera_shard", + "filename": "max_potion", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 7, + "y": 4, + "w": 18, + "h": 24 + }, + "frame": { + "x": 129, + "y": 71, + "w": 18, + "h": 24 + } + }, + { + "filename": "fairy_tera_shard", "rotated": false, "trimmed": true, "sourceSize": { @@ -2775,14 +2670,14 @@ "h": 23 }, "frame": { - "x": 150, - "y": 213, + "x": 147, + "y": 68, "w": 22, "h": 23 } }, { - "filename": "fire_stone", + "filename": "fighting_tera_shard", "rotated": false, "trimmed": true, "sourceSize": { @@ -2790,20 +2685,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, - "y": 5, + "x": 6, + "y": 4, "w": 22, "h": 23 }, "frame": { - "x": 150, - "y": 236, + "x": 169, + "y": 68, "w": 22, "h": 23 } }, { - "filename": "protein", + "filename": "fire_stone", "rotated": false, "trimmed": true, "sourceSize": { @@ -2811,20 +2706,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, - "y": 4, - "w": 16, - "h": 24 + "x": 5, + "y": 5, + "w": 22, + "h": 23 }, "frame": { - "x": 139, - "y": 261, - "w": 16, - "h": 24 + "x": 191, + "y": 68, + "w": 22, + "h": 23 } }, { - "filename": "repel", + "filename": "dragon_fang", "rotated": false, "trimmed": true, "sourceSize": { @@ -2832,16 +2727,16 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, - "y": 4, - "w": 16, - "h": 24 + "x": 5, + "y": 5, + "w": 21, + "h": 23 }, "frame": { - "x": 139, - "y": 285, - "w": 16, - "h": 24 + "x": 333, + "y": 45, + "w": 21, + "h": 23 } }, { @@ -2859,8 +2754,8 @@ "h": 23 }, "frame": { - "x": 155, - "y": 259, + "x": 354, + "y": 44, "w": 22, "h": 23 } @@ -2880,14 +2775,14 @@ "h": 23 }, "frame": { - "x": 155, - "y": 282, + "x": 376, + "y": 44, "w": 22, "h": 23 } }, { - "filename": "super_repel", + "filename": "icy_reins_of_unity", "rotated": false, "trimmed": true, "sourceSize": { @@ -2895,20 +2790,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, - "y": 4, - "w": 16, - "h": 24 + "x": 4, + "y": 7, + "w": 24, + "h": 20 }, "frame": { - "x": 159, - "y": 22, - "w": 16, - "h": 24 + "x": 398, + "y": 48, + "w": 24, + "h": 20 } }, { - "filename": "peat_block", + "filename": "prism_scale", "rotated": false, "trimmed": true, "sourceSize": { @@ -2916,20 +2811,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, - "y": 5, - "w": 24, - "h": 22 + "x": 9, + "y": 8, + "w": 15, + "h": 15 }, "frame": { - "x": 175, - "y": 21, - "w": 24, - "h": 22 + "x": 112, + "y": 95, + "w": 15, + "h": 15 } }, { - "filename": "healing_charm", + "filename": "max_repel", "rotated": false, "trimmed": true, "sourceSize": { @@ -2937,20 +2832,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, - "y": 5, - "w": 23, - "h": 22 - }, - "frame": { - "x": 199, - "y": 21, - "w": 23, - "h": 22 + "x": 8, + "y": 4, + "w": 16, + "h": 24 + }, + "frame": { + "x": 113, + "y": 110, + "w": 16, + "h": 24 } }, { - "filename": "rusted_sword", + "filename": "pp_max", "rotated": false, "trimmed": true, "sourceSize": { @@ -2958,20 +2853,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, - "y": 5, - "w": 23, - "h": 22 + "x": 8, + "y": 4, + "w": 16, + "h": 24 }, "frame": { - "x": 222, - "y": 21, - "w": 23, - "h": 22 + "x": 115, + "y": 134, + "w": 16, + "h": 24 } }, { - "filename": "bug_memory", + "filename": "focus_sash", "rotated": false, "trimmed": true, "sourceSize": { @@ -2980,19 +2875,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 5, + "y": 4, "w": 22, - "h": 22 + "h": 23 }, "frame": { - "x": 245, - "y": 21, + "x": 111, + "y": 158, "w": 22, - "h": 22 + "h": 23 } }, { - "filename": "charcoal", + "filename": "ghost_tera_shard", "rotated": false, "trimmed": true, "sourceSize": { @@ -3000,20 +2895,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, - "y": 5, + "x": 6, + "y": 4, "w": 22, - "h": 22 + "h": 23 }, "frame": { - "x": 267, - "y": 21, + "x": 111, + "y": 181, "w": 22, - "h": 22 + "h": 23 } }, { - "filename": "dark_memory", + "filename": "grass_tera_shard", "rotated": false, "trimmed": true, "sourceSize": { @@ -3021,20 +2916,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, - "y": 5, + "x": 6, + "y": 4, "w": 22, - "h": 22 + "h": 23 }, "frame": { - "x": 289, - "y": 21, + "x": 112, + "y": 204, "w": 22, - "h": 22 + "h": 23 } }, { - "filename": "dire_hit", + "filename": "ground_tera_shard", "rotated": false, "trimmed": true, "sourceSize": { @@ -3042,20 +2937,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, - "y": 5, + "x": 6, + "y": 4, "w": 22, - "h": 22 + "h": 23 }, "frame": { - "x": 311, - "y": 21, + "x": 113, + "y": 227, "w": 22, - "h": 22 + "h": 23 } }, { - "filename": "focus_sash", + "filename": "ice_tera_shard", "rotated": false, "trimmed": true, "sourceSize": { @@ -3063,20 +2958,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, + "x": 6, "y": 4, "w": 22, "h": 23 }, "frame": { - "x": 333, - "y": 20, + "x": 114, + "y": 250, "w": 22, "h": 23 } }, { - "filename": "ghost_tera_shard", + "filename": "lansat_berry", "rotated": false, "trimmed": true, "sourceSize": { @@ -3084,20 +2979,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, + "x": 5, "y": 4, - "w": 22, + "w": 21, "h": 23 }, "frame": { - "x": 355, - "y": 20, - "w": 22, + "x": 115, + "y": 273, + "w": 21, "h": 23 } }, { - "filename": "grass_tera_shard", + "filename": "leaf_stone", "rotated": false, "trimmed": true, "sourceSize": { @@ -3105,20 +3000,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 4, - "w": 22, + "x": 5, + "y": 5, + "w": 21, "h": 23 }, "frame": { - "x": 377, - "y": 20, - "w": 22, + "x": 116, + "y": 296, + "w": 21, "h": 23 } }, { - "filename": "icy_reins_of_unity", + "filename": "never_melt_ice", "rotated": false, "trimmed": true, "sourceSize": { @@ -3126,20 +3021,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, - "y": 7, - "w": 24, - "h": 20 + "x": 5, + "y": 5, + "w": 22, + "h": 23 }, "frame": { - "x": 399, - "y": 22, - "w": 24, - "h": 20 + "x": 116, + "y": 319, + "w": 22, + "h": 23 } }, { - "filename": "dragon_scale", + "filename": "normal_tera_shard", "rotated": false, "trimmed": true, "sourceSize": { @@ -3147,20 +3042,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, - "y": 8, - "w": 24, - "h": 18 + "x": 6, + "y": 4, + "w": 22, + "h": 23 }, "frame": { - "x": 175, - "y": 43, - "w": 24, - "h": 18 + "x": 117, + "y": 342, + "w": 22, + "h": 23 } }, { - "filename": "metal_powder", + "filename": "petaya_berry", "rotated": false, "trimmed": true, "sourceSize": { @@ -3168,20 +3063,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, - "y": 6, - "w": 24, - "h": 20 + "x": 5, + "y": 5, + "w": 22, + "h": 23 }, "frame": { - "x": 199, - "y": 43, - "w": 24, - "h": 20 + "x": 117, + "y": 365, + "w": 22, + "h": 23 } }, { - "filename": "quick_powder", + "filename": "poison_tera_shard", "rotated": false, "trimmed": true, "sourceSize": { @@ -3189,20 +3084,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, - "y": 6, - "w": 24, - "h": 20 + "x": 6, + "y": 4, + "w": 22, + "h": 23 }, "frame": { - "x": 223, - "y": 43, - "w": 24, - "h": 20 + "x": 117, + "y": 388, + "w": 22, + "h": 23 } }, { - "filename": "rusted_shield", + "filename": "black_glasses", "rotated": false, "trimmed": true, "sourceSize": { @@ -3211,19 +3106,19 @@ }, "spriteSourceSize": { "x": 4, - "y": 6, - "w": 24, - "h": 20 + "y": 8, + "w": 23, + "h": 17 }, "frame": { - "x": 247, - "y": 43, - "w": 24, - "h": 20 + "x": 125, + "y": 411, + "w": 23, + "h": 17 } }, { - "filename": "sacred_ash", + "filename": "hyper_potion", "rotated": false, "trimmed": true, "sourceSize": { @@ -3231,20 +3126,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, - "y": 7, - "w": 24, - "h": 20 + "x": 8, + "y": 5, + "w": 17, + "h": 23 }, "frame": { - "x": 271, - "y": 43, - "w": 24, - "h": 20 + "x": 213, + "y": 68, + "w": 17, + "h": 23 } }, { - "filename": "shadow_reins_of_unity", + "filename": "psychic_tera_shard", "rotated": false, "trimmed": true, "sourceSize": { @@ -3252,20 +3147,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, - "y": 7, - "w": 24, - "h": 20 + "x": 6, + "y": 4, + "w": 22, + "h": 23 }, "frame": { - "x": 295, - "y": 43, - "w": 24, - "h": 20 + "x": 230, + "y": 67, + "w": 22, + "h": 23 } }, { - "filename": "soft_sand", + "filename": "rusted_sword", "rotated": false, "trimmed": true, "sourceSize": { @@ -3274,19 +3169,19 @@ }, "spriteSourceSize": { "x": 4, - "y": 7, - "w": 24, - "h": 20 + "y": 5, + "w": 23, + "h": 22 }, "frame": { - "x": 319, - "y": 43, - "w": 24, - "h": 20 + "x": 252, + "y": 68, + "w": 23, + "h": 22 } }, { - "filename": "binding_band", + "filename": "amulet_coin", "rotated": false, "trimmed": true, "sourceSize": { @@ -3294,20 +3189,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, - "y": 6, + "x": 6, + "y": 5, "w": 23, - "h": 20 + "h": 21 }, "frame": { - "x": 343, - "y": 43, + "x": 275, + "y": 68, "w": 23, - "h": 20 + "h": 21 } }, { - "filename": "moon_stone", + "filename": "auspicious_armor", "rotated": false, "trimmed": true, "sourceSize": { @@ -3316,19 +3211,19 @@ }, "spriteSourceSize": { "x": 4, - "y": 6, + "y": 5, "w": 23, "h": 21 }, "frame": { - "x": 366, - "y": 43, + "x": 298, + "y": 68, "w": 23, "h": 21 } }, { - "filename": "black_glasses", + "filename": "metal_powder", "rotated": false, "trimmed": true, "sourceSize": { @@ -3337,19 +3232,19 @@ }, "spriteSourceSize": { "x": 4, - "y": 8, - "w": 23, - "h": 17 + "y": 6, + "w": 24, + "h": 20 }, "frame": { - "x": 165, - "y": 61, - "w": 23, - "h": 17 + "x": 321, + "y": 68, + "w": 24, + "h": 20 } }, { - "filename": "unknown", + "filename": "apicot_berry", "rotated": false, "trimmed": true, "sourceSize": { @@ -3357,20 +3252,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, - "y": 4, - "w": 16, - "h": 24 + "x": 6, + "y": 6, + "w": 19, + "h": 20 }, "frame": { - "x": 172, - "y": 78, - "w": 16, - "h": 24 + "x": 345, + "y": 68, + "w": 19, + "h": 20 } }, { - "filename": "apicot_berry", + "filename": "moon_stone", "rotated": false, "trimmed": true, "sourceSize": { @@ -3378,20 +3273,41 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, + "x": 4, "y": 6, - "w": 19, + "w": 23, + "h": 21 + }, + "frame": { + "x": 364, + "y": 67, + "w": 23, + "h": 21 + } + }, + { + "filename": "quick_powder", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 4, + "y": 6, + "w": 24, "h": 20 }, "frame": { - "x": 172, - "y": 102, - "w": 19, + "x": 387, + "y": 68, + "w": 24, "h": 20 } }, { - "filename": "ground_tera_shard", + "filename": "super_lure", "rotated": false, "trimmed": true, "sourceSize": { @@ -3399,20 +3315,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, + "x": 8, "y": 4, - "w": 22, - "h": 23 + "w": 17, + "h": 24 }, "frame": { - "x": 171, - "y": 122, - "w": 22, - "h": 23 + "x": 411, + "y": 68, + "w": 17, + "h": 24 } }, { - "filename": "ice_tera_shard", + "filename": "rusted_shield", "rotated": false, "trimmed": true, "sourceSize": { @@ -3420,20 +3336,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 4, - "w": 22, - "h": 23 + "x": 4, + "y": 6, + "w": 24, + "h": 20 }, "frame": { - "x": 171, - "y": 145, - "w": 22, - "h": 23 + "x": 147, + "y": 91, + "w": 24, + "h": 20 } }, { - "filename": "dna_splicers", + "filename": "sacred_ash", "rotated": false, "trimmed": true, "sourceSize": { @@ -3441,20 +3357,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, - "y": 5, - "w": 22, - "h": 22 + "x": 4, + "y": 7, + "w": 24, + "h": 20 }, "frame": { "x": 171, - "y": 168, - "w": 22, - "h": 22 + "y": 91, + "w": 24, + "h": 20 } }, { - "filename": "never_melt_ice", + "filename": "shadow_reins_of_unity", "rotated": false, "trimmed": true, "sourceSize": { @@ -3462,20 +3378,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, - "y": 5, - "w": 22, - "h": 23 + "x": 4, + "y": 7, + "w": 24, + "h": 20 }, "frame": { - "x": 170, - "y": 190, - "w": 22, - "h": 23 + "x": 195, + "y": 91, + "w": 24, + "h": 20 } }, { - "filename": "lansat_berry", + "filename": "sachet", "rotated": false, "trimmed": true, "sourceSize": { @@ -3483,20 +3399,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, + "x": 6, "y": 4, - "w": 21, + "w": 18, "h": 23 }, "frame": { - "x": 172, - "y": 213, - "w": 21, + "x": 129, + "y": 95, + "w": 18, "h": 23 } }, { - "filename": "leaf_stone", + "filename": "relic_band", "rotated": false, "trimmed": true, "sourceSize": { @@ -3504,20 +3420,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, - "y": 5, - "w": 21, - "h": 23 + "x": 7, + "y": 9, + "w": 17, + "h": 16 }, "frame": { - "x": 172, - "y": 236, - "w": 21, - "h": 23 + "x": 129, + "y": 118, + "w": 17, + "h": 16 } }, { - "filename": "zinc", + "filename": "pp_up", "rotated": false, "trimmed": true, "sourceSize": { @@ -3531,14 +3447,14 @@ "h": 24 }, "frame": { - "x": 177, - "y": 259, + "x": 131, + "y": 134, "w": 16, "h": 24 } }, { - "filename": "berry_pot", + "filename": "protein", "rotated": false, "trimmed": true, "sourceSize": { @@ -3546,20 +3462,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, - "y": 5, - "w": 18, - "h": 22 + "x": 8, + "y": 4, + "w": 16, + "h": 24 }, "frame": { - "x": 177, - "y": 283, - "w": 18, - "h": 22 + "x": 133, + "y": 158, + "w": 16, + "h": 24 } }, { - "filename": "normal_tera_shard", + "filename": "charcoal", "rotated": false, "trimmed": true, "sourceSize": { @@ -3567,20 +3483,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 4, + "x": 5, + "y": 5, "w": 22, - "h": 23 + "h": 22 }, "frame": { - "x": 188, - "y": 63, + "x": 133, + "y": 182, "w": 22, - "h": 23 + "h": 22 } }, { - "filename": "petaya_berry", + "filename": "reaper_cloth", "rotated": false, "trimmed": true, "sourceSize": { @@ -3594,14 +3510,14 @@ "h": 23 }, "frame": { - "x": 210, - "y": 63, + "x": 134, + "y": 204, "w": 22, "h": 23 } }, { - "filename": "poison_tera_shard", + "filename": "rock_tera_shard", "rotated": false, "trimmed": true, "sourceSize": { @@ -3615,14 +3531,14 @@ "h": 23 }, "frame": { - "x": 232, - "y": 63, + "x": 135, + "y": 227, "w": 22, "h": 23 } }, { - "filename": "psychic_tera_shard", + "filename": "sharp_beak", "rotated": false, "trimmed": true, "sourceSize": { @@ -3630,20 +3546,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 4, - "w": 22, + "x": 5, + "y": 5, + "w": 21, "h": 23 }, "frame": { - "x": 254, - "y": 63, - "w": 22, + "x": 136, + "y": 250, + "w": 21, "h": 23 } }, { - "filename": "reaper_cloth", + "filename": "steel_tera_shard", "rotated": false, "trimmed": true, "sourceSize": { @@ -3651,20 +3567,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, - "y": 5, + "x": 6, + "y": 4, "w": 22, "h": 23 }, "frame": { - "x": 276, - "y": 63, + "x": 136, + "y": 273, "w": 22, "h": 23 } }, { - "filename": "rock_tera_shard", + "filename": "stellar_tera_shard", "rotated": false, "trimmed": true, "sourceSize": { @@ -3678,14 +3594,14 @@ "h": 23 }, "frame": { - "x": 298, - "y": 63, + "x": 137, + "y": 296, "w": 22, "h": 23 } }, { - "filename": "steel_tera_shard", + "filename": "water_tera_shard", "rotated": false, "trimmed": true, "sourceSize": { @@ -3699,14 +3615,14 @@ "h": 23 }, "frame": { - "x": 320, - "y": 63, + "x": 138, + "y": 319, "w": 22, "h": 23 } }, { - "filename": "stellar_tera_shard", + "filename": "whipped_dream", "rotated": false, "trimmed": true, "sourceSize": { @@ -3714,20 +3630,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, + "x": 5, "y": 4, - "w": 22, + "w": 21, "h": 23 }, "frame": { - "x": 342, - "y": 63, - "w": 22, + "x": 139, + "y": 342, + "w": 21, "h": 23 } }, { - "filename": "dragon_memory", + "filename": "wide_lens", "rotated": false, "trimmed": true, "sourceSize": { @@ -3736,19 +3652,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 5, + "y": 4, "w": 22, - "h": 22 + "h": 23 }, "frame": { - "x": 364, - "y": 64, + "x": 139, + "y": 365, "w": 22, - "h": 22 + "h": 23 } }, { - "filename": "aggronite", + "filename": "mystic_water", "rotated": false, "trimmed": true, "sourceSize": { @@ -3756,16 +3672,16 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, - "y": 8, - "w": 16, - "h": 16 + "x": 6, + "y": 5, + "w": 20, + "h": 23 }, "frame": { - "x": 188, - "y": 86, - "w": 16, - "h": 16 + "x": 139, + "y": 388, + "w": 20, + "h": 23 } }, { @@ -3783,14 +3699,14 @@ "h": 17 }, "frame": { - "x": 204, - "y": 86, + "x": 148, + "y": 411, "w": 23, "h": 17 } }, { - "filename": "chill_drive", + "filename": "potion", "rotated": false, "trimmed": true, "sourceSize": { @@ -3798,20 +3714,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, - "y": 8, - "w": 23, - "h": 17 + "x": 8, + "y": 5, + "w": 17, + "h": 23 }, "frame": { - "x": 227, - "y": 86, - "w": 23, - "h": 17 + "x": 159, + "y": 388, + "w": 17, + "h": 23 } }, { - "filename": "coupon", + "filename": "chill_drive", "rotated": false, "trimmed": true, "sourceSize": { @@ -3820,40 +3736,40 @@ }, "spriteSourceSize": { "x": 4, - "y": 7, + "y": 8, "w": 23, - "h": 19 + "h": 17 }, "frame": { - "x": 250, - "y": 86, + "x": 171, + "y": 411, "w": 23, - "h": 19 + "h": 17 } }, { - "filename": "golden_mystic_ticket", + "filename": "berry_juice", "rotated": false, "trimmed": true, "sourceSize": { - "w": 32, - "h": 32 + "w": 24, + "h": 23 }, "spriteSourceSize": { - "x": 4, - "y": 7, - "w": 23, - "h": 19 + "x": 1, + "y": 1, + "w": 22, + "h": 21 }, "frame": { - "x": 273, - "y": 86, - "w": 23, - "h": 19 + "x": 219, + "y": 91, + "w": 22, + "h": 21 } }, { - "filename": "mystic_ticket", + "filename": "dark_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -3861,20 +3777,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, - "y": 7, - "w": 23, - "h": 19 + "x": 5, + "y": 5, + "w": 22, + "h": 22 }, "frame": { - "x": 296, - "y": 86, - "w": 23, - "h": 19 + "x": 241, + "y": 90, + "w": 22, + "h": 22 } }, { - "filename": "n_lunarizer", + "filename": "dire_hit", "rotated": false, "trimmed": true, "sourceSize": { @@ -3882,20 +3798,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, - "y": 6, - "w": 23, - "h": 21 + "x": 5, + "y": 5, + "w": 22, + "h": 22 }, "frame": { - "x": 319, - "y": 86, - "w": 23, - "h": 21 + "x": 263, + "y": 90, + "w": 22, + "h": 22 } }, { - "filename": "n_solarizer", + "filename": "dna_splicers", "rotated": false, "trimmed": true, "sourceSize": { @@ -3903,20 +3819,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, - "y": 6, - "w": 23, - "h": 21 + "x": 5, + "y": 5, + "w": 22, + "h": 22 }, "frame": { - "x": 342, - "y": 86, - "w": 23, - "h": 21 + "x": 285, + "y": 89, + "w": 22, + "h": 22 } }, { - "filename": "deep_sea_tooth", + "filename": "dragon_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -3925,19 +3841,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 6, + "y": 5, "w": 22, - "h": 21 + "h": 22 }, "frame": { - "x": 365, - "y": 86, + "x": 307, + "y": 89, "w": 22, - "h": 21 + "h": 22 } }, { - "filename": "dawn_stone", + "filename": "electirizer", "rotated": false, "trimmed": true, "sourceSize": { @@ -3945,20 +3861,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 6, - "w": 20, - "h": 21 + "x": 5, + "y": 5, + "w": 22, + "h": 22 }, "frame": { - "x": 389, - "y": 43, - "w": 20, - "h": 21 + "x": 329, + "y": 88, + "w": 22, + "h": 22 } }, { - "filename": "hyper_potion", + "filename": "electric_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -3966,20 +3882,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, + "x": 5, "y": 5, - "w": 17, - "h": 23 + "w": 22, + "h": 22 }, "frame": { - "x": 409, - "y": 42, - "w": 17, - "h": 23 + "x": 351, + "y": 88, + "w": 22, + "h": 22 } }, { - "filename": "electirizer", + "filename": "enigma_berry", "rotated": false, "trimmed": true, "sourceSize": { @@ -3993,14 +3909,14 @@ "h": 22 }, "frame": { - "x": 386, - "y": 64, + "x": 373, + "y": 88, "w": 22, "h": 22 } }, { - "filename": "sachet", + "filename": "repel", "rotated": false, "trimmed": true, "sourceSize": { @@ -4008,20 +3924,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, + "x": 8, "y": 4, - "w": 18, - "h": 23 + "w": 16, + "h": 24 }, "frame": { - "x": 408, - "y": 65, - "w": 18, - "h": 23 + "x": 395, + "y": 88, + "w": 16, + "h": 24 } }, { - "filename": "dusk_stone", + "filename": "super_potion", "rotated": false, "trimmed": true, "sourceSize": { @@ -4029,20 +3945,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 6, - "w": 21, - "h": 21 + "x": 8, + "y": 5, + "w": 17, + "h": 23 }, "frame": { - "x": 387, - "y": 86, - "w": 21, - "h": 21 + "x": 411, + "y": 92, + "w": 17, + "h": 23 } }, { - "filename": "razor_fang", + "filename": "soft_sand", "rotated": false, "trimmed": true, "sourceSize": { @@ -4050,20 +3966,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, - "y": 6, - "w": 18, + "x": 4, + "y": 7, + "w": 24, "h": 20 }, "frame": { - "x": 408, - "y": 88, - "w": 18, + "x": 147, + "y": 111, + "w": 24, "h": 20 } }, { - "filename": "pair_of_tickets", + "filename": "binding_band", "rotated": false, "trimmed": true, "sourceSize": { @@ -4071,20 +3987,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, - "y": 7, + "x": 5, + "y": 6, "w": 23, - "h": 19 + "h": 20 }, "frame": { - "x": 191, - "y": 103, + "x": 171, + "y": 111, "w": 23, - "h": 19 + "h": 20 } }, { - "filename": "sharp_beak", + "filename": "n_lunarizer", "rotated": false, "trimmed": true, "sourceSize": { @@ -4092,20 +4008,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, - "y": 5, - "w": 21, - "h": 23 + "x": 4, + "y": 6, + "w": 23, + "h": 21 }, "frame": { - "x": 193, - "y": 122, - "w": 21, - "h": 23 + "x": 194, + "y": 111, + "w": 23, + "h": 21 } }, { - "filename": "water_tera_shard", + "filename": "fairy_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -4113,20 +4029,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 4, + "x": 5, + "y": 5, "w": 22, - "h": 23 + "h": 22 }, "frame": { - "x": 214, - "y": 103, + "x": 147, + "y": 131, "w": 22, - "h": 23 + "h": 22 } }, { - "filename": "whipped_dream", + "filename": "fighting_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -4135,19 +4051,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 4, - "w": 21, - "h": 23 + "y": 5, + "w": 22, + "h": 22 }, "frame": { - "x": 193, - "y": 145, - "w": 21, - "h": 23 + "x": 169, + "y": 131, + "w": 22, + "h": 22 } }, { - "filename": "wide_lens", + "filename": "n_solarizer", "rotated": false, "trimmed": true, "sourceSize": { @@ -4155,20 +4071,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, - "y": 4, - "w": 22, - "h": 23 + "x": 4, + "y": 6, + "w": 23, + "h": 21 }, "frame": { - "x": 214, - "y": 126, - "w": 22, - "h": 23 + "x": 217, + "y": 112, + "w": 23, + "h": 21 } }, { - "filename": "electric_memory", + "filename": "wellspring_mask", "rotated": false, "trimmed": true, "sourceSize": { @@ -4176,20 +4092,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, + "x": 4, "y": 5, - "w": 22, - "h": 22 + "w": 23, + "h": 21 }, "frame": { - "x": 193, - "y": 168, - "w": 22, - "h": 22 + "x": 240, + "y": 112, + "w": 23, + "h": 21 } }, { - "filename": "enigma_berry", + "filename": "deep_sea_tooth", "rotated": false, "trimmed": true, "sourceSize": { @@ -4198,19 +4114,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 5, + "y": 6, "w": 22, - "h": 22 + "h": 21 }, "frame": { - "x": 192, - "y": 190, + "x": 263, + "y": 112, "w": 22, - "h": 22 + "h": 21 } }, { - "filename": "blunder_policy", + "filename": "fire_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -4219,19 +4135,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 6, + "y": 5, "w": 22, - "h": 19 + "h": 22 }, "frame": { - "x": 214, - "y": 149, + "x": 285, + "y": 111, "w": 22, - "h": 19 + "h": 22 } }, { - "filename": "fairy_memory", + "filename": "flying_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -4245,14 +4161,14 @@ "h": 22 }, "frame": { - "x": 215, - "y": 168, + "x": 307, + "y": 111, "w": 22, "h": 22 } }, { - "filename": "fighting_memory", + "filename": "ganlon_berry", "rotated": false, "trimmed": true, "sourceSize": { @@ -4266,14 +4182,14 @@ "h": 22 }, "frame": { - "x": 214, - "y": 190, + "x": 329, + "y": 110, "w": 22, "h": 22 } }, { - "filename": "fire_memory", + "filename": "ghost_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -4287,14 +4203,14 @@ "h": 22 }, "frame": { - "x": 193, - "y": 212, + "x": 351, + "y": 110, "w": 22, "h": 22 } }, { - "filename": "flying_memory", + "filename": "grass_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -4308,14 +4224,14 @@ "h": 22 }, "frame": { - "x": 193, - "y": 234, + "x": 373, + "y": 110, "w": 22, "h": 22 } }, { - "filename": "ganlon_berry", + "filename": "ground_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -4329,14 +4245,14 @@ "h": 22 }, "frame": { - "x": 193, - "y": 256, + "x": 191, + "y": 132, "w": 22, "h": 22 } }, { - "filename": "ghost_memory", + "filename": "guard_spec", "rotated": false, "trimmed": true, "sourceSize": { @@ -4350,14 +4266,14 @@ "h": 22 }, "frame": { - "x": 215, - "y": 212, + "x": 149, + "y": 153, "w": 22, "h": 22 } }, { - "filename": "grass_memory", + "filename": "hard_meteorite", "rotated": false, "trimmed": true, "sourceSize": { @@ -4365,20 +4281,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, + "x": 7, "y": 5, - "w": 22, + "w": 20, "h": 22 }, "frame": { - "x": 215, - "y": 234, - "w": 22, + "x": 171, + "y": 153, + "w": 20, "h": 22 } }, { - "filename": "ground_memory", + "filename": "ice_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -4392,14 +4308,14 @@ "h": 22 }, "frame": { - "x": 215, - "y": 256, + "x": 191, + "y": 154, "w": 22, "h": 22 } }, { - "filename": "guard_spec", + "filename": "ice_stone", "rotated": false, "trimmed": true, "sourceSize": { @@ -4413,14 +4329,14 @@ "h": 22 }, "frame": { - "x": 195, - "y": 278, + "x": 213, + "y": 133, "w": 22, "h": 22 } }, { - "filename": "hard_meteorite", + "filename": "magmarizer", "rotated": false, "trimmed": true, "sourceSize": { @@ -4428,20 +4344,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, + "x": 5, "y": 5, - "w": 20, + "w": 22, "h": 22 }, "frame": { - "x": 217, - "y": 278, - "w": 20, + "x": 235, + "y": 133, + "w": 22, "h": 22 } }, { - "filename": "ice_memory", + "filename": "mini_black_hole", "rotated": false, "trimmed": true, "sourceSize": { @@ -4455,14 +4371,14 @@ "h": 22 }, "frame": { - "x": 236, - "y": 105, + "x": 257, + "y": 133, "w": 22, "h": 22 } }, { - "filename": "ice_stone", + "filename": "normal_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -4476,14 +4392,14 @@ "h": 22 }, "frame": { - "x": 258, - "y": 105, + "x": 279, + "y": 133, "w": 22, "h": 22 } }, { - "filename": "magmarizer", + "filename": "poison_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -4497,14 +4413,14 @@ "h": 22 }, "frame": { - "x": 236, - "y": 127, + "x": 301, + "y": 133, "w": 22, "h": 22 } }, { - "filename": "mini_black_hole", + "filename": "liechi_berry", "rotated": false, "trimmed": true, "sourceSize": { @@ -4513,19 +4429,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 5, + "y": 6, "w": 22, - "h": 22 + "h": 21 }, "frame": { - "x": 280, - "y": 105, + "x": 213, + "y": 155, "w": 22, - "h": 22 + "h": 21 } }, { - "filename": "normal_memory", + "filename": "protector", "rotated": false, "trimmed": true, "sourceSize": { @@ -4539,14 +4455,14 @@ "h": 22 }, "frame": { - "x": 258, - "y": 127, + "x": 235, + "y": 155, "w": 22, "h": 22 } }, { - "filename": "poison_memory", + "filename": "psychic_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -4560,14 +4476,14 @@ "h": 22 }, "frame": { - "x": 280, - "y": 127, + "x": 257, + "y": 155, "w": 22, "h": 22 } }, { - "filename": "dubious_disc", + "filename": "rock_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -4576,19 +4492,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 7, + "y": 5, "w": 22, - "h": 19 + "h": 22 }, "frame": { - "x": 236, - "y": 149, + "x": 279, + "y": 155, "w": 22, - "h": 19 + "h": 22 } }, { - "filename": "protector", + "filename": "scroll_of_darkness", "rotated": false, "trimmed": true, "sourceSize": { @@ -4602,14 +4518,14 @@ "h": 22 }, "frame": { - "x": 237, - "y": 168, + "x": 301, + "y": 155, "w": 22, "h": 22 } }, { - "filename": "psychic_memory", + "filename": "super_repel", "rotated": false, "trimmed": true, "sourceSize": { @@ -4617,20 +4533,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, - "y": 5, - "w": 22, - "h": 22 + "x": 8, + "y": 4, + "w": 16, + "h": 24 }, "frame": { - "x": 236, - "y": 190, - "w": 22, - "h": 22 + "x": 395, + "y": 112, + "w": 16, + "h": 24 } }, { - "filename": "mystic_water", + "filename": "metronome", "rotated": false, "trimmed": true, "sourceSize": { @@ -4638,20 +4554,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, + "x": 7, "y": 5, - "w": 20, - "h": 23 + "w": 17, + "h": 22 }, "frame": { - "x": 237, - "y": 212, - "w": 20, - "h": 23 + "x": 411, + "y": 115, + "w": 17, + "h": 22 } }, { - "filename": "potion", + "filename": "scroll_of_waters", "rotated": false, "trimmed": true, "sourceSize": { @@ -4659,20 +4575,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, + "x": 5, "y": 5, - "w": 17, - "h": 23 + "w": 22, + "h": 22 }, "frame": { - "x": 302, - "y": 105, - "w": 17, - "h": 23 + "x": 155, + "y": 175, + "w": 22, + "h": 22 } }, { - "filename": "wellspring_mask", + "filename": "shed_shell", "rotated": false, "trimmed": true, "sourceSize": { @@ -4680,20 +4596,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, + "x": 5, "y": 5, - "w": 23, - "h": 21 + "w": 22, + "h": 22 }, "frame": { - "x": 319, - "y": 107, - "w": 23, - "h": 21 + "x": 156, + "y": 197, + "w": 22, + "h": 22 } }, { - "filename": "liechi_berry", + "filename": "starf_berry", "rotated": false, "trimmed": true, "sourceSize": { @@ -4702,19 +4618,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 6, + "y": 5, "w": 22, - "h": 21 + "h": 22 }, "frame": { - "x": 302, - "y": 128, + "x": 157, + "y": 219, "w": 22, - "h": 21 + "h": 22 } }, { - "filename": "reviver_seed", + "filename": "steel_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -4723,19 +4639,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 8, - "w": 23, - "h": 20 + "y": 5, + "w": 22, + "h": 22 }, "frame": { - "x": 342, - "y": 107, - "w": 23, - "h": 20 + "x": 157, + "y": 241, + "w": 22, + "h": 22 } }, { - "filename": "shell_bell", + "filename": "thick_club", "rotated": false, "trimmed": true, "sourceSize": { @@ -4744,19 +4660,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 7, - "w": 23, - "h": 20 + "y": 5, + "w": 22, + "h": 22 }, "frame": { - "x": 365, - "y": 107, - "w": 23, - "h": 20 + "x": 158, + "y": 263, + "w": 22, + "h": 22 } }, { - "filename": "rock_memory", + "filename": "thunder_stone", "rotated": false, "trimmed": true, "sourceSize": { @@ -4770,14 +4686,14 @@ "h": 22 }, "frame": { - "x": 237, - "y": 235, + "x": 159, + "y": 285, "w": 22, "h": 22 } }, { - "filename": "scroll_of_darkness", + "filename": "tm_bug", "rotated": false, "trimmed": true, "sourceSize": { @@ -4791,14 +4707,14 @@ "h": 22 }, "frame": { - "x": 237, - "y": 257, + "x": 160, + "y": 307, "w": 22, "h": 22 } }, { - "filename": "scroll_of_waters", + "filename": "tm_dark", "rotated": false, "trimmed": true, "sourceSize": { @@ -4812,14 +4728,14 @@ "h": 22 }, "frame": { - "x": 237, - "y": 279, + "x": 160, + "y": 329, "w": 22, "h": 22 } }, { - "filename": "upgrade", + "filename": "sweet_apple", "rotated": false, "trimmed": true, "sourceSize": { @@ -4828,19 +4744,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 7, + "y": 6, "w": 22, - "h": 19 + "h": 21 }, "frame": { - "x": 258, - "y": 149, + "x": 177, + "y": 176, "w": 22, - "h": 19 + "h": 21 } }, { - "filename": "shed_shell", + "filename": "tm_dragon", "rotated": false, "trimmed": true, "sourceSize": { @@ -4854,14 +4770,14 @@ "h": 22 }, "frame": { - "x": 259, - "y": 168, + "x": 178, + "y": 197, "w": 22, "h": 22 } }, { - "filename": "starf_berry", + "filename": "syrupy_apple", "rotated": false, "trimmed": true, "sourceSize": { @@ -4870,19 +4786,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 5, + "y": 6, "w": 22, - "h": 22 + "h": 21 }, "frame": { - "x": 258, - "y": 190, + "x": 199, + "y": 176, "w": 22, - "h": 22 + "h": 21 } }, { - "filename": "steel_memory", + "filename": "tm_electric", "rotated": false, "trimmed": true, "sourceSize": { @@ -4896,14 +4812,14 @@ "h": 22 }, "frame": { - "x": 257, - "y": 212, + "x": 179, + "y": 219, "w": 22, "h": 22 } }, { - "filename": "big_nugget", + "filename": "tm_fairy", "rotated": false, "trimmed": true, "sourceSize": { @@ -4911,20 +4827,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 6, - "w": 20, - "h": 20 + "x": 5, + "y": 5, + "w": 22, + "h": 22 }, "frame": { - "x": 388, - "y": 107, - "w": 20, - "h": 20 + "x": 179, + "y": 241, + "w": 22, + "h": 22 } }, { - "filename": "oval_stone", + "filename": "tm_fighting", "rotated": false, "trimmed": true, "sourceSize": { @@ -4932,20 +4848,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, - "y": 7, - "w": 18, - "h": 19 + "x": 5, + "y": 5, + "w": 22, + "h": 22 }, "frame": { - "x": 408, - "y": 108, - "w": 18, - "h": 19 + "x": 200, + "y": 197, + "w": 22, + "h": 22 } }, { - "filename": "metal_alloy", + "filename": "tm_fire", "rotated": false, "trimmed": true, "sourceSize": { @@ -4953,20 +4869,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 7, - "w": 21, - "h": 19 + "x": 5, + "y": 5, + "w": 22, + "h": 22 }, "frame": { - "x": 280, - "y": 149, - "w": 21, - "h": 19 + "x": 180, + "y": 263, + "w": 22, + "h": 22 } }, { - "filename": "sitrus_berry", + "filename": "tm_flying", "rotated": false, "trimmed": true, "sourceSize": { @@ -4974,20 +4890,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, + "x": 5, "y": 5, - "w": 20, + "w": 22, "h": 22 }, "frame": { - "x": 281, - "y": 168, - "w": 20, + "x": 181, + "y": 285, + "w": 22, "h": 22 } }, { - "filename": "thick_club", + "filename": "tm_ghost", "rotated": false, "trimmed": true, "sourceSize": { @@ -5001,14 +4917,14 @@ "h": 22 }, "frame": { - "x": 301, - "y": 149, + "x": 201, + "y": 219, "w": 22, "h": 22 } }, { - "filename": "thunder_stone", + "filename": "tm_grass", "rotated": false, "trimmed": true, "sourceSize": { @@ -5022,14 +4938,14 @@ "h": 22 }, "frame": { - "x": 280, - "y": 190, + "x": 201, + "y": 241, "w": 22, "h": 22 } }, { - "filename": "tm_bug", + "filename": "tm_ground", "rotated": false, "trimmed": true, "sourceSize": { @@ -5043,14 +4959,14 @@ "h": 22 }, "frame": { - "x": 279, - "y": 212, + "x": 202, + "y": 263, "w": 22, "h": 22 } }, { - "filename": "tm_dark", + "filename": "tm_ice", "rotated": false, "trimmed": true, "sourceSize": { @@ -5064,14 +4980,14 @@ "h": 22 }, "frame": { - "x": 259, - "y": 234, + "x": 182, + "y": 307, "w": 22, "h": 22 } }, { - "filename": "tm_dragon", + "filename": "tm_normal", "rotated": false, "trimmed": true, "sourceSize": { @@ -5085,14 +5001,14 @@ "h": 22 }, "frame": { - "x": 259, - "y": 256, + "x": 182, + "y": 329, "w": 22, "h": 22 } }, { - "filename": "tm_electric", + "filename": "tm_poison", "rotated": false, "trimmed": true, "sourceSize": { @@ -5106,14 +5022,14 @@ "h": 22 }, "frame": { - "x": 259, - "y": 278, + "x": 203, + "y": 285, "w": 22, "h": 22 } }, { - "filename": "tm_fairy", + "filename": "tm_psychic", "rotated": false, "trimmed": true, "sourceSize": { @@ -5127,14 +5043,14 @@ "h": 22 }, "frame": { - "x": 281, - "y": 234, + "x": 204, + "y": 307, "w": 22, "h": 22 } }, { - "filename": "tm_fighting", + "filename": "tm_rock", "rotated": false, "trimmed": true, "sourceSize": { @@ -5148,14 +5064,14 @@ "h": 22 }, "frame": { - "x": 281, - "y": 256, + "x": 204, + "y": 329, "w": 22, "h": 22 } }, { - "filename": "tm_fire", + "filename": "reviver_seed", "rotated": false, "trimmed": true, "sourceSize": { @@ -5164,61 +5080,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 5, - "w": 22, - "h": 22 - }, - "frame": { - "x": 281, - "y": 278, - "w": 22, - "h": 22 - } - }, - { - "filename": "lum_berry", - "rotated": false, - "trimmed": true, - "sourceSize": { - "w": 32, - "h": 32 - }, - "spriteSourceSize": { - "x": 6, - "y": 7, - "w": 20, - "h": 19 - }, - "frame": { - "x": 301, - "y": 171, - "w": 20, - "h": 19 - } - }, - { - "filename": "metal_coat", - "rotated": false, - "trimmed": true, - "sourceSize": { - "w": 32, - "h": 32 - }, - "spriteSourceSize": { - "x": 6, - "y": 5, - "w": 19, - "h": 22 + "y": 8, + "w": 23, + "h": 20 }, "frame": { - "x": 302, - "y": 190, - "w": 19, - "h": 22 + "x": 221, + "y": 177, + "w": 23, + "h": 20 } }, { - "filename": "tm_flying", + "filename": "tm_steel", "rotated": false, "trimmed": true, "sourceSize": { @@ -5232,14 +5106,14 @@ "h": 22 }, "frame": { - "x": 301, - "y": 212, + "x": 222, + "y": 197, "w": 22, "h": 22 } }, { - "filename": "tm_ghost", + "filename": "tm_water", "rotated": false, "trimmed": true, "sourceSize": { @@ -5253,14 +5127,14 @@ "h": 22 }, "frame": { - "x": 303, - "y": 234, + "x": 244, + "y": 177, "w": 22, "h": 22 } }, { - "filename": "tm_grass", + "filename": "water_memory", "rotated": false, "trimmed": true, "sourceSize": { @@ -5274,14 +5148,14 @@ "h": 22 }, "frame": { - "x": 303, - "y": 256, + "x": 266, + "y": 177, "w": 22, "h": 22 } }, { - "filename": "tm_ground", + "filename": "water_stone", "rotated": false, "trimmed": true, "sourceSize": { @@ -5295,14 +5169,14 @@ "h": 22 }, "frame": { - "x": 303, - "y": 278, + "x": 288, + "y": 177, "w": 22, "h": 22 } }, { - "filename": "poison_barb", + "filename": "shell_bell", "rotated": false, "trimmed": true, "sourceSize": { @@ -5311,19 +5185,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 6, - "w": 21, - "h": 21 + "y": 7, + "w": 23, + "h": 20 }, "frame": { - "x": 324, - "y": 128, - "w": 21, - "h": 21 + "x": 244, + "y": 199, + "w": 23, + "h": 20 } }, { - "filename": "tm_ice", + "filename": "x_accuracy", "rotated": false, "trimmed": true, "sourceSize": { @@ -5337,14 +5211,14 @@ "h": 22 }, "frame": { - "x": 323, - "y": 149, + "x": 223, + "y": 219, "w": 22, "h": 22 } }, { - "filename": "tm_normal", + "filename": "x_attack", "rotated": false, "trimmed": true, "sourceSize": { @@ -5358,14 +5232,14 @@ "h": 22 }, "frame": { - "x": 321, - "y": 171, + "x": 223, + "y": 241, "w": 22, "h": 22 } }, { - "filename": "tm_poison", + "filename": "x_defense", "rotated": false, "trimmed": true, "sourceSize": { @@ -5379,14 +5253,14 @@ "h": 22 }, "frame": { - "x": 345, - "y": 127, + "x": 245, + "y": 219, "w": 22, "h": 22 } }, { - "filename": "tm_psychic", + "filename": "x_sp_atk", "rotated": false, "trimmed": true, "sourceSize": { @@ -5400,14 +5274,14 @@ "h": 22 }, "frame": { - "x": 345, - "y": 149, + "x": 267, + "y": 199, "w": 22, "h": 22 } }, { - "filename": "tm_rock", + "filename": "x_sp_def", "rotated": false, "trimmed": true, "sourceSize": { @@ -5421,14 +5295,14 @@ "h": 22 }, "frame": { - "x": 343, - "y": 171, + "x": 224, + "y": 263, "w": 22, "h": 22 } }, { - "filename": "tm_steel", + "filename": "x_speed", "rotated": false, "trimmed": true, "sourceSize": { @@ -5442,14 +5316,14 @@ "h": 22 }, "frame": { - "x": 367, - "y": 127, + "x": 245, + "y": 241, "w": 22, "h": 22 } }, { - "filename": "tm_water", + "filename": "sitrus_berry", "rotated": false, "trimmed": true, "sourceSize": { @@ -5457,20 +5331,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, + "x": 6, "y": 5, - "w": 22, + "w": 20, "h": 22 }, "frame": { - "x": 367, - "y": 149, - "w": 22, + "x": 225, + "y": 285, + "w": 20, "h": 22 } }, { - "filename": "water_memory", + "filename": "tart_apple", "rotated": false, "trimmed": true, "sourceSize": { @@ -5479,19 +5353,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 5, + "y": 6, "w": 22, - "h": 22 + "h": 21 }, "frame": { - "x": 365, - "y": 171, + "x": 267, + "y": 221, "w": 22, - "h": 22 + "h": 21 } }, { - "filename": "water_stone", + "filename": "deep_sea_scale", "rotated": false, "trimmed": true, "sourceSize": { @@ -5500,19 +5374,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 5, + "y": 6, "w": 22, - "h": 22 + "h": 20 }, "frame": { - "x": 389, - "y": 127, + "x": 267, + "y": 242, "w": 22, - "h": 22 + "h": 20 } }, { - "filename": "full_heal", + "filename": "dusk_stone", "rotated": false, "trimmed": true, "sourceSize": { @@ -5520,20 +5394,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 9, - "y": 4, - "w": 15, - "h": 23 + "x": 6, + "y": 6, + "w": 21, + "h": 21 }, "frame": { - "x": 411, - "y": 127, - "w": 15, - "h": 23 + "x": 289, + "y": 199, + "w": 21, + "h": 21 } }, { - "filename": "x_accuracy", + "filename": "poison_barb", "rotated": false, "trimmed": true, "sourceSize": { @@ -5542,19 +5416,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 5, - "w": 22, - "h": 22 + "y": 6, + "w": 21, + "h": 21 }, "frame": { - "x": 389, - "y": 149, - "w": 22, - "h": 22 + "x": 246, + "y": 263, + "w": 21, + "h": 21 } }, { - "filename": "leftovers", + "filename": "fairy_feather", "rotated": false, "trimmed": true, "sourceSize": { @@ -5562,20 +5436,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, - "y": 5, - "w": 15, - "h": 22 + "x": 5, + "y": 7, + "w": 22, + "h": 20 }, "frame": { - "x": 411, - "y": 150, - "w": 15, - "h": 22 + "x": 267, + "y": 262, + "w": 22, + "h": 20 } }, { - "filename": "x_attack", + "filename": "shiny_stone", "rotated": false, "trimmed": true, "sourceSize": { @@ -5584,19 +5458,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 5, - "w": 22, - "h": 22 + "y": 6, + "w": 21, + "h": 21 }, "frame": { - "x": 387, - "y": 171, - "w": 22, - "h": 22 + "x": 289, + "y": 220, + "w": 21, + "h": 21 } }, { - "filename": "super_potion", + "filename": "zoom_lens", "rotated": false, "trimmed": true, "sourceSize": { @@ -5604,20 +5478,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, - "y": 5, - "w": 17, - "h": 23 + "x": 5, + "y": 6, + "w": 21, + "h": 21 }, "frame": { - "x": 409, - "y": 172, - "w": 17, - "h": 23 + "x": 289, + "y": 241, + "w": 21, + "h": 21 } }, { - "filename": "power_herb", + "filename": "lock_capsule", "rotated": false, "trimmed": true, "sourceSize": { @@ -5625,20 +5499,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 7, - "w": 20, - "h": 19 + "x": 7, + "y": 5, + "w": 19, + "h": 22 }, "frame": { - "x": 321, - "y": 193, - "w": 20, - "h": 19 + "x": 226, + "y": 307, + "w": 19, + "h": 22 } }, { - "filename": "x_defense", + "filename": "malicious_armor", "rotated": false, "trimmed": true, "sourceSize": { @@ -5647,19 +5521,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 5, + "y": 6, "w": 22, - "h": 22 + "h": 20 }, "frame": { - "x": 323, - "y": 212, + "x": 289, + "y": 262, "w": 22, - "h": 22 + "h": 20 } }, { - "filename": "razor_claw", + "filename": "metal_coat", "rotated": false, "trimmed": true, "sourceSize": { @@ -5668,19 +5542,103 @@ }, "spriteSourceSize": { "x": 6, + "y": 5, + "w": 19, + "h": 22 + }, + "frame": { + "x": 226, + "y": 329, + "w": 19, + "h": 22 + } + }, + { + "filename": "unknown", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 8, + "y": 4, + "w": 16, + "h": 24 + }, + "frame": { + "x": 310, + "y": 177, + "w": 16, + "h": 24 + } + }, + { + "filename": "zinc", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 8, + "y": 4, + "w": 16, + "h": 24 + }, + "frame": { + "x": 310, + "y": 201, + "w": 16, + "h": 24 + } + }, + { + "filename": "soothe_bell", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 8, + "y": 5, + "w": 17, + "h": 22 + }, + "frame": { + "x": 310, + "y": 225, + "w": 17, + "h": 22 + } + }, + { + "filename": "coupon", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 4, "y": 7, - "w": 20, + "w": 23, "h": 19 }, "frame": { - "x": 341, - "y": 193, - "w": 20, + "x": 161, + "y": 351, + "w": 23, "h": 19 } }, { - "filename": "x_sp_atk", + "filename": "relic_crown", "rotated": false, "trimmed": true, "sourceSize": { @@ -5688,20 +5646,125 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, + "x": 4, + "y": 7, + "w": 23, + "h": 18 + }, + "frame": { + "x": 161, + "y": 370, + "w": 23, + "h": 18 + } + }, + { + "filename": "golden_mystic_ticket", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 4, + "y": 7, + "w": 23, + "h": 19 + }, + "frame": { + "x": 184, + "y": 351, + "w": 23, + "h": 19 + } + }, + { + "filename": "mystic_ticket", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 4, + "y": 7, + "w": 23, + "h": 19 + }, + "frame": { + "x": 207, + "y": 351, + "w": 23, + "h": 19 + } + }, + { + "filename": "pair_of_tickets", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 4, + "y": 7, + "w": 23, + "h": 19 + }, + "frame": { + "x": 184, + "y": 370, + "w": 23, + "h": 19 + } + }, + { + "filename": "leftovers", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 8, "y": 5, - "w": 22, + "w": 15, "h": 22 }, "frame": { - "x": 325, - "y": 234, - "w": 22, + "x": 176, + "y": 389, + "w": 15, "h": 22 } }, { - "filename": "x_sp_def", + "filename": "black_sludge", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 24, + "h": 24 + }, + "spriteSourceSize": { + "x": 1, + "y": 2, + "w": 22, + "h": 19 + }, + "frame": { + "x": 207, + "y": 370, + "w": 22, + "h": 19 + } + }, + { + "filename": "tera_orb", "rotated": false, "trimmed": true, "sourceSize": { @@ -5710,19 +5773,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 5, + "y": 6, "w": 22, - "h": 22 + "h": 20 }, "frame": { - "x": 325, - "y": 256, + "x": 191, + "y": 389, "w": 22, - "h": 22 + "h": 20 } }, { - "filename": "x_speed", + "filename": "blunder_policy", "rotated": false, "trimmed": true, "sourceSize": { @@ -5731,19 +5794,19 @@ }, "spriteSourceSize": { "x": 5, - "y": 5, + "y": 6, "w": 22, - "h": 22 + "h": 19 }, "frame": { - "x": 325, - "y": 278, + "x": 194, + "y": 409, "w": 22, - "h": 22 + "h": 19 } }, { - "filename": "deep_sea_scale", + "filename": "big_nugget", "rotated": false, "trimmed": true, "sourceSize": { @@ -5751,20 +5814,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, + "x": 6, "y": 6, - "w": 22, + "w": 20, "h": 20 }, "frame": { - "x": 361, - "y": 193, - "w": 22, + "x": 213, + "y": 389, + "w": 20, "h": 20 } }, { - "filename": "fairy_feather", + "filename": "dubious_disc", "rotated": false, "trimmed": true, "sourceSize": { @@ -5775,17 +5838,80 @@ "x": 5, "y": 7, "w": 22, - "h": 20 + "h": 19 }, "frame": { - "x": 383, - "y": 193, + "x": 216, + "y": 409, "w": 22, + "h": 19 + } + }, + { + "filename": "big_mushroom", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 6, + "y": 6, + "w": 19, + "h": 19 + }, + "frame": { + "x": 230, + "y": 351, + "w": 19, + "h": 19 + } + }, + { + "filename": "lum_berry", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 6, + "y": 7, + "w": 20, + "h": 19 + }, + "frame": { + "x": 229, + "y": 370, + "w": 20, + "h": 19 + } + }, + { + "filename": "blue_orb", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 6, + "y": 6, + "w": 20, + "h": 20 + }, + "frame": { + "x": 233, + "y": 389, + "w": 20, "h": 20 } }, { - "filename": "shiny_stone", + "filename": "upgrade", "rotated": false, "trimmed": true, "sourceSize": { @@ -5794,19 +5920,40 @@ }, "spriteSourceSize": { "x": 5, + "y": 7, + "w": 22, + "h": 19 + }, + "frame": { + "x": 238, + "y": 409, + "w": 22, + "h": 19 + } + }, + { + "filename": "dawn_stone", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 6, "y": 6, - "w": 21, + "w": 20, "h": 21 }, "frame": { - "x": 405, - "y": 195, - "w": 21, + "x": 323, + "y": 133, + "w": 20, "h": 21 } }, { - "filename": "mystery_egg", + "filename": "gb", "rotated": false, "trimmed": true, "sourceSize": { @@ -5814,16 +5961,16 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, - "y": 8, - "w": 16, - "h": 18 + "x": 6, + "y": 6, + "w": 20, + "h": 20 }, "frame": { - "x": 345, - "y": 212, - "w": 16, - "h": 18 + "x": 323, + "y": 154, + "w": 20, + "h": 20 } }, { @@ -5841,14 +5988,14 @@ "h": 17 }, "frame": { - "x": 361, - "y": 213, + "x": 343, + "y": 132, "w": 23, "h": 17 } }, { - "filename": "masterpiece_teacup", + "filename": "magnet", "rotated": false, "trimmed": true, "sourceSize": { @@ -5856,20 +6003,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, - "y": 7, - "w": 21, - "h": 18 + "x": 6, + "y": 6, + "w": 20, + "h": 20 }, "frame": { - "x": 384, - "y": 213, - "w": 21, - "h": 18 + "x": 343, + "y": 149, + "w": 20, + "h": 20 } }, { - "filename": "zoom_lens", + "filename": "shock_drive", "rotated": false, "trimmed": true, "sourceSize": { @@ -5877,20 +6024,41 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, + "x": 4, + "y": 8, + "w": 23, + "h": 17 + }, + "frame": { + "x": 366, + "y": 132, + "w": 23, + "h": 17 + } + }, + { + "filename": "mb", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 6, "y": 6, - "w": 21, - "h": 21 + "w": 20, + "h": 20 }, "frame": { - "x": 405, - "y": 216, - "w": 21, - "h": 21 + "x": 363, + "y": 149, + "w": 20, + "h": 20 } }, { - "filename": "sweet_apple", + "filename": "quick_claw", "rotated": false, "trimmed": true, "sourceSize": { @@ -5898,20 +6066,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, + "x": 6, "y": 6, - "w": 22, + "w": 19, "h": 21 }, "frame": { - "x": 347, - "y": 230, - "w": 22, + "x": 326, + "y": 174, + "w": 19, "h": 21 } }, { - "filename": "syrupy_apple", + "filename": "spell_tag", "rotated": false, "trimmed": true, "sourceSize": { @@ -5919,20 +6087,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, + "x": 7, "y": 6, - "w": 22, + "w": 19, "h": 21 }, "frame": { - "x": 347, - "y": 251, - "w": 22, + "x": 326, + "y": 195, + "w": 19, "h": 21 } }, { - "filename": "tart_apple", + "filename": "metal_alloy", "rotated": false, "trimmed": true, "sourceSize": { @@ -5940,20 +6108,41 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, + "x": 6, + "y": 7, + "w": 21, + "h": 19 + }, + "frame": { + "x": 345, + "y": 169, + "w": 21, + "h": 19 + } + }, + { + "filename": "pb", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 6, "y": 6, - "w": 22, - "h": 21 + "w": 20, + "h": 20 }, "frame": { - "x": 347, - "y": 272, - "w": 22, - "h": 21 + "x": 345, + "y": 188, + "w": 20, + "h": 20 } }, { - "filename": "eviolite", + "filename": "candy_jar", "rotated": false, "trimmed": true, "sourceSize": { @@ -5961,20 +6150,62 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, + "x": 6, + "y": 6, + "w": 19, + "h": 20 + }, + "frame": { + "x": 366, + "y": 169, + "w": 19, + "h": 20 + } + }, + { + "filename": "pb_gold", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 6, + "y": 6, + "w": 20, + "h": 20 + }, + "frame": { + "x": 365, + "y": 189, + "w": 20, + "h": 20 + } + }, + { + "filename": "everstone", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 32, + "h": 32 + }, + "spriteSourceSize": { + "x": 6, "y": 8, - "w": 15, - "h": 15 + "w": 20, + "h": 17 }, "frame": { - "x": 369, - "y": 230, - "w": 15, - "h": 15 + "x": 345, + "y": 208, + "w": 20, + "h": 17 } }, { - "filename": "sharp_meteorite", + "filename": "razor_fang", "rotated": false, "trimmed": true, "sourceSize": { @@ -5982,20 +6213,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 8, - "w": 21, - "h": 18 + "x": 7, + "y": 6, + "w": 18, + "h": 20 }, "frame": { - "x": 384, - "y": 231, - "w": 21, - "h": 18 + "x": 327, + "y": 216, + "w": 18, + "h": 20 } }, { - "filename": "unremarkable_teacup", + "filename": "masterpiece_teacup", "rotated": false, "trimmed": true, "sourceSize": { @@ -6009,14 +6240,14 @@ "h": 18 }, "frame": { - "x": 405, - "y": 237, + "x": 365, + "y": 209, "w": 21, "h": 18 } }, { - "filename": "prism_scale", + "filename": "power_herb", "rotated": false, "trimmed": true, "sourceSize": { @@ -6024,20 +6255,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 9, - "y": 8, - "w": 15, - "h": 15 + "x": 6, + "y": 7, + "w": 20, + "h": 19 }, "frame": { - "x": 369, - "y": 245, - "w": 15, - "h": 15 + "x": 345, + "y": 225, + "w": 20, + "h": 19 } }, { - "filename": "metronome", + "filename": "old_gateau", "rotated": false, "trimmed": true, "sourceSize": { @@ -6045,20 +6276,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, - "y": 5, - "w": 17, - "h": 22 + "x": 6, + "y": 8, + "w": 21, + "h": 18 }, "frame": { - "x": 369, - "y": 260, - "w": 17, - "h": 22 + "x": 365, + "y": 227, + "w": 21, + "h": 18 } }, { - "filename": "quick_claw", + "filename": "baton", "rotated": false, "trimmed": true, "sourceSize": { @@ -6066,20 +6297,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 6, - "w": 19, - "h": 21 + "x": 7, + "y": 7, + "w": 18, + "h": 18 }, "frame": { - "x": 386, - "y": 249, - "w": 19, - "h": 21 + "x": 327, + "y": 236, + "w": 18, + "h": 18 } }, { - "filename": "blue_orb", + "filename": "razor_claw", "rotated": false, "trimmed": true, "sourceSize": { @@ -6088,19 +6319,19 @@ }, "spriteSourceSize": { "x": 6, - "y": 6, + "y": 7, "w": 20, - "h": 20 + "h": 19 }, "frame": { - "x": 405, - "y": 255, + "x": 345, + "y": 244, "w": 20, - "h": 20 + "h": 19 } }, { - "filename": "candy_jar", + "filename": "sharp_meteorite", "rotated": false, "trimmed": true, "sourceSize": { @@ -6109,15 +6340,15 @@ }, "spriteSourceSize": { "x": 6, - "y": 6, - "w": 19, - "h": 20 + "y": 8, + "w": 21, + "h": 18 }, "frame": { - "x": 386, - "y": 270, - "w": 19, - "h": 20 + "x": 365, + "y": 245, + "w": 21, + "h": 18 } }, { @@ -6135,54 +6366,12 @@ "h": 20 }, "frame": { - "x": 369, - "y": 282, + "x": 383, + "y": 149, "w": 17, "h": 20 } }, - { - "filename": "malicious_armor", - "rotated": false, - "trimmed": true, - "sourceSize": { - "w": 32, - "h": 32 - }, - "spriteSourceSize": { - "x": 5, - "y": 6, - "w": 22, - "h": 20 - }, - "frame": { - "x": 347, - "y": 293, - "w": 22, - "h": 20 - } - }, - { - "filename": "everstone", - "rotated": false, - "trimmed": true, - "sourceSize": { - "w": 32, - "h": 32 - }, - "spriteSourceSize": { - "x": 6, - "y": 8, - "w": 20, - "h": 17 - }, - "frame": { - "x": 405, - "y": 275, - "w": 20, - "h": 17 - } - }, { "filename": "hard_stone", "rotated": false, @@ -6198,14 +6387,14 @@ "h": 20 }, "frame": { - "x": 386, - "y": 290, + "x": 385, + "y": 169, "w": 19, "h": 20 } }, { - "filename": "gb", + "filename": "rb", "rotated": false, "trimmed": true, "sourceSize": { @@ -6219,14 +6408,14 @@ "h": 20 }, "frame": { - "x": 405, - "y": 292, + "x": 385, + "y": 189, "w": 20, "h": 20 } }, { - "filename": "lucky_egg", + "filename": "smooth_meteorite", "rotated": false, "trimmed": true, "sourceSize": { @@ -6236,18 +6425,18 @@ "spriteSourceSize": { "x": 7, "y": 6, - "w": 17, + "w": 20, "h": 20 }, "frame": { - "x": 369, - "y": 302, - "w": 17, + "x": 386, + "y": 209, + "w": 20, "h": 20 } }, { - "filename": "miracle_seed", + "filename": "strange_ball", "rotated": false, "trimmed": true, "sourceSize": { @@ -6256,19 +6445,19 @@ }, "spriteSourceSize": { "x": 6, - "y": 7, - "w": 19, - "h": 19 + "y": 6, + "w": 20, + "h": 20 }, "frame": { "x": 386, - "y": 310, - "w": 19, - "h": 19 + "y": 229, + "w": 20, + "h": 20 } }, { - "filename": "magnet", + "filename": "ub", "rotated": false, "trimmed": true, "sourceSize": { @@ -6282,14 +6471,14 @@ "h": 20 }, "frame": { - "x": 405, - "y": 312, + "x": 386, + "y": 249, "w": 20, "h": 20 } }, { - "filename": "relic_crown", + "filename": "wise_glasses", "rotated": false, "trimmed": true, "sourceSize": { @@ -6298,40 +6487,19 @@ }, "spriteSourceSize": { "x": 4, - "y": 7, + "y": 8, "w": 23, - "h": 18 + "h": 17 }, "frame": { - "x": 195, - "y": 300, + "x": 405, + "y": 137, "w": 23, - "h": 18 - } - }, - { - "filename": "spell_tag", - "rotated": false, - "trimmed": true, - "sourceSize": { - "w": 32, - "h": 32 - }, - "spriteSourceSize": { - "x": 7, - "y": 6, - "w": 19, - "h": 21 - }, - "frame": { - "x": 218, - "y": 300, - "w": 19, - "h": 21 + "h": 17 } }, { - "filename": "tera_orb", + "filename": "mystery_egg", "rotated": false, "trimmed": true, "sourceSize": { @@ -6339,20 +6507,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 5, - "y": 6, - "w": 22, - "h": 20 + "x": 8, + "y": 8, + "w": 16, + "h": 18 }, "frame": { - "x": 237, - "y": 301, - "w": 22, - "h": 20 + "x": 311, + "y": 247, + "w": 16, + "h": 18 } }, { - "filename": "mb", + "filename": "candy", "rotated": false, "trimmed": true, "sourceSize": { @@ -6360,41 +6528,41 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 6, - "w": 20, - "h": 20 + "x": 7, + "y": 11, + "w": 18, + "h": 18 }, "frame": { - "x": 259, - "y": 300, - "w": 20, - "h": 20 + "x": 327, + "y": 254, + "w": 18, + "h": 18 } }, { - "filename": "pb", + "filename": "absolite", "rotated": false, "trimmed": true, "sourceSize": { "w": 32, "h": 32 }, - "spriteSourceSize": { - "x": 6, - "y": 6, - "w": 20, - "h": 20 - }, + "spriteSourceSize": { + "x": 8, + "y": 8, + "w": 16, + "h": 16 + }, "frame": { - "x": 279, - "y": 300, - "w": 20, - "h": 20 + "x": 311, + "y": 265, + "w": 16, + "h": 16 } }, { - "filename": "pb_gold", + "filename": "unremarkable_teacup", "rotated": false, "trimmed": true, "sourceSize": { @@ -6402,20 +6570,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 6, - "w": 20, - "h": 20 + "x": 5, + "y": 7, + "w": 21, + "h": 18 }, "frame": { - "x": 299, - "y": 300, - "w": 20, - "h": 20 + "x": 345, + "y": 263, + "w": 21, + "h": 18 } }, { - "filename": "rb", + "filename": "white_herb", "rotated": false, "trimmed": true, "sourceSize": { @@ -6424,19 +6592,19 @@ }, "spriteSourceSize": { "x": 6, - "y": 6, + "y": 7, "w": 20, - "h": 20 + "h": 19 }, "frame": { - "x": 319, - "y": 300, + "x": 366, + "y": 263, "w": 20, - "h": 20 + "h": 19 } }, { - "filename": "shock_drive", + "filename": "dark_stone", "rotated": false, "trimmed": true, "sourceSize": { @@ -6444,20 +6612,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, - "y": 8, - "w": 23, - "h": 17 + "x": 7, + "y": 7, + "w": 18, + "h": 18 }, "frame": { - "x": 155, - "y": 305, - "w": 23, - "h": 17 + "x": 327, + "y": 272, + "w": 18, + "h": 18 } }, { - "filename": "soothe_bell", + "filename": "wl_ability_urge", "rotated": false, "trimmed": true, "sourceSize": { @@ -6465,20 +6633,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 8, - "y": 5, - "w": 17, - "h": 22 + "x": 6, + "y": 8, + "w": 20, + "h": 18 }, "frame": { - "x": 178, - "y": 305, - "w": 17, - "h": 22 + "x": 386, + "y": 269, + "w": 20, + "h": 18 } }, { - "filename": "wise_glasses", + "filename": "wl_antidote", "rotated": false, "trimmed": true, "sourceSize": { @@ -6486,20 +6654,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 4, + "x": 6, "y": 8, - "w": 23, - "h": 17 + "w": 20, + "h": 18 }, "frame": { - "x": 195, - "y": 318, - "w": 23, - "h": 17 + "x": 345, + "y": 281, + "w": 20, + "h": 18 } }, { - "filename": "smooth_meteorite", + "filename": "wl_awakening", "rotated": false, "trimmed": true, "sourceSize": { @@ -6507,20 +6675,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, - "y": 6, + "x": 6, + "y": 8, "w": 20, - "h": 20 + "h": 18 }, "frame": { - "x": 218, - "y": 321, + "x": 365, + "y": 282, "w": 20, - "h": 20 + "h": 18 } }, { - "filename": "strange_ball", + "filename": "wl_burn_heal", "rotated": false, "trimmed": true, "sourceSize": { @@ -6529,19 +6697,19 @@ }, "spriteSourceSize": { "x": 6, - "y": 6, + "y": 8, "w": 20, - "h": 20 + "h": 18 }, "frame": { - "x": 238, - "y": 321, + "x": 385, + "y": 287, "w": 20, - "h": 20 + "h": 18 } }, { - "filename": "alakazite", + "filename": "aerodactylite", "rotated": false, "trimmed": true, "sourceSize": { @@ -6555,14 +6723,14 @@ "h": 16 }, "frame": { - "x": 139, - "y": 309, + "x": 311, + "y": 281, "w": 16, "h": 16 } }, { - "filename": "ub", + "filename": "flame_orb", "rotated": false, "trimmed": true, "sourceSize": { @@ -6570,20 +6738,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 6, - "w": 20, - "h": 20 + "x": 7, + "y": 7, + "w": 18, + "h": 18 }, "frame": { - "x": 135, - "y": 325, - "w": 20, - "h": 20 + "x": 327, + "y": 290, + "w": 18, + "h": 18 } }, { - "filename": "white_herb", + "filename": "wl_custom_spliced", "rotated": false, "trimmed": true, "sourceSize": { @@ -6592,19 +6760,19 @@ }, "spriteSourceSize": { "x": 6, - "y": 7, + "y": 8, "w": 20, - "h": 19 + "h": 18 }, "frame": { - "x": 155, - "y": 322, + "x": 345, + "y": 299, "w": 20, - "h": 19 + "h": 18 } }, { - "filename": "wl_ability_urge", + "filename": "wl_custom_thief", "rotated": false, "trimmed": true, "sourceSize": { @@ -6618,14 +6786,14 @@ "h": 18 }, "frame": { - "x": 175, - "y": 327, + "x": 365, + "y": 300, "w": 20, "h": 18 } }, { - "filename": "wl_antidote", + "filename": "wl_elixir", "rotated": false, "trimmed": true, "sourceSize": { @@ -6639,14 +6807,14 @@ "h": 18 }, "frame": { - "x": 136, - "y": 345, + "x": 385, + "y": 305, "w": 20, "h": 18 } }, { - "filename": "baton", + "filename": "miracle_seed", "rotated": false, "trimmed": true, "sourceSize": { @@ -6654,20 +6822,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, + "x": 6, "y": 7, - "w": 18, - "h": 18 + "w": 19, + "h": 19 }, "frame": { - "x": 156, - "y": 341, - "w": 18, - "h": 18 + "x": 404, + "y": 154, + "w": 19, + "h": 19 } }, { - "filename": "wl_awakening", + "filename": "aggronite", "rotated": false, "trimmed": true, "sourceSize": { @@ -6675,20 +6843,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, + "x": 8, "y": 8, - "w": 20, - "h": 18 + "w": 16, + "h": 16 }, "frame": { - "x": 174, - "y": 345, - "w": 20, - "h": 18 + "x": 404, + "y": 173, + "w": 16, + "h": 16 } }, { - "filename": "wl_burn_heal", + "filename": "lucky_egg", "rotated": false, "trimmed": true, "sourceSize": { @@ -6696,20 +6864,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 8, - "w": 20, - "h": 18 + "x": 7, + "y": 6, + "w": 17, + "h": 20 }, "frame": { - "x": 195, - "y": 335, - "w": 20, - "h": 18 + "x": 405, + "y": 189, + "w": 17, + "h": 20 } }, { - "filename": "wl_custom_spliced", + "filename": "oval_stone", "rotated": false, "trimmed": true, "sourceSize": { @@ -6717,20 +6885,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 8, - "w": 20, - "h": 18 + "x": 7, + "y": 7, + "w": 18, + "h": 19 }, "frame": { - "x": 215, - "y": 341, - "w": 20, - "h": 18 + "x": 406, + "y": 209, + "w": 18, + "h": 19 } }, { - "filename": "wl_custom_thief", + "filename": "light_ball", "rotated": false, "trimmed": true, "sourceSize": { @@ -6738,20 +6906,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 8, - "w": 20, + "x": 7, + "y": 7, + "w": 18, "h": 18 }, "frame": { - "x": 235, - "y": 341, - "w": 20, + "x": 406, + "y": 228, + "w": 18, "h": 18 } }, { - "filename": "wl_elixir", + "filename": "light_stone", "rotated": false, "trimmed": true, "sourceSize": { @@ -6759,20 +6927,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 8, - "w": 20, + "x": 7, + "y": 7, + "w": 18, "h": 18 }, "frame": { - "x": 194, - "y": 353, - "w": 20, + "x": 406, + "y": 246, + "w": 18, "h": 18 } }, { - "filename": "wl_ether", + "filename": "toxic_orb", "rotated": false, "trimmed": true, "sourceSize": { @@ -6780,20 +6948,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 6, - "y": 8, - "w": 20, + "x": 7, + "y": 7, + "w": 18, "h": 18 }, "frame": { - "x": 214, - "y": 359, - "w": 20, + "x": 406, + "y": 264, + "w": 18, "h": 18 } }, { - "filename": "wl_full_heal", + "filename": "wl_ether", "rotated": false, "trimmed": true, "sourceSize": { @@ -6807,14 +6975,14 @@ "h": 18 }, "frame": { - "x": 234, - "y": 359, + "x": 406, + "y": 282, "w": 20, "h": 18 } }, { - "filename": "candy", + "filename": "wl_full_heal", "rotated": false, "trimmed": true, "sourceSize": { @@ -6822,15 +6990,15 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, - "y": 11, - "w": 18, + "x": 6, + "y": 8, + "w": 20, "h": 18 }, "frame": { - "x": 156, - "y": 359, - "w": 18, + "x": 405, + "y": 300, + "w": 20, "h": 18 } }, @@ -6849,8 +7017,8 @@ "h": 18 }, "frame": { - "x": 174, - "y": 363, + "x": 405, + "y": 318, "w": 20, "h": 18 } @@ -6870,8 +7038,8 @@ "h": 18 }, "frame": { - "x": 194, - "y": 371, + "x": 267, + "y": 282, "w": 20, "h": 18 } @@ -6891,8 +7059,8 @@ "h": 18 }, "frame": { - "x": 214, - "y": 377, + "x": 287, + "y": 282, "w": 20, "h": 18 } @@ -6912,14 +7080,14 @@ "h": 18 }, "frame": { - "x": 234, - "y": 377, + "x": 307, + "y": 297, "w": 20, "h": 18 } }, { - "filename": "relic_gold", + "filename": "alakazite", "rotated": false, "trimmed": true, "sourceSize": { @@ -6927,16 +7095,16 @@ "h": 32 }, "spriteSourceSize": { - "x": 9, - "y": 11, - "w": 15, - "h": 11 + "x": 8, + "y": 8, + "w": 16, + "h": 16 }, "frame": { - "x": 141, - "y": 363, - "w": 15, - "h": 11 + "x": 327, + "y": 308, + "w": 16, + "h": 16 } }, { @@ -6954,8 +7122,8 @@ "h": 18 }, "frame": { - "x": 141, - "y": 377, + "x": 343, + "y": 317, "w": 20, "h": 18 } @@ -6975,8 +7143,8 @@ "h": 18 }, "frame": { - "x": 141, - "y": 395, + "x": 363, + "y": 318, "w": 20, "h": 18 } @@ -6996,8 +7164,8 @@ "h": 18 }, "frame": { - "x": 161, - "y": 381, + "x": 383, + "y": 323, "w": 20, "h": 18 } @@ -7017,8 +7185,8 @@ "h": 18 }, "frame": { - "x": 161, - "y": 399, + "x": 403, + "y": 336, "w": 20, "h": 18 } @@ -7038,8 +7206,8 @@ "h": 18 }, "frame": { - "x": 181, - "y": 389, + "x": 246, + "y": 284, "w": 20, "h": 18 } @@ -7059,8 +7227,8 @@ "h": 18 }, "frame": { - "x": 181, - "y": 407, + "x": 245, + "y": 302, "w": 20, "h": 18 } @@ -7080,35 +7248,14 @@ "h": 18 }, "frame": { - "x": 201, - "y": 395, - "w": 20, - "h": 18 - } - }, - { - "filename": "wl_potion", - "rotated": false, - "trimmed": true, - "sourceSize": { - "w": 32, - "h": 32 - }, - "spriteSourceSize": { - "x": 6, - "y": 8, - "w": 20, - "h": 18 - }, - "frame": { - "x": 221, - "y": 395, + "x": 245, + "y": 320, "w": 20, "h": 18 } }, { - "filename": "dark_stone", + "filename": "relic_gold", "rotated": false, "trimmed": true, "sourceSize": { @@ -7116,20 +7263,20 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, - "y": 7, - "w": 18, - "h": 18 + "x": 9, + "y": 11, + "w": 15, + "h": 11 }, "frame": { - "x": 241, - "y": 395, - "w": 18, - "h": 18 + "x": 245, + "y": 338, + "w": 15, + "h": 11 } }, { - "filename": "flame_orb", + "filename": "wl_potion", "rotated": false, "trimmed": true, "sourceSize": { @@ -7137,15 +7284,15 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, - "y": 7, - "w": 18, + "x": 6, + "y": 8, + "w": 20, "h": 18 }, "frame": { - "x": 255, - "y": 341, - "w": 18, + "x": 249, + "y": 349, + "w": 20, "h": 18 } }, @@ -7164,8 +7311,8 @@ "h": 18 }, "frame": { - "x": 254, - "y": 359, + "x": 249, + "y": 367, "w": 20, "h": 18 } @@ -7185,33 +7332,12 @@ "h": 18 }, "frame": { - "x": 254, - "y": 377, + "x": 253, + "y": 385, "w": 20, "h": 18 } }, - { - "filename": "light_ball", - "rotated": false, - "trimmed": true, - "sourceSize": { - "w": 32, - "h": 32 - }, - "spriteSourceSize": { - "x": 7, - "y": 7, - "w": 18, - "h": 18 - }, - "frame": { - "x": 259, - "y": 395, - "w": 18, - "h": 18 - } - }, { "filename": "wl_super_potion", "rotated": false, @@ -7227,35 +7353,14 @@ "h": 18 }, "frame": { - "x": 259, - "y": 320, + "x": 260, + "y": 403, "w": 20, "h": 18 } }, { - "filename": "light_stone", - "rotated": false, - "trimmed": true, - "sourceSize": { - "w": 32, - "h": 32 - }, - "spriteSourceSize": { - "x": 7, - "y": 7, - "w": 18, - "h": 18 - }, - "frame": { - "x": 279, - "y": 320, - "w": 18, - "h": 18 - } - }, - { - "filename": "toxic_orb", + "filename": "revive", "rotated": false, "trimmed": true, "sourceSize": { @@ -7263,16 +7368,16 @@ "h": 32 }, "spriteSourceSize": { - "x": 7, - "y": 7, - "w": 18, - "h": 18 + "x": 10, + "y": 8, + "w": 12, + "h": 17 }, "frame": { - "x": 297, - "y": 320, - "w": 18, - "h": 18 + "x": 265, + "y": 302, + "w": 12, + "h": 17 } }, { @@ -7290,8 +7395,8 @@ "h": 16 }, "frame": { - "x": 315, - "y": 320, + "x": 277, + "y": 300, "w": 16, "h": 16 } @@ -7311,8 +7416,8 @@ "h": 16 }, "frame": { - "x": 273, - "y": 338, + "x": 265, + "y": 319, "w": 16, "h": 16 } @@ -7332,8 +7437,8 @@ "h": 16 }, "frame": { - "x": 289, - "y": 338, + "x": 281, + "y": 316, "w": 16, "h": 16 } @@ -7353,8 +7458,8 @@ "h": 16 }, "frame": { - "x": 274, - "y": 354, + "x": 297, + "y": 315, "w": 16, "h": 16 } @@ -7374,8 +7479,8 @@ "h": 16 }, "frame": { - "x": 274, - "y": 370, + "x": 281, + "y": 332, "w": 16, "h": 16 } @@ -7395,8 +7500,8 @@ "h": 16 }, "frame": { - "x": 290, - "y": 354, + "x": 297, + "y": 331, "w": 16, "h": 16 } @@ -7416,8 +7521,8 @@ "h": 16 }, "frame": { - "x": 290, - "y": 370, + "x": 269, + "y": 348, "w": 16, "h": 16 } @@ -7437,8 +7542,8 @@ "h": 16 }, "frame": { - "x": 305, - "y": 338, + "x": 269, + "y": 364, "w": 16, "h": 16 } @@ -7458,8 +7563,8 @@ "h": 16 }, "frame": { - "x": 306, - "y": 354, + "x": 285, + "y": 348, "w": 16, "h": 16 } @@ -7479,8 +7584,8 @@ "h": 16 }, "frame": { - "x": 306, - "y": 370, + "x": 285, + "y": 364, "w": 16, "h": 16 } @@ -7500,8 +7605,8 @@ "h": 16 }, "frame": { - "x": 331, - "y": 320, + "x": 273, + "y": 380, "w": 16, "h": 16 } @@ -7521,8 +7626,8 @@ "h": 16 }, "frame": { - "x": 321, - "y": 336, + "x": 289, + "y": 380, "w": 16, "h": 16 } @@ -7542,8 +7647,8 @@ "h": 16 }, "frame": { - "x": 322, - "y": 352, + "x": 301, + "y": 347, "w": 16, "h": 16 } @@ -7563,8 +7668,8 @@ "h": 16 }, "frame": { - "x": 322, - "y": 368, + "x": 301, + "y": 363, "w": 16, "h": 16 } @@ -7584,8 +7689,8 @@ "h": 16 }, "frame": { - "x": 337, - "y": 336, + "x": 305, + "y": 379, "w": 16, "h": 16 } @@ -7605,8 +7710,8 @@ "h": 16 }, "frame": { - "x": 338, - "y": 352, + "x": 280, + "y": 396, "w": 16, "h": 16 } @@ -7626,8 +7731,8 @@ "h": 16 }, "frame": { - "x": 338, - "y": 368, + "x": 280, + "y": 412, "w": 16, "h": 16 } @@ -7647,8 +7752,8 @@ "h": 16 }, "frame": { - "x": 347, - "y": 313, + "x": 296, + "y": 396, "w": 16, "h": 16 } @@ -7668,8 +7773,8 @@ "h": 16 }, "frame": { - "x": 277, - "y": 386, + "x": 296, + "y": 412, "w": 16, "h": 16 } @@ -7689,8 +7794,8 @@ "h": 16 }, "frame": { - "x": 293, - "y": 386, + "x": 312, + "y": 395, "w": 16, "h": 16 } @@ -7710,8 +7815,8 @@ "h": 16 }, "frame": { - "x": 309, - "y": 386, + "x": 312, + "y": 411, "w": 16, "h": 16 } @@ -7731,8 +7836,8 @@ "h": 16 }, "frame": { - "x": 325, - "y": 384, + "x": 313, + "y": 324, "w": 16, "h": 16 } @@ -7752,8 +7857,8 @@ "h": 16 }, "frame": { - "x": 341, - "y": 384, + "x": 317, + "y": 340, "w": 16, "h": 16 } @@ -7773,8 +7878,8 @@ "h": 16 }, "frame": { - "x": 277, - "y": 402, + "x": 317, + "y": 356, "w": 16, "h": 16 } @@ -7794,8 +7899,8 @@ "h": 16 }, "frame": { - "x": 293, - "y": 402, + "x": 321, + "y": 372, "w": 16, "h": 16 } @@ -7815,8 +7920,8 @@ "h": 16 }, "frame": { - "x": 309, - "y": 402, + "x": 328, + "y": 388, "w": 16, "h": 16 } @@ -7836,8 +7941,8 @@ "h": 16 }, "frame": { - "x": 325, - "y": 400, + "x": 328, + "y": 404, "w": 16, "h": 16 } @@ -7857,8 +7962,8 @@ "h": 16 }, "frame": { - "x": 341, - "y": 400, + "x": 333, + "y": 335, "w": 16, "h": 16 } @@ -7878,8 +7983,8 @@ "h": 16 }, "frame": { - "x": 353, - "y": 329, + "x": 333, + "y": 351, "w": 16, "h": 16 } @@ -7899,8 +8004,8 @@ "h": 16 }, "frame": { - "x": 369, - "y": 322, + "x": 337, + "y": 367, "w": 16, "h": 16 } @@ -7920,8 +8025,8 @@ "h": 16 }, "frame": { - "x": 354, - "y": 345, + "x": 344, + "y": 383, "w": 16, "h": 16 } @@ -7941,8 +8046,8 @@ "h": 16 }, "frame": { - "x": 354, - "y": 361, + "x": 344, + "y": 399, "w": 16, "h": 16 } @@ -7962,8 +8067,8 @@ "h": 16 }, "frame": { - "x": 385, - "y": 329, + "x": 349, + "y": 336, "w": 16, "h": 16 } @@ -7983,8 +8088,8 @@ "h": 16 }, "frame": { - "x": 401, - "y": 332, + "x": 365, + "y": 336, "w": 16, "h": 16 } @@ -8004,8 +8109,8 @@ "h": 16 }, "frame": { - "x": 357, - "y": 377, + "x": 381, + "y": 341, "w": 16, "h": 16 } @@ -8025,8 +8130,8 @@ "h": 16 }, "frame": { - "x": 357, - "y": 393, + "x": 353, + "y": 352, "w": 16, "h": 16 } @@ -8046,8 +8151,8 @@ "h": 16 }, "frame": { - "x": 357, - "y": 409, + "x": 369, + "y": 357, "w": 16, "h": 16 } @@ -8067,8 +8172,8 @@ "h": 16 }, "frame": { - "x": 370, - "y": 345, + "x": 385, + "y": 357, "w": 16, "h": 16 } @@ -8088,8 +8193,8 @@ "h": 16 }, "frame": { - "x": 370, - "y": 361, + "x": 401, + "y": 354, "w": 16, "h": 16 } @@ -8109,8 +8214,8 @@ "h": 16 }, "frame": { - "x": 373, - "y": 377, + "x": 401, + "y": 370, "w": 16, "h": 16 } @@ -8130,8 +8235,8 @@ "h": 16 }, "frame": { - "x": 373, - "y": 393, + "x": 360, + "y": 386, "w": 16, "h": 16 } @@ -8151,8 +8256,8 @@ "h": 16 }, "frame": { - "x": 373, - "y": 409, + "x": 360, + "y": 402, "w": 16, "h": 16 } @@ -8172,8 +8277,8 @@ "h": 16 }, "frame": { - "x": 386, - "y": 348, + "x": 376, + "y": 373, "w": 16, "h": 16 } @@ -8193,8 +8298,8 @@ "h": 16 }, "frame": { - "x": 402, - "y": 348, + "x": 376, + "y": 389, "w": 16, "h": 16 } @@ -8214,8 +8319,8 @@ "h": 16 }, "frame": { - "x": 389, - "y": 364, + "x": 376, + "y": 405, "w": 16, "h": 16 } @@ -8235,8 +8340,8 @@ "h": 16 }, "frame": { - "x": 389, - "y": 380, + "x": 392, + "y": 386, "w": 16, "h": 16 } @@ -8247,6 +8352,6 @@ "meta": { "app": "https://www.codeandweb.com/texturepacker", "version": "3.0", - "smartupdate": "$TexturePacker:SmartUpdate:c004184e48566e1da6f13477a3348fd3:dc1a5489f7821641aade35ba290bbea7:110e074689c9edd2c54833ce2e4d9270$" + "smartupdate": "$TexturePacker:SmartUpdate:2fe5215a80083d35f525901078cb1f59:c17ac8d050238e3fca0ec935f6e8d37f:110e074689c9edd2c54833ce2e4d9270$" } } diff --git a/public/images/items.png b/public/images/items.png index 4c366e4d72ad..4724f7b7b0ea 100644 Binary files a/public/images/items.png and b/public/images/items.png differ diff --git a/public/images/items/berry_juice.png b/public/images/items/berry_juice.png new file mode 100644 index 000000000000..c0986b804f9f Binary files /dev/null and b/public/images/items/berry_juice.png differ diff --git a/public/images/items/black_sludge.png b/public/images/items/black_sludge.png new file mode 100644 index 000000000000..39684a403108 Binary files /dev/null and b/public/images/items/black_sludge.png differ diff --git a/public/images/items/golden_net.png b/public/images/items/golden_net.png new file mode 100644 index 000000000000..5fea1ee7dba1 Binary files /dev/null and b/public/images/items/golden_net.png differ diff --git a/public/images/items/macho_brace.png b/public/images/items/macho_brace.png new file mode 100644 index 000000000000..2085829e1ce7 Binary files /dev/null and b/public/images/items/macho_brace.png differ diff --git a/public/images/items/old_gateau.png b/public/images/items/old_gateau.png new file mode 100644 index 000000000000..c910e90f1016 Binary files /dev/null and b/public/images/items/old_gateau.png differ diff --git a/public/images/mystery-encounters/b2w2_lady.json b/public/images/mystery-encounters/b2w2_lady.json new file mode 100644 index 000000000000..e143086e1572 --- /dev/null +++ b/public/images/mystery-encounters/b2w2_lady.json @@ -0,0 +1,734 @@ +{ + "textures": [ + { + "image": "b2w2_lady.png", + "format": "RGBA8888", + "size": { + "w": 399, + "h": 360 + }, + "scale": 1, + "frames": [ + { + "filename": "0000.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 8, + "y": 8, + "w": 56, + "h": 72 + }, + "frame": { + "x": 0, + "y": 0, + "w": 56, + "h": 72 + } + }, + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 8, + "y": 8, + "w": 56, + "h": 72 + }, + "frame": { + "x": 57, + "y": 0, + "w": 56, + "h": 72 + } + }, + { + "filename": "0002.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 8, + "y": 8, + "w": 56, + "h": 72 + }, + "frame": { + "x": 114, + "y": 0, + "w": 56, + "h": 72 + } + }, + { + "filename": "0003.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 9, + "y": 8, + "w": 55, + "h": 72 + }, + "frame": { + "x": 171, + "y": 0, + "w": 55, + "h": 72 + } + }, + { + "filename": "0004.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 11, + "y": 8, + "w": 54, + "h": 72 + }, + "frame": { + "x": 228, + "y": 0, + "w": 54, + "h": 72 + } + }, + { + "filename": "0005.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 11, + "y": 8, + "w": 54, + "h": 72 + }, + "frame": { + "x": 285, + "y": 0, + "w": 54, + "h": 72 + } + }, + { + "filename": "0006.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 14, + "y": 8, + "w": 52, + "h": 72 + }, + "frame": { + "x": 342, + "y": 0, + "w": 52, + "h": 72 + } + }, + { + "filename": "0007.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 20, + "y": 8, + "w": 48, + "h": 72 + }, + "frame": { + "x": 0, + "y": 72, + "w": 48, + "h": 72 + } + }, + { + "filename": "0008.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 22, + "y": 8, + "w": 47, + "h": 72 + }, + "frame": { + "x": 57, + "y": 72, + "w": 47, + "h": 72 + } + }, + { + "filename": "0009.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 22, + "y": 8, + "w": 47, + "h": 72 + }, + "frame": { + "x": 114, + "y": 72, + "w": 47, + "h": 72 + } + }, + { + "filename": "0010.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 22, + "y": 8, + "w": 48, + "h": 72 + }, + "frame": { + "x": 171, + "y": 72, + "w": 48, + "h": 72 + } + }, + { + "filename": "0011.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 22, + "y": 8, + "w": 48, + "h": 72 + }, + "frame": { + "x": 228, + "y": 72, + "w": 48, + "h": 72 + } + }, + { + "filename": "0012.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 22, + "y": 8, + "w": 48, + "h": 72 + }, + "frame": { + "x": 285, + "y": 72, + "w": 48, + "h": 72 + } + }, + { + "filename": "0013.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 22, + "y": 8, + "w": 48, + "h": 72 + }, + "frame": { + "x": 342, + "y": 72, + "w": 48, + "h": 72 + } + }, + { + "filename": "0014.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 22, + "y": 8, + "w": 49, + "h": 72 + }, + "frame": { + "x": 0, + "y": 144, + "w": 49, + "h": 72 + } + }, + { + "filename": "0015.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 22, + "y": 8, + "w": 49, + "h": 72 + }, + "frame": { + "x": 57, + "y": 144, + "w": 49, + "h": 72 + } + }, + { + "filename": "0016.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 22, + "y": 8, + "w": 49, + "h": 72 + }, + "frame": { + "x": 114, + "y": 144, + "w": 49, + "h": 72 + } + }, + { + "filename": "0017.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 22, + "y": 8, + "w": 49, + "h": 72 + }, + "frame": { + "x": 171, + "y": 144, + "w": 49, + "h": 72 + } + }, + { + "filename": "0018.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 22, + "y": 8, + "w": 48, + "h": 72 + }, + "frame": { + "x": 228, + "y": 144, + "w": 48, + "h": 72 + } + }, + { + "filename": "0019.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 22, + "y": 8, + "w": 48, + "h": 72 + }, + "frame": { + "x": 285, + "y": 144, + "w": 48, + "h": 72 + } + }, + { + "filename": "0020.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 22, + "y": 8, + "w": 48, + "h": 72 + }, + "frame": { + "x": 342, + "y": 144, + "w": 48, + "h": 72 + } + }, + { + "filename": "0021.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 22, + "y": 8, + "w": 48, + "h": 72 + }, + "frame": { + "x": 0, + "y": 216, + "w": 48, + "h": 72 + } + }, + { + "filename": "0022.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 20, + "y": 8, + "w": 50, + "h": 72 + }, + "frame": { + "x": 57, + "y": 216, + "w": 50, + "h": 72 + } + }, + { + "filename": "0023.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 18, + "y": 8, + "w": 51, + "h": 72 + }, + "frame": { + "x": 114, + "y": 216, + "w": 51, + "h": 72 + } + }, + { + "filename": "0024.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 18, + "y": 8, + "w": 51, + "h": 72 + }, + "frame": { + "x": 171, + "y": 216, + "w": 51, + "h": 72 + } + }, + { + "filename": "0025.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 15, + "y": 8, + "w": 53, + "h": 72 + }, + "frame": { + "x": 228, + "y": 216, + "w": 53, + "h": 72 + } + }, + { + "filename": "0026.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 10, + "y": 8, + "w": 57, + "h": 72 + }, + "frame": { + "x": 285, + "y": 216, + "w": 57, + "h": 72 + } + }, + { + "filename": "0027.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 10, + "y": 8, + "w": 56, + "h": 72 + }, + "frame": { + "x": 342, + "y": 216, + "w": 56, + "h": 72 + } + }, + { + "filename": "0028.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 10, + "y": 8, + "w": 56, + "h": 72 + }, + "frame": { + "x": 0, + "y": 288, + "w": 56, + "h": 72 + } + }, + { + "filename": "0029.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 9, + "y": 8, + "w": 55, + "h": 72 + }, + "frame": { + "x": 57, + "y": 288, + "w": 55, + "h": 72 + } + }, + { + "filename": "0030.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 8, + "y": 8, + "w": 56, + "h": 72 + }, + "frame": { + "x": 114, + "y": 288, + "w": 56, + "h": 72 + } + }, + { + "filename": "0031.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 8, + "y": 8, + "w": 56, + "h": 72 + }, + "frame": { + "x": 171, + "y": 288, + "w": 56, + "h": 72 + } + }, + { + "filename": "0032.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 8, + "y": 8, + "w": 56, + "h": 72 + }, + "frame": { + "x": 228, + "y": 288, + "w": 56, + "h": 72 + } + }, + { + "filename": "0033.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 8, + "y": 8, + "w": 56, + "h": 72 + }, + "frame": { + "x": 285, + "y": 288, + "w": 56, + "h": 72 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:e7f062304401dbd7b3ec79512f0ff4cb:0136dac01331f88892a3df26aeab78f5:1ed1e22abb9b55d76337a5a599835c06$" + } +} diff --git a/public/images/mystery-encounters/b2w2_lady.png b/public/images/mystery-encounters/b2w2_lady.png new file mode 100644 index 000000000000..9dcc1281c9eb Binary files /dev/null and b/public/images/mystery-encounters/b2w2_lady.png differ diff --git a/public/images/mystery-encounters/b2w2_veteran_m.json b/public/images/mystery-encounters/b2w2_veteran_m.json new file mode 100644 index 000000000000..8f07c7d44e21 --- /dev/null +++ b/public/images/mystery-encounters/b2w2_veteran_m.json @@ -0,0 +1,797 @@ +{ + "textures": [ + { + "image": "b2w2_veteran_m.png", + "format": "RGBA8888", + "size": { + "w": 424, + "h": 390 + }, + "scale": 1, + "frames": [ + { + "filename": "0000.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 43, + "h": 78 + }, + "frame": { + "x": 0, + "y": 0, + "w": 43, + "h": 78 + } + }, + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 43, + "h": 78 + }, + "frame": { + "x": 53, + "y": 0, + "w": 43, + "h": 78 + } + }, + { + "filename": "0002.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 43, + "h": 78 + }, + "frame": { + "x": 106, + "y": 0, + "w": 43, + "h": 78 + } + }, + { + "filename": "0003.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 43, + "h": 78 + }, + "frame": { + "x": 159, + "y": 0, + "w": 43, + "h": 78 + } + }, + { + "filename": "0004.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 44, + "h": 78 + }, + "frame": { + "x": 212, + "y": 0, + "w": 44, + "h": 78 + } + }, + { + "filename": "0005.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 44, + "h": 78 + }, + "frame": { + "x": 265, + "y": 0, + "w": 44, + "h": 78 + } + }, + { + "filename": "0006.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 44, + "h": 78 + }, + "frame": { + "x": 318, + "y": 0, + "w": 44, + "h": 78 + } + }, + { + "filename": "0007.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 44, + "h": 78 + }, + "frame": { + "x": 371, + "y": 0, + "w": 44, + "h": 78 + } + }, + { + "filename": "0008.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 44, + "h": 78 + }, + "frame": { + "x": 0, + "y": 78, + "w": 44, + "h": 78 + } + }, + { + "filename": "0009.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 44, + "h": 78 + }, + "frame": { + "x": 53, + "y": 78, + "w": 44, + "h": 78 + } + }, + { + "filename": "0010.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 48, + "h": 78 + }, + "frame": { + "x": 106, + "y": 78, + "w": 48, + "h": 78 + } + }, + { + "filename": "0011.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 50, + "h": 78 + }, + "frame": { + "x": 159, + "y": 78, + "w": 50, + "h": 78 + } + }, + { + "filename": "0012.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 53, + "h": 78 + }, + "frame": { + "x": 212, + "y": 78, + "w": 53, + "h": 78 + } + }, + { + "filename": "0013.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 53, + "h": 78 + }, + "frame": { + "x": 265, + "y": 78, + "w": 53, + "h": 78 + } + }, + { + "filename": "0014.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 52, + "h": 78 + }, + "frame": { + "x": 318, + "y": 78, + "w": 52, + "h": 78 + } + }, + { + "filename": "0015.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 51, + "h": 78 + }, + "frame": { + "x": 371, + "y": 78, + "w": 51, + "h": 78 + } + }, + { + "filename": "0016.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 52, + "h": 78 + }, + "frame": { + "x": 0, + "y": 156, + "w": 52, + "h": 78 + } + }, + { + "filename": "0017.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 52, + "h": 78 + }, + "frame": { + "x": 53, + "y": 156, + "w": 52, + "h": 78 + } + }, + { + "filename": "0018.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 53, + "h": 78 + }, + "frame": { + "x": 106, + "y": 156, + "w": 53, + "h": 78 + } + }, + { + "filename": "0019.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 53, + "h": 78 + }, + "frame": { + "x": 159, + "y": 156, + "w": 53, + "h": 78 + } + }, + { + "filename": "0020.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 53, + "h": 78 + }, + "frame": { + "x": 212, + "y": 156, + "w": 53, + "h": 78 + } + }, + { + "filename": "0021.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 52, + "h": 78 + }, + "frame": { + "x": 265, + "y": 156, + "w": 52, + "h": 78 + } + }, + { + "filename": "0022.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 51, + "h": 78 + }, + "frame": { + "x": 318, + "y": 156, + "w": 51, + "h": 78 + } + }, + { + "filename": "0023.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 51, + "h": 78 + }, + "frame": { + "x": 371, + "y": 156, + "w": 51, + "h": 78 + } + }, + { + "filename": "0024.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 51, + "h": 78 + }, + "frame": { + "x": 0, + "y": 234, + "w": 51, + "h": 78 + } + }, + { + "filename": "0025.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 50, + "h": 78 + }, + "frame": { + "x": 53, + "y": 234, + "w": 50, + "h": 78 + } + }, + { + "filename": "0026.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 48, + "h": 78 + }, + "frame": { + "x": 106, + "y": 234, + "w": 48, + "h": 78 + } + }, + { + "filename": "0027.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 46, + "h": 78 + }, + "frame": { + "x": 159, + "y": 234, + "w": 46, + "h": 78 + } + }, + { + "filename": "0028.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 46, + "h": 78 + }, + "frame": { + "x": 212, + "y": 234, + "w": 46, + "h": 78 + } + }, + { + "filename": "0029.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 44, + "h": 78 + }, + "frame": { + "x": 265, + "y": 234, + "w": 44, + "h": 78 + } + }, + { + "filename": "0030.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 44, + "h": 78 + }, + "frame": { + "x": 318, + "y": 234, + "w": 44, + "h": 78 + } + }, + { + "filename": "0031.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 44, + "h": 78 + }, + "frame": { + "x": 371, + "y": 234, + "w": 44, + "h": 78 + } + }, + { + "filename": "0032.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 44, + "h": 78 + }, + "frame": { + "x": 0, + "y": 312, + "w": 44, + "h": 78 + } + }, + { + "filename": "0033.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 43, + "h": 78 + }, + "frame": { + "x": 53, + "y": 312, + "w": 43, + "h": 78 + } + }, + { + "filename": "0034.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 43, + "h": 78 + }, + "frame": { + "x": 106, + "y": 312, + "w": 43, + "h": 78 + } + }, + { + "filename": "0035.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 43, + "h": 78 + }, + "frame": { + "x": 159, + "y": 312, + "w": 43, + "h": 78 + } + }, + { + "filename": "0036.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 2, + "w": 43, + "h": 78 + }, + "frame": { + "x": 212, + "y": 312, + "w": 43, + "h": 78 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:4deb068879a8ac195cb4f00c8b17b7f5:b32f0f90436649264b6f3c49b09ac06a:05e903aa75b8e50c28334d9b5e14c85a$" + } +} diff --git a/public/images/mystery-encounters/b2w2_veteran_m.png b/public/images/mystery-encounters/b2w2_veteran_m.png new file mode 100644 index 000000000000..967d82973e61 Binary files /dev/null and b/public/images/mystery-encounters/b2w2_veteran_m.png differ diff --git a/public/images/mystery-encounters/bait.json b/public/images/mystery-encounters/bait.json new file mode 100644 index 000000000000..ae9ee38ee138 --- /dev/null +++ b/public/images/mystery-encounters/bait.json @@ -0,0 +1,83 @@ +{ + "textures": [ + { + "image": "bait.png", + "format": "RGBA8888", + "size": { + "w": 14, + "h": 43 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 12, + "h": 16 + }, + "spriteSourceSize": { + "x": 0, + "y": 3, + "w": 12, + "h": 13 + }, + "frame": { + "x": 1, + "y": 1, + "w": 12, + "h": 13 + } + }, + { + "filename": "0002.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 12, + "h": 16 + }, + "spriteSourceSize": { + "x": 0, + "y": 3, + "w": 12, + "h": 13 + }, + "frame": { + "x": 1, + "y": 16, + "w": 12, + "h": 13 + } + }, + { + "filename": "0003.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 12, + "h": 16 + }, + "spriteSourceSize": { + "x": 0, + "y": 5, + "w": 11, + "h": 11 + }, + "frame": { + "x": 1, + "y": 31, + "w": 11, + "h": 11 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:f0ec04fcd67ac346dce973693711d032:b697e09191c4312b8faaa0a080a309b7:1af241a52e61fa01ca849aa03c112f85$" + } +} diff --git a/public/images/mystery-encounters/bait.png b/public/images/mystery-encounters/bait.png new file mode 100644 index 000000000000..7de9169d1875 Binary files /dev/null and b/public/images/mystery-encounters/bait.png differ diff --git a/public/images/mystery-encounters/berry_bush.json b/public/images/mystery-encounters/berry_bush.json new file mode 100644 index 000000000000..397538d8af2d --- /dev/null +++ b/public/images/mystery-encounters/berry_bush.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "berry_bush.png", + "format": "RGBA8888", + "size": { + "w": 49, + "h": 53 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 49, + "h": 53 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 49, + "h": 53 + }, + "frame": { + "x": 0, + "y": 0, + "w": 49, + "h": 53 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:d5f83625477b5f98b726343f4a3a396f:f4665258986e97345cfeee041b4b8bcf:e7781fcc447e6d12deb2af78c9493c7f$" + } +} diff --git a/public/images/mystery-encounters/berry_bush.png b/public/images/mystery-encounters/berry_bush.png new file mode 100644 index 000000000000..e9be20b4863b Binary files /dev/null and b/public/images/mystery-encounters/berry_bush.png differ diff --git a/public/images/mystery-encounters/buoy.json b/public/images/mystery-encounters/buoy.json new file mode 100644 index 000000000000..ba5d9567fe5d --- /dev/null +++ b/public/images/mystery-encounters/buoy.json @@ -0,0 +1,19 @@ +{ "frames": [ + { + "filename": "0001.png", + "frame": { "x": 0, "y": 0, "w": 46, "h": 60 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 46, "h": 60 }, + "sourceSize": { "w": 46, "h": 60 } + } + ], + "meta": { + "app": "https://www.aseprite.org/", + "version": "1.3.7-x64", + "image": "buoy-sheet.png", + "format": "RGBA8888", + "size": { "w": 46, "h": 60 }, + "scale": "1" + } +} diff --git a/public/images/mystery-encounters/buoy.png b/public/images/mystery-encounters/buoy.png new file mode 100644 index 000000000000..fb957ac29f04 Binary files /dev/null and b/public/images/mystery-encounters/buoy.png differ diff --git a/public/images/mystery-encounters/carnival_game.json b/public/images/mystery-encounters/carnival_game.json new file mode 100644 index 000000000000..0572b95990ce --- /dev/null +++ b/public/images/mystery-encounters/carnival_game.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "carnival_game.png", + "format": "RGBA8888", + "size": { + "w": 38, + "h": 82 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 38, + "h": 82 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 38, + "h": 82 + }, + "frame": { + "x": 0, + "y": 0, + "w": 38, + "h": 82 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:d40b6742392c2fe8ca0735b3f561e319:5dcda5410b12f0aa75eb0dd1fbcbe4f9:d171fb17d3017d1f655cd8dd14c252b7$" + } +} diff --git a/public/images/mystery-encounters/carnival_game.png b/public/images/mystery-encounters/carnival_game.png new file mode 100644 index 000000000000..03a3b9c9cbcb Binary files /dev/null and b/public/images/mystery-encounters/carnival_game.png differ diff --git a/public/images/mystery-encounters/carnival_man.json b/public/images/mystery-encounters/carnival_man.json new file mode 100644 index 000000000000..3e77765bbce0 --- /dev/null +++ b/public/images/mystery-encounters/carnival_man.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "carnival_man.png", + "format": "RGBA8888", + "size": { + "w": 50, + "h": 77 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 15, + "y": 3, + "w": 50, + "h": 77 + }, + "frame": { + "x": 0, + "y": 0, + "w": 50, + "h": 77 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:e80aa9a809a7cca6d05992cb82f6dbd9:ea9962edd1cdc1e503deecf2ce1863c1:55647352b6547cf03212506309f2abf5$" + } +} diff --git a/public/images/mystery-encounters/carnival_man.png b/public/images/mystery-encounters/carnival_man.png new file mode 100644 index 000000000000..05f94dbd33df Binary files /dev/null and b/public/images/mystery-encounters/carnival_man.png differ diff --git a/public/images/mystery-encounters/carnival_wobbuffet.json b/public/images/mystery-encounters/carnival_wobbuffet.json new file mode 100644 index 000000000000..c059bb35a963 --- /dev/null +++ b/public/images/mystery-encounters/carnival_wobbuffet.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "carnival_wobbuffet.png", + "format": "RGBA8888", + "size": { + "w": 45, + "h": 55 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 45, + "h": 55 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 45, + "h": 55 + }, + "frame": { + "x": 0, + "y": 0, + "w": 45, + "h": 55 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:879de17da906ea52e5a71afacb88fcf6:90f64e8eaac4ff1e67373f60c3d98d36:a090cb3294ca1218a4f90ecb97df81d7$" + } +} diff --git a/public/images/mystery-encounters/carnival_wobbuffet.png b/public/images/mystery-encounters/carnival_wobbuffet.png new file mode 100644 index 000000000000..37e7220196a7 Binary files /dev/null and b/public/images/mystery-encounters/carnival_wobbuffet.png differ diff --git a/public/images/mystery-encounters/chest_blue.json b/public/images/mystery-encounters/chest_blue.json new file mode 100644 index 000000000000..916afc3242cd --- /dev/null +++ b/public/images/mystery-encounters/chest_blue.json @@ -0,0 +1,209 @@ +{ + "textures": [ + { + "image": "chest_blue.png", + "format": "RGBA8888", + "size": { + "w": 54, + "h": 492 + }, + "scale": 1, + "frames": [ + { + "filename": "0000.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 46, + "h": 39 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 46, + "h": 39 + }, + "frame": { + "x": 0, + "y": 0, + "w": 46, + "h": 39 + } + }, + { + "filename": "0001.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 47, + "h": 35 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 47, + "h": 35 + }, + "frame": { + "x": 0, + "y": 39, + "w": 47, + "h": 35 + } + }, + { + "filename": "0002.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 46, + "h": 39 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 46, + "h": 39 + }, + "frame": { + "x": 0, + "y": 74, + "w": 46, + "h": 39 + } + }, + { + "filename": "0003.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 46, + "h": 46 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 46, + "h": 46 + }, + "frame": { + "x": 0, + "y": 113, + "w": 46, + "h": 46 + } + }, + { + "filename": "0004.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 53, + "h": 65 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 53, + "h": 65 + }, + "frame": { + "x": 0, + "y": 159, + "w": 53, + "h": 65 + } + }, + { + "filename": "0005.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 54, + "h": 67 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 54, + "h": 67 + }, + "frame": { + "x": 0, + "y": 224, + "w": 54, + "h": 67 + } + }, + { + "filename": "0006.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 54, + "h": 67 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 54, + "h": 67 + }, + "frame": { + "x": 0, + "y": 291, + "w": 54, + "h": 67 + } + }, + { + "filename": "0007.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 54, + "h": 67 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 54, + "h": 67 + }, + "frame": { + "x": 0, + "y": 358, + "w": 54, + "h": 67 + } + }, + { + "filename": "0008.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 54, + "h": 67 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 54, + "h": 67 + }, + "frame": { + "x": 0, + "y": 425, + "w": 54, + "h": 67 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:017ecc2437e580a185f9843f97e80da5:f44ef1c27a4a17183a5bcf1f7fc8ce6a:f4f3c064e6c93b8d1290f93bee927f60$" + } +} diff --git a/public/images/mystery-encounters/chest_blue.png b/public/images/mystery-encounters/chest_blue.png new file mode 100644 index 000000000000..e67bdcafa045 Binary files /dev/null and b/public/images/mystery-encounters/chest_blue.png differ diff --git a/public/images/mystery-encounters/chest_red.json b/public/images/mystery-encounters/chest_red.json new file mode 100644 index 000000000000..579cf7bda061 --- /dev/null +++ b/public/images/mystery-encounters/chest_red.json @@ -0,0 +1,209 @@ +{ + "textures": [ + { + "image": "chest_red.png", + "format": "RGBA8888", + "size": { + "w": 54, + "h": 492 + }, + "scale": 1, + "frames": [ + { + "filename": "0000.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 46, + "h": 39 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 46, + "h": 39 + }, + "frame": { + "x": 0, + "y": 0, + "w": 46, + "h": 39 + } + }, + { + "filename": "0001.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 47, + "h": 35 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 47, + "h": 35 + }, + "frame": { + "x": 0, + "y": 39, + "w": 47, + "h": 35 + } + }, + { + "filename": "0002.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 46, + "h": 39 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 46, + "h": 39 + }, + "frame": { + "x": 0, + "y": 74, + "w": 46, + "h": 39 + } + }, + { + "filename": "0003.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 46, + "h": 46 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 46, + "h": 46 + }, + "frame": { + "x": 0, + "y": 113, + "w": 46, + "h": 46 + } + }, + { + "filename": "0004.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 53, + "h": 65 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 53, + "h": 65 + }, + "frame": { + "x": 0, + "y": 159, + "w": 53, + "h": 65 + } + }, + { + "filename": "0005.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 54, + "h": 67 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 54, + "h": 67 + }, + "frame": { + "x": 0, + "y": 224, + "w": 54, + "h": 67 + } + }, + { + "filename": "0006.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 54, + "h": 67 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 54, + "h": 67 + }, + "frame": { + "x": 0, + "y": 291, + "w": 54, + "h": 67 + } + }, + { + "filename": "0007.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 54, + "h": 67 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 54, + "h": 67 + }, + "frame": { + "x": 0, + "y": 358, + "w": 54, + "h": 67 + } + }, + { + "filename": "0008.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 54, + "h": 67 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 54, + "h": 67 + }, + "frame": { + "x": 0, + "y": 425, + "w": 54, + "h": 67 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:2a0b6c93c5be115efa635d40780603f0:b5fde49f991c2ecc49afedd80cc8a544:a163d960e9966469ae4dde4b53c13496$" + } +} diff --git a/public/images/mystery-encounters/chest_red.png b/public/images/mystery-encounters/chest_red.png new file mode 100644 index 000000000000..c20a8218be64 Binary files /dev/null and b/public/images/mystery-encounters/chest_red.png differ diff --git a/public/images/mystery-encounters/dark_deal_porygon.json b/public/images/mystery-encounters/dark_deal_porygon.json new file mode 100644 index 000000000000..5a48d95c18d4 --- /dev/null +++ b/public/images/mystery-encounters/dark_deal_porygon.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "dark_deal_porygon.png", + "format": "RGBA8888", + "size": { + "w": 36, + "h": 45 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 36, + "h": 45 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 44, + "h": 44 + }, + "frame": { + "x": 0, + "y": 0, + "w": 36, + "h": 45 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:895f0a79b89fa0fb44167f4584fd9a22:357b46953b7e17c6b2f43a62d52855d8:cc1ed0e4f90aaa9dcf1b39a0af1283b0$" + } +} diff --git a/public/images/mystery-encounters/dark_deal_porygon.png b/public/images/mystery-encounters/dark_deal_porygon.png new file mode 100644 index 000000000000..168999fb0f46 Binary files /dev/null and b/public/images/mystery-encounters/dark_deal_porygon.png differ diff --git a/public/images/mystery-encounters/encounter_radar.png b/public/images/mystery-encounters/encounter_radar.png new file mode 100644 index 000000000000..deb9426c2696 Binary files /dev/null and b/public/images/mystery-encounters/encounter_radar.png differ diff --git a/public/images/mystery-encounters/exclaim.png b/public/images/mystery-encounters/exclaim.png new file mode 100644 index 000000000000..a7727f4da2e1 Binary files /dev/null and b/public/images/mystery-encounters/exclaim.png differ diff --git a/public/images/mystery-encounters/global_trade_system.json b/public/images/mystery-encounters/global_trade_system.json new file mode 100644 index 000000000000..ae5d96127b77 --- /dev/null +++ b/public/images/mystery-encounters/global_trade_system.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "global_trade_system.png", + "format": "RGBA8888", + "size": { + "w": 77, + "h": 78 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 77, + "h": 78 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 77, + "h": 78 + }, + "frame": { + "x": 0, + "y": 0, + "w": 77, + "h": 78 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:8a51d7a17b3d8c32f0e5e4a0f15daeb4:6eba29c5345847f735d8b69a05fc49d1:98ad8b8b8d8c4865d7d23ec97b516594$" + } +} diff --git a/public/images/mystery-encounters/global_trade_system.png b/public/images/mystery-encounters/global_trade_system.png new file mode 100644 index 000000000000..cb0ffb0ab200 Binary files /dev/null and b/public/images/mystery-encounters/global_trade_system.png differ diff --git a/public/images/mystery-encounters/mad_scientist_m.json b/public/images/mystery-encounters/mad_scientist_m.json new file mode 100644 index 000000000000..10aa3d6f42af --- /dev/null +++ b/public/images/mystery-encounters/mad_scientist_m.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "mad_scientist_m.png", + "format": "RGBA8888", + "size": { + "w": 46, + "h": 76 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 44, + "h": 74 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 44, + "h": 74 + }, + "frame": { + "x": 1, + "y": 1, + "w": 44, + "h": 74 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:a7f8ff2bbb362868f51125c254eb6681:cf76e61ddd31a8f46af67ced168c44a2:4fc09abe16c0608828269e5da81d0744$" + } +} diff --git a/public/images/mystery-encounters/mad_scientist_m.png b/public/images/mystery-encounters/mad_scientist_m.png new file mode 100644 index 000000000000..453cb767ec14 Binary files /dev/null and b/public/images/mystery-encounters/mad_scientist_m.png differ diff --git a/public/images/mystery-encounters/mud.json b/public/images/mystery-encounters/mud.json new file mode 100644 index 000000000000..505a6fadd27c --- /dev/null +++ b/public/images/mystery-encounters/mud.json @@ -0,0 +1,104 @@ +{ + "textures": [ + { + "image": "mud.png", + "format": "RGBA8888", + "size": { + "w": 14, + "h": 68 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 12, + "h": 20 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 12, + "h": 13 + }, + "frame": { + "x": 1, + "y": 1, + "w": 12, + "h": 13 + } + }, + { + "filename": "0002.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 12, + "h": 20 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 12, + "h": 14 + }, + "frame": { + "x": 1, + "y": 16, + "w": 12, + "h": 14 + } + }, + { + "filename": "0003.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 12, + "h": 20 + }, + "spriteSourceSize": { + "x": 0, + "y": 1, + "w": 12, + "h": 16 + }, + "frame": { + "x": 1, + "y": 32, + "w": 12, + "h": 16 + } + }, + { + "filename": "0004.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 12, + "h": 20 + }, + "spriteSourceSize": { + "x": 0, + "y": 3, + "w": 12, + "h": 17 + }, + "frame": { + "x": 1, + "y": 50, + "w": 12, + "h": 17 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:4f18a8effb8f01eb70f9f25b8294c1bf:ad663a73c51f780bbf45d00a52519553:c64f6b8befc3d5e9f836246d2b9536be$" + } +} diff --git a/public/images/mystery-encounters/mud.png b/public/images/mystery-encounters/mud.png new file mode 100644 index 000000000000..2ba7cb00047a Binary files /dev/null and b/public/images/mystery-encounters/mud.png differ diff --git a/public/images/mystery-encounters/pokemon_salesman.json b/public/images/mystery-encounters/pokemon_salesman.json new file mode 100644 index 000000000000..23d9df44f2b2 --- /dev/null +++ b/public/images/mystery-encounters/pokemon_salesman.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "pokemon_salesman.png", + "format": "RGBA8888", + "size": { + "w": 40, + "h": 80 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 21, + "y": 2, + "w": 38, + "h": 78 + }, + "frame": { + "x": 1, + "y": 1, + "w": 38, + "h": 78 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:dd57e3db21f3933c15be65bec261f4c1:05c7ef32252a5c2d3ad007b7e26fabd7:ae82f52e471ed81e2558206f05476cd7$" + } +} diff --git a/public/images/mystery-encounters/pokemon_salesman.png b/public/images/mystery-encounters/pokemon_salesman.png new file mode 100644 index 000000000000..1251dd8eda70 Binary files /dev/null and b/public/images/mystery-encounters/pokemon_salesman.png differ diff --git a/public/images/mystery-encounters/safari_zone.json b/public/images/mystery-encounters/safari_zone.json new file mode 100644 index 000000000000..fe81d1b9f539 --- /dev/null +++ b/public/images/mystery-encounters/safari_zone.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "safari_zone.png", + "format": "RGBA8888", + "size": { + "w": 120, + "h": 84 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 118, + "h": 82 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 118, + "h": 82 + }, + "frame": { + "x": 1, + "y": 1, + "w": 118, + "h": 82 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:6fad7a61e47043b974153148b4fd3997:5ec4d0890f2f03446daf22c8ae8ba77b:87aa745cd95eef6cbf38935230f4e10f$" + } +} diff --git a/public/images/mystery-encounters/safari_zone.png b/public/images/mystery-encounters/safari_zone.png new file mode 100644 index 000000000000..375d66ebbe98 Binary files /dev/null and b/public/images/mystery-encounters/safari_zone.png differ diff --git a/public/images/mystery-encounters/teacher.json b/public/images/mystery-encounters/teacher.json new file mode 100644 index 000000000000..457d440a010c --- /dev/null +++ b/public/images/mystery-encounters/teacher.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "teacher.png", + "format": "RGBA8888", + "size": { + "w": 43, + "h": 74 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 19, + "y": 8, + "w": 41, + "h": 72 + }, + "frame": { + "x": 1, + "y": 1, + "w": 41, + "h": 72 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:506e5a4ce79c134a7b4af90a90aef244:1b81d3d84bf12cedc419805eaff82548:59bc5dd000b5e72588320b473e31c312$" + } +} diff --git a/public/images/mystery-encounters/teacher.png b/public/images/mystery-encounters/teacher.png new file mode 100644 index 000000000000..b4332bc0032c Binary files /dev/null and b/public/images/mystery-encounters/teacher.png differ diff --git a/public/images/mystery-encounters/teleporter.json b/public/images/mystery-encounters/teleporter.json new file mode 100644 index 000000000000..4fe45807be2c --- /dev/null +++ b/public/images/mystery-encounters/teleporter.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "teleporter.png", + "format": "RGBA8888", + "size": { + "w": 74, + "h": 79 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 74, + "h": 79 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 74, + "h": 79 + }, + "frame": { + "x": 0, + "y": 0, + "w": 74, + "h": 79 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:937d8502b98f79720118061b6021e108:2b4f9db00d5b0997b42a5466f808509b:ce1615396ce7b0a146766d50b319bb81$" + } +} diff --git a/public/images/mystery-encounters/teleporter.png b/public/images/mystery-encounters/teleporter.png new file mode 100644 index 000000000000..9a049c30ab17 Binary files /dev/null and b/public/images/mystery-encounters/teleporter.png differ diff --git a/public/images/mystery-encounters/training_gear.json b/public/images/mystery-encounters/training_gear.json new file mode 100644 index 000000000000..fb8f4ec9c8ea --- /dev/null +++ b/public/images/mystery-encounters/training_gear.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "training_gear.png", + "format": "RGBA8888", + "size": { + "w": 76, + "h": 57 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 76, + "h": 57 + }, + "spriteSourceSize": { + "x": 10, + "y": 3, + "w": 56, + "h": 54 + }, + "frame": { + "x": 8, + "y": 0, + "w": 56, + "h": 54 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:895f0a79b89fa0fb44167f4584fd9a22:357b46953b7e17c6b2f43a62d52855d8:cc1ed0e4f90aaa9dcf1b39a0af1283b0$" + } +} diff --git a/public/images/mystery-encounters/training_gear.png b/public/images/mystery-encounters/training_gear.png new file mode 100644 index 000000000000..42c3a9bb7d4e Binary files /dev/null and b/public/images/mystery-encounters/training_gear.png differ diff --git a/public/images/mystery-encounters/warehouse_crate.json b/public/images/mystery-encounters/warehouse_crate.json new file mode 100644 index 000000000000..fa86d1a511de --- /dev/null +++ b/public/images/mystery-encounters/warehouse_crate.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "warehouse_crate.png", + "format": "RGBA8888", + "size": { + "w": 71, + "h": 52 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 56 + }, + "spriteSourceSize": { + "x": 5, + "y": 4, + "w": 71, + "h": 52 + }, + "frame": { + "x": 0, + "y": 0, + "w": 71, + "h": 52 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:c8df5f0b35fb9c2a69b0e4aaa9fa9f91:f1d4643c26f2aed86ad77d354e669aaf:0c073e3c2048ea0779db9429e5e1d8bc$" + } +} diff --git a/public/images/mystery-encounters/warehouse_crate.png b/public/images/mystery-encounters/warehouse_crate.png new file mode 100644 index 000000000000..fb70a6e534a8 Binary files /dev/null and b/public/images/mystery-encounters/warehouse_crate.png differ diff --git a/public/images/mystery-encounters/weird_dream_woman.json b/public/images/mystery-encounters/weird_dream_woman.json new file mode 100644 index 000000000000..66a9b8d68dbf --- /dev/null +++ b/public/images/mystery-encounters/weird_dream_woman.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "weird_dream_woman.png", + "format": "RGBA8888", + "size": { + "w": 78, + "h": 87 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 87 + }, + "spriteSourceSize": { + "x": 1, + "y": 0, + "w": 78, + "h": 87 + }, + "frame": { + "x": 0, + "y": 0, + "w": 78, + "h": 87 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:d3cce87ee0e3a880d840bffe9373d5d4:7c776d33b75abad1fe36b14a5e5734af:56468b7a2883e66dadcd2af13ebd8010$" + } +} diff --git a/public/images/mystery-encounters/weird_dream_woman.png b/public/images/mystery-encounters/weird_dream_woman.png new file mode 100644 index 000000000000..1b8d142ed5b2 Binary files /dev/null and b/public/images/mystery-encounters/weird_dream_woman.png differ diff --git a/public/images/trainer/buck.json b/public/images/trainer/buck.json new file mode 100644 index 000000000000..d2d215f716a9 --- /dev/null +++ b/public/images/trainer/buck.json @@ -0,0 +1,524 @@ +{ + "textures": [ + { + "image": "buck.png", + "format": "RGBA8888", + "size": { + "w": 120, + "h": 78 + }, + "scale": 1, + "frames": [ + { + "filename": "0002.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 33, + "y": 4, + "w": 35, + "h": 76 + }, + "frame": { + "x": 1, + "y": 1, + "w": 35, + "h": 76 + } + }, + { + "filename": "0003.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 33, + "y": 4, + "w": 35, + "h": 76 + }, + "frame": { + "x": 1, + "y": 1, + "w": 35, + "h": 76 + } + }, + { + "filename": "0006.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 33, + "y": 4, + "w": 35, + "h": 76 + }, + "frame": { + "x": 1, + "y": 1, + "w": 35, + "h": 76 + } + }, + { + "filename": "0007.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 33, + "y": 4, + "w": 35, + "h": 76 + }, + "frame": { + "x": 1, + "y": 1, + "w": 35, + "h": 76 + } + }, + { + "filename": "0010.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 33, + "y": 4, + "w": 35, + "h": 76 + }, + "frame": { + "x": 1, + "y": 1, + "w": 35, + "h": 76 + } + }, + { + "filename": "0011.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 33, + "y": 4, + "w": 35, + "h": 76 + }, + "frame": { + "x": 1, + "y": 1, + "w": 35, + "h": 76 + } + }, + { + "filename": "0014.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 33, + "y": 4, + "w": 35, + "h": 76 + }, + "frame": { + "x": 1, + "y": 1, + "w": 35, + "h": 76 + } + }, + { + "filename": "0015.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 33, + "y": 4, + "w": 35, + "h": 76 + }, + "frame": { + "x": 1, + "y": 1, + "w": 35, + "h": 76 + } + }, + { + "filename": "0018.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 18, + "y": 8, + "w": 44, + "h": 72 + }, + "frame": { + "x": 38, + "y": 1, + "w": 44, + "h": 72 + } + }, + { + "filename": "0019.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 18, + "y": 8, + "w": 44, + "h": 72 + }, + "frame": { + "x": 38, + "y": 1, + "w": 44, + "h": 72 + } + }, + { + "filename": "0020.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 15, + "y": 8, + "w": 44, + "h": 72 + }, + "frame": { + "x": 38, + "y": 1, + "w": 44, + "h": 72 + } + }, + { + "filename": "0021.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 15, + "y": 8, + "w": 44, + "h": 72 + }, + "frame": { + "x": 38, + "y": 1, + "w": 44, + "h": 72 + } + }, + { + "filename": "0022.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 8, + "w": 44, + "h": 72 + }, + "frame": { + "x": 38, + "y": 1, + "w": 44, + "h": 72 + } + }, + { + "filename": "0023.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 8, + "w": 44, + "h": 72 + }, + "frame": { + "x": 38, + "y": 1, + "w": 44, + "h": 72 + } + }, + { + "filename": "0000.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 34, + "y": 5, + "w": 35, + "h": 75 + }, + "frame": { + "x": 84, + "y": 1, + "w": 35, + "h": 75 + } + }, + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 34, + "y": 5, + "w": 35, + "h": 75 + }, + "frame": { + "x": 84, + "y": 1, + "w": 35, + "h": 75 + } + }, + { + "filename": "0004.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 34, + "y": 5, + "w": 35, + "h": 75 + }, + "frame": { + "x": 84, + "y": 1, + "w": 35, + "h": 75 + } + }, + { + "filename": "0005.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 34, + "y": 5, + "w": 35, + "h": 75 + }, + "frame": { + "x": 84, + "y": 1, + "w": 35, + "h": 75 + } + }, + { + "filename": "0008.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 34, + "y": 5, + "w": 35, + "h": 75 + }, + "frame": { + "x": 84, + "y": 1, + "w": 35, + "h": 75 + } + }, + { + "filename": "0009.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 34, + "y": 5, + "w": 35, + "h": 75 + }, + "frame": { + "x": 84, + "y": 1, + "w": 35, + "h": 75 + } + }, + { + "filename": "0012.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 34, + "y": 5, + "w": 35, + "h": 75 + }, + "frame": { + "x": 84, + "y": 1, + "w": 35, + "h": 75 + } + }, + { + "filename": "0013.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 34, + "y": 5, + "w": 35, + "h": 75 + }, + "frame": { + "x": 84, + "y": 1, + "w": 35, + "h": 75 + } + }, + { + "filename": "0016.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 34, + "y": 5, + "w": 35, + "h": 75 + }, + "frame": { + "x": 84, + "y": 1, + "w": 35, + "h": 75 + } + }, + { + "filename": "0017.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 34, + "y": 5, + "w": 35, + "h": 75 + }, + "frame": { + "x": 84, + "y": 1, + "w": 35, + "h": 75 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:033f3d363b4192f64c92e02c19622c15:0d06141bef5af87ef82da967253207cb:3347efe478119141b0e3e6eccdecd0f5$" + } +} diff --git a/public/images/trainer/buck.png b/public/images/trainer/buck.png new file mode 100644 index 000000000000..2384fb42a336 Binary files /dev/null and b/public/images/trainer/buck.png differ diff --git a/public/images/trainer/bug_type_superfan.json b/public/images/trainer/bug_type_superfan.json new file mode 100644 index 000000000000..74dca3583d5d --- /dev/null +++ b/public/images/trainer/bug_type_superfan.json @@ -0,0 +1,1469 @@ +{ + "textures": [ + { + "image": "bug_type_superfan.png", + "format": "RGBA8888", + "size": { + "w": 224, + "h": 224 + }, + "scale": 1, + "frames": [ + { + "filename": "0009.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 5, + "y": 1, + "w": 52, + "h": 85 + }, + "frame": { + "x": 1, + "y": 1, + "w": 52, + "h": 85 + } + }, + { + "filename": "0010.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 5, + "y": 1, + "w": 52, + "h": 85 + }, + "frame": { + "x": 1, + "y": 1, + "w": 52, + "h": 85 + } + }, + { + "filename": "0011.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 9, + "y": 11, + "w": 60, + "h": 75 + }, + "frame": { + "x": 55, + "y": 1, + "w": 60, + "h": 75 + } + }, + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0026.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0027.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0028.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0029.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0030.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0031.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0032.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0033.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0034.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0035.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0036.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0037.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0038.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0039.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0040.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0041.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0042.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0043.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0044.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0045.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0046.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0047.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0048.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0049.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0050.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0051.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0052.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0053.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0054.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0055.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0056.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0057.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0058.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0059.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0060.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0061.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0062.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0063.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0064.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0065.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0066.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0067.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0068.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0069.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 6, + "y": 18, + "w": 65, + "h": 68 + }, + "frame": { + "x": 117, + "y": 1, + "w": 65, + "h": 68 + } + }, + { + "filename": "0002.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 10, + "y": 0, + "w": 46, + "h": 86 + }, + "frame": { + "x": 1, + "y": 88, + "w": 46, + "h": 86 + } + }, + { + "filename": "0003.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 10, + "y": 0, + "w": 46, + "h": 86 + }, + "frame": { + "x": 1, + "y": 88, + "w": 46, + "h": 86 + } + }, + { + "filename": "0004.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 10, + "y": 0, + "w": 46, + "h": 86 + }, + "frame": { + "x": 1, + "y": 88, + "w": 46, + "h": 86 + } + }, + { + "filename": "0005.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 10, + "y": 0, + "w": 46, + "h": 86 + }, + "frame": { + "x": 1, + "y": 88, + "w": 46, + "h": 86 + } + }, + { + "filename": "0006.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 10, + "y": 0, + "w": 46, + "h": 86 + }, + "frame": { + "x": 1, + "y": 88, + "w": 46, + "h": 86 + } + }, + { + "filename": "0007.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 10, + "y": 0, + "w": 46, + "h": 86 + }, + "frame": { + "x": 1, + "y": 88, + "w": 46, + "h": 86 + } + }, + { + "filename": "0008.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 10, + "y": 0, + "w": 46, + "h": 86 + }, + "frame": { + "x": 1, + "y": 88, + "w": 46, + "h": 86 + } + }, + { + "filename": "0012.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 8, + "y": 19, + "w": 66, + "h": 67 + }, + "frame": { + "x": 49, + "y": 88, + "w": 66, + "h": 67 + } + }, + { + "filename": "0013.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 8, + "y": 19, + "w": 66, + "h": 67 + }, + "frame": { + "x": 49, + "y": 88, + "w": 66, + "h": 67 + } + }, + { + "filename": "0014.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 8, + "y": 20, + "w": 65, + "h": 66 + }, + "frame": { + "x": 49, + "y": 157, + "w": 65, + "h": 66 + } + }, + { + "filename": "0015.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 8, + "y": 20, + "w": 65, + "h": 66 + }, + "frame": { + "x": 49, + "y": 157, + "w": 65, + "h": 66 + } + }, + { + "filename": "0016.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 8, + "y": 20, + "w": 65, + "h": 66 + }, + "frame": { + "x": 116, + "y": 157, + "w": 65, + "h": 66 + } + }, + { + "filename": "0017.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 8, + "y": 20, + "w": 65, + "h": 66 + }, + "frame": { + "x": 116, + "y": 157, + "w": 65, + "h": 66 + } + }, + { + "filename": "0018.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 8, + "y": 20, + "w": 65, + "h": 66 + }, + "frame": { + "x": 116, + "y": 157, + "w": 65, + "h": 66 + } + }, + { + "filename": "0019.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 8, + "y": 20, + "w": 65, + "h": 66 + }, + "frame": { + "x": 116, + "y": 157, + "w": 65, + "h": 66 + } + }, + { + "filename": "0020.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 8, + "y": 20, + "w": 65, + "h": 66 + }, + "frame": { + "x": 116, + "y": 157, + "w": 65, + "h": 66 + } + }, + { + "filename": "0021.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 8, + "y": 20, + "w": 65, + "h": 66 + }, + "frame": { + "x": 116, + "y": 157, + "w": 65, + "h": 66 + } + }, + { + "filename": "0022.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 8, + "y": 20, + "w": 65, + "h": 66 + }, + "frame": { + "x": 116, + "y": 157, + "w": 65, + "h": 66 + } + }, + { + "filename": "0023.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 8, + "y": 20, + "w": 65, + "h": 66 + }, + "frame": { + "x": 116, + "y": 157, + "w": 65, + "h": 66 + } + }, + { + "filename": "0024.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 7, + "y": 19, + "w": 65, + "h": 67 + }, + "frame": { + "x": 117, + "y": 71, + "w": 65, + "h": 67 + } + }, + { + "filename": "0025.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 86 + }, + "spriteSourceSize": { + "x": 7, + "y": 19, + "w": 65, + "h": 67 + }, + "frame": { + "x": 117, + "y": 71, + "w": 65, + "h": 67 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:442c13442d70348845d7f5fcdfc121b3:3b8402aa64ee8990e64c7f03ffffbc55:568199339797fd79d11ae8d741953c1c$" + } +} diff --git a/public/images/trainer/bug_type_superfan.png b/public/images/trainer/bug_type_superfan.png new file mode 100644 index 000000000000..59316fe6ed89 Binary files /dev/null and b/public/images/trainer/bug_type_superfan.png differ diff --git a/public/images/trainer/cheryl.json b/public/images/trainer/cheryl.json new file mode 100644 index 000000000000..4cac665a5886 --- /dev/null +++ b/public/images/trainer/cheryl.json @@ -0,0 +1,398 @@ +{ + "textures": [ + { + "image": "cheryl.png", + "format": "RGBA8888", + "size": { + "w": 154, + "h": 83 + }, + "scale": 1, + "frames": [ + { + "filename": "0006.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 25, + "y": 0, + "w": 41, + "h": 81 + }, + "frame": { + "x": 1, + "y": 1, + "w": 41, + "h": 81 + } + }, + { + "filename": "0007.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 25, + "y": 0, + "w": 41, + "h": 81 + }, + "frame": { + "x": 1, + "y": 1, + "w": 41, + "h": 81 + } + }, + { + "filename": "0008.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 26, + "y": 0, + "w": 41, + "h": 81 + }, + "frame": { + "x": 1, + "y": 1, + "w": 41, + "h": 81 + } + }, + { + "filename": "0009.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 26, + "y": 0, + "w": 41, + "h": 81 + }, + "frame": { + "x": 1, + "y": 1, + "w": 41, + "h": 81 + } + }, + { + "filename": "0010.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 27, + "y": 0, + "w": 41, + "h": 81 + }, + "frame": { + "x": 44, + "y": 1, + "w": 41, + "h": 81 + } + }, + { + "filename": "0011.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 27, + "y": 0, + "w": 41, + "h": 81 + }, + "frame": { + "x": 44, + "y": 1, + "w": 41, + "h": 81 + } + }, + { + "filename": "0012.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 24, + "y": 0, + "w": 41, + "h": 81 + }, + "frame": { + "x": 44, + "y": 1, + "w": 41, + "h": 81 + } + }, + { + "filename": "0013.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 24, + "y": 0, + "w": 41, + "h": 81 + }, + "frame": { + "x": 44, + "y": 1, + "w": 41, + "h": 81 + } + }, + { + "filename": "0014.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 27, + "y": 0, + "w": 33, + "h": 81 + }, + "frame": { + "x": 87, + "y": 1, + "w": 33, + "h": 81 + } + }, + { + "filename": "0015.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 27, + "y": 0, + "w": 33, + "h": 81 + }, + "frame": { + "x": 87, + "y": 1, + "w": 33, + "h": 81 + } + }, + { + "filename": "0016.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 26, + "y": 0, + "w": 33, + "h": 81 + }, + "frame": { + "x": 87, + "y": 1, + "w": 33, + "h": 81 + } + }, + { + "filename": "0017.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 26, + "y": 0, + "w": 33, + "h": 81 + }, + "frame": { + "x": 87, + "y": 1, + "w": 33, + "h": 81 + } + }, + { + "filename": "0000.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 20, + "y": 0, + "w": 31, + "h": 81 + }, + "frame": { + "x": 122, + "y": 1, + "w": 31, + "h": 81 + } + }, + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 20, + "y": 0, + "w": 31, + "h": 81 + }, + "frame": { + "x": 122, + "y": 1, + "w": 31, + "h": 81 + } + }, + { + "filename": "0002.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 20, + "y": 0, + "w": 31, + "h": 81 + }, + "frame": { + "x": 122, + "y": 1, + "w": 31, + "h": 81 + } + }, + { + "filename": "0003.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 20, + "y": 0, + "w": 31, + "h": 81 + }, + "frame": { + "x": 122, + "y": 1, + "w": 31, + "h": 81 + } + }, + { + "filename": "0004.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 21, + "y": 0, + "w": 31, + "h": 81 + }, + "frame": { + "x": 122, + "y": 1, + "w": 31, + "h": 81 + } + }, + { + "filename": "0005.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 81 + }, + "spriteSourceSize": { + "x": 21, + "y": 0, + "w": 31, + "h": 81 + }, + "frame": { + "x": 122, + "y": 1, + "w": 31, + "h": 81 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:dfcf7aedbd588c4e42427a2e17c171bf:206549943a0e3325d20a017ef01eefee:a233cd27590422717866c66e366b68fb$" + } +} diff --git a/public/images/trainer/cheryl.png b/public/images/trainer/cheryl.png new file mode 100644 index 000000000000..c46505f6b258 Binary files /dev/null and b/public/images/trainer/cheryl.png differ diff --git a/public/images/trainer/marley.json b/public/images/trainer/marley.json new file mode 100644 index 000000000000..92d9f1449e53 --- /dev/null +++ b/public/images/trainer/marley.json @@ -0,0 +1,83 @@ +{ "frames": [ + { + "filename": "0000.png", + "frame": { "x": 0, "y": 0, "w": 31, "h": 77 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 26, "y": 2, "w": 31, "h": 77 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0001.png", + "frame": { "x": 0, "y": 0, "w": 31, "h": 77 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 26, "y": 2, "w": 31, "h": 77 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0002.png", + "frame": { "x": 0, "y": 0, "w": 31, "h": 77 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 26, "y": 2, "w": 31, "h": 77 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0003.png", + "frame": { "x": 0, "y": 0, "w": 31, "h": 77 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 26, "y": 2, "w": 31, "h": 77 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0004.png", + "frame": { "x": 32, "y": 0, "w": 28, "h": 78 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 28, "y": 1, "w": 28, "h": 78 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0005.png", + "frame": { "x": 32, "y": 0, "w": 28, "h": 78 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 28, "y": 1, "w": 28, "h": 78 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0006.png", + "frame": { "x": 0, "y": 78, "w": 31, "h": 77 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 28, "y": 2, "w": 31, "h": 77 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0007.png", + "frame": { "x": 0, "y": 78, "w": 31, "h": 77 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 28, "y": 2, "w": 31, "h": 77 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + } + ], + "meta": { + "app": "https://www.pngprite.org/", + "version": "1.3.7-x64", + "image": "marley.png", + "format": "I8", + "size": { "w": 60, "h": 155 }, + "scale": "1" + } +} diff --git a/public/images/trainer/marley.png b/public/images/trainer/marley.png new file mode 100644 index 000000000000..8e78e11e8ad6 Binary files /dev/null and b/public/images/trainer/marley.png differ diff --git a/public/images/trainer/mira.json b/public/images/trainer/mira.json new file mode 100644 index 000000000000..7bd29f534759 --- /dev/null +++ b/public/images/trainer/mira.json @@ -0,0 +1,209 @@ +{ "frames": [ + { + "filename": "0000.png", + "frame": { "x": 53, "y": 0, "w": 44, "h": 65 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 23, "y": 14, "w": 44, "h": 65 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0001.png", + "frame": { "x": 53, "y": 0, "w": 44, "h": 65 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 23, "y": 14, "w": 44, "h": 65 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0002.png", + "frame": { "x": 53, "y": 0, "w": 44, "h": 65 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 22, "y": 13, "w": 44, "h": 65 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0003.png", + "frame": { "x": 53, "y": 0, "w": 44, "h": 65 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 21, "y": 11, "w": 44, "h": 65 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0004.png", + "frame": { "x": 0, "y": 0, "w": 53, "h": 63 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 21, "y": 11, "w": 53, "h": 63 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0005.png", + "frame": { "x": 0, "y": 0, "w": 53, "h": 63 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 21, "y": 12, "w": 53, "h": 63 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0006.png", + "frame": { "x": 0, "y": 63, "w": 44, "h": 65 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 13, "y": 11, "w": 44, "h": 65 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0007.png", + "frame": { "x": 0, "y": 63, "w": 44, "h": 65 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 12, "y": 13, "w": 44, "h": 65 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0008.png", + "frame": { "x": 0, "y": 63, "w": 44, "h": 65 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 11, "y": 14, "w": 44, "h": 65 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0009.png", + "frame": { "x": 0, "y": 63, "w": 44, "h": 65 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 12, "y": 13, "w": 44, "h": 65 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0010.png", + "frame": { "x": 0, "y": 63, "w": 44, "h": 65 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 13, "y": 11, "w": 44, "h": 65 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0011.png", + "frame": { "x": 0, "y": 0, "w": 53, "h": 63 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 21, "y": 11, "w": 53, "h": 63 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0012.png", + "frame": { "x": 0, "y": 0, "w": 53, "h": 63 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 21, "y": 12, "w": 53, "h": 63 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0013.png", + "frame": { "x": 53, "y": 0, "w": 44, "h": 65 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 21, "y": 11, "w": 44, "h": 65 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0014.png", + "frame": { "x": 53, "y": 0, "w": 44, "h": 65 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 22, "y": 13, "w": 44, "h": 65 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0015.png", + "frame": { "x": 53, "y": 0, "w": 44, "h": 65 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 23, "y": 14, "w": 44, "h": 65 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0016.png", + "frame": { "x": 53, "y": 0, "w": 44, "h": 65 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 22, "y": 13, "w": 44, "h": 65 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0017.png", + "frame": { "x": 53, "y": 0, "w": 44, "h": 65 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 21, "y": 11, "w": 44, "h": 65 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0018.png", + "frame": { "x": 0, "y": 0, "w": 53, "h": 63 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 21, "y": 11, "w": 53, "h": 63 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0019.png", + "frame": { "x": 0, "y": 0, "w": 53, "h": 63 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 21, "y": 12, "w": 53, "h": 63 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0020.png", + "frame": { "x": 0, "y": 63, "w": 44, "h": 65 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 13, "y": 11, "w": 44, "h": 65 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0021.png", + "frame": { "x": 0, "y": 63, "w": 44, "h": 65 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 12, "y": 13, "w": 44, "h": 65 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + } + ], + "meta": { + "app": "https://www.aseprite.org/", + "version": "1.3.7-x64", + "image": "mira.png", + "format": "I8", + "size": { "w": 97, "h": 128 }, + "scale": "1" + } +} diff --git a/public/images/trainer/mira.png b/public/images/trainer/mira.png new file mode 100644 index 000000000000..5c1afe5d2410 Binary files /dev/null and b/public/images/trainer/mira.png differ diff --git a/public/images/trainer/riley.json b/public/images/trainer/riley.json new file mode 100644 index 000000000000..f0f84a909db7 --- /dev/null +++ b/public/images/trainer/riley.json @@ -0,0 +1,209 @@ +{ "frames": [ + { + "filename": "0000.png", + "frame": { "x": 0, "y": 0, "w": 55, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 11, "y": 0, "w": 55, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0001.png", + "frame": { "x": 0, "y": 0, "w": 55, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 11, "y": 0, "w": 55, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0002.png", + "frame": { "x": 0, "y": 0, "w": 55, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 11, "y": 0, "w": 55, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0003.png", + "frame": { "x": 0, "y": 0, "w": 55, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 11, "y": 0, "w": 55, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0004.png", + "frame": { "x": 55, "y": 80, "w": 37, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 31, "y": 0, "w": 37, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0005.png", + "frame": { "x": 55, "y": 80, "w": 37, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 31, "y": 0, "w": 37, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0006.png", + "frame": { "x": 55, "y": 80, "w": 37, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 30, "y": 0, "w": 37, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0007.png", + "frame": { "x": 55, "y": 80, "w": 37, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 30, "y": 0, "w": 37, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0008.png", + "frame": { "x": 55, "y": 80, "w": 37, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 28, "y": 0, "w": 37, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0009.png", + "frame": { "x": 55, "y": 80, "w": 37, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 28, "y": 0, "w": 37, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0010.png", + "frame": { "x": 0, "y": 0, "w": 55, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 10, "y": 0, "w": 55, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0011.png", + "frame": { "x": 0, "y": 0, "w": 55, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 10, "y": 0, "w": 55, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0012.png", + "frame": { "x": 0, "y": 0, "w": 55, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 11, "y": 0, "w": 55, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0013.png", + "frame": { "x": 0, "y": 0, "w": 55, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 11, "y": 0, "w": 55, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0014.png", + "frame": { "x": 55, "y": 0, "w": 55, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 12, "y": 0, "w": 55, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0015.png", + "frame": { "x": 55, "y": 0, "w": 55, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 12, "y": 0, "w": 55, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0016.png", + "frame": { "x": 0, "y": 80, "w": 55, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 12, "y": 0, "w": 55, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0017.png", + "frame": { "x": 0, "y": 80, "w": 55, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 12, "y": 0, "w": 55, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0018.png", + "frame": { "x": 55, "y": 0, "w": 55, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 12, "y": 0, "w": 55, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0019.png", + "frame": { "x": 55, "y": 0, "w": 55, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 12, "y": 0, "w": 55, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0020.png", + "frame": { "x": 0, "y": 0, "w": 55, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 12, "y": 0, "w": 55, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + }, + { + "filename": "0021.png", + "frame": { "x": 0, "y": 0, "w": 55, "h": 80 }, + "rotated": false, + "trimmed": true, + "spriteSourceSize": { "x": 12, "y": 0, "w": 55, "h": 80 }, + "sourceSize": { "w": 80, "h": 80 }, + "duration": 100 + } + ], + "meta": { + "app": "https://www.aseprite.org/", + "version": "1.3.7-x64", + "image": "riley.png", + "format": "I8", + "size": { "w": 110, "h": 160 }, + "scale": "1" + } +} diff --git a/public/images/trainer/riley.png b/public/images/trainer/riley.png new file mode 100644 index 000000000000..a9f0e3b53a9c Binary files /dev/null and b/public/images/trainer/riley.png differ diff --git a/public/images/trainer/vicky.json b/public/images/trainer/vicky.json new file mode 100644 index 000000000000..c19cf11622d3 --- /dev/null +++ b/public/images/trainer/vicky.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "vicky.png", + "format": "RGBA8888", + "size": { + "w": 52, + "h": 53 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 27, + "w": 52, + "h": 53 + }, + "frame": { + "x": 0, + "y": 0, + "w": 52, + "h": 53 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:bf9d2d417a1982282dd711456ac71206:101e07828e3d6e2a2a7a80aebfa802ad:cabe44a4410c334298b1984a219f8160$" + } +} diff --git a/public/images/trainer/vicky.png b/public/images/trainer/vicky.png new file mode 100644 index 000000000000..3e2d6c136969 Binary files /dev/null and b/public/images/trainer/vicky.png differ diff --git a/public/images/trainer/victor.json b/public/images/trainer/victor.json new file mode 100644 index 000000000000..5afa97045672 --- /dev/null +++ b/public/images/trainer/victor.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "victor.png", + "format": "RGBA8888", + "size": { + "w": 55, + "h": 53 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 12, + "y": 27, + "w": 55, + "h": 53 + }, + "frame": { + "x": 0, + "y": 0, + "w": 55, + "h": 53 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:64eff0f697754cdf9552b46342c9292a:611e0e2cacbd90c1229ce5443b2414f0:0cc0f5a2c1b2eedb46dd8318e8feb1d8$" + } +} diff --git a/public/images/trainer/victor.png b/public/images/trainer/victor.png new file mode 100644 index 000000000000..3ffddea24bbf Binary files /dev/null and b/public/images/trainer/victor.png differ diff --git a/public/images/trainer/victoria.json b/public/images/trainer/victoria.json new file mode 100644 index 000000000000..7917113621a4 --- /dev/null +++ b/public/images/trainer/victoria.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "victoria.png", + "format": "RGBA8888", + "size": { + "w": 52, + "h": 54 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 14, + "y": 26, + "w": 52, + "h": 54 + }, + "frame": { + "x": 0, + "y": 0, + "w": 52, + "h": 54 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:4dafeae3674d63b12cc4d8044f67b5a3:7834687d784c31169256927f419c7958:cf0eb39e0a3f2e42f23ca29747d73c40$" + } +} diff --git a/public/images/trainer/victoria.png b/public/images/trainer/victoria.png new file mode 100644 index 000000000000..e2874f266adf Binary files /dev/null and b/public/images/trainer/victoria.png differ diff --git a/public/images/trainer/vito.json b/public/images/trainer/vito.json new file mode 100644 index 000000000000..61dcf7af0ef2 --- /dev/null +++ b/public/images/trainer/vito.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "vito.png", + "format": "RGBA8888", + "size": { + "w": 41, + "h": 78 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 20, + "y": 2, + "w": 41, + "h": 78 + }, + "frame": { + "x": 0, + "y": 0, + "w": 41, + "h": 78 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:cb988be58fcd5381174e9d120b051e38:4d4723dbbcd9713ee0ed3c2d84ef4bfb:1c7723b536b218346e3138016d865ce9$" + } +} diff --git a/public/images/trainer/vito.png b/public/images/trainer/vito.png new file mode 100644 index 000000000000..a7c6c0444f4a Binary files /dev/null and b/public/images/trainer/vito.png differ diff --git a/public/images/trainer/vivi.json b/public/images/trainer/vivi.json new file mode 100644 index 000000000000..b36ebcd7c0cf --- /dev/null +++ b/public/images/trainer/vivi.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "vivi.png", + "format": "RGBA8888", + "size": { + "w": 48, + "h": 69 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 80, + "h": 80 + }, + "spriteSourceSize": { + "x": 13, + "y": 11, + "w": 48, + "h": 69 + }, + "frame": { + "x": 0, + "y": 0, + "w": 48, + "h": 69 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:0a51b4df0b2ed0fed7e3bdb5dffd9e28:af1f3b1480023b3e3761c49e49faf5f1:4fc6bf2bec74c4bb8809df38231deb01$" + } +} diff --git a/public/images/trainer/vivi.png b/public/images/trainer/vivi.png new file mode 100644 index 000000000000..cd97e676cfba Binary files /dev/null and b/public/images/trainer/vivi.png differ diff --git a/src/battle-scene.ts b/src/battle-scene.ts index f06ae607b0e7..9a7221d3fb38 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -2,18 +2,28 @@ import Phaser from "phaser"; import UI from "./ui/ui"; import Pokemon, { PlayerPokemon, EnemyPokemon } from "./field/pokemon"; import PokemonSpecies, { PokemonSpeciesFilter, allSpecies, getPokemonSpecies } from "./data/pokemon-species"; -import { Constructor } from "#app/utils"; +import { Constructor, isNullOrUndefined } from "#app/utils"; import * as Utils from "./utils"; -import { Modifier, ModifierBar, ConsumablePokemonModifier, ConsumableModifier, PokemonHpRestoreModifier, TurnHeldItemTransferModifier, HealingBoosterModifier, PersistentModifier, PokemonHeldItemModifier, ModifierPredicate, DoubleBattleChanceBoosterModifier, FusePokemonModifier, PokemonFormChangeItemModifier, TerastallizeModifier, overrideModifiers, overrideHeldItems } from "./modifier/modifier"; +import { Modifier, ModifierBar, ConsumablePokemonModifier, ConsumableModifier, PokemonHpRestoreModifier, TurnHeldItemTransferModifier, HealingBoosterModifier, PersistentModifier, PokemonHeldItemModifier, ModifierPredicate, DoubleBattleChanceBoosterModifier, FusePokemonModifier, PokemonFormChangeItemModifier, TerastallizeModifier, overrideModifiers, overrideHeldItems, PokemonIncrementingStatModifier, ExpShareModifier, ExpBalanceModifier, MultipleParticipantExpBonusModifier, PokemonExpBoosterModifier } from "./modifier/modifier"; import { PokeballType } from "./data/pokeball"; import { initCommonAnims, initMoveAnim, loadCommonAnimAssets, loadMoveAnimAssets, populateAnims } from "./data/battle-anims"; import { Phase } from "./phase"; import { initGameSpeed } from "./system/game-speed"; import { Arena, ArenaBase } from "./field/arena"; import { GameData } from "./system/game-data"; -import { TextStyle, addTextObject, getTextColor } from "./ui/text"; +import { addTextObject, getTextColor, TextStyle } from "./ui/text"; import { allMoves } from "./data/move"; -import { ModifierPoolType, getDefaultModifierTypeForTier, getEnemyModifierTypesForWave, getLuckString, getLuckTextTint, getModifierPoolForType, getModifierType, getPartyLuckValue, modifierTypes } from "./modifier/modifier-type"; +import { + ModifierPoolType, + getDefaultModifierTypeForTier, + getEnemyModifierTypesForWave, + getLuckString, + getLuckTextTint, + getModifierPoolForType, + getModifierType, + getPartyLuckValue, + modifierTypes, PokemonHeldItemModifierType +} from "./modifier/modifier-type"; import AbilityBar from "./ui/ability-bar"; import { BlockItemTheftAbAttr, DoubleBattleChanceAbAttr, ChangeMovePriorityAbAttr, PostBattleInitAbAttr, applyAbAttrs, applyPostBattleInitAbAttrs } from "./data/ability"; import { allAbilities } from "./data/ability"; @@ -22,14 +32,14 @@ import { GameMode, GameModes, getGameMode } from "./game-mode"; import FieldSpritePipeline from "./pipelines/field-sprite"; import SpritePipeline from "./pipelines/sprite"; import PartyExpBar from "./ui/party-exp-bar"; -import { TrainerSlot, trainerConfigs } from "./data/trainer-config"; +import { trainerConfigs, TrainerSlot } from "./data/trainer-config"; import Trainer, { TrainerVariant } from "./field/trainer"; import TrainerData from "./system/trainer-data"; import SoundFade from "phaser3-rex-plugins/plugins/soundfade"; import { pokemonPrevolutions } from "./data/pokemon-evolutions"; import PokeballTray from "./ui/pokeball-tray"; import InvertPostFX from "./pipelines/invert"; -import { Achv, ModifierAchv, MoneyAchv, achvs } from "./system/achv"; +import { Achv, achvs, ModifierAchv, MoneyAchv } from "./system/achv"; import { Voucher, vouchers } from "./system/voucher"; import { Gender } from "./data/gender"; import UIPlugin from "phaser3-rex-plugins/templates/ui/ui-plugin"; @@ -86,6 +96,15 @@ import { TitlePhase } from "./phases/title-phase"; import { ToggleDoublePositionPhase } from "./phases/toggle-double-position-phase"; import { TurnInitPhase } from "./phases/turn-init-phase"; import { ShopCursorTarget } from "./enums/shop-cursor-target"; +import MysteryEncounter from "./data/mystery-encounters/mystery-encounter"; +import { allMysteryEncounters, ANTI_VARIANCE_WEIGHT_MODIFIER, AVERAGE_ENCOUNTERS_PER_RUN_TARGET, BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT, MYSTERY_ENCOUNTER_SPAWN_MAX_WEIGHT, mysteryEncountersByBiome, WEIGHT_INCREMENT_ON_SPAWN_MISS } from "./data/mystery-encounters/mystery-encounters"; +import { MysteryEncounterSaveData } from "#app/data/mystery-encounters/mystery-encounter-save-data"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import HeldModifierConfig from "#app/interfaces/held-modifier-config"; +import { ExpPhase } from "#app/phases/exp-phase"; +import { ShowPartyExpBarPhase } from "#app/phases/show-party-exp-bar-phase"; +import { MysteryEncounterMode } from "#enums/mystery-encounter-mode"; export const bypassLogin = import.meta.env.VITE_BYPASS_LOGIN === "1"; @@ -246,6 +265,10 @@ export default class BattleScene extends SceneBase { public money: integer; public pokemonInfoContainer: PokemonInfoContainer; private party: PlayerPokemon[]; + /** Session save data that pertains to Mystery Encounters */ + public mysteryEncounterSaveData: MysteryEncounterSaveData = new MysteryEncounterSaveData(); + /** If the previous wave was a MysteryEncounter, tracks the object with this variable. Mostly used for visual object cleanup */ + public lastMysteryEncounter?: MysteryEncounter; /** Combined Biome and Wave count text */ private biomeWaveText: Phaser.GameObjects.Text; private moneyText: Phaser.GameObjects.Text; @@ -884,6 +907,26 @@ export default class BattleScene extends SceneBase { return pokemon; } + /** + * Removes a {@linkcode PlayerPokemon} from the party, and clears modifiers for that Pokemon's id + * Useful for MEs/Challenges that remove Pokemon from the player party temporarily or permanently + * @param pokemon + * @param destroy Default true. If true, will destroy the {@linkcode PlayerPokemon} after removing + */ + removePokemonFromPlayerParty(pokemon: PlayerPokemon, destroy: boolean = true) { + if (!pokemon) { + return; + } + + const partyIndex = this.party.indexOf(pokemon); + this.party.splice(partyIndex, 1); + if (destroy) { + this.field.remove(pokemon, true); + pokemon.destroy(); + } + this.updateModifiers(true); + } + addPokemonIcon(pokemon: Pokemon, x: number, y: number, originX: number = 0.5, originY: number = 0.5, ignoreOverride: boolean = false): Phaser.GameObjects.Container { const container = this.add.container(x, y); container.setName(`${pokemon.name}-icon`); @@ -1086,7 +1129,7 @@ export default class BattleScene extends SceneBase { } } - newBattle(waveIndex?: integer, battleType?: BattleType, trainerData?: TrainerData, double?: boolean): Battle | null { + newBattle(waveIndex?: integer, battleType?: BattleType, trainerData?: TrainerData, double?: boolean, mysteryEncounterType?: MysteryEncounterType): Battle | null { const _startingWave = Overrides.STARTING_WAVE_OVERRIDE || startingWave; const newWaveIndex = waveIndex || ((this.currentBattle?.waveIndex || (_startingWave - 1)) + 1); let newDouble: boolean | undefined; @@ -1135,6 +1178,36 @@ export default class BattleScene extends SceneBase { newTrainer = trainerData !== undefined ? trainerData.toTrainer(this) : new Trainer(this, trainerType, variant); this.field.add(newTrainer); } + + // Check for mystery encounter + // Can only occur in place of a standard (non-boss) wild battle, waves 10-180 + const [lowestMysteryEncounterWave, highestMysteryEncounterWave] = this.gameMode.getMysteryEncounterLegalWaves(); + if (this.gameMode.hasMysteryEncounters && newBattleType === BattleType.WILD && !this.gameMode.isBoss(newWaveIndex) && newWaveIndex < highestMysteryEncounterWave && newWaveIndex > lowestMysteryEncounterWave) { + const roll = Utils.randSeedInt(MYSTERY_ENCOUNTER_SPAWN_MAX_WEIGHT); + + // Base spawn weight is BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT/256, and increases by WEIGHT_INCREMENT_ON_SPAWN_MISS/256 for each missed attempt at spawning an encounter on a valid floor + const sessionEncounterRate = this.mysteryEncounterSaveData.encounterSpawnChance; + const encounteredEvents = this.mysteryEncounterSaveData.encounteredEvents; + + // If total number of encounters is lower than expected for the run, slightly favor a new encounter spawn (reverse as well) + // Reduces occurrence of runs with total encounters significantly different from AVERAGE_ENCOUNTERS_PER_RUN_TARGET + const expectedEncountersByFloor = AVERAGE_ENCOUNTERS_PER_RUN_TARGET / (highestMysteryEncounterWave - lowestMysteryEncounterWave) * (newWaveIndex - lowestMysteryEncounterWave); + const currentRunDiffFromAvg = expectedEncountersByFloor - encounteredEvents.length; + const favoredEncounterRate = sessionEncounterRate + currentRunDiffFromAvg * ANTI_VARIANCE_WEIGHT_MODIFIER; + + const successRate = isNullOrUndefined(Overrides.MYSTERY_ENCOUNTER_RATE_OVERRIDE) ? favoredEncounterRate : Overrides.MYSTERY_ENCOUNTER_RATE_OVERRIDE!; + + // If the most recent ME was 3 or fewer waves ago, can never spawn a ME + const canSpawn = encounteredEvents.length === 0 || (newWaveIndex - encounteredEvents[encounteredEvents.length - 1].waveIndex) > 3 || !isNullOrUndefined(Overrides.MYSTERY_ENCOUNTER_RATE_OVERRIDE); + + if (canSpawn && roll < successRate) { + newBattleType = BattleType.MYSTERY_ENCOUNTER; + // Reset base spawn weight + this.mysteryEncounterSaveData.encounterSpawnChance = BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT; + } else { + this.mysteryEncounterSaveData.encounterSpawnChance = sessionEncounterRate + WEIGHT_INCREMENT_ON_SPAWN_MISS; + } + } } if (double === undefined && newWaveIndex > 1) { @@ -1167,12 +1240,21 @@ export default class BattleScene extends SceneBase { const maxExpLevel = this.getMaxExpLevel(); this.lastEnemyTrainer = lastBattle?.trainer ?? null; + this.lastMysteryEncounter = lastBattle?.mysteryEncounter; this.executeWithSeedOffset(() => { this.currentBattle = new Battle(this.gameMode, newWaveIndex, newBattleType, newTrainer, newDouble); }, newWaveIndex << 3, this.waveSeed); this.currentBattle.incrementTurn(this); + if (newBattleType === BattleType.MYSTERY_ENCOUNTER) { + // Disable double battle on mystery encounters (it may be re-enabled as part of encounter) + this.currentBattle.double = false; + this.executeWithSeedOffset(() => { + this.currentBattle.mysteryEncounter = this.getMysteryEncounter(mysteryEncounterType); + }, this.currentBattle.waveIndex << 4); + } + //this.pushPhase(new TrainerMessageTestPhase(this, TrainerType.RIVAL, TrainerType.RIVAL_2, TrainerType.RIVAL_3, TrainerType.RIVAL_4, TrainerType.RIVAL_5, TrainerType.RIVAL_6)); if (!waveIndex && lastBattle) { @@ -1181,7 +1263,7 @@ export default class BattleScene extends SceneBase { const isEndlessFifthWave = this.gameMode.hasShortBiomes && (lastBattle.waveIndex % 5) === 0; const isWaveIndexMultipleOfFiftyMinusOne = (lastBattle.waveIndex % 50) === 49; const isNewBiome = isWaveIndexMultipleOfTen || isEndlessFifthWave || (isEndlessOrDaily && isWaveIndexMultipleOfFiftyMinusOne); - const resetArenaState = isNewBiome || this.currentBattle.battleType === BattleType.TRAINER || this.currentBattle.battleSpec === BattleSpec.FINAL_BOSS; + const resetArenaState = isNewBiome || [BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(this.currentBattle.battleType) || this.currentBattle.battleSpec === BattleSpec.FINAL_BOSS; this.getEnemyParty().forEach(enemyPokemon => enemyPokemon.destroy()); this.trySpreadPokerus(); if (!isNewBiome && (newWaveIndex % 10) === 5) { @@ -1189,14 +1271,21 @@ export default class BattleScene extends SceneBase { } if (resetArenaState) { this.arena.resetArenaEffects(); - playerField.forEach((_, p) => this.pushPhase(new ReturnPhase(this, p))); + + playerField.forEach((pokemon, p) => { + if (pokemon.isOnField()) { + this.pushPhase(new ReturnPhase(this, p)); + } + }); for (const pokemon of this.getParty()) { pokemon.resetBattleData(); applyPostBattleInitAbAttrs(PostBattleInitAbAttr, pokemon); } - this.pushPhase(new ShowTrainerPhase(this)); + if (!this.trainer.visible) { + this.pushPhase(new ShowTrainerPhase(this)); + } } for (const pokemon of this.getParty()) { @@ -1290,7 +1379,6 @@ export default class BattleScene extends SceneBase { case Species.ZARUDE: case Species.SQUAWKABILLY: case Species.TATSUGIRI: - case Species.GIMMIGHOUL: case Species.PALDEA_TAUROS: return Utils.randSeedInt(species.forms.length); case Species.PIKACHU: @@ -1316,6 +1404,13 @@ export default class BattleScene extends SceneBase { return 1; } return 0; + case Species.GIMMIGHOUL: + // Chest form can only be found in Mysterious Chest Encounter, if this is a game mode with MEs + if (this.gameMode.hasMysteryEncounters) { + return 1; // Wandering form + } else { + return Utils.randSeedInt(species.forms.length); + } } if (ignoreArena) { @@ -2485,7 +2580,7 @@ export default class BattleScene extends SceneBase { }); } - generateEnemyModifiers(): Promise { + generateEnemyModifiers(heldModifiersConfigs?: HeldModifierConfig[][]): Promise { return new Promise(resolve => { if (this.currentBattle.battleSpec === BattleSpec.FINAL_BOSS) { return resolve(); @@ -2507,29 +2602,47 @@ export default class BattleScene extends SceneBase { } party.forEach((enemyPokemon: EnemyPokemon, i: integer) => { - const isBoss = enemyPokemon.isBoss() || (this.currentBattle.battleType === BattleType.TRAINER && !!this.currentBattle.trainer?.config.isBoss); - let upgradeChance = 32; - if (isBoss) { - upgradeChance /= 2; - } - if (isFinalBoss) { - upgradeChance /= 8; - } - const modifierChance = this.gameMode.getEnemyModifierChance(isBoss); - let pokemonModifierChance = modifierChance; - if (this.currentBattle.battleType === BattleType.TRAINER && this.currentBattle.trainer) - pokemonModifierChance = Math.ceil(pokemonModifierChance * this.currentBattle.trainer.getPartyMemberModifierChanceMultiplier(i)); // eslint-disable-line - let count = 0; - for (let c = 0; c < chances; c++) { - if (!Utils.randSeedInt(modifierChance)) { - count++; + if (heldModifiersConfigs && i < heldModifiersConfigs.length && heldModifiersConfigs[i] && heldModifiersConfigs[i].length > 0) { + heldModifiersConfigs[i].forEach(mt => { + let modifier: PokemonHeldItemModifier; + if (mt.modifier instanceof PokemonHeldItemModifierType) { + modifier = mt.modifier.newModifier(enemyPokemon); + } else { + modifier = mt.modifier as PokemonHeldItemModifier; + modifier.pokemonId = enemyPokemon.id; + } + const stackCount = mt.stackCount ?? 1; + modifier.stackCount = stackCount; + // TODO: set isTransferable + // modifier.isTransferrable = mt.isTransferable ?? true; + this.addEnemyModifier(modifier, true); + }); + } else { + const isBoss = enemyPokemon.isBoss() || (this.currentBattle.battleType === BattleType.TRAINER && !!this.currentBattle.trainer?.config.isBoss); + let upgradeChance = 32; + if (isBoss) { + upgradeChance /= 2; } + if (isFinalBoss) { + upgradeChance /= 8; + } + const modifierChance = this.gameMode.getEnemyModifierChance(isBoss); + let pokemonModifierChance = modifierChance; + if (this.currentBattle.battleType === BattleType.TRAINER && this.currentBattle.trainer) + pokemonModifierChance = Math.ceil(pokemonModifierChance * this.currentBattle.trainer.getPartyMemberModifierChanceMultiplier(i)); // eslint-disable-line + let count = 0; + for (let c = 0; c < chances; c++) { + if (!Utils.randSeedInt(modifierChance)) { + count++; + } + } + if (isBoss) { + count = Math.max(count, Math.floor(chances / 2)); + } + getEnemyModifierTypesForWave(difficultyWaveIndex, count, [ enemyPokemon ], this.currentBattle.battleType === BattleType.TRAINER ? ModifierPoolType.TRAINER : ModifierPoolType.WILD, upgradeChance) + .map(mt => mt.newModifier(enemyPokemon).add(this.enemyModifiers, false, this)); } - if (isBoss) { - count = Math.max(count, Math.floor(chances / 2)); - } - getEnemyModifierTypesForWave(difficultyWaveIndex, count, [ enemyPokemon ], this.currentBattle.battleType === BattleType.TRAINER ? ModifierPoolType.TRAINER : ModifierPoolType.WILD, upgradeChance) - .map(mt => mt.newModifier(enemyPokemon).add(this.enemyModifiers, false, this)); + return true; }); this.updateModifiers(false).then(() => resolve()); }); @@ -2837,4 +2950,220 @@ export default class BattleScene extends SceneBase { this.shiftPhase(); } + + /** + * Updates Exp and level values for Player's party, adding new level up phases as required + * @param expValue raw value of exp to split among participants, OR the base multiplier to use with waveIndex + * @param pokemonDefeated If true, will increment Macho Brace stacks and give the party Pokemon friendship increases + * @param useWaveIndexMultiplier Default false. If true, will multiply expValue by a scaling waveIndex multiplier. Not needed if expValue is already scaled by level/wave + * @param pokemonParticipantIds Participants. If none are defined, no exp will be given. To spread evenly among the party, should pass all ids of party members. + */ + applyPartyExp(expValue: number, pokemonDefeated: boolean, useWaveIndexMultiplier?: boolean, pokemonParticipantIds?: Set): void { + const participantIds = pokemonParticipantIds ?? this.currentBattle.playerParticipantIds; + const party = this.getParty(); + const expShareModifier = this.findModifier(m => m instanceof ExpShareModifier) as ExpShareModifier; + const expBalanceModifier = this.findModifier(m => m instanceof ExpBalanceModifier) as ExpBalanceModifier; + const multipleParticipantExpBonusModifier = this.findModifier(m => m instanceof MultipleParticipantExpBonusModifier) as MultipleParticipantExpBonusModifier; + const nonFaintedPartyMembers = party.filter(p => p.hp); + const expPartyMembers = nonFaintedPartyMembers.filter(p => p.level < this.getMaxExpLevel()); + const partyMemberExp: number[] = []; + // EXP value calculation is based off Pokemon.getExpValue + if (useWaveIndexMultiplier) { + expValue = Math.floor(expValue * this.currentBattle.waveIndex / 5 + 1); + } + + if (participantIds.size > 0) { + if (this.currentBattle.battleType === BattleType.TRAINER || this.currentBattle.mysteryEncounter?.encounterMode === MysteryEncounterMode.TRAINER_BATTLE) { + expValue = Math.floor(expValue * 1.5); + } else if (this.currentBattle.battleType === BattleType.MYSTERY_ENCOUNTER && this.currentBattle.mysteryEncounter) { + expValue = Math.floor(expValue * this.currentBattle.mysteryEncounter.expMultiplier); + } + for (const partyMember of nonFaintedPartyMembers) { + const pId = partyMember.id; + const participated = participantIds.has(pId); + if (participated && pokemonDefeated) { + partyMember.addFriendship(2); + const machoBraceModifier = partyMember.getHeldItems().find(m => m instanceof PokemonIncrementingStatModifier); + if (machoBraceModifier && machoBraceModifier.stackCount < machoBraceModifier.getMaxStackCount(this)) { + machoBraceModifier.stackCount++; + this.updateModifiers(true, true); + partyMember.updateInfo(); + } + } + if (!expPartyMembers.includes(partyMember)) { + continue; + } + if (!participated && !expShareModifier) { + partyMemberExp.push(0); + continue; + } + let expMultiplier = 0; + if (participated) { + expMultiplier += (1 / participantIds.size); + if (participantIds.size > 1 && multipleParticipantExpBonusModifier) { + expMultiplier += multipleParticipantExpBonusModifier.getStackCount() * 0.2; + } + } else if (expShareModifier) { + expMultiplier += (expShareModifier.getStackCount() * 0.2) / participantIds.size; + } + if (partyMember.pokerus) { + expMultiplier *= 1.5; + } + if (Overrides.XP_MULTIPLIER_OVERRIDE !== null) { + expMultiplier = Overrides.XP_MULTIPLIER_OVERRIDE; + } + const pokemonExp = new Utils.NumberHolder(expValue * expMultiplier); + this.applyModifiers(PokemonExpBoosterModifier, true, partyMember, pokemonExp); + partyMemberExp.push(Math.floor(pokemonExp.value)); + } + + if (expBalanceModifier) { + let totalLevel = 0; + let totalExp = 0; + expPartyMembers.forEach((expPartyMember, epm) => { + totalExp += partyMemberExp[epm]; + totalLevel += expPartyMember.level; + }); + + const medianLevel = Math.floor(totalLevel / expPartyMembers.length); + + const recipientExpPartyMemberIndexes: number[] = []; + expPartyMembers.forEach((expPartyMember, epm) => { + if (expPartyMember.level <= medianLevel) { + recipientExpPartyMemberIndexes.push(epm); + } + }); + + const splitExp = Math.floor(totalExp / recipientExpPartyMemberIndexes.length); + + expPartyMembers.forEach((_partyMember, pm) => { + partyMemberExp[pm] = Phaser.Math.Linear(partyMemberExp[pm], recipientExpPartyMemberIndexes.indexOf(pm) > -1 ? splitExp : 0, 0.2 * expBalanceModifier.getStackCount()); + }); + } + + for (let pm = 0; pm < expPartyMembers.length; pm++) { + const exp = partyMemberExp[pm]; + + if (exp) { + const partyMemberIndex = party.indexOf(expPartyMembers[pm]); + this.unshiftPhase(expPartyMembers[pm].isOnField() ? new ExpPhase(this, partyMemberIndex, exp) : new ShowPartyExpBarPhase(this, partyMemberIndex, exp)); + } + } + } + } + + /** + * Loads or generates a mystery encounter + * @param encounterType used to load session encounter when restarting game, etc. + * @returns + */ + getMysteryEncounter(encounterType?: MysteryEncounterType): MysteryEncounter { + // Loading override or session encounter + let encounter: MysteryEncounter | null; + if (!isNullOrUndefined(Overrides.MYSTERY_ENCOUNTER_OVERRIDE) && allMysteryEncounters.hasOwnProperty(Overrides.MYSTERY_ENCOUNTER_OVERRIDE!)) { + encounter = allMysteryEncounters[Overrides.MYSTERY_ENCOUNTER_OVERRIDE!]; + } else { + encounter = !isNullOrUndefined(encounterType) ? allMysteryEncounters[encounterType!] : null; + } + + // Check for queued encounters first + if (!encounter && this.mysteryEncounterSaveData?.queuedEncounters && this.mysteryEncounterSaveData.queuedEncounters.length > 0) { + let i = 0; + while (i < this.mysteryEncounterSaveData.queuedEncounters.length && !!encounter) { + const candidate = this.mysteryEncounterSaveData.queuedEncounters[i]; + const forcedChance = candidate.spawnPercent; + if (Utils.randSeedInt(100) < forcedChance) { + encounter = allMysteryEncounters[candidate.type]; + } + + i++; + } + } + + if (encounter) { + encounter = new MysteryEncounter(encounter); + encounter.populateDialogueTokensFromRequirements(this); + return encounter; + } + + // See Enum values for base tier weights + const tierWeights = [MysteryEncounterTier.COMMON, MysteryEncounterTier.GREAT, MysteryEncounterTier.ULTRA, MysteryEncounterTier.ROGUE]; + + // Adjust tier weights by previously encountered events to lower odds of only Common/Great in run + this.mysteryEncounterSaveData.encounteredEvents.forEach(seenEncounterData => { + if (seenEncounterData.tier === MysteryEncounterTier.COMMON) { + tierWeights[0] = tierWeights[0] - 6; + } else if (seenEncounterData.tier === MysteryEncounterTier.GREAT) { + tierWeights[1] = tierWeights[1] - 4; + } + }); + + const totalWeight = tierWeights.reduce((a, b) => a + b); + const tierValue = Utils.randSeedInt(totalWeight); + const commonThreshold = totalWeight - tierWeights[0]; + const greatThreshold = totalWeight - tierWeights[0] - tierWeights[1]; + const ultraThreshold = totalWeight - tierWeights[0] - tierWeights[1] - tierWeights[2]; + let tier: MysteryEncounterTier | null = tierValue > commonThreshold ? MysteryEncounterTier.COMMON : tierValue > greatThreshold ? MysteryEncounterTier.GREAT : tierValue > ultraThreshold ? MysteryEncounterTier.ULTRA : MysteryEncounterTier.ROGUE; + + if (!isNullOrUndefined(Overrides.MYSTERY_ENCOUNTER_TIER_OVERRIDE)) { + tier = Overrides.MYSTERY_ENCOUNTER_TIER_OVERRIDE!; + } + + let availableEncounters: MysteryEncounter[] = []; + // New encounter should never be the same as the most recent encounter + const previousEncounter = this.mysteryEncounterSaveData.encounteredEvents.length > 0 ? this.mysteryEncounterSaveData.encounteredEvents[this.mysteryEncounterSaveData.encounteredEvents.length - 1].type : null; + const biomeMysteryEncounters = mysteryEncountersByBiome.get(this.arena.biomeType) ?? []; + // If no valid encounters exist at tier, checks next tier down, continuing until there are some encounters available + while (availableEncounters.length === 0 && tier !== null) { + availableEncounters = biomeMysteryEncounters + .filter((encounterType) => { + const encounterCandidate = allMysteryEncounters[encounterType]; + if (!encounterCandidate) { + return false; + } + if (encounterCandidate.encounterTier !== tier) { // Encounter is in tier + return false; + } + const disabledModes = encounterCandidate.disabledGameModes; + if (disabledModes && disabledModes.length > 0 + && disabledModes.includes(this.gameMode.modeId)) { // Encounter is enabled for game mode + return false; + } + if (!encounterCandidate.meetsRequirements(this)) { // Meets encounter requirements + return false; + } + if (previousEncounter !== null && encounterType === previousEncounter) { // Previous encounter was not this one + return false; + } + if (this.mysteryEncounterSaveData.encounteredEvents.length > 0 && // Encounter has not exceeded max allowed encounters + (encounterCandidate.maxAllowedEncounters && encounterCandidate.maxAllowedEncounters > 0) + && this.mysteryEncounterSaveData.encounteredEvents.filter(e => e.type === encounterType).length >= encounterCandidate.maxAllowedEncounters) { + return false; + } + return true; + }) + .map((m) => (allMysteryEncounters[m])); + // Decrement tier + if (tier === MysteryEncounterTier.ROGUE) { + tier = MysteryEncounterTier.ULTRA; + } else if (tier === MysteryEncounterTier.ULTRA) { + tier = MysteryEncounterTier.GREAT; + } else if (tier === MysteryEncounterTier.GREAT) { + tier = MysteryEncounterTier.COMMON; + } else { + tier = null; // Ends loop + } + } + + // If absolutely no encounters are available, spawn 0th encounter + if (availableEncounters.length === 0) { + console.log("No Mystery Encounters found, falling back to Mysterious Challengers."); + return allMysteryEncounters[MysteryEncounterType.MYSTERIOUS_CHALLENGERS]; + } + encounter = availableEncounters[Utils.randSeedInt(availableEncounters.length)]; + // New encounter object to not dirty flags + encounter = new MysteryEncounter(encounter); + encounter.populateDialogueTokensFromRequirements(this); + return encounter; + } } diff --git a/src/battle.ts b/src/battle.ts index a3e7b0a43360..a886a0eb771b 100644 --- a/src/battle.ts +++ b/src/battle.ts @@ -14,28 +14,31 @@ import { PlayerGender } from "#enums/player-gender"; import { Species } from "#enums/species"; import { TrainerType } from "#enums/trainer-type"; import i18next from "#app/plugins/i18n"; +import MysteryEncounter from "./data/mystery-encounters/mystery-encounter"; +import { MysteryEncounterMode } from "#enums/mystery-encounter-mode"; export enum BattleType { - WILD, - TRAINER, - CLEAR + WILD, + TRAINER, + CLEAR, + MYSTERY_ENCOUNTER } export enum BattlerIndex { - ATTACKER = -1, - PLAYER, - PLAYER_2, - ENEMY, - ENEMY_2 + ATTACKER = -1, + PLAYER, + PLAYER_2, + ENEMY, + ENEMY_2 } export interface TurnCommand { - command: Command; - cursor?: number; - move?: QueuedMove; - targets?: BattlerIndex[]; - skip?: boolean; - args?: any[]; + command: Command; + cursor?: number; + move?: QueuedMove; + targets?: BattlerIndex[]; + skip?: boolean; + args?: any[]; } export interface FaintLogEntry { @@ -44,7 +47,7 @@ export interface FaintLogEntry { } interface TurnCommands { - [key: number]: TurnCommand | null + [key: number]: TurnCommand | null } export default class Battle { @@ -77,6 +80,9 @@ export default class Battle { public playerFaintsHistory: FaintLogEntry[] = []; public enemyFaintsHistory: FaintLogEntry[] = []; + /** If the current battle is a Mystery Encounter, this will always be defined */ + public mysteryEncounter?: MysteryEncounter; + private rngCounter: number = 0; constructor(gameMode: GameMode, waveIndex: number, battleType: BattleType, trainer?: Trainer, double?: boolean) { @@ -99,7 +105,7 @@ export default class Battle { this.battleSpec = spec; } - private getLevelForWave(): number { + public getLevelForWave(): number { const levelWaveIndex = this.gameMode.getWaveForDifficulty(this.waveIndex); const baseLevel = 1 + levelWaveIndex / 2 + Math.pow(levelWaveIndex / 25, 2); const bossMultiplier = 1.2; @@ -197,7 +203,11 @@ export default class Battle { getBgmOverride(scene: BattleScene): string | null { const battlers = this.enemyParty.slice(0, this.getBattlerCount()); - if (this.battleType === BattleType.TRAINER) { + if (this.battleType === BattleType.MYSTERY_ENCOUNTER && this.mysteryEncounter?.encounterMode === MysteryEncounterMode.DEFAULT) { + // Music is overridden for MEs during ME onInit() + // Should not use any BGM overrides before swapping from DEFAULT mode + return null; + } else if (this.battleType === BattleType.TRAINER || this.mysteryEncounter?.encounterMode === MysteryEncounterMode.TRAINER_BATTLE) { if (!this.started && this.trainer?.config.encounterBgm && this.trainer?.getEncounterMessages()?.length) { return `encounter_${this.trainer?.getEncounterBgm()}`; } diff --git a/src/data/battle-anims.ts b/src/data/battle-anims.ts index 102d435fc60f..64f7d85cae36 100644 --- a/src/data/battle-anims.ts +++ b/src/data/battle-anims.ts @@ -7,6 +7,9 @@ import { BattlerIndex } from "../battle"; import { Element } from "json-stable-stringify"; import { Moves } from "#enums/moves"; import { SubstituteTag } from "./battler-tags"; +import { isNullOrUndefined } from "../utils"; +import Phaser from "phaser"; +import { EncounterAnim } from "#enums/encounter-anims"; //import fs from 'vite-plugin-fs/browser'; export enum AnimFrameTarget { @@ -304,7 +307,7 @@ abstract class AnimTimedEvent { this.resourceName = resourceName; } - abstract execute(scene: BattleScene, battleAnim: BattleAnim): integer; + abstract execute(scene: BattleScene, battleAnim: BattleAnim, priority?: number): integer; abstract getEventType(): string; } @@ -322,7 +325,7 @@ class AnimTimedSoundEvent extends AnimTimedEvent { } } - execute(scene: BattleScene, battleAnim: BattleAnim): integer { + execute(scene: BattleScene, battleAnim: BattleAnim, priority?: number): integer { const soundConfig = { rate: (this.pitch * 0.01), volume: (this.volume * 0.01) }; if (this.resourceName) { try { @@ -384,7 +387,7 @@ class AnimTimedUpdateBgEvent extends AnimTimedBgEvent { super(frameIndex, resourceName, source); } - execute(scene: BattleScene, moveAnim: MoveAnim): integer { + execute(scene: BattleScene, moveAnim: MoveAnim, priority?: number): integer { const tweenProps = {}; if (this.bgX !== undefined) { tweenProps["x"] = (this.bgX * 0.5) - 320; @@ -414,7 +417,7 @@ class AnimTimedAddBgEvent extends AnimTimedBgEvent { super(frameIndex, resourceName, source); } - execute(scene: BattleScene, moveAnim: MoveAnim): integer { + execute(scene: BattleScene, moveAnim: MoveAnim, priority?: number): integer { if (moveAnim.bgSprite) { moveAnim.bgSprite.destroy(); } @@ -426,7 +429,9 @@ class AnimTimedAddBgEvent extends AnimTimedBgEvent { moveAnim.bgSprite.setAlpha(this.opacity / 255); scene.field.add(moveAnim.bgSprite); const fieldPokemon = scene.getEnemyPokemon() || scene.getPlayerPokemon(); - if (fieldPokemon?.isOnField()) { + if (!isNullOrUndefined(priority)) { + scene.field.moveTo(moveAnim.bgSprite as Phaser.GameObjects.GameObject, priority!); + } else if (fieldPokemon?.isOnField()) { scene.field.moveBelow(moveAnim.bgSprite as Phaser.GameObjects.GameObject, fieldPokemon); } @@ -446,6 +451,7 @@ class AnimTimedAddBgEvent extends AnimTimedBgEvent { export const moveAnims = new Map(); export const chargeAnims = new Map(); export const commonAnims = new Map(); +export const encounterAnims = new Map(); export function initCommonAnims(scene: BattleScene): Promise { return new Promise(resolve => { @@ -516,6 +522,26 @@ export function initMoveAnim(scene: BattleScene, move: Moves): Promise { }); } +/** + * Fetches animation configs to be used in a Mystery Encounter + * @param scene + * @param encounterAnim one or more animations to fetch + */ +export async function initEncounterAnims(scene: BattleScene, encounterAnim: EncounterAnim | EncounterAnim[]): Promise { + const anims = Array.isArray(encounterAnim) ? encounterAnim : [encounterAnim]; + const encounterAnimNames = Utils.getEnumKeys(EncounterAnim); + const encounterAnimFetches: Promise>[] = []; + for (const anim of anims) { + if (encounterAnims.has(anim) && !isNullOrUndefined(encounterAnims.get(anim))) { + continue; + } + encounterAnimFetches.push(scene.cachedFetch(`./battle-anims/encounter-${encounterAnimNames[anim].toLowerCase().replace(/\_/g, "-")}.json`) + .then(response => response.json()) + .then(cas => encounterAnims.set(anim, new AnimConfig(cas)))); + } + await Promise.allSettled(encounterAnimFetches); +} + export function initMoveChargeAnim(scene: BattleScene, chargeAnim: ChargeAnim): Promise { return new Promise(resolve => { if (chargeAnims.has(chargeAnim)) { @@ -570,6 +596,16 @@ export function loadCommonAnimAssets(scene: BattleScene, startLoad?: boolean): P }); } +/** + * Loads encounter animation assets to scene + * MUST be called after {@linkcode initEncounterAnims()} to load all required animations properly + * @param scene + * @param startLoad + */ +export async function loadEncounterAnimAssets(scene: BattleScene, startLoad?: boolean): Promise { + await loadAnimAssets(scene, Array.from(encounterAnims.values()), startLoad); +} + export function loadMoveAnimAssets(scene: BattleScene, moveIds: Moves[], startLoad?: boolean): Promise { return new Promise(resolve => { const moveAnimations = moveIds.map(m => moveAnims.get(m) as AnimConfig).flat(); @@ -679,14 +715,16 @@ export abstract class BattleAnim { public target: Pokemon | null; public sprites: Phaser.GameObjects.Sprite[]; public bgSprite: Phaser.GameObjects.TileSprite | Phaser.GameObjects.Rectangle; + public playOnEmptyField: boolean; private srcLine: number[]; private dstLine: number[]; - constructor(user?: Pokemon, target?: Pokemon) { + constructor(user?: Pokemon, target?: Pokemon, playOnEmptyField: boolean = false) { this.user = user ?? null; this.target = target ?? null; this.sprites = []; + this.playOnEmptyField = playOnEmptyField; } abstract getAnim(): AnimConfig | null; @@ -761,9 +799,9 @@ export abstract class BattleAnim { play(scene: BattleScene, onSubstitute?: boolean, callback?: Function) { const isOppAnim = this.isOppAnim(); const user = !isOppAnim ? this.user! : this.target!; // TODO: are those bangs correct? - const target = !isOppAnim ? this.target : this.user; + const target = !isOppAnim ? this.target! : this.user!; - if (!target?.isOnField()) { + if (!target?.isOnField() && !this.playOnEmptyField) { if (callback) { callback(); } @@ -866,6 +904,8 @@ export abstract class BattleAnim { const isUser = frame.target === AnimFrameTarget.USER; if (isUser && target === user) { continue; + } else if (this.playOnEmptyField && frame.target === AnimFrameTarget.TARGET && !target.isOnField()) { + continue; } const sprites = spriteCache[isUser ? AnimFrameTarget.USER : AnimFrameTarget.TARGET]; const spriteSource = isUser ? userSprite : targetSprite; @@ -1017,13 +1057,175 @@ export abstract class BattleAnim { } }); } + + private getGraphicFrameDataWithoutTarget(frames: AnimFrame[], targetInitialX: number, targetInitialY: number): Map> { + const ret: Map> = new Map([ + [AnimFrameTarget.GRAPHIC, new Map() ], + [AnimFrameTarget.USER, new Map() ], + [AnimFrameTarget.TARGET, new Map() ] + ]); + + let g = 0; + let u = 0; + let t = 0; + + for (const frame of frames) { + let { x, y } = frame; + const scaleX = (frame.zoomX / 100) * (!frame.mirror ? 1 : -1); + const scaleY = (frame.zoomY / 100); + x += targetInitialX; + y += targetInitialY; + const angle = -frame.angle; + const key = frame.target === AnimFrameTarget.GRAPHIC ? g++ : frame.target === AnimFrameTarget.USER ? u++ : t++; + ret.get(frame.target)?.set(key, { x: x, y: y, scaleX: scaleX, scaleY: scaleY, angle: angle }); + } + + return ret; + } + + /** + * + * @param scene + * @param targetInitialX + * @param targetInitialY + * @param frameTimeMult + * @param frameTimedEventPriority + * - 0 is behind all other sprites (except BG) + * - 1 on top of player field + * - 3 is on top of both fields + * - 5 is on top of player sprite + * @param callback + */ + playWithoutTargets(scene: BattleScene, targetInitialX: number, targetInitialY: number, frameTimeMult: number, frameTimedEventPriority?: 0 | 1 | 3 | 5, callback?: Function) { + const spriteCache: SpriteCache = { + [AnimFrameTarget.GRAPHIC]: [], + [AnimFrameTarget.USER]: [], + [AnimFrameTarget.TARGET]: [] + }; + + const cleanUpAndComplete = () => { + for (const ms of Object.values(spriteCache).flat()) { + if (ms) { + ms.destroy(); + } + } + if (this.bgSprite) { + this.bgSprite.destroy(); + } + if (callback) { + callback(); + } + }; + + if (!scene.moveAnimations) { + return cleanUpAndComplete(); + } + + const anim = this.getAnim(); + + this.srcLine = [ userFocusX, userFocusY, targetFocusX, targetFocusY ]; + this.dstLine = [ 150, 75, targetInitialX, targetInitialY ]; + + let totalFrames = anim!.frames.length; + let frameCount = 0; + + let existingFieldSprites = scene.field.getAll().slice(0); + + scene.tweens.addCounter({ + duration: Utils.getFrameMs(3) * frameTimeMult, + repeat: anim!.frames.length, + onRepeat: () => { + existingFieldSprites = scene.field.getAll().slice(0); + const spriteFrames = anim!.frames[frameCount]; + const frameData = this.getGraphicFrameDataWithoutTarget(anim!.frames[frameCount], targetInitialX, targetInitialY); + let graphicFrameCount = 0; + for (const frame of spriteFrames) { + if (frame.target !== AnimFrameTarget.GRAPHIC) { + console.log("Encounter animations do not support targets"); + continue; + } + + const sprites = spriteCache[AnimFrameTarget.GRAPHIC]; + if (graphicFrameCount === sprites.length) { + const newSprite: Phaser.GameObjects.Sprite = scene.addFieldSprite(0, 0, anim!.graphic, 1); + sprites.push(newSprite); + scene.field.add(newSprite); + } + + const graphicIndex = graphicFrameCount++; + const moveSprite = sprites[graphicIndex]; + if (!isNullOrUndefined(frame.priority)) { + const setSpritePriority = (priority: integer) => { + if (existingFieldSprites.length > priority) { + // Move to specified priority index + const index = scene.field.getIndex(existingFieldSprites[priority]); + scene.field.moveTo(moveSprite, index); + } else { + // Move to top of scene + scene.field.moveTo(moveSprite, scene.field.getAll().length - 1); + } + }; + setSpritePriority(frame.priority); + } + moveSprite.setFrame(frame.graphicFrame); + + const graphicFrameData = frameData.get(frame.target)?.get(graphicIndex); + if (graphicFrameData) { + moveSprite.setPosition(graphicFrameData.x, graphicFrameData.y); + moveSprite.setAngle(graphicFrameData.angle); + moveSprite.setScale(graphicFrameData.scaleX, graphicFrameData.scaleY); + + moveSprite.setAlpha(frame.opacity / 255); + moveSprite.setVisible(frame.visible); + moveSprite.setBlendMode(frame.blendType === AnimBlendType.NORMAL ? Phaser.BlendModes.NORMAL : frame.blendType === AnimBlendType.ADD ? Phaser.BlendModes.ADD : Phaser.BlendModes.DIFFERENCE); + } + } + if (anim?.frameTimedEvents.get(frameCount)) { + for (const event of anim.frameTimedEvents.get(frameCount)!) { + totalFrames = Math.max((anim.frames.length - frameCount) + event.execute(scene, this, frameTimedEventPriority), totalFrames); + } + } + const targets = Utils.getEnumValues(AnimFrameTarget); + for (const i of targets) { + const count = graphicFrameCount; + if (count < spriteCache[i].length) { + const spritesToRemove = spriteCache[i].slice(count, spriteCache[i].length); + for (const sprite of spritesToRemove) { + if (!sprite.getData("locked") as boolean) { + const spriteCacheIndex = spriteCache[i].indexOf(sprite); + spriteCache[i].splice(spriteCacheIndex, 1); + sprite.destroy(); + } + } + } + } + frameCount++; + totalFrames--; + }, + onComplete: () => { + for (const sprite of Object.values(spriteCache).flat()) { + if (sprite && !sprite.getData("locked")) { + sprite.destroy(); + } + } + if (totalFrames) { + scene.tweens.addCounter({ + duration: Utils.getFrameMs(totalFrames), + onComplete: () => cleanUpAndComplete() + }); + } else { + cleanUpAndComplete(); + } + } + }); + } } export class CommonBattleAnim extends BattleAnim { public commonAnim: CommonAnim | null; - constructor(commonAnim: CommonAnim | null, user: Pokemon, target?: Pokemon) { - super(user, target || user); + constructor(commonAnim: CommonAnim | null, user: Pokemon, target?: Pokemon, playOnEmptyField: boolean = false) { + super(user, target || user, playOnEmptyField); this.commonAnim = commonAnim; } @@ -1040,8 +1242,8 @@ export class CommonBattleAnim extends BattleAnim { export class MoveAnim extends BattleAnim { public move: Moves; - constructor(move: Moves, user: Pokemon, target: BattlerIndex) { - super(user, user.scene.getField()[target]); + constructor(move: Moves, user: Pokemon, target: BattlerIndex, playOnEmptyField: boolean = false) { + super(user, user.scene.getField()[target], playOnEmptyField); this.move = move; } @@ -1085,6 +1287,26 @@ export class MoveChargeAnim extends MoveAnim { } } +export class EncounterBattleAnim extends BattleAnim { + public encounterAnim: EncounterAnim; + public oppAnim: boolean; + + constructor(encounterAnim: EncounterAnim, user: Pokemon, target?: Pokemon, oppAnim?: boolean) { + super(user, target ?? user, true); + + this.encounterAnim = encounterAnim; + this.oppAnim = oppAnim ?? false; + } + + getAnim(): AnimConfig | null { + return encounterAnims.get(this.encounterAnim) ?? null; + } + + isOppAnim(): boolean { + return this.oppAnim; + } +} + export async function populateAnims() { const commonAnimNames = Utils.getEnumKeys(CommonAnim).map(k => k.toLowerCase()); const commonAnimMatchNames = commonAnimNames.map(k => k.replace(/\_/g, "")); diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index a43fa58ba1d8..965da8441212 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -5,7 +5,7 @@ import { StatusEffect } from "./status-effect"; import * as Utils from "../utils"; import { ChargeAttr, MoveFlags, allMoves } from "./move"; import { Type } from "./type"; -import { BlockNonDirectDamageAbAttr, FlinchEffectAbAttr, ReverseDrainAbAttr, applyAbAttrs } from "./ability"; +import { BlockNonDirectDamageAbAttr, FlinchEffectAbAttr, ReverseDrainAbAttr, applyAbAttrs, ProtectStatAbAttr } from "./ability"; import { TerrainType } from "./terrain"; import { WeatherType } from "./weather"; import { allAbilities } from "./ability"; @@ -2304,6 +2304,45 @@ export class SubstituteTag extends BattlerTag { } } +/** + * Tag that adds extra post-summon effects to a battle for a specific Pokemon. + * These post-summon effects are performed through {@linkcode Pokemon.mysteryEncounterBattleEffects}, + * and can be used to unshift special phases, etc. + * Currently used only in MysteryEncounters to provide start of fight stat buffs. + */ +export class MysteryEncounterPostSummonTag extends BattlerTag { + constructor() { + super(BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON, BattlerTagLapseType.CUSTOM, 1); + } + + /** Event when tag is added */ + onAdd(pokemon: Pokemon): void { + super.onAdd(pokemon); + } + + /** Performs post-summon effects through {@linkcode Pokemon.mysteryEncounterBattleEffects} */ + lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { + const ret = super.lapse(pokemon, lapseType); + + if (lapseType === BattlerTagLapseType.CUSTOM) { + const cancelled = new Utils.BooleanHolder(false); + applyAbAttrs(ProtectStatAbAttr, pokemon, cancelled); + if (!cancelled.value) { + if (pokemon.mysteryEncounterBattleEffects) { + pokemon.mysteryEncounterBattleEffects(pokemon); + } + } + } + + return ret; + } + + /** Event when tag is removed */ + onRemove(pokemon: Pokemon): void { + super.onRemove(pokemon); + } +} + /** * Retrieves a {@linkcode BattlerTag} based on the provided tag type, turn count, source move, and source ID. * @@ -2465,6 +2504,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source return new GorillaTacticsTag(); case BattlerTagType.SUBSTITUTE: return new SubstituteTag(sourceMove, sourceId); + case BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON: + return new MysteryEncounterPostSummonTag(); case BattlerTagType.NONE: default: return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId); diff --git a/src/data/dialogue.ts b/src/data/dialogue.ts index 355f05523d14..b01242d083a7 100644 --- a/src/data/dialogue.ts +++ b/src/data/dialogue.ts @@ -1093,6 +1093,136 @@ export const trainerTypeDialogue: TrainerTypeDialogue = { ] } ], + [TrainerType.BUCK]: [ + { + encounter: [ + "dialogue:stat_trainer_buck.encounter.1", + "dialogue:stat_trainer_buck.encounter.2" + ], + victory: [ + "dialogue:stat_trainer_buck.victory.1", + "dialogue:stat_trainer_buck.victory.2" + ], + defeat: [ + "dialogue:stat_trainer_buck.defeat.1", + "dialogue:stat_trainer_buck.defeat.2" + ] + } + ], + [TrainerType.CHERYL]: [ + { + encounter: [ + "dialogue:stat_trainer_cheryl.encounter.1", + "dialogue:stat_trainer_cheryl.encounter.2" + ], + victory: [ + "dialogue:stat_trainer_cheryl.victory.1", + "dialogue:stat_trainer_cheryl.victory.2" + ], + defeat: [ + "dialogue:stat_trainer_cheryl.defeat.1", + "dialogue:stat_trainer_cheryl.defeat.2" + ] + } + ], + [TrainerType.MARLEY]: [ + { + encounter: [ + "dialogue:stat_trainer_marley.encounter.1", + "dialogue:stat_trainer_marley.encounter.2" + ], + victory: [ + "dialogue:stat_trainer_marley.victory.1", + "dialogue:stat_trainer_marley.victory.2" + ], + defeat: [ + "dialogue:stat_trainer_marley.defeat.1", + "dialogue:stat_trainer_marley.defeat.2" + ] + } + ], + [TrainerType.MIRA]: [ + { + encounter: [ + "dialogue:stat_trainer_mira.encounter.1", + "dialogue:stat_trainer_mira.encounter.2" + ], + victory: [ + "dialogue:stat_trainer_mira.victory.1", + "dialogue:stat_trainer_mira.victory.2" + ], + defeat: [ + "dialogue:stat_trainer_mira.defeat.1", + "dialogue:stat_trainer_mira.defeat.2" + ] + } + ], + [TrainerType.RILEY]: [ + { + encounter: [ + "dialogue:stat_trainer_riley.encounter.1", + "dialogue:stat_trainer_riley.encounter.2" + ], + victory: [ + "dialogue:stat_trainer_riley.victory.1", + "dialogue:stat_trainer_riley.victory.2" + ], + defeat: [ + "dialogue:stat_trainer_riley.defeat.1", + "dialogue:stat_trainer_riley.defeat.2" + ] + } + ], + [TrainerType.VICTOR]: [ + { + encounter: [ + "dialogue:winstrates_victor.encounter.1", + ], + victory: [ + "dialogue:winstrates_victor.victory.1" + ], + } + ], + [TrainerType.VICTORIA]: [ + { + encounter: [ + "dialogue:winstrates_victoria.encounter.1", + ], + victory: [ + "dialogue:winstrates_victoria.victory.1" + ], + } + ], + [TrainerType.VIVI]: [ + { + encounter: [ + "dialogue:winstrates_vivi.encounter.1", + ], + victory: [ + "dialogue:winstrates_vivi.victory.1" + ], + } + ], + [TrainerType.VICKY]: [ + { + encounter: [ + "dialogue:winstrates_vicky.encounter.1", + ], + victory: [ + "dialogue:winstrates_vicky.victory.1" + ], + } + ], + [TrainerType.VITO]: [ + { + encounter: [ + "dialogue:winstrates_vito.encounter.1", + ], + victory: [ + "dialogue:winstrates_vito.victory.1" + ], + } + ], [TrainerType.BROCK]: { encounter: [ "dialogue:brock.encounter.1", diff --git a/src/data/egg.ts b/src/data/egg.ts index 1cd5c65fc18b..0219f4f5b47a 100644 --- a/src/data/egg.ts +++ b/src/data/egg.ts @@ -61,7 +61,10 @@ export interface IEggOptions { /** Defines if the egg will hatch with the hidden ability of this species. * If no hidden ability exist, a random one will get choosen. */ - overrideHiddenAbility?: boolean + overrideHiddenAbility?: boolean, + + /** Can customize the message displayed for where the egg was obtained */ + eggDescriptor?: string; } export class Egg { @@ -83,6 +86,8 @@ export class Egg { private _overrideHiddenAbility: boolean; + private _eggDescriptor?: string; + //// // #endregion //// @@ -191,6 +196,8 @@ export class Egg { } else { // For legacy eggs without scene generateEggProperties(eggOptions); } + + this._eggDescriptor = eggOptions?.eggDescriptor; } //// @@ -292,13 +299,15 @@ export class Egg { public getEggTypeDescriptor(scene: BattleScene): string { switch (this.sourceType) { case EggSourceType.SAME_SPECIES_EGG: - return i18next.t("egg:sameSpeciesEgg", { species: getPokemonSpecies(this._species).getName()}); + return this._eggDescriptor ?? i18next.t("egg:sameSpeciesEgg", { species: getPokemonSpecies(this._species).getName()}); case EggSourceType.GACHA_LEGENDARY: - return `${i18next.t("egg:gachaTypeLegendary")} (${getPokemonSpecies(getLegendaryGachaSpeciesForTimestamp(scene, this.timestamp)).getName()})`; + return this._eggDescriptor ?? `${i18next.t("egg:gachaTypeLegendary")} (${getPokemonSpecies(getLegendaryGachaSpeciesForTimestamp(scene, this.timestamp)).getName()})`; case EggSourceType.GACHA_SHINY: - return i18next.t("egg:gachaTypeShiny"); + return this._eggDescriptor ?? i18next.t("egg:gachaTypeShiny"); case EggSourceType.GACHA_MOVE: - return i18next.t("egg:gachaTypeMove"); + return this._eggDescriptor ?? i18next.t("egg:gachaTypeMove"); + case EggSourceType.EVENT: + return this._eggDescriptor ?? i18next.t("egg:eventType"); default: console.warn("getEggTypeDescriptor case not defined. Returning default empty string"); return ""; diff --git a/src/data/mystery-encounters/encounters/a-trainers-test-encounter.ts b/src/data/mystery-encounters/encounters/a-trainers-test-encounter.ts new file mode 100644 index 000000000000..b66ca10c9f5b --- /dev/null +++ b/src/data/mystery-encounters/encounters/a-trainers-test-encounter.ts @@ -0,0 +1,186 @@ +import { EnemyPartyConfig, initBattleWithEnemyConfig, leaveEncounterWithoutBattle, setEncounterRewards, transitionMysteryEncounterIntroVisuals, } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { trainerConfigs, } from "#app/data/trainer-config"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { TrainerType } from "#enums/trainer-type"; +import { Species } from "#enums/species"; +import { getSpriteKeysFromSpecies } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { randSeedInt } from "#app/utils"; +import i18next from "i18next"; +import { IEggOptions } from "#app/data/egg"; +import { EggSourceType } from "#enums/egg-source-types"; +import { EggTier } from "#enums/egg-type"; +import { PartyHealPhase } from "#app/phases/party-heal-phase"; +import { ModifierTier } from "#app/modifier/modifier-tier"; +import { modifierTypes } from "#app/modifier/modifier-type"; + +/** the i18n namespace for the encounter */ +const namespace = "mysteryEncounter:aTrainersTest"; + +/** + * A Trainer's Test encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3816 | GitHub Issue #3816} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const ATrainersTestEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.A_TRAINERS_TEST) + .withEncounterTier(MysteryEncounterTier.ROGUE) + .withSceneWaveRangeRequirement(100, 180) + .withIntroSpriteConfigs([]) // These are set in onInit() + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + ]) + .withAutoHideIntroVisuals(false) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + + // Randomly pick from 1 of the 5 stat trainers to spawn + let trainerType: TrainerType; + let spriteKeys; + let trainerNameKey: string; + switch (randSeedInt(5)) { + default: + case 0: + trainerType = TrainerType.BUCK; + spriteKeys = getSpriteKeysFromSpecies(Species.CLAYDOL); + trainerNameKey = "buck"; + break; + case 1: + trainerType = TrainerType.CHERYL; + spriteKeys = getSpriteKeysFromSpecies(Species.BLISSEY); + trainerNameKey = "cheryl"; + break; + case 2: + trainerType = TrainerType.MARLEY; + spriteKeys = getSpriteKeysFromSpecies(Species.ARCANINE); + trainerNameKey = "marley"; + break; + case 3: + trainerType = TrainerType.MIRA; + spriteKeys = getSpriteKeysFromSpecies(Species.ALAKAZAM, false, 1); + trainerNameKey = "mira"; + break; + case 4: + trainerType = TrainerType.RILEY; + spriteKeys = getSpriteKeysFromSpecies(Species.LUCARIO, false, 1); + trainerNameKey = "riley"; + break; + } + + // Dialogue and tokens for trainer + encounter.dialogue.intro = [ + { + speaker: `trainerNames:${trainerNameKey}`, + text: `${namespace}.${trainerNameKey}.intro_dialogue` + } + ]; + encounter.options[0].dialogue!.selected = [ + { + speaker: `trainerNames:${trainerNameKey}`, + text: `${namespace}.${trainerNameKey}.accept` + } + ]; + encounter.options[1].dialogue!.selected = [ + { + speaker: `trainerNames:${trainerNameKey}`, + text: `${namespace}.${trainerNameKey}.decline` + } + ]; + + encounter.setDialogueToken("statTrainerName", i18next.t(`trainerNames:${trainerNameKey}`)); + const eggDescription = i18next.t(`${namespace}.title`) + ":\n" + i18next.t(`trainerNames:${trainerNameKey}`); + encounter.misc = { trainerType, trainerNameKey, trainerEggDescription: eggDescription }; + + // Trainer config + const trainerConfig = trainerConfigs[trainerType].clone(); + const trainerSpriteKey = trainerConfig.getSpriteKey(); + encounter.enemyPartyConfigs.push({ + levelAdditiveMultiplier: 1, + trainerConfig: trainerConfig + }); + + encounter.spriteConfigs = [ + { + spriteKey: spriteKeys.spriteKey, + fileRoot: spriteKeys.fileRoot, + hasShadow: true, + repeat: true, + isPokemon: true, + x: 22, + y: -2, + yShadow: -2 + }, + { + spriteKey: trainerSpriteKey, + fileRoot: "trainer", + hasShadow: true, + disableAnimation: true, + x: -24, + y: 4, + yShadow: 4 + } + ]; + + return true; + }) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withIntroDialogue() + .withSimpleOption( + { + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip` + }, + async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + // Battle the stat trainer for an Egg and great rewards + const config: EnemyPartyConfig = encounter.enemyPartyConfigs[0]; + + await transitionMysteryEncounterIntroVisuals(scene); + + const eggOptions: IEggOptions = { + scene, + pulled: false, + sourceType: EggSourceType.EVENT, + eggDescriptor: encounter.misc.trainerEggDescription, + tier: EggTier.ULTRA + }; + encounter.setDialogueToken("eggType", i18next.t(`${namespace}.eggTypes.epic`)); + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: [modifierTypes.SACRED_ASH], guaranteedModifierTiers: [ModifierTier.ROGUE, ModifierTier.ULTRA], fillRemaining: true }, [eggOptions]); + + return initBattleWithEnemyConfig(scene, config); + } + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip` + }, + async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + // Full heal party + scene.unshiftPhase(new PartyHealPhase(scene, true)); + + const eggOptions: IEggOptions = { + scene, + pulled: false, + sourceType: EggSourceType.EVENT, + eggDescriptor: encounter.misc.trainerEggDescription, + tier: EggTier.GREAT + }; + encounter.setDialogueToken("eggType", i18next.t(`${namespace}.eggTypes.rare`)); + setEncounterRewards(scene, { fillRemaining: false, rerollMultiplier: -1 }, [eggOptions]); + leaveEncounterWithoutBattle(scene); + } + ) + .withOutroDialogue([ + { + text: `${namespace}.outro`, + }, + ]) + .build(); diff --git a/src/data/mystery-encounters/encounters/absolute-avarice-encounter.ts b/src/data/mystery-encounters/encounters/absolute-avarice-encounter.ts new file mode 100644 index 000000000000..a9a273c6ec4e --- /dev/null +++ b/src/data/mystery-encounters/encounters/absolute-avarice-encounter.ts @@ -0,0 +1,521 @@ +import { EnemyPartyConfig, generateModifierType, initBattleWithEnemyConfig, leaveEncounterWithoutBattle, setEncounterRewards, transitionMysteryEncounterIntroVisuals, } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import Pokemon, { EnemyPokemon, PokemonMove } from "#app/field/pokemon"; +import { BerryModifierType, modifierTypes, PokemonHeldItemModifierType } from "#app/modifier/modifier-type"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { Species } from "#enums/species"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterOptionBuilder } from "../mystery-encounter-option"; +import { PersistentModifierRequirement } from "../mystery-encounter-requirements"; +import { queueEncounterMessage } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { BerryModifier } from "#app/modifier/modifier"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { Moves } from "#enums/moves"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { randInt } from "#app/utils"; +import { BattlerIndex } from "#app/battle"; +import { applyModifierTypeToPlayerPokemon, catchPokemon, getHighestLevelPlayerPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { TrainerSlot } from "#app/data/trainer-config"; +import { PokeballType } from "#app/data/pokeball"; +import HeldModifierConfig from "#app/interfaces/held-modifier-config"; +import { BerryType } from "#enums/berry-type"; +import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; +import { Stat } from "#enums/stat"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for this encounter */ +const namespace = "mysteryEncounter:absoluteAvarice"; + +/** + * Absolute Avarice encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3805 | GitHub Issue #3805} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const AbsoluteAvariceEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.ABSOLUTE_AVARICE) + .withEncounterTier(MysteryEncounterTier.GREAT) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withSceneRequirement(new PersistentModifierRequirement("BerryModifier", 4)) // Must have at least 4 berries to spawn + .withIntroSpriteConfigs([ + { + // This sprite has the shadow + spriteKey: "", + fileRoot: "", + species: Species.GREEDENT, + hasShadow: true, + alpha: 0.001, + repeat: true, + x: -5 + }, + { + spriteKey: "", + fileRoot: "", + species: Species.GREEDENT, + hasShadow: false, + repeat: true, + x: -5 + }, + { + spriteKey: "lum_berry", + fileRoot: "items", + isItem: true, + x: 7, + y: -14, + hidden: true, + disableAnimation: true + }, + { + spriteKey: "salac_berry", + fileRoot: "items", + isItem: true, + x: 2, + y: 4, + hidden: true, + disableAnimation: true + }, + { + spriteKey: "lansat_berry", + fileRoot: "items", + isItem: true, + x: 32, + y: 5, + hidden: true, + disableAnimation: true + }, + { + spriteKey: "liechi_berry", + fileRoot: "items", + isItem: true, + x: 6, + y: -5, + hidden: true, + disableAnimation: true + }, + { + spriteKey: "sitrus_berry", + fileRoot: "items", + isItem: true, + x: 7, + y: 8, + hidden: true, + disableAnimation: true + }, + { + spriteKey: "enigma_berry", + fileRoot: "items", + isItem: true, + x: 26, + y: -4, + hidden: true, + disableAnimation: true + }, + { + spriteKey: "leppa_berry", + fileRoot: "items", + isItem: true, + x: 16, + y: -27, + hidden: true, + disableAnimation: true + }, + { + spriteKey: "petaya_berry", + fileRoot: "items", + isItem: true, + x: 30, + y: -17, + hidden: true, + disableAnimation: true + }, + { + spriteKey: "ganlon_berry", + fileRoot: "items", + isItem: true, + x: 16, + y: -11, + hidden: true, + disableAnimation: true + }, + { + spriteKey: "apicot_berry", + fileRoot: "items", + isItem: true, + x: 14, + y: -2, + hidden: true, + disableAnimation: true + }, + { + spriteKey: "starf_berry", + fileRoot: "items", + isItem: true, + x: 18, + y: 9, + hidden: true, + disableAnimation: true + }, + ]) + .withHideWildIntroMessage(true) + .withAutoHideIntroVisuals(false) + .withOnVisualsStart((scene: BattleScene) => { + doGreedentSpriteSteal(scene); + doBerrySpritePile(scene); + + return true; + }) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + } + ]) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + + scene.loadSe("PRSFX- Bug Bite", "battle_anims", "PRSFX- Bug Bite.wav"); + scene.loadSe("Follow Me", "battle_anims", "Follow Me.mp3"); + + // Get all player berry items, remove from party, and store reference + const berryItems = scene.findModifiers(m => m instanceof BerryModifier) as BerryModifier[]; + + // Sort berries by party member ID to more easily re-add later if necessary + const berryItemsMap = new Map(); + scene.getParty().forEach(pokemon => { + const pokemonBerries = berryItems.filter(b => b.pokemonId === pokemon.id); + if (pokemonBerries?.length > 0) { + berryItemsMap.set(pokemon.id, pokemonBerries); + } + }); + + encounter.misc = { berryItemsMap }; + + // Generates copies of the stolen berries to put on the Greedent + const bossModifierConfigs: HeldModifierConfig[] = []; + berryItems.forEach(berryMod => { + // Can't define stack count on a ModifierType, have to just create separate instances for each stack + // Overflow berries will be "lost" on the boss, but it's un-catchable anyway + for (let i = 0; i < berryMod.stackCount; i++) { + const modifierType = generateModifierType(scene, modifierTypes.BERRY, [berryMod.berryType]) as PokemonHeldItemModifierType; + bossModifierConfigs.push({ modifier: modifierType }); + } + + scene.removeModifier(berryMod); + }); + + // Calculate boss mon + const config: EnemyPartyConfig = { + levelAdditiveMultiplier: 1, + pokemonConfigs: [ + { + species: getPokemonSpecies(Species.GREEDENT), + isBoss: true, + bossSegments: 3, + moveSet: [Moves.THRASH, Moves.BODY_PRESS, Moves.STUFF_CHEEKS, Moves.SLACK_OFF], + modifierConfigs: bossModifierConfigs, + tags: [BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON], + mysteryEncounterBattleEffects: (pokemon: Pokemon) => { + queueEncounterMessage(pokemon.scene, `${namespace}.option.1.boss_enraged`); + pokemon.scene.unshiftPhase(new StatStageChangePhase(pokemon.scene, pokemon.getBattlerIndex(), true, [Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD], 1)); + } + } + ], + }; + + encounter.enemyPartyConfigs = [config]; + encounter.setDialogueToken("greedentName", getPokemonSpecies(Species.GREEDENT).getName()); + + return true; + }) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }) + .withOptionPhase(async (scene: BattleScene) => { + // Pick battle + const encounter = scene.currentBattle.mysteryEncounter!; + + // Provides 1x Reviver Seed to each party member at end of battle + const revSeed = generateModifierType(scene, modifierTypes.REVIVER_SEED); + const givePartyPokemonReviverSeeds = () => { + const party = scene.getParty(); + party.forEach(p => { + if (revSeed) { + const seedModifier = revSeed.newModifier(p); + if (seedModifier) { + encounter.setDialogueToken("foodReward", seedModifier.type.name); + } + scene.addModifier(seedModifier, false, false, false, true); + } + }); + queueEncounterMessage(scene, `${namespace}.option.1.food_stash`); + }; + + setEncounterRewards(scene, { fillRemaining: true }, undefined, givePartyPokemonReviverSeeds); + encounter.startOfBattleEffects.push({ + sourceBattlerIndex: BattlerIndex.ENEMY, + targets: [BattlerIndex.ENEMY], + move: new PokemonMove(Moves.STUFF_CHEEKS), + ignorePp: true + }); + + transitionMysteryEncounterIntroVisuals(scene, true, true, 500); + await initBattleWithEnemyConfig(scene, encounter.enemyPartyConfigs[0]); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }) + .withOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + const berryMap = encounter.misc.berryItemsMap; + + // Returns 2/5 of the berries stolen from each Pokemon + const party = scene.getParty(); + party.forEach(pokemon => { + const stolenBerries: BerryModifier[] = berryMap.get(pokemon.id); + const berryTypesAsArray: BerryType[] = []; + stolenBerries?.forEach(bMod => berryTypesAsArray.push(...new Array(bMod.stackCount).fill(bMod.berryType))); + const returnedBerryCount = Math.floor((berryTypesAsArray.length ?? 0) * 2 / 5); + + if (returnedBerryCount > 0) { + for (let i = 0; i < returnedBerryCount; i++) { + // Shuffle remaining berry types and pop + Phaser.Math.RND.shuffle(berryTypesAsArray); + const randBerryType = berryTypesAsArray.pop(); + + const berryModType = generateModifierType(scene, modifierTypes.BERRY, [randBerryType]) as BerryModifierType; + applyModifierTypeToPlayerPokemon(scene, pokemon, berryModType); + } + } + }); + + transitionMysteryEncounterIntroVisuals(scene, true, true, 500); + leaveEncounterWithoutBattle(scene, true); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene) => { + // Animate berries being eaten + doGreedentEatBerries(scene); + doBerrySpritePile(scene, true); + return true; + }) + .withOptionPhase(async (scene: BattleScene) => { + // Let it have the food + // Greedent joins the team, level equal to 2 below highest party member + const level = getHighestLevelPlayerPokemon(scene).level - 2; + const greedent = new EnemyPokemon(scene, getPokemonSpecies(Species.GREEDENT), level, TrainerSlot.NONE, false); + greedent.moveset = [new PokemonMove(Moves.THRASH), new PokemonMove(Moves.BODY_PRESS), new PokemonMove(Moves.STUFF_CHEEKS), new PokemonMove(Moves.SLACK_OFF)]; + greedent.passive = true; + + transitionMysteryEncounterIntroVisuals(scene, true, true, 500); + await catchPokemon(scene, greedent, null, PokeballType.POKEBALL, false); + leaveEncounterWithoutBattle(scene, true); + }) + .build() + ) + .build(); + +function doGreedentSpriteSteal(scene: BattleScene) { + const shakeDelay = 50; + const slideDelay = 500; + + const greedentSprites = scene.currentBattle.mysteryEncounter!.introVisuals?.getSpriteAtIndex(1); + + scene.playSound("battle_anims/Follow Me"); + scene.tweens.chain({ + targets: greedentSprites, + tweens: [ + { // Slide Greedent diagonally + duration: slideDelay, + ease: "Cubic.easeOut", + y: "+=75", + x: "-=65", + scale: 1.1 + }, + { // Shake + duration: shakeDelay, + ease: "Cubic.easeOut", + yoyo: true, + x: (randInt(2) > 0 ? "-=" : "+=") + 5, + y: (randInt(2) > 0 ? "-=" : "+=") + 5, + }, + { // Shake + duration: shakeDelay, + ease: "Cubic.easeOut", + yoyo: true, + x: (randInt(2) > 0 ? "-=" : "+=") + 5, + y: (randInt(2) > 0 ? "-=" : "+=") + 5, + }, + { // Shake + duration: shakeDelay, + ease: "Cubic.easeOut", + yoyo: true, + x: (randInt(2) > 0 ? "-=" : "+=") + 5, + y: (randInt(2) > 0 ? "-=" : "+=") + 5, + }, + { // Shake + duration: shakeDelay, + ease: "Cubic.easeOut", + yoyo: true, + x: (randInt(2) > 0 ? "-=" : "+=") + 5, + y: (randInt(2) > 0 ? "-=" : "+=") + 5, + }, + { // Shake + duration: shakeDelay, + ease: "Cubic.easeOut", + yoyo: true, + x: (randInt(2) > 0 ? "-=" : "+=") + 5, + y: (randInt(2) > 0 ? "-=" : "+=") + 5, + }, + { // Shake + duration: shakeDelay, + ease: "Cubic.easeOut", + yoyo: true, + x: (randInt(2) > 0 ? "-=" : "+=") + 5, + y: (randInt(2) > 0 ? "-=" : "+=") + 5, + }, + { // Slide Greedent diagonally + duration: slideDelay, + ease: "Cubic.easeOut", + y: "-=75", + x: "+=65", + scale: 1 + }, + { // Bounce at the end + duration: 300, + ease: "Cubic.easeOut", + yoyo: true, + y: "-=20", + loop: 1, + } + ] + }); +} + +function doGreedentEatBerries(scene: BattleScene) { + const greedentSprites = scene.currentBattle.mysteryEncounter!.introVisuals?.getSpriteAtIndex(1); + let index = 1; + scene.tweens.add({ + targets: greedentSprites, + duration: 150, + ease: "Cubic.easeOut", + yoyo: true, + y: "-=8", + loop: 5, + onStart: () => { + scene.playSound("battle_anims/PRSFX- Bug Bite"); + }, + onLoop: () => { + if (index % 2 === 0) { + scene.playSound("battle_anims/PRSFX- Bug Bite"); + } + index++; + } + }); +} + +/** + * + * @param scene + * @param isEat Default false. Will "create" pile when false, and remove pile when true. + */ +function doBerrySpritePile(scene: BattleScene, isEat: boolean = false) { + const berryAddDelay = 150; + let animationOrder = ["starf", "sitrus", "lansat", "salac", "apicot", "enigma", "liechi", "ganlon", "lum", "petaya", "leppa"]; + if (isEat) { + animationOrder = animationOrder.reverse(); + } + const encounter = scene.currentBattle.mysteryEncounter!; + animationOrder.forEach((berry, i) => { + const introVisualsIndex = encounter.spriteConfigs.findIndex(config => config.spriteKey?.includes(berry)); + let sprite: Phaser.GameObjects.Sprite, tintSprite: Phaser.GameObjects.Sprite; + const sprites = encounter.introVisuals?.getSpriteAtIndex(introVisualsIndex); + if (sprites) { + sprite = sprites[0]; + tintSprite = sprites[1]; + } + scene.time.delayedCall(berryAddDelay * i + 400, () => { + if (sprite) { + sprite.setVisible(!isEat); + } + if (tintSprite) { + tintSprite.setVisible(!isEat); + } + + // Animate Petaya berry falling off the pile + if (berry === "petaya" && sprite && tintSprite && !isEat) { + scene.time.delayedCall(200, () => { + doBerryBounce(scene, [sprite, tintSprite], 30, 500); + }); + } + }); + }); +} + +function doBerryBounce(scene: BattleScene, berrySprites: Phaser.GameObjects.Sprite[], yd: number, baseBounceDuration: number) { + let bouncePower = 1; + let bounceYOffset = yd; + + const doBounce = () => { + scene.tweens.add({ + targets: berrySprites, + y: "+=" + bounceYOffset, + x: { value: "+=" + (bouncePower * bouncePower * 10), ease: "Linear" }, + duration: bouncePower * baseBounceDuration, + ease: "Cubic.easeIn", + onComplete: () => { + bouncePower = bouncePower > 0.01 ? bouncePower * 0.5 : 0; + + if (bouncePower) { + bounceYOffset = bounceYOffset * bouncePower; + + scene.tweens.add({ + targets: berrySprites, + y: "-=" + bounceYOffset, + x: { value: "+=" + (bouncePower * bouncePower * 10), ease: "Linear" }, + duration: bouncePower * baseBounceDuration, + ease: "Cubic.easeOut", + onComplete: () => doBounce() + }); + } + } + }); + }; + + doBounce(); +} diff --git a/src/data/mystery-encounters/encounters/an-offer-you-cant-refuse-encounter.ts b/src/data/mystery-encounters/encounters/an-offer-you-cant-refuse-encounter.ts new file mode 100644 index 000000000000..9f38b5a4deaa --- /dev/null +++ b/src/data/mystery-encounters/encounters/an-offer-you-cant-refuse-encounter.ts @@ -0,0 +1,165 @@ +import { leaveEncounterWithoutBattle, setEncounterExp, updatePlayerMoney, } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { modifierTypes } from "#app/modifier/modifier-type"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { Species } from "#enums/species"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterOptionBuilder } from "../mystery-encounter-option"; +import { AbilityRequirement, CombinationPokemonRequirement, MoveRequirement } from "../mystery-encounter-requirements"; +import { getHighestStatTotalPlayerPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { EXTORTION_ABILITIES, EXTORTION_MOVES } from "#app/data/mystery-encounters/requirements/requirement-groups"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { ModifierRewardPhase } from "#app/phases/modifier-reward-phase"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for this encounter */ +const namespace = "mysteryEncounter:offerYouCantRefuse"; + +/** + * An Offer You Can't Refuse encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3808 | GitHub Issue #3808} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const AnOfferYouCantRefuseEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE) + .withEncounterTier(MysteryEncounterTier.GREAT) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withScenePartySizeRequirement(2, 6) // Must have at least 2 pokemon in party + .withIntroSpriteConfigs([ + { + spriteKey: Species.LIEPARD.toString(), + fileRoot: "pokemon", + hasShadow: true, + repeat: true, + x: 0, + y: -4, + yShadow: -4 + }, + { + spriteKey: "rich_kid_m", + fileRoot: "trainer", + hasShadow: true, + x: 2, + y: 5, + yShadow: 5 + }, + ]) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + { + text: `${namespace}.intro_dialogue`, + speaker: `${namespace}.speaker`, + }, + ]) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + const pokemon = getHighestStatTotalPlayerPokemon(scene, false); + const price = scene.getWaveMoneyAmount(10); + + encounter.setDialogueToken("strongestPokemon", pokemon.getNameToRender()); + encounter.setDialogueToken("price", price.toString()); + + // Store pokemon and price + encounter.misc = { + pokemon: pokemon, + price: price + }; + + // If player meets the combo OR requirements for option 2, populate the token + const opt2Req = encounter.options[1].primaryPokemonRequirements[0]; + if (opt2Req.meetsRequirement(scene)) { + const abilityToken = encounter.dialogueTokens["option2PrimaryAbility"]; + const moveToken = encounter.dialogueTokens["option2PrimaryMove"]; + if (abilityToken) { + encounter.setDialogueToken("moveOrAbility", abilityToken); + } else if (moveToken) { + encounter.setDialogueToken("moveOrAbility", moveToken); + } + } + + encounter.setDialogueToken("liepardName", getPokemonSpecies(Species.LIEPARD).getName()); + + return true; + }) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + speaker: `${namespace}.speaker`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene): Promise => { + const encounter = scene.currentBattle.mysteryEncounter!; + // Update money and remove pokemon from party + updatePlayerMoney(scene, encounter.misc.price); + scene.removePokemonFromPlayerParty(encounter.misc.pokemon); + return true; + }) + .withOptionPhase(async (scene: BattleScene) => { + // Give the player a Shiny charm + scene.unshiftPhase(new ModifierRewardPhase(scene, modifierTypes.SHINY_CHARM)); + leaveEncounterWithoutBattle(scene, true); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL) + .withPrimaryPokemonRequirement(new CombinationPokemonRequirement( + new MoveRequirement(EXTORTION_MOVES), + new AbilityRequirement(EXTORTION_ABILITIES)) + ) + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + disabledButtonTooltip: `${namespace}.option.2.tooltip_disabled`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.2.selected`, + }, + ], + }) + .withOptionPhase(async (scene: BattleScene) => { + // Extort the rich kid for money + const encounter = scene.currentBattle.mysteryEncounter!; + // Update money and remove pokemon from party + updatePlayerMoney(scene, encounter.misc.price); + + setEncounterExp(scene, encounter.options[1].primaryPokemon!.id, getPokemonSpecies(Species.LIEPARD).baseExp, true); + + leaveEncounterWithoutBattle(scene, true); + }) + .build() + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.3.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Leave encounter with no rewards or exp + leaveEncounterWithoutBattle(scene, true); + return true; + } + ) + .build(); diff --git a/src/data/mystery-encounters/encounters/berries-abound-encounter.ts b/src/data/mystery-encounters/encounters/berries-abound-encounter.ts new file mode 100644 index 000000000000..7e6914cabddd --- /dev/null +++ b/src/data/mystery-encounters/encounters/berries-abound-encounter.ts @@ -0,0 +1,273 @@ +import { MysteryEncounterOptionBuilder } from "#app/data/mystery-encounters/mystery-encounter-option"; +import { + EnemyPartyConfig, generateModifierType, generateModifierTypeOption, + initBattleWithEnemyConfig, + leaveEncounterWithoutBattle, setEncounterExp, + setEncounterRewards +} from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import Pokemon, { EnemyPokemon, PlayerPokemon } from "#app/field/pokemon"; +import { + BerryModifierType, + getPartyLuckValue, + ModifierPoolType, + ModifierTypeOption, modifierTypes, + regenerateModifierPoolThresholds, +} from "#app/modifier/modifier-type"; +import { randSeedInt } from "#app/utils"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { queueEncounterMessage, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { getPokemonNameWithAffix } from "#app/messages"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { TrainerSlot } from "#app/data/trainer-config"; +import { applyModifierTypeToPlayerPokemon, getHighestStatPlayerPokemon, getSpriteKeysFromPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import PokemonData from "#app/system/pokemon-data"; +import { BerryModifier } from "#app/modifier/modifier"; +import i18next from "#app/plugins/i18n"; +import { BerryType } from "#enums/berry-type"; +import { PERMANENT_STATS, Stat } from "#enums/stat"; +import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for the encounter */ +const namespace = "mysteryEncounter:berriesAbound"; + +/** + * Berries Abound encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3810 | GitHub Issue #3810} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const BerriesAboundEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.BERRIES_ABOUND) + .withEncounterTier(MysteryEncounterTier.COMMON) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withCatchAllowed(true) + .withHideWildIntroMessage(true) + .withIntroSpriteConfigs([]) // Set in onInit() + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + ]) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + + // Calculate boss mon + const level = (scene.currentBattle.enemyLevels?.[0] ?? scene.currentBattle.waveIndex) + Math.max(Math.round((scene.currentBattle.waveIndex / 10)), 0); + const bossSpecies = scene.arena.randomSpecies(scene.currentBattle.waveIndex, level, 0, getPartyLuckValue(scene.getParty()), true); + const bossPokemon = new EnemyPokemon(scene, bossSpecies, level, TrainerSlot.NONE, true); + encounter.setDialogueToken("enemyPokemon", getPokemonNameWithAffix(bossPokemon)); + const config: EnemyPartyConfig = { + levelAdditiveMultiplier: 1, + pokemonConfigs: [{ + level: level, + species: bossSpecies, + dataSource: new PokemonData(bossPokemon), + isBoss: true + }], + }; + encounter.enemyPartyConfigs = [config]; + + // Calculate the number of extra berries that player receives + // 10-40: 2, 40-120: 4, 120-160: 5, 160-180: 7 + const numBerries = + scene.currentBattle.waveIndex > 160 ? 7 + : scene.currentBattle.waveIndex > 120 ? 5 + : scene.currentBattle.waveIndex > 40 ? 4 : 2; + regenerateModifierPoolThresholds(scene.getParty(), ModifierPoolType.PLAYER, 0); + encounter.misc = { numBerries }; + + const { spriteKey, fileRoot } = getSpriteKeysFromPokemon(bossPokemon); + encounter.spriteConfigs = [ + { + spriteKey: "berry_bush", + fileRoot: "mystery-encounters", + x: 25, + y: -6, + yShadow: -7, + disableAnimation: true, + hasShadow: true + }, + { + spriteKey: spriteKey, + fileRoot: fileRoot, + hasShadow: true, + tint: 0.25, + x: -5, + repeat: true, + isPokemon: true + } + ]; + + // Get fastest party pokemon for option 2 + const fastestPokemon = getHighestStatPlayerPokemon(scene, PERMANENT_STATS[Stat.SPD], true); + encounter.misc.fastestPokemon = fastestPokemon; + encounter.misc.enemySpeed = bossPokemon.getStat(Stat.SPD); + encounter.setDialogueToken("fastestPokemon", fastestPokemon.getNameToRender()); + + return true; + }) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Pick battle + const encounter = scene.currentBattle.mysteryEncounter!; + const numBerries = encounter.misc.numBerries; + + const doBerryRewards = async () => { + const berryText = numBerries + " " + i18next.t(`${namespace}.berries`); + + scene.playSound("item_fanfare"); + queueEncounterMessage(scene, i18next.t("battle:rewardGain", { modifierName: berryText })); + + // Generate a random berry and give it to the first Pokemon with room for it + for (let i = 0; i < numBerries; i++) { + await tryGiveBerry(scene); + } + }; + + const shopOptions: ModifierTypeOption[] = []; + for (let i = 0; i < 5; i++) { + // Generate shop berries + const mod = generateModifierTypeOption(scene, modifierTypes.BERRY); + if (mod) { + shopOptions.push(mod); + } + } + + setEncounterRewards(scene, { guaranteedModifierTypeOptions: shopOptions, fillRemaining: false }, undefined, doBerryRewards); + await initBattleWithEnemyConfig(scene, scene.currentBattle.mysteryEncounter!.enemyPartyConfigs[0]); + } + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip` + }) + .withOptionPhase(async (scene: BattleScene) => { + // Pick race for berries + const encounter = scene.currentBattle.mysteryEncounter!; + const fastestPokemon = encounter.misc.fastestPokemon; + const enemySpeed = encounter.misc.enemySpeed; + const speedDiff = fastestPokemon.getStat(Stat.SPD) / (enemySpeed * 1.1); + const numBerries = encounter.misc.numBerries; + + const shopOptions: ModifierTypeOption[] = []; + for (let i = 0; i < 5; i++) { + // Generate shop berries + const mod = generateModifierTypeOption(scene, modifierTypes.BERRY); + if (mod) { + shopOptions.push(mod); + } + } + + if (speedDiff < 1) { + // Caught and attacked by boss, gets +1 to all stats at start of fight + const doBerryRewards = async () => { + const berryText = numBerries + " " + i18next.t(`${namespace}.berries`); + + scene.playSound("item_fanfare"); + queueEncounterMessage(scene, i18next.t("battle:rewardGain", { modifierName: berryText })); + + // Generate a random berry and give it to the first Pokemon with room for it + for (let i = 0; i < numBerries; i++) { + await tryGiveBerry(scene); + } + }; + + const config = scene.currentBattle.mysteryEncounter!.enemyPartyConfigs[0]; + config.pokemonConfigs![0].tags = [BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON]; + config.pokemonConfigs![0].mysteryEncounterBattleEffects = (pokemon: Pokemon) => { + queueEncounterMessage(pokemon.scene, `${namespace}.option.2.boss_enraged`); + pokemon.scene.unshiftPhase(new StatStageChangePhase(pokemon.scene, pokemon.getBattlerIndex(), true, [Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD], 1)); + }; + setEncounterRewards(scene, { guaranteedModifierTypeOptions: shopOptions, fillRemaining: false }, undefined, doBerryRewards); + await showEncounterText(scene, `${namespace}.option.2.selected_bad`); + await initBattleWithEnemyConfig(scene, config); + return; + } else { + // Gains 1 berry for every 10% faster the player's pokemon is than the enemy, up to a max of numBerries, minimum of 2 + const numBerriesGrabbed = Math.max(Math.min(Math.round((speedDiff - 1)/0.08), numBerries), 2); + encounter.setDialogueToken("numBerries", String(numBerriesGrabbed)); + const doFasterBerryRewards = async () => { + const berryText = numBerriesGrabbed + " " + i18next.t(`${namespace}.berries`); + + scene.playSound("item_fanfare"); + queueEncounterMessage(scene, i18next.t("battle:rewardGain", { modifierName: berryText })); + + // Generate a random berry and give it to the first Pokemon with room for it (trying to give to fastest first) + for (let i = 0; i < numBerriesGrabbed; i++) { + await tryGiveBerry(scene, fastestPokemon); + } + }; + + setEncounterExp(scene, fastestPokemon.id, encounter.enemyPartyConfigs[0].pokemonConfigs![0].species.baseExp); + setEncounterRewards(scene, { guaranteedModifierTypeOptions: shopOptions, fillRemaining: false }, undefined, doFasterBerryRewards); + await showEncounterText(scene, `${namespace}.option.2.selected`); + leaveEncounterWithoutBattle(scene); + } + }) + .build() + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Leave encounter with no rewards or exp + leaveEncounterWithoutBattle(scene, true); + return true; + } + ) + .build(); + +async function tryGiveBerry(scene: BattleScene, prioritizedPokemon?: PlayerPokemon) { + const berryType = randSeedInt(Object.keys(BerryType).filter(s => !isNaN(Number(s))).length) as BerryType; + const berry = generateModifierType(scene, modifierTypes.BERRY, [berryType]) as BerryModifierType; + + const party = scene.getParty(); + + // Will try to apply to prioritized pokemon first, then do normal application method if it fails + if (prioritizedPokemon) { + const heldBerriesOfType = scene.findModifier(m => m instanceof BerryModifier + && m.pokemonId === prioritizedPokemon.id && (m as BerryModifier).berryType === berryType, true) as BerryModifier; + + if (!heldBerriesOfType || heldBerriesOfType.getStackCount() < heldBerriesOfType.getMaxStackCount(scene)) { + await applyModifierTypeToPlayerPokemon(scene, prioritizedPokemon, berry); + return; + } + } + + // Iterate over the party until berry was successfully given + for (const pokemon of party) { + const heldBerriesOfType = scene.findModifier(m => m instanceof BerryModifier + && m.pokemonId === pokemon.id && (m as BerryModifier).berryType === berryType, true) as BerryModifier; + + if (!heldBerriesOfType || heldBerriesOfType.getStackCount() < heldBerriesOfType.getMaxStackCount(scene)) { + await applyModifierTypeToPlayerPokemon(scene, pokemon, berry); + return; + } + } +} diff --git a/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts b/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts new file mode 100644 index 000000000000..7fdaec35dc38 --- /dev/null +++ b/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts @@ -0,0 +1,670 @@ +import { + EnemyPartyConfig, generateModifierType, + generateModifierTypeOption, + initBattleWithEnemyConfig, + leaveEncounterWithoutBattle, + selectOptionThenPokemon, + selectPokemonForOption, + setEncounterRewards, + transitionMysteryEncounterIntroVisuals, +} from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { + trainerConfigs, + TrainerPartyCompoundTemplate, + TrainerPartyTemplate, + TrainerSlot, +} from "#app/data/trainer-config"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { PartyMemberStrength } from "#enums/party-member-strength"; +import BattleScene from "#app/battle-scene"; +import * as Utils from "#app/utils"; +import { isNullOrUndefined, randSeedInt, randSeedShuffle } from "#app/utils"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { TrainerType } from "#enums/trainer-type"; +import { Species } from "#enums/species"; +import Pokemon, { EnemyPokemon, PlayerPokemon, PokemonMove } from "#app/field/pokemon"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { getEncounterText, showEncounterDialogue } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { LearnMovePhase } from "#app/phases/learn-move-phase"; +import { Moves } from "#enums/moves"; +import { OptionSelectItem } from "#app/ui/abstact-option-select-ui-handler"; +import { MysteryEncounterOptionBuilder } from "#app/data/mystery-encounters/mystery-encounter-option"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { + AttackTypeBoosterHeldItemTypeRequirement, + CombinationPokemonRequirement, + HeldItemRequirement, + TypeRequirement +} from "#app/data/mystery-encounters/mystery-encounter-requirements"; +import { Type } from "#app/data/type"; +import { AttackTypeBoosterModifierType, ModifierTypeOption, modifierTypes } from "#app/modifier/modifier-type"; +import { + AttackTypeBoosterModifier, + BypassSpeedChanceModifier, + ContactHeldItemTransferChanceModifier, + PokemonHeldItemModifier +} from "#app/modifier/modifier"; +import i18next from "i18next"; +import MoveInfoOverlay from "#app/ui/move-info-overlay"; +import { allMoves } from "#app/data/move"; +import { ModifierTier } from "#app/modifier/modifier-tier"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for the encounter */ +const namespace = "mysteryEncounter:bugTypeSuperfan"; + +const POOL_1_POKEMON = [ + Species.PARASECT, + Species.VENOMOTH, + Species.LEDIAN, + Species.ARIADOS, + Species.YANMA, + Species.BEAUTIFLY, + Species.DUSTOX, + Species.MASQUERAIN, + Species.NINJASK, + Species.VOLBEAT, + Species.ILLUMISE, + Species.ANORITH, + Species.KRICKETUNE, + Species.WORMADAM, + Species.MOTHIM, + Species.SKORUPI, + Species.JOLTIK, + Species.LARVESTA, + Species.VIVILLON, + Species.CHARJABUG, + Species.RIBOMBEE, + Species.SPIDOPS, + Species.LOKIX +]; + +const POOL_2_POKEMON = [ + Species.SCYTHER, + Species.PINSIR, + Species.HERACROSS, + Species.FORRETRESS, + Species.SCIZOR, + Species.SHUCKLE, + Species.SHEDINJA, + Species.ARMALDO, + Species.VESPIQUEN, + Species.DRAPION, + Species.YANMEGA, + Species.LEAVANNY, + Species.SCOLIPEDE, + Species.CRUSTLE, + Species.ESCAVALIER, + Species.ACCELGOR, + Species.GALVANTULA, + Species.VIKAVOLT, + Species.ARAQUANID, + Species.ORBEETLE, + Species.CENTISKORCH, + Species.FROSMOTH, + Species.KLEAVOR, +]; + +const POOL_3_POKEMON: { species: Species, formIndex?: number }[] = [ + { + species: Species.PINSIR, + formIndex: 1 + }, + { + species: Species.SCIZOR, + formIndex: 1 + }, + { + species: Species.HERACROSS, + formIndex: 1 + }, + { + species: Species.ORBEETLE, + formIndex: 1 + }, + { + species: Species.CENTISKORCH, + formIndex: 1 + }, + { + species: Species.DURANT, + }, + { + species: Species.VOLCARONA, + }, + { + species: Species.GOLISOPOD, + }, +]; + +const POOL_4_POKEMON = [ + Species.GENESECT, + Species.SLITHER_WING, + Species.BUZZWOLE, + Species.PHEROMOSA +]; + +const PHYSICAL_TUTOR_MOVES = [ + Moves.MEGAHORN, + Moves.X_SCISSOR, + Moves.ATTACK_ORDER, + Moves.PIN_MISSILE, + Moves.FIRST_IMPRESSION +]; + +const SPECIAL_TUTOR_MOVES = [ + Moves.SILVER_WIND, + Moves.BUG_BUZZ, + Moves.SIGNAL_BEAM, + Moves.POLLEN_PUFF +]; + +const STATUS_TUTOR_MOVES = [ + Moves.STRING_SHOT, + Moves.STICKY_WEB, + Moves.SILK_TRAP, + Moves.RAGE_POWDER, + Moves.HEAL_ORDER +]; + +const MISC_TUTOR_MOVES = [ + Moves.BUG_BITE, + Moves.LEECH_LIFE, + Moves.DEFEND_ORDER, + Moves.QUIVER_DANCE, + Moves.TAIL_GLOW, + Moves.INFESTATION, + Moves.U_TURN +]; + +/** + * Bug Type Superfan encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3810 | GitHub Issue #3810} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const BugTypeSuperfanEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.BUG_TYPE_SUPERFAN) + .withEncounterTier(MysteryEncounterTier.GREAT) + .withPrimaryPokemonRequirement(new CombinationPokemonRequirement( + // Must have at least 1 Bug type on team, OR have a bug item somewhere on the team + new HeldItemRequirement(["BypassSpeedChanceModifier", "ContactHeldItemTransferChanceModifier"], 1), + new AttackTypeBoosterHeldItemTypeRequirement(Type.BUG, 1), + new TypeRequirement(Type.BUG, false, 1) + )) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withIntroSpriteConfigs([]) // These are set in onInit() + .withAutoHideIntroVisuals(false) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.intro_dialogue`, + }, + ]) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + // Calculates what trainers are available for battle in the encounter + + // Bug type superfan trainer config + const config = getTrainerConfigForWave(scene.currentBattle.waveIndex); + const spriteKey = config.getSpriteKey(); + encounter.enemyPartyConfigs.push({ + trainerConfig: config, + female: true, + }); + + encounter.spriteConfigs = [ + { + spriteKey: spriteKey, + fileRoot: "trainer", + hasShadow: true, + }, + ]; + + const requiredItems = [ + generateModifierType(scene, modifierTypes.QUICK_CLAW), + generateModifierType(scene, modifierTypes.GRIP_CLAW), + generateModifierType(scene, modifierTypes.ATTACK_TYPE_BOOSTER, [Type.BUG]), + ]; + + const requiredItemString = requiredItems.map(m => m?.name ?? "unknown").join("/"); + encounter.setDialogueToken("requiredBugItems", requiredItemString); + + return true; + }) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.1.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Select battle the bug trainer + const encounter = scene.currentBattle.mysteryEncounter!; + const config: EnemyPartyConfig = encounter.enemyPartyConfigs[0]; + + // Init the moves available for tutor + const moveTutorOptions: PokemonMove[] = []; + moveTutorOptions.push(new PokemonMove(PHYSICAL_TUTOR_MOVES[randSeedInt(PHYSICAL_TUTOR_MOVES.length)])); + moveTutorOptions.push(new PokemonMove(SPECIAL_TUTOR_MOVES[randSeedInt(SPECIAL_TUTOR_MOVES.length)])); + moveTutorOptions.push(new PokemonMove(STATUS_TUTOR_MOVES[randSeedInt(STATUS_TUTOR_MOVES.length)])); + moveTutorOptions.push(new PokemonMove(MISC_TUTOR_MOVES[randSeedInt(MISC_TUTOR_MOVES.length)])); + encounter.misc = { + moveTutorOptions + }; + + // Assigns callback that teaches move before continuing to rewards + encounter.onRewards = doBugTypeMoveTutor; + + setEncounterRewards(scene, { fillRemaining: true }); + await transitionMysteryEncounterIntroVisuals(scene, true, true); + await initBattleWithEnemyConfig(scene, config); + } + ) + .withOption(MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT) + .withPrimaryPokemonRequirement(new TypeRequirement(Type.BUG, false, 1)) // Must have 1 Bug type on team + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + disabledButtonTooltip: `${namespace}.option.2.disabled_tooltip` + }) + .withPreOptionPhase(async (scene: BattleScene) => { + // Player shows off their bug types + const encounter = scene.currentBattle.mysteryEncounter!; + + // Player gets different rewards depending on the number of bug types they have + const numBugTypes = scene.getParty().filter(p => p.isOfType(Type.BUG, true)).length; + encounter.setDialogueToken("numBugTypes", numBugTypes.toString()); + + if (numBugTypes < 2) { + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: [modifierTypes.SUPER_LURE, modifierTypes.GREAT_BALL], fillRemaining: false }); + encounter.selectedOption!.dialogue!.selected = [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.2.selected_0_to_1`, + }, + ]; + } else if (numBugTypes < 4) { + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: [modifierTypes.QUICK_CLAW, modifierTypes.MAX_LURE, modifierTypes.ULTRA_BALL], fillRemaining: false }); + encounter.selectedOption!.dialogue!.selected = [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.2.selected_2_to_3`, + }, + ]; + } else if (numBugTypes < 6) { + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: [modifierTypes.GRIP_CLAW, modifierTypes.MAX_LURE, modifierTypes.ROGUE_BALL], fillRemaining: false }); + encounter.selectedOption!.dialogue!.selected = [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.2.selected_4_to_5`, + }, + ]; + } else { + // If player has any evolution/form change items that are valid for their party, will spawn one of those items in addition to a Master Ball + const modifierOptions: ModifierTypeOption[] = [generateModifierTypeOption(scene, modifierTypes.MASTER_BALL)!, generateModifierTypeOption(scene, modifierTypes.MAX_LURE)!]; + const specialOptions: ModifierTypeOption[] = []; + + const nonRareEvolutionModifier = generateModifierTypeOption(scene, modifierTypes.EVOLUTION_ITEM); + if (nonRareEvolutionModifier) { + specialOptions.push(nonRareEvolutionModifier); + } + const rareEvolutionModifier = generateModifierTypeOption(scene, modifierTypes.RARE_EVOLUTION_ITEM); + if (rareEvolutionModifier) { + specialOptions.push(rareEvolutionModifier); + } + const formChangeModifier = generateModifierTypeOption(scene, modifierTypes.FORM_CHANGE_ITEM); + if (formChangeModifier) { + specialOptions.push(formChangeModifier); + } + if (specialOptions.length > 0) { + modifierOptions.push(specialOptions[randSeedInt(specialOptions.length)]); + } + + setEncounterRewards(scene, { guaranteedModifierTypeOptions: modifierOptions, fillRemaining: false }); + encounter.selectedOption!.dialogue!.selected = [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.2.selected_6`, + }, + ]; + } + }) + .withOptionPhase(async (scene: BattleScene) => { + // Player shows off their bug types + leaveEncounterWithoutBattle(scene); + }) + .build()) + .withOption(MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT) + .withPrimaryPokemonRequirement(new CombinationPokemonRequirement( + // Meets one or both of the below reqs + new HeldItemRequirement(["BypassSpeedChanceModifier", "ContactHeldItemTransferChanceModifier"], 1), + new AttackTypeBoosterHeldItemTypeRequirement(Type.BUG, 1) + )) + .withDialogue({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + disabledButtonTooltip: `${namespace}.option.3.disabled_tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.3.selected_dialogue`, + }, + ], + secondOptionPrompt: `${namespace}.option.3.select_prompt`, + }) + .withPreOptionPhase(async (scene: BattleScene): Promise => { + const encounter = scene.currentBattle.mysteryEncounter!; + + const onPokemonSelected = (pokemon: PlayerPokemon) => { + // Get Pokemon held items and filter for valid ones + const validItems = pokemon.getHeldItems().filter(item => { + return item instanceof BypassSpeedChanceModifier || + item instanceof ContactHeldItemTransferChanceModifier || + (item instanceof AttackTypeBoosterModifier && (item.type as AttackTypeBoosterModifierType).moveType === Type.BUG); + }); + + return validItems.map((modifier: PokemonHeldItemModifier) => { + const option: OptionSelectItem = { + label: modifier.type.name, + handler: () => { + // Pokemon and item selected + encounter.setDialogueToken("selectedItem", modifier.type.name); + encounter.misc = { + chosenPokemon: pokemon, + chosenModifier: modifier, + }; + return true; + }, + }; + return option; + }); + }; + + const selectableFilter = (pokemon: Pokemon) => { + // If pokemon has valid item, it can be selected + const hasValidItem = pokemon.getHeldItems().some(item => { + return item instanceof BypassSpeedChanceModifier || + item instanceof ContactHeldItemTransferChanceModifier || + (item instanceof AttackTypeBoosterModifier && (item.type as AttackTypeBoosterModifierType).moveType === Type.BUG); + }); + if (!hasValidItem) { + return getEncounterText(scene, `${namespace}.option.3.invalid_selection`) ?? null; + } + + return null; + }; + + return selectPokemonForOption(scene, onPokemonSelected, undefined, selectableFilter); + }) + .withOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + const modifier = encounter.misc.chosenModifier; + + // Remove the modifier if its stacks go to 0 + modifier.stackCount -= 1; + if (modifier.stackCount === 0) { + scene.removeModifier(modifier); + } + scene.updateModifiers(true, true); + + const bugNet = generateModifierTypeOption(scene, modifierTypes.MYSTERY_ENCOUNTER_GOLDEN_BUG_NET)!; + bugNet.type.tier = ModifierTier.ROGUE; + + setEncounterRewards(scene, { guaranteedModifierTypeOptions: [bugNet], guaranteedModifierTypeFuncs: [modifierTypes.REVIVER_SEED], fillRemaining: false }); + leaveEncounterWithoutBattle(scene, true); + }) + .build()) + .withOutroDialogue([ + { + text: `${namespace}.outro`, + }, + ]) + .build(); + +function getTrainerConfigForWave(waveIndex: number) { + // Bug type superfan trainer config + const config = trainerConfigs[TrainerType.BUG_TYPE_SUPERFAN].clone(); + config.name = i18next.t("trainerNames:bug_type_superfan"); + + const pool3Copy = POOL_3_POKEMON.slice(0); + randSeedShuffle(pool3Copy); + const pool3Mon = pool3Copy.pop()!; + + if (waveIndex < 30) { + // Use default template (2 AVG) + config + .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.BEEDRILL ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.BUTTERFREE ], TrainerSlot.TRAINER, true)); + } else if (waveIndex < 50) { + config + .setPartyTemplates(new TrainerPartyTemplate(3, PartyMemberStrength.AVERAGE)) + .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.BEEDRILL ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.BUTTERFREE ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(2, getRandomPartyMemberFunc(POOL_1_POKEMON, TrainerSlot.TRAINER, true)); + } else if (waveIndex < 70) { + config + .setPartyTemplates(new TrainerPartyTemplate(4, PartyMemberStrength.AVERAGE)) + .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.BEEDRILL ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.BUTTERFREE ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(2, getRandomPartyMemberFunc(POOL_1_POKEMON, TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(3, getRandomPartyMemberFunc(POOL_2_POKEMON, TrainerSlot.TRAINER, true)); + } else if (waveIndex < 100) { + config + .setPartyTemplates(new TrainerPartyTemplate(5, PartyMemberStrength.AVERAGE)) + .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.BEEDRILL ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.BUTTERFREE ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(2, getRandomPartyMemberFunc(POOL_1_POKEMON, TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(3, getRandomPartyMemberFunc(POOL_2_POKEMON, TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(4, getRandomPartyMemberFunc(POOL_2_POKEMON, TrainerSlot.TRAINER, true)); + } else if (waveIndex < 120) { + config + .setPartyTemplates(new TrainerPartyTemplate(5, PartyMemberStrength.AVERAGE)) + .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.BEEDRILL ], TrainerSlot.TRAINER, true, p => { + p.formIndex = 1; + p.generateAndPopulateMoveset(); + p.generateName(); + })) + .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.BUTTERFREE ], TrainerSlot.TRAINER, true, p => { + p.formIndex = 1; + p.generateAndPopulateMoveset(); + p.generateName(); + })) + .setPartyMemberFunc(2, getRandomPartyMemberFunc(POOL_2_POKEMON, TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(3, getRandomPartyMemberFunc(POOL_2_POKEMON, TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(4, getRandomPartyMemberFunc([pool3Mon.species], TrainerSlot.TRAINER, true, p => { + if (!isNullOrUndefined(pool3Mon.formIndex)) { + p.formIndex = pool3Mon.formIndex!; + p.generateAndPopulateMoveset(); + p.generateName(); + } + })); + } else if (waveIndex < 140) { + randSeedShuffle(pool3Copy); + const pool3Mon2 = pool3Copy.pop()!; + config + .setPartyTemplates(new TrainerPartyTemplate(5, PartyMemberStrength.AVERAGE)) + .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.BEEDRILL ], TrainerSlot.TRAINER, true, p => { + p.formIndex = 1; + p.generateAndPopulateMoveset(); + p.generateName(); + })) + .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.BUTTERFREE ], TrainerSlot.TRAINER, true, p => { + p.formIndex = 1; + p.generateAndPopulateMoveset(); + p.generateName(); + })) + .setPartyMemberFunc(2, getRandomPartyMemberFunc(POOL_2_POKEMON, TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(3, getRandomPartyMemberFunc([pool3Mon.species], TrainerSlot.TRAINER, true, p => { + if (!isNullOrUndefined(pool3Mon.formIndex)) { + p.formIndex = pool3Mon.formIndex!; + p.generateAndPopulateMoveset(); + p.generateName(); + } + })) + .setPartyMemberFunc(4, getRandomPartyMemberFunc([pool3Mon2.species], TrainerSlot.TRAINER, true, p => { + if (!isNullOrUndefined(pool3Mon2.formIndex)) { + p.formIndex = pool3Mon2.formIndex!; + p.generateAndPopulateMoveset(); + p.generateName(); + } + })); + } else if (waveIndex < 160) { + config + .setPartyTemplates(new TrainerPartyCompoundTemplate(new TrainerPartyTemplate(4, PartyMemberStrength.AVERAGE), new TrainerPartyTemplate(1, PartyMemberStrength.STRONG))) + .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.BEEDRILL ], TrainerSlot.TRAINER, true, p => { + p.formIndex = 1; + p.generateAndPopulateMoveset(); + p.generateName(); + })) + .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.BUTTERFREE ], TrainerSlot.TRAINER, true, p => { + p.formIndex = 1; + p.generateAndPopulateMoveset(); + p.generateName(); + })) + .setPartyMemberFunc(2, getRandomPartyMemberFunc(POOL_2_POKEMON, TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(3, getRandomPartyMemberFunc([pool3Mon.species], TrainerSlot.TRAINER, true, p => { + if (!isNullOrUndefined(pool3Mon.formIndex)) { + p.formIndex = pool3Mon.formIndex!; + p.generateAndPopulateMoveset(); + p.generateName(); + } + })) + .setPartyMemberFunc(4, getRandomPartyMemberFunc(POOL_4_POKEMON, TrainerSlot.TRAINER, true)); + } else { + config + .setPartyTemplates(new TrainerPartyCompoundTemplate(new TrainerPartyTemplate(4, PartyMemberStrength.AVERAGE), new TrainerPartyTemplate(1, PartyMemberStrength.STRONG))) + .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.BEEDRILL ], TrainerSlot.TRAINER, true, p => { + p.setBoss(true, 2); + p.formIndex = 1; + p.generateAndPopulateMoveset(); + p.generateName(); + })) + .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.BUTTERFREE ], TrainerSlot.TRAINER, true, p => { + p.setBoss(true, 2); + p.formIndex = 1; + p.generateAndPopulateMoveset(); + p.generateName(); + })) + .setPartyMemberFunc(2, getRandomPartyMemberFunc([pool3Mon.species], TrainerSlot.TRAINER, true, p => { + if (!isNullOrUndefined(pool3Mon.formIndex)) { + p.formIndex = pool3Mon.formIndex!; + p.generateAndPopulateMoveset(); + p.generateName(); + } + })) + .setPartyMemberFunc(3, getRandomPartyMemberFunc([pool3Mon.species], TrainerSlot.TRAINER, true, p => { + if (!isNullOrUndefined(pool3Mon.formIndex)) { + p.formIndex = pool3Mon.formIndex!; + p.generateAndPopulateMoveset(); + p.generateName(); + } + })) + .setPartyMemberFunc(4, getRandomPartyMemberFunc(POOL_4_POKEMON, TrainerSlot.TRAINER, true)); + } + + return config; +} + +function getRandomPartyMemberFunc(speciesPool: Species[], trainerSlot: TrainerSlot = TrainerSlot.TRAINER, ignoreEvolution: boolean = false, postProcess?: (enemyPokemon: EnemyPokemon) => void) { + return (scene: BattleScene, level: number, strength: PartyMemberStrength) => { + let species = Utils.randSeedItem(speciesPool); + if (!ignoreEvolution) { + species = getPokemonSpecies(species).getTrainerSpeciesForLevel(level, true, strength); + } + return scene.addEnemyPokemon(getPokemonSpecies(species), level, trainerSlot, undefined, undefined, postProcess); + }; +} + +function doBugTypeMoveTutor(scene: BattleScene): Promise { + return new Promise(async resolve => { + const moveOptions = scene.currentBattle.mysteryEncounter!.misc.moveTutorOptions; + await showEncounterDialogue(scene, `${namespace}.battle_won`, `${namespace}.speaker`); + + const overlayScale = 1; + const moveInfoOverlay = new MoveInfoOverlay(scene, { + delayVisibility: false, + scale: overlayScale, + onSide: true, + right: true, + x: 1, + y: -MoveInfoOverlay.getHeight(overlayScale, true) - 1, + width: (scene.game.canvas.width / 6) - 2, + }); + scene.ui.add(moveInfoOverlay); + + const optionSelectItems = moveOptions.map((move: PokemonMove) => { + const option: OptionSelectItem = { + label: move.getName(), + handler: () => { + moveInfoOverlay.active = false; + moveInfoOverlay.setVisible(false); + return true; + }, + onHover: () => { + moveInfoOverlay.active = true; + moveInfoOverlay.show(allMoves[move.moveId]); + }, + }; + return option; + }); + + const onHoverOverCancel = () => { + moveInfoOverlay.active = false; + moveInfoOverlay.setVisible(false); + }; + + const result = await selectOptionThenPokemon(scene, optionSelectItems, `${namespace}.teach_move_prompt`, undefined, onHoverOverCancel); + // let forceExit = !!result; + if (!result) { + moveInfoOverlay.active = false; + moveInfoOverlay.setVisible(false); + } + + // TODO: add menu to confirm player doesn't want to teach a move + // while (!result && !forceExit) { + // // Didn't teach a move, ask the player to confirm they don't want to teach a move + // await showEncounterDialogue(scene, `${namespace}.confirm_no_teach`, `${namespace}.speaker`); + // const confirm = await new Promise(confirmResolve => { + // scene.ui.setMode(Mode.CONFIRM, () => confirmResolve(true), () => confirmResolve(false)); + // }); + // scene.ui.clearText(); + // await scene.ui.setMode(Mode.MESSAGE); + // if (confirm) { + // // No teach, break out of loop + // forceExit = true; + // } else { + // // Re-show learn menu + // result = await selectOptionThenPokemon(scene, optionSelectItems, `${namespace}.teach_move_prompt`, undefined, onHoverOverCancel); + // if (!result) { + // moveInfoOverlay.active = false; + // moveInfoOverlay.setVisible(false); + // } + // } + // } + + // Option select complete, handle if they are learning a move + if (result && result.selectedOptionIndex < moveOptions.length) { + scene.unshiftPhase(new LearnMovePhase(scene, result.selectedPokemonIndex, moveOptions[result.selectedOptionIndex].moveId)); + } + + // Complete battle and go to rewards + resolve(); + }); +} diff --git a/src/data/mystery-encounters/encounters/clowning-around-encounter.ts b/src/data/mystery-encounters/encounters/clowning-around-encounter.ts new file mode 100644 index 000000000000..061d2a33e8a5 --- /dev/null +++ b/src/data/mystery-encounters/encounters/clowning-around-encounter.ts @@ -0,0 +1,496 @@ +import { EnemyPartyConfig, generateModifierType, initBattleWithEnemyConfig, leaveEncounterWithoutBattle, loadCustomMovesForEncounter, selectPokemonForOption, setEncounterRewards, transitionMysteryEncounterIntroVisuals } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { trainerConfigs, TrainerPartyCompoundTemplate, TrainerPartyTemplate, } from "#app/data/trainer-config"; +import { ModifierTier } from "#app/modifier/modifier-tier"; +import { modifierTypes, PokemonHeldItemModifierType } from "#app/modifier/modifier-type"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { PartyMemberStrength } from "#enums/party-member-strength"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { Species } from "#enums/species"; +import { TrainerType } from "#enums/trainer-type"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { Abilities } from "#enums/abilities"; +import { applyModifierTypeToPlayerPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { Type } from "#app/data/type"; +import { MysteryEncounterOptionBuilder } from "#app/data/mystery-encounters/mystery-encounter-option"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { randSeedInt, randSeedShuffle } from "#app/utils"; +import { showEncounterDialogue, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { Mode } from "#app/ui/ui"; +import i18next from "i18next"; +import { OptionSelectConfig } from "#app/ui/abstact-option-select-ui-handler"; +import { PlayerPokemon, PokemonMove } from "#app/field/pokemon"; +import { Ability } from "#app/data/ability"; +import { BerryModifier } from "#app/modifier/modifier"; +import { BerryType } from "#enums/berry-type"; +import { BattlerIndex } from "#app/battle"; +import { Moves } from "#enums/moves"; +import { EncounterBattleAnim } from "#app/data/battle-anims"; +import { MoveCategory } from "#app/data/move"; +import { MysteryEncounterPokemonData } from "#app/data/mystery-encounters/mystery-encounter-pokemon-data"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES, GameModes } from "#app/game-mode"; +import { EncounterAnim } from "#enums/encounter-anims"; + +/** the i18n namespace for the encounter */ +const namespace = "mysteryEncounter:clowningAround"; + +const RANDOM_ABILITY_POOL = [ + Abilities.STURDY, + Abilities.PICKUP, + Abilities.INTIMIDATE, + Abilities.GUTS, + Abilities.DROUGHT, + Abilities.DRIZZLE, + Abilities.SNOW_WARNING, + Abilities.SAND_STREAM, + Abilities.ELECTRIC_SURGE, + Abilities.PSYCHIC_SURGE, + Abilities.GRASSY_SURGE, + Abilities.MISTY_SURGE, + Abilities.MAGICIAN, + Abilities.SHEER_FORCE, + Abilities.PRANKSTER +]; + +/** + * Clowning Around encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3807 | GitHub Issue #3807} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const ClowningAroundEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.CLOWNING_AROUND) + .withEncounterTier(MysteryEncounterTier.ULTRA) + .withDisabledGameModes(GameModes.CHALLENGE) + .withSceneWaveRangeRequirement(80, CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES[1]) + .withAnimations(EncounterAnim.SMOKESCREEN) + .withAutoHideIntroVisuals(false) + .withIntroSpriteConfigs([ + { + spriteKey: Species.MR_MIME.toString(), + fileRoot: "pokemon", + hasShadow: true, + repeat: true, + x: -25, + tint: 0.3, + y: -3, + yShadow: -3 + }, + { + spriteKey: Species.BLACEPHALON.toString(), + fileRoot: "pokemon/exp", + hasShadow: true, + repeat: true, + x: 25, + tint: 0.3, + y: -3, + yShadow: -3 + }, + { + spriteKey: "harlequin", + fileRoot: "trainer", + hasShadow: true, + x: 0, + y: 2, + yShadow: 2 + }, + ]) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + { + text: `${namespace}.intro_dialogue`, + speaker: `${namespace}.speaker` + }, + ]) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + + const clownTrainerType = TrainerType.HARLEQUIN; + const clownConfig = trainerConfigs[clownTrainerType].clone(); + const clownPartyTemplate = new TrainerPartyCompoundTemplate( + new TrainerPartyTemplate(1, PartyMemberStrength.STRONG), + new TrainerPartyTemplate(1, PartyMemberStrength.STRONGER)); + clownConfig.setPartyTemplates(clownPartyTemplate); + clownConfig.setDoubleOnly(); + // @ts-ignore + clownConfig.partyTemplateFunc = null; // Overrides party template func if it exists + + // Generate random ability for Blacephalon from pool + const ability = RANDOM_ABILITY_POOL[randSeedInt(RANDOM_ABILITY_POOL.length)]; + encounter.setDialogueToken("ability", new Ability(ability, 3).name); + encounter.misc = { ability }; + + encounter.enemyPartyConfigs.push({ + trainerConfig: clownConfig, + pokemonConfigs: [ // Overrides first 2 pokemon to be Mr. Mime and Blacephalon + { + species: getPokemonSpecies(Species.MR_MIME), + isBoss: true, + moveSet: [Moves.TEETER_DANCE, Moves.ALLY_SWITCH, Moves.DAZZLING_GLEAM, Moves.PSYCHIC] + }, + { // Blacephalon has the random ability from pool, and 2 entirely random types to fit with the theme of the encounter + species: getPokemonSpecies(Species.BLACEPHALON), + mysteryEncounterPokemonData: new MysteryEncounterPokemonData({ ability: ability, types: [randSeedInt(18), randSeedInt(18)] }), + isBoss: true, + moveSet: [Moves.TRICK, Moves.HYPNOSIS, Moves.SHADOW_BALL, Moves.MIND_BLOWN] + }, + ], + doubleBattle: true + }); + + // Load animations/sfx for start of fight moves + loadCustomMovesForEncounter(scene, [Moves.ROLE_PLAY, Moves.TAUNT]); + + encounter.setDialogueToken("blacephalonName", getPokemonSpecies(Species.BLACEPHALON).getName()); + + return true; + }) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + speaker: `${namespace}.speaker` + }, + ], + }) + .withOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + // Spawn battle + const config: EnemyPartyConfig = encounter.enemyPartyConfigs[0]; + + setEncounterRewards(scene, { fillRemaining: true }); + + // TODO: when Magic Room and Wonder Room are implemented, add those to start of battle + encounter.startOfBattleEffects.push( + { // Mr. Mime copies the Blacephalon's random ability + sourceBattlerIndex: BattlerIndex.ENEMY, + targets: [BattlerIndex.ENEMY_2], + move: new PokemonMove(Moves.ROLE_PLAY), + ignorePp: true + }, + { + sourceBattlerIndex: BattlerIndex.ENEMY_2, + targets: [BattlerIndex.PLAYER], + move: new PokemonMove(Moves.TAUNT), + ignorePp: true + }, + { + sourceBattlerIndex: BattlerIndex.ENEMY_2, + targets: [BattlerIndex.PLAYER_2], + move: new PokemonMove(Moves.TAUNT), + ignorePp: true + }); + + await transitionMysteryEncounterIntroVisuals(scene); + await initBattleWithEnemyConfig(scene, config); + }) + .withPostOptionPhase(async (scene: BattleScene): Promise => { + // After the battle, offer the player the opportunity to permanently swap ability + const abilityWasSwapped = await handleSwapAbility(scene); + if (abilityWasSwapped) { + await showEncounterText(scene, `${namespace}.option.1.ability_gained`); + } + + // Play animations once ability swap is complete + // Trainer sprite that is shown at end of battle is not the same as mystery encounter intro visuals + scene.tweens.add({ + targets: scene.currentBattle.trainer, + x: "+=16", + y: "-=16", + alpha: 0, + ease: "Sine.easeInOut", + duration: 250 + }); + const background = new EncounterBattleAnim(EncounterAnim.SMOKESCREEN, scene.getPlayerPokemon()!, scene.getPlayerPokemon()); + background.playWithoutTargets(scene, 230, 40, 2); + return true; + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + speaker: `${namespace}.speaker` + }, + { + text: `${namespace}.option.2.selected_2`, + }, + { + text: `${namespace}.option.2.selected_3`, + speaker: `${namespace}.speaker` + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene) => { + // Swap player's items on pokemon with the most items + // Item comparisons look at whichever Pokemon has the greatest number of TRANSFERABLE, non-berry items + // So Vitamins, form change items, etc. are not included + const encounter = scene.currentBattle.mysteryEncounter!; + + const party = scene.getParty(); + let mostHeldItemsPokemon = party[0]; + let count = mostHeldItemsPokemon.getHeldItems() + .filter(m => m.isTransferrable && !(m instanceof BerryModifier)) + .reduce((v, m) => v + m.stackCount, 0); + + party.forEach(pokemon => { + const nextCount = pokemon.getHeldItems() + .filter(m => m.isTransferrable && !(m instanceof BerryModifier)) + .reduce((v, m) => v + m.stackCount, 0); + if (nextCount > count) { + mostHeldItemsPokemon = pokemon; + count = nextCount; + } + }); + + encounter.setDialogueToken("switchPokemon", mostHeldItemsPokemon.getNameToRender()); + + const items = mostHeldItemsPokemon.getHeldItems(); + + // Shuffles Berries (if they have any) + let numBerries = 0; + items.filter(m => m instanceof BerryModifier) + .forEach(m => { + numBerries += m.stackCount; + scene.removeModifier(m); + }); + + generateItemsOfTier(scene, mostHeldItemsPokemon, numBerries, "Berries"); + + // Shuffle Transferable held items in the same tier (only shuffles Ultra and Rogue atm) + let numUltra = 0; + let numRogue = 0; + items.filter(m => m.isTransferrable && !(m instanceof BerryModifier)) + .forEach(m => { + const type = m.type.withTierFromPool(); + const tier = type.tier ?? ModifierTier.ULTRA; + if (type.id === "GOLDEN_EGG" || tier === ModifierTier.ROGUE) { + numRogue += m.stackCount; + scene.removeModifier(m); + } else if (type.id === "LUCKY_EGG" || tier === ModifierTier.ULTRA) { + numUltra += m.stackCount; + scene.removeModifier(m); + } + }); + + generateItemsOfTier(scene, mostHeldItemsPokemon, numUltra, ModifierTier.ULTRA); + generateItemsOfTier(scene, mostHeldItemsPokemon, numRogue, ModifierTier.ROGUE); + }) + .withOptionPhase(async (scene: BattleScene) => { + leaveEncounterWithoutBattle(scene, true); + }) + .withPostOptionPhase(async (scene: BattleScene) => { + // Play animations + const background = new EncounterBattleAnim(EncounterAnim.SMOKESCREEN, scene.getPlayerPokemon()!, scene.getPlayerPokemon()); + background.playWithoutTargets(scene, 230, 40, 2); + await transitionMysteryEncounterIntroVisuals(scene, true, true, 200); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + speaker: `${namespace}.speaker` + }, + { + text: `${namespace}.option.3.selected_2`, + }, + { + text: `${namespace}.option.3.selected_3`, + speaker: `${namespace}.speaker` + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene) => { + // Randomize the second type of all player's pokemon + // If the pokemon does not normally have a second type, it will gain 1 + for (const pokemon of scene.getParty()) { + const originalTypes = pokemon.getTypes(false, false, true); + + // If the Pokemon has non-status moves that don't match the Pokemon's type, prioritizes those as the new type + // Makes the "randomness" of the shuffle slightly less punishing + let priorityTypes = pokemon.moveset + .filter(move => move && !originalTypes.includes(move.getMove().type) && move.getMove().category !== MoveCategory.STATUS) + .map(move => move!.getMove().type); + if (priorityTypes?.length > 0) { + priorityTypes = [...new Set(priorityTypes)]; + randSeedShuffle(priorityTypes); + } + + const newTypes = [originalTypes[0]]; + let secondType: Type | null = null; + while (secondType === null || secondType === newTypes[0] || originalTypes.includes(secondType)) { + if (priorityTypes.length > 0) { + secondType = priorityTypes.pop() ?? null; + } else { + secondType = randSeedInt(18) as Type; + } + } + newTypes.push(secondType); + if (!pokemon.mysteryEncounterPokemonData) { + pokemon.mysteryEncounterPokemonData = new MysteryEncounterPokemonData(); + } + pokemon.mysteryEncounterPokemonData.types = newTypes; + } + }) + .withOptionPhase(async (scene: BattleScene) => { + leaveEncounterWithoutBattle(scene, true); + }) + .withPostOptionPhase(async (scene: BattleScene) => { + // Play animations + const background = new EncounterBattleAnim(EncounterAnim.SMOKESCREEN, scene.getPlayerPokemon()!, scene.getPlayerPokemon()); + background.playWithoutTargets(scene, 230, 40, 2); + await transitionMysteryEncounterIntroVisuals(scene, true, true, 200); + }) + .build() + ) + .withOutroDialogue([ + { + text: `${namespace}.outro`, + }, + ]) + .build(); + +async function handleSwapAbility(scene: BattleScene) { + return new Promise(async resolve => { + await showEncounterDialogue(scene, `${namespace}.option.1.apply_ability_dialogue`, `${namespace}.speaker`); + await showEncounterText(scene, `${namespace}.option.1.apply_ability_message`); + + scene.ui.setMode(Mode.MESSAGE).then(() => { + displayYesNoOptions(scene, resolve); + }); + }); +} + +function displayYesNoOptions(scene: BattleScene, resolve) { + showEncounterText(scene, `${namespace}.option.1.ability_prompt`, null, 500, false); + const fullOptions = [ + { + label: i18next.t("menu:yes"), + handler: () => { + onYesAbilitySwap(scene, resolve); + return true; + } + }, + { + label: i18next.t("menu:no"), + handler: () => { + resolve(false); + return true; + } + } + ]; + + const config: OptionSelectConfig = { + options: fullOptions, + maxOptions: 7, + yOffset: 0 + }; + scene.ui.setModeWithoutClear(Mode.OPTION_SELECT, config, null, true); +} + +function onYesAbilitySwap(scene: BattleScene, resolve) { + const onPokemonSelected = (pokemon: PlayerPokemon) => { + // Do ability swap + const encounter = scene.currentBattle.mysteryEncounter!; + if (!pokemon.mysteryEncounterPokemonData) { + pokemon.mysteryEncounterPokemonData = new MysteryEncounterPokemonData(); + } + pokemon.mysteryEncounterPokemonData.ability = encounter.misc.ability; + encounter.setDialogueToken("chosenPokemon", pokemon.getNameToRender()); + scene.ui.setMode(Mode.MESSAGE).then(() => resolve(true)); + }; + + const onPokemonNotSelected = () => { + scene.ui.setMode(Mode.MESSAGE).then(() => { + displayYesNoOptions(scene, resolve); + }); + }; + + selectPokemonForOption(scene, onPokemonSelected, onPokemonNotSelected); +} + +function generateItemsOfTier(scene: BattleScene, pokemon: PlayerPokemon, numItems: number, tier: ModifierTier | "Berries") { + // These pools have to be defined at runtime so that modifierTypes exist + // Pools have instances of the modifier type equal to the max stacks that modifier can be applied to any one pokemon + // This is to prevent "over-generating" a random item of a certain type during item swaps + const ultraPool = [ + [modifierTypes.REVIVER_SEED, 1], + [modifierTypes.GOLDEN_PUNCH, 5], + [modifierTypes.ATTACK_TYPE_BOOSTER, 99], + [modifierTypes.QUICK_CLAW, 3], + [modifierTypes.WIDE_LENS, 3] + ]; + + const roguePool = [ + [modifierTypes.LEFTOVERS, 4], + [modifierTypes.SHELL_BELL, 4], + [modifierTypes.SOUL_DEW, 10], + [modifierTypes.SOOTHE_BELL, 3], + [modifierTypes.SCOPE_LENS, 1], + [modifierTypes.BATON, 1], + [modifierTypes.FOCUS_BAND, 5], + [modifierTypes.KINGS_ROCK, 3], + [modifierTypes.GRIP_CLAW, 5] + ]; + + const berryPool = [ + [BerryType.APICOT, 3], + [BerryType.ENIGMA, 2], + [BerryType.GANLON, 3], + [BerryType.LANSAT, 3], + [BerryType.LEPPA, 2], + [BerryType.LIECHI, 3], + [BerryType.LUM, 2], + [BerryType.PETAYA, 3], + [BerryType.SALAC, 2], + [BerryType.SITRUS, 2], + [BerryType.STARF, 3] + ]; + + let pool: any[]; + if (tier === "Berries") { + pool = berryPool; + } else { + pool = tier === ModifierTier.ULTRA ? ultraPool : roguePool; + } + + for (let i = 0; i < numItems; i++) { + const randIndex = randSeedInt(pool.length); + const newItemType = pool[randIndex]; + let newMod; + if (tier === "Berries") { + newMod = generateModifierType(scene, modifierTypes.BERRY, [newItemType[0]]) as PokemonHeldItemModifierType; + } else { + newMod = generateModifierType(scene, newItemType[0]) as PokemonHeldItemModifierType; + } + applyModifierTypeToPlayerPokemon(scene, pokemon, newMod); + // Decrement max stacks and remove from pool if at max + newItemType[1]--; + if (newItemType[1] <= 0) { + pool.splice(randIndex, 1); + } + } +} diff --git a/src/data/mystery-encounters/encounters/dancing-lessons-encounter.ts b/src/data/mystery-encounters/encounters/dancing-lessons-encounter.ts new file mode 100644 index 000000000000..046e2b2f8768 --- /dev/null +++ b/src/data/mystery-encounters/encounters/dancing-lessons-encounter.ts @@ -0,0 +1,325 @@ +import { EnemyPartyConfig, initBattleWithEnemyConfig, leaveEncounterWithoutBattle, selectPokemonForOption, setEncounterRewards } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import Pokemon, { EnemyPokemon, PlayerPokemon, PokemonMove } from "#app/field/pokemon"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { Species } from "#enums/species"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterOptionBuilder } from "../mystery-encounter-option"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { Moves } from "#enums/moves"; +import { TrainerSlot } from "#app/data/trainer-config"; +import PokemonData from "#app/system/pokemon-data"; +import { Biome } from "#enums/biome"; +import { EncounterBattleAnim } from "#app/data/battle-anims"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { getEncounterText, queueEncounterMessage } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { MoveRequirement } from "#app/data/mystery-encounters/mystery-encounter-requirements"; +import { DANCING_MOVES } from "#app/data/mystery-encounters/requirements/requirement-groups"; +import { OptionSelectItem } from "#app/ui/abstact-option-select-ui-handler"; +import { BattlerIndex } from "#app/battle"; +import { catchPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { PokeballType } from "#enums/pokeball"; +import { modifierTypes } from "#app/modifier/modifier-type"; +import { LearnMovePhase } from "#app/phases/learn-move-phase"; +import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; +import { Stat } from "#enums/stat"; +import { EncounterAnim } from "#enums/encounter-anims"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for this encounter */ +const namespace = "mysteryEncounter:dancingLessons"; + +// Fire form +const BAILE_STYLE_BIOMES = [ + Biome.VOLCANO, + Biome.BEACH, + Biome.ISLAND, + Biome.WASTELAND, + Biome.MOUNTAIN, + Biome.BADLANDS, + Biome.DESERT +]; + +// Electric form +const POM_POM_STYLE_BIOMES = [ + Biome.CONSTRUCTION_SITE, + Biome.POWER_PLANT, + Biome.FACTORY, + Biome.LABORATORY, + Biome.SLUM, + Biome.METROPOLIS, + Biome.DOJO +]; + +// Psychic form +const PAU_STYLE_BIOMES = [ + Biome.JUNGLE, + Biome.FAIRY_CAVE, + Biome.MEADOW, + Biome.PLAINS, + Biome.GRASS, + Biome.TALL_GRASS, + Biome.FOREST +]; + +// Ghost form +const SENSU_STYLE_BIOMES = [ + Biome.RUINS, + Biome.SWAMP, + Biome.CAVE, + Biome.ABYSS, + Biome.GRAVEYARD, + Biome.LAKE, + Biome.TEMPLE +]; + +/** + * Dancing Lessons encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3823 | GitHub Issue #3823} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const DancingLessonsEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.DANCING_LESSONS) + .withEncounterTier(MysteryEncounterTier.GREAT) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withIntroSpriteConfigs([]) // Uses a real Pokemon sprite instead of ME Intro Visuals + .withAnimations(EncounterAnim.DANCE) + .withHideWildIntroMessage(true) + .withAutoHideIntroVisuals(false) + .withCatchAllowed(true) + .withOnVisualsStart((scene: BattleScene) => { + const danceAnim = new EncounterBattleAnim(EncounterAnim.DANCE, scene.getEnemyPokemon()!, scene.getParty()[0]); + danceAnim.play(scene); + + return true; + }) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + } + ]) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + + const species = getPokemonSpecies(Species.ORICORIO); + const level = (scene.currentBattle.enemyLevels?.[0] ?? scene.currentBattle.waveIndex) + Math.max(Math.round((scene.currentBattle.waveIndex / 10)), 0); + const enemyPokemon = new EnemyPokemon(scene, species, level, TrainerSlot.NONE, false); + if (!enemyPokemon.moveset.some(m => m && m.getMove().id === Moves.REVELATION_DANCE)) { + if (enemyPokemon.moveset.length < 4) { + enemyPokemon.moveset.push(new PokemonMove(Moves.REVELATION_DANCE)); + } else { + enemyPokemon.moveset[0] = new PokemonMove(Moves.REVELATION_DANCE); + } + } + + // Set the form index based on the biome + // Defaults to Baile style if somehow nothing matches + const currentBiome = scene.arena.biomeType; + if (BAILE_STYLE_BIOMES.includes(currentBiome)) { + enemyPokemon.formIndex = 0; + } else if (POM_POM_STYLE_BIOMES.includes(currentBiome)) { + enemyPokemon.formIndex = 1; + } else if (PAU_STYLE_BIOMES.includes(currentBiome)) { + enemyPokemon.formIndex = 2; + } else if (SENSU_STYLE_BIOMES.includes(currentBiome)) { + enemyPokemon.formIndex = 3; + } else { + enemyPokemon.formIndex = 0; + } + + const oricorioData = new PokemonData(enemyPokemon); + const oricorio = scene.addEnemyPokemon(species, scene.currentBattle.enemyLevels![0], TrainerSlot.NONE, false, oricorioData); + + // Adds a real Pokemon sprite to the field (required for the animation) + scene.getEnemyParty().forEach(enemyPokemon => { + scene.field.remove(enemyPokemon, true); + }); + scene.currentBattle.enemyParty = [oricorio]; + scene.field.add(oricorio); + // Spawns on offscreen field + oricorio.x -= 300; + encounter.loadAssets.push(oricorio.loadAssets()); + + const config: EnemyPartyConfig = { + levelAdditiveMultiplier: 1, + pokemonConfigs: [{ + species: species, + dataSource: oricorioData, + isBoss: true, + // Gets +1 to all stats except SPD on battle start + tags: [BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON], + mysteryEncounterBattleEffects: (pokemon: Pokemon) => { + queueEncounterMessage(pokemon.scene, `${namespace}.option.1.boss_enraged`); + pokemon.scene.unshiftPhase(new StatStageChangePhase(pokemon.scene, pokemon.getBattlerIndex(), true, [Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF], 1)); + } + }], + }; + encounter.enemyPartyConfigs = [config]; + encounter.misc = { + oricorioData + }; + + encounter.setDialogueToken("oricorioName", getPokemonSpecies(Species.ORICORIO).getName()); + + return true; + }) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }) + .withOptionPhase(async (scene: BattleScene) => { + // Pick battle + const encounter = scene.currentBattle.mysteryEncounter!; + + encounter.startOfBattleEffects.push({ + sourceBattlerIndex: BattlerIndex.ENEMY, + targets: [BattlerIndex.PLAYER], + move: new PokemonMove(Moves.REVELATION_DANCE), + ignorePp: true + }); + + await hideOricorioPokemon(scene); + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: [modifierTypes.BATON], fillRemaining: true }); + await initBattleWithEnemyConfig(scene, encounter.enemyPartyConfigs[0]); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene) => { + // Learn its Dance + const encounter = scene.currentBattle.mysteryEncounter!; + + const onPokemonSelected = (pokemon: PlayerPokemon) => { + encounter.setDialogueToken("selectedPokemon", pokemon.getNameToRender()); + scene.unshiftPhase(new LearnMovePhase(scene, scene.getParty().indexOf(pokemon), Moves.REVELATION_DANCE)); + + // Play animation again to "learn" the dance + const danceAnim = new EncounterBattleAnim(EncounterAnim.DANCE, scene.getEnemyPokemon()!, scene.getPlayerPokemon()); + danceAnim.play(scene); + }; + + return selectPokemonForOption(scene, onPokemonSelected); + }) + .withOptionPhase(async (scene: BattleScene) => { + // Learn its Dance + hideOricorioPokemon(scene); + leaveEncounterWithoutBattle(scene, true); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL) + .withPrimaryPokemonRequirement(new MoveRequirement(DANCING_MOVES)) // Will set option3PrimaryName and option3PrimaryMove dialogue tokens automatically + .withDialogue({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + disabledButtonTooltip: `${namespace}.option.3.disabled_tooltip`, + secondOptionPrompt: `${namespace}.option.3.select_prompt`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene) => { + // Open menu for selecting pokemon with a Dancing move + const encounter = scene.currentBattle.mysteryEncounter!; + const onPokemonSelected = (pokemon: PlayerPokemon) => { + // Return the options for nature selection + return pokemon.moveset + .filter(move => move && DANCING_MOVES.includes(move.getMove().id)) + .map((move: PokemonMove) => { + const option: OptionSelectItem = { + label: move.getName(), + handler: () => { + // Pokemon and second option selected + encounter.setDialogueToken("selectedPokemon", pokemon.getNameToRender()); + encounter.setDialogueToken("selectedMove", move.getName()); + encounter.misc.selectedMove = move; + + return true; + }, + }; + return option; + }); + }; + + // Only Pokemon that have a Dancing move can be selected + const selectableFilter = (pokemon: Pokemon) => { + // If pokemon meets primary pokemon reqs, it can be selected + const meetsReqs = encounter.options[2].pokemonMeetsPrimaryRequirements(scene, pokemon); + if (!meetsReqs) { + return getEncounterText(scene, `${namespace}.invalid_selection`) ?? null; + } + + return null; + }; + + return selectPokemonForOption(scene, onPokemonSelected, undefined, selectableFilter); + }) + .withOptionPhase(async (scene: BattleScene) => { + // Show the Oricorio a dance, and recruit it + const encounter = scene.currentBattle.mysteryEncounter!; + const oricorio = encounter.misc.oricorioData.toPokemon(scene); + oricorio.passive = true; + + // Ensure the Oricorio's moveset gains the Dance move the player used + const move = encounter.misc.selectedMove?.getMove().id; + if (!oricorio.moveset.some(m => m.getMove().id === move)) { + if (oricorio.moveset.length < 4) { + oricorio.moveset.push(new PokemonMove(move)); + } else { + oricorio.moveset[3] = new PokemonMove(move); + } + } + + hideOricorioPokemon(scene); + await catchPokemon(scene, oricorio, null, PokeballType.POKEBALL, false); + leaveEncounterWithoutBattle(scene, true); + }) + .build() + ) + .build(); + +function hideOricorioPokemon(scene: BattleScene) { + return new Promise(resolve => { + const oricorioSprite = scene.getEnemyParty()[0]; + scene.tweens.add({ + targets: oricorioSprite, + x: "+=16", + y: "-=16", + alpha: 0, + ease: "Sine.easeInOut", + duration: 750, + onComplete: () => { + scene.field.remove(oricorioSprite, true); + resolve(); + } + }); + }); +} diff --git a/src/data/mystery-encounters/encounters/dark-deal-encounter.ts b/src/data/mystery-encounters/encounters/dark-deal-encounter.ts new file mode 100644 index 000000000000..212ff6ed1bb1 --- /dev/null +++ b/src/data/mystery-encounters/encounters/dark-deal-encounter.ts @@ -0,0 +1,197 @@ +import { Type } from "#app/data/type"; +import { isNullOrUndefined, randSeedInt } from "#app/utils"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { Species } from "#enums/species"; +import BattleScene from "#app/battle-scene"; +import { modifierTypes } from "#app/modifier/modifier-type"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterOptionBuilder } from "../mystery-encounter-option"; +import { EnemyPartyConfig, EnemyPokemonConfig, initBattleWithEnemyConfig, leaveEncounterWithoutBattle, } from "../utils/encounter-phase-utils"; +import { getRandomPlayerPokemon, getRandomSpeciesByStarterTier } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { ModifierRewardPhase } from "#app/phases/modifier-reward-phase"; +import { PokemonFormChangeItemModifier, PokemonHeldItemModifier } from "#app/modifier/modifier"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** i18n namespace for encounter */ +const namespace = "mysteryEncounter:darkDeal"; + +/** Exclude Ultra Beasts (inludes Cosmog/Solgaleo/Lunala/Necrozma), Paradox (includes Miraidon/Koraidon), Eternatus, and egg-locked mythicals */ +const excludedBosses = [ + Species.NECROZMA, + Species.COSMOG, + Species.COSMOEM, + Species.SOLGALEO, + Species.LUNALA, + Species.ETERNATUS, + Species.NIHILEGO, + Species.BUZZWOLE, + Species.PHEROMOSA, + Species.XURKITREE, + Species.CELESTEELA, + Species.KARTANA, + Species.GUZZLORD, + Species.POIPOLE, + Species.NAGANADEL, + Species.STAKATAKA, + Species.BLACEPHALON, + Species.GREAT_TUSK, + Species.SCREAM_TAIL, + Species.BRUTE_BONNET, + Species.FLUTTER_MANE, + Species.SLITHER_WING, + Species.SANDY_SHOCKS, + Species.ROARING_MOON, + Species.KORAIDON, + Species.WALKING_WAKE, + Species.GOUGING_FIRE, + Species.RAGING_BOLT, + Species.IRON_TREADS, + Species.IRON_BUNDLE, + Species.IRON_HANDS, + Species.IRON_JUGULIS, + Species.IRON_MOTH, + Species.IRON_THORNS, + Species.IRON_VALIANT, + Species.MIRAIDON, + Species.IRON_LEAVES, + Species.IRON_BOULDER, + Species.IRON_CROWN, + Species.MEW, + Species.CELEBI, + Species.DEOXYS, + Species.JIRACHI, + Species.PHIONE, + Species.MANAPHY, + Species.ARCEUS, + Species.VICTINI, + Species.MELTAN, + Species.PECHARUNT, +]; + +/** + * Dark Deal encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3806 | GitHub Issue #3806} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const DarkDealEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.DARK_DEAL) + .withEncounterTier(MysteryEncounterTier.ROGUE) + .withIntroSpriteConfigs([ + { + spriteKey: "mad_scientist_m", + fileRoot: "mystery-encounters", + hasShadow: true, + }, + { + spriteKey: "dark_deal_porygon", + fileRoot: "mystery-encounters", + hasShadow: true, + repeat: true, + }, + ]) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.intro_dialogue`, + }, + ]) + .withSceneWaveRangeRequirement(30, CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES[1]) + .withScenePartySizeRequirement(2, 6) // Must have at least 2 pokemon in party + .withCatchAllowed(true) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.1.selected_dialogue`, + }, + { + text: `${namespace}.option.1.selected_message`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene) => { + // Removes random pokemon (including fainted) from party and adds name to dialogue data tokens + // Will never return last battle able mon and instead pick fainted/unable to battle + const removedPokemon = getRandomPlayerPokemon(scene, false, true); + // Get all the pokemon's held items + const modifiers = removedPokemon.getHeldItems().filter(m => !(m instanceof PokemonFormChangeItemModifier)); + scene.removePokemonFromPlayerParty(removedPokemon); + + const encounter = scene.currentBattle.mysteryEncounter!; + encounter.setDialogueToken("pokeName", removedPokemon.getNameToRender()); + + // Store removed pokemon types + encounter.misc = { + removedTypes: removedPokemon.getTypes(), + modifiers + }; + }) + .withOptionPhase(async (scene: BattleScene) => { + // Give the player 5 Rogue Balls + const encounter = scene.currentBattle.mysteryEncounter!; + scene.unshiftPhase(new ModifierRewardPhase(scene, modifierTypes.ROGUE_BALL)); + + // Start encounter with random legendary (7-10 starter strength) that has level additive + const bossTypes: Type[] = encounter.misc.removedTypes; + const bossModifiers: PokemonHeldItemModifier[] = encounter.misc.modifiers; + // Starter egg tier, 35/50/10/5 %odds for tiers 6/7/8/9+ + const roll = randSeedInt(100); + const starterTier: number | [number, number] = + roll > 65 ? 6 : roll > 15 ? 7 : roll > 5 ? 8 : [9, 10]; + const bossSpecies = getPokemonSpecies(getRandomSpeciesByStarterTier(starterTier, excludedBosses, bossTypes)); + const pokemonConfig: EnemyPokemonConfig = { + species: bossSpecies, + isBoss: true, + modifierConfigs: bossModifiers.map(m => { + return { + modifier: m + }; + }) + }; + if (!isNullOrUndefined(bossSpecies.forms) && bossSpecies.forms.length > 0) { + pokemonConfig.formIndex = 0; + } + const config: EnemyPartyConfig = { + pokemonConfigs: [pokemonConfig], + }; + return initBattleWithEnemyConfig(scene, config); + }) + .build() + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.2.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Leave encounter with no rewards or exp + leaveEncounterWithoutBattle(scene, true); + return true; + } + ) + .withOutroDialogue([ + { + text: `${namespace}.outro` + } + ]) + .build(); diff --git a/src/data/mystery-encounters/encounters/delibirdy-encounter.ts b/src/data/mystery-encounters/encounters/delibirdy-encounter.ts new file mode 100644 index 000000000000..ed9344d3c959 --- /dev/null +++ b/src/data/mystery-encounters/encounters/delibirdy-encounter.ts @@ -0,0 +1,309 @@ +import { generateModifierType, leaveEncounterWithoutBattle, selectPokemonForOption, updatePlayerMoney, } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import Pokemon, { PlayerPokemon } from "#app/field/pokemon"; +import { modifierTypes, PokemonHeldItemModifierType } from "#app/modifier/modifier-type"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { Species } from "#enums/species"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterOptionBuilder } from "../mystery-encounter-option"; +import { CombinationPokemonRequirement, HeldItemRequirement, MoneyRequirement } from "../mystery-encounter-requirements"; +import { getEncounterText, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { HealingBoosterModifier, HiddenAbilityRateBoosterModifier, LevelIncrementBoosterModifier, PokemonHeldItemModifier, PreserveBerryModifier } from "#app/modifier/modifier"; +import { OptionSelectItem } from "#app/ui/abstact-option-select-ui-handler"; +import { applyModifierTypeToPlayerPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import i18next from "#app/plugins/i18n"; +import { ModifierRewardPhase } from "#app/phases/modifier-reward-phase"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for this encounter */ +const namespace = "mysteryEncounter:delibirdy"; + +/** Berries only */ +const OPTION_2_ALLOWED_MODIFIERS = ["BerryModifier", "PokemonInstantReviveModifier"]; + +/** Disallowed items are berries, Reviver Seeds, and Vitamins (form change items and fusion items are not PokemonHeldItemModifiers) */ +const OPTION_3_DISALLOWED_MODIFIERS = [ + "BerryModifier", + "PokemonInstantReviveModifier", + "TerastallizeModifier", + "PokemonBaseStatModifier", + "PokemonBaseStatTotalModifier" +]; + +/** + * Delibird-y encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3804 | GitHub Issue #3804} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const DelibirdyEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.DELIBIRDY) + .withEncounterTier(MysteryEncounterTier.GREAT) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withSceneRequirement(new MoneyRequirement(0, 2)) // Must have enough money for it to spawn at the very least + .withPrimaryPokemonRequirement(new CombinationPokemonRequirement( // Must also have either option 2 or 3 available to spawn + new HeldItemRequirement(OPTION_2_ALLOWED_MODIFIERS), + new HeldItemRequirement(OPTION_3_DISALLOWED_MODIFIERS, 1, true) + )) + .withIntroSpriteConfigs([ + { + spriteKey: "", + fileRoot: "", + species: Species.DELIBIRD, + hasShadow: true, + repeat: true, + startFrame: 38, + scale: 0.94 + }, + { + spriteKey: "", + fileRoot: "", + species: Species.DELIBIRD, + hasShadow: true, + repeat: true, + scale: 1.06 + }, + { + spriteKey: "", + fileRoot: "", + species: Species.DELIBIRD, + hasShadow: true, + repeat: true, + startFrame: 65, + x: 1, + y: 5, + yShadow: 5 + }, + ]) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + } + ]) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOutroDialogue([ + { + text: `${namespace}.outro`, + } + ]) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + encounter.setDialogueToken("delibirdName", getPokemonSpecies(Species.DELIBIRD).getName()); + return true; + }) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT) + .withSceneMoneyRequirement(0, 2) // Must have money to spawn + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene): Promise => { + const encounter = scene.currentBattle.mysteryEncounter!; + updatePlayerMoney(scene, -(encounter.options[0].requirements[0] as MoneyRequirement).requiredMoney, true, false); + return true; + }) + .withOptionPhase(async (scene: BattleScene) => { + // Give the player an Ability Charm + // Check if the player has max stacks of that item already + const existing = scene.findModifier(m => m instanceof HiddenAbilityRateBoosterModifier) as HiddenAbilityRateBoosterModifier; + + if (existing && existing.getStackCount() >= existing.getMaxStackCount(scene)) { + // At max stacks, give the first party pokemon a Shell Bell instead + const shellBell = generateModifierType(scene, modifierTypes.SHELL_BELL) as PokemonHeldItemModifierType; + await applyModifierTypeToPlayerPokemon(scene, scene.getParty()[0], shellBell); + scene.playSound("item_fanfare"); + await showEncounterText(scene, i18next.t("battle:rewardGain", { modifierName: shellBell.name }), null, undefined, true); + } else { + scene.unshiftPhase(new ModifierRewardPhase(scene, modifierTypes.ABILITY_CHARM)); + } + + leaveEncounterWithoutBattle(scene, true); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT) + .withPrimaryPokemonRequirement(new HeldItemRequirement(OPTION_2_ALLOWED_MODIFIERS)) + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + secondOptionPrompt: `${namespace}.option.2.select_prompt`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene): Promise => { + const encounter = scene.currentBattle.mysteryEncounter!; + const onPokemonSelected = (pokemon: PlayerPokemon) => { + // Get Pokemon held items and filter for valid ones + const validItems = pokemon.getHeldItems().filter((it) => { + return OPTION_2_ALLOWED_MODIFIERS.some(heldItem => it.constructor.name === heldItem); + }); + + return validItems.map((modifier: PokemonHeldItemModifier) => { + const option: OptionSelectItem = { + label: modifier.type.name, + handler: () => { + // Pokemon and item selected + encounter.setDialogueToken("chosenItem", modifier.type.name); + encounter.misc = { + chosenPokemon: pokemon, + chosenModifier: modifier, + }; + return true; + }, + }; + return option; + }); + }; + + // Only Pokemon that can gain benefits are above 1/3rd HP with no status + const selectableFilter = (pokemon: Pokemon) => { + // If pokemon meets primary pokemon reqs, it can be selected + const meetsReqs = encounter.options[1].pokemonMeetsPrimaryRequirements(scene, pokemon); + if (!meetsReqs) { + return getEncounterText(scene, `${namespace}.invalid_selection`) ?? null; + } + + return null; + }; + + return selectPokemonForOption(scene, onPokemonSelected, undefined, selectableFilter); + }) + .withOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + const modifier = encounter.misc.chosenModifier; + + // Give the player a Candy Jar if they gave a Berry, and a Healing Charm for Reviver Seed + if (modifier.type.name.includes("Berry")) { + // Check if the player has max stacks of that Candy Jar already + const existing = scene.findModifier(m => m instanceof LevelIncrementBoosterModifier) as LevelIncrementBoosterModifier; + + if (existing && existing.getStackCount() >= existing.getMaxStackCount(scene)) { + // At max stacks, give the first party pokemon a Shell Bell instead + const shellBell = generateModifierType(scene, modifierTypes.SHELL_BELL) as PokemonHeldItemModifierType; + await applyModifierTypeToPlayerPokemon(scene, scene.getParty()[0], shellBell); + scene.playSound("item_fanfare"); + await showEncounterText(scene, i18next.t("battle:rewardGain", { modifierName: shellBell.name }), null, undefined, true); + } else { + scene.unshiftPhase(new ModifierRewardPhase(scene, modifierTypes.CANDY_JAR)); + } + } else { + // Check if the player has max stacks of that Healing Charm already + const existing = scene.findModifier(m => m instanceof HealingBoosterModifier) as HealingBoosterModifier; + + if (existing && existing.getStackCount() >= existing.getMaxStackCount(scene)) { + // At max stacks, give the first party pokemon a Shell Bell instead + const shellBell = generateModifierType(scene, modifierTypes.SHELL_BELL) as PokemonHeldItemModifierType; + await applyModifierTypeToPlayerPokemon(scene, scene.getParty()[0], shellBell); + scene.playSound("item_fanfare"); + await showEncounterText(scene, i18next.t("battle:rewardGain", { modifierName: shellBell.name }), null, undefined, true); + } else { + scene.unshiftPhase(new ModifierRewardPhase(scene, modifierTypes.HEALING_CHARM)); + } + } + + // Remove the modifier if its stacks go to 0 + modifier.stackCount -= 1; + if (modifier.stackCount === 0) { + scene.removeModifier(modifier); + } + + leaveEncounterWithoutBattle(scene, true); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT) + .withPrimaryPokemonRequirement(new HeldItemRequirement(OPTION_3_DISALLOWED_MODIFIERS, 1, true)) + .withDialogue({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + secondOptionPrompt: `${namespace}.option.3.select_prompt`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene): Promise => { + const encounter = scene.currentBattle.mysteryEncounter!; + const onPokemonSelected = (pokemon: PlayerPokemon) => { + // Get Pokemon held items and filter for valid ones + const validItems = pokemon.getHeldItems().filter((it) => { + return !OPTION_3_DISALLOWED_MODIFIERS.some(heldItem => it.constructor.name === heldItem); + }); + + return validItems.map((modifier: PokemonHeldItemModifier) => { + const option: OptionSelectItem = { + label: modifier.type.name, + handler: () => { + // Pokemon and item selected + encounter.setDialogueToken("chosenItem", modifier.type.name); + encounter.misc = { + chosenPokemon: pokemon, + chosenModifier: modifier, + }; + return true; + }, + }; + return option; + }); + }; + + // Only Pokemon that can gain benefits are above 1/3rd HP with no status + const selectableFilter = (pokemon: Pokemon) => { + // If pokemon meets primary pokemon reqs, it can be selected + const meetsReqs = encounter.options[2].pokemonMeetsPrimaryRequirements(scene, pokemon); + if (!meetsReqs) { + return getEncounterText(scene, `${namespace}.invalid_selection`) ?? null; + } + + return null; + }; + + return selectPokemonForOption(scene, onPokemonSelected, undefined, selectableFilter); + }) + .withOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + const modifier = encounter.misc.chosenModifier; + + // Check if the player has max stacks of Berry Pouch already + const existing = scene.findModifier(m => m instanceof PreserveBerryModifier) as PreserveBerryModifier; + + if (existing && existing.getStackCount() >= existing.getMaxStackCount(scene)) { + // At max stacks, give the first party pokemon a Shell Bell instead + const shellBell = generateModifierType(scene, modifierTypes.SHELL_BELL) as PokemonHeldItemModifierType; + await applyModifierTypeToPlayerPokemon(scene, scene.getParty()[0], shellBell); + scene.playSound("item_fanfare"); + await showEncounterText(scene, i18next.t("battle:rewardGain", { modifierName: shellBell.name }), null, undefined, true); + } else { + scene.unshiftPhase(new ModifierRewardPhase(scene, modifierTypes.BERRY_POUCH)); + } + + // Remove the modifier if its stacks go to 0 + modifier.stackCount -= 1; + if (modifier.stackCount === 0) { + scene.removeModifier(modifier); + } + + leaveEncounterWithoutBattle(scene, true); + }) + .build() + ) + .build(); diff --git a/src/data/mystery-encounters/encounters/department-store-sale-encounter.ts b/src/data/mystery-encounters/encounters/department-store-sale-encounter.ts new file mode 100644 index 000000000000..e35ca08b6a0d --- /dev/null +++ b/src/data/mystery-encounters/encounters/department-store-sale-encounter.ts @@ -0,0 +1,164 @@ +import { + leaveEncounterWithoutBattle, + setEncounterRewards, +} from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { ModifierTypeFunc, modifierTypes } from "#app/modifier/modifier-type"; +import { randSeedInt } from "#app/utils"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { Species } from "#enums/species"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { + MysteryEncounterBuilder, +} from "../mystery-encounter"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** i18n namespace for encounter */ +const namespace = "mysteryEncounter:departmentStoreSale"; + +/** + * Department Store Sale encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3797 | GitHub Issue #3797} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const DepartmentStoreSaleEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.DEPARTMENT_STORE_SALE) + .withEncounterTier(MysteryEncounterTier.COMMON) + .withSceneWaveRangeRequirement(CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES[0], 100) + .withIntroSpriteConfigs([ + { + spriteKey: "b2w2_lady", + fileRoot: "mystery-encounters", + hasShadow: true, + x: -20, + }, + { + spriteKey: "", + fileRoot: "", + species: Species.FURFROU, + hasShadow: true, + repeat: true, + x: 30, + }, + ]) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + { + text: `${namespace}.intro_dialogue`, + speaker: `${namespace}.speaker`, + }, + ]) + .withAutoHideIntroVisuals(false) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + }, + async (scene: BattleScene) => { + // Choose TMs + const modifiers: ModifierTypeFunc[] = []; + let i = 0; + while (i < 4) { + // 2/2/1 weight on TM rarity + const roll = randSeedInt(5); + if (roll < 2) { + modifiers.push(modifierTypes.TM_COMMON); + } else if (roll < 4) { + modifiers.push(modifierTypes.TM_GREAT); + } else { + modifiers.push(modifierTypes.TM_ULTRA); + } + i++; + } + + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: modifiers, fillRemaining: false, }); + leaveEncounterWithoutBattle(scene); + } + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + }, + async (scene: BattleScene) => { + // Choose Vitamins + const modifiers: ModifierTypeFunc[] = []; + let i = 0; + while (i < 3) { + // 2/1 weight on base stat booster vs PP Up + const roll = randSeedInt(3); + if (roll === 0) { + modifiers.push(modifierTypes.PP_UP); + } else { + modifiers.push(modifierTypes.BASE_STAT_BOOSTER); + } + i++; + } + + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: modifiers, fillRemaining: false, }); + leaveEncounterWithoutBattle(scene); + } + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + }, + async (scene: BattleScene) => { + // Choose X Items + const modifiers: ModifierTypeFunc[] = []; + let i = 0; + while (i < 5) { + // 4/1 weight on base stat booster vs Dire Hit + const roll = randSeedInt(5); + if (roll === 0) { + modifiers.push(modifierTypes.DIRE_HIT); + } else { + modifiers.push(modifierTypes.TEMP_STAT_STAGE_BOOSTER); + } + i++; + } + + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: modifiers, fillRemaining: false, }); + leaveEncounterWithoutBattle(scene); + } + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.4.label`, + buttonTooltip: `${namespace}.option.4.tooltip`, + }, + async (scene: BattleScene) => { + // Choose Pokeballs + const modifiers: ModifierTypeFunc[] = []; + let i = 0; + while (i < 4) { + // 10/30/20/5 weight on pokeballs + const roll = randSeedInt(65); + if (roll < 10) { + modifiers.push(modifierTypes.POKEBALL); + } else if (roll < 40) { + modifiers.push(modifierTypes.GREAT_BALL); + } else if (roll < 60) { + modifiers.push(modifierTypes.ULTRA_BALL); + } else { + modifiers.push(modifierTypes.ROGUE_BALL); + } + i++; + } + + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: modifiers, fillRemaining: false, }); + leaveEncounterWithoutBattle(scene); + } + ) + .withOutroDialogue([ + { + text: `${namespace}.outro`, + } + ]) + .build(); diff --git a/src/data/mystery-encounters/encounters/field-trip-encounter.ts b/src/data/mystery-encounters/encounters/field-trip-encounter.ts new file mode 100644 index 000000000000..e0101d60a2ab --- /dev/null +++ b/src/data/mystery-encounters/encounters/field-trip-encounter.ts @@ -0,0 +1,235 @@ +import { MoveCategory } from "#app/data/move"; +import { MysteryEncounterOptionBuilder } from "#app/data/mystery-encounters/mystery-encounter-option"; +import { generateModifierTypeOption, leaveEncounterWithoutBattle, selectPokemonForOption, setEncounterExp, setEncounterRewards } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { PlayerPokemon, PokemonMove } from "#app/field/pokemon"; +import { modifierTypes } from "#app/modifier/modifier-type"; +import { OptionSelectItem } from "#app/ui/abstact-option-select-ui-handler"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { Stat } from "#enums/stat"; +import i18next from "i18next"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** i18n namespace for the encounter */ +const namespace = "mysteryEncounter:fieldTrip"; + +/** + * Field Trip encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3794 | GitHub Issue #3794} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const FieldTripEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.FIELD_TRIP) + .withEncounterTier(MysteryEncounterTier.COMMON) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withIntroSpriteConfigs([ + { + spriteKey: "preschooler_m", + fileRoot: "trainer", + hasShadow: true, + }, + { + spriteKey: "teacher", + fileRoot: "mystery-encounters", + hasShadow: true, + }, + { + spriteKey: "preschooler_f", + fileRoot: "trainer", + hasShadow: true, + }, + ]) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + { + text: `${namespace}.intro_dialogue`, + speaker: `${namespace}.speaker`, + }, + ]) + .withAutoHideIntroVisuals(false) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + secondOptionPrompt: `${namespace}.second_option_prompt`, + }) + .withPreOptionPhase(async (scene: BattleScene): Promise => { + const encounter = scene.currentBattle.mysteryEncounter!; + const onPokemonSelected = (pokemon: PlayerPokemon) => { + // Return the options for Pokemon move valid for this option + return pokemon.moveset.map((move: PokemonMove) => { + const option: OptionSelectItem = { + label: move.getName(), + handler: () => { + // Pokemon and move selected + encounter.setDialogueToken("moveCategory", i18next.t(`${namespace}.physical`)); + pokemonAndMoveChosen(scene, pokemon, move, MoveCategory.PHYSICAL); + return true; + }, + }; + return option; + }); + }; + + return selectPokemonForOption(scene, onPokemonSelected); + }) + .withOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + if (encounter.misc.correctMove) { + const modifiers = [ + generateModifierTypeOption(scene, modifierTypes.TEMP_STAT_STAGE_BOOSTER, [Stat.ATK])!, + generateModifierTypeOption(scene, modifierTypes.TEMP_STAT_STAGE_BOOSTER, [Stat.DEF])!, + generateModifierTypeOption(scene, modifierTypes.TEMP_STAT_STAGE_BOOSTER, [Stat.SPD])!, + generateModifierTypeOption(scene, modifierTypes.DIRE_HIT)!, + generateModifierTypeOption(scene, modifierTypes.RARER_CANDY)!, + ]; + + setEncounterRewards(scene, { guaranteedModifierTypeOptions: modifiers, fillRemaining: false }); + } + + leaveEncounterWithoutBattle(scene, !encounter.misc.correctMove); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + secondOptionPrompt: `${namespace}.second_option_prompt`, + }) + .withPreOptionPhase(async (scene: BattleScene): Promise => { + const encounter = scene.currentBattle.mysteryEncounter!; + const onPokemonSelected = (pokemon: PlayerPokemon) => { + // Return the options for Pokemon move valid for this option + return pokemon.moveset.map((move: PokemonMove) => { + const option: OptionSelectItem = { + label: move.getName(), + handler: () => { + // Pokemon and move selected + encounter.setDialogueToken("moveCategory", i18next.t(`${namespace}.special`)); + pokemonAndMoveChosen(scene, pokemon, move, MoveCategory.SPECIAL); + return true; + }, + }; + return option; + }); + }; + + return selectPokemonForOption(scene, onPokemonSelected); + }) + .withOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + if (encounter.misc.correctMove) { + const modifiers = [ + generateModifierTypeOption(scene, modifierTypes.TEMP_STAT_STAGE_BOOSTER, [Stat.SPATK])!, + generateModifierTypeOption(scene, modifierTypes.TEMP_STAT_STAGE_BOOSTER, [Stat.SPDEF])!, + generateModifierTypeOption(scene, modifierTypes.TEMP_STAT_STAGE_BOOSTER, [Stat.SPD])!, + generateModifierTypeOption(scene, modifierTypes.DIRE_HIT)!, + generateModifierTypeOption(scene, modifierTypes.RARER_CANDY)!, + ]; + + setEncounterRewards(scene, { guaranteedModifierTypeOptions: modifiers, fillRemaining: false }); + } + + leaveEncounterWithoutBattle(scene, !encounter.misc.correctMove); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + secondOptionPrompt: `${namespace}.second_option_prompt`, + }) + .withPreOptionPhase(async (scene: BattleScene): Promise => { + const encounter = scene.currentBattle.mysteryEncounter!; + const onPokemonSelected = (pokemon: PlayerPokemon) => { + // Return the options for Pokemon move valid for this option + return pokemon.moveset.map((move: PokemonMove) => { + const option: OptionSelectItem = { + label: move.getName(), + handler: () => { + // Pokemon and move selected + encounter.setDialogueToken("moveCategory", i18next.t(`${namespace}.status`)); + pokemonAndMoveChosen(scene, pokemon, move, MoveCategory.STATUS); + return true; + }, + }; + return option; + }); + }; + + return selectPokemonForOption(scene, onPokemonSelected); + }) + .withOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + if (encounter.misc.correctMove) { + const modifiers = [ + generateModifierTypeOption(scene, modifierTypes.TEMP_STAT_STAGE_BOOSTER, [Stat.ACC])!, + generateModifierTypeOption(scene, modifierTypes.TEMP_STAT_STAGE_BOOSTER, [Stat.SPD])!, + generateModifierTypeOption(scene, modifierTypes.GREAT_BALL)!, + generateModifierTypeOption(scene, modifierTypes.IV_SCANNER)!, + generateModifierTypeOption(scene, modifierTypes.RARER_CANDY)!, + ]; + + setEncounterRewards(scene, { guaranteedModifierTypeOptions: modifiers, fillRemaining: false }); + } + + leaveEncounterWithoutBattle(scene, !encounter.misc.correctMove); + }) + .build() + ) + .build(); + +function pokemonAndMoveChosen(scene: BattleScene, pokemon: PlayerPokemon, move: PokemonMove, correctMoveCategory: MoveCategory) { + const encounter = scene.currentBattle.mysteryEncounter!; + const correctMove = move.getMove().category === correctMoveCategory; + encounter.setDialogueToken("pokeName", pokemon.getNameToRender()); + encounter.setDialogueToken("move", move.getName()); + if (!correctMove) { + encounter.selectedOption!.dialogue!.selected = [ + { + text: `${namespace}.option.selected`, + }, + { + text: `${namespace}.incorrect`, + speaker: `${namespace}.speaker`, + }, + { + text: `${namespace}.incorrect_exp`, + }, + ]; + setEncounterExp(scene, scene.getParty().map((p) => p.id), 50); + } else { + encounter.selectedOption!.dialogue!.selected = [ + { + text: `${namespace}.option.selected`, + }, + { + text: `${namespace}.correct`, + speaker: `${namespace}.speaker`, + }, + { + text: `${namespace}.correct_exp`, + }, + ]; + setEncounterExp(scene, [pokemon.id], 100); + } + encounter.misc = { + correctMove: correctMove, + }; +} diff --git a/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts b/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts new file mode 100644 index 000000000000..1861abcc7a48 --- /dev/null +++ b/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts @@ -0,0 +1,255 @@ +import { MysteryEncounterOptionBuilder } from "#app/data/mystery-encounters/mystery-encounter-option"; +import { EnemyPartyConfig, initBattleWithEnemyConfig, loadCustomMovesForEncounter, leaveEncounterWithoutBattle, setEncounterExp, setEncounterRewards, transitionMysteryEncounterIntroVisuals, generateModifierType } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { AttackTypeBoosterModifierType, modifierTypes, } from "#app/modifier/modifier-type"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { TypeRequirement } from "../mystery-encounter-requirements"; +import { Species } from "#enums/species"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { Gender } from "#app/data/gender"; +import { Type } from "#app/data/type"; +import { BattlerIndex } from "#app/battle"; +import { PokemonMove } from "#app/field/pokemon"; +import { Moves } from "#enums/moves"; +import { EncounterBattleAnim } from "#app/data/battle-anims"; +import { WeatherType } from "#app/data/weather"; +import { isNullOrUndefined, randSeedInt } from "#app/utils"; +import { StatusEffect } from "#app/data/status-effect"; +import { queueEncounterMessage } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { applyDamageToPokemon, applyModifierTypeToPlayerPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { EncounterAnim } from "#enums/encounter-anims"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for the encounter */ +const namespace = "mysteryEncounter:fieryFallout"; + +/** + * Damage percentage taken when suffering the heat. + * Can be a number between `0` - `100`. + * The higher the more damage taken (100% = instant KO). + */ +const DAMAGE_PERCENTAGE: number = 20; + +/** + * Fiery Fallout encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3814 | GitHub Issue #3814} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const FieryFalloutEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.FIERY_FALLOUT) + .withEncounterTier(MysteryEncounterTier.COMMON) + .withSceneWaveRangeRequirement(40, CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES[1]) + .withCatchAllowed(true) + .withIntroSpriteConfigs([]) // Set in onInit() + .withAnimations(EncounterAnim.MAGMA_BG, EncounterAnim.MAGMA_SPOUT) + .withAutoHideIntroVisuals(false) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + ]) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + + // Calculate boss mons + const volcaronaSpecies = getPokemonSpecies(Species.VOLCARONA); + const config: EnemyPartyConfig = { + pokemonConfigs: [ + { + species: volcaronaSpecies, + isBoss: false, + gender: Gender.MALE + }, + { + species: volcaronaSpecies, + isBoss: false, + gender: Gender.FEMALE + } + ], + doubleBattle: true, + disableSwitch: true + }; + encounter.enemyPartyConfigs = [config]; + + // Load hidden Volcarona sprites + encounter.spriteConfigs = [ + { + spriteKey: "", + fileRoot: "", + species: Species.VOLCARONA, + repeat: true, + hidden: true, + hasShadow: true, + x: -20, + startFrame: 20 + }, + { + spriteKey: "", + fileRoot: "", + species: Species.VOLCARONA, + repeat: true, + hidden: true, + hasShadow: true, + x: 20 + }, + ]; + + // Load animations/sfx for Volcarona moves + loadCustomMovesForEncounter(scene, [Moves.FIRE_SPIN, Moves.QUIVER_DANCE]); + + scene.arena.trySetWeather(WeatherType.SUNNY, true); + + encounter.setDialogueToken("volcaronaName", getPokemonSpecies(Species.VOLCARONA).getName()); + + return true; + }) + .withOnVisualsStart((scene: BattleScene) => { + // Play animations + const background = new EncounterBattleAnim(EncounterAnim.MAGMA_BG, scene.getPlayerPokemon()!, scene.getPlayerPokemon()); + background.playWithoutTargets(scene, 200, 70, 2, 3); + const animation = new EncounterBattleAnim(EncounterAnim.MAGMA_SPOUT, scene.getPlayerPokemon()!, scene.getPlayerPokemon()); + animation.playWithoutTargets(scene, 80, 100, 2); + scene.time.delayedCall(600, () => { + animation.playWithoutTargets(scene, -20, 100, 2); + }); + scene.time.delayedCall(1200, () => { + animation.playWithoutTargets(scene, 140, 150, 2); + }); + + return true; + }) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Pick battle + const encounter = scene.currentBattle.mysteryEncounter!; + setEncounterRewards(scene, { fillRemaining: true }, undefined, () => giveLeadPokemonCharcoal(scene)); + + encounter.startOfBattleEffects.push( + { + sourceBattlerIndex: BattlerIndex.ENEMY, + targets: [BattlerIndex.PLAYER], + move: new PokemonMove(Moves.FIRE_SPIN), + ignorePp: true + }, + { + sourceBattlerIndex: BattlerIndex.ENEMY_2, + targets: [BattlerIndex.PLAYER_2], + move: new PokemonMove(Moves.FIRE_SPIN), + ignorePp: true + }, + { + sourceBattlerIndex: BattlerIndex.ENEMY, + targets: [BattlerIndex.ENEMY], + move: new PokemonMove(Moves.QUIVER_DANCE), + ignorePp: true + }, + { + sourceBattlerIndex: BattlerIndex.ENEMY_2, + targets: [BattlerIndex.ENEMY_2], + move: new PokemonMove(Moves.QUIVER_DANCE), + ignorePp: true + }); + await initBattleWithEnemyConfig(scene, scene.currentBattle.mysteryEncounter!.enemyPartyConfigs[0]); + } + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Damage non-fire types and burn 1 random non-fire type member + const encounter = scene.currentBattle.mysteryEncounter!; + const nonFireTypes = scene.getParty().filter((p) => p.isAllowedInBattle() && !p.getTypes().includes(Type.FIRE)); + + for (const pkm of nonFireTypes) { + const percentage = DAMAGE_PERCENTAGE / 100; + const damage = Math.floor(pkm.getMaxHp() * percentage); + applyDamageToPokemon(scene, pkm, damage); + } + + // Burn random member + const burnable = nonFireTypes.filter(p => isNullOrUndefined(p.status) || isNullOrUndefined(p.status!.effect) || p.status?.effect === StatusEffect.BURN); + if (burnable?.length > 0) { + const roll = randSeedInt(burnable.length); + const chosenPokemon = burnable[roll]; + if (chosenPokemon.trySetStatus(StatusEffect.BURN)) { + // Burn applied + encounter.setDialogueToken("burnedPokemon", chosenPokemon.getNameToRender()); + queueEncounterMessage(scene, `${namespace}.option.2.target_burned`); + } + } + + // No rewards + leaveEncounterWithoutBattle(scene, true); + } + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL) + .withPrimaryPokemonRequirement(new TypeRequirement(Type.FIRE, true, 1)) // Will set option3PrimaryName dialogue token automatically + .withSecondaryPokemonRequirement(new TypeRequirement(Type.FIRE, true, 1)) // Will set option3SecondaryName dialogue token automatically + .withDialogue({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + disabledButtonTooltip: `${namespace}.option.3.disabled_tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene) => { + transitionMysteryEncounterIntroVisuals(scene, false, false, 2000); + }) + .withOptionPhase(async (scene: BattleScene) => { + // Fire types help calm the Volcarona + const encounter = scene.currentBattle.mysteryEncounter!; + transitionMysteryEncounterIntroVisuals(scene); + setEncounterRewards(scene, + { fillRemaining: true }, + undefined, + () => { + giveLeadPokemonCharcoal(scene); + }); + + const primary = encounter.options[2].primaryPokemon!; + const secondary = encounter.options[2].secondaryPokemon![0]; + + setEncounterExp(scene, [primary.id, secondary.id], getPokemonSpecies(Species.VOLCARONA).baseExp * 2); + leaveEncounterWithoutBattle(scene); + }) + .build() + ) + .build(); + +function giveLeadPokemonCharcoal(scene: BattleScene) { + // Give first party pokemon Charcoal for free at end of battle + const leadPokemon = scene.getParty()?.[0]; + if (leadPokemon) { + const charcoal = generateModifierType(scene, modifierTypes.ATTACK_TYPE_BOOSTER, [Type.FIRE]) as AttackTypeBoosterModifierType; + applyModifierTypeToPlayerPokemon(scene, leadPokemon, charcoal); + scene.currentBattle.mysteryEncounter!.setDialogueToken("leadPokemon", leadPokemon.getNameToRender()); + queueEncounterMessage(scene, `${namespace}.found_charcoal`); + } +} diff --git a/src/data/mystery-encounters/encounters/fight-or-flight-encounter.ts b/src/data/mystery-encounters/encounters/fight-or-flight-encounter.ts new file mode 100644 index 000000000000..c163a2fc194c --- /dev/null +++ b/src/data/mystery-encounters/encounters/fight-or-flight-encounter.ts @@ -0,0 +1,186 @@ +import { MysteryEncounterOptionBuilder } from "#app/data/mystery-encounters/mystery-encounter-option"; +import { + EnemyPartyConfig, + initBattleWithEnemyConfig, + leaveEncounterWithoutBattle, setEncounterExp, + setEncounterRewards +} from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { STEALING_MOVES } from "#app/data/mystery-encounters/requirements/requirement-groups"; +import Pokemon, { EnemyPokemon } from "#app/field/pokemon"; +import { ModifierTier } from "#app/modifier/modifier-tier"; +import { + getPartyLuckValue, + getPlayerModifierTypeOptions, + ModifierPoolType, + ModifierTypeOption, + regenerateModifierPoolThresholds, +} from "#app/modifier/modifier-type"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MoveRequirement } from "../mystery-encounter-requirements"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { TrainerSlot } from "#app/data/trainer-config"; +import { getSpriteKeysFromPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import PokemonData from "#app/system/pokemon-data"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { queueEncounterMessage } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { randSeedInt } from "#app/utils"; +import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for the encounter */ +const namespace = "mysteryEncounter:fightOrFlight"; + +/** + * Fight or Flight encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3795 | GitHub Issue #3795} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const FightOrFlightEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.FIGHT_OR_FLIGHT) + .withEncounterTier(MysteryEncounterTier.COMMON) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withCatchAllowed(true) + .withHideWildIntroMessage(true) + .withIntroSpriteConfigs([]) // Set in onInit() + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + ]) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + + // Calculate boss mon + const level = (scene.currentBattle.enemyLevels?.[0] ?? scene.currentBattle.waveIndex) + Math.max(Math.round((scene.currentBattle.waveIndex / 10)), 0); + const bossSpecies = scene.arena.randomSpecies(scene.currentBattle.waveIndex, level, 0, getPartyLuckValue(scene.getParty()), true); + const bossPokemon = new EnemyPokemon(scene, bossSpecies, level, TrainerSlot.NONE, true); + encounter.setDialogueToken("enemyPokemon", bossPokemon.getNameToRender()); + const config: EnemyPartyConfig = { + levelAdditiveMultiplier: 1, + pokemonConfigs: [{ + level: level, + species: bossSpecies, + dataSource: new PokemonData(bossPokemon), + isBoss: true, + tags: [BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON], + mysteryEncounterBattleEffects: (pokemon: Pokemon) => { + queueEncounterMessage(pokemon.scene, `${namespace}.option.1.stat_boost`); + // Randomly boost 1 stat 2 stages + pokemon.scene.unshiftPhase(new StatStageChangePhase(pokemon.scene, pokemon.getBattlerIndex(), true, [randSeedInt(5)], 2)); + } + }], + }; + encounter.enemyPartyConfigs = [config]; + + // Calculate item + // Waves 10-40 GREAT, 60-120 ULTRA, 120-160 ROGUE, 160-180 MASTER + const tier = + scene.currentBattle.waveIndex > 160 + ? ModifierTier.MASTER + : scene.currentBattle.waveIndex > 120 + ? ModifierTier.ROGUE + : scene.currentBattle.waveIndex > 40 + ? ModifierTier.ULTRA + : ModifierTier.GREAT; + regenerateModifierPoolThresholds(scene.getParty(), ModifierPoolType.PLAYER, 0); + let item: ModifierTypeOption | null = null; + // TMs and Candy Jar excluded from possible rewards as they're too swingy in value for a singular item reward + while (!item || item.type.id.includes("TM_") || item.type.id === "CANDY_JAR") { + item = getPlayerModifierTypeOptions(1, scene.getParty(), [], { guaranteedModifierTiers: [tier], allowLuckUpgrades: false })[0]; + } + encounter.setDialogueToken("itemName", item.type.name); + encounter.misc = item; + + const { spriteKey, fileRoot } = getSpriteKeysFromPokemon(bossPokemon); + encounter.spriteConfigs = [ + { + spriteKey: item.type.iconImage, + fileRoot: "items", + hasShadow: false, + x: 35, + y: -5, + scale: 0.75, + isItem: true, + disableAnimation: true + }, + { + spriteKey: spriteKey, + fileRoot: fileRoot, + hasShadow: true, + tint: 0.25, + x: -5, + repeat: true, + isPokemon: true + }, + ]; + + return true; + }) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Pick battle + // Pokemon will randomly boost 1 stat by 2 stages + const item = scene.currentBattle.mysteryEncounter!.misc as ModifierTypeOption; + setEncounterRewards(scene, { guaranteedModifierTypeOptions: [item], fillRemaining: false }); + await initBattleWithEnemyConfig(scene, scene.currentBattle.mysteryEncounter!.enemyPartyConfigs[0]); + } + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL) + .withPrimaryPokemonRequirement(new MoveRequirement(STEALING_MOVES)) // Will set option2PrimaryName and option2PrimaryMove dialogue tokens automatically + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + disabledButtonTooltip: `${namespace}.option.2.disabled_tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected` + } + ] + }) + .withOptionPhase(async (scene: BattleScene) => { + // Pick steal + const encounter = scene.currentBattle.mysteryEncounter!; + const item = scene.currentBattle.mysteryEncounter!.misc as ModifierTypeOption; + setEncounterRewards(scene, { guaranteedModifierTypeOptions: [item], fillRemaining: false }); + + // Use primaryPokemon to execute the thievery + const primaryPokemon = encounter.options[1].primaryPokemon!; + setEncounterExp(scene, primaryPokemon.id, encounter.enemyPartyConfigs[0].pokemonConfigs![0].species.baseExp); + leaveEncounterWithoutBattle(scene); + }) + .build() + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Leave encounter with no rewards or exp + leaveEncounterWithoutBattle(scene, true); + return true; + } + ) + .build(); diff --git a/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts b/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts new file mode 100644 index 000000000000..a544657e47ca --- /dev/null +++ b/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts @@ -0,0 +1,425 @@ +import { leaveEncounterWithoutBattle, selectPokemonForOption, setEncounterRewards, transitionMysteryEncounterIntroVisuals, updatePlayerMoney, } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterOptionBuilder } from "#app/data/mystery-encounters/mystery-encounter-option"; +import { TrainerSlot } from "#app/data/trainer-config"; +import Pokemon, { FieldPosition, PlayerPokemon } from "#app/field/pokemon"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { MoneyRequirement } from "#app/data/mystery-encounters/mystery-encounter-requirements"; +import { getEncounterText, queueEncounterMessage, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { Species } from "#enums/species"; +import i18next from "i18next"; +import { getPokemonNameWithAffix } from "#app/messages"; +import { PlayerGender } from "#enums/player-gender"; +import { getPokeballAtlasKey, getPokeballTintColor } from "#app/data/pokeball"; +import { addPokeballOpenParticles } from "#app/field/anims"; +import { ShinySparklePhase } from "#app/phases/shiny-sparkle-phase"; +import { SpeciesFormChangeActiveTrigger } from "#app/data/pokemon-forms"; +import { PostSummonPhase } from "#app/phases/post-summon-phase"; +import { modifierTypes } from "#app/modifier/modifier-type"; +import { Nature } from "#enums/nature"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for the encounter */ +const namespace = "mysteryEncounter:funAndGames"; + +/** + * Fun and Games! encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3819 | GitHub Issue #3819} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const FunAndGamesEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.FUN_AND_GAMES) + .withEncounterTier(MysteryEncounterTier.GREAT) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withSceneRequirement(new MoneyRequirement(0, 1.5)) // Cost equal to 1 Max Potion to play + .withAutoHideIntroVisuals(false) + // Allows using move without a visible enemy pokemon + .withBattleAnimationsWithoutTargets(true) + // The Wobbuffet won't use moves + .withSkipEnemyBattleTurns(true) + // Will skip COMMAND selection menu and go straight to FIGHT (move select) menu + .withSkipToFightInput(true) + .withIntroSpriteConfigs([ + { + spriteKey: "carnival_game", + fileRoot: "mystery-encounters", + hasShadow: false, + x: 0, + y: 6, + }, + { + spriteKey: "carnival_wobbuffet", + fileRoot: "mystery-encounters", + hasShadow: true, + x: -28, + y: 6, + yShadow: 6 + }, + { + spriteKey: "carnival_man", + fileRoot: "mystery-encounters", + hasShadow: true, + x: 40, + y: 6, + yShadow: 6 + }, + ]) + .withIntroDialogue([ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.intro_dialogue`, + }, + ]) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + scene.loadBgm("mystery_encounter_fun_and_games", "mystery_encounter_fun_and_games.mp3"); + encounter.setDialogueToken("wobbuffetName", getPokemonSpecies(Species.WOBBUFFET).getName()); + return true; + }) + .withOnVisualsStart((scene: BattleScene) => { + // Change the bgm + scene.fadeOutBgm(2000, false); + scene.time.delayedCall(2000, () => { + scene.playBgm("mystery_encounter_fun_and_games"); + }); + + return true; + }) + .withOption(MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT) + .withSceneRequirement(new MoneyRequirement(0, 1.5)) // Cost equal to 1 Max Potion + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene) => { + // Select Pokemon for minigame + const encounter = scene.currentBattle.mysteryEncounter!; + const onPokemonSelected = (pokemon: PlayerPokemon) => { + encounter.misc = { + playerPokemon: pokemon, + }; + }; + + // Only Pokemon that are not KOed/legal can be selected + const selectableFilter = (pokemon: Pokemon) => { + const meetsReqs = pokemon.isAllowedInBattle(); + if (!meetsReqs) { + return getEncounterText(scene, `${namespace}.invalid_selection`) ?? null; + } + + return null; + }; + + return selectPokemonForOption(scene, onPokemonSelected, undefined, selectableFilter); + }) + .withOptionPhase(async (scene: BattleScene) => { + // Start minigame + const encounter = scene.currentBattle.mysteryEncounter!; + encounter.misc.turnsRemaining = 3; + + // Update money + const moneyCost = (encounter.options[0].requirements[0] as MoneyRequirement).requiredMoney; + updatePlayerMoney(scene, -moneyCost, true, false); + await showEncounterText(scene, i18next.t("mysteryEncounterMessages:paid_money", { amount: moneyCost })); + + // Handlers for battle events + encounter.onTurnStart = handleNextTurn; // triggered during TurnInitPhase + encounter.doContinueEncounter = handleLoseMinigame; // triggered during MysteryEncounterRewardsPhase, post VictoryPhase if the player KOs Wobbuffet + + hideShowmanIntroSprite(scene); + await summonPlayerPokemon(scene); + await showWobbuffetHealthBar(scene); + + return true; + }) + .build() + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Leave encounter with no rewards or exp + transitionMysteryEncounterIntroVisuals(scene, true, true); + leaveEncounterWithoutBattle(scene, true); + return true; + } + ) + .build(); + +async function summonPlayerPokemon(scene: BattleScene) { + return new Promise(async resolve => { + const encounter = scene.currentBattle.mysteryEncounter!; + + const playerPokemon = encounter.misc.playerPokemon; + // Swaps the chosen Pokemon and the first player's lead Pokemon in the party + const party = scene.getParty(); + const chosenIndex = party.indexOf(playerPokemon); + if (chosenIndex !== 0) { + [party[chosenIndex], party[0]] = [party[chosenIndex], party[chosenIndex]]; + } + + // Do trainer summon animation + let playerAnimationPromise: Promise | undefined; + scene.ui.showText(i18next.t("battle:playerGo", { pokemonName: getPokemonNameWithAffix(playerPokemon) })); + scene.pbTray.hide(); + scene.trainer.setTexture(`trainer_${scene.gameData.gender === PlayerGender.FEMALE ? "f" : "m"}_back_pb`); + scene.time.delayedCall(562, () => { + scene.trainer.setFrame("2"); + scene.time.delayedCall(64, () => { + scene.trainer.setFrame("3"); + }); + }); + scene.tweens.add({ + targets: scene.trainer, + x: -36, + duration: 1000, + onComplete: () => scene.trainer.setVisible(false) + }); + scene.time.delayedCall(750, () => { + playerAnimationPromise = summonPlayerPokemonAnimation(scene, playerPokemon); + }); + + // Also loads Wobbuffet data + const enemySpecies = getPokemonSpecies(Species.WOBBUFFET); + scene.currentBattle.enemyParty = []; + const wobbuffet = scene.addEnemyPokemon(enemySpecies, encounter.misc.playerPokemon.level, TrainerSlot.NONE, false); + wobbuffet.ivs = [0, 0, 0, 0, 0, 0]; + wobbuffet.setNature(Nature.MILD); + wobbuffet.setAlpha(0); + wobbuffet.setVisible(false); + wobbuffet.calculateStats(); + scene.currentBattle.enemyParty[0] = wobbuffet; + scene.gameData.setPokemonSeen(wobbuffet, true); + await wobbuffet.loadAssets(); + const id = setInterval(checkPlayerAnimationPromise, 500); + async function checkPlayerAnimationPromise() { + if (playerAnimationPromise) { + clearInterval(id); + await playerAnimationPromise; + resolve(); + } + } + }); +} + +function handleLoseMinigame(scene: BattleScene) { + return new Promise(async resolve => { + // Check Wobbuffet is still alive + const wobbuffet = scene.getEnemyPokemon(); + if (!wobbuffet || wobbuffet.isFainted(true) || wobbuffet.hp === 0) { + // Player loses + // End the battle + if (wobbuffet) { + wobbuffet.hideInfo(); + scene.field.remove(wobbuffet); + } + transitionMysteryEncounterIntroVisuals(scene, true, true); + scene.currentBattle.enemyParty = []; + scene.currentBattle.mysteryEncounter!.doContinueEncounter = undefined; + leaveEncounterWithoutBattle(scene, true); + await showEncounterText(scene, `${namespace}.ko`); + const reviveCost = scene.getWaveMoneyAmount(1.5); + updatePlayerMoney(scene, -reviveCost, true, false); + } + + resolve(); + }); +} + +function handleNextTurn(scene: BattleScene) { + const encounter = scene.currentBattle.mysteryEncounter!; + + const wobbuffet = scene.getEnemyPokemon(); + if (!wobbuffet) { + // Should never be triggered, just handling the edge case + handleLoseMinigame(scene); + return true; + } + if (encounter.misc.turnsRemaining <= 0) { + // Check Wobbuffet's health for the actual result + const healthRatio = wobbuffet.hp / wobbuffet.getMaxHp(); + let resultMessageKey: string; + let isHealPhase = false; + if (healthRatio < 0.03) { + // Grand prize + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: [modifierTypes.MULTI_LENS], fillRemaining: false }); + resultMessageKey = `${namespace}.best_result`; + } else if (healthRatio < 0.15) { + // 2nd prize + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: [modifierTypes.SCOPE_LENS], fillRemaining: false }); + resultMessageKey = `${namespace}.great_result`; + } else if (healthRatio < 0.33) { + // 3rd prize + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: [modifierTypes.WIDE_LENS], fillRemaining: false }); + resultMessageKey = `${namespace}.good_result`; + } else { + // No prize + isHealPhase = true; + resultMessageKey = `${namespace}.bad_result`; + } + + // End the battle + wobbuffet.hideInfo(); + scene.field.remove(wobbuffet); + scene.currentBattle.enemyParty = []; + scene.currentBattle.mysteryEncounter!.doContinueEncounter = undefined; + leaveEncounterWithoutBattle(scene, isHealPhase); + // Must end the TurnInit phase prematurely so battle phases aren't added to queue + queueEncounterMessage(scene, `${namespace}.end_game`); + queueEncounterMessage(scene, resultMessageKey); + + // Skip remainder of TurnInitPhase + return true; + } else { + if (encounter.misc.turnsRemaining < 3) { + // Display charging messages on turns that aren't the initial turn + queueEncounterMessage(scene, `${namespace}.charging_continue`); + } + queueEncounterMessage(scene, `${namespace}.turn_remaining_${encounter.misc.turnsRemaining}`); + encounter.misc.turnsRemaining--; + } + + // Don't skip remainder of TurnInitPhase + return false; +} + +async function showWobbuffetHealthBar(scene: BattleScene) { + const wobbuffet = scene.getEnemyPokemon()!; + + scene.add.existing(wobbuffet); + scene.field.add(wobbuffet); + + const playerPokemon = scene.getPlayerPokemon() as Pokemon; + if (playerPokemon?.visible) { + scene.field.moveBelow(wobbuffet, playerPokemon); + } + // Show health bar and trigger cry + wobbuffet.showInfo(); + scene.time.delayedCall(1000, () => { + wobbuffet.cry(); + }); + wobbuffet.resetSummonData(); + + // Track the HP change across turns + scene.currentBattle.mysteryEncounter!.misc.wobbuffetHealth = wobbuffet.hp; +} + +function summonPlayerPokemonAnimation(scene: BattleScene, pokemon: PlayerPokemon): Promise { + return new Promise(resolve => { + const pokeball = scene.addFieldSprite(36, 80, "pb", getPokeballAtlasKey(pokemon.pokeball)); + pokeball.setVisible(false); + pokeball.setOrigin(0.5, 0.625); + scene.field.add(pokeball); + + pokemon.setFieldPosition(FieldPosition.CENTER, 0); + + const fpOffset = pokemon.getFieldPositionOffset(); + + pokeball.setVisible(true); + + scene.tweens.add({ + targets: pokeball, + duration: 650, + x: 100 + fpOffset[0] + }); + + scene.tweens.add({ + targets: pokeball, + duration: 150, + ease: "Cubic.easeOut", + y: 70 + fpOffset[1], + onComplete: () => { + scene.tweens.add({ + targets: pokeball, + duration: 500, + ease: "Cubic.easeIn", + angle: 1440, + y: 132 + fpOffset[1], + onComplete: () => { + scene.playSound("se/pb_rel"); + pokeball.destroy(); + scene.add.existing(pokemon); + scene.field.add(pokemon); + addPokeballOpenParticles(scene, pokemon.x, pokemon.y - 16, pokemon.pokeball); + scene.updateModifiers(true); + scene.updateFieldScale(); + pokemon.showInfo(); + pokemon.playAnim(); + pokemon.setVisible(true); + pokemon.getSprite().setVisible(true); + pokemon.setScale(0.5); + pokemon.tint(getPokeballTintColor(pokemon.pokeball)); + pokemon.untint(250, "Sine.easeIn"); + scene.updateFieldScale(); + scene.tweens.add({ + targets: pokemon, + duration: 250, + ease: "Sine.easeIn", + scale: pokemon.getSpriteScale(), + onComplete: () => { + pokemon.cry(pokemon.getHpRatio() > 0.25 ? undefined : { rate: 0.85 }); + pokemon.getSprite().clearTint(); + pokemon.resetSummonData(); + scene.time.delayedCall(1000, () => { + if (pokemon.isShiny()) { + scene.unshiftPhase(new ShinySparklePhase(scene, pokemon.getBattlerIndex())); + } + + pokemon.resetTurnData(); + + scene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true); + scene.pushPhase(new PostSummonPhase(scene, pokemon.getBattlerIndex())); + resolve(); + }); + } + }); + } + }); + } + }); + }); +} + +function hideShowmanIntroSprite(scene: BattleScene) { + const carnivalGame = scene.currentBattle.mysteryEncounter!.introVisuals?.getSpriteAtIndex(0)[0]; + const wobbuffet = scene.currentBattle.mysteryEncounter!.introVisuals?.getSpriteAtIndex(1)[0]; + const showMan = scene.currentBattle.mysteryEncounter!.introVisuals?.getSpriteAtIndex(2)[0]; + + // Hide the showman + scene.tweens.add({ + targets: showMan, + x: "+=16", + y: "-=16", + alpha: 0, + ease: "Sine.easeInOut", + duration: 750 + }); + + // Slide the Wobbuffet and Game over slightly + scene.tweens.add({ + targets: [wobbuffet, carnivalGame], + x: "+=16", + ease: "Sine.easeInOut", + duration: 750 + }); +} diff --git a/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts b/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts new file mode 100644 index 000000000000..1c5a1f009d94 --- /dev/null +++ b/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts @@ -0,0 +1,830 @@ +import { leaveEncounterWithoutBattle, selectPokemonForOption, setEncounterRewards } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { TrainerSlot, } from "#app/data/trainer-config"; +import { ModifierTier } from "#app/modifier/modifier-tier"; +import { getPlayerModifierTypeOptions, ModifierPoolType, ModifierTypeOption, regenerateModifierPoolThresholds } from "#app/modifier/modifier-type"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { Species } from "#enums/species"; +import PokemonSpecies, { allSpecies, getPokemonSpecies } from "#app/data/pokemon-species"; +import { getTypeRgb } from "#app/data/type"; +import { MysteryEncounterOptionBuilder } from "#app/data/mystery-encounters/mystery-encounter-option"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { IntegerHolder, isNullOrUndefined, randInt, randSeedInt, randSeedShuffle } from "#app/utils"; +import Pokemon, { EnemyPokemon, PlayerPokemon } from "#app/field/pokemon"; +import { HiddenAbilityRateBoosterModifier, PokemonFormChangeItemModifier, PokemonHeldItemModifier, SpeciesStatBoosterModifier } from "#app/modifier/modifier"; +import { OptionSelectItem } from "#app/ui/abstact-option-select-ui-handler"; +import PokemonData from "#app/system/pokemon-data"; +import i18next from "i18next"; +import { Gender, getGenderSymbol } from "#app/data/gender"; +import { getNatureName } from "#app/data/nature"; +import { getPokeballAtlasKey, getPokeballTintColor, PokeballType } from "#app/data/pokeball"; +import { getEncounterText, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { trainerNamePools } from "#app/data/trainer-names"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for the encounter */ +const namespace = "mysteryEncounter:globalTradeSystem"; + +const LEGENDARY_TRADE_POOLS = { + 1: [Species.RATTATA, Species.PIDGEY, Species.WEEDLE], + 2: [Species.SENTRET, Species.HOOTHOOT, Species.LEDYBA], + 3: [Species.POOCHYENA, Species.ZIGZAGOON, Species.TAILLOW], + 4: [Species.BIDOOF, Species.STARLY, Species.KRICKETOT], + 5: [Species.PATRAT, Species.PURRLOIN, Species.PIDOVE], + 6: [Species.BUNNELBY, Species.LITLEO, Species.SCATTERBUG], + 7: [Species.PIKIPEK, Species.YUNGOOS, Species.ROCKRUFF], + 8: [Species.SKWOVET, Species.WOOLOO, Species.ROOKIDEE], + 9: [Species.LECHONK, Species.FIDOUGH, Species.TAROUNTULA] +}; + +/** Exclude Paradox mons as they aren't considered legendary/mythical */ +const EXCLUDED_TRADE_SPECIES = [ + Species.GREAT_TUSK, + Species.SCREAM_TAIL, + Species.BRUTE_BONNET, + Species.FLUTTER_MANE, + Species.SLITHER_WING, + Species.SANDY_SHOCKS, + Species.ROARING_MOON, + Species.WALKING_WAKE, + Species.GOUGING_FIRE, + Species.RAGING_BOLT, + Species.IRON_TREADS, + Species.IRON_BUNDLE, + Species.IRON_HANDS, + Species.IRON_JUGULIS, + Species.IRON_MOTH, + Species.IRON_THORNS, + Species.IRON_VALIANT, + Species.IRON_LEAVES, + Species.IRON_BOULDER, + Species.IRON_CROWN +]; + +/** + * Global Trade System encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3812 | GitHub Issue #3812} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const GlobalTradeSystemEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.GLOBAL_TRADE_SYSTEM) + .withEncounterTier(MysteryEncounterTier.COMMON) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withAutoHideIntroVisuals(false) + .withIntroSpriteConfigs([ + { + spriteKey: "global_trade_system", + fileRoot: "mystery-encounters", + hasShadow: true, + disableAnimation: true, + x: 3, + y: 5, + yShadow: 1 + } + ]) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + } + ]) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + + // Load bgm + let bgmKey: string; + if (scene.musicPreference === 0) { + bgmKey = "mystery_encounter_gen_5_gts"; + scene.loadBgm(bgmKey, `${bgmKey}.mp3`); + } else { + // Mixed option + bgmKey = "mystery_encounter_gen_6_gts"; + scene.loadBgm(bgmKey, `${bgmKey}.mp3`); + } + + // Load possible trade options + // Maps current party member's id to 3 EnemyPokemon objects + // None of the trade options can be the same species + const tradeOptionsMap: Map = getPokemonTradeOptions(scene); + encounter.misc = { + tradeOptionsMap, + bgmKey + }; + + return true; + }) + .withOnVisualsStart((scene: BattleScene) => { + // Change the bgm + scene.fadeOutBgm(1500, false); + scene.time.delayedCall(1500, () => { + scene.playBgm(scene.currentBattle.mysteryEncounter!.misc.bgmKey); + }); + + return true; + }) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + secondOptionPrompt: `${namespace}.option.1.trade_options_prompt`, + }) + .withPreOptionPhase(async (scene: BattleScene): Promise => { + const encounter = scene.currentBattle.mysteryEncounter!; + const onPokemonSelected = (pokemon: PlayerPokemon) => { + // Get the trade species options for the selected pokemon + const tradeOptionsMap: Map = encounter.misc.tradeOptionsMap; + const tradeOptions = tradeOptionsMap.get(pokemon.id); + if (!tradeOptions) { + return []; + } + + return tradeOptions.map((tradePokemon: EnemyPokemon) => { + const option: OptionSelectItem = { + label: tradePokemon.getNameToRender(), + handler: () => { + // Pokemon trade selected + encounter.setDialogueToken("tradedPokemon", pokemon.getNameToRender()); + encounter.setDialogueToken("received", tradePokemon.getNameToRender()); + encounter.misc.tradedPokemon = pokemon; + encounter.misc.receivedPokemon = tradePokemon; + return true; + }, + onHover: () => { + const formName = tradePokemon.species.forms?.[pokemon.formIndex]?.formName; + const line1 = i18next.t("pokemonInfoContainer:ability") + " " + tradePokemon.getAbility().name + (tradePokemon.getGender() !== Gender.GENDERLESS ? " | " + i18next.t("pokemonInfoContainer:gender") + " " + getGenderSymbol(tradePokemon.getGender()) : ""); + const line2 = i18next.t("pokemonInfoContainer:nature") + " " + getNatureName(tradePokemon.getNature()) + (formName ? " | " + i18next.t("pokemonInfoContainer:form") + " " + formName : ""); + showEncounterText(scene, `${line1}\n${line2}`, 0, 0, false); + }, + }; + return option; + }); + }; + + return selectPokemonForOption(scene, onPokemonSelected); + }) + .withOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + const tradedPokemon: PlayerPokemon = encounter.misc.tradedPokemon; + const receivedPokemonData: EnemyPokemon = encounter.misc.receivedPokemon; + const modifiers = tradedPokemon.getHeldItems().filter(m => !(m instanceof PokemonFormChangeItemModifier) && !(m instanceof SpeciesStatBoosterModifier)); + + // Generate a trainer name + const traderName = generateRandomTraderName(); + encounter.setDialogueToken("tradeTrainerName", traderName.trim()); + + // Remove the original party member from party + scene.removePokemonFromPlayerParty(tradedPokemon, false); + + // Set data properly, then generate the new Pokemon's assets + receivedPokemonData.passive = tradedPokemon.passive; + // Pokeball to Ultra ball, randomly + receivedPokemonData.pokeball = randInt(4) as PokeballType; + const dataSource = new PokemonData(receivedPokemonData); + const newPlayerPokemon = scene.addPlayerPokemon(receivedPokemonData.species, receivedPokemonData.level, dataSource.abilityIndex, dataSource.formIndex, dataSource.gender, dataSource.shiny, dataSource.variant, dataSource.ivs, dataSource.nature, dataSource); + scene.getParty().push(newPlayerPokemon); + await newPlayerPokemon.loadAssets(); + + for (const mod of modifiers) { + mod.pokemonId = newPlayerPokemon.id; + scene.addModifier(mod, true, false, false, true); + } + + // Show the trade animation + await showTradeBackground(scene); + await doPokemonTradeSequence(scene, tradedPokemon, newPlayerPokemon); + await showEncounterText(scene, `${namespace}.trade_received`, null, 0, true, 4000); + scene.playBgm(encounter.misc.bgmKey); + await hideTradeBackground(scene); + tradedPokemon.destroy(); + + leaveEncounterWithoutBattle(scene, true); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + }) + .withPreOptionPhase(async (scene: BattleScene): Promise => { + const encounter = scene.currentBattle.mysteryEncounter!; + const onPokemonSelected = (pokemon: PlayerPokemon) => { + // Randomly generate a Wonder Trade pokemon + // const randomTradeOption = generateTradeOption(scene.getParty().map(p => p.species)); + const randomTradeOption = getPokemonSpecies(Species.BURMY); + const tradePokemon = new EnemyPokemon(scene, randomTradeOption, pokemon.level, TrainerSlot.NONE, false); + // Extra shiny roll at 1/128 odds (boosted by events and charms) + if (!tradePokemon.shiny) { + // 512/65536 -> 1/128 + tradePokemon.trySetShinySeed(512, true); + } + + // Extra HA roll at base 1/64 odds (boosted by events and charms) + if (pokemon.species.abilityHidden) { + const hiddenIndex = pokemon.species.ability2 ? 2 : 1; + if (pokemon.abilityIndex < hiddenIndex) { + const hiddenAbilityChance = new IntegerHolder(64); + scene.applyModifiers(HiddenAbilityRateBoosterModifier, true, hiddenAbilityChance); + + const hasHiddenAbility = !randSeedInt(hiddenAbilityChance.value); + + if (hasHiddenAbility) { + pokemon.abilityIndex = hiddenIndex; + } + } + } + + encounter.setDialogueToken("tradedPokemon", pokemon.getNameToRender()); + encounter.setDialogueToken("received", tradePokemon.getNameToRender()); + encounter.misc.tradedPokemon = pokemon; + encounter.misc.receivedPokemon = tradePokemon; + }; + + return selectPokemonForOption(scene, onPokemonSelected); + }) + .withOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + const tradedPokemon: PlayerPokemon = encounter.misc.tradedPokemon; + const receivedPokemonData: EnemyPokemon = encounter.misc.receivedPokemon; + const modifiers = tradedPokemon.getHeldItems().filter(m => !(m instanceof PokemonFormChangeItemModifier) && !(m instanceof SpeciesStatBoosterModifier)); + + // Generate a trainer name + const traderName = generateRandomTraderName(); + encounter.setDialogueToken("tradeTrainerName", traderName.trim()); + + // Remove the original party member from party + scene.removePokemonFromPlayerParty(tradedPokemon, false); + + // Set data properly, then generate the new Pokemon's assets + receivedPokemonData.passive = tradedPokemon.passive; + receivedPokemonData.pokeball = randInt(4) as PokeballType; + const dataSource = new PokemonData(receivedPokemonData); + const newPlayerPokemon = scene.addPlayerPokemon(receivedPokemonData.species, receivedPokemonData.level, dataSource.abilityIndex, dataSource.formIndex, dataSource.gender, dataSource.shiny, dataSource.variant, dataSource.ivs, dataSource.nature, dataSource); + scene.getParty().push(newPlayerPokemon); + await newPlayerPokemon.loadAssets(); + + for (const mod of modifiers) { + mod.pokemonId = newPlayerPokemon.id; + scene.addModifier(mod, true, false, false, true); + } + + // Show the trade animation + await showTradeBackground(scene); + await doPokemonTradeSequence(scene, tradedPokemon, newPlayerPokemon); + await showEncounterText(scene, `${namespace}.trade_received`, null, 0, true, 4000); + scene.playBgm(scene.currentBattle.mysteryEncounter!.misc.bgmKey); + await hideTradeBackground(scene); + tradedPokemon.destroy(); + + leaveEncounterWithoutBattle(scene, true); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + secondOptionPrompt: `${namespace}.option.3.trade_options_prompt`, + }) + .withPreOptionPhase(async (scene: BattleScene): Promise => { + const encounter = scene.currentBattle.mysteryEncounter!; + const onPokemonSelected = (pokemon: PlayerPokemon) => { + // Get Pokemon held items and filter for valid ones + const validItems = pokemon.getHeldItems().filter((it) => { + return it.isTransferrable; + }); + + return validItems.map((modifier: PokemonHeldItemModifier) => { + const option: OptionSelectItem = { + label: modifier.type.name, + handler: () => { + // Pokemon and item selected + encounter.setDialogueToken("chosenItem", modifier.type.name); + encounter.misc.chosenModifier = modifier; + return true; + }, + }; + return option; + }); + }; + + // Only Pokemon that can gain benefits are above 1/3rd HP with no status + const selectableFilter = (pokemon: Pokemon) => { + // If pokemon has items to trade + const meetsReqs = pokemon.getHeldItems().filter((it) => { + return it.isTransferrable; + }).length > 0; + if (!meetsReqs) { + return getEncounterText(scene, `${namespace}.option.3.invalid_selection`) ?? null; + } + + return null; + }; + + return selectPokemonForOption(scene, onPokemonSelected, undefined, selectableFilter); + }) + .withOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + const modifier = encounter.misc.chosenModifier; + + // Check tier of the traded item, the received item will be one tier up + const type = modifier.type.withTierFromPool(); + let tier = type.tier ?? ModifierTier.GREAT; + // Eggs and White Herb are not in the pool + if (type.id === "WHITE_HERB") { + tier = ModifierTier.GREAT; + } else if (type.id === "LUCKY_EGG") { + tier = ModifierTier.ULTRA; + } else if (type.id === "GOLDEN_EGG") { + tier = ModifierTier.ROGUE; + } + // Increment tier by 1 + if (tier < ModifierTier.MASTER) { + tier++; + } + + regenerateModifierPoolThresholds(scene.getParty(), ModifierPoolType.PLAYER, 0); + let item: ModifierTypeOption | null = null; + // TMs excluded from possible rewards + while (!item || item.type.id.includes("TM_")) { + item = getPlayerModifierTypeOptions(1, scene.getParty(), [], { guaranteedModifierTiers: [tier], allowLuckUpgrades: false })[0]; + } + + encounter.setDialogueToken("itemName", item.type.name); + setEncounterRewards(scene, { guaranteedModifierTypeOptions: [item], fillRemaining: false }); + + // Remove the chosen modifier if its stacks go to 0 + modifier.stackCount -= 1; + if (modifier.stackCount === 0) { + scene.removeModifier(modifier); + } + scene.updateModifiers(true, true); + + // Generate a trainer name + const traderName = generateRandomTraderName(); + encounter.setDialogueToken("tradeTrainerName", traderName.trim()); + await showEncounterText(scene, `${namespace}.item_trade_selected`); + leaveEncounterWithoutBattle(scene); + }) + .build() + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.4.label`, + buttonTooltip: `${namespace}.option.4.tooltip`, + selected: [ + { + text: `${namespace}.option.4.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Leave encounter with no rewards or exp + leaveEncounterWithoutBattle(scene, true); + return true; + } + ) + .build(); + +function getPokemonTradeOptions(scene: BattleScene): Map { + const tradeOptionsMap: Map = new Map(); + // Starts by filtering out any current party members as valid resulting species + const alreadyUsedSpecies: PokemonSpecies[] = scene.getParty().map(p => p.species); + + scene.getParty().forEach(pokemon => { + // If the party member is legendary/mythical, the only trade options available are always pulled from generation-specific legendary trade pools + if (pokemon.species.legendary || pokemon.species.subLegendary || pokemon.species.mythical) { + const generation = pokemon.species.generation; + const tradeOptions: EnemyPokemon[] = LEGENDARY_TRADE_POOLS[generation].map(s => { + const pokemonSpecies = getPokemonSpecies(s); + return new EnemyPokemon(scene, pokemonSpecies, 5, TrainerSlot.NONE, false); + }); + tradeOptionsMap.set(pokemon.id, tradeOptions); + } else { + const originalBst = pokemon.calculateBaseStats().reduce((a, b) => a + b, 0); + + const tradeOptions: PokemonSpecies[] = []; + for (let i = 0; i < 3; i++) { + const speciesTradeOption = generateTradeOption(alreadyUsedSpecies, originalBst); + alreadyUsedSpecies.push(speciesTradeOption); + tradeOptions.push(speciesTradeOption); + } + + // Add trade options to map + tradeOptionsMap.set(pokemon.id, tradeOptions.map(s => { + return new EnemyPokemon(scene, s, pokemon.level, TrainerSlot.NONE, false); + })); + } + }); + + return tradeOptionsMap; +} + +function generateTradeOption(alreadyUsedSpecies: PokemonSpecies[], originalBst?: number): PokemonSpecies { + let newSpecies: PokemonSpecies | undefined; + while (isNullOrUndefined(newSpecies)) { + let bstCap = 9999; + let bstMin = 0; + if (originalBst) { + bstCap = originalBst + 100; + bstMin = originalBst - 100; + } + + // Get all non-legendary species that fall within the Bst range requirements + let validSpecies = allSpecies + .filter(s => { + const isLegendaryOrMythical = s.legendary || s.subLegendary || s.mythical; + const speciesBst = s.getBaseStatTotal(); + const bstInRange = speciesBst >= bstMin && speciesBst <= bstCap; + return !isLegendaryOrMythical && bstInRange && !EXCLUDED_TRADE_SPECIES.includes(s.speciesId); + }); + + // There must be at least 20 species available before it will choose one + if (validSpecies?.length > 20) { + validSpecies = randSeedShuffle(validSpecies); + newSpecies = validSpecies.pop(); + while (isNullOrUndefined(newSpecies) || alreadyUsedSpecies.includes(newSpecies!)) { + newSpecies = validSpecies.pop(); + } + } else { + // Expands search range until at least 20 are in the pool + bstMin -= 10; + bstCap += 10; + } + } + + return newSpecies!; +} + +function showTradeBackground(scene: BattleScene) { + return new Promise(resolve => { + const tradeContainer = scene.add.container(0, -scene.game.canvas.height / 6); + tradeContainer.setName("Trade Background"); + + const flyByStaticBg = scene.add.rectangle(0, 0, scene.game.canvas.width / 6, scene.game.canvas.height / 6, 0); + flyByStaticBg.setName("Black Background"); + flyByStaticBg.setOrigin(0, 0); + flyByStaticBg.setVisible(false); + tradeContainer.add(flyByStaticBg); + + const tradeBaseBg = scene.add.image(0, 0, "default_bg"); + tradeBaseBg.setName("Trade Background Image"); + tradeBaseBg.setOrigin(0, 0); + tradeContainer.add(tradeBaseBg); + + scene.fieldUI.add(tradeContainer); + scene.fieldUI.bringToTop(tradeContainer); + tradeContainer.setVisible(true); + tradeContainer.alpha = 0; + + scene.tweens.add({ + targets: tradeContainer, + alpha: 1, + duration: 500, + ease: "Sine.easeInOut", + onComplete: () => { + resolve(); + } + }); + }); +} + +function hideTradeBackground(scene: BattleScene) { + return new Promise(resolve => { + const transformationContainer = scene.fieldUI.getByName("Trade Background"); + + scene.tweens.add({ + targets: transformationContainer, + alpha: 0, + duration: 1000, + ease: "Sine.easeInOut", + onComplete: () => { + scene.fieldUI.remove(transformationContainer, true); + resolve(); + } + }); + }); +} + +/** + * Initiates an "evolution-like" animation to transform a previousPokemon (presumably from the player's party) into a new one, not necessarily an evolution species. + * @param scene + * @param tradedPokemon + * @param receivedPokemon + */ +function doPokemonTradeSequence(scene: BattleScene, tradedPokemon: PlayerPokemon, receivedPokemon: PlayerPokemon) { + return new Promise(resolve => { + const tradeContainer = scene.fieldUI.getByName("Trade Background") as Phaser.GameObjects.Container; + const tradeBaseBg = tradeContainer.getByName("Trade Background Image") as Phaser.GameObjects.Image; + + let tradedPokemonSprite: Phaser.GameObjects.Sprite; + let tradedPokemonTintSprite: Phaser.GameObjects.Sprite; + let receivedPokemonSprite: Phaser.GameObjects.Sprite; + let receivedPokemonTintSprite: Phaser.GameObjects.Sprite; + + const getPokemonSprite = () => { + const ret = scene.addPokemonSprite(tradedPokemon, tradeBaseBg.displayWidth / 2, tradeBaseBg.displayHeight / 2, "pkmn__sub"); + ret.setPipeline(scene.spritePipeline, { tone: [ 0.0, 0.0, 0.0, 0.0 ], ignoreTimeTint: true }); + return ret; + }; + + tradeContainer.add((tradedPokemonSprite = getPokemonSprite())); + tradeContainer.add((tradedPokemonTintSprite = getPokemonSprite())); + tradeContainer.add((receivedPokemonSprite = getPokemonSprite())); + tradeContainer.add((receivedPokemonTintSprite = getPokemonSprite())); + + tradedPokemonSprite.setAlpha(0); + tradedPokemonTintSprite.setAlpha(0); + tradedPokemonTintSprite.setTintFill(getPokeballTintColor(tradedPokemon.pokeball)); + receivedPokemonSprite.setVisible(false); + receivedPokemonTintSprite.setVisible(false); + receivedPokemonTintSprite.setTintFill(getPokeballTintColor(receivedPokemon.pokeball)); + + [ tradedPokemonSprite, tradedPokemonTintSprite ].map(sprite => { + sprite.play(tradedPokemon.getSpriteKey(true)); + sprite.setPipeline(scene.spritePipeline, { tone: [ 0.0, 0.0, 0.0, 0.0 ], hasShadow: false, teraColor: getTypeRgb(tradedPokemon.getTeraType()) }); + sprite.setPipelineData("ignoreTimeTint", true); + sprite.setPipelineData("spriteKey", tradedPokemon.getSpriteKey()); + sprite.setPipelineData("shiny", tradedPokemon.shiny); + sprite.setPipelineData("variant", tradedPokemon.variant); + [ "spriteColors", "fusionSpriteColors" ].map(k => { + if (tradedPokemon.summonData?.speciesForm) { + k += "Base"; + } + sprite.pipelineData[k] = tradedPokemon.getSprite().pipelineData[k]; + }); + }); + + [ receivedPokemonSprite, receivedPokemonTintSprite ].map(sprite => { + sprite.play(receivedPokemon.getSpriteKey(true)); + sprite.setPipeline(scene.spritePipeline, { tone: [ 0.0, 0.0, 0.0, 0.0 ], hasShadow: false, teraColor: getTypeRgb(tradedPokemon.getTeraType()) }); + sprite.setPipelineData("ignoreTimeTint", true); + sprite.setPipelineData("spriteKey", receivedPokemon.getSpriteKey()); + sprite.setPipelineData("shiny", receivedPokemon.shiny); + sprite.setPipelineData("variant", receivedPokemon.variant); + [ "spriteColors", "fusionSpriteColors" ].map(k => { + if (receivedPokemon.summonData?.speciesForm) { + k += "Base"; + } + sprite.pipelineData[k] = receivedPokemon.getSprite().pipelineData[k]; + }); + }); + + // Traded pokemon pokeball + const tradedPbAtlasKey = getPokeballAtlasKey(tradedPokemon.pokeball); + const tradedPokeball: Phaser.GameObjects.Sprite = scene.add.sprite(tradeBaseBg.displayWidth / 2, tradeBaseBg.displayHeight / 2, "pb", tradedPbAtlasKey); + tradedPokeball.setVisible(false); + tradeContainer.add(tradedPokeball); + + // Received pokemon pokeball + const receivedPbAtlasKey = getPokeballAtlasKey(receivedPokemon.pokeball); + const receivedPokeball: Phaser.GameObjects.Sprite = scene.add.sprite(tradeBaseBg.displayWidth / 2, tradeBaseBg.displayHeight / 2, "pb", receivedPbAtlasKey); + receivedPokeball.setVisible(false); + tradeContainer.add(receivedPokeball); + + scene.tweens.add({ + targets: tradedPokemonSprite, + alpha: 1, + ease: "Cubic.easeInOut", + duration: 500, + onComplete: async () => { + scene.fadeOutBgm(1000, false); + await showEncounterText(scene, `${namespace}.pokemon_trade_selected`); + tradedPokemon.cry(); + scene.playBgm("evolution"); + await showEncounterText(scene, `${namespace}.pokemon_trade_goodbye`); + + tradedPokeball.setAlpha(0); + tradedPokeball.setVisible(true); + scene.tweens.add({ + targets: tradedPokeball, + alpha: 1, + ease: "Cubic.easeInOut", + duration: 250, + onComplete: () => { + tradedPokeball.setTexture("pb", `${tradedPbAtlasKey}_opening`); + scene.time.delayedCall(17, () => tradedPokeball.setTexture("pb", `${tradedPbAtlasKey}_open`)); + scene.playSound("se/pb_rel"); + tradedPokemonTintSprite.setVisible(true); + + // TODO: need to add particles to fieldUI instead of field + // addPokeballOpenParticles(scene, tradedPokemon.x, tradedPokemon.y, tradedPokemon.pokeball); + + scene.tweens.add({ + targets: [tradedPokemonTintSprite, tradedPokemonSprite], + duration: 500, + ease: "Sine.easeIn", + scale: 0.25, + onComplete: () => { + tradedPokemonSprite.setVisible(false); + tradedPokeball.setTexture("pb", `${tradedPbAtlasKey}_opening`); + tradedPokemonTintSprite.setVisible(false); + scene.playSound("se/pb_catch"); + scene.time.delayedCall(17, () => tradedPokeball.setTexture("pb", `${tradedPbAtlasKey}`)); + + scene.tweens.add({ + targets: tradedPokeball, + y: "+=10", + duration: 200, + delay: 250, + ease: "Cubic.easeIn", + onComplete: () => { + scene.playSound("se/pb_bounce_1"); + + scene.tweens.add({ + targets: tradedPokeball, + y: "-=100", + duration: 200, + delay: 1000, + ease: "Cubic.easeInOut", + onStart: () => { + scene.playSound("se/pb_throw"); + }, + onComplete: async () => { + await doPokemonTradeFlyBySequence(scene, tradedPokemonSprite, receivedPokemonSprite); + await doTradeReceivedSequence(scene, receivedPokemon, receivedPokemonSprite, receivedPokemonTintSprite, receivedPokeball, receivedPbAtlasKey); + resolve(); + } + }); + } + }); + } + }); + } + }); + } + }); + }); +} + +function doPokemonTradeFlyBySequence(scene: BattleScene, tradedPokemonSprite: Phaser.GameObjects.Sprite, receivedPokemonSprite: Phaser.GameObjects.Sprite) { + return new Promise(resolve => { + const tradeContainer = scene.fieldUI.getByName("Trade Background") as Phaser.GameObjects.Container; + const tradeBaseBg = tradeContainer.getByName("Trade Background Image") as Phaser.GameObjects.Image; + const flyByStaticBg = tradeContainer.getByName("Black Background") as Phaser.GameObjects.Rectangle; + flyByStaticBg.setVisible(true); + tradeContainer.bringToTop(tradedPokemonSprite); + tradeContainer.bringToTop(receivedPokemonSprite); + + tradedPokemonSprite.x = tradeBaseBg.displayWidth / 4; + tradedPokemonSprite.y = 200; + tradedPokemonSprite.scale = 1; + tradedPokemonSprite.setVisible(true); + receivedPokemonSprite.x = tradeBaseBg.displayWidth * 3 / 4; + receivedPokemonSprite.y = -200; + receivedPokemonSprite.scale = 1; + receivedPokemonSprite.setVisible(true); + + const FADE_DELAY = 300; + const ANIM_DELAY = 750; + const BASE_ANIM_DURATION = 1000; + + // Fade out trade background + scene.tweens.add({ + targets: tradeBaseBg, + alpha: 0, + ease: "Cubic.easeInOut", + duration: FADE_DELAY, + onComplete: () => { + scene.tweens.add({ + targets: [receivedPokemonSprite, tradedPokemonSprite], + y: tradeBaseBg.displayWidth / 2 - 100, + ease: "Cubic.easeInOut", + duration: BASE_ANIM_DURATION * 3, + onComplete: () => { + scene.tweens.add({ + targets: receivedPokemonSprite, + x: tradeBaseBg.displayWidth / 4, + ease: "Cubic.easeInOut", + duration: BASE_ANIM_DURATION / 2, + delay: ANIM_DELAY + }); + scene.tweens.add({ + targets: tradedPokemonSprite, + x: tradeBaseBg.displayWidth * 3 / 4, + ease: "Cubic.easeInOut", + duration: BASE_ANIM_DURATION / 2, + delay: ANIM_DELAY, + onComplete: () => { + scene.tweens.add({ + targets: receivedPokemonSprite, + y: "+=200", + ease: "Cubic.easeInOut", + duration: BASE_ANIM_DURATION * 2, + delay: ANIM_DELAY, + }); + scene.tweens.add({ + targets: tradedPokemonSprite, + y: "-=200", + ease: "Cubic.easeInOut", + duration: BASE_ANIM_DURATION * 2, + delay: ANIM_DELAY, + onComplete: () => { + scene.tweens.add({ + targets: tradeBaseBg, + alpha: 1, + ease: "Cubic.easeInOut", + duration: FADE_DELAY, + onComplete: () => { + resolve(); + } + }); + } + }); + } + }); + } + }); + } + }); + }); +} + +function doTradeReceivedSequence(scene: BattleScene, receivedPokemon: PlayerPokemon, receivedPokemonSprite: Phaser.GameObjects.Sprite, receivedPokemonTintSprite: Phaser.GameObjects.Sprite, receivedPokeballSprite: Phaser.GameObjects.Sprite, receivedPbAtlasKey: string) { + return new Promise(resolve => { + const tradeContainer = scene.fieldUI.getByName("Trade Background") as Phaser.GameObjects.Container; + const tradeBaseBg = tradeContainer.getByName("Trade Background Image") as Phaser.GameObjects.Image; + + receivedPokemonSprite.setVisible(false); + receivedPokemonSprite.x = tradeBaseBg.displayWidth / 2; + receivedPokemonSprite.y = tradeBaseBg.displayHeight / 2; + receivedPokemonTintSprite.setVisible(false); + receivedPokemonTintSprite.x = tradeBaseBg.displayWidth / 2; + receivedPokemonTintSprite.y = tradeBaseBg.displayHeight / 2; + + receivedPokeballSprite.setVisible(true); + receivedPokeballSprite.x = tradeBaseBg.displayWidth / 2; + receivedPokeballSprite.y = tradeBaseBg.displayHeight / 2 - 100; + + const BASE_ANIM_DURATION = 1000; + + // Pokeball falls to the screen + scene.playSound("se/pb_throw"); + scene.tweens.add({ + targets: receivedPokeballSprite, + y: "+=100", + ease: "Cubic.easeInOut", + duration: BASE_ANIM_DURATION, + onComplete: () => { + scene.playSound("se/pb_bounce_1"); + scene.time.delayedCall(100, () => scene.playSound("se/pb_bounce_1")); + + scene.time.delayedCall(2000, () => { + scene.playSound("se/pb_rel"); + scene.fadeOutBgm(500, false); + receivedPokemon.cry(); + receivedPokemonTintSprite.scale = 0.25; + receivedPokemonTintSprite.alpha = 1; + receivedPokemonSprite.setVisible(true); + receivedPokemonSprite.scale = 0.25; + receivedPokemonTintSprite.alpha = 1; + receivedPokemonTintSprite.setVisible(true); + receivedPokeballSprite.setTexture("pb", `${receivedPbAtlasKey}_opening`); + scene.time.delayedCall(17, () => receivedPokeballSprite.setTexture("pb", `${receivedPbAtlasKey}_open`)); + scene.tweens.add({ + targets: receivedPokemonSprite, + duration: 250, + ease: "Sine.easeOut", + scale: 1 + }); + scene.tweens.add({ + targets: receivedPokemonTintSprite, + duration: 250, + ease: "Sine.easeOut", + scale: 1, + alpha: 0, + onComplete: () => { + receivedPokeballSprite.destroy(); + scene.time.delayedCall(2000, () => resolve()); + } + }); + }); + } + }); + }); +} + +function generateRandomTraderName() { + const length = Object.keys(trainerNamePools).length; + // +1 avoids TrainerType.UNKNOWN + let trainerTypePool = trainerNamePools[randInt(length) + 1]; + while (!trainerTypePool) { + trainerTypePool = trainerNamePools[randInt(length) + 1]; + } + // Some trainers have 2 gendered pools, some do not + const genderedPool = trainerTypePool[randInt(trainerTypePool.length)]; + const trainerNameString = genderedPool instanceof Array ? genderedPool[randInt(genderedPool.length)] : genderedPool; + // Some names have an '&' symbol and need to be trimmed to a single name instead of a double name + const trainerNames = trainerNameString.split(" & "); + return trainerNames[randInt(trainerNames.length)]; +} diff --git a/src/data/mystery-encounters/encounters/lost-at-sea-encounter.ts b/src/data/mystery-encounters/encounters/lost-at-sea-encounter.ts new file mode 100644 index 000000000000..0f0538d7542f --- /dev/null +++ b/src/data/mystery-encounters/encounters/lost-at-sea-encounter.ts @@ -0,0 +1,142 @@ +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { Moves } from "#app/enums/moves"; +import { Species } from "#app/enums/species"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterOptionBuilder } from "../mystery-encounter-option"; +import { leaveEncounterWithoutBattle, setEncounterExp } from "../utils/encounter-phase-utils"; +import { applyDamageToPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +const OPTION_1_REQUIRED_MOVE = Moves.SURF; +const OPTION_2_REQUIRED_MOVE = Moves.FLY; +/** + * Damage percentage taken when wandering aimlessly. + * Can be a number between `0` - `100`. + * The higher the more damage taken (100% = instant KO). + */ +const DAMAGE_PERCENTAGE: number = 25; +/** The i18n namespace for the encounter */ +const namespace = "mysteryEncounter:lostAtSea"; + +/** + * Lost at sea encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3793 | GitHub Issue #3793} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const LostAtSeaEncounter: MysteryEncounter = MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.LOST_AT_SEA) + .withEncounterTier(MysteryEncounterTier.COMMON) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withIntroSpriteConfigs([ + { + spriteKey: "buoy", + fileRoot: "mystery-encounters", + hasShadow: false, + x: 20, + y: 3, + }, + ]) + .withIntroDialogue([{ text: `${namespace}.intro` }]) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + + encounter.setDialogueToken("damagePercentage", String(DAMAGE_PERCENTAGE)); + encounter.setDialogueToken("option1RequiredMove", Moves[OPTION_1_REQUIRED_MOVE]); + encounter.setDialogueToken("option2RequiredMove", Moves[OPTION_2_REQUIRED_MOVE]); + + return true; + }) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOption( + // Option 1: Use a (non fainted) pokemon that can learn Surf to guide you back/ + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT) + .withPokemonCanLearnMoveRequirement(OPTION_1_REQUIRED_MOVE) + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + disabledButtonLabel: `${namespace}.option.1.label_disabled`, + buttonTooltip: `${namespace}.option.1.tooltip`, + disabledButtonTooltip: `${namespace}.option.1.tooltip_disabled`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }) + .withOptionPhase(async (scene: BattleScene) => handlePokemonGuidingYouPhase(scene)) + .build() + ) + .withOption( + //Option 2: Use a (non fainted) pokemon that can learn fly to guide you back. + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT) + .withPokemonCanLearnMoveRequirement(OPTION_2_REQUIRED_MOVE) + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + disabledButtonLabel: `${namespace}.option.2.label_disabled`, + buttonTooltip: `${namespace}.option.2.tooltip`, + disabledButtonTooltip: `${namespace}.option.2.tooltip_disabled`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }) + .withOptionPhase(async (scene: BattleScene) => handlePokemonGuidingYouPhase(scene)) + .build() + ) + .withSimpleOption( + // Option 3: Wander aimlessly + { + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + ], + }, + async (scene: BattleScene) => { + const allowedPokemon = scene.getParty().filter((p) => p.isAllowedInBattle()); + + for (const pkm of allowedPokemon) { + const percentage = DAMAGE_PERCENTAGE / 100; + const damage = Math.floor(pkm.getMaxHp() * percentage); + applyDamageToPokemon(scene, pkm, damage); + } + + leaveEncounterWithoutBattle(scene); + + return true; + } + ) + .withOutroDialogue([ + { + text: `${namespace}.outro`, + }, + ]) + .build(); + +/** + * Generic handler for using a guiding pokemon to guide you back. + * + * @param scene Battle scene + */ +async function handlePokemonGuidingYouPhase(scene: BattleScene) { + const laprasSpecies = getPokemonSpecies(Species.LAPRAS); + const { mysteryEncounter } = scene.currentBattle; + + if (mysteryEncounter?.selectedOption?.primaryPokemon?.id) { + setEncounterExp(scene, mysteryEncounter.selectedOption.primaryPokemon.id, laprasSpecies.baseExp, true); + } else { + console.warn("Lost at sea: No guide pokemon found but pokemon guides player. huh!?"); + } + + leaveEncounterWithoutBattle(scene); + return true; +} diff --git a/src/data/mystery-encounters/encounters/mysterious-challengers-encounter.ts b/src/data/mystery-encounters/encounters/mysterious-challengers-encounter.ts new file mode 100644 index 000000000000..230f5242a364 --- /dev/null +++ b/src/data/mystery-encounters/encounters/mysterious-challengers-encounter.ts @@ -0,0 +1,213 @@ +import { + EnemyPartyConfig, + initBattleWithEnemyConfig, + setEncounterRewards, +} from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { + trainerConfigs, + TrainerPartyCompoundTemplate, + TrainerPartyTemplate, + trainerPartyTemplates, +} from "#app/data/trainer-config"; +import { ModifierTier } from "#app/modifier/modifier-tier"; +import { modifierTypes } from "#app/modifier/modifier-type"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { PartyMemberStrength } from "#enums/party-member-strength"; +import BattleScene from "#app/battle-scene"; +import * as Utils from "#app/utils"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for the encounter */ +const namespace = "mysteryEncounter:mysteriousChallengers"; + +/** + * Mysterious Challengers encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3801 | GitHub Issue #3801} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const MysteriousChallengersEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.MYSTERIOUS_CHALLENGERS) + .withEncounterTier(MysteryEncounterTier.GREAT) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withIntroSpriteConfigs([]) // These are set in onInit() + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + ]) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + // Calculates what trainers are available for battle in the encounter + + // Normal difficulty trainer is randomly pulled from biome + const normalTrainerType = scene.arena.randomTrainerType(scene.currentBattle.waveIndex); + const normalConfig = trainerConfigs[normalTrainerType].clone(); + let female = false; + if (normalConfig.hasGenders) { + female = !!Utils.randSeedInt(2); + } + const normalSpriteKey = normalConfig.getSpriteKey(female, normalConfig.doubleOnly); + encounter.enemyPartyConfigs.push({ + trainerConfig: normalConfig, + female: female, + }); + + // Hard difficulty trainer is another random trainer, but with AVERAGE_BALANCED config + // Number of mons is based off wave: 1-20 is 2, 20-40 is 3, etc. capping at 6 after wave 100 + const hardTrainerType = scene.arena.randomTrainerType(scene.currentBattle.waveIndex); + const hardTemplate = new TrainerPartyCompoundTemplate( + new TrainerPartyTemplate(1, PartyMemberStrength.STRONGER, false, true), + new TrainerPartyTemplate( + Math.min(Math.ceil(scene.currentBattle.waveIndex / 20), 5), + PartyMemberStrength.AVERAGE, + false, + true + ) + ); + const hardConfig = trainerConfigs[hardTrainerType].clone(); + hardConfig.setPartyTemplates(hardTemplate); + female = false; + if (hardConfig.hasGenders) { + female = !!Utils.randSeedInt(2); + } + const hardSpriteKey = hardConfig.getSpriteKey(female, hardConfig.doubleOnly); + encounter.enemyPartyConfigs.push({ + trainerConfig: hardConfig, + levelAdditiveMultiplier: 1, + female: female, + }); + + // Brutal trainer is pulled from pool of boss trainers (gym leaders) for the biome + // They are given an E4 template team, so will be stronger than usual boss encounter and always have 6 mons + const brutalTrainerType = scene.arena.randomTrainerType( + scene.currentBattle.waveIndex, + true + ); + const e4Template = trainerPartyTemplates.ELITE_FOUR; + const brutalConfig = trainerConfigs[brutalTrainerType].clone(); + brutalConfig.setPartyTemplates(e4Template); + // @ts-ignore + brutalConfig.partyTemplateFunc = null; // Overrides gym leader party template func + female = false; + if (brutalConfig.hasGenders) { + female = !!Utils.randSeedInt(2); + } + const brutalSpriteKey = brutalConfig.getSpriteKey(female, brutalConfig.doubleOnly); + encounter.enemyPartyConfigs.push({ + trainerConfig: brutalConfig, + levelAdditiveMultiplier: 1.5, + female: female, + }); + + encounter.spriteConfigs = [ + { + spriteKey: normalSpriteKey, + fileRoot: "trainer", + hasShadow: true, + tint: 1, + }, + { + spriteKey: hardSpriteKey, + fileRoot: "trainer", + hasShadow: true, + tint: 1, + }, + { + spriteKey: brutalSpriteKey, + fileRoot: "trainer", + hasShadow: true, + tint: 1, + }, + ]; + + return true; + }) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.selected`, + }, + ], + }, + async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + // Spawn standard trainer battle with memory mushroom reward + const config: EnemyPartyConfig = encounter.enemyPartyConfigs[0]; + + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: [modifierTypes.TM_COMMON, modifierTypes.TM_GREAT, modifierTypes.MEMORY_MUSHROOM], fillRemaining: true }); + + // Seed offsets to remove possibility of different trainers having exact same teams + let ret; + scene.executeWithSeedOffset(() => { + ret = initBattleWithEnemyConfig(scene, config); + }, scene.currentBattle.waveIndex * 10); + return ret; + } + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.selected`, + }, + ], + }, + async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + // Spawn hard fight + const config: EnemyPartyConfig = encounter.enemyPartyConfigs[1]; + + setEncounterRewards(scene, { guaranteedModifierTiers: [ModifierTier.ULTRA, ModifierTier.ULTRA, ModifierTier.GREAT, ModifierTier.GREAT], fillRemaining: true }); + + // Seed offsets to remove possibility of different trainers having exact same teams + let ret; + scene.executeWithSeedOffset(() => { + ret = initBattleWithEnemyConfig(scene, config); + }, scene.currentBattle.waveIndex * 100); + return ret; + } + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + selected: [ + { + text: `${namespace}.option.selected`, + }, + ], + }, + async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + // Spawn brutal fight + const config: EnemyPartyConfig = encounter.enemyPartyConfigs[2]; + + // To avoid player level snowballing from picking this option + encounter.expMultiplier = 0.9; + + setEncounterRewards(scene, { guaranteedModifierTiers: [ModifierTier.ROGUE, ModifierTier.ROGUE, ModifierTier.ULTRA, ModifierTier.GREAT], fillRemaining: true }); + + // Seed offsets to remove possibility of different trainers having exact same teams + let ret; + scene.executeWithSeedOffset(() => { + ret = initBattleWithEnemyConfig(scene, config); + }, scene.currentBattle.waveIndex * 1000); + return ret; + } + ) + .withOutroDialogue([ + { + text: `${namespace}.outro`, + }, + ]) + .build(); diff --git a/src/data/mystery-encounters/encounters/mysterious-chest-encounter.ts b/src/data/mystery-encounters/encounters/mysterious-chest-encounter.ts new file mode 100644 index 000000000000..26e846ed8744 --- /dev/null +++ b/src/data/mystery-encounters/encounters/mysterious-chest-encounter.ts @@ -0,0 +1,204 @@ +import { queueEncounterMessage, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { EnemyPartyConfig, initBattleWithEnemyConfig, leaveEncounterWithoutBattle, setEncounterRewards, transitionMysteryEncounterIntroVisuals } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { getHighestLevelPlayerPokemon, koPlayerPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { ModifierTier } from "#app/modifier/modifier-tier"; +import { randSeedInt } from "#app/utils"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterOptionBuilder } from "../mystery-encounter-option"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { Species } from "#enums/species"; +import { Moves } from "#enums/moves"; +import { GameOverPhase } from "#app/phases/game-over-phase"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** i18n namespace for encounter */ +const namespace = "mysteryEncounter:mysteriousChest"; + +const RAND_LENGTH = 100; +const COMMON_REWARDS_WEIGHT = 20; // 20% +const ULTRA_REWARDS_WEIGHT = 50; // 30% +const ROGUE_REWARDS_WEIGHT = 60; // 10% +const MASTER_REWARDS_WEIGHT = 65; // 5% + +/** + * Mysterious Chest encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3796 | GitHub Issue #3796} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const MysteriousChestEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.MYSTERIOUS_CHEST) + .withEncounterTier(MysteryEncounterTier.COMMON) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withAutoHideIntroVisuals(false) + .withCatchAllowed(true) + .withIntroSpriteConfigs([ + { + spriteKey: "chest_blue", + fileRoot: "mystery-encounters", + hasShadow: true, + y: 8, + yShadow: 6, + alpha: 1, + disableAnimation: true, // Re-enabled after option select + }, + { + spriteKey: "chest_red", + fileRoot: "mystery-encounters", + hasShadow: false, + y: 8, + yShadow: 6, + alpha: 0, + disableAnimation: true, // Re-enabled after option select + } + ]) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + } + ]) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + + // Calculate boss mon + const config: EnemyPartyConfig = { + levelAdditiveMultiplier: 0.5, + disableSwitch: true, + pokemonConfigs: [ + { + species: getPokemonSpecies(Species.GIMMIGHOUL), + formIndex: 0, + isBoss: true, + moveSet: [Moves.NASTY_PLOT, Moves.SHADOW_BALL, Moves.POWER_GEM, Moves.THIEF] + } + ], + }; + + encounter.enemyPartyConfigs = [config]; + + encounter.setDialogueToken("gimmighoulName", getPokemonSpecies(Species.GIMMIGHOUL).getName()); + + return true; + }) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene) => { + // Play animation + const encounter = scene.currentBattle.mysteryEncounter!; + const introVisuals = encounter.introVisuals!; + + // Determine roll first + const roll = randSeedInt(RAND_LENGTH); + encounter.misc = { + roll + }; + + if (roll >= MASTER_REWARDS_WEIGHT) { + // Chest is springing trap, change to red chest sprite + const blueChestSprites = introVisuals.getSpriteAtIndex(0); + const redChestSprites = introVisuals.getSpriteAtIndex(1); + redChestSprites[0].setAlpha(1); + blueChestSprites[0].setAlpha(0.001); + } + introVisuals.spriteConfigs[0].disableAnimation = false; + introVisuals.spriteConfigs[1].disableAnimation = false; + introVisuals.playAnim(); + }) + .withOptionPhase(async (scene: BattleScene) => { + // Open the chest + const encounter = scene.currentBattle.mysteryEncounter!; + const roll = encounter.misc.roll; + if (roll < COMMON_REWARDS_WEIGHT) { + // Choose between 2 COMMON / 2 GREAT tier items (20%) + setEncounterRewards(scene, { + guaranteedModifierTiers: [ + ModifierTier.COMMON, + ModifierTier.COMMON, + ModifierTier.GREAT, + ModifierTier.GREAT, + ], + }); + // Display result message then proceed to rewards + queueEncounterMessage(scene, `${namespace}.option.1.normal`); + leaveEncounterWithoutBattle(scene); + } else if (roll < ULTRA_REWARDS_WEIGHT) { + // Choose between 3 ULTRA tier items (30%) + setEncounterRewards(scene, { + guaranteedModifierTiers: [ + ModifierTier.ULTRA, + ModifierTier.ULTRA, + ModifierTier.ULTRA, + ], + }); + // Display result message then proceed to rewards + queueEncounterMessage(scene, `${namespace}.option.1.good`); + leaveEncounterWithoutBattle(scene); + } else if (roll < ROGUE_REWARDS_WEIGHT) { + // Choose between 2 ROGUE tier items (10%) + setEncounterRewards(scene, { guaranteedModifierTiers: [ModifierTier.ROGUE, ModifierTier.ROGUE] }); + // Display result message then proceed to rewards + queueEncounterMessage(scene, `${namespace}.option.1.great`); + leaveEncounterWithoutBattle(scene); + } else if (roll < MASTER_REWARDS_WEIGHT) { + // Choose 1 MASTER tier item (5%) + setEncounterRewards(scene, { guaranteedModifierTiers: [ModifierTier.MASTER] }); + // Display result message then proceed to rewards + queueEncounterMessage(scene, `${namespace}.option.1.amazing`); + leaveEncounterWithoutBattle(scene); + } else { + // Your highest level unfainted Pokemon gets OHKO. Start battle against a Gimmighoul (35%) + const highestLevelPokemon = getHighestLevelPlayerPokemon( + scene, + true + ); + koPlayerPokemon(scene, highestLevelPokemon); + // Handle game over edge case + const allowedPokemon = scene.getParty().filter(p => p.isAllowedInBattle()); + if (allowedPokemon.length === 0) { + // If there are no longer any legal pokemon in the party, game over. + scene.clearPhaseQueue(); + scene.unshiftPhase(new GameOverPhase(scene)); + } else { + // Show which Pokemon was KOed, then start battle against Gimmighoul + encounter.setDialogueToken("pokeName", highestLevelPokemon.getNameToRender()); + await showEncounterText(scene, `${namespace}.option.1.bad`); + transitionMysteryEncounterIntroVisuals(scene, true, true, 500); + await initBattleWithEnemyConfig(scene, encounter.enemyPartyConfigs[0]); + } + } + }) + .build() + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Leave encounter with no rewards or exp + leaveEncounterWithoutBattle(scene, true); + return true; + } + ) + .build(); diff --git a/src/data/mystery-encounters/encounters/part-timer-encounter.ts b/src/data/mystery-encounters/encounters/part-timer-encounter.ts new file mode 100644 index 000000000000..2abbb53c333b --- /dev/null +++ b/src/data/mystery-encounters/encounters/part-timer-encounter.ts @@ -0,0 +1,340 @@ +import { MysteryEncounterOptionBuilder } from "#app/data/mystery-encounters/mystery-encounter-option"; +import { leaveEncounterWithoutBattle, selectPokemonForOption, setEncounterExp, setEncounterRewards, transitionMysteryEncounterIntroVisuals, updatePlayerMoney } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MoveRequirement } from "../mystery-encounter-requirements"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { Stat } from "#enums/stat"; +import { CHARMING_MOVES } from "#app/data/mystery-encounters/requirements/requirement-groups"; +import { getEncounterText, showEncounterDialogue, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import i18next from "i18next"; +import Pokemon, { PlayerPokemon } from "#app/field/pokemon"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for the encounter */ +const namespace = "mysteryEncounter:partTimer"; + +/** + * Part Timer encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3813 | GitHub Issue #3813} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const PartTimerEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.PART_TIMER) + .withEncounterTier(MysteryEncounterTier.COMMON) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withIntroSpriteConfigs([ + { + spriteKey: "warehouse_crate", + fileRoot: "mystery-encounters", + hasShadow: false, + y: 6, + x: 15 + }, + { + spriteKey: "worker_f", + fileRoot: "trainer", + hasShadow: true, + x: -18, + y: 4 + } + ]) + .withAutoHideIntroVisuals(false) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.intro_dialogue`, + }, + ]) + .withOnInit((scene: BattleScene) => { + // Load sfx + scene.loadSe("PRSFX- Horn Drill1", "battle_anims", "PRSFX- Horn Drill1.wav"); + scene.loadSe("PRSFX- Horn Drill3", "battle_anims", "PRSFX- Horn Drill3.wav"); + scene.loadSe("PRSFX- Guillotine2", "battle_anims", "PRSFX- Guillotine2.wav"); + scene.loadSe("PRSFX- Heavy Slam2", "battle_anims", "PRSFX- Heavy Slam2.wav"); + + scene.loadSe("PRSFX- Agility", "battle_anims", "PRSFX- Agility.wav"); + scene.loadSe("PRSFX- Extremespeed1", "battle_anims", "PRSFX- Extremespeed1.wav"); + scene.loadSe("PRSFX- Accelerock1", "battle_anims", "PRSFX- Accelerock1.wav"); + + scene.loadSe("PRSFX- Captivate", "battle_anims", "PRSFX- Captivate.wav"); + scene.loadSe("PRSFX- Attract2", "battle_anims", "PRSFX- Attract2.wav"); + scene.loadSe("PRSFX- Aurora Veil2", "battle_anims", "PRSFX- Aurora Veil2.wav"); + + return true; + }) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOption(MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected` + } + ] + }) + .withPreOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + + const onPokemonSelected = (pokemon: PlayerPokemon) => { + encounter.setDialogueToken("selectedPokemon", pokemon.getNameToRender()); + + // Calculate the "baseline" stat value (90 base stat, 16 IVs, neutral nature, same level as pokemon) to compare + // Resulting money is 2.5 * (% difference from baseline), with minimum of 1 and maximum of 4. + // Calculation from Pokemon.calculateStats + const baselineValue = Math.floor(((2 * 90 + 16) * pokemon.level) * 0.01) + 5; + const percentDiff = (pokemon.getStat(Stat.SPD) - baselineValue) / baselineValue; + const moneyMultiplier = Math.min(Math.max(2.5 * (1+ percentDiff), 1), 4); + + encounter.misc = { + moneyMultiplier + }; + + // Reduce all PP to 2 (if they started at greater than 2) + pokemon.moveset.forEach(move => { + if (move) { + const newPpUsed = move.getMovePp() - 2; + move.ppUsed = move.ppUsed < newPpUsed ? newPpUsed : move.ppUsed; + } + }); + + setEncounterExp(scene, pokemon.id, 100); + + // Hide intro visuals + transitionMysteryEncounterIntroVisuals(scene, true, false); + // Play sfx for "working" + doDeliverySfx(scene); + }; + + // Only Pokemon non-KOd pokemon can be selected + const selectableFilter = (pokemon: Pokemon) => { + if (!pokemon.isAllowedInBattle()) { + return getEncounterText(scene, `${namespace}.invalid_selection`) ?? null; + } + + return null; + }; + + return selectPokemonForOption(scene, onPokemonSelected, undefined, selectableFilter); + }) + .withOptionPhase(async (scene: BattleScene) => { + // Pick Deliveries + // Bring visuals back in + await transitionMysteryEncounterIntroVisuals(scene, false, false); + + const moneyMultiplier = scene.currentBattle.mysteryEncounter!.misc.moneyMultiplier; + + // Give money and do dialogue + if (moneyMultiplier > 2.5) { + await showEncounterDialogue(scene, `${namespace}.job_complete_good`, `${namespace}.speaker`); + } else { + await showEncounterDialogue(scene, `${namespace}.job_complete_bad`, `${namespace}.speaker`); + } + const moneyChange = scene.getWaveMoneyAmount(moneyMultiplier); + updatePlayerMoney(scene, moneyChange, true, false); + await showEncounterText(scene, i18next.t("mysteryEncounterMessages:receive_money", { amount: moneyChange })); + await showEncounterText(scene, `${namespace}.pokemon_tired`); + + setEncounterRewards(scene, { fillRemaining: true }); + leaveEncounterWithoutBattle(scene); + }) + .build() + ) + .withOption(MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected` + } + ] + }) + .withPreOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + + const onPokemonSelected = (pokemon: PlayerPokemon) => { + encounter.setDialogueToken("selectedPokemon", pokemon.getNameToRender()); + + // Calculate the "baseline" stat value (75 base stat, 16 IVs, neutral nature, same level as pokemon) to compare + // Resulting money is 2.5 * (% difference from baseline), with minimum of 1 and maximum of 4. + // Calculation from Pokemon.calculateStats + const baselineHp = Math.floor(((2 * 75 + 16) * pokemon.level) * 0.01) + pokemon.level + 10; + const baselineAtkDef = Math.floor(((2 * 75 + 16) * pokemon.level) * 0.01) + 5; + const baselineValue = baselineHp + 1.5 * (baselineAtkDef * 2); + const strongestValue = pokemon.getStat(Stat.HP) + 1.5 * (pokemon.getStat(Stat.ATK) + pokemon.getStat(Stat.DEF)); + const percentDiff = (strongestValue - baselineValue) / baselineValue; + const moneyMultiplier = Math.min(Math.max(2.5 * (1 + percentDiff), 1), 4); + + encounter.misc = { + moneyMultiplier + }; + + // Reduce all PP to 2 (if they started at greater than 2) + pokemon.moveset.forEach(move => { + if (move) { + const newPpUsed = move.getMovePp() - 2; + move.ppUsed = move.ppUsed < newPpUsed ? newPpUsed : move.ppUsed; + } + }); + + setEncounterExp(scene, pokemon.id, 100); + + // Hide intro visuals + transitionMysteryEncounterIntroVisuals(scene, true, false); + // Play sfx for "working" + doStrongWorkSfx(scene); + }; + + // Only Pokemon non-KOd pokemon can be selected + const selectableFilter = (pokemon: Pokemon) => { + if (!pokemon.isAllowedInBattle()) { + return getEncounterText(scene, `${namespace}.invalid_selection`) ?? null; + } + + return null; + }; + + return selectPokemonForOption(scene, onPokemonSelected, undefined, selectableFilter); + }) + .withOptionPhase(async (scene: BattleScene) => { + // Pick Move Warehouse items + // Bring visuals back in + await transitionMysteryEncounterIntroVisuals(scene, false, false); + + const moneyMultiplier = scene.currentBattle.mysteryEncounter!.misc.moneyMultiplier; + + // Give money and do dialogue + if (moneyMultiplier > 2.5) { + await showEncounterDialogue(scene, `${namespace}.job_complete_good`, `${namespace}.speaker`); + } else { + await showEncounterDialogue(scene, `${namespace}.job_complete_bad`, `${namespace}.speaker`); + } + const moneyChange = scene.getWaveMoneyAmount(moneyMultiplier); + updatePlayerMoney(scene, moneyChange, true, false); + await showEncounterText(scene, i18next.t("mysteryEncounterMessages:receive_money", { amount: moneyChange })); + await showEncounterText(scene, `${namespace}.pokemon_tired`); + + setEncounterRewards(scene, { fillRemaining: true }); + leaveEncounterWithoutBattle(scene); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL) + .withPrimaryPokemonRequirement(new MoveRequirement(CHARMING_MOVES)) // Will set option3PrimaryName and option3PrimaryMove dialogue tokens automatically + .withDialogue({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + disabledButtonTooltip: `${namespace}.option.3.disabled_tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + const selectedPokemon = encounter.selectedOption?.primaryPokemon!; + encounter.setDialogueToken("selectedPokemon", selectedPokemon.getNameToRender()); + + // Reduce all PP to 2 (if they started at greater than 2) + selectedPokemon.moveset.forEach(move => { + if (move) { + const newPpUsed = move.getMovePp() - 2; + move.ppUsed = move.ppUsed < newPpUsed ? newPpUsed : move.ppUsed; + } + }); + + setEncounterExp(scene, selectedPokemon.id, 100); + + // Hide intro visuals + transitionMysteryEncounterIntroVisuals(scene, true, false); + // Play sfx for "working" + doSalesSfx(scene); + return true; + }) + .withOptionPhase(async (scene: BattleScene) => { + // Assist with Sales + // Bring visuals back in + await transitionMysteryEncounterIntroVisuals(scene, false, false); + + // Give money and do dialogue + await showEncounterDialogue(scene, `${namespace}.job_complete_good`, `${namespace}.speaker`); + const moneyChange = scene.getWaveMoneyAmount(2.5); + updatePlayerMoney(scene, moneyChange, true, false); + await showEncounterText(scene, i18next.t("mysteryEncounterMessages:receive_money", { amount: moneyChange })); + await showEncounterText(scene, `${namespace}.pokemon_tired`); + + setEncounterRewards(scene, { fillRemaining: true }); + leaveEncounterWithoutBattle(scene); + }) + .build() + ) + .withOutroDialogue([ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.outro`, + } + ]) + .build(); + +function doStrongWorkSfx(scene: BattleScene) { + scene.playSound("battle_anims/PRSFX- Horn Drill1"); + scene.playSound("battle_anims/PRSFX- Horn Drill1"); + + scene.time.delayedCall(1000, () => { + scene.playSound("battle_anims/PRSFX- Guillotine2"); + }); + + scene.time.delayedCall(2000, () => { + scene.playSound("battle_anims/PRSFX- Heavy Slam2"); + }); + + scene.time.delayedCall(2500, () => { + scene.playSound("battle_anims/PRSFX- Guillotine2"); + }); +} + +function doDeliverySfx(scene: BattleScene) { + scene.playSound("battle_anims/PRSFX- Accelerock1"); + + scene.time.delayedCall(1500, () => { + scene.playSound("battle_anims/PRSFX- Extremespeed1"); + }); + + scene.time.delayedCall(2000, () => { + scene.playSound("battle_anims/PRSFX- Extremespeed1"); + }); + + scene.time.delayedCall(2250, () => { + scene.playSound("battle_anims/PRSFX- Agility"); + }); +} + +function doSalesSfx(scene: BattleScene) { + scene.playSound("battle_anims/PRSFX- Captivate"); + + scene.time.delayedCall(1500, () => { + scene.playSound("battle_anims/PRSFX- Attract2"); + }); + + scene.time.delayedCall(2000, () => { + scene.playSound("battle_anims/PRSFX- Aurora Veil2"); + }); + + scene.time.delayedCall(3000, () => { + scene.playSound("battle_anims/PRSFX- Attract2"); + }); +} diff --git a/src/data/mystery-encounters/encounters/safari-zone-encounter.ts b/src/data/mystery-encounters/encounters/safari-zone-encounter.ts new file mode 100644 index 000000000000..49abf98cf49e --- /dev/null +++ b/src/data/mystery-encounters/encounters/safari-zone-encounter.ts @@ -0,0 +1,518 @@ +import { initSubsequentOptionSelect, leaveEncounterWithoutBattle, transitionMysteryEncounterIntroVisuals, updatePlayerMoney, } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import MysteryEncounterOption, { MysteryEncounterOptionBuilder } from "#app/data/mystery-encounters/mystery-encounter-option"; +import { TrainerSlot } from "#app/data/trainer-config"; +import { HiddenAbilityRateBoosterModifier, IvScannerModifier } from "#app/modifier/modifier"; +import { EnemyPokemon } from "#app/field/pokemon"; +import { PokeballType } from "#app/data/pokeball"; +import { PlayerGender } from "#enums/player-gender"; +import { IntegerHolder, randSeedInt } from "#app/utils"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { MoneyRequirement } from "#app/data/mystery-encounters/mystery-encounter-requirements"; +import { doPlayerFlee, doPokemonFlee, getRandomSpeciesByStarterTier, trainerThrowPokeball } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { getEncounterText, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { getPokemonNameWithAffix } from "#app/messages"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { ScanIvsPhase } from "#app/phases/scan-ivs-phase"; +import { SummonPhase } from "#app/phases/summon-phase"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for the encounter */ +const namespace = "mysteryEncounter:safariZone"; + +const TRAINER_THROW_ANIMATION_TIMES = [512, 184, 768]; + +const SAFARI_MONEY_MULTIPLIER = 2.75; + +/** + * Safari Zone encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3800 | GitHub Issue #3800} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const SafariZoneEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.SAFARI_ZONE) + .withEncounterTier(MysteryEncounterTier.GREAT) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withSceneRequirement(new MoneyRequirement(0, SAFARI_MONEY_MULTIPLIER)) // Cost equal to 1 Max Revive + .withAutoHideIntroVisuals(false) + .withIntroSpriteConfigs([ + { + spriteKey: "safari_zone", + fileRoot: "mystery-encounters", + hasShadow: false, + x: 4, + y: 6 + }, + ]) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + ]) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOption(MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT) + .withSceneRequirement(new MoneyRequirement(0, SAFARI_MONEY_MULTIPLIER)) // Cost equal to 1 Max Revive + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }) + .withOptionPhase(async (scene: BattleScene) => { + // Start safari encounter + const encounter = scene.currentBattle.mysteryEncounter!; + encounter.continuousEncounter = true; + encounter.misc = { + safariPokemonRemaining: 3 + }; + updatePlayerMoney(scene, -(encounter.options[0].requirements[0] as MoneyRequirement).requiredMoney); + // Load bait/mud assets + scene.loadSe("PRSFX- Bug Bite", "battle_anims", "PRSFX- Bug Bite.wav"); + scene.loadSe("PRSFX- Sludge Bomb2", "battle_anims", "PRSFX- Sludge Bomb2.wav"); + scene.loadSe("PRSFX- Taunt2", "battle_anims", "PRSFX- Taunt2.wav"); + scene.loadAtlas("bait", "mystery-encounters"); + scene.loadAtlas("mud", "mystery-encounters"); + // Clear enemy party + scene.currentBattle.enemyParty = []; + await transitionMysteryEncounterIntroVisuals(scene); + await summonSafariPokemon(scene); + initSubsequentOptionSelect(scene, { overrideOptions: safariZoneGameOptions, hideDescription: true }); + return true; + }) + .build() + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Leave encounter with no rewards or exp + leaveEncounterWithoutBattle(scene, true); + return true; + } + ) + .build(); + +/** + * SAFARI ZONE MINIGAME OPTIONS + * + * Catch and flee rate stages are calculated in the same way stat changes are (they range from -6/+6) + * https://bulbapedia.bulbagarden.net/wiki/Catch_rate#Great_Marsh_and_Johto_Safari_Zone + * + * Catch Rate calculation: + * catchRate = speciesCatchRate [1 to 255] * catchStageMultiplier [2/8 to 8/2] * ballCatchRate [1.5] + * + * Flee calculation: + * The harder a species is to catch, the higher its flee rate is + * (Caps at 50% base chance to flee for the hardest to catch Pokemon, before factoring in flee stage) + * fleeRate = ((255^2 - speciesCatchRate^2) / 255 / 2) [0 to 127.5] * fleeStageMultiplier [2/8 to 8/2] + * Flee chance = fleeRate / 255 + */ +const safariZoneGameOptions: MysteryEncounterOption[] = [ + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.safari.1.label`, + buttonTooltip: `${namespace}.safari.1.tooltip`, + selected: [ + { + text: `${namespace}.safari.1.selected`, + } + ], + }) + .withOptionPhase(async (scene: BattleScene) => { + // Throw a ball option + const encounter = scene.currentBattle.mysteryEncounter!; + const pokemon = encounter.misc.pokemon; + const catchResult = await throwPokeball(scene, pokemon); + + if (catchResult) { + // You caught pokemon + // Check how many safari pokemon left + if (encounter.misc.safariPokemonRemaining > 0) { + await summonSafariPokemon(scene); + initSubsequentOptionSelect(scene, { overrideOptions: safariZoneGameOptions, startingCursorIndex: 0, hideDescription: true }); + } else { + // End safari mode + encounter.continuousEncounter = false; + leaveEncounterWithoutBattle(scene, true); + } + } else { + // Pokemon catch failed, end turn + await doEndTurn(scene, 0); + } + return true; + }) + .build(), + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.safari.2.label`, + buttonTooltip: `${namespace}.safari.2.tooltip`, + selected: [ + { + text: `${namespace}.safari.2.selected`, + }, + ], + }) + .withOptionPhase(async (scene: BattleScene) => { + // Throw bait option + const pokemon = scene.currentBattle.mysteryEncounter!.misc.pokemon; + await throwBait(scene, pokemon); + + // 100% chance to increase catch stage +2 + tryChangeCatchStage(scene, 2); + // 80% chance to increase flee stage +1 + const fleeChangeResult = tryChangeFleeStage(scene, 1, 8); + if (!fleeChangeResult) { + await showEncounterText(scene, getEncounterText(scene, `${namespace}.safari.busy_eating`) ?? "", null, 1000, false ); + } else { + await showEncounterText(scene, getEncounterText(scene, `${namespace}.safari.eating`) ?? "", null, 1000, false); + } + + await doEndTurn(scene, 1); + return true; + }) + .build(), + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.safari.3.label`, + buttonTooltip: `${namespace}.safari.3.tooltip`, + selected: [ + { + text: `${namespace}.safari.3.selected`, + }, + ], + }) + .withOptionPhase(async (scene: BattleScene) => { + // Throw mud option + const pokemon = scene.currentBattle.mysteryEncounter!.misc.pokemon; + await throwMud(scene, pokemon); + // 100% chance to decrease flee stage -2 + tryChangeFleeStage(scene, -2); + // 80% chance to decrease catch stage -1 + const catchChangeResult = tryChangeCatchStage(scene, -1, 8); + if (!catchChangeResult) { + await showEncounterText(scene, getEncounterText(scene, `${namespace}.safari.beside_itself_angry`) ?? "", null, 1000, false ); + } else { + await showEncounterText(scene, getEncounterText(scene, `${namespace}.safari.angry`) ?? "", null, 1000, false ); + } + + await doEndTurn(scene, 2); + return true; + }) + .build(), + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.safari.4.label`, + buttonTooltip: `${namespace}.safari.4.tooltip`, + }) + .withOptionPhase(async (scene: BattleScene) => { + // Flee option + const encounter = scene.currentBattle.mysteryEncounter!; + const pokemon = encounter.misc.pokemon; + await doPlayerFlee(scene, pokemon); + // Check how many safari pokemon left + if (encounter.misc.safariPokemonRemaining > 0) { + await summonSafariPokemon(scene); + initSubsequentOptionSelect(scene, { overrideOptions: safariZoneGameOptions, startingCursorIndex: 3, hideDescription: true }); + } else { + // End safari mode + encounter.continuousEncounter = false; + leaveEncounterWithoutBattle(scene, true); + } + return true; + }) + .build() +]; + +async function summonSafariPokemon(scene: BattleScene) { + const encounter = scene.currentBattle.mysteryEncounter!; + // Message pokemon remaining + encounter.setDialogueToken("remainingCount", encounter.misc.safariPokemonRemaining); + scene.queueMessage(getEncounterText(scene, `${namespace}.safari.remaining_count`) ?? "", null, true); + + // Generate pokemon using safariPokemonRemaining so they are always the same pokemon no matter how many turns are taken + // Safari pokemon roll twice on shiny and HA chances, but are otherwise normal + let enemySpecies; + let pokemon; + scene.executeWithSeedOffset(() => { + enemySpecies = getPokemonSpecies(getRandomSpeciesByStarterTier([0, 5])); + const level = scene.currentBattle.getLevelForWave(); + enemySpecies = getPokemonSpecies(enemySpecies.getWildSpeciesForLevel(level, true, false, scene.gameMode)); + pokemon = scene.addEnemyPokemon(enemySpecies, level, TrainerSlot.NONE, false); + + // Roll shiny twice + if (!pokemon.shiny) { + pokemon.trySetShinySeed(); + } + + // Roll HA twice + if (pokemon.species.abilityHidden) { + const hiddenIndex = pokemon.species.ability2 ? 2 : 1; + if (pokemon.abilityIndex < hiddenIndex) { + const hiddenAbilityChance = new IntegerHolder(256); + scene.applyModifiers(HiddenAbilityRateBoosterModifier, true, hiddenAbilityChance); + + const hasHiddenAbility = !randSeedInt(hiddenAbilityChance.value); + + if (hasHiddenAbility) { + pokemon.abilityIndex = hiddenIndex; + } + } + } + + pokemon.calculateStats(); + + scene.currentBattle.enemyParty.unshift(pokemon); + }, scene.currentBattle.waveIndex * 1000 + encounter.misc.safariPokemonRemaining); + + scene.gameData.setPokemonSeen(pokemon, true); + await pokemon.loadAssets(); + + // Reset safari catch and flee rates + encounter.misc.catchStage = 0; + encounter.misc.fleeStage = 0; + encounter.misc.pokemon = pokemon; + encounter.misc.safariPokemonRemaining -= 1; + + scene.unshiftPhase(new SummonPhase(scene, 0, false)); + + encounter.setDialogueToken("pokemonName", getPokemonNameWithAffix(pokemon)); + showEncounterText(scene, getEncounterText(scene, "battle:singleWildAppeared") ?? "", null, 1500, false) + .then(() => { + const ivScannerModifier = scene.findModifier(m => m instanceof IvScannerModifier); + if (ivScannerModifier) { + scene.pushPhase(new ScanIvsPhase(scene, pokemon.getBattlerIndex(), Math.min(ivScannerModifier.getStackCount() * 2, 6))); + } + }); +} + +function throwPokeball(scene: BattleScene, pokemon: EnemyPokemon): Promise { + const baseCatchRate = pokemon.species.catchRate; + // Catch stage ranges from -6 to +6 (like stat boost stages) + const safariCatchStage = scene.currentBattle.mysteryEncounter!.misc.catchStage; + // Catch modifier ranges from 2/8 (-6 stage) to 8/2 (+6) + const safariModifier = (2 + Math.min(Math.max(safariCatchStage, 0), 6)) / (2 - Math.max(Math.min(safariCatchStage, 0), -6)); + // Catch rate same as safari ball + const pokeballMultiplier = 1.5; + const catchRate = Math.round(baseCatchRate * pokeballMultiplier * safariModifier); + const ballTwitchRate = Math.round(1048560 / Math.sqrt(Math.sqrt(16711680 / catchRate))); + return trainerThrowPokeball(scene, pokemon, PokeballType.POKEBALL, ballTwitchRate); +} + +async function throwBait(scene: BattleScene, pokemon: EnemyPokemon): Promise { + const originalY: number = pokemon.y; + + const fpOffset = pokemon.getFieldPositionOffset(); + const bait: Phaser.GameObjects.Sprite = scene.addFieldSprite(16 + 75, 80 + 25, "bait", "0001.png"); + bait.setOrigin(0.5, 0.625); + scene.field.add(bait); + + return new Promise(resolve => { + scene.trainer.setTexture(`trainer_${scene.gameData.gender === PlayerGender.FEMALE ? "f" : "m"}_back_pb`); + scene.time.delayedCall(TRAINER_THROW_ANIMATION_TIMES[0], () => { + scene.playSound("se/pb_throw"); + + // Trainer throw frames + scene.trainer.setFrame("2"); + scene.time.delayedCall(TRAINER_THROW_ANIMATION_TIMES[1], () => { + scene.trainer.setFrame("3"); + scene.time.delayedCall(TRAINER_THROW_ANIMATION_TIMES[2], () => { + scene.trainer.setTexture(`trainer_${scene.gameData.gender === PlayerGender.FEMALE ? "f" : "m"}_back`); + }); + }); + + // Pokeball move and catch logic + scene.tweens.add({ + targets: bait, + x: { value: 210 + fpOffset[0], ease: "Linear" }, + y: { value: 55 + fpOffset[1], ease: "Cubic.easeOut" }, + duration: 500, + onComplete: () => { + + let index = 1; + scene.time.delayedCall(768, () => { + scene.tweens.add({ + targets: pokemon, + duration: 150, + ease: "Cubic.easeOut", + yoyo: true, + y: originalY - 5, + loop: 6, + onStart: () => { + scene.playSound("battle_anims/PRSFX- Bug Bite"); + bait.setFrame("0002.png"); + }, + onLoop: () => { + if (index % 2 === 0) { + scene.playSound("battle_anims/PRSFX- Bug Bite"); + } + if (index === 4) { + bait.setFrame("0003.png"); + } + index++; + }, + onComplete: () => { + scene.time.delayedCall(256, () => { + bait.destroy(); + resolve(true); + }); + } + }); + }); + } + }); + }); + }); +} + +async function throwMud(scene: BattleScene, pokemon: EnemyPokemon): Promise { + const originalY: number = pokemon.y; + + const fpOffset = pokemon.getFieldPositionOffset(); + const mud: Phaser.GameObjects.Sprite = scene.addFieldSprite(16 + 75, 80 + 35, "mud", "0001.png"); + mud.setOrigin(0.5, 0.625); + scene.field.add(mud); + + return new Promise(resolve => { + scene.trainer.setTexture(`trainer_${scene.gameData.gender === PlayerGender.FEMALE ? "f" : "m"}_back_pb`); + scene.time.delayedCall(TRAINER_THROW_ANIMATION_TIMES[0], () => { + scene.playSound("se/pb_throw"); + + // Trainer throw frames + scene.trainer.setFrame("2"); + scene.time.delayedCall(TRAINER_THROW_ANIMATION_TIMES[1], () => { + scene.trainer.setFrame("3"); + scene.time.delayedCall(TRAINER_THROW_ANIMATION_TIMES[2], () => { + scene.trainer.setTexture(`trainer_${scene.gameData.gender === PlayerGender.FEMALE ? "f" : "m"}_back`); + }); + }); + + // Mud throw and splat + scene.tweens.add({ + targets: mud, + x: { value: 230 + fpOffset[0], ease: "Linear" }, + y: { value: 55 + fpOffset[1], ease: "Cubic.easeOut" }, + duration: 500, + onComplete: () => { + // Mud frame 2 + scene.playSound("battle_anims/PRSFX- Sludge Bomb2"); + mud.setFrame("0002.png"); + // Mud splat + scene.time.delayedCall(200, () => { + mud.setFrame("0003.png"); + scene.time.delayedCall(400, () => { + mud.setFrame("0004.png"); + }); + }); + + // Fade mud then angry animation + scene.tweens.add({ + targets: mud, + alpha: 0, + ease: "Cubic.easeIn", + duration: 1000, + onComplete: () => { + mud.destroy(); + scene.tweens.add({ + targets: pokemon, + duration: 300, + ease: "Cubic.easeOut", + yoyo: true, + y: originalY - 20, + loop: 1, + onStart: () => { + scene.playSound("battle_anims/PRSFX- Taunt2"); + }, + onLoop: () => { + scene.playSound("battle_anims/PRSFX- Taunt2"); + }, + onComplete: () => { + resolve(true); + } + }); + } + }); + } + }); + }); + }); +} + +function isPokemonFlee(pokemon: EnemyPokemon, fleeStage: number): boolean { + const speciesCatchRate = pokemon.species.catchRate; + const fleeModifier = (2 + Math.min(Math.max(fleeStage, 0), 6)) / (2 - Math.max(Math.min(fleeStage, 0), -6)); + const fleeRate = (255 * 255 - speciesCatchRate * speciesCatchRate) / 255 / 2 * fleeModifier; + console.log("Flee rate: " + fleeRate); + const roll = randSeedInt(256); + console.log("Roll: " + roll); + return roll < fleeRate; +} + +function tryChangeFleeStage(scene: BattleScene, change: number, chance?: number): boolean { + if (chance && randSeedInt(10) >= chance) { + return false; + } + const currentFleeStage = scene.currentBattle.mysteryEncounter!.misc.fleeStage ?? 0; + scene.currentBattle.mysteryEncounter!.misc.fleeStage = Math.min(Math.max(currentFleeStage + change, -6), 6); + return true; +} + +function tryChangeCatchStage(scene: BattleScene, change: number, chance?: number): boolean { + if (chance && randSeedInt(10) >= chance) { + return false; + } + const currentCatchStage = scene.currentBattle.mysteryEncounter!.misc.catchStage ?? 0; + scene.currentBattle.mysteryEncounter!.misc.catchStage = Math.min(Math.max(currentCatchStage + change, -6), 6); + return true; +} + +async function doEndTurn(scene: BattleScene, cursorIndex: number) { + // First cleanup and destroy old Pokemon objects that were left in the enemyParty + // They are left in enemyParty temporarily so that VictoryPhase properly handles EXP + const party = scene.getEnemyParty(); + if (party.length > 1) { + for (let i = 1; i < party.length; i++) { + party[i].destroy(); + } + scene.currentBattle.enemyParty = party.slice(0, 1); + } + + const encounter = scene.currentBattle.mysteryEncounter!; + const pokemon = encounter.misc.pokemon; + const isFlee = isPokemonFlee(pokemon, encounter.misc.fleeStage); + if (isFlee) { + // Pokemon flees! + await doPokemonFlee(scene, pokemon); + // Check how many safari pokemon left + if (encounter.misc.safariPokemonRemaining > 0) { + await summonSafariPokemon(scene); + initSubsequentOptionSelect(scene, { overrideOptions: safariZoneGameOptions, startingCursorIndex: cursorIndex, hideDescription: true }); + } else { + // End safari mode + encounter.continuousEncounter = false; + leaveEncounterWithoutBattle(scene, true); + } + } else { + scene.queueMessage(getEncounterText(scene, `${namespace}.safari.watching`) ?? "", 0, null, 1000); + initSubsequentOptionSelect(scene, { overrideOptions: safariZoneGameOptions, startingCursorIndex: cursorIndex, hideDescription: true }); + } +} diff --git a/src/data/mystery-encounters/encounters/shady-vitamin-dealer-encounter.ts b/src/data/mystery-encounters/encounters/shady-vitamin-dealer-encounter.ts new file mode 100644 index 000000000000..8ee4782def55 --- /dev/null +++ b/src/data/mystery-encounters/encounters/shady-vitamin-dealer-encounter.ts @@ -0,0 +1,227 @@ +import { generateModifierType, leaveEncounterWithoutBattle, selectPokemonForOption, setEncounterExp, updatePlayerMoney, } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import Pokemon, { PlayerPokemon } from "#app/field/pokemon"; +import { modifierTypes } from "#app/modifier/modifier-type"; +import { randSeedInt } from "#app/utils"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { Species } from "#enums/species"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterOptionBuilder } from "../mystery-encounter-option"; +import { MoneyRequirement } from "../mystery-encounter-requirements"; +import { getEncounterText, queueEncounterMessage } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { applyDamageToPokemon, applyModifierTypeToPlayerPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { Nature } from "#enums/nature"; +import { getNatureName } from "#app/data/nature"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for this encounter */ +const namespace = "mysteryEncounter:shadyVitaminDealer"; + +/** + * Shady Vitamin Dealer encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3798 | GitHub Issue #3798} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const ShadyVitaminDealerEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.SHADY_VITAMIN_DEALER) + .withEncounterTier(MysteryEncounterTier.COMMON) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withSceneRequirement(new MoneyRequirement(0, 1.5)) // Must have the money for at least the cheap deal + .withPrimaryPokemonHealthRatioRequirement([0.5, 1]) // At least 1 Pokemon must have above half HP + .withIntroSpriteConfigs([ + { + spriteKey: Species.KROOKODILE.toString(), + fileRoot: "pokemon", + hasShadow: true, + repeat: true, + x: 12, + y: -5, + yShadow: -5 + }, + { + spriteKey: "b2w2_veteran_m", + fileRoot: "mystery-encounters", + hasShadow: true, + x: -12, + y: 3, + yShadow: 3 + }, + ]) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + { + text: `${namespace}.intro_dialogue`, + speaker: `${namespace}.speaker`, + }, + ]) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT) + .withSceneMoneyRequirement(0, 1.5) + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.selected`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene): Promise => { + const encounter = scene.currentBattle.mysteryEncounter!; + const onPokemonSelected = (pokemon: PlayerPokemon) => { + // Update money + updatePlayerMoney(scene, -(encounter.options[0].requirements[0] as MoneyRequirement).requiredMoney); + // Calculate modifiers and dialogue tokens + const modifiers = [ + generateModifierType(scene, modifierTypes.BASE_STAT_BOOSTER)!, + generateModifierType(scene, modifierTypes.BASE_STAT_BOOSTER)!, + ]; + encounter.setDialogueToken("boost1", modifiers[0].name); + encounter.setDialogueToken("boost2", modifiers[1].name); + encounter.misc = { + chosenPokemon: pokemon, + modifiers: modifiers, + }; + }; + + // Only Pokemon that can gain benefits are above half HP with no status + const selectableFilter = (pokemon: Pokemon) => { + // If pokemon meets primary pokemon reqs, it can be selected + const meetsReqs = encounter.pokemonMeetsPrimaryRequirements(scene, pokemon); + if (!meetsReqs) { + return getEncounterText(scene, `${namespace}.invalid_selection`) ?? null; + } + + return null; + }; + + return selectPokemonForOption(scene, onPokemonSelected, undefined, selectableFilter); + }) + .withOptionPhase(async (scene: BattleScene) => { + // Choose Cheap Option + const encounter = scene.currentBattle.mysteryEncounter!; + const chosenPokemon = encounter.misc.chosenPokemon; + const modifiers = encounter.misc.modifiers; + + for (const modType of modifiers) { + await applyModifierTypeToPlayerPokemon(scene, chosenPokemon, modType); + } + + leaveEncounterWithoutBattle(scene); + }) + .withPostOptionPhase(async (scene: BattleScene) => { + // Damage and status applied after dealer leaves (to make thematic sense) + const encounter = scene.currentBattle.mysteryEncounter!; + const chosenPokemon = encounter.misc.chosenPokemon as PlayerPokemon; + + // Pokemon takes half max HP damage and nature is randomized (does not update dex) + applyDamageToPokemon(scene, chosenPokemon, Math.floor(chosenPokemon.getMaxHp() / 2)); + + const currentNature = chosenPokemon.nature; + let newNature = randSeedInt(25) as Nature; + while (newNature === currentNature) { + newNature = randSeedInt(25) as Nature; + } + + chosenPokemon.nature = newNature; + encounter.setDialogueToken("newNature", getNatureName(newNature)); + queueEncounterMessage(scene, `${namespace}.cheap_side_effects`); + setEncounterExp(scene, [chosenPokemon.id], 100); + chosenPokemon.updateInfo(); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT) + .withSceneMoneyRequirement(0, 3.5) + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.selected`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene): Promise => { + const encounter = scene.currentBattle.mysteryEncounter!; + const onPokemonSelected = (pokemon: PlayerPokemon) => { + // Update money + updatePlayerMoney(scene, -(encounter.options[1].requirements[0] as MoneyRequirement).requiredMoney); + // Calculate modifiers and dialogue tokens + const modifiers = [ + generateModifierType(scene, modifierTypes.BASE_STAT_BOOSTER)!, + generateModifierType(scene, modifierTypes.BASE_STAT_BOOSTER)!, + ]; + encounter.setDialogueToken("boost1", modifiers[0].name); + encounter.setDialogueToken("boost2", modifiers[1].name); + encounter.misc = { + chosenPokemon: pokemon, + modifiers: modifiers, + }; + }; + + // Only Pokemon that can gain benefits are unfainted + const selectableFilter = (pokemon: Pokemon) => { + // If pokemon is unfainted it can be selected + const meetsReqs = !pokemon.isFainted(true); + if (!meetsReqs) { + return getEncounterText(scene, `${namespace}.invalid_selection`) ?? null; + } + + return null; + }; + + return selectPokemonForOption(scene, onPokemonSelected, undefined, selectableFilter); + }) + .withOptionPhase(async (scene: BattleScene) => { + // Choose Expensive Option + const encounter = scene.currentBattle.mysteryEncounter!; + const chosenPokemon = encounter.misc.chosenPokemon; + const modifiers = encounter.misc.modifiers; + + for (const modType of modifiers) { + await applyModifierTypeToPlayerPokemon(scene, chosenPokemon, modType); + } + + leaveEncounterWithoutBattle(scene); + }) + .withPostOptionPhase(async (scene: BattleScene) => { + // Status applied after dealer leaves (to make thematic sense) + const encounter = scene.currentBattle.mysteryEncounter!; + const chosenPokemon = encounter.misc.chosenPokemon; + + queueEncounterMessage(scene, `${namespace}.no_bad_effects`); + setEncounterExp(scene, [chosenPokemon.id], 100); + + chosenPokemon.updateInfo(); + }) + .build() + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + speaker: `${namespace}.speaker` + } + ] + }, + async (scene: BattleScene) => { + // Leave encounter with no rewards or exp + leaveEncounterWithoutBattle(scene, true); + return true; + } + ) + .build(); diff --git a/src/data/mystery-encounters/encounters/slumbering-snorlax-encounter.ts b/src/data/mystery-encounters/encounters/slumbering-snorlax-encounter.ts new file mode 100644 index 000000000000..b9f08b12ffd9 --- /dev/null +++ b/src/data/mystery-encounters/encounters/slumbering-snorlax-encounter.ts @@ -0,0 +1,151 @@ +import { STEALING_MOVES } from "#app/data/mystery-encounters/requirements/requirement-groups"; +import { modifierTypes } from "#app/modifier/modifier-type"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { Species } from "#enums/species"; +import BattleScene from "#app/battle-scene"; +import { StatusEffect } from "#app/data/status-effect"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterOptionBuilder } from "../mystery-encounter-option"; +import { MoveRequirement } from "../mystery-encounter-requirements"; +import { EnemyPartyConfig, EnemyPokemonConfig, initBattleWithEnemyConfig, loadCustomMovesForEncounter, leaveEncounterWithoutBattle, setEncounterExp, setEncounterRewards, } from "../utils/encounter-phase-utils"; +import { queueEncounterMessage } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { Moves } from "#enums/moves"; +import { BattlerIndex } from "#app/battle"; +import { PokemonMove } from "#app/field/pokemon"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { PartyHealPhase } from "#app/phases/party-heal-phase"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** i18n namespace for the encounter */ +const namespace = "mysteryEncounter:slumberingSnorlax"; + +/** + * Sleeping Snorlax encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3815 | GitHub Issue #3815} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const SlumberingSnorlaxEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.SLUMBERING_SNORLAX) + .withEncounterTier(MysteryEncounterTier.GREAT) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withCatchAllowed(true) + .withHideWildIntroMessage(true) + .withIntroSpriteConfigs([ + { + spriteKey: Species.SNORLAX.toString(), + fileRoot: "pokemon", + hasShadow: true, + tint: 0.25, + scale: 1.5, + repeat: true, + y: 5, + }, + ]) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + ]) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + console.log(encounter); + + // Calculate boss mon + const bossSpecies = getPokemonSpecies(Species.SNORLAX); + const pokemonConfig: EnemyPokemonConfig = { + species: bossSpecies, + isBoss: true, + status: [StatusEffect.SLEEP, 5], // Extra turns on timer for Snorlax's start of fight moves + moveSet: [Moves.REST, Moves.SLEEP_TALK, Moves.CRUNCH, Moves.GIGA_IMPACT] + }; + const config: EnemyPartyConfig = { + levelAdditiveMultiplier: 0.5, + pokemonConfigs: [pokemonConfig], + }; + encounter.enemyPartyConfigs = [config]; + + // Load animations/sfx for Snorlax fight start moves + loadCustomMovesForEncounter(scene, [Moves.SNORE]); + + encounter.setDialogueToken("snorlaxName", getPokemonSpecies(Species.SNORLAX).getName()); + + return true; + }) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Pick battle + const encounter = scene.currentBattle.mysteryEncounter!; + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: [modifierTypes.LEFTOVERS], fillRemaining: true}); + encounter.startOfBattleEffects.push( + { + sourceBattlerIndex: BattlerIndex.ENEMY, + targets: [BattlerIndex.PLAYER], + move: new PokemonMove(Moves.SNORE), + ignorePp: true + }, + { + sourceBattlerIndex: BattlerIndex.ENEMY, + targets: [BattlerIndex.PLAYER], + move: new PokemonMove(Moves.SNORE), + ignorePp: true + }); + await initBattleWithEnemyConfig(scene, encounter.enemyPartyConfigs[0]); + } + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Fall asleep waiting for Snorlax + // Full heal party + scene.unshiftPhase(new PartyHealPhase(scene, true)); + queueEncounterMessage(scene, `${namespace}.option.2.rest_result`); + leaveEncounterWithoutBattle(scene); + } + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL) + .withPrimaryPokemonRequirement(new MoveRequirement(STEALING_MOVES)) + .withDialogue({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + disabledButtonTooltip: `${namespace}.option.3.disabled_tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected` + } + ] + }) + .withOptionPhase(async (scene: BattleScene) => { + // Steal the Snorlax's Leftovers + const instance = scene.currentBattle.mysteryEncounter!; + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: [modifierTypes.LEFTOVERS], fillRemaining: false }); + // Snorlax exp to Pokemon that did the stealing + setEncounterExp(scene, instance.primaryPokemon!.id, getPokemonSpecies(Species.SNORLAX).baseExp); + leaveEncounterWithoutBattle(scene); + }) + .build() + ) + .build(); diff --git a/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts b/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts new file mode 100644 index 000000000000..bf976458fddc --- /dev/null +++ b/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts @@ -0,0 +1,237 @@ +import { EnemyPartyConfig, generateModifierTypeOption, initBattleWithEnemyConfig, setEncounterExp, setEncounterRewards, transitionMysteryEncounterIntroVisuals, updatePlayerMoney, } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { randSeedInt } from "#app/utils"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MoneyRequirement, WaveModulusRequirement } from "../mystery-encounter-requirements"; +import Pokemon, { EnemyPokemon } from "#app/field/pokemon"; +import { MysteryEncounterOptionBuilder } from "#app/data/mystery-encounters/mystery-encounter-option"; +import { queueEncounterMessage, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import PokemonData from "#app/system/pokemon-data"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { Biome } from "#enums/biome"; +import { getBiomeKey } from "#app/field/arena"; +import { Type } from "#app/data/type"; +import { getPartyLuckValue, modifierTypes } from "#app/modifier/modifier-type"; +import { TrainerSlot } from "#app/data/trainer-config"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { getPokemonNameWithAffix } from "#app/messages"; +import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; +import { Stat } from "#enums/stat"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for this encounter */ +const namespace = "mysteryEncounter:teleportingHijinks"; + +const MONEY_COST_MULTIPLIER = 2.5; +const BIOME_CANDIDATES = [Biome.SPACE, Biome.FAIRY_CAVE, Biome.LABORATORY, Biome.ISLAND]; +const MACHINE_INTERFACING_TYPES = [Type.ELECTRIC, Type.STEEL]; + +/** + * Teleporting Hijinks encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3817 | GitHub Issue #3817} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const TeleportingHijinksEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.TELEPORTING_HIJINKS) + .withEncounterTier(MysteryEncounterTier.COMMON) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withSceneRequirement(new WaveModulusRequirement([1, 2, 3], 10)) // Must be in first 3 waves after boss wave + .withSceneRequirement(new MoneyRequirement(undefined, MONEY_COST_MULTIPLIER)) // Must be able to pay teleport cost + .withAutoHideIntroVisuals(false) + .withCatchAllowed(true) + .withIntroSpriteConfigs([ + { + spriteKey: "teleporter", + fileRoot: "mystery-encounters", + hasShadow: true, + x: 4, + y: 4, + yShadow: 1 + } + ]) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + } + ]) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + const price = scene.getWaveMoneyAmount(MONEY_COST_MULTIPLIER); + encounter.setDialogueToken("price", price.toString()); + encounter.misc = { + price + }; + + return true; + }) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT) + .withSceneMoneyRequirement(undefined, MONEY_COST_MULTIPLIER) // Must be able to pay teleport cost + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + } + ], + }) + .withPreOptionPhase(async (scene: BattleScene) => { + // Update money + updatePlayerMoney(scene, -scene.currentBattle.mysteryEncounter!.misc.price, true, false); + }) + .withOptionPhase(async (scene: BattleScene) => { + const config: EnemyPartyConfig = await doBiomeTransitionDialogueAndBattleInit(scene); + setEncounterRewards(scene, { fillRemaining: true }); + await initBattleWithEnemyConfig(scene, config); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL) + .withPokemonTypeRequirement(MACHINE_INTERFACING_TYPES, true, 1) // Must have Steel or Electric type + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + disabledButtonTooltip: `${namespace}.option.2.disabled_tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + } + ], + }) + .withOptionPhase(async (scene: BattleScene) => { + const config: EnemyPartyConfig = await doBiomeTransitionDialogueAndBattleInit(scene); + setEncounterRewards(scene, { fillRemaining: true }); + setEncounterExp(scene, scene.currentBattle.mysteryEncounter!.selectedOption!.primaryPokemon!.id, 100); + await initBattleWithEnemyConfig(scene, config); + }) + .build() + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Inspect the Machine + const encounter = scene.currentBattle.mysteryEncounter!; + + // Init enemy + const level = (scene.currentBattle.enemyLevels?.[0] ?? scene.currentBattle.waveIndex) + Math.max(Math.round((scene.currentBattle.waveIndex / 10)), 0); + const bossSpecies = scene.arena.randomSpecies(scene.currentBattle.waveIndex, level, 0, getPartyLuckValue(scene.getParty()), true); + const bossPokemon = new EnemyPokemon(scene, bossSpecies, level, TrainerSlot.NONE, true); + encounter.setDialogueToken("enemyPokemon", getPokemonNameWithAffix(bossPokemon)); + const config: EnemyPartyConfig = { + pokemonConfigs: [{ + level: level, + species: bossSpecies, + dataSource: new PokemonData(bossPokemon), + isBoss: true, + }], + }; + + const magnet = generateModifierTypeOption(scene, modifierTypes.ATTACK_TYPE_BOOSTER, [Type.STEEL])!; + const metalCoat = generateModifierTypeOption(scene, modifierTypes.ATTACK_TYPE_BOOSTER, [Type.ELECTRIC])!; + setEncounterRewards(scene, { guaranteedModifierTypeOptions: [magnet, metalCoat], fillRemaining: true }); + transitionMysteryEncounterIntroVisuals(scene, true, true); + await initBattleWithEnemyConfig(scene, config); + } + ) + .build(); + +async function doBiomeTransitionDialogueAndBattleInit(scene: BattleScene) { + const encounter = scene.currentBattle.mysteryEncounter!; + + // Calculate new biome (cannot be current biome) + const filteredBiomes = BIOME_CANDIDATES.filter(b => scene.arena.biomeType !== b); + const newBiome = filteredBiomes[randSeedInt(filteredBiomes.length)]; + + // Show dialogue and transition biome + await showEncounterText(scene, `${namespace}.transport`); + await Promise.all([animateBiomeChange(scene, newBiome), transitionMysteryEncounterIntroVisuals(scene)]); + scene.playBgm(); + await showEncounterText(scene, `${namespace}.attacked`); + + // Init enemy + const level = (scene.currentBattle.enemyLevels?.[0] ?? scene.currentBattle.waveIndex) + Math.max(Math.round((scene.currentBattle.waveIndex / 10)), 0); + const bossSpecies = scene.arena.randomSpecies(scene.currentBattle.waveIndex, level, 0, getPartyLuckValue(scene.getParty()), true); + const bossPokemon = new EnemyPokemon(scene, bossSpecies, level, TrainerSlot.NONE, true); + encounter.setDialogueToken("enemyPokemon", getPokemonNameWithAffix(bossPokemon)); + const config: EnemyPartyConfig = { + pokemonConfigs: [{ + level: level, + species: bossSpecies, + dataSource: new PokemonData(bossPokemon), + isBoss: true, + tags: [BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON], + mysteryEncounterBattleEffects: (pokemon: Pokemon) => { + queueEncounterMessage(pokemon.scene, `${namespace}.boss_enraged`); + pokemon.scene.unshiftPhase(new StatStageChangePhase(pokemon.scene, pokemon.getBattlerIndex(), true, [Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD], 1)); + } + }], + }; + + return config; +} + +async function animateBiomeChange(scene: BattleScene, nextBiome: Biome) { + return new Promise(resolve => { + scene.tweens.add({ + targets: [scene.arenaEnemy, scene.lastEnemyTrainer], + x: "+=300", + duration: 2000, + onComplete: () => { + scene.newArena(nextBiome); + + const biomeKey = getBiomeKey(nextBiome); + const bgTexture = `${biomeKey}_bg`; + scene.arenaBgTransition.setTexture(bgTexture); + scene.arenaBgTransition.setAlpha(0); + scene.arenaBgTransition.setVisible(true); + scene.arenaPlayerTransition.setBiome(nextBiome); + scene.arenaPlayerTransition.setAlpha(0); + scene.arenaPlayerTransition.setVisible(true); + + scene.tweens.add({ + targets: [scene.arenaPlayer, scene.arenaBgTransition, scene.arenaPlayerTransition], + duration: 1000, + ease: "Sine.easeInOut", + alpha: (target: any) => target === scene.arenaPlayer ? 0 : 1, + onComplete: () => { + scene.arenaBg.setTexture(bgTexture); + scene.arenaPlayer.setBiome(nextBiome); + scene.arenaPlayer.setAlpha(1); + scene.arenaEnemy.setBiome(nextBiome); + scene.arenaEnemy.setAlpha(1); + scene.arenaNextEnemy.setBiome(nextBiome); + scene.arenaBgTransition.setVisible(false); + scene.arenaPlayerTransition.setVisible(false); + if (scene.lastEnemyTrainer) { + scene.lastEnemyTrainer.destroy(); + } + + resolve(); + + scene.tweens.add({ + targets: scene.arenaEnemy, + x: "-=300", + }); + } + }); + } + }); + }); +} diff --git a/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts b/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts new file mode 100644 index 000000000000..16b0c421bd43 --- /dev/null +++ b/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts @@ -0,0 +1,159 @@ +import { leaveEncounterWithoutBattle, transitionMysteryEncounterIntroVisuals, updatePlayerMoney, } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { isNullOrUndefined, randSeedInt } from "#app/utils"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MoneyRequirement } from "../mystery-encounter-requirements"; +import { catchPokemon, getRandomSpeciesByStarterTier, getSpriteKeysFromPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { getPokemonSpecies, speciesStarters } from "#app/data/pokemon-species"; +import { Species } from "#enums/species"; +import { PokeballType } from "#app/data/pokeball"; +import { EnemyPokemon, PlayerPokemon } from "#app/field/pokemon"; +import { MysteryEncounterOptionBuilder } from "#app/data/mystery-encounters/mystery-encounter-option"; +import { showEncounterDialogue } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import PokemonData from "#app/system/pokemon-data"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for this encounter */ +const namespace = "mysteryEncounter:pokemonSalesman"; + +const MAX_POKEMON_PRICE_MULTIPLIER = 6; + +/** + * Pokemon Salesman encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3799 | GitHub Issue #3799} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const ThePokemonSalesmanEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.THE_POKEMON_SALESMAN) + .withEncounterTier(MysteryEncounterTier.ULTRA) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withSceneRequirement(new MoneyRequirement(undefined, MAX_POKEMON_PRICE_MULTIPLIER)) // Some costs may not be as significant, this is the max you'd pay + .withAutoHideIntroVisuals(false) + .withIntroSpriteConfigs([ + { + spriteKey: "pokemon_salesman", + fileRoot: "mystery-encounters", + hasShadow: true + } + ]) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + { + text: `${namespace}.intro_dialogue`, + speaker: `${namespace}.speaker`, + }, + ]) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + + let species = getPokemonSpecies(getRandomSpeciesByStarterTier([0, 5])); + const tries = 0; + + // Reroll any species that don't have HAs + while (isNullOrUndefined(species.abilityHidden) && tries < 5) { + species = getPokemonSpecies(getRandomSpeciesByStarterTier([0, 5])); + } + + let pokemon: PlayerPokemon; + if (isNullOrUndefined(species.abilityHidden) || randSeedInt(100) === 0) { + // If no HA mon found or you roll 1%, give shiny Magikarp + species = getPokemonSpecies(Species.MAGIKARP); + const hiddenIndex = species.ability2 ? 2 : 1; + pokemon = new PlayerPokemon(scene, species, 5, hiddenIndex, species.formIndex, undefined, true); + } else { + const hiddenIndex = species.ability2 ? 2 : 1; + pokemon = new PlayerPokemon(scene, species, 5, hiddenIndex, species.formIndex); + } + pokemon.generateAndPopulateMoveset(); + + const { spriteKey, fileRoot } = getSpriteKeysFromPokemon(pokemon); + encounter.spriteConfigs.push({ + spriteKey: spriteKey, + fileRoot: fileRoot, + hasShadow: true, + repeat: true, + isPokemon: true + }); + + const starterTier = speciesStarters[species.speciesId]; + // Prices decrease by starter tier less than 5, but only reduces cost by half at max + let priceMultiplier = MAX_POKEMON_PRICE_MULTIPLIER * (Math.max(starterTier, 2.5) / 5); + if (pokemon.shiny) { + // Always max price for shiny (flip HA back to normal), and add special messaging + priceMultiplier = MAX_POKEMON_PRICE_MULTIPLIER; + pokemon.abilityIndex = 0; + encounter.dialogue.encounterOptionsDialogue!.description = `${namespace}.description_shiny`; + encounter.options[0].dialogue!.buttonTooltip = `${namespace}.option.1.tooltip_shiny`; + } + const price = scene.getWaveMoneyAmount(priceMultiplier); + encounter.setDialogueToken("purchasePokemon", pokemon.getNameToRender()); + encounter.setDialogueToken("price", price.toString()); + encounter.misc = { + price: price, + pokemon: pokemon + }; + + pokemon.calculateStats(); + + return true; + }) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT) + .withHasDexProgress(true) + .withSceneMoneyRequirement(undefined, MAX_POKEMON_PRICE_MULTIPLIER) // Wave scaling money multiplier of 2 + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected_message`, + } + ], + }) + .withOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + const price = encounter.misc.price; + const purchasedPokemon = encounter.misc.pokemon as PlayerPokemon; + + // Update money + updatePlayerMoney(scene, -price, true, false); + + // Show dialogue + await showEncounterDialogue(scene, `${namespace}.option.1.selected_dialogue`, `${namespace}.speaker`); + await transitionMysteryEncounterIntroVisuals(scene); + + // "Catch" purchased pokemon + const data = new PokemonData(purchasedPokemon); + data.player = false; + await catchPokemon(scene, data.toPokemon(scene) as EnemyPokemon, null, PokeballType.POKEBALL, true, true); + + leaveEncounterWithoutBattle(scene, true); + }) + .build() + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Leave encounter with no rewards or exp + leaveEncounterWithoutBattle(scene, true); + return true; + } + ) + .build(); diff --git a/src/data/mystery-encounters/encounters/the-strong-stuff-encounter.ts b/src/data/mystery-encounters/encounters/the-strong-stuff-encounter.ts new file mode 100644 index 000000000000..047aa0d83f68 --- /dev/null +++ b/src/data/mystery-encounters/encounters/the-strong-stuff-encounter.ts @@ -0,0 +1,199 @@ +import { EnemyPartyConfig, initBattleWithEnemyConfig, loadCustomMovesForEncounter, leaveEncounterWithoutBattle, setEncounterRewards, transitionMysteryEncounterIntroVisuals, generateModifierType } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { modifierTypes, PokemonHeldItemModifierType, } from "#app/modifier/modifier-type"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { Species } from "#enums/species"; +import { Nature } from "#app/data/nature"; +import Pokemon, { PokemonMove } from "#app/field/pokemon"; +import { queueEncounterMessage, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { modifyPlayerPokemonBST } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { Moves } from "#enums/moves"; +import { BattlerIndex } from "#app/battle"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { BerryType } from "#enums/berry-type"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterPokemonData } from "#app/data/mystery-encounters/mystery-encounter-pokemon-data"; +import { Stat } from "#enums/stat"; +import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for the encounter */ +const namespace = "mysteryEncounter:theStrongStuff"; + +// Halved for HP stat +const HIGH_BST_REDUCTION_VALUE = 15; +const BST_INCREASE_VALUE = 10; + +/** + * The Strong Stuff encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3803 | GitHub Issue #3803} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const TheStrongStuffEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.THE_STRONG_STUFF) + .withEncounterTier(MysteryEncounterTier.GREAT) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withScenePartySizeRequirement(3, 6) // Must have at least 3 pokemon in party + .withHideWildIntroMessage(true) + .withAutoHideIntroVisuals(false) + .withIntroSpriteConfigs([ + { + spriteKey: "berry_juice", + fileRoot: "items", + hasShadow: true, + isItem: true, + scale: 1.25, + x: -15, + y: 3, + disableAnimation: true + }, + { + spriteKey: Species.SHUCKLE.toString(), + fileRoot: "pokemon", + hasShadow: true, + repeat: true, + scale: 1.25, + x: 20, + y: 10, + yShadow: 7 + }, + ]) // Set in onInit() + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + ]) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + + // Calculate boss mon + const config: EnemyPartyConfig = { + levelAdditiveMultiplier: 1, + disableSwitch: true, + pokemonConfigs: [ + { + species: getPokemonSpecies(Species.SHUCKLE), + isBoss: true, + bossSegments: 5, + mysteryEncounterPokemonData: new MysteryEncounterPokemonData({ spriteScale: 1.25 }), + nature: Nature.BOLD, + moveSet: [Moves.INFESTATION, Moves.SALT_CURE, Moves.GASTRO_ACID, Moves.HEAL_ORDER], + modifierConfigs: [ + { + modifier: generateModifierType(scene, modifierTypes.BERRY, [BerryType.SITRUS]) as PokemonHeldItemModifierType + }, + { + modifier: generateModifierType(scene, modifierTypes.BERRY, [BerryType.ENIGMA]) as PokemonHeldItemModifierType + }, + { + modifier: generateModifierType(scene, modifierTypes.BERRY, [BerryType.APICOT]) as PokemonHeldItemModifierType + }, + { + modifier: generateModifierType(scene, modifierTypes.BERRY, [BerryType.GANLON]) as PokemonHeldItemModifierType + }, + { + modifier: generateModifierType(scene, modifierTypes.BERRY, [BerryType.LUM]) as PokemonHeldItemModifierType, + stackCount: 2 + } + ], + tags: [BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON], + mysteryEncounterBattleEffects: (pokemon: Pokemon) => { + queueEncounterMessage(pokemon.scene, `${namespace}.option.2.stat_boost`); + pokemon.scene.unshiftPhase(new StatStageChangePhase(pokemon.scene, pokemon.getBattlerIndex(), true, [Stat.DEF, Stat.SPDEF], 2)); + } + } + ], + }; + + encounter.enemyPartyConfigs = [config]; + + loadCustomMovesForEncounter(scene, [Moves.GASTRO_ACID, Moves.STEALTH_ROCK]); + + encounter.setDialogueToken("shuckleName", getPokemonSpecies(Species.SHUCKLE).getName()); + + return true; + }) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected` + } + ] + }, + async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + // Do blackout and hide intro visuals during blackout + scene.time.delayedCall(750, () => { + transitionMysteryEncounterIntroVisuals(scene, true, true, 50); + }); + + // -15 to all base stats of highest BST (halved for HP), +10 to all base stats of rest of party (halved for HP) + // Sort party by bst + const sortedParty = scene.getParty().slice(0) + .sort((pokemon1, pokemon2) => { + const pokemon1Bst = pokemon1.calculateBaseStats().reduce((a, b) => a + b, 0); + const pokemon2Bst = pokemon2.calculateBaseStats().reduce((a, b) => a + b, 0); + return pokemon2Bst - pokemon1Bst; + }); + + sortedParty.forEach((pokemon, index) => { + if (index < 2) { + // -15 to the two highest BST mons + modifyPlayerPokemonBST(pokemon, -HIGH_BST_REDUCTION_VALUE); + encounter.setDialogueToken("highBstPokemon" + (index + 1), pokemon.getNameToRender()); + } else { + // +10 for the rest + modifyPlayerPokemonBST(pokemon, BST_INCREASE_VALUE); + } + }); + + encounter.setDialogueToken("reductionValue", HIGH_BST_REDUCTION_VALUE.toString()); + encounter.setDialogueToken("increaseValue", BST_INCREASE_VALUE.toString()); + await showEncounterText(scene, `${namespace}.option.1.selected_2`, null, undefined, true); + + setEncounterRewards(scene, { fillRemaining: true }); + leaveEncounterWithoutBattle(scene, true); + return true; + } + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Pick battle + const encounter = scene.currentBattle.mysteryEncounter!; + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: [modifierTypes.SOUL_DEW], fillRemaining: true }); + encounter.startOfBattleEffects.push( + { + sourceBattlerIndex: BattlerIndex.ENEMY, + targets: [BattlerIndex.PLAYER], + move: new PokemonMove(Moves.GASTRO_ACID), + ignorePp: true + }, + { + sourceBattlerIndex: BattlerIndex.ENEMY, + targets: [BattlerIndex.PLAYER], + move: new PokemonMove(Moves.STEALTH_ROCK), + ignorePp: true + }); + + transitionMysteryEncounterIntroVisuals(scene, true, true, 500); + await initBattleWithEnemyConfig(scene, encounter.enemyPartyConfigs[0]); + } + ) + .build(); diff --git a/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts b/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts new file mode 100644 index 000000000000..902aefcb4905 --- /dev/null +++ b/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts @@ -0,0 +1,510 @@ +import { EnemyPartyConfig, generateModifierType, generateModifierTypeOption, initBattleWithEnemyConfig, leaveEncounterWithoutBattle, setEncounterRewards, transitionMysteryEncounterIntroVisuals, } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { modifierTypes, PokemonHeldItemModifierType } from "#app/modifier/modifier-type"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { TrainerType } from "#enums/trainer-type"; +import { Species } from "#enums/species"; +import { Abilities } from "#enums/abilities"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { Moves } from "#enums/moves"; +import { Nature } from "#enums/nature"; +import { Type } from "#app/data/type"; +import { BerryType } from "#enums/berry-type"; +import { Stat } from "#enums/stat"; +import { SpeciesFormChangeManualTrigger } from "#app/data/pokemon-forms"; +import { applyPostBattleInitAbAttrs, PostBattleInitAbAttr } from "#app/data/ability"; +import { showEncounterDialogue, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { MysteryEncounterMode } from "#enums/mystery-encounter-mode"; +import { PartyHealPhase } from "#app/phases/party-heal-phase"; +import { ShowTrainerPhase } from "#app/phases/show-trainer-phase"; +import { ReturnPhase } from "#app/phases/return-phase"; +import i18next from "i18next"; +import { ModifierTier } from "#app/modifier/modifier-tier"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for the encounter */ +const namespace = "mysteryEncounter:theWinstrateChallenge"; + +/** + * The Winstrate Challenge encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3821 | GitHub Issue #3821} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const TheWinstrateChallengeEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.THE_WINSTRATE_CHALLENGE) + .withEncounterTier(MysteryEncounterTier.ROGUE) + .withSceneWaveRangeRequirement(100, CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES[1]) + .withIntroSpriteConfigs([ + { + spriteKey: "vito", + fileRoot: "trainer", + hasShadow: false, + x: 16, + y: -4 + }, + { + spriteKey: "vivi", + fileRoot: "trainer", + hasShadow: false, + x: -14, + y: -4 + }, + { + spriteKey: "victor", + fileRoot: "trainer", + hasShadow: true, + x: -32 + }, + { + spriteKey: "victoria", + fileRoot: "trainer", + hasShadow: true, + x: 40, + }, + { + spriteKey: "vicky", + fileRoot: "trainer", + hasShadow: true, + x: 3, + y: 5, + yShadow: 5 + }, + ]) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.intro_dialogue`, + }, + ]) + .withAutoHideIntroVisuals(false) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + + // Loaded back to front for pop() operations + encounter.enemyPartyConfigs.push(getVitoTrainerConfig(scene)); + encounter.enemyPartyConfigs.push(getVickyTrainerConfig(scene)); + encounter.enemyPartyConfigs.push(getViviTrainerConfig(scene)); + encounter.enemyPartyConfigs.push(getVictoriaTrainerConfig(scene)); + encounter.enemyPartyConfigs.push(getVictorTrainerConfig(scene)); + + return true; + }) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.1.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Spawn 5 trainer battles back to back with Macho Brace in rewards + scene.currentBattle.mysteryEncounter!.doContinueEncounter = (scene: BattleScene) => { + return endTrainerBattleAndShowDialogue(scene); + }; + await transitionMysteryEncounterIntroVisuals(scene, true, false); + await spawnNextTrainerOrEndEncounter(scene); + } + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.2.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Refuse the challenge, they full heal the party and give the player a Rarer Candy + scene.unshiftPhase(new PartyHealPhase(scene, true)); + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: [modifierTypes.RARER_CANDY], fillRemaining: false }); + leaveEncounterWithoutBattle(scene); + } + ) + .build(); + +async function spawnNextTrainerOrEndEncounter(scene: BattleScene) { + const encounter = scene.currentBattle.mysteryEncounter!; + const nextConfig = encounter.enemyPartyConfigs.pop(); + if (!nextConfig) { + await transitionMysteryEncounterIntroVisuals(scene, false, false); + await showEncounterDialogue(scene, `${namespace}.victory`, `${namespace}.speaker`); + + // Give 10x Voucher + const newModifier = modifierTypes.VOUCHER_PREMIUM().newModifier(); + scene.addModifier(newModifier); + scene.playSound("item_fanfare"); + await showEncounterText(scene, i18next.t("battle:rewardGain", { modifierName: newModifier?.type.name })); + + await showEncounterDialogue(scene, `${namespace}.victory_2`, `${namespace}.speaker`); + scene.ui.clearText(); // Clears "Winstrate" title from screen as rewards get animated in + const machoBrace = generateModifierTypeOption(scene, modifierTypes.MYSTERY_ENCOUNTER_MACHO_BRACE)!; + machoBrace.type.tier = ModifierTier.MASTER; + setEncounterRewards(scene, { guaranteedModifierTypeOptions: [machoBrace], fillRemaining: false }); + encounter.doContinueEncounter = undefined; + leaveEncounterWithoutBattle(scene, false, MysteryEncounterMode.NO_BATTLE); + } else { + await initBattleWithEnemyConfig(scene, nextConfig); + } +} + +function endTrainerBattleAndShowDialogue(scene: BattleScene): Promise { + return new Promise(async resolve => { + if (scene.currentBattle.mysteryEncounter!.enemyPartyConfigs.length === 0) { + // Battle is over + const trainer = scene.currentBattle.trainer; + if (trainer) { + scene.tweens.add({ + targets: trainer, + x: "+=16", + y: "-=16", + alpha: 0, + ease: "Sine.easeInOut", + duration: 750, + onComplete: () => { + scene.field.remove(trainer, true); + } + }); + } + + await spawnNextTrainerOrEndEncounter(scene); + resolve(); // Wait for all dialogue/post battle stuff to complete before resolving + } else { + scene.arena.resetArenaEffects(); + const playerField = scene.getPlayerField(); + playerField.forEach((_, p) => scene.unshiftPhase(new ReturnPhase(scene, p))); + + for (const pokemon of scene.getParty()) { + // Only trigger form change when Eiscue is in Noice form + // Hardcoded Eiscue for now in case it is fused with another pokemon + if (pokemon.species.speciesId === Species.EISCUE && pokemon.hasAbility(Abilities.ICE_FACE) && pokemon.formIndex === 1) { + scene.triggerPokemonFormChange(pokemon, SpeciesFormChangeManualTrigger); + } + + pokemon.resetBattleData(); + applyPostBattleInitAbAttrs(PostBattleInitAbAttr, pokemon); + } + + scene.unshiftPhase(new ShowTrainerPhase(scene)); + // Hide the trainer and init next battle + const trainer = scene.currentBattle.trainer; + // Unassign previous trainer from battle so it isn't destroyed before animation completes + scene.currentBattle.trainer = null; + await spawnNextTrainerOrEndEncounter(scene); + if (trainer) { + scene.tweens.add({ + targets: trainer, + x: "+=16", + y: "-=16", + alpha: 0, + ease: "Sine.easeInOut", + duration: 750, + onComplete: () => { + scene.field.remove(trainer, true); + resolve(); + } + }); + } + } + }); +} + +function getVictorTrainerConfig(scene: BattleScene): EnemyPartyConfig { + return { + trainerType: TrainerType.VICTOR, + pokemonConfigs: [ + { + species: getPokemonSpecies(Species.SWELLOW), + isBoss: false, + abilityIndex: 0, // Guts + nature: Nature.ADAMANT, + moveSet: [Moves.FACADE, Moves.BRAVE_BIRD, Moves.PROTECT, Moves.QUICK_ATTACK], + modifierConfigs: [ + { + modifier: generateModifierType(scene, modifierTypes.FLAME_ORB) as PokemonHeldItemModifierType, + isTransferable: false + }, + { + modifier: generateModifierType(scene, modifierTypes.FOCUS_BAND) as PokemonHeldItemModifierType, + stackCount: 2, + isTransferable: false + }, + ] + }, + { + species: getPokemonSpecies(Species.OBSTAGOON), + isBoss: false, + abilityIndex: 1, // Guts + nature: Nature.ADAMANT, + moveSet: [Moves.FACADE, Moves.OBSTRUCT, Moves.NIGHT_SLASH, Moves.FIRE_PUNCH], + modifierConfigs: [ + { + modifier: generateModifierType(scene, modifierTypes.FLAME_ORB) as PokemonHeldItemModifierType, + isTransferable: false + }, + { + modifier: generateModifierType(scene, modifierTypes.LEFTOVERS) as PokemonHeldItemModifierType, + stackCount: 2, + isTransferable: false + } + ] + } + ] + }; +} + +function getVictoriaTrainerConfig(scene: BattleScene): EnemyPartyConfig { + return { + trainerType: TrainerType.VICTORIA, + pokemonConfigs: [ + { + species: getPokemonSpecies(Species.ROSERADE), + isBoss: false, + abilityIndex: 0, // Natural Cure + nature: Nature.CALM, + moveSet: [Moves.SYNTHESIS, Moves.SLUDGE_BOMB, Moves.GIGA_DRAIN, Moves.SLEEP_POWDER], + modifierConfigs: [ + { + modifier: generateModifierType(scene, modifierTypes.SOUL_DEW) as PokemonHeldItemModifierType, + isTransferable: false + }, + { + modifier: generateModifierType(scene, modifierTypes.QUICK_CLAW) as PokemonHeldItemModifierType, + stackCount: 2, + isTransferable: false + } + ] + }, + { + species: getPokemonSpecies(Species.GARDEVOIR), + isBoss: false, + formIndex: 1, + nature: Nature.TIMID, + moveSet: [Moves.PSYSHOCK, Moves.MOONBLAST, Moves.SHADOW_BALL, Moves.WILL_O_WISP], + modifierConfigs: [ + { + modifier: generateModifierType(scene, modifierTypes.ATTACK_TYPE_BOOSTER, [Type.PSYCHIC]) as PokemonHeldItemModifierType, + stackCount: 1, + isTransferable: false + }, + { + modifier: generateModifierType(scene, modifierTypes.ATTACK_TYPE_BOOSTER, [Type.FAIRY]) as PokemonHeldItemModifierType, + stackCount: 1, + isTransferable: false + } + ] + } + ] + }; +} + +function getViviTrainerConfig(scene: BattleScene): EnemyPartyConfig { + return { + trainerType: TrainerType.VIVI, + pokemonConfigs: [ + { + species: getPokemonSpecies(Species.SEAKING), + isBoss: false, + abilityIndex: 3, // Lightning Rod + nature: Nature.ADAMANT, + moveSet: [Moves.WATERFALL, Moves.MEGAHORN, Moves.KNOCK_OFF, Moves.REST], + modifierConfigs: [ + { + modifier: generateModifierType(scene, modifierTypes.BERRY, [BerryType.LUM]) as PokemonHeldItemModifierType, + stackCount: 2, + isTransferable: false + }, + { + modifier: generateModifierType(scene, modifierTypes.BASE_STAT_BOOSTER, [Stat.HP]) as PokemonHeldItemModifierType, + stackCount: 4, + isTransferable: false + } + ] + }, + { + species: getPokemonSpecies(Species.BRELOOM), + isBoss: false, + abilityIndex: 1, // Poison Heal + nature: Nature.JOLLY, + moveSet: [Moves.SPORE, Moves.SWORDS_DANCE, Moves.SEED_BOMB, Moves.DRAIN_PUNCH], + modifierConfigs: [ + { + modifier: generateModifierType(scene, modifierTypes.BASE_STAT_BOOSTER, [Stat.HP]) as PokemonHeldItemModifierType, + stackCount: 4, + isTransferable: false + }, + { + modifier: generateModifierType(scene, modifierTypes.TOXIC_ORB) as PokemonHeldItemModifierType, + isTransferable: false + } + ] + }, + { + species: getPokemonSpecies(Species.CAMERUPT), + isBoss: false, + formIndex: 1, + nature: Nature.CALM, + moveSet: [Moves.EARTH_POWER, Moves.FIRE_BLAST, Moves.YAWN, Moves.PROTECT], + modifierConfigs: [ + { + modifier: generateModifierType(scene, modifierTypes.QUICK_CLAW) as PokemonHeldItemModifierType, + stackCount: 3, + isTransferable: false + }, + ] + } + ] + }; +} + +function getVickyTrainerConfig(scene: BattleScene): EnemyPartyConfig { + return { + trainerType: TrainerType.VICKY, + pokemonConfigs: [ + { + species: getPokemonSpecies(Species.MEDICHAM), + isBoss: false, + formIndex: 1, + nature: Nature.IMPISH, + moveSet: [Moves.AXE_KICK, Moves.ICE_PUNCH, Moves.ZEN_HEADBUTT, Moves.BULLET_PUNCH], + modifierConfigs: [ + { + modifier: generateModifierType(scene, modifierTypes.SHELL_BELL) as PokemonHeldItemModifierType, + isTransferable: false + } + ] + } + ] + }; +} + +function getVitoTrainerConfig(scene: BattleScene): EnemyPartyConfig { + return { + trainerType: TrainerType.VITO, + pokemonConfigs: [ + { + species: getPokemonSpecies(Species.HISUI_ELECTRODE), + isBoss: false, + abilityIndex: 0, // Soundproof + nature: Nature.MODEST, + moveSet: [Moves.THUNDERBOLT, Moves.GIGA_DRAIN, Moves.FOUL_PLAY, Moves.THUNDER_WAVE], + modifierConfigs: [ + { + modifier: generateModifierType(scene, modifierTypes.BASE_STAT_BOOSTER, [Stat.SPD]) as PokemonHeldItemModifierType, + stackCount: 2, + isTransferable: false + } + ] + }, + { + species: getPokemonSpecies(Species.SWALOT), + isBoss: false, + abilityIndex: 2, // Gluttony + nature: Nature.QUIET, + moveSet: [Moves.SLUDGE_BOMB, Moves.GIGA_DRAIN, Moves.ICE_BEAM, Moves.EARTHQUAKE], + modifierConfigs: [ + { + modifier: generateModifierType(scene, modifierTypes.BERRY, [BerryType.SITRUS]) as PokemonHeldItemModifierType, + stackCount: 2, + }, + { + modifier: generateModifierType(scene, modifierTypes.BERRY, [BerryType.APICOT]) as PokemonHeldItemModifierType, + stackCount: 2, + }, + { + modifier: generateModifierType(scene, modifierTypes.BERRY, [BerryType.GANLON]) as PokemonHeldItemModifierType, + stackCount: 2, + }, + { + modifier: generateModifierType(scene, modifierTypes.BERRY, [BerryType.STARF]) as PokemonHeldItemModifierType, + stackCount: 2, + }, + { + modifier: generateModifierType(scene, modifierTypes.BERRY, [BerryType.SALAC]) as PokemonHeldItemModifierType, + stackCount: 2, + }, + { + modifier: generateModifierType(scene, modifierTypes.BERRY, [BerryType.LUM]) as PokemonHeldItemModifierType, + stackCount: 2, + }, + { + modifier: generateModifierType(scene, modifierTypes.BERRY, [BerryType.LANSAT]) as PokemonHeldItemModifierType, + stackCount: 2, + }, + { + modifier: generateModifierType(scene, modifierTypes.BERRY, [BerryType.LIECHI]) as PokemonHeldItemModifierType, + stackCount: 2, + }, + { + modifier: generateModifierType(scene, modifierTypes.BERRY, [BerryType.PETAYA]) as PokemonHeldItemModifierType, + stackCount: 2, + }, + { + modifier: generateModifierType(scene, modifierTypes.BERRY, [BerryType.ENIGMA]) as PokemonHeldItemModifierType, + stackCount: 2, + }, + { + modifier: generateModifierType(scene, modifierTypes.BERRY, [BerryType.LEPPA]) as PokemonHeldItemModifierType, + stackCount: 2, + } + ] + }, + { + species: getPokemonSpecies(Species.DODRIO), + isBoss: false, + abilityIndex: 2, // Tangled Feet + nature: Nature.JOLLY, + moveSet: [Moves.DRILL_PECK, Moves.QUICK_ATTACK, Moves.THRASH, Moves.KNOCK_OFF], + modifierConfigs: [ + { + modifier: generateModifierType(scene, modifierTypes.KINGS_ROCK) as PokemonHeldItemModifierType, + stackCount: 2, + isTransferable: false + } + ] + }, + { + species: getPokemonSpecies(Species.ALAKAZAM), + isBoss: false, + formIndex: 1, + nature: Nature.BOLD, + moveSet: [Moves.PSYCHIC, Moves.SHADOW_BALL, Moves.FOCUS_BLAST, Moves.THUNDERBOLT], + modifierConfigs: [ + { + modifier: generateModifierType(scene, modifierTypes.WIDE_LENS) as PokemonHeldItemModifierType, + stackCount: 2, + isTransferable: false + }, + ] + }, + { + species: getPokemonSpecies(Species.DARMANITAN), + isBoss: false, + abilityIndex: 0, // Sheer Force + nature: Nature.IMPISH, + moveSet: [Moves.EARTHQUAKE, Moves.U_TURN, Moves.FLARE_BLITZ, Moves.ROCK_SLIDE], + modifierConfigs: [ + { + modifier: generateModifierType(scene, modifierTypes.QUICK_CLAW) as PokemonHeldItemModifierType, + stackCount: 2, + isTransferable: false + }, + ] + } + ] + }; +} diff --git a/src/data/mystery-encounters/encounters/training-session-encounter.ts b/src/data/mystery-encounters/encounters/training-session-encounter.ts new file mode 100644 index 000000000000..6c0f1706fa5f --- /dev/null +++ b/src/data/mystery-encounters/encounters/training-session-encounter.ts @@ -0,0 +1,440 @@ +import { Ability, allAbilities } from "#app/data/ability"; +import { EnemyPartyConfig, initBattleWithEnemyConfig, leaveEncounterWithoutBattle, selectPokemonForOption, setEncounterRewards, } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { getNatureName, Nature } from "#app/data/nature"; +import { speciesStarters } from "#app/data/pokemon-species"; +import Pokemon, { PlayerPokemon } from "#app/field/pokemon"; +import { PokemonFormChangeItemModifier, PokemonHeldItemModifier } from "#app/modifier/modifier"; +import { AbilityAttr } from "#app/system/game-data"; +import PokemonData from "#app/system/pokemon-data"; +import { OptionSelectItem } from "#app/ui/abstact-option-select-ui-handler"; +import { isNullOrUndefined, randSeedShuffle } from "#app/utils"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterOptionBuilder } from "../mystery-encounter-option"; +import { getEncounterText, queueEncounterMessage, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import HeldModifierConfig from "#app/interfaces/held-modifier-config"; +import i18next from "i18next"; +import { getStatKey } from "#enums/stat"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** The i18n namespace for the encounter */ +const namespace = "mysteryEncounter:trainingSession"; + +/** + * Training Session encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3802 | GitHub Issue #3802} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const TrainingSessionEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.TRAINING_SESSION) + .withEncounterTier(MysteryEncounterTier.ULTRA) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withScenePartySizeRequirement(2, 6, true) // Must have at least 2 unfainted pokemon in party + .withHideWildIntroMessage(true) + .withIntroSpriteConfigs([ + { + spriteKey: "training_gear", + fileRoot: "mystery-encounters", + hasShadow: true, + y: 6, + x: 5, + yShadow: -2 + }, + ]) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + } + ]) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withHasDexProgress(true) + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.selected`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene): Promise => { + const encounter = scene.currentBattle.mysteryEncounter!; + const onPokemonSelected = (pokemon: PlayerPokemon) => { + encounter.misc = { + playerPokemon: pokemon, + }; + }; + + // Only Pokemon that are not KOed/legal can be trained + const selectableFilter = (pokemon: Pokemon) => { + const meetsReqs = pokemon.isAllowedInBattle(); + if (!meetsReqs) { + return getEncounterText(scene, `${namespace}.invalid_selection`) ?? null; + } + + return null; + }; + + return selectPokemonForOption(scene, onPokemonSelected, undefined, selectableFilter); + }) + .withOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + const playerPokemon: PlayerPokemon = encounter.misc.playerPokemon; + + // Spawn light training session with chosen pokemon + // Every 50 waves, add +1 boss segment, capping at 5 + const segments = Math.min( + 2 + Math.floor(scene.currentBattle.waveIndex / 50), + 5 + ); + const modifiers = new ModifiersHolder(); + const config = getEnemyConfig( + scene, + playerPokemon, + segments, + modifiers + ); + scene.removePokemonFromPlayerParty(playerPokemon, false); + + const onBeforeRewardsPhase = () => { + encounter.setDialogueToken("stat1", "-"); + encounter.setDialogueToken("stat2", "-"); + // Add the pokemon back to party with IV boost + const ivIndexes: any[] = []; + playerPokemon.ivs.forEach((iv, index) => { + if (iv < 31) { + ivIndexes.push({ iv: iv, index: index }); + } + }); + + // Improves 2 random non-maxed IVs + // +10 if IV is < 10, +5 if between 10-20, and +3 if > 20 + // A 0-4 starting IV will cap in 6 encounters (assuming you always rolled that IV) + // 5-14 starting IV caps in 5 encounters + // 15-19 starting IV caps in 4 encounters + // 20-24 starting IV caps in 3 encounters + // 25-27 starting IV caps in 2 encounters + let improvedCount = 0; + while (ivIndexes.length > 0 && improvedCount < 2) { + randSeedShuffle(ivIndexes); + const ivToChange = ivIndexes.pop(); + let newVal = ivToChange.iv; + if (improvedCount === 0) { + encounter.setDialogueToken( + "stat1", + i18next.t(getStatKey(ivToChange.index)) ?? "" + ); + } else { + encounter.setDialogueToken( + "stat2", + i18next.t(getStatKey(ivToChange.index)) ?? "" + ); + } + + // Corrects required encounter breakpoints to be continuous for all IV values + if (ivToChange.iv <= 21 && ivToChange.iv - (1 % 5) === 0) { + newVal += 1; + } + + newVal += ivToChange.iv <= 10 ? 10 : ivToChange.iv <= 20 ? 5 : 3; + newVal = Math.min(newVal, 31); + playerPokemon.ivs[ivToChange.index] = newVal; + improvedCount++; + } + + if (improvedCount > 0) { + playerPokemon.calculateStats(); + scene.gameData.updateSpeciesDexIvs( + playerPokemon.species.getRootSpeciesId(true), + playerPokemon.ivs + ); + scene.gameData.setPokemonCaught(playerPokemon, false); + } + + // Add pokemon and mods back + scene.getParty().push(playerPokemon); + for (const mod of modifiers.value) { + scene.addModifier(mod, true, false, false, true); + } + scene.updateModifiers(true); + queueEncounterMessage(scene, `${namespace}.option.1.finished`); + }; + + setEncounterRewards(scene, { fillRemaining: true }, undefined, onBeforeRewardsPhase); + + return initBattleWithEnemyConfig(scene, config); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withHasDexProgress(true) + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + secondOptionPrompt: `${namespace}.option.2.select_prompt`, + selected: [ + { + text: `${namespace}.option.selected`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene): Promise => { + // Open menu for selecting pokemon and Nature + const encounter = scene.currentBattle.mysteryEncounter!; + const natures = new Array(25).fill(null).map((val, i) => i as Nature); + const onPokemonSelected = (pokemon: PlayerPokemon) => { + // Return the options for nature selection + return natures.map((nature: Nature) => { + const option: OptionSelectItem = { + label: getNatureName(nature, true, true, true, scene.uiTheme), + handler: () => { + // Pokemon and second option selected + encounter.setDialogueToken("nature", getNatureName(nature)); + encounter.misc = { + playerPokemon: pokemon, + chosenNature: nature, + }; + return true; + }, + }; + return option; + }); + }; + + // Only Pokemon that are not KOed/legal can be trained + const selectableFilter = (pokemon: Pokemon) => { + const meetsReqs = pokemon.isAllowedInBattle(); + if (!meetsReqs) { + return getEncounterText(scene, `${namespace}.invalid_selection`) ?? null; + } + + return null; + }; + + return selectPokemonForOption(scene, onPokemonSelected, undefined, selectableFilter); + }) + .withOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + const playerPokemon: PlayerPokemon = encounter.misc.playerPokemon; + + // Spawn medium training session with chosen pokemon + // Every 40 waves, add +1 boss segment, capping at 6 + const segments = Math.min( + 2 + Math.floor(scene.currentBattle.waveIndex / 40), + 6 + ); + const modifiers = new ModifiersHolder(); + const config = getEnemyConfig( + scene, + playerPokemon, + segments, + modifiers + ); + scene.removePokemonFromPlayerParty(playerPokemon, false); + + const onBeforeRewardsPhase = () => { + queueEncounterMessage(scene, `${namespace}.option.2.finished`); + // Add the pokemon back to party with Nature change + playerPokemon.setNature(encounter.misc.chosenNature); + scene.gameData.setPokemonCaught(playerPokemon, false); + + // Add pokemon and modifiers back + scene.getParty().push(playerPokemon); + for (const mod of modifiers.value) { + mod.pokemonId = playerPokemon.id; + scene.addModifier(mod, true, false, false, true); + } + scene.updateModifiers(true); + }; + + setEncounterRewards(scene, { fillRemaining: true }, undefined, onBeforeRewardsPhase); + + return initBattleWithEnemyConfig(scene, config); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withHasDexProgress(true) + .withDialogue({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + secondOptionPrompt: `${namespace}.option.3.select_prompt`, + selected: [ + { + text: `${namespace}.option.selected`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene): Promise => { + // Open menu for selecting pokemon and ability to learn + const encounter = scene.currentBattle.mysteryEncounter!; + const onPokemonSelected = (pokemon: PlayerPokemon) => { + // Return the options for ability selection + const speciesForm = !!pokemon.getFusionSpeciesForm() + ? pokemon.getFusionSpeciesForm() + : pokemon.getSpeciesForm(); + const abilityCount = speciesForm.getAbilityCount(); + const abilities: Ability[] = new Array(abilityCount) + .fill(null) + .map((val, i) => allAbilities[speciesForm.getAbility(i)]); + + const optionSelectItems: OptionSelectItem[] = []; + abilities.forEach((ability: Ability, index) => { + if (!optionSelectItems.some(o => o.label === ability.name)) { + const option: OptionSelectItem = { + label: ability.name, + handler: () => { + // Pokemon and ability selected + encounter.setDialogueToken("ability", ability.name); + encounter.misc = { + playerPokemon: pokemon, + abilityIndex: index, + }; + return true; + }, + onHover: () => { + showEncounterText(scene, ability.description, 0, 0, false); + }, + }; + optionSelectItems.push(option); + } + }); + + return optionSelectItems; + }; + + // Only Pokemon that are not KOed/legal can be trained + const selectableFilter = (pokemon: Pokemon) => { + const meetsReqs = pokemon.isAllowedInBattle(); + if (!meetsReqs) { + return getEncounterText(scene, `${namespace}.invalid_selection`) ?? null; + } + + return null; + }; + + return selectPokemonForOption(scene, onPokemonSelected, undefined, selectableFilter); + }) + .withOptionPhase(async (scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + const playerPokemon: PlayerPokemon = encounter.misc.playerPokemon; + + // Spawn hard training session with chosen pokemon + // Every 30 waves, add +1 boss segment, capping at 6 + // Also starts with +1 to all stats + const segments = Math.min(2 + Math.floor(scene.currentBattle.waveIndex / 30), 6); + const modifiers = new ModifiersHolder(); + const config = getEnemyConfig(scene, playerPokemon, segments, modifiers); + config.pokemonConfigs![0].tags = [ + BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON, + ]; + scene.removePokemonFromPlayerParty(playerPokemon, false); + + const onBeforeRewardsPhase = () => { + queueEncounterMessage(scene, `${namespace}.option.3.finished`); + // Add the pokemon back to party with ability change + const abilityIndex = encounter.misc.abilityIndex; + if (!!playerPokemon.getFusionSpeciesForm()) { + playerPokemon.fusionAbilityIndex = abilityIndex; + if (!isNullOrUndefined(playerPokemon.fusionSpecies?.speciesId) && speciesStarters.hasOwnProperty(playerPokemon.fusionSpecies!.speciesId)) { + scene.gameData.starterData[playerPokemon.fusionSpecies!.speciesId] + .abilityAttr |= + abilityIndex !== 1 || playerPokemon.fusionSpecies!.ability2 + ? Math.pow(2, playerPokemon.fusionAbilityIndex) + : AbilityAttr.ABILITY_HIDDEN; + } + } else { + playerPokemon.abilityIndex = abilityIndex; + if ( + speciesStarters.hasOwnProperty(playerPokemon.species.speciesId) + ) { + scene.gameData.starterData[ + playerPokemon.species.speciesId + ].abilityAttr |= + abilityIndex !== 1 || playerPokemon.species.ability2 + ? Math.pow(2, playerPokemon.abilityIndex) + : AbilityAttr.ABILITY_HIDDEN; + } + } + + playerPokemon.getAbility(); + playerPokemon.calculateStats(); + scene.gameData.setPokemonCaught(playerPokemon, false); + + // Add pokemon and mods back + scene.getParty().push(playerPokemon); + for (const mod of modifiers.value) { + scene.addModifier(mod, true, false, false, true); + } + scene.updateModifiers(true); + }; + + setEncounterRewards(scene, { fillRemaining: true }, undefined, onBeforeRewardsPhase); + + return initBattleWithEnemyConfig(scene, config); + }) + .build() + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.4.label`, + buttonTooltip: `${namespace}.option.4.tooltip`, + selected: [ + { + text: `${namespace}.option.4.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Leave encounter with no rewards or exp + leaveEncounterWithoutBattle(scene, true); + return true; + } + ) + .build(); + +function getEnemyConfig(scene: BattleScene, playerPokemon: PlayerPokemon, segments: number, modifiers: ModifiersHolder): EnemyPartyConfig { + playerPokemon.resetSummonData(); + + // Passes modifiers by reference + modifiers.value = playerPokemon.getHeldItems().filter(m => !(m instanceof PokemonFormChangeItemModifier)); + const modifierConfigs = modifiers.value.map((mod) => { + return { + modifier: mod + }; + }) as HeldModifierConfig[]; + + const data = new PokemonData(playerPokemon); + return { + pokemonConfigs: [ + { + species: playerPokemon.species, + isBoss: true, + bossSegments: segments, + formIndex: playerPokemon.formIndex, + level: playerPokemon.level, + dataSource: data, + modifierConfigs: modifierConfigs, + }, + ], + }; +} + +class ModifiersHolder { + public value: PokemonHeldItemModifier[] = []; + + constructor() {} +} diff --git a/src/data/mystery-encounters/encounters/trash-to-treasure-encounter.ts b/src/data/mystery-encounters/encounters/trash-to-treasure-encounter.ts new file mode 100644 index 000000000000..ec6291f2a8cd --- /dev/null +++ b/src/data/mystery-encounters/encounters/trash-to-treasure-encounter.ts @@ -0,0 +1,222 @@ +import { EnemyPartyConfig, EnemyPokemonConfig, generateModifierType, initBattleWithEnemyConfig, leaveEncounterWithoutBattle, loadCustomMovesForEncounter, setEncounterRewards, transitionMysteryEncounterIntroVisuals, } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { modifierTypes, PokemonHeldItemModifierType } from "#app/modifier/modifier-type"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterOptionBuilder } from "../mystery-encounter-option"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { Species } from "#enums/species"; +import { HitHealModifier, PokemonHeldItemModifier, TurnHealModifier } from "#app/modifier/modifier"; +import { applyModifierTypeToPlayerPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import i18next from "#app/plugins/i18n"; +import { ModifierTier } from "#app/modifier/modifier-tier"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { Moves } from "#enums/moves"; +import { BattlerIndex } from "#app/battle"; +import { PokemonMove } from "#app/field/pokemon"; +import { ModifierRewardPhase } from "#app/phases/modifier-reward-phase"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for this encounter */ +const namespace = "mysteryEncounter:trashToTreasure"; + +const SOUND_EFFECT_WAIT_TIME = 700; + +/** + * Trash to Treasure encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3809 | GitHub Issue #3809} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const TrashToTreasureEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.TRASH_TO_TREASURE) + .withEncounterTier(MysteryEncounterTier.ULTRA) + .withSceneWaveRangeRequirement(60, CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES[1]) + .withMaxAllowedEncounters(1) + .withIntroSpriteConfigs([ + { + spriteKey: Species.GARBODOR.toString() + "-gigantamax", + fileRoot: "pokemon", + hasShadow: false, + disableAnimation: true, + scale: 1.5, + y: 8, + tint: 0.4 + } + ]) + .withAutoHideIntroVisuals(false) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + ]) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + + // Calculate boss mon + const bossSpecies = getPokemonSpecies(Species.GARBODOR); + const pokemonConfig: EnemyPokemonConfig = { + species: bossSpecies, + isBoss: true, + formIndex: 1, // Gmax + bossSegmentModifier: 1, // +1 Segment from normal + moveSet: [Moves.PAYBACK, Moves.GUNK_SHOT, Moves.STOMPING_TANTRUM, Moves.DRAIN_PUNCH] + }; + const config: EnemyPartyConfig = { + levelAdditiveMultiplier: 1, + pokemonConfigs: [pokemonConfig], + disableSwitch: true + }; + encounter.enemyPartyConfigs = [config]; + + // Load animations/sfx for Garbodor fight start moves + loadCustomMovesForEncounter(scene, [Moves.TOXIC, Moves.AMNESIA]); + + scene.loadSe("PRSFX- Dig2", "battle_anims", "PRSFX- Dig2.wav"); + scene.loadSe("PRSFX- Venom Drench", "battle_anims", "PRSFX- Venom Drench.wav"); + + return true; + }) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }) + .withPreOptionPhase(async (scene: BattleScene) => { + // Play Dig2 and then Venom Drench sfx + doGarbageDig(scene); + }) + .withOptionPhase(async (scene: BattleScene) => { + // Gain 2 Leftovers and 2 Shell Bell + transitionMysteryEncounterIntroVisuals(scene); + await tryApplyDigRewardItems(scene); + + // Give the player the Black Sludge curse + scene.unshiftPhase(new ModifierRewardPhase(scene, modifierTypes.MYSTERY_ENCOUNTER_BLACK_SLUDGE)); + leaveEncounterWithoutBattle(scene, true); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }) + .withOptionPhase(async (scene: BattleScene) => { + // Investigate garbage, battle Gmax Garbodor + scene.setFieldScale(0.75); + await showEncounterText(scene, `${namespace}.option.2.selected_2`); + transitionMysteryEncounterIntroVisuals(scene); + + const encounter = scene.currentBattle.mysteryEncounter!; + + setEncounterRewards(scene, { guaranteedModifierTiers: [ModifierTier.ROGUE, ModifierTier.ROGUE, ModifierTier.ULTRA, ModifierTier.GREAT], fillRemaining: true }); + encounter.startOfBattleEffects.push( + { + sourceBattlerIndex: BattlerIndex.ENEMY, + targets: [BattlerIndex.PLAYER], + move: new PokemonMove(Moves.TOXIC), + ignorePp: true + }, + { + sourceBattlerIndex: BattlerIndex.ENEMY, + targets: [BattlerIndex.ENEMY], + move: new PokemonMove(Moves.AMNESIA), + ignorePp: true + }); + await initBattleWithEnemyConfig(scene, encounter.enemyPartyConfigs[0]); + }) + .build() + ) + .build(); + +async function tryApplyDigRewardItems(scene: BattleScene) { + const shellBell = generateModifierType(scene, modifierTypes.SHELL_BELL) as PokemonHeldItemModifierType; + const leftovers = generateModifierType(scene, modifierTypes.LEFTOVERS) as PokemonHeldItemModifierType; + + const party = scene.getParty(); + + // Iterate over the party until an item was successfully given + // First leftovers + for (const pokemon of party) { + const heldItems = scene.findModifiers(m => m instanceof PokemonHeldItemModifier + && m.pokemonId === pokemon.id, true) as PokemonHeldItemModifier[]; + const existingLeftovers = heldItems.find(m => m instanceof TurnHealModifier) as TurnHealModifier; + + if (!existingLeftovers || existingLeftovers.getStackCount() < existingLeftovers.getMaxStackCount(scene)) { + await applyModifierTypeToPlayerPokemon(scene, pokemon, leftovers); + break; + } + } + + // Second leftovers + for (const pokemon of party) { + const heldItems = scene.findModifiers(m => m instanceof PokemonHeldItemModifier + && m.pokemonId === pokemon.id, true) as PokemonHeldItemModifier[]; + const existingLeftovers = heldItems.find(m => m instanceof TurnHealModifier) as TurnHealModifier; + + if (!existingLeftovers || existingLeftovers.getStackCount() < existingLeftovers.getMaxStackCount(scene)) { + await applyModifierTypeToPlayerPokemon(scene, pokemon, leftovers); + break; + } + } + + scene.playSound("item_fanfare"); + await showEncounterText(scene, i18next.t("battle:rewardGain", { modifierName: "2 " + leftovers.name }), null, undefined, true); + + // First Shell bell + for (const pokemon of party) { + const heldItems = scene.findModifiers(m => m instanceof PokemonHeldItemModifier + && m.pokemonId === pokemon.id, true) as PokemonHeldItemModifier[]; + const existingShellBell = heldItems.find(m => m instanceof HitHealModifier) as HitHealModifier; + + if (!existingShellBell || existingShellBell.getStackCount() < existingShellBell.getMaxStackCount(scene)) { + await applyModifierTypeToPlayerPokemon(scene, pokemon, shellBell); + break; + } + } + + // Second Shell bell + for (const pokemon of party) { + const heldItems = scene.findModifiers(m => m instanceof PokemonHeldItemModifier + && m.pokemonId === pokemon.id, true) as PokemonHeldItemModifier[]; + const existingShellBell = heldItems.find(m => m instanceof HitHealModifier) as HitHealModifier; + + if (!existingShellBell || existingShellBell.getStackCount() < existingShellBell.getMaxStackCount(scene)) { + await applyModifierTypeToPlayerPokemon(scene, pokemon, shellBell); + break; + } + } + + scene.playSound("item_fanfare"); + await showEncounterText(scene, i18next.t("battle:rewardGain", { modifierName: "2 " + shellBell.name }), null, undefined, true); +} + +async function doGarbageDig(scene: BattleScene) { + scene.playSound("battle_anims/PRSFX- Dig2"); + scene.time.delayedCall(SOUND_EFFECT_WAIT_TIME, () => { + scene.playSound("battle_anims/PRSFX- Dig2"); + scene.playSound("battle_anims/PRSFX- Venom Drench", { volume: 2 }); + }); + scene.time.delayedCall(SOUND_EFFECT_WAIT_TIME * 2, () => { + scene.playSound("battle_anims/PRSFX- Dig2"); + }); +} diff --git a/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts b/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts new file mode 100644 index 000000000000..f9148b87f9bd --- /dev/null +++ b/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts @@ -0,0 +1,268 @@ +import { MysteryEncounterOptionBuilder } from "#app/data/mystery-encounters/mystery-encounter-option"; +import { EnemyPartyConfig, initBattleWithEnemyConfig, leaveEncounterWithoutBattle, setEncounterExp, setEncounterRewards } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { CHARMING_MOVES } from "#app/data/mystery-encounters/requirements/requirement-groups"; +import Pokemon, { EnemyPokemon, PokemonMove } from "#app/field/pokemon"; +import { getPartyLuckValue } from "#app/modifier/modifier-type"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MoveRequirement, PersistentModifierRequirement } from "../mystery-encounter-requirements"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { TrainerSlot } from "#app/data/trainer-config"; +import { catchPokemon, getHighestLevelPlayerPokemon, getSpriteKeysFromPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import PokemonData from "#app/system/pokemon-data"; +import { speciesEggMoves } from "#app/data/egg-moves"; +import { isNullOrUndefined, randSeedInt } from "#app/utils"; +import { Moves } from "#enums/moves"; +import { BattlerIndex } from "#app/battle"; +import { SelfStatusMove } from "#app/data/move"; +import { PokeballType } from "#enums/pokeball"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { queueEncounterMessage } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { BerryModifier } from "#app/modifier/modifier"; +import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; +import { Stat } from "#enums/stat"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; + +/** the i18n namespace for the encounter */ +const namespace = "mysteryEncounter:uncommonBreed"; + +/** + * Uncommon Breed encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3811 | GitHub Issue #3811} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const UncommonBreedEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.UNCOMMON_BREED) + .withEncounterTier(MysteryEncounterTier.COMMON) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withCatchAllowed(true) + .withHideWildIntroMessage(true) + .withIntroSpriteConfigs([]) // Set in onInit() + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + ]) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter!; + + // Calculate boss mon + // Level equal to 2 below highest party member + const level = getHighestLevelPlayerPokemon(scene).level - 2; + const species = scene.arena.randomSpecies(scene.currentBattle.waveIndex, level, 0, getPartyLuckValue(scene.getParty()), true); + const pokemon = new EnemyPokemon(scene, species, level, TrainerSlot.NONE, true); + const speciesRootForm = pokemon.species.getRootSpeciesId(); + + // Pokemon will always have one of its egg moves in its moveset + if (speciesEggMoves.hasOwnProperty(speciesRootForm)) { + const eggMoves: Moves[] = speciesEggMoves[speciesRootForm]; + const eggMoveIndex = randSeedInt(4); + const randomEggMove: Moves = eggMoves[eggMoveIndex]; + encounter.misc = { + eggMove: randomEggMove + }; + if (pokemon.moveset.length < 4) { + pokemon.moveset.push(new PokemonMove(randomEggMove)); + } else { + pokemon.moveset[0] = new PokemonMove(randomEggMove); + } + } + + encounter.misc.pokemon = pokemon; + + const config: EnemyPartyConfig = { + pokemonConfigs: [{ + level: level, + species: species, + dataSource: new PokemonData(pokemon), + isBoss: false, + tags: [BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON], + mysteryEncounterBattleEffects: (pokemon: Pokemon) => { + queueEncounterMessage(pokemon.scene, `${namespace}.option.1.stat_boost`); + pokemon.scene.unshiftPhase(new StatStageChangePhase(pokemon.scene, pokemon.getBattlerIndex(), true, [Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD], 1)); + } + }], + }; + encounter.enemyPartyConfigs = [config]; + + const { spriteKey, fileRoot } = getSpriteKeysFromPokemon(pokemon); + encounter.spriteConfigs = [ + { + spriteKey: spriteKey, + fileRoot: fileRoot, + hasShadow: true, + x: -5, + repeat: true, + isPokemon: true + }, + ]; + + encounter.setDialogueToken("enemyPokemon", pokemon.getNameToRender()); + scene.loadSe("PRSFX- Spotlight2", "battle_anims", "PRSFX- Spotlight2.wav"); + return true; + }) + .withOnVisualsStart((scene: BattleScene) => { + // Animate the pokemon + const encounter = scene.currentBattle.mysteryEncounter!; + const pokemonSprite = encounter.introVisuals!.getSprites(); + + scene.tweens.add({ // Bounce at the end + targets: pokemonSprite, + duration: 300, + ease: "Cubic.easeOut", + yoyo: true, + y: "-=20", + loop: 1, + }); + + scene.time.delayedCall(500, () => scene.playSound("battle_anims/PRSFX- Spotlight2")); + return true; + }) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Pick battle + const encounter = scene.currentBattle.mysteryEncounter!; + + const eggMove = encounter.misc.eggMove; + if (!isNullOrUndefined(eggMove)) { + // Check what type of move the egg move is to determine target + const pokemonMove = new PokemonMove(eggMove); + const move = pokemonMove.getMove(); + const target = move instanceof SelfStatusMove ? BattlerIndex.ENEMY : BattlerIndex.PLAYER; + + encounter.startOfBattleEffects.push( + { + sourceBattlerIndex: BattlerIndex.ENEMY, + targets: [target], + move: pokemonMove, + ignorePp: true + }); + } + + setEncounterRewards(scene, { fillRemaining: true }); + await initBattleWithEnemyConfig(scene, encounter.enemyPartyConfigs[0]); + } + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL) + .withSceneRequirement(new PersistentModifierRequirement("BerryModifier", 4)) // Will set option2PrimaryName and option2PrimaryMove dialogue tokens automatically + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + disabledButtonTooltip: `${namespace}.option.2.disabled_tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected` + } + ] + }) + .withOptionPhase(async (scene: BattleScene) => { + // Give it some food + + // Remove 4 random berries from player's party + // Get all player berry items, remove from party, and store reference + const berryItems: BerryModifier[]= scene.findModifiers(m => m instanceof BerryModifier) as BerryModifier[]; + for (let i = 0; i < 4; i++) { + const index = randSeedInt(berryItems.length); + const randBerry = berryItems[index]; + randBerry.stackCount--; + if (randBerry.stackCount === 0) { + scene.removeModifier(randBerry); + berryItems.splice(index, 1); + } + } + await scene.updateModifiers(true, true); + + // Pokemon joins the team, with 2 egg moves + const encounter = scene.currentBattle.mysteryEncounter!; + const pokemon = encounter.misc.pokemon; + + // Give 1 additional egg move + const previousEggMove = encounter.misc.eggMove; + const speciesRootForm = pokemon.species.getRootSpeciesId(); + if (speciesEggMoves.hasOwnProperty(speciesRootForm)) { + const eggMoves: Moves[] = speciesEggMoves[speciesRootForm]; + let randomEggMove: Moves = eggMoves[randSeedInt(4)]; + while (randomEggMove === previousEggMove) { + randomEggMove = eggMoves[randSeedInt(4)]; + } + if (pokemon.moveset.length < 4) { + pokemon.moveset.push(new PokemonMove(randomEggMove)); + } else { + pokemon.moveset[1] = new PokemonMove(randomEggMove); + } + } + + await catchPokemon(scene, pokemon, null, PokeballType.POKEBALL, false); + setEncounterRewards(scene, { fillRemaining: true }); + leaveEncounterWithoutBattle(scene); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL) + .withPrimaryPokemonRequirement(new MoveRequirement(CHARMING_MOVES)) // Will set option2PrimaryName and option2PrimaryMove dialogue tokens automatically + .withDialogue({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + disabledButtonTooltip: `${namespace}.option.3.disabled_tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected` + } + ] + }) + .withOptionPhase(async (scene: BattleScene) => { + // Attract the pokemon with a move + // Pokemon joins the team, with 2 egg moves and IVs rolled an additional time + const encounter = scene.currentBattle.mysteryEncounter!; + const pokemon = encounter.misc.pokemon; + + // Give 1 additional egg move + const previousEggMove = encounter.misc.eggMove; + const speciesRootForm = pokemon.species.getRootSpeciesId(); + if (speciesEggMoves.hasOwnProperty(speciesRootForm)) { + const eggMoves: Moves[] = speciesEggMoves[speciesRootForm]; + let randomEggMove: Moves = eggMoves[randSeedInt(4)]; + while (randomEggMove === previousEggMove) { + randomEggMove = eggMoves[randSeedInt(4)]; + } + if (pokemon.moveset.length < 4) { + pokemon.moveset.push(new PokemonMove(randomEggMove)); + } else { + pokemon.moveset[1] = new PokemonMove(randomEggMove); + } + } + + // Roll IVs a second time + pokemon.ivs = pokemon.ivs.map(iv => { + const newValue = randSeedInt(31); + return newValue > iv ? newValue : iv; + }); + + await catchPokemon(scene, pokemon, null, PokeballType.POKEBALL, false); + if (encounter.selectedOption?.primaryPokemon?.id) { + setEncounterExp(scene, encounter.selectedOption.primaryPokemon.id, pokemon.getExpValue(), false); + } + setEncounterRewards(scene, { fillRemaining: true }); + leaveEncounterWithoutBattle(scene); + }) + .build() + ) + .build(); diff --git a/src/data/mystery-encounters/encounters/weird-dream-encounter.ts b/src/data/mystery-encounters/encounters/weird-dream-encounter.ts new file mode 100644 index 000000000000..476cc98f503e --- /dev/null +++ b/src/data/mystery-encounters/encounters/weird-dream-encounter.ts @@ -0,0 +1,542 @@ +import { Type } from "#app/data/type"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { Species } from "#enums/species"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MysteryEncounterOptionBuilder } from "../mystery-encounter-option"; +import { leaveEncounterWithoutBattle, setEncounterRewards, } from "../utils/encounter-phase-utils"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { PlayerPokemon, PokemonMove } from "#app/field/pokemon"; +import { IntegerHolder, isNullOrUndefined, randSeedInt, randSeedShuffle } from "#app/utils"; +import PokemonSpecies, { allSpecies, getPokemonSpecies } from "#app/data/pokemon-species"; +import { HiddenAbilityRateBoosterModifier, PokemonFormChangeItemModifier, PokemonHeldItemModifier } from "#app/modifier/modifier"; +import { achvs } from "#app/system/achv"; +import { speciesEggMoves } from "#app/data/egg-moves"; +import { MysteryEncounterPokemonData } from "#app/data/mystery-encounters/mystery-encounter-pokemon-data"; +import { queueEncounterMessage, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { modifierTypes } from "#app/modifier/modifier-type"; +import i18next from "#app/plugins/i18n"; +import { doPokemonTransformationSequence, TransformationScreenPosition } from "#app/data/mystery-encounters/utils/encounter-transformation-sequence"; +import { getLevelTotalExp } from "#app/data/exp"; +import { Stat } from "#enums/stat"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES, GameModes } from "#app/game-mode"; + +/** i18n namespace for encounter */ +const namespace = "mysteryEncounter:weirdDream"; + +/** Exclude Ultra Beasts, Paradox, Eternatus, and all legendary/mythical/trio pokemon that are below 570 BST */ +const EXCLUDED_TRANSFORMATION_SPECIES = [ + Species.ETERNATUS, + /** UBs */ + Species.NIHILEGO, + Species.BUZZWOLE, + Species.PHEROMOSA, + Species.XURKITREE, + Species.CELESTEELA, + Species.KARTANA, + Species.GUZZLORD, + Species.POIPOLE, + Species.NAGANADEL, + Species.STAKATAKA, + Species.BLACEPHALON, + /** Paradox */ + Species.GREAT_TUSK, + Species.SCREAM_TAIL, + Species.BRUTE_BONNET, + Species.FLUTTER_MANE, + Species.SLITHER_WING, + Species.SANDY_SHOCKS, + Species.ROARING_MOON, + Species.WALKING_WAKE, + Species.GOUGING_FIRE, + Species.RAGING_BOLT, + Species.IRON_TREADS, + Species.IRON_BUNDLE, + Species.IRON_HANDS, + Species.IRON_JUGULIS, + Species.IRON_MOTH, + Species.IRON_THORNS, + Species.IRON_VALIANT, + Species.IRON_LEAVES, + Species.IRON_BOULDER, + Species.IRON_CROWN, + /** These are banned so they don't appear in the < 570 BST pool */ + Species.COSMOG, + Species.MELTAN, + Species.KUBFU, + Species.COSMOEM, + Species.POIPOLE, + Species.TERAPAGOS, + Species.TYPE_NULL, + Species.CALYREX, + Species.NAGANADEL, + Species.URSHIFU, + Species.OGERPON, + Species.OKIDOGI, + Species.MUNKIDORI, + Species.FEZANDIPITI, +]; + +const SUPER_LEGENDARY_BST_THRESHOLD = 600; +const NON_LEGENDARY_BST_THRESHOLD = 570; +const GAIN_OLD_GATEAU_ITEM_BST_THRESHOLD = 450; + +/** + * Value ranges of the resulting species BST transformations after adding values to original species + * 2 Pokemon in the party use this range + */ +const HIGH_BST_TRANSFORM_BASE_VALUES: [number, number] = [90, 110]; +/** + * Value ranges of the resulting species BST transformations after adding values to original species + * All remaining Pokemon in the party use this range + */ +const STANDARD_BST_TRANSFORM_BASE_VALUES: [number, number] = [40, 50]; + +/** + * Weird Dream encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3822 | GitHub Issue #3822} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const WeirdDreamEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.WEIRD_DREAM) + .withEncounterTier(MysteryEncounterTier.ROGUE) + .withDisabledGameModes(GameModes.CHALLENGE) + .withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES) + .withIntroSpriteConfigs([ + { + spriteKey: "weird_dream_woman", + fileRoot: "mystery-encounters", + hasShadow: true, + y: 11, + yShadow: 6, + x: 4 + }, + ]) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.intro_dialogue`, + }, + ]) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOnInit((scene: BattleScene) => { + scene.loadBgm("mystery_encounter_weird_dream", "mystery_encounter_weird_dream.mp3"); + return true; + }) + .withOnVisualsStart((scene: BattleScene) => { + // Change the bgm + scene.fadeOutBgm(3000, false); + scene.time.delayedCall(3000, () => { + scene.playBgm("mystery_encounter_weird_dream"); + }); + + return true; + }) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withHasDexProgress(true) + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + } + ], + }) + .withPreOptionPhase(async (scene: BattleScene) => { + // Play the animation as the player goes through the dialogue + scene.time.delayedCall(1000, () => { + doShowDreamBackground(scene); + }); + + // Calculate all the newly transformed Pokemon and begin asset load + const teamTransformations = getTeamTransformations(scene); + const loadAssets = teamTransformations.map(t => (t.newPokemon as PlayerPokemon).loadAssets()); + scene.currentBattle.mysteryEncounter!.misc = { + teamTransformations, + loadAssets + }; + }) + .withOptionPhase(async (scene: BattleScene) => { + // Starts cutscene dialogue, but does not await so that cutscene plays as player goes through dialogue + const cutsceneDialoguePromise = showEncounterText(scene, `${namespace}.option.1.cutscene`); + + // Change the entire player's party + // Wait for all new Pokemon assets to be loaded before showing transformation animations + await Promise.all(scene.currentBattle.mysteryEncounter!.misc.loadAssets); + const transformations = scene.currentBattle.mysteryEncounter!.misc.teamTransformations; + + // If there are 1-3 transformations, do them centered back to back + // Otherwise, the first 3 transformations are executed side-by-side, then any remaining 1-3 transformations occur in those same respective positions + if (transformations.length <= 3) { + for (const transformation of transformations) { + const pokemon1 = transformation.previousPokemon; + const pokemon2 = transformation.newPokemon; + + await doPokemonTransformationSequence(scene, pokemon1, pokemon2, TransformationScreenPosition.CENTER); + } + } else { + await doSideBySideTransformations(scene, transformations); + } + + // Make sure player has finished cutscene dialogue + await cutsceneDialoguePromise; + + doHideDreamBackground(scene); + await showEncounterText(scene, `${namespace}.option.1.dream_complete`); + + await doNewTeamPostProcess(scene, transformations); + setEncounterRewards(scene, { guaranteedModifierTypeFuncs: [modifierTypes.MEMORY_MUSHROOM, modifierTypes.ROGUE_BALL, modifierTypes.MINT, modifierTypes.MINT]}); + leaveEncounterWithoutBattle(scene, true); + }) + .build() + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Reduce party levels by 20% + for (const pokemon of scene.getParty()) { + pokemon.level = Math.max(Math.ceil(0.8 * pokemon.level), 1); + pokemon.exp = getLevelTotalExp(pokemon.level, pokemon.species.growthRate); + pokemon.levelExp = 0; + + pokemon.calculateStats(); + pokemon.updateInfo(); + } + + leaveEncounterWithoutBattle(scene, true); + return true; + } + ) + .build(); + +interface PokemonTransformation { + previousPokemon: PlayerPokemon; + newSpecies: PokemonSpecies; + newPokemon: PlayerPokemon; + heldItems: PokemonHeldItemModifier[]; +} + +function getTeamTransformations(scene: BattleScene): PokemonTransformation[] { + const party = scene.getParty(); + // Removes all pokemon from the party + const alreadyUsedSpecies: PokemonSpecies[] = []; + const pokemonTransformations: PokemonTransformation[] = party.map(p => { + return { + previousPokemon: p + } as PokemonTransformation; + }); + + // Only 1 Pokemon can be transformed into BST higher than 600 + let hasPokemonInSuperLegendaryBstThreshold = false; + // Only 1 other Pokemon can be transformed into BST between 570-600 + let hasPokemonInLegendaryBstThreshold = false; + + // First, roll 2 of the party members to new Pokemon at a +90 to +110 BST difference + // Then, roll the remainder of the party members at a +40 to +50 BST difference + const numPokemon = party.length; + for (let i = 0; i < numPokemon; i++) { + const removed = party[randSeedInt(party.length)]; + const index = pokemonTransformations.findIndex(p => p.previousPokemon.id === removed.id); + pokemonTransformations[index].heldItems = removed.getHeldItems().filter(m => !(m instanceof PokemonFormChangeItemModifier)); + scene.removePokemonFromPlayerParty(removed, false); + + const bst = removed.calculateBaseStats().reduce((a, b) => a + b, 0); + let newBstRange: [number, number]; + if (i < 2) { + newBstRange = HIGH_BST_TRANSFORM_BASE_VALUES; + } else { + newBstRange = STANDARD_BST_TRANSFORM_BASE_VALUES; + } + + const newSpecies = getTransformedSpecies(bst, newBstRange, hasPokemonInSuperLegendaryBstThreshold, hasPokemonInLegendaryBstThreshold, alreadyUsedSpecies); + + const newSpeciesBst = newSpecies.getBaseStatTotal(); + if (newSpeciesBst > SUPER_LEGENDARY_BST_THRESHOLD) { + hasPokemonInSuperLegendaryBstThreshold = true; + } + if (newSpeciesBst <= SUPER_LEGENDARY_BST_THRESHOLD && newSpeciesBst >= NON_LEGENDARY_BST_THRESHOLD) { + hasPokemonInLegendaryBstThreshold = true; + } + + + pokemonTransformations[index].newSpecies = newSpecies; + alreadyUsedSpecies.push(newSpecies); + } + + for (const transformation of pokemonTransformations) { + const newAbilityIndex = randSeedInt(transformation.newSpecies.getAbilityCount()); + const newPlayerPokemon = scene.addPlayerPokemon(transformation.newSpecies, transformation.previousPokemon.level, newAbilityIndex, undefined); + transformation.newPokemon = newPlayerPokemon; + scene.getParty().push(newPlayerPokemon); + } + + return pokemonTransformations; +} + +async function doNewTeamPostProcess(scene: BattleScene, transformations: PokemonTransformation[]) { + let atLeastOneNewStarter = false; + for (const transformation of transformations) { + const previousPokemon = transformation.previousPokemon; + const newPokemon = transformation.newPokemon; + const speciesRootForm = newPokemon.species.getRootSpeciesId(); + + // Roll HA a second time + if (newPokemon.species.abilityHidden) { + const hiddenIndex = newPokemon.species.ability2 ? 2 : 1; + if (newPokemon.abilityIndex < hiddenIndex) { + const hiddenAbilityChance = new IntegerHolder(256); + scene.applyModifiers(HiddenAbilityRateBoosterModifier, true, hiddenAbilityChance); + + const hasHiddenAbility = !randSeedInt(hiddenAbilityChance.value); + + if (hasHiddenAbility) { + newPokemon.abilityIndex = hiddenIndex; + } + } + } + + // Roll IVs a second time + newPokemon.ivs = newPokemon.ivs.map(iv => { + const newValue = randSeedInt(31); + return newValue > iv ? newValue : iv; + }); + + // For pokemon at/below 570 BST or any shiny pokemon, unlock it permanently as if you had caught it + if (newPokemon.getSpeciesForm().getBaseStatTotal() <= NON_LEGENDARY_BST_THRESHOLD || newPokemon.isShiny()) { + if (newPokemon.getSpeciesForm().abilityHidden && newPokemon.abilityIndex === newPokemon.getSpeciesForm().getAbilityCount() - 1) { + scene.validateAchv(achvs.HIDDEN_ABILITY); + } + + if (newPokemon.species.subLegendary) { + scene.validateAchv(achvs.CATCH_SUB_LEGENDARY); + } + + if (newPokemon.species.legendary) { + scene.validateAchv(achvs.CATCH_LEGENDARY); + } + + if (newPokemon.species.mythical) { + scene.validateAchv(achvs.CATCH_MYTHICAL); + } + + scene.gameData.updateSpeciesDexIvs(newPokemon.species.getRootSpeciesId(true), newPokemon.ivs); + const newStarterUnlocked = await scene.gameData.setPokemonCaught(newPokemon, true, false, false); + if (newStarterUnlocked) { + atLeastOneNewStarter = true; + queueEncounterMessage(scene, i18next.t("battle:addedAsAStarter", { pokemonName: getPokemonSpecies(speciesRootForm).getName() })); + } + } + + // If the previous pokemon had higher IVs, override to those (after updating dex IVs > prevents perfect 31s on a new unlock) + newPokemon.ivs = newPokemon.ivs.map((iv, index) => { + return previousPokemon.ivs[index] > iv ? previousPokemon.ivs[index] : iv; + }); + + // For pokemon that the player owns (including ones just caught), gain a candy + if (!!scene.gameData.dexData[speciesRootForm].caughtAttr) { + scene.gameData.addStarterCandy(getPokemonSpecies(speciesRootForm), 1); + } + + // Set the moveset of the new pokemon to be the same as previous, but with 1 egg move of the new species + newPokemon.moveset = previousPokemon.moveset; + if (speciesEggMoves.hasOwnProperty(speciesRootForm)) { + const eggMoves = speciesEggMoves[speciesRootForm]; + const eggMoveIndex = randSeedInt(4); + const randomEggMove = eggMoves[eggMoveIndex]; + if (newPokemon.moveset.length < 4) { + newPokemon.moveset.push(new PokemonMove(randomEggMove)); + } else { + newPokemon.moveset[randSeedInt(4)] = new PokemonMove(randomEggMove); + } + // For pokemon that the player owns (including ones just caught), unlock the egg move + if (!!scene.gameData.dexData[speciesRootForm].caughtAttr) { + await scene.gameData.setEggMoveUnlocked(getPokemonSpecies(speciesRootForm), eggMoveIndex, true); + } + } + + // Randomize the second type of the pokemon + // If the pokemon does not normally have a second type, it will gain 1 + const newTypes = [newPokemon.getTypes()[0]]; + let newType = randSeedInt(18) as Type; + while (newType === newTypes[0]) { + newType = randSeedInt(18) as Type; + } + newTypes.push(newType); + if (!newPokemon.mysteryEncounterPokemonData) { + newPokemon.mysteryEncounterPokemonData = new MysteryEncounterPokemonData(); + } + newPokemon.mysteryEncounterPokemonData.types = newTypes; + + for (const item of transformation.heldItems) { + item.pokemonId = newPokemon.id; + scene.addModifier(item, false, false, false, true); + } + + // Any pokemon that is at or below 450 BST gets +20 permanent BST to 3 stats: HP (halved, +10), lowest of Atk/SpAtk, and lowest of Def/SpDef + if (newPokemon.getSpeciesForm().getBaseStatTotal() <= GAIN_OLD_GATEAU_ITEM_BST_THRESHOLD) { + const stats: Stat[] = [Stat.HP]; + const baseStats = newPokemon.getSpeciesForm().baseStats.slice(0); + // Attack or SpAtk + stats.push(baseStats[Stat.ATK] < baseStats[Stat.SPATK] ? Stat.ATK : Stat.SPATK); + // Def or SpDef + stats.push(baseStats[Stat.DEF] < baseStats[Stat.SPDEF] ? Stat.DEF : Stat.SPDEF); + // const mod = modifierTypes.MYSTERY_ENCOUNTER_OLD_GATEAU().newModifier(newPokemon, 20, stats); + const modType = modifierTypes.MYSTERY_ENCOUNTER_OLD_GATEAU().generateType(scene.getParty(), [20, stats]); + const modifier = modType?.newModifier(newPokemon); + if (modifier) { + scene.addModifier(modifier); + } + } + + // Enable passive if previous had it + newPokemon.passive = previousPokemon.passive; + + newPokemon.calculateStats(); + newPokemon.initBattleInfo(); + } + + // One random pokemon will get its passive unlocked + const passiveDisabledPokemon = scene.getParty().filter(p => !p.passive); + if (passiveDisabledPokemon?.length > 0) { + passiveDisabledPokemon[randSeedInt(passiveDisabledPokemon.length)].passive = true; + } + + // If at least one new starter was unlocked, play 1 fanfare + if (atLeastOneNewStarter) { + scene.playSound("level_up_fanfare"); + } +} + +function getTransformedSpecies(originalBst: number, bstSearchRange: [number, number], hasPokemonBstHigherThan600: boolean, hasPokemonBstBetween570And600: boolean, alreadyUsedSpecies: PokemonSpecies[]): PokemonSpecies { + let newSpecies: PokemonSpecies | undefined; + while (isNullOrUndefined(newSpecies)) { + const bstCap = originalBst + bstSearchRange[1]; + const bstMin = Math.max(originalBst + bstSearchRange[0], 0); + + // Get any/all species that fall within the Bst range requirements + let validSpecies = allSpecies + .filter(s => { + const speciesBst = s.getBaseStatTotal(); + const bstInRange = speciesBst >= bstMin && speciesBst <= bstCap; + // Checks that a Pokemon has not already been added in the +600 or 570-600 slots; + const validBst = (!hasPokemonBstBetween570And600 || (speciesBst < NON_LEGENDARY_BST_THRESHOLD || speciesBst > SUPER_LEGENDARY_BST_THRESHOLD)) && + (!hasPokemonBstHigherThan600 || speciesBst <= SUPER_LEGENDARY_BST_THRESHOLD); + return bstInRange && validBst && !EXCLUDED_TRANSFORMATION_SPECIES.includes(s.speciesId); + }); + + // There must be at least 20 species available before it will choose one + if (validSpecies?.length > 20) { + validSpecies = randSeedShuffle(validSpecies); + newSpecies = validSpecies.pop(); + while (isNullOrUndefined(newSpecies) || alreadyUsedSpecies.includes(newSpecies!)) { + newSpecies = validSpecies.pop(); + } + } else { + // Expands search rand until a Pokemon is found + bstSearchRange[0] -= 10; + bstSearchRange[1] += 10; + } + } + + return newSpecies!; +} + +function doShowDreamBackground(scene: BattleScene) { + const transformationContainer = scene.add.container(0, -scene.game.canvas.height / 6); + transformationContainer.name = "Dream Background"; + + // In case it takes a bit for video to load + const transformationStaticBg = scene.add.rectangle(0, 0, scene.game.canvas.width / 6, scene.game.canvas.height / 6, 0); + transformationStaticBg.setName("Black Background"); + transformationStaticBg.setOrigin(0, 0); + transformationContainer.add(transformationStaticBg); + transformationStaticBg.setVisible(true); + + const transformationVideoBg: Phaser.GameObjects.Video = scene.add.video(0, 0, "evo_bg").stop(); + transformationVideoBg.setLoop(true); + transformationVideoBg.setOrigin(0, 0); + transformationVideoBg.setScale(0.4359673025); + transformationContainer.add(transformationVideoBg); + + scene.fieldUI.add(transformationContainer); + scene.fieldUI.bringToTop(transformationContainer); + transformationVideoBg.play(); + + transformationContainer.setVisible(true); + transformationContainer.alpha = 0; + + scene.tweens.add({ + targets: transformationContainer, + alpha: 1, + duration: 3000, + ease: "Sine.easeInOut" + }); +} + +function doHideDreamBackground(scene: BattleScene) { + const transformationContainer = scene.fieldUI.getByName("Dream Background"); + + scene.tweens.add({ + targets: transformationContainer, + alpha: 0, + duration: 3000, + ease: "Sine.easeInOut", + onComplete: () => { + scene.fieldUI.remove(transformationContainer, true); + } + }); +} + +function doSideBySideTransformations(scene: BattleScene, transformations: PokemonTransformation[]) { + return new Promise(resolve => { + const allTransformationPromises: Promise[] = []; + for (let i = 0; i < 3; i++) { + const delay = i * 4000; + scene.time.delayedCall(delay, () => { + const transformation = transformations[i]; + const pokemon1 = transformation.previousPokemon; + const pokemon2 = transformation.newPokemon; + const screenPosition = i as TransformationScreenPosition; + + const transformationPromise = doPokemonTransformationSequence(scene, pokemon1, pokemon2, screenPosition) + .then(() => { + if (transformations.length > i + 3) { + const nextTransformationAtPosition = transformations[i + 3]; + const nextPokemon1 = nextTransformationAtPosition.previousPokemon; + const nextPokemon2 = nextTransformationAtPosition.newPokemon; + + allTransformationPromises.push(doPokemonTransformationSequence(scene, nextPokemon1, nextPokemon2, screenPosition)); + } + }); + allTransformationPromises.push(transformationPromise); + }); + } + + // Wait for all transformations to be loaded into promise array + const id = setInterval(checkAllPromisesExist, 500); + async function checkAllPromisesExist() { + if (allTransformationPromises.length === transformations.length) { + clearInterval(id); + await Promise.all(allTransformationPromises); + resolve(); + } + } + }); +} diff --git a/src/data/mystery-encounters/mystery-encounter-dialogue.ts b/src/data/mystery-encounters/mystery-encounter-dialogue.ts new file mode 100644 index 000000000000..e0ba8512d34d --- /dev/null +++ b/src/data/mystery-encounters/mystery-encounter-dialogue.ts @@ -0,0 +1,75 @@ +import { TextStyle } from "#app/ui/text"; + +export class TextDisplay { + speaker?: string; + text: string; + style?: TextStyle; +} + +export class OptionTextDisplay { + buttonLabel: string; + buttonTooltip?: string; + disabledButtonLabel?: string; + disabledButtonTooltip?: string; + secondOptionPrompt?: string; + selected?: TextDisplay[]; + style?: TextStyle; +} + +export class EncounterOptionsDialogue { + title?: string; + description?: string; + query?: string; + /** Options array with minimum 2 options */ + options?: [...OptionTextDisplay[]]; +} + +/** + * Example MysteryEncounterDialogue object: + * + { + intro: [ + { + text: "this is a rendered as a message window (no title display)" + }, + { + speaker: "John" + text: "this is a rendered as a dialogue window (title "John" is displayed above text)" + } + ], + encounterOptionsDialogue: { + title: "This is the title displayed at top of encounter description box", + description: "This is the description in the middle of encounter description box", + query: "This is an optional question displayed at the bottom of the description box (keep it short)", + options: [ + { + buttonLabel: "Option #1 button label (keep these short)", + selected: [ // Optional dialogue windows displayed when specific option is selected and before functional logic for the option is executed + { + text: "You chose option #1 message" + }, + { + speaker: "John" + text: "So, you've chosen option #1! It's time to d-d-d-duel!" + } + ] + }, + { + buttonLabel: "Option #2" + } + ], + }, + outro: [ + { + text: "This message will be displayed at the very end of the encounter (i.e. post battle, post reward, etc.)" + } + ], + } + * + */ +export default class MysteryEncounterDialogue { + intro?: TextDisplay[]; + encounterOptionsDialogue?: EncounterOptionsDialogue; + outro?: TextDisplay[]; +} + diff --git a/src/data/mystery-encounters/mystery-encounter-option.ts b/src/data/mystery-encounters/mystery-encounter-option.ts new file mode 100644 index 000000000000..fb3daf53a8ba --- /dev/null +++ b/src/data/mystery-encounters/mystery-encounter-option.ts @@ -0,0 +1,303 @@ +import { OptionTextDisplay } from "#app/data/mystery-encounters/mystery-encounter-dialogue"; +import { Moves } from "#app/enums/moves"; +import Pokemon, { PlayerPokemon } from "#app/field/pokemon"; +import BattleScene from "#app/battle-scene"; +import { Type } from "../type"; +import { EncounterPokemonRequirement, EncounterSceneRequirement, MoneyRequirement, TypeRequirement } from "./mystery-encounter-requirements"; +import { CanLearnMoveRequirement, CanLearnMoveRequirementOptions } from "./requirements/can-learn-move-requirement"; +import { isNullOrUndefined, randSeedInt } from "#app/utils"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; + + +export type OptionPhaseCallback = (scene: BattleScene) => Promise; + +/** + * Used by {@linkcode MysteryEncounterOptionBuilder} class to define required/optional properties on the {@linkcode MysteryEncounterOption} class when building. + * + * Should ONLY contain properties that are necessary for {@linkcode MysteryEncounterOption} construction. + * Post-construct and flag data properties are defined in the {@linkcode MysteryEncounterOption} class itself. + */ +export interface IMysteryEncounterOption { + optionMode: MysteryEncounterOptionMode; + hasDexProgress: boolean; + requirements: EncounterSceneRequirement[]; + primaryPokemonRequirements: EncounterPokemonRequirement[]; + secondaryPokemonRequirements: EncounterPokemonRequirement[]; + excludePrimaryFromSecondaryRequirements: boolean; + + dialogue?: OptionTextDisplay; + + onPreOptionPhase?: OptionPhaseCallback; + onOptionPhase: OptionPhaseCallback; + onPostOptionPhase?: OptionPhaseCallback; +} + +export default class MysteryEncounterOption implements IMysteryEncounterOption { + optionMode: MysteryEncounterOptionMode; + hasDexProgress: boolean; + requirements: EncounterSceneRequirement[]; + primaryPokemonRequirements: EncounterPokemonRequirement[]; + secondaryPokemonRequirements: EncounterPokemonRequirement[]; + primaryPokemon?: PlayerPokemon; + secondaryPokemon?: PlayerPokemon[]; + excludePrimaryFromSecondaryRequirements: boolean; + + /** + * Dialogue object containing all the dialogue, messages, tooltips, etc. for this option + * Will be populated on {@linkcode MysteryEncounter} initialization + */ + dialogue?: OptionTextDisplay; + + /** Executes before any following dialogue or business logic from option. Usually this will be for calculating dialogueTokens or performing scene/data updates */ + onPreOptionPhase?: OptionPhaseCallback; + /** Business logic function for option */ + onOptionPhase: OptionPhaseCallback; + /** Executes after the encounter is over. Usually this will be for calculating dialogueTokens or performing data updates */ + onPostOptionPhase?: OptionPhaseCallback; + + constructor(option: IMysteryEncounterOption | null) { + if (!isNullOrUndefined(option)) { + Object.assign(this, option); + } + this.hasDexProgress = this.hasDexProgress ?? false; + this.requirements = this.requirements ?? []; + this.primaryPokemonRequirements = this.primaryPokemonRequirements ?? []; + this.secondaryPokemonRequirements = this.secondaryPokemonRequirements ?? []; + } + + /** + * Returns true if option contains any {@linkcode EncounterRequirement}s, false otherwise. + */ + hasRequirements(): boolean { + return this.requirements.length > 0 || this.primaryPokemonRequirements.length > 0 || this.secondaryPokemonRequirements.length > 0; + } + + /** + * Returns true if all {@linkcode EncounterRequirement}s for the option are met + * @param scene + */ + meetsRequirements(scene: BattleScene): boolean { + return !this.requirements.some(requirement => !requirement.meetsRequirement(scene)) + && this.meetsSupportingRequirementAndSupportingPokemonSelected(scene) + && this.meetsPrimaryRequirementAndPrimaryPokemonSelected(scene); + } + + /** + * Returns true if all PRIMARY {@linkcode EncounterRequirement}s for the option are met + * @param scene + * @param pokemon + */ + pokemonMeetsPrimaryRequirements(scene: BattleScene, pokemon: Pokemon): boolean { + return !this.primaryPokemonRequirements.some(req => !req.queryParty(scene.getParty()).map(p => p.id).includes(pokemon.id)); + } + + /** + * Returns true if all PRIMARY {@linkcode EncounterRequirement}s for the option are met, + * AND there is a valid Pokemon assigned to {@linkcode primaryPokemon}. + * If both {@linkcode primaryPokemonRequirements} and {@linkcode secondaryPokemonRequirements} are defined, + * can cause scenarios where there are not enough Pokemon that are sufficient for all requirements. + * @param scene + */ + meetsPrimaryRequirementAndPrimaryPokemonSelected(scene: BattleScene): boolean { + if (!this.primaryPokemonRequirements || this.primaryPokemonRequirements.length === 0) { + return true; + } + let qualified: PlayerPokemon[] = scene.getParty(); + for (const req of this.primaryPokemonRequirements) { + if (req.meetsRequirement(scene)) { + const queryParty = req.queryParty(scene.getParty()); + qualified = qualified.filter(pkmn => queryParty.includes(pkmn)); + } else { + this.primaryPokemon = undefined; + return false; + } + } + + if (qualified.length === 0) { + return false; + } + + if (this.excludePrimaryFromSecondaryRequirements && this.secondaryPokemon && this.secondaryPokemon.length > 0) { + const truePrimaryPool: PlayerPokemon[] = []; + const overlap: PlayerPokemon[] = []; + for (const qp of qualified) { + if (!this.secondaryPokemon.includes(qp)) { + truePrimaryPool.push(qp); + } else { + overlap.push(qp); + } + + } + if (truePrimaryPool.length > 0) { + // always choose from the non-overlapping pokemon first + this.primaryPokemon = truePrimaryPool[randSeedInt(truePrimaryPool.length)]; + return true; + } else { + // if there are multiple overlapping pokemon, we're okay - just choose one and take it out of the supporting pokemon pool + if (overlap.length > 1 || (this.secondaryPokemon.length - overlap.length >= 1)) { + this.primaryPokemon = overlap[randSeedInt(overlap.length)]; + this.secondaryPokemon = this.secondaryPokemon.filter((supp) => supp !== this.primaryPokemon); + return true; + } + console.log("Mystery Encounter Edge Case: Requirement not met due to primay pokemon overlapping with support pokemon. There's no valid primary pokemon left."); + return false; + } + } else { + // Just pick the first qualifying Pokemon + this.primaryPokemon = qualified[0]; + return true; + } + } + + /** + * Returns true if all SECONDARY {@linkcode EncounterRequirement}s for the option are met, + * AND there is a valid Pokemon assigned to {@linkcode secondaryPokemon} (if applicable). + * If both {@linkcode primaryPokemonRequirements} and {@linkcode secondaryPokemonRequirements} are defined, + * can cause scenarios where there are not enough Pokemon that are sufficient for all requirements. + * @param scene + */ + meetsSupportingRequirementAndSupportingPokemonSelected(scene: BattleScene): boolean { + if (!this.secondaryPokemonRequirements || this.secondaryPokemonRequirements.length === 0) { + this.secondaryPokemon = []; + return true; + } + + let qualified: PlayerPokemon[] = scene.getParty(); + for (const req of this.secondaryPokemonRequirements) { + if (req.meetsRequirement(scene)) { + const queryParty = req.queryParty(scene.getParty()); + qualified = qualified.filter(pkmn => queryParty.includes(pkmn)); + } else { + this.secondaryPokemon = []; + return false; + } + } + this.secondaryPokemon = qualified; + return true; + } +} + +export class MysteryEncounterOptionBuilder implements Partial { + optionMode: MysteryEncounterOptionMode = MysteryEncounterOptionMode.DEFAULT; + requirements: EncounterSceneRequirement[] = []; + primaryPokemonRequirements: EncounterPokemonRequirement[] = []; + secondaryPokemonRequirements: EncounterPokemonRequirement[] = []; + excludePrimaryFromSecondaryRequirements: boolean = false; + isDisabledOnRequirementsNotMet: boolean = true; + hasDexProgress: boolean = false; + dialogue?: OptionTextDisplay; + + static newOptionWithMode(optionMode: MysteryEncounterOptionMode): MysteryEncounterOptionBuilder & Pick { + return Object.assign(new MysteryEncounterOptionBuilder(), { optionMode }); + } + + withHasDexProgress(hasDexProgress: boolean): this & Required> { + return Object.assign(this, { hasDexProgress: hasDexProgress }); + } + + /** + * Adds a {@linkcode EncounterSceneRequirement} to {@linkcode requirements} + * @param requirement + */ + withSceneRequirement(requirement: EncounterSceneRequirement): this & Required> { + if (requirement instanceof EncounterPokemonRequirement) { + Error("Incorrectly added pokemon requirement as scene requirement."); + } + + this.requirements.push(requirement); + return Object.assign(this, { requirements: this.requirements }); + } + + withSceneMoneyRequirement(requiredMoney?: number, scalingMultiplier?: number) { + return this.withSceneRequirement(new MoneyRequirement(requiredMoney, scalingMultiplier)); + } + + /** + * Defines logic that runs immediately when an option is selected, but before the Encounter continues. + * Can determine whether or not the Encounter *should* continue. + * If there are scenarios where the Encounter should NOT continue, should return boolean instead of void. + * @param onPreOptionPhase + */ + withPreOptionPhase(onPreOptionPhase: OptionPhaseCallback): this & Required> { + return Object.assign(this, { onPreOptionPhase: onPreOptionPhase }); + } + + /** + * MUST be defined by every {@linkcode MysteryEncounterOption} + * @param onOptionPhase + */ + withOptionPhase(onOptionPhase: OptionPhaseCallback): this & Required> { + return Object.assign(this, { onOptionPhase: onOptionPhase }); + } + + withPostOptionPhase(onPostOptionPhase: OptionPhaseCallback): this & Required> { + return Object.assign(this, { onPostOptionPhase: onPostOptionPhase }); + } + + /** + * Adds a {@linkcode EncounterPokemonRequirement} to {@linkcode primaryPokemonRequirements} + * @param requirement + */ + withPrimaryPokemonRequirement(requirement: EncounterPokemonRequirement): this & Required> { + if (requirement instanceof EncounterSceneRequirement) { + Error("Incorrectly added scene requirement as pokemon requirement."); + } + + this.primaryPokemonRequirements.push(requirement); + return Object.assign(this, { primaryPokemonRequirements: this.primaryPokemonRequirements }); + } + + /** + * Player is required to have certain type/s of pokemon in his party (with optional min number of pokemons with that type) + * + * @param type the required type/s + * @param excludeFainted whether to exclude fainted pokemon + * @param minNumberOfPokemon number of pokemons to have that type + * @param invertQuery + * @returns + */ + withPokemonTypeRequirement(type: Type | Type[], excludeFainted?: boolean, minNumberOfPokemon?: number, invertQuery?: boolean) { + return this.withPrimaryPokemonRequirement(new TypeRequirement(type, excludeFainted, minNumberOfPokemon, invertQuery)); + } + + /** + * Player is required to have a pokemon that can learn a certain move/moveset + * + * @param move the required move/moves + * @param options see {@linkcode CanLearnMoveRequirementOptions} + * @returns + */ + withPokemonCanLearnMoveRequirement(move: Moves | Moves[], options?: CanLearnMoveRequirementOptions) { + return this.withPrimaryPokemonRequirement(new CanLearnMoveRequirement(move, options)); + } + + /** + * Adds a {@linkcode EncounterPokemonRequirement} to {@linkcode secondaryPokemonRequirements} + * @param requirement + * @param excludePrimaryFromSecondaryRequirements + */ + withSecondaryPokemonRequirement(requirement: EncounterPokemonRequirement, excludePrimaryFromSecondaryRequirements: boolean = true): this & Required> { + if (requirement instanceof EncounterSceneRequirement) { + Error("Incorrectly added scene requirement as pokemon requirement."); + } + + this.secondaryPokemonRequirements.push(requirement); + this.excludePrimaryFromSecondaryRequirements = excludePrimaryFromSecondaryRequirements; + return Object.assign(this, { secondaryPokemonRequirements: this.secondaryPokemonRequirements }); + } + + /** + * Set the full dialogue object to the option. Will override anything already set + * + * @param dialogue see {@linkcode OptionTextDisplay} + * @returns + */ + withDialogue(dialogue: OptionTextDisplay) { + this.dialogue = dialogue; + return this; + } + + build(this: IMysteryEncounterOption) { + return new MysteryEncounterOption(this); + } +} diff --git a/src/data/mystery-encounters/mystery-encounter-pokemon-data.ts b/src/data/mystery-encounters/mystery-encounter-pokemon-data.ts new file mode 100644 index 000000000000..fc6ce313d415 --- /dev/null +++ b/src/data/mystery-encounters/mystery-encounter-pokemon-data.ts @@ -0,0 +1,25 @@ +import { Abilities } from "#enums/abilities"; +import { Type } from "#app/data/type"; +import { isNullOrUndefined } from "#app/utils"; + +/** + * Data that can customize a Pokemon in non-standard ways from its Species + * Currently only used by Mystery Encounters, may need to be renamed if it becomes more widely used + */ +export class MysteryEncounterPokemonData { + public spriteScale: number; + public ability: Abilities | -1; + public passive: Abilities | -1; + public types: Type[]; + + constructor(data?: MysteryEncounterPokemonData | Partial) { + if (!isNullOrUndefined(data)) { + Object.assign(this, data); + } + + this.spriteScale = this.spriteScale ?? -1; + this.ability = this.ability ?? -1; + this.passive = this.passive ?? -1; + this.types = this.types ?? []; + } +} diff --git a/src/data/mystery-encounters/mystery-encounter-requirements.ts b/src/data/mystery-encounters/mystery-encounter-requirements.ts new file mode 100644 index 000000000000..8dd6568e9295 --- /dev/null +++ b/src/data/mystery-encounters/mystery-encounter-requirements.ts @@ -0,0 +1,1022 @@ +import { PlayerPokemon } from "#app/field/pokemon"; +import BattleScene from "#app/battle-scene"; +import { isNullOrUndefined } from "#app/utils"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { TimeOfDay } from "#enums/time-of-day"; +import { Nature } from "../nature"; +import { EvolutionItem, pokemonEvolutions } from "../pokemon-evolutions"; +import { FormChangeItem, pokemonFormChanges, SpeciesFormChangeItemTrigger } from "../pokemon-forms"; +import { SpeciesFormKey } from "../pokemon-species"; +import { StatusEffect } from "../status-effect"; +import { Type } from "../type"; +import { WeatherType } from "../weather"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { AttackTypeBoosterModifier } from "#app/modifier/modifier"; +import { AttackTypeBoosterModifierType } from "#app/modifier/modifier-type"; + +export interface EncounterRequirement { + meetsRequirement(scene: BattleScene): boolean; // Boolean to see if a requirement is met + getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string]; +} + +export abstract class EncounterSceneRequirement implements EncounterRequirement { + /** + * Returns whether the EncounterSceneRequirement's... requirements, are met by the given scene + * @param partyPokemon + */ + abstract meetsRequirement(scene: BattleScene): boolean; + /** + * Returns a dialogue token key/value pair for a given Requirement. + * Should be overridden by child Requirement classes. + * @param scene + * @param pokemon + */ + abstract getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string]; +} + +export class CombinationSceneRequirement extends EncounterSceneRequirement { + orRequirements: EncounterSceneRequirement[]; + + constructor(... orRequirements: EncounterSceneRequirement[]) { + super(); + this.orRequirements = orRequirements; + } + + override meetsRequirement(scene: BattleScene): boolean { + for (const req of this.orRequirements) { + if (req.meetsRequirement(scene)) { + return true; + } + } + return false; + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + for (const req of this.orRequirements) { + if (req.meetsRequirement(scene)) { + return req.getDialogueToken(scene, pokemon); + } + } + + return this.orRequirements[0].getDialogueToken(scene, pokemon); + } +} + +export abstract class EncounterPokemonRequirement implements EncounterRequirement { + public minNumberOfPokemon: number; + public invertQuery: boolean; + + /** + * Returns whether the EncounterPokemonRequirement's... requirements, are met by the given scene + * @param partyPokemon + */ + abstract meetsRequirement(scene: BattleScene): boolean; + + /** + * Returns all party members that are compatible with this requirement. For non pokemon related requirements, the entire party is returned. + * @param partyPokemon + */ + abstract queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[]; + + /** + * Returns a dialogue token key/value pair for a given Requirement. + * Should be overridden by child Requirement classes. + * @param scene + * @param pokemon + */ + abstract getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string]; +} + +export class CombinationPokemonRequirement extends EncounterPokemonRequirement { + orRequirements: EncounterPokemonRequirement[]; + + constructor(...orRequirements: EncounterPokemonRequirement[]) { + super(); + this.invertQuery = false; + this.minNumberOfPokemon = 1; + this.orRequirements = orRequirements; + } + + override meetsRequirement(scene: BattleScene): boolean { + for (const req of this.orRequirements) { + if (req.meetsRequirement(scene)) { + return true; + } + } + return false; + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + for (const req of this.orRequirements) { + const result = req.queryParty(partyPokemon); + if (result?.length > 0) { + return result; + } + } + + return []; + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + for (const req of this.orRequirements) { + if (req.meetsRequirement(scene)) { + return req.getDialogueToken(scene, pokemon); + } + } + + return this.orRequirements[0].getDialogueToken(scene, pokemon); + } +} + +export class PreviousEncounterRequirement extends EncounterSceneRequirement { + previousEncounterRequirement: MysteryEncounterType; + + /** + * Used for specifying an encounter that must be seen before this encounter can spawn + * @param previousEncounterRequirement + */ + constructor(previousEncounterRequirement: MysteryEncounterType) { + super(); + this.previousEncounterRequirement = previousEncounterRequirement; + } + + override meetsRequirement(scene: BattleScene): boolean { + return scene.mysteryEncounterSaveData.encounteredEvents.some(e => e.type === this.previousEncounterRequirement); + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + return ["previousEncounter", scene.mysteryEncounterSaveData.encounteredEvents.find(e => e.type === this.previousEncounterRequirement)?.[0].toString() ?? ""]; + } +} + +export class WaveRangeRequirement extends EncounterSceneRequirement { + waveRange: [number, number]; + + /** + * Used for specifying a unique wave or wave range requirement + * If minWaveIndex and maxWaveIndex are equivalent, will check for exact wave number + * @param waveRange [min, max] + */ + constructor(waveRange: [number, number]) { + super(); + this.waveRange = waveRange; + } + + override meetsRequirement(scene: BattleScene): boolean { + if (!isNullOrUndefined(this.waveRange) && this.waveRange?.[0] <= this.waveRange?.[1]) { + const waveIndex = scene.currentBattle.waveIndex; + if (waveIndex >= 0 && (this.waveRange?.[0] >= 0 && this.waveRange?.[0] > waveIndex) || (this.waveRange?.[1] >= 0 && this.waveRange?.[1] < waveIndex)) { + return false; + } + } + return true; + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + return ["waveIndex", scene.currentBattle.waveIndex.toString()]; + } +} + +export class WaveModulusRequirement extends EncounterSceneRequirement { + waveModuli: number[]; + modulusValue: number; + + /** + * Used for specifying a modulus requirement on the wave index + * For example, can be used to require the wave index to end with 1, 2, or 3 + * @param waveModuli The allowed modulus results + * @param modulusValue The modulus calculation value + * + * Example: + * new WaveModulusRequirement([1, 2, 3], 10) will check for 1st/2nd/3rd waves that are immediately after a multiple of 10 wave + * So waves 21, 32, 53 all return true. 58, 14, 99 return false. + */ + constructor(waveModuli: number[], modulusValue: number) { + super(); + this.waveModuli = waveModuli; + this.modulusValue = modulusValue; + } + + override meetsRequirement(scene: BattleScene): boolean { + return this.waveModuli.includes(scene.currentBattle.waveIndex % this.modulusValue); + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + return ["waveIndex", scene.currentBattle.waveIndex.toString()]; + } +} + +export class TimeOfDayRequirement extends EncounterSceneRequirement { + requiredTimeOfDay: TimeOfDay[]; + + constructor(timeOfDay: TimeOfDay | TimeOfDay[]) { + super(); + this.requiredTimeOfDay = Array.isArray(timeOfDay) ? timeOfDay : [timeOfDay]; + } + + override meetsRequirement(scene: BattleScene): boolean { + const timeOfDay = scene.arena?.getTimeOfDay(); + if (!isNullOrUndefined(timeOfDay) && this.requiredTimeOfDay?.length > 0 && !this.requiredTimeOfDay.includes(timeOfDay)) { + return false; + } + + return true; + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + return ["timeOfDay", TimeOfDay[scene.arena.getTimeOfDay()].toLocaleLowerCase()]; + } +} + +export class WeatherRequirement extends EncounterSceneRequirement { + requiredWeather: WeatherType[]; + + constructor(weather: WeatherType | WeatherType[]) { + super(); + this.requiredWeather = Array.isArray(weather) ? weather : [weather]; + } + + override meetsRequirement(scene: BattleScene): boolean { + const currentWeather = scene.arena.weather?.weatherType; + if (!isNullOrUndefined(currentWeather) && this.requiredWeather?.length > 0 && !this.requiredWeather.includes(currentWeather!)) { + return false; + } + + return true; + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + const currentWeather = scene.arena.weather?.weatherType; + let token = ""; + if (!isNullOrUndefined(currentWeather)) { + token = WeatherType[currentWeather!].replace("_", " ").toLocaleLowerCase(); + } + return ["weather", token]; + } +} + +export class PartySizeRequirement extends EncounterSceneRequirement { + partySizeRange: [number, number]; + excludeFainted: boolean; + + /** + * Used for specifying a party size requirement + * If min and max are equivalent, will check for exact size + * @param partySizeRange + * @param excludeFainted + */ + constructor(partySizeRange: [number, number], excludeFainted: boolean) { + super(); + this.partySizeRange = partySizeRange; + this.excludeFainted = excludeFainted; + } + + override meetsRequirement(scene: BattleScene): boolean { + if (!isNullOrUndefined(this.partySizeRange) && this.partySizeRange?.[0] <= this.partySizeRange?.[1]) { + const partySize = this.excludeFainted ? scene.getParty().filter(p => p.isAllowedInBattle()).length : scene.getParty().length; + if (partySize >= 0 && (this.partySizeRange?.[0] >= 0 && this.partySizeRange?.[0] > partySize) || (this.partySizeRange?.[1] >= 0 && this.partySizeRange?.[1] < partySize)) { + return false; + } + } + + return true; + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + return ["partySize", scene.getParty().length.toString()]; + } +} + +export class PersistentModifierRequirement extends EncounterSceneRequirement { + requiredHeldItemModifiers: string[]; + minNumberOfItems: number; + + constructor(heldItem: string | string[], minNumberOfItems: number = 1) { + super(); + this.minNumberOfItems = minNumberOfItems; + this.requiredHeldItemModifiers = Array.isArray(heldItem) ? heldItem : [heldItem]; + } + + override meetsRequirement(scene: BattleScene): boolean { + const partyPokemon = scene.getParty(); + if (isNullOrUndefined(partyPokemon) || this.requiredHeldItemModifiers?.length < 0) { + return false; + } + let modifierCount = 0; + this.requiredHeldItemModifiers.forEach(modifier => { + const matchingMods = scene.findModifiers(m => m.constructor.name === modifier); + if (matchingMods?.length > 0) { + matchingMods.forEach(matchingMod => { + modifierCount += matchingMod.stackCount; + }); + } + }); + + return modifierCount >= this.minNumberOfItems; + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + return ["requiredItem", this.requiredHeldItemModifiers[0]]; + } +} + +export class MoneyRequirement extends EncounterSceneRequirement { + requiredMoney: number; // Static value + scalingMultiplier: number; // Calculates required money based off wave index + + constructor(requiredMoney?: number, scalingMultiplier?: number) { + super(); + this.requiredMoney = requiredMoney ?? 0; + this.scalingMultiplier = scalingMultiplier ?? 0; + } + + override meetsRequirement(scene: BattleScene): boolean { + const money = scene.money; + if (isNullOrUndefined(money)) { + return false; + } + + if (this.scalingMultiplier > 0) { + this.requiredMoney = scene.getWaveMoneyAmount(this.scalingMultiplier); + } + return !(this.requiredMoney > 0 && this.requiredMoney > money); + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + const value = this.scalingMultiplier > 0 ? scene.getWaveMoneyAmount(this.scalingMultiplier).toString() : this.requiredMoney.toString(); + return ["money", value]; + } +} + +export class SpeciesRequirement extends EncounterPokemonRequirement { + requiredSpecies: Species[]; + minNumberOfPokemon: number; + invertQuery: boolean; + + constructor(species: Species | Species[], minNumberOfPokemon: number = 1, invertQuery: boolean = false) { + super(); + this.minNumberOfPokemon = minNumberOfPokemon; + this.invertQuery = invertQuery; + this.requiredSpecies = Array.isArray(species) ? species : [species]; + } + + override meetsRequirement(scene: BattleScene): boolean { + const partyPokemon = scene.getParty(); + if (isNullOrUndefined(partyPokemon) || this.requiredSpecies?.length < 0) { + return false; + } + return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + if (!this.invertQuery) { + return partyPokemon.filter((pokemon) => this.requiredSpecies.filter((species) => pokemon.species.speciesId === species).length > 0); + } else { + // for an inverted query, we only want to get the pokemon that don't have ANY of the listed speciess + return partyPokemon.filter((pokemon) => this.requiredSpecies.filter((species) => pokemon.species.speciesId === species).length === 0); + } + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + if (pokemon?.species.speciesId && this.requiredSpecies.includes(pokemon.species.speciesId)) { + return ["species", Species[pokemon.species.speciesId]]; + } + return ["species", ""]; + } +} + + +export class NatureRequirement extends EncounterPokemonRequirement { + requiredNature: Nature[]; + minNumberOfPokemon: number; + invertQuery: boolean; + + constructor(nature: Nature | Nature[], minNumberOfPokemon: number = 1, invertQuery: boolean = false) { + super(); + this.minNumberOfPokemon = minNumberOfPokemon; + this.invertQuery = invertQuery; + this.requiredNature = Array.isArray(nature) ? nature : [nature]; + } + + override meetsRequirement(scene: BattleScene): boolean { + const partyPokemon = scene.getParty(); + if (isNullOrUndefined(partyPokemon) || this.requiredNature?.length < 0) { + return false; + } + return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + if (!this.invertQuery) { + return partyPokemon.filter((pokemon) => this.requiredNature.filter((nature) => pokemon.nature === nature).length > 0); + } else { + // for an inverted query, we only want to get the pokemon that don't have ANY of the listed natures + return partyPokemon.filter((pokemon) => this.requiredNature.filter((nature) => pokemon.nature === nature).length === 0); + } + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + if (!isNullOrUndefined(pokemon?.nature) && this.requiredNature.includes(pokemon!.nature)) { + return ["nature", Nature[pokemon!.nature]]; + } + return ["nature", ""]; + } +} + +export class TypeRequirement extends EncounterPokemonRequirement { + requiredType: Type[]; + excludeFainted: boolean; + minNumberOfPokemon: number; + invertQuery: boolean; + + constructor(type: Type | Type[], excludeFainted: boolean = true, minNumberOfPokemon: number = 1, invertQuery: boolean = false) { + super(); + this.excludeFainted = excludeFainted; + this.minNumberOfPokemon = minNumberOfPokemon; + this.invertQuery = invertQuery; + this.requiredType = Array.isArray(type) ? type : [type]; + } + + override meetsRequirement(scene: BattleScene): boolean { + let partyPokemon = scene.getParty(); + + if (isNullOrUndefined(partyPokemon)) { + return false; + } + + if (this.excludeFainted) { + partyPokemon = partyPokemon.filter((pokemon) => !pokemon.isFainted()); + } + + return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + if (!this.invertQuery) { + return partyPokemon.filter((pokemon) => this.requiredType.filter((type) => pokemon.getTypes().includes(type)).length > 0); + } else { + // for an inverted query, we only want to get the pokemon that don't have ANY of the listed types + return partyPokemon.filter((pokemon) => this.requiredType.filter((type) => pokemon.getTypes().includes(type)).length === 0); + } + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + const includedTypes = this.requiredType.filter((ty) => pokemon?.getTypes().includes(ty)); + if (includedTypes.length > 0) { + return ["type", Type[includedTypes[0]]]; + } + return ["type", ""]; + } +} + + +export class MoveRequirement extends EncounterPokemonRequirement { + requiredMoves: Moves[] = []; + minNumberOfPokemon: number; + invertQuery: boolean; + + constructor(moves: Moves | Moves[], minNumberOfPokemon: number = 1, invertQuery: boolean = false) { + super(); + this.minNumberOfPokemon = minNumberOfPokemon; + this.invertQuery = invertQuery; + this.requiredMoves = Array.isArray(moves) ? moves : [moves]; + } + + override meetsRequirement(scene: BattleScene): boolean { + const partyPokemon = scene.getParty(); + if (isNullOrUndefined(partyPokemon) || this.requiredMoves?.length < 0) { + return false; + } + return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + if (!this.invertQuery) { + return partyPokemon.filter((pokemon) => this.requiredMoves.filter((reqMove) => pokemon.moveset.filter((move) => move?.moveId === reqMove).length > 0).length > 0); + } else { + // for an inverted query, we only want to get the pokemon that don't have ANY of the listed moves + return partyPokemon.filter((pokemon) => this.requiredMoves.filter((reqMove) => pokemon.moveset.filter((move) => move?.moveId === reqMove).length === 0).length === 0); + } + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + const includedMoves = pokemon?.moveset.filter((move) => move?.moveId && this.requiredMoves.includes(move.moveId)); + if (includedMoves && includedMoves.length > 0 && includedMoves[0]) { + return ["move", includedMoves[0].getName()]; + } + return ["move", ""]; + } + +} + +/** + * Find out if Pokemon in the party are able to learn one of many specific moves by TM. + * NOTE: Egg moves are not included as learnable. + * NOTE: If the Pokemon already knows the move, this requirement will fail, since it's not technically learnable. + */ +export class CompatibleMoveRequirement extends EncounterPokemonRequirement { + requiredMoves: Moves[]; + minNumberOfPokemon: number; + invertQuery: boolean; + + constructor(learnableMove: Moves | Moves[], minNumberOfPokemon: number = 1, invertQuery: boolean = false) { + super(); + this.minNumberOfPokemon = minNumberOfPokemon; + this.invertQuery = invertQuery; + this.requiredMoves = Array.isArray(learnableMove) ? learnableMove : [learnableMove]; + } + + override meetsRequirement(scene: BattleScene): boolean { + const partyPokemon = scene.getParty(); + if (isNullOrUndefined(partyPokemon) || this.requiredMoves?.length < 0) { + return false; + } + return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + if (!this.invertQuery) { + return partyPokemon.filter((pokemon) => this.requiredMoves.filter((learnableMove) => pokemon.compatibleTms.filter(tm => !pokemon.moveset.find(m => m?.moveId === tm)).includes(learnableMove)).length > 0); + } else { + // for an inverted query, we only want to get the pokemon that don't have ANY of the listed learnableMoves + return partyPokemon.filter((pokemon) => this.requiredMoves.filter((learnableMove) => pokemon.compatibleTms.filter(tm => !pokemon.moveset.find(m => m?.moveId === tm)).includes(learnableMove)).length === 0); + } + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + const includedCompatMoves = this.requiredMoves.filter((reqMove) => pokemon?.compatibleTms.filter((tm) => !pokemon.moveset.find(m => m?.moveId === tm)).includes(reqMove)); + if (includedCompatMoves.length > 0) { + return ["compatibleMove", Moves[includedCompatMoves[0]]]; + } + return ["compatibleMove", ""]; + } + +} + +export class AbilityRequirement extends EncounterPokemonRequirement { + requiredAbilities: Abilities[]; + minNumberOfPokemon: number; + invertQuery: boolean; + + constructor(abilities: Abilities | Abilities[], minNumberOfPokemon: number = 1, invertQuery: boolean = false) { + super(); + this.minNumberOfPokemon = minNumberOfPokemon; + this.invertQuery = invertQuery; + this.requiredAbilities = Array.isArray(abilities) ? abilities : [abilities]; + } + + override meetsRequirement(scene: BattleScene): boolean { + const partyPokemon = scene.getParty(); + if (isNullOrUndefined(partyPokemon) || this.requiredAbilities?.length < 0) { + return false; + } + return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + if (!this.invertQuery) { + return partyPokemon.filter((pokemon) => this.requiredAbilities.some((ability) => pokemon.getAbility().id === ability)); + } else { + // for an inverted query, we only want to get the pokemon that don't have ANY of the listed abilitiess + return partyPokemon.filter((pokemon) => this.requiredAbilities.filter((ability) => pokemon.getAbility().id === ability).length === 0); + } + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + if (pokemon?.getAbility().id && this.requiredAbilities.some(a => pokemon.getAbility().id === a)) { + return ["ability", pokemon.getAbility().name]; + } + return ["ability", ""]; + } +} + +export class StatusEffectRequirement extends EncounterPokemonRequirement { + requiredStatusEffect: StatusEffect[]; + minNumberOfPokemon: number; + invertQuery: boolean; + + constructor(statusEffect: StatusEffect | StatusEffect[], minNumberOfPokemon: number = 1, invertQuery: boolean = false) { + super(); + this.minNumberOfPokemon = minNumberOfPokemon; + this.invertQuery = invertQuery; + this.requiredStatusEffect = Array.isArray(statusEffect) ? statusEffect : [statusEffect]; + } + + override meetsRequirement(scene: BattleScene): boolean { + const partyPokemon = scene.getParty(); + if (isNullOrUndefined(partyPokemon) || this.requiredStatusEffect?.length < 0) { + return false; + } + const x = this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; + console.log(x); + return x; + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + if (!this.invertQuery) { + return partyPokemon.filter((pokemon) => { + return this.requiredStatusEffect.some((statusEffect) => { + if (statusEffect === StatusEffect.NONE) { + // StatusEffect.NONE also checks for null or undefined status + return isNullOrUndefined(pokemon.status) || isNullOrUndefined(pokemon.status!.effect) || pokemon.status?.effect === statusEffect; + } else { + return pokemon.status?.effect === statusEffect; + } + }); + }); + } else { + // for an inverted query, we only want to get the pokemon that don't have ANY of the listed StatusEffects + // return partyPokemon.filter((pokemon) => this.requiredStatusEffect.filter((statusEffect) => pokemon.status?.effect === statusEffect).length === 0); + return partyPokemon.filter((pokemon) => { + return !this.requiredStatusEffect.some((statusEffect) => { + if (statusEffect === StatusEffect.NONE) { + // StatusEffect.NONE also checks for null or undefined status + return isNullOrUndefined(pokemon.status) || isNullOrUndefined(pokemon.status!.effect) || pokemon.status?.effect === statusEffect; + } else { + return pokemon.status?.effect === statusEffect; + } + }); + }); + } + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + const reqStatus = this.requiredStatusEffect.filter((a) => { + if (a === StatusEffect.NONE) { + return isNullOrUndefined(pokemon?.status) || isNullOrUndefined(pokemon!.status!.effect) || pokemon!.status!.effect === a; + } + return pokemon!.status?.effect === a; + }); + if (reqStatus.length > 0) { + return ["status", StatusEffect[reqStatus[0]]]; + } + return ["status", ""]; + } + +} + +/** + * Finds if there are pokemon that can form change with a given item. + * Notice that we mean specific items, like Charizardite, not the Mega Bracelet. + * If you want to trigger the event based on the form change enabler, use PersistentModifierRequirement. + */ +export class CanFormChangeWithItemRequirement extends EncounterPokemonRequirement { + requiredFormChangeItem: FormChangeItem[]; + minNumberOfPokemon: number; + invertQuery: boolean; + + constructor(formChangeItem: FormChangeItem | FormChangeItem[], minNumberOfPokemon: number = 1, invertQuery: boolean = false) { + super(); + this.minNumberOfPokemon = minNumberOfPokemon; + this.invertQuery = invertQuery; + this.requiredFormChangeItem = Array.isArray(formChangeItem) ? formChangeItem : [formChangeItem]; + } + + override meetsRequirement(scene: BattleScene): boolean { + const partyPokemon = scene.getParty(); + if (isNullOrUndefined(partyPokemon) || this.requiredFormChangeItem?.length < 0) { + return false; + } + return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; + } + + filterByForm(pokemon, formChangeItem) { + if (pokemonFormChanges.hasOwnProperty(pokemon.species.speciesId) + // Get all form changes for this species with an item trigger, including any compound triggers + && pokemonFormChanges[pokemon.species.speciesId].filter(fc => fc.trigger.hasTriggerType(SpeciesFormChangeItemTrigger)) + // Returns true if any form changes match this item + .map(fc => fc.findTrigger(SpeciesFormChangeItemTrigger) as SpeciesFormChangeItemTrigger) + .flat().flatMap(fc => fc.item).includes(formChangeItem)) { + return true; + } else { + return false; + } + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + if (!this.invertQuery) { + return partyPokemon.filter((pokemon) => this.requiredFormChangeItem.filter((formChangeItem) => this.filterByForm(pokemon, formChangeItem)).length > 0); + } else { + // for an inverted query, we only want to get the pokemon that don't have ANY of the listed formChangeItems + return partyPokemon.filter((pokemon) => this.requiredFormChangeItem.filter((formChangeItem) => this.filterByForm(pokemon, formChangeItem)).length === 0); + } + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + const requiredItems = this.requiredFormChangeItem.filter((formChangeItem) => this.filterByForm(pokemon, formChangeItem)); + if (requiredItems.length > 0) { + return ["formChangeItem", FormChangeItem[requiredItems[0]]]; + } + return ["formChangeItem", ""]; + } + +} + +export class CanEvolveWithItemRequirement extends EncounterPokemonRequirement { + requiredEvolutionItem: EvolutionItem[]; + minNumberOfPokemon: number; + invertQuery: boolean; + + constructor(evolutionItems: EvolutionItem | EvolutionItem[], minNumberOfPokemon: number = 1, invertQuery: boolean = false) { + super(); + this.minNumberOfPokemon = minNumberOfPokemon; + this.invertQuery = invertQuery; + this.requiredEvolutionItem = Array.isArray(evolutionItems) ? evolutionItems : [evolutionItems]; + } + + override meetsRequirement(scene: BattleScene): boolean { + const partyPokemon = scene.getParty(); + if (isNullOrUndefined(partyPokemon) || this.requiredEvolutionItem?.length < 0) { + return false; + } + return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; + } + + filterByEvo(pokemon, evolutionItem) { + if (pokemonEvolutions.hasOwnProperty(pokemon.species.speciesId) && pokemonEvolutions[pokemon.species.speciesId].filter(e => e.item === evolutionItem + && (!e.condition || e.condition.predicate(pokemon))).length && (pokemon.getFormKey() !== SpeciesFormKey.GIGANTAMAX)) { + return true; + } else if (pokemon.isFusion() && pokemonEvolutions.hasOwnProperty(pokemon.fusionSpecies.speciesId) && pokemonEvolutions[pokemon.fusionSpecies.speciesId].filter(e => e.item === evolutionItem + && (!e.condition || e.condition.predicate(pokemon))).length && (pokemon.getFusionFormKey() !== SpeciesFormKey.GIGANTAMAX)) { + return true; + } + return false; + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + if (!this.invertQuery) { + return partyPokemon.filter((pokemon) => this.requiredEvolutionItem.filter((evolutionItem) => this.filterByEvo(pokemon, evolutionItem)).length > 0); + } else { + // for an inverted query, we only want to get the pokemon that don't have ANY of the listed evolutionItemss + return partyPokemon.filter((pokemon) => this.requiredEvolutionItem.filter((evolutionItems) => this.filterByEvo(pokemon, evolutionItems)).length === 0); + } + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + const requiredItems = this.requiredEvolutionItem.filter((evoItem) => this.filterByEvo(pokemon, evoItem)); + if (requiredItems.length > 0) { + return ["evolutionItem", EvolutionItem[requiredItems[0]]]; + } + return ["evolutionItem", ""]; + } +} + +export class HeldItemRequirement extends EncounterPokemonRequirement { + requiredHeldItemModifiers: string[]; + minNumberOfPokemon: number; + invertQuery: boolean; + + constructor(heldItem: string | string[], minNumberOfPokemon: number = 1, invertQuery: boolean = false) { + super(); + this.minNumberOfPokemon = minNumberOfPokemon; + this.invertQuery = invertQuery; + this.requiredHeldItemModifiers = Array.isArray(heldItem) ? heldItem : [heldItem]; + } + + override meetsRequirement(scene: BattleScene): boolean { + const partyPokemon = scene.getParty(); + if (isNullOrUndefined(partyPokemon)) { + return false; + } + return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + if (!this.invertQuery) { + return partyPokemon.filter((pokemon) => this.requiredHeldItemModifiers.some((heldItem) => { + return pokemon.getHeldItems().some((it) => { + return it.constructor.name === heldItem; + }); + })); + } else { + // for an inverted query, we only want to get the pokemon that have any held items that are NOT in requiredHeldItemModifiers + // E.g. functions as a blacklist + return partyPokemon.filter((pokemon) => pokemon.getHeldItems().filter((it) => { + return !this.requiredHeldItemModifiers.some(heldItem => it.constructor.name === heldItem); + }).length > 0); + } + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + const requiredItems = pokemon?.getHeldItems().filter((it) => { + return this.requiredHeldItemModifiers.some(heldItem => it.constructor.name === heldItem); + }); + if (requiredItems && requiredItems.length > 0) { + return ["heldItem", requiredItems[0].type.name]; + } + return ["heldItem", ""]; + } +} + +export class AttackTypeBoosterHeldItemTypeRequirement extends EncounterPokemonRequirement { + requiredHeldItemTypes: Type[]; + minNumberOfPokemon: number; + invertQuery: boolean; + + constructor(heldItemTypes: Type | Type[], minNumberOfPokemon: number = 1, invertQuery: boolean = false) { + super(); + this.minNumberOfPokemon = minNumberOfPokemon; + this.invertQuery = invertQuery; + this.requiredHeldItemTypes = Array.isArray(heldItemTypes) ? heldItemTypes : [heldItemTypes]; + } + + override meetsRequirement(scene: BattleScene): boolean { + const partyPokemon = scene.getParty(); + if (isNullOrUndefined(partyPokemon)) { + return false; + } + return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + if (!this.invertQuery) { + return partyPokemon.filter((pokemon) => this.requiredHeldItemTypes.some((heldItemType) => { + return pokemon.getHeldItems().some((it) => { + return it instanceof AttackTypeBoosterModifier && (it.type as AttackTypeBoosterModifierType).moveType === heldItemType; + }); + })); + } else { + // for an inverted query, we only want to get the pokemon that have any held items that are NOT in requiredHeldItemModifiers + // E.g. functions as a blacklist + return partyPokemon.filter((pokemon) => pokemon.getHeldItems().filter((it) => { + return !this.requiredHeldItemTypes.some(heldItemType => it instanceof AttackTypeBoosterModifier && (it.type as AttackTypeBoosterModifierType).moveType === heldItemType); + }).length > 0); + } + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + const requiredItems = pokemon?.getHeldItems().filter((it) => { + return this.requiredHeldItemTypes.some(heldItemType => it instanceof AttackTypeBoosterModifier && (it.type as AttackTypeBoosterModifierType).moveType === heldItemType); + }); + if (requiredItems && requiredItems.length > 0) { + return ["heldItem", requiredItems[0].type.name]; + } + return ["heldItem", ""]; + } +} + +export class LevelRequirement extends EncounterPokemonRequirement { + requiredLevelRange: [number, number]; + minNumberOfPokemon: number; + invertQuery: boolean; + + constructor(requiredLevelRange: [number, number], minNumberOfPokemon: number = 1, invertQuery: boolean = false) { + super(); + this.minNumberOfPokemon = minNumberOfPokemon; + this.invertQuery = invertQuery; + this.requiredLevelRange = requiredLevelRange; + } + + override meetsRequirement(scene: BattleScene): boolean { + // Party Pokemon inside required level range + if (!isNullOrUndefined(this.requiredLevelRange) && this.requiredLevelRange[0] <= this.requiredLevelRange[1]) { + const partyPokemon = scene.getParty(); + const pokemonInRange = this.queryParty(partyPokemon); + if (pokemonInRange.length < this.minNumberOfPokemon) { + return false; + } + } + return true; + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + if (!this.invertQuery) { + return partyPokemon.filter((pokemon) => pokemon.level >= this.requiredLevelRange[0] && pokemon.level <= this.requiredLevelRange[1]); + } else { + // for an inverted query, we only want to get the pokemon that don't have ANY of the listed requiredLevelRanges + return partyPokemon.filter((pokemon) => pokemon.level < this.requiredLevelRange[0] || pokemon.level > this.requiredLevelRange[1]); + } + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + return ["level", pokemon?.level.toString() ?? ""]; + } +} + +export class FriendshipRequirement extends EncounterPokemonRequirement { + requiredFriendshipRange: [number, number]; + minNumberOfPokemon: number; + invertQuery: boolean; + + constructor(requiredFriendshipRange: [number, number], minNumberOfPokemon: number = 1, invertQuery: boolean = false) { + super(); + this.minNumberOfPokemon = minNumberOfPokemon; + this.invertQuery = invertQuery; + this.requiredFriendshipRange = requiredFriendshipRange; + } + + override meetsRequirement(scene: BattleScene): boolean { + // Party Pokemon inside required friendship range + if (!isNullOrUndefined(this.requiredFriendshipRange) && this.requiredFriendshipRange[0] <= this.requiredFriendshipRange[1]) { + const partyPokemon = scene.getParty(); + const pokemonInRange = this.queryParty(partyPokemon); + if (pokemonInRange.length < this.minNumberOfPokemon) { + return false; + } + } + return true; + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + if (!this.invertQuery) { + return partyPokemon.filter((pokemon) => pokemon.friendship >= this.requiredFriendshipRange[0] && pokemon.friendship <= this.requiredFriendshipRange[1]); + } else { + // for an inverted query, we only want to get the pokemon that don't have ANY of the listed requiredFriendshipRanges + return partyPokemon.filter((pokemon) => pokemon.friendship < this.requiredFriendshipRange[0] || pokemon.friendship > this.requiredFriendshipRange[1]); + } + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + return ["friendship", pokemon?.friendship.toString() ?? ""]; + } +} + +/** + * .1 -> 10% hp + * .5 -> 50% hp + * 1 -> 100% hp + */ +export class HealthRatioRequirement extends EncounterPokemonRequirement { + requiredHealthRange: [number, number]; + minNumberOfPokemon: number; + invertQuery: boolean; + + constructor(requiredHealthRange: [number, number], minNumberOfPokemon: number = 1, invertQuery: boolean = false) { + super(); + this.minNumberOfPokemon = minNumberOfPokemon; + this.invertQuery = invertQuery; + this.requiredHealthRange = requiredHealthRange; + } + + override meetsRequirement(scene: BattleScene): boolean { + // Party Pokemon's health inside required health range + if (!isNullOrUndefined(this.requiredHealthRange) && this.requiredHealthRange[0] <= this.requiredHealthRange[1]) { + const partyPokemon = scene.getParty(); + const pokemonInRange = this.queryParty(partyPokemon); + if (pokemonInRange.length < this.minNumberOfPokemon) { + return false; + } + } + return true; + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + if (!this.invertQuery) { + return partyPokemon.filter((pokemon) => { + return pokemon.getHpRatio() >= this.requiredHealthRange[0] && pokemon.getHpRatio() <= this.requiredHealthRange[1]; + }); + } else { + // for an inverted query, we only want to get the pokemon that don't have ANY of the listed requiredHealthRanges + return partyPokemon.filter((pokemon) => pokemon.getHpRatio() < this.requiredHealthRange[0] || pokemon.getHpRatio() > this.requiredHealthRange[1]); + } + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + if (!isNullOrUndefined(pokemon?.getHpRatio())) { + return ["healthRatio", Math.floor(pokemon!.getHpRatio() * 100).toString() + "%"]; + } + return ["healthRatio", ""]; + } +} + +export class WeightRequirement extends EncounterPokemonRequirement { + requiredWeightRange: [number, number]; + minNumberOfPokemon: number; + invertQuery: boolean; + + constructor(requiredWeightRange: [number, number], minNumberOfPokemon: number = 1, invertQuery: boolean = false) { + super(); + this.minNumberOfPokemon = minNumberOfPokemon; + this.invertQuery = invertQuery; + this.requiredWeightRange = requiredWeightRange; + } + + override meetsRequirement(scene: BattleScene): boolean { + // Party Pokemon's weight inside required weight range + if (!isNullOrUndefined(this.requiredWeightRange) && this.requiredWeightRange[0] <= this.requiredWeightRange[1]) { + const partyPokemon = scene.getParty(); + const pokemonInRange = this.queryParty(partyPokemon); + if (pokemonInRange.length < this.minNumberOfPokemon) { + return false; + } + } + return true; + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + if (!this.invertQuery) { + return partyPokemon.filter((pokemon) => pokemon.getWeight() >= this.requiredWeightRange[0] && pokemon.getWeight() <= this.requiredWeightRange[1]); + } else { + // for an inverted query, we only want to get the pokemon that don't have ANY of the listed requiredWeightRanges + return partyPokemon.filter((pokemon) => pokemon.getWeight() < this.requiredWeightRange[0] || pokemon.getWeight() > this.requiredWeightRange[1]); + } + } + + override getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + return ["weight", pokemon?.getWeight().toString() ?? ""]; + } +} + + diff --git a/src/data/mystery-encounters/mystery-encounter-save-data.ts b/src/data/mystery-encounters/mystery-encounter-save-data.ts new file mode 100644 index 000000000000..259fbff7b854 --- /dev/null +++ b/src/data/mystery-encounters/mystery-encounter-save-data.ts @@ -0,0 +1,38 @@ +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT } from "#app/data/mystery-encounters/mystery-encounters"; +import { isNullOrUndefined } from "#app/utils"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; + +export class SeenEncounterData { + type: MysteryEncounterType; + tier: MysteryEncounterTier; + waveIndex: number; + selectedOption: number; + + constructor(type: MysteryEncounterType, tier: MysteryEncounterTier, waveIndex: number, selectedOption?: number) { + this.type = type; + this.tier = tier; + this.waveIndex = waveIndex; + this.selectedOption = selectedOption ?? -1; + } +} + +export interface QueuedEncounter { + type: MysteryEncounterType; + spawnPercent: number; // Out of 100 +} + +export class MysteryEncounterSaveData { + encounteredEvents: SeenEncounterData[] = []; + encounterSpawnChance: number = BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT; + queuedEncounters: QueuedEncounter[] = []; + + constructor(data?: MysteryEncounterSaveData) { + if (!isNullOrUndefined(data)) { + Object.assign(this, data); + } + + this.encounteredEvents = this.encounteredEvents ?? []; + this.queuedEncounters = this.queuedEncounters ?? []; + } +} diff --git a/src/data/mystery-encounters/mystery-encounter.ts b/src/data/mystery-encounters/mystery-encounter.ts new file mode 100644 index 000000000000..a204ea848da2 --- /dev/null +++ b/src/data/mystery-encounters/mystery-encounter.ts @@ -0,0 +1,957 @@ +import { EnemyPartyConfig } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import Pokemon, { PlayerPokemon, PokemonMove } from "#app/field/pokemon"; +import { capitalizeFirstLetter, isNullOrUndefined } from "#app/utils"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounterIntroVisuals, { MysteryEncounterSpriteConfig } from "#app/field/mystery-encounter-intro"; +import * as Utils from "#app/utils"; +import { StatusEffect } from "../status-effect"; +import MysteryEncounterDialogue, { OptionTextDisplay } from "./mystery-encounter-dialogue"; +import MysteryEncounterOption, { MysteryEncounterOptionBuilder, OptionPhaseCallback } from "./mystery-encounter-option"; +import { EncounterPokemonRequirement, EncounterSceneRequirement, HealthRatioRequirement, PartySizeRequirement, StatusEffectRequirement, WaveRangeRequirement } from "./mystery-encounter-requirements"; +import { BattlerIndex } from "#app/battle"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterMode } from "#enums/mystery-encounter-mode"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { GameModes } from "#app/game-mode"; +import { EncounterAnim } from "#enums/encounter-anims"; + +export interface EncounterStartOfBattleEffect { + sourcePokemon?: Pokemon; + sourceBattlerIndex?: BattlerIndex; + targets: BattlerIndex[]; + move: PokemonMove; + ignorePp: boolean; + followUp?: boolean; +} + +/** + * Used by {@linkcode MysteryEncounterBuilder} class to define required/optional properties on the {@linkcode MysteryEncounter} class when building. + * + * Should ONLY contain properties that are necessary for {@linkcode MysteryEncounter} construction. + * Post-construct and flag data properties are defined in the {@linkcode MysteryEncounter} class itself. + */ +export interface IMysteryEncounter { + encounterType: MysteryEncounterType; + options: [MysteryEncounterOption, MysteryEncounterOption, ...MysteryEncounterOption[]]; + spriteConfigs: MysteryEncounterSpriteConfig[]; + encounterTier: MysteryEncounterTier; + encounterAnimations?: EncounterAnim[]; + disabledGameModes?: GameModes[]; + hideBattleIntroMessage: boolean; + autoHideIntroVisuals: boolean; + enterIntroVisualsFromRight: boolean; + catchAllowed: boolean; + continuousEncounter: boolean; + maxAllowedEncounters: number; + hasBattleAnimationsWithoutTargets: boolean; + skipEnemyBattleTurns: boolean; + skipToFightInput: boolean; + + onInit?: (scene: BattleScene) => boolean; + onVisualsStart?: (scene: BattleScene) => boolean; + doEncounterExp?: (scene: BattleScene) => boolean; + doEncounterRewards?: (scene: BattleScene) => boolean; + doContinueEncounter?: (scene: BattleScene) => Promise; + + requirements: EncounterSceneRequirement[]; + primaryPokemonRequirements: EncounterPokemonRequirement[]; + secondaryPokemonRequirements: EncounterPokemonRequirement[]; + excludePrimaryFromSupportRequirements: boolean; + + dialogue: MysteryEncounterDialogue; + enemyPartyConfigs: EnemyPartyConfig[]; + + dialogueTokens: Record; + expMultiplier: number; +} + +/** + * MysteryEncounter class that defines the logic for a single encounter + * These objects will be saved as part of session data any time the player is on a floor with an encounter + * Unless you know what you're doing, you should use MysteryEncounterBuilder to create an instance for this class + */ +export default class MysteryEncounter implements IMysteryEncounter { + // #region Required params + + encounterType: MysteryEncounterType; + options: [MysteryEncounterOption, MysteryEncounterOption, ...MysteryEncounterOption[]]; + spriteConfigs: MysteryEncounterSpriteConfig[]; + + // #region Optional params + + encounterTier: MysteryEncounterTier; + /** + * Custom battle animations that are configured for encounter effects and visuals + * Specify here so that assets are loaded on initialization of encounter + */ + encounterAnimations?: EncounterAnim[]; + /** + * If specified, defines any game modes where the {@linkcode MysteryEncounter} should *NOT* spawn + */ + disabledGameModes?: GameModes[]; + /** + * If true, hides "A Wild X Appeared" etc. messages + * Default true + */ + hideBattleIntroMessage: boolean; + /** + * If true, when an option is selected the field visuals will fade out automatically + * Default false + */ + autoHideIntroVisuals: boolean; + /** + * Intro visuals on the field will slide in from the right instead of the left + * Default false + */ + enterIntroVisualsFromRight: boolean; + /** + * If true, allows catching a wild pokemon during the encounter + * Default false + */ + catchAllowed: boolean; + /** + * If true, encounter will continuously run through multiple battles/puzzles/etc. instead of going to next wave + * MUST EVENTUALLY BE DISABLED TO CONTINUE TO NEXT WAVE + * Default false + */ + continuousEncounter: boolean; + /** + * Maximum number of times the encounter can be seen per run + * Rogue tier encounters default to 1, others default to 3 + */ + maxAllowedEncounters: number; + /** + * If true, encounter will not animate the target Pokemon as part of battle animations + * Used for encounters where it is not a "real" battle, but still uses battle animations and commands (see {@linkcode FunAndGamesEncounter} for an example) + */ + hasBattleAnimationsWithoutTargets: boolean; + /** + * If true, will skip enemy pokemon turns during battle for the encounter + * Used for encounters where it is not a "real" battle, but still uses battle animations and commands (see {@linkcode FunAndGamesEncounter} for an example) + */ + skipEnemyBattleTurns: boolean; + /** + * If true, will skip COMMAND input and go straight to FIGHT (move select) input menu + */ + skipToFightInput: boolean; + + // #region Event callback functions + + /** Event when Encounter is first loaded, use it for data conditioning */ + onInit?: (scene: BattleScene) => boolean; + /** Event when battlefield visuals have finished sliding in and the encounter dialogue begins */ + onVisualsStart?: (scene: BattleScene) => boolean; + /** Event triggered prior to {@linkcode CommandPhase}, during {@linkcode TurnInitPhase} */ + onTurnStart?: (scene: BattleScene) => boolean; + /** Event prior to any rewards logic in {@linkcode MysteryEncounterRewardsPhase} */ + onRewards?: (scene: BattleScene) => Promise; + /** Will provide the player party EXP before rewards are displayed for that wave */ + doEncounterExp?: (scene: BattleScene) => boolean; + /** Will provide the player a rewards shop for that wave */ + doEncounterRewards?: (scene: BattleScene) => boolean; + /** Will execute callback during VictoryPhase of a continuousEncounter */ + doContinueEncounter?: (scene: BattleScene) => Promise; + + /** + * Requirements + */ + requirements: EncounterSceneRequirement[]; + /** Primary Pokemon is a single pokemon randomly selected from the party that meet ALL primary pokemon requirements */ + primaryPokemonRequirements: EncounterPokemonRequirement[]; + /** + * Secondary Pokemon are pokemon that meet ALL secondary pokemon requirements + * Note that an individual requirement may require multiple pokemon, but the resulting pokemon after all secondary requirements are met may be lower than expected + * If the primary pokemon and secondary pokemon are the same and ExcludePrimaryFromSupportRequirements flag is true, primary pokemon may be promoted from secondary pool + */ + secondaryPokemonRequirements: EncounterPokemonRequirement[]; + excludePrimaryFromSupportRequirements: boolean; + primaryPokemon?: PlayerPokemon; + secondaryPokemon?: PlayerPokemon[]; + + // #region Post-construct / Auto-populated params + + /** + * Dialogue object containing all the dialogue, messages, tooltips, etc. for an encounter + */ + dialogue: MysteryEncounterDialogue; + /** + * Data used for setting up/initializing enemy party in battles + * Can store multiple configs so that one can be chosen based on option selected + * Should usually be defined in `onInit()` or `onPreOptionPhase()` + */ + enemyPartyConfigs: EnemyPartyConfig[]; + /** + * Object instance containing sprite data for an encounter when it is being spawned + * Otherwise, will be undefined + * You probably shouldn't do anything directly with this unless you have a very specific need + */ + introVisuals?: MysteryEncounterIntroVisuals; + + // #region Flags + + /** + * Can be set for uses programatic dialogue during an encounter (storing the name of one of the party's pokemon, etc.) + * Example use: see MYSTERIOUS_CHEST + */ + dialogueTokens: Record; + /** + * Should be set depending upon option selected as part of an encounter + * For example, if there is no battle as part of the encounter/selected option, should be set to NO_BATTLE + * Defaults to DEFAULT + */ + encounterMode: MysteryEncounterMode; + /** + * Flag for checking if it's the first time a shop is being shown for an encounter. + * Defaults to true so that the first shop does not override the specified rewards. + * Will be set to false after a shop is shown (so can't reroll same rarity items for free) + */ + lockEncounterRewardTiers: boolean; + /** + * Will be set automatically, indicates special moves in startOfBattleEffects are complete (so will not repeat) + */ + startOfBattleEffectsComplete: boolean; + /** + * Will be set by option select handlers automatically, and can be used to refer to which option was chosen by later phases + */ + selectedOption?: MysteryEncounterOption; + /** + * Will be set by option select handlers automatically, and can be used to refer to which option was chosen by later phases + */ + startOfBattleEffects: EncounterStartOfBattleEffect[] = []; + /** + * Can be set higher or lower based on the type of battle or exp gained for an option/encounter + * Defaults to 1 + */ + expMultiplier: number; + /** + * Can add any asset load promises here during onInit() to make sure the scene awaits the loads properly + */ + loadAssets: Promise[]; + /** + * Generic property to set any custom data required for the encounter + * Extremely useful for carrying state/data between onPreOptionPhase/onOptionPhase/onPostOptionPhase + */ + misc?: any; + /** + * Used for keeping RNG consistent on session resets, but increments when cycling through multiple "Encounters" on the same wave + * You should only need to interact via getter/update methods + */ + private seedOffset?: any; + + constructor(encounter: IMysteryEncounter | null) { + if (!isNullOrUndefined(encounter)) { + Object.assign(this, encounter); + } + this.encounterTier = this.encounterTier ?? MysteryEncounterTier.COMMON; + this.dialogue = this.dialogue ?? {}; + this.spriteConfigs = this.spriteConfigs ? [...this.spriteConfigs] : []; + // Default max is 1 for ROGUE encounters, 3 for others + this.maxAllowedEncounters = this.maxAllowedEncounters ?? this.encounterTier === MysteryEncounterTier.ROGUE ? 1 : 3; + this.encounterMode = MysteryEncounterMode.DEFAULT; + this.requirements = this.requirements ? this.requirements : []; + this.hideBattleIntroMessage = this.hideBattleIntroMessage ?? false; + this.autoHideIntroVisuals = this.autoHideIntroVisuals ?? true; + this.enterIntroVisualsFromRight = this.enterIntroVisualsFromRight ?? false; + this.continuousEncounter = this.continuousEncounter ?? false; + + // Reset any dirty flags or encounter data + this.startOfBattleEffectsComplete = false; + this.lockEncounterRewardTiers = true; + this.dialogueTokens = {}; + this.enemyPartyConfigs = []; + this.startOfBattleEffects = []; + this.introVisuals = undefined; + this.misc = null; + this.expMultiplier = 1; + this.loadAssets = []; + } + + /** + * Checks if the current scene state meets the requirements for the {@linkcode MysteryEncounter} to spawn + * This is used to filter the pool of encounters down to only the ones with all requirements met + * @param scene + * @returns + */ + meetsRequirements(scene: BattleScene): boolean { + const sceneReq = !this.requirements.some(requirement => !requirement.meetsRequirement(scene)); + const secReqs = this.meetsSecondaryRequirementAndSecondaryPokemonSelected(scene); // secondary is checked first to handle cases of primary overlapping with secondary + const priReqs = this.meetsPrimaryRequirementAndPrimaryPokemonSelected(scene); + + return sceneReq && secReqs && priReqs; + } + + /** + * Checks if a specific player pokemon meets all given primary EncounterPokemonRequirements + * Used automatically as part of {@linkcode meetsRequirements}, but can also be used to manually check certain Pokemon where needed + * @param scene + * @param pokemon + */ + pokemonMeetsPrimaryRequirements(scene: BattleScene, pokemon: Pokemon): boolean { + return !this.primaryPokemonRequirements.some(req => !req.queryParty(scene.getParty()).map(p => p.id).includes(pokemon.id)); + } + + /** + * Returns true if all PRIMARY {@linkcode EncounterRequirement}s for the option are met, + * AND there is a valid Pokemon assigned to {@linkcode primaryPokemon}. + * If both {@linkcode primaryPokemonRequirements} and {@linkcode secondaryPokemonRequirements} are defined, + * can cause scenarios where there are not enough Pokemon that are sufficient for all requirements. + * @param scene + */ + private meetsPrimaryRequirementAndPrimaryPokemonSelected(scene: BattleScene): boolean { + if (!this.primaryPokemonRequirements || this.primaryPokemonRequirements.length === 0) { + const activeMon = scene.getParty().filter(p => p.isActive(true)); + if (activeMon.length > 0) { + this.primaryPokemon = activeMon[0]; + } else { + this.primaryPokemon = scene.getParty().filter(p => !p.isFainted())[0]; + } + return true; + } + let qualified: PlayerPokemon[] = scene.getParty(); + for (const req of this.primaryPokemonRequirements) { + if (req.meetsRequirement(scene)) { + qualified = qualified.filter(pkmn => req.queryParty(scene.getParty()).includes(pkmn)); + } else { + this.primaryPokemon = undefined; + return false; + } + } + + if (qualified.length === 0) { + return false; + } + + if (this.excludePrimaryFromSupportRequirements && this.secondaryPokemon && this.secondaryPokemon.length > 0) { + const truePrimaryPool: PlayerPokemon[] = []; + const overlap: PlayerPokemon[] = []; + for (const qp of qualified) { + if (!this.secondaryPokemon.includes(qp)) { + truePrimaryPool.push(qp); + } else { + overlap.push(qp); + } + + } + if (truePrimaryPool.length > 0) { + // Always choose from the non-overlapping pokemon first + this.primaryPokemon = truePrimaryPool[Utils.randSeedInt(truePrimaryPool.length, 0)]; + return true; + } else { + // If there are multiple overlapping pokemon, we're okay - just choose one and take it out of the primary pokemon pool + if (overlap.length > 1 || (this.secondaryPokemon.length - overlap.length >= 1)) { + // is this working? + this.primaryPokemon = overlap[Utils.randSeedInt(overlap.length, 0)]; + this.secondaryPokemon = this.secondaryPokemon.filter((supp) => supp !== this.primaryPokemon); + return true; + } + console.log("Mystery Encounter Edge Case: Requirement not met due to primary pokemon overlapping with secondary pokemon. There's no valid primary pokemon left."); + return false; + } + } else { + // this means we CAN have the same pokemon be a primary and secondary pokemon, so just choose any qualifying one randomly. + this.primaryPokemon = qualified[Utils.randSeedInt(qualified.length, 0)]; + return true; + } + } + + /** + * Returns true if all SECONDARY {@linkcode EncounterRequirement}s for the option are met, + * AND there is a valid Pokemon assigned to {@linkcode secondaryPokemon} (if applicable). + * If both {@linkcode primaryPokemonRequirements} and {@linkcode secondaryPokemonRequirements} are defined, + * can cause scenarios where there are not enough Pokemon that are sufficient for all requirements. + * @param scene + */ + private meetsSecondaryRequirementAndSecondaryPokemonSelected(scene: BattleScene): boolean { + if (!this.secondaryPokemonRequirements || this.secondaryPokemonRequirements.length === 0) { + this.secondaryPokemon = []; + return true; + } + + let qualified: PlayerPokemon[] = scene.getParty(); + for (const req of this.secondaryPokemonRequirements) { + if (req.meetsRequirement(scene)) { + qualified = qualified.filter(pkmn => req.queryParty(scene.getParty()).includes(pkmn)); + } else { + this.secondaryPokemon = []; + return false; + } + } + this.secondaryPokemon = qualified; + return true; + } + + /** + * Initializes encounter intro sprites based on the sprite configs defined in spriteConfigs + * @param scene + */ + initIntroVisuals(scene: BattleScene): void { + this.introVisuals = new MysteryEncounterIntroVisuals(scene, this); + } + + /** + * Auto-pushes dialogue tokens from the encounter (and option) requirements. + * Will use the first support pokemon in list + * For multiple support pokemon in the dialogue token, it will have to be overridden. + */ + populateDialogueTokensFromRequirements(scene: BattleScene): void { + this.meetsRequirements(scene); + if (this.requirements?.length > 0) { + for (const req of this.requirements) { + const dialogueToken = req.getDialogueToken(scene); + if (dialogueToken?.length === 2) { + this.setDialogueToken(...dialogueToken); + } + } + } + if (this.primaryPokemon && this.primaryPokemon.length > 0) { + this.setDialogueToken("primaryName", this.primaryPokemon.getNameToRender()); + for (const req of this.primaryPokemonRequirements) { + if (!req.invertQuery) { + const value = req.getDialogueToken(scene, this.primaryPokemon); + if (value?.length === 2) { + this.setDialogueToken("primary" + capitalizeFirstLetter(value[0]), value[1]); + } + } + } + } + if (this.secondaryPokemonRequirements?.length > 0 && this.secondaryPokemon && this.secondaryPokemon.length > 0) { + this.setDialogueToken("secondaryName", this.secondaryPokemon[0].getNameToRender()); + for (const req of this.secondaryPokemonRequirements) { + if (!req.invertQuery) { + const value = req.getDialogueToken(scene, this.secondaryPokemon[0]); + if (value?.length === 2) { + this.setDialogueToken("primary" + capitalizeFirstLetter(value[0]), value[1]); + } + this.setDialogueToken("secondary" + capitalizeFirstLetter(value[0]), value[1]); + } + } + } + + // Dialogue tokens for options + for (let i = 0; i < this.options.length; i++) { + const opt = this.options[i]; + opt.meetsRequirements(scene); + const j = i + 1; + if (opt.requirements.length > 0) { + for (const req of opt.requirements) { + const dialogueToken = req.getDialogueToken(scene); + if (dialogueToken?.length === 2) { + this.setDialogueToken("option" + j + capitalizeFirstLetter(dialogueToken[0]), dialogueToken[1]); + } + } + } + if (opt.primaryPokemonRequirements.length > 0 && opt.primaryPokemon) { + this.setDialogueToken("option" + j + "PrimaryName", opt.primaryPokemon.getNameToRender()); + for (const req of opt.primaryPokemonRequirements) { + if (!req.invertQuery) { + const value = req.getDialogueToken(scene, opt.primaryPokemon); + if (value?.length === 2) { + this.setDialogueToken("option" + j + "Primary" + capitalizeFirstLetter(value[0]), value[1]); + } + } + } + } + if (opt.secondaryPokemonRequirements?.length > 0 && opt.secondaryPokemon && opt.secondaryPokemon.length > 0) { + this.setDialogueToken("option" + j + "SecondaryName", opt.secondaryPokemon[0].getNameToRender()); + for (const req of opt.secondaryPokemonRequirements) { + if (!req.invertQuery) { + const value = req.getDialogueToken(scene, opt.secondaryPokemon[0]); + if (value?.length === 2) { + this.setDialogueToken("option" + j + "Secondary" + capitalizeFirstLetter(value[0]), value[1]); + } + } + } + } + } + } + + /** + * Used to cache a dialogue token for the encounter. + * Tokens will be auto-injected via the `{{key}}` pattern with `value`, + * when using the {@linkcode showEncounterText} and {@linkcode showEncounterDialogue} helper functions. + * + * @param key + * @param value + */ + setDialogueToken(key: string, value: string): void { + this.dialogueTokens[key] = value; + } + + /** + * If an encounter uses {@linkcode MysteryEncounterMode.continuousEncounter}, + * should rely on this value for seed offset instead of wave index. + * + * This offset is incremented for each new {@linkcode MysteryEncounterPhase} that occurs, + * so multi-encounter RNG will be consistent on resets and not be affected by number of turns, move RNG, etc. + */ + getSeedOffset() { + return this.seedOffset; + } + + /** + * Maintains seed offset for RNG consistency + * Increments if the same {@linkcode MysteryEncounter} has multiple option select cycles + * @param scene + */ + updateSeedOffset(scene: BattleScene) { + const currentOffset = this.seedOffset ?? scene.currentBattle.waveIndex * 1000; + this.seedOffset = currentOffset + 512; + } +} + +/** + * Builder class for creating a MysteryEncounter + * must call `build()` at the end after specifying all params for the MysteryEncounter + */ +export class MysteryEncounterBuilder implements Partial { + options: [MysteryEncounterOption, MysteryEncounterOption, ...MysteryEncounterOption[]]; + enemyPartyConfigs: EnemyPartyConfig[] = []; + + dialogue: MysteryEncounterDialogue = {}; + requirements: EncounterSceneRequirement[] = []; + primaryPokemonRequirements: EncounterPokemonRequirement[] = []; + secondaryPokemonRequirements: EncounterPokemonRequirement[] = []; + excludePrimaryFromSupportRequirements: boolean = true; + dialogueTokens: Record = {}; + + hideBattleIntroMessage: boolean = false; + autoHideIntroVisuals: boolean = true; + enterIntroVisualsFromRight: boolean = false; + continuousEncounter: boolean = false; + catchAllowed: boolean = false; + lockEncounterRewardTiers: boolean = false; + startOfBattleEffectsComplete: boolean = false; + hasBattleAnimationsWithoutTargets: boolean = false; + skipEnemyBattleTurns: boolean = false; + skipToFightInput: boolean = false; + maxAllowedEncounters: number = 3; + expMultiplier: number = 1; + + /** + * REQUIRED + */ + + /** + * @statif Defines the type of encounter which is used as an identifier, should be tied to a unique MysteryEncounterType + * NOTE: if new functions are added to {@linkcode MysteryEncounter} class + * @param encounterType + * @returns this + */ + static withEncounterType(encounterType: MysteryEncounterType): MysteryEncounterBuilder & Pick { + return Object.assign(new MysteryEncounterBuilder(), { encounterType }); + } + + /** + * Defines an option for the encounter. + * Use for complex options. + * There should be at least 2 options defined and no more than 4. + * + * @param option MysteryEncounterOption to add, can use MysteryEncounterOptionBuilder to create instance + * @returns + */ + withOption(option: MysteryEncounterOption): this & Pick { + if (!this.options) { + const options = [option]; + return Object.assign(this, { options }); + } else { + this.options.push(option); + return this; + } + } + + /** + * Defines an option + phasefor the encounter. + * Use for easy/streamlined options. + * There should be at least 2 options defined and no more than 4. + * If complex use {@linkcode MysteryEncounterBuilder.withOption} + * + * @param dialogue {@linkcode OptionTextDisplay} + * @param callback {@linkcode OptionPhaseCallback} + * @returns + */ + withSimpleOption(dialogue: OptionTextDisplay, callback: OptionPhaseCallback): this & Pick { + return this.withOption(MysteryEncounterOptionBuilder.newOptionWithMode(MysteryEncounterOptionMode.DEFAULT).withDialogue(dialogue).withOptionPhase(callback).build()); + } + + /** + * Defines an option + phasefor the encounter. + * Use for easy/streamlined options. + * There should be at least 2 options defined and no more than 4. + * If complex use {@linkcode MysteryEncounterBuilder.withOption} + * + * @param dialogue - {@linkcode OptionTextDisplay} + * @param callback - {@linkcode OptionPhaseCallback} + * @returns + */ + withSimpleDexProgressOption(dialogue: OptionTextDisplay, callback: OptionPhaseCallback): this & Pick { + return this.withOption(MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DEFAULT) + .withHasDexProgress(true) + .withDialogue(dialogue) + .withOptionPhase(callback).build()); + } + + /** + * Defines the sprites that will be shown on the enemy field when the encounter spawns + * Can be one or more sprites, recommended not to exceed 4 + * @param spriteConfigs + * @returns + */ + withIntroSpriteConfigs(spriteConfigs: MysteryEncounterSpriteConfig[]): this & Pick { + return Object.assign(this, { spriteConfigs: spriteConfigs }); + } + + withIntroDialogue(dialogue: MysteryEncounterDialogue["intro"] = []): this { + this.dialogue = {...this.dialogue, intro: dialogue }; + return this; + } + + withIntro({spriteConfigs, dialogue} : {spriteConfigs: MysteryEncounterSpriteConfig[], dialogue?: MysteryEncounterDialogue["intro"]}) { + return this.withIntroSpriteConfigs(spriteConfigs).withIntroDialogue(dialogue); + } + + /** + * OPTIONAL + */ + + /** + * Sets the rarity tier for an encounter + * If not specified, defaults to COMMON + * Tiers are: + * COMMON 32/64 odds + * GREAT 16/64 odds + * ULTRA 10/64 odds + * ROGUE 6/64 odds + * ULTRA_RARE Not currently used + * @param encounterTier + * @returns + */ + withEncounterTier(encounterTier: MysteryEncounterTier): this & Pick { + return Object.assign(this, { encounterTier: encounterTier }); + } + + /** + * Defines any EncounterAnim animations that are intended to be used during the encounter + * EncounterAnims are custom battle animations (think Ice Beam) that can be played at any point during an encounter or callback + * They just need to be specified here so that resources are loaded on encounter init + * @param encounterAnimations + * @returns + */ + withAnimations(...encounterAnimations: EncounterAnim[]): this & Required> { + const animations = Array.isArray(encounterAnimations) ? encounterAnimations : [encounterAnimations]; + return Object.assign(this, { encounterAnimations: animations }); + } + + /** + * Defines any game modes where the Mystery Encounter should *NOT* spawn + * @returns + * @param disabledGameModes + */ + withDisabledGameModes(...disabledGameModes: GameModes[]): this & Required> { + const gameModes = Array.isArray(disabledGameModes) ? disabledGameModes : [disabledGameModes]; + return Object.assign(this, { disabledGameModes: gameModes }); + } + + /** + * If true, encounter will continuously run through multiple battles/puzzles/etc. instead of going to next wave + * MUST EVENTUALLY BE DISABLED TO CONTINUE TO NEXT WAVE + * Default false + * @param continuousEncounter + */ + withContinuousEncounter(continuousEncounter: boolean): this & Required> { + return Object.assign(this, { continuousEncounter: continuousEncounter }); + } + + /** + * If true, encounter will not animate the target Pokemon as part of battle animations + * Used for encounters where it is not a "real" battle, but still uses battle animations and commands (see {@linkcode FunAndGamesEncounter} for an example) + * Default false + * @param hasBattleAnimationsWithoutTargets + */ + withBattleAnimationsWithoutTargets(hasBattleAnimationsWithoutTargets: boolean): this & Required> { + return Object.assign(this, { hasBattleAnimationsWithoutTargets }); + } + + /** + * If true, encounter will not animate the target Pokemon as part of battle animations + * Used for encounters where it is not a "real" battle, but still uses battle animations and commands (see {@linkcode FunAndGamesEncounter} for an example) + * Default false + * @param skipEnemyBattleTurns + */ + withSkipEnemyBattleTurns(skipEnemyBattleTurns: boolean): this & Required> { + return Object.assign(this, { skipEnemyBattleTurns }); + } + + /** + * If true, will skip COMMAND input and go straight to FIGHT (move select) input menu + * Default false + * @param skipToFightInput + */ + withSkipToFightInput(skipToFightInput: boolean): this & Required> { + return Object.assign(this, { skipToFightInput }); + } + + /** + * Sets the maximum number of times that an encounter can spawn in a given Classic run + * @param maxAllowedEncounters + * @returns + */ + withMaxAllowedEncounters(maxAllowedEncounters: number): this & Required> { + return Object.assign(this, { maxAllowedEncounters: maxAllowedEncounters }); + } + + /** + * Specifies a requirement for an encounter + * For example, passing requirement as "new WaveCountRequirement([2, 180])" would create a requirement that the encounter can only be spawned between waves 2 and 180 + * Existing Requirement objects are defined in mystery-encounter-requirements.ts, and more can always be created to meet a requirement need + * @param requirement + * @returns + */ + withSceneRequirement(requirement: EncounterSceneRequirement): this & Required> { + if (requirement instanceof EncounterPokemonRequirement) { + Error("Incorrectly added pokemon requirement as scene requirement."); + } + this.requirements.push(requirement); + return this; + } + + /** + * Specifies a wave range requirement for an encounter. + * + * @param min min wave (or exact wave if only min is given) + * @param max optional max wave. If not given, defaults to min => exact wave + * @returns + */ + withSceneWaveRangeRequirement(min: number, max?: number): this & Required> { + return this.withSceneRequirement(new WaveRangeRequirement([min, max ?? min])); + } + + /** + * Specifies a party size requirement for an encounter. + * + * @param min min wave (or exact size if only min is given) + * @param max optional max size. If not given, defaults to min => exact wave + * @param excludeFainted - if true, only counts unfainted mons + * @returns + */ + withScenePartySizeRequirement(min: number, max?: number, excludeFainted: boolean = false): this & Required> { + return this.withSceneRequirement(new PartySizeRequirement([min, max ?? min], excludeFainted)); + } + + /** + * Add a primary pokemon requirement + * + * @param requirement {@linkcode EncounterPokemonRequirement} + * @returns + */ + withPrimaryPokemonRequirement(requirement: EncounterPokemonRequirement): this & Required> { + if (requirement instanceof EncounterSceneRequirement) { + Error("Incorrectly added scene requirement as pokemon requirement."); + } + + this.primaryPokemonRequirements.push(requirement); + return Object.assign(this, { primaryPokemonRequirements: this.primaryPokemonRequirements }); + } + + /** + * Add a primary pokemon status effect requirement + * + * @param statusEffect the status effect/s to check + * @param minNumberOfPokemon minimum number of pokemon to have the effect + * @param invertQuery if true will invert the query + * @returns + */ + withPrimaryPokemonStatusEffectRequirement(statusEffect: StatusEffect | StatusEffect[], minNumberOfPokemon: number = 1, invertQuery: boolean = false): this & Required> { + return this.withPrimaryPokemonRequirement(new StatusEffectRequirement(statusEffect, minNumberOfPokemon, invertQuery)); + } + + /** + * Add a primary pokemon health ratio requirement + * + * @param requiredHealthRange the health range to check + * @param minNumberOfPokemon minimum number of pokemon to have the health range + * @param invertQuery if true will invert the query + * @returns + */ + withPrimaryPokemonHealthRatioRequirement(requiredHealthRange: [number, number], minNumberOfPokemon: number = 1, invertQuery: boolean = false): this & Required> { + return this.withPrimaryPokemonRequirement(new HealthRatioRequirement(requiredHealthRange, minNumberOfPokemon, invertQuery)); + } + + // TODO: Maybe add an optional parameter for excluding primary pokemon from the support cast? + // ex. if your only grass type pokemon, a snivy, is chosen as primary, if the support pokemon requires a grass type, the event won't trigger because + // it's already been + withSecondaryPokemonRequirement(requirement: EncounterPokemonRequirement, excludePrimaryFromSecondaryRequirements: boolean = false): this & Required> { + if (requirement instanceof EncounterSceneRequirement) { + Error("Incorrectly added scene requirement as pokemon requirement."); + } + + this.secondaryPokemonRequirements.push(requirement); + this.excludePrimaryFromSupportRequirements = excludePrimaryFromSecondaryRequirements; + return Object.assign(this, { excludePrimaryFromSecondaryRequirements: this.excludePrimaryFromSupportRequirements, secondaryPokemonRequirements: this.secondaryPokemonRequirements }); + } + + /** + * Can set custom encounter rewards via this callback function + * If rewards are always deterministic for an encounter, this is a good way to set them + * + * NOTE: If rewards are dependent on options selected, runtime data, etc., + * It may be better to programmatically set doEncounterRewards elsewhere. + * There is a helper function in mystery-encounter utils, setEncounterRewards(), which can be called programmatically to set rewards + * @param doEncounterRewards - synchronous callback function to perform during rewards phase of the encounter + * @returns + */ + withRewards(doEncounterRewards: (scene: BattleScene) => boolean): this & Required> { + return Object.assign(this, { doEncounterRewards: doEncounterRewards }); + } + + /** + * Can set custom encounter exp via this callback function + * If exp always deterministic for an encounter, this is a good way to set them + * + * NOTE: If rewards are dependent on options selected, runtime data, etc., + * It may be better to programmatically set doEncounterExp elsewhere. + * There is a helper function in mystery-encounter utils, setEncounterExp(), which can be called programmatically to set rewards + * @param doEncounterExp - synchronous callback function to perform during rewards phase of the encounter + * @returns + */ + withExp(doEncounterExp: (scene: BattleScene) => boolean): this & Required> { + return Object.assign(this, { doEncounterExp: doEncounterExp }); + } + + /** + * Can be used to perform init logic before intro visuals are shown and before the MysteryEncounterPhase begins + * Useful for performing things like procedural generation of intro sprites, etc. + * + * @param onInit - synchronous callback function to perform as soon as the encounter is selected for the next phase + * @returns + */ + withOnInit(onInit: (scene: BattleScene) => boolean): this & Required> { + return Object.assign(this, { onInit }); + } + + /** + * Can be used to perform some extra logic (usually animations) when the enemy field is finished sliding in + * + * @param onVisualsStart - synchronous callback function to perform as soon as the enemy field finishes sliding in + * @returns + */ + withOnVisualsStart(onVisualsStart: (scene: BattleScene) => boolean): this & Required> { + return Object.assign(this, { onVisualsStart: onVisualsStart }); + } + + /** + * Can set whether catching is allowed or not on the encounter + * This flag can also be programmatically set inside option event functions or elsewhere + * @param catchAllowed - if true, allows enemy pokemon to be caught during the encounter + * @returns + */ + withCatchAllowed(catchAllowed: boolean): this & Required> { + return Object.assign(this, { catchAllowed: catchAllowed }); + } + + /** + * @param hideBattleIntroMessage - if true, will not show the trainerAppeared/wildAppeared/bossAppeared message for an encounter + * @returns + */ + withHideWildIntroMessage(hideBattleIntroMessage: boolean): this & Required> { + return Object.assign(this, { hideBattleIntroMessage: hideBattleIntroMessage }); + } + + /** + * @param autoHideIntroVisuals - if false, will not hide the intro visuals that are displayed at the beginning of encounter + * @returns + */ + withAutoHideIntroVisuals(autoHideIntroVisuals: boolean): this & Required> { + return Object.assign(this, { autoHideIntroVisuals: autoHideIntroVisuals }); + } + + /** + * @param enterIntroVisualsFromRight - If true, will slide in intro visuals from the right side of the screen. If false, slides in from left, as normal + * Default false + * @returns + */ + withEnterIntroVisualsFromRight(enterIntroVisualsFromRight: boolean): this & Required> { + return Object.assign(this, { enterIntroVisualsFromRight: enterIntroVisualsFromRight }); + } + + /** + * Add a title for the encounter + * + * @param title - title of the encounter + * @returns + */ + withTitle(title: string): this { + const encounterOptionsDialogue = this.dialogue.encounterOptionsDialogue ?? {}; + + this.dialogue = { + ...this.dialogue, + encounterOptionsDialogue: { + ...encounterOptionsDialogue, + title, + } + }; + + return this; + } + + /** + * Add a description of the encounter + * + * @param description - description of the encounter + * @returns + */ + withDescription(description: string): this { + const encounterOptionsDialogue = this.dialogue.encounterOptionsDialogue ?? {}; + + this.dialogue = { + ...this.dialogue, + encounterOptionsDialogue: { + ...encounterOptionsDialogue, + description, + } + }; + + return this; + } + + /** + * Add a query for the encounter + * + * @param query - query to use for the encounter + * @returns + */ + withQuery(query: string): this { + const encounterOptionsDialogue = this.dialogue.encounterOptionsDialogue ?? {}; + + this.dialogue = { + ...this.dialogue, + encounterOptionsDialogue: { + ...encounterOptionsDialogue, + query, + } + }; + + return this; + } + + /** + * Add outro dialogue/s for the encounter + * + * @param dialogue - outro dialogue/s + * @returns + */ + withOutroDialogue(dialogue: MysteryEncounterDialogue["outro"] = []): this { + this.dialogue = {...this.dialogue, outro: dialogue }; + return this; + } + + /** + * Builds the mystery encounter + * + * @returns + */ + build(this: IMysteryEncounter): MysteryEncounter { + return new MysteryEncounter(this); + } +} diff --git a/src/data/mystery-encounters/mystery-encounters.ts b/src/data/mystery-encounters/mystery-encounters.ts new file mode 100644 index 000000000000..d235ff868618 --- /dev/null +++ b/src/data/mystery-encounters/mystery-encounters.ts @@ -0,0 +1,368 @@ +import { Biome } from "#enums/biome"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { DarkDealEncounter } from "./encounters/dark-deal-encounter"; +import { DepartmentStoreSaleEncounter } from "./encounters/department-store-sale-encounter"; +import { FieldTripEncounter } from "./encounters/field-trip-encounter"; +import { FightOrFlightEncounter } from "./encounters/fight-or-flight-encounter"; +import { LostAtSeaEncounter } from "./encounters/lost-at-sea-encounter"; +import { MysteriousChallengersEncounter } from "./encounters/mysterious-challengers-encounter"; +import { MysteriousChestEncounter } from "./encounters/mysterious-chest-encounter"; +import { ShadyVitaminDealerEncounter } from "./encounters/shady-vitamin-dealer-encounter"; +import { SlumberingSnorlaxEncounter } from "./encounters/slumbering-snorlax-encounter"; +import { TrainingSessionEncounter } from "./encounters/training-session-encounter"; +import MysteryEncounter from "./mystery-encounter"; +import { SafariZoneEncounter } from "#app/data/mystery-encounters/encounters/safari-zone-encounter"; +import { FieryFalloutEncounter } from "#app/data/mystery-encounters/encounters/fiery-fallout-encounter"; +import { TheStrongStuffEncounter } from "#app/data/mystery-encounters/encounters/the-strong-stuff-encounter"; +import { ThePokemonSalesmanEncounter } from "#app/data/mystery-encounters/encounters/the-pokemon-salesman-encounter"; +import { AnOfferYouCantRefuseEncounter } from "#app/data/mystery-encounters/encounters/an-offer-you-cant-refuse-encounter"; +import { DelibirdyEncounter } from "#app/data/mystery-encounters/encounters/delibirdy-encounter"; +import { AbsoluteAvariceEncounter } from "#app/data/mystery-encounters/encounters/absolute-avarice-encounter"; +import { ATrainersTestEncounter } from "#app/data/mystery-encounters/encounters/a-trainers-test-encounter"; +import { TrashToTreasureEncounter } from "#app/data/mystery-encounters/encounters/trash-to-treasure-encounter"; +import { BerriesAboundEncounter } from "#app/data/mystery-encounters/encounters/berries-abound-encounter"; +import { ClowningAroundEncounter } from "#app/data/mystery-encounters/encounters/clowning-around-encounter"; +import { PartTimerEncounter } from "#app/data/mystery-encounters/encounters/part-timer-encounter"; +import { DancingLessonsEncounter } from "#app/data/mystery-encounters/encounters/dancing-lessons-encounter"; +import { WeirdDreamEncounter } from "#app/data/mystery-encounters/encounters/weird-dream-encounter"; +import { TheWinstrateChallengeEncounter } from "#app/data/mystery-encounters/encounters/the-winstrate-challenge-encounter"; +import { TeleportingHijinksEncounter } from "#app/data/mystery-encounters/encounters/teleporting-hijinks-encounter"; +import { BugTypeSuperfanEncounter } from "#app/data/mystery-encounters/encounters/bug-type-superfan-encounter"; +import { FunAndGamesEncounter } from "#app/data/mystery-encounters/encounters/fun-and-games-encounter"; +import { UncommonBreedEncounter } from "#app/data/mystery-encounters/encounters/uncommon-breed-encounter"; +import { GlobalTradeSystemEncounter } from "#app/data/mystery-encounters/encounters/global-trade-system-encounter"; + +/** + * Spawn chance: (BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT + WIGHT_INCREMENT_ON_SPAWN_MISS * ) / MYSTERY_ENCOUNTER_SPAWN_MAX_WEIGHT + */ +export const BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT = 3; +/** + * The divisor for determining ME spawns, defines the "maximum" weight required for a spawn + * If spawn_weight === MYSTERY_ENCOUNTER_SPAWN_MAX_WEIGHT, 100% chance to spawn a ME + */ +export const MYSTERY_ENCOUNTER_SPAWN_MAX_WEIGHT = 256; +/** + * When an ME spawn roll fails, WEIGHT_INCREMENT_ON_SPAWN_MISS is added to future rolls for ME spawn checks. + * These values are cleared whenever the next ME spawns, and spawn weight returns to BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT + */ +export const WEIGHT_INCREMENT_ON_SPAWN_MISS = 3; +/** + * Specifies the target average for total ME spawns in a single Classic run. + * Used by anti-variance mechanic to check whether a run is above or below the target on a given wave. + */ +export const AVERAGE_ENCOUNTERS_PER_RUN_TARGET = 12; +/** + * Will increase/decrease the chance of spawning a ME based on the current run's total MEs encountered vs AVERAGE_ENCOUNTERS_PER_RUN_TARGET + * Example: + * AVERAGE_ENCOUNTERS_PER_RUN_TARGET = 17 (expects avg 1 ME every 10 floors) + * ANTI_VARIANCE_WEIGHT_MODIFIER = 15 + * + * On wave 20, if 1 ME has been encountered, the difference from expected average is 0 MEs. + * So anti-variance adds 0/256 to the spawn weight check for ME spawn. + * + * On wave 20, if 0 MEs have been encountered, the difference from expected average is 1 ME. + * So anti-variance adds 15/256 to the spawn weight check for ME spawn. + * + * On wave 20, if 2 MEs have been encountered, the difference from expected average is -1 ME. + * So anti-variance adds -15/256 to the spawn weight check for ME spawn. + */ +export const ANTI_VARIANCE_WEIGHT_MODIFIER = 15; + +export const EXTREME_ENCOUNTER_BIOMES = [ + Biome.SEA, + Biome.SEABED, + Biome.BADLANDS, + Biome.DESERT, + Biome.ICE_CAVE, + Biome.VOLCANO, + Biome.WASTELAND, + Biome.ABYSS, + Biome.SPACE, + Biome.END +]; + +export const NON_EXTREME_ENCOUNTER_BIOMES = [ + Biome.TOWN, + Biome.PLAINS, + Biome.GRASS, + Biome.TALL_GRASS, + Biome.METROPOLIS, + Biome.FOREST, + Biome.SWAMP, + Biome.BEACH, + Biome.LAKE, + Biome.MOUNTAIN, + Biome.CAVE, + Biome.MEADOW, + Biome.POWER_PLANT, + Biome.GRAVEYARD, + Biome.DOJO, + Biome.FACTORY, + Biome.RUINS, + Biome.CONSTRUCTION_SITE, + Biome.JUNGLE, + Biome.FAIRY_CAVE, + Biome.TEMPLE, + Biome.SLUM, + Biome.SNOWY_FOREST, + Biome.ISLAND, + Biome.LABORATORY +]; + +/** + * Places where you could very reasonably expect to encounter a single human + * + * Diff from NON_EXTREME_ENCOUNTER_BIOMES: + * + BADLANDS + * + DESERT + * + ICE_CAVE + */ +export const HUMAN_TRANSITABLE_BIOMES = [ + Biome.TOWN, + Biome.PLAINS, + Biome.GRASS, + Biome.TALL_GRASS, + Biome.METROPOLIS, + Biome.FOREST, + Biome.SWAMP, + Biome.BEACH, + Biome.LAKE, + Biome.MOUNTAIN, + Biome.BADLANDS, + Biome.CAVE, + Biome.DESERT, + Biome.ICE_CAVE, + Biome.MEADOW, + Biome.POWER_PLANT, + Biome.GRAVEYARD, + Biome.DOJO, + Biome.FACTORY, + Biome.RUINS, + Biome.CONSTRUCTION_SITE, + Biome.JUNGLE, + Biome.FAIRY_CAVE, + Biome.TEMPLE, + Biome.SLUM, + Biome.SNOWY_FOREST, + Biome.ISLAND, + Biome.LABORATORY +]; + +/** + * Places where you could expect a town or city, some form of large civilization + */ +export const CIVILIZATION_ENCOUNTER_BIOMES = [ + Biome.TOWN, + Biome.PLAINS, + Biome.GRASS, + Biome.TALL_GRASS, + Biome.METROPOLIS, + Biome.BEACH, + Biome.LAKE, + Biome.MEADOW, + Biome.POWER_PLANT, + Biome.GRAVEYARD, + Biome.DOJO, + Biome.FACTORY, + Biome.CONSTRUCTION_SITE, + Biome.SLUM, + Biome.ISLAND +]; + +export const allMysteryEncounters: { [encounterType: number]: MysteryEncounter } = {}; + + +const extremeBiomeEncounters: MysteryEncounterType[] = []; + +const nonExtremeBiomeEncounters: MysteryEncounterType[] = [ + MysteryEncounterType.FIELD_TRIP, + MysteryEncounterType.DANCING_LESSONS, // Is also in BADLANDS, DESERT, VOLCANO, WASTELAND, ABYSS +]; + +const humanTransitableBiomeEncounters: MysteryEncounterType[] = [ + MysteryEncounterType.MYSTERIOUS_CHALLENGERS, + MysteryEncounterType.SHADY_VITAMIN_DEALER, + MysteryEncounterType.THE_POKEMON_SALESMAN, + MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE, + MysteryEncounterType.THE_WINSTRATE_CHALLENGE +]; + +const civilizationBiomeEncounters: MysteryEncounterType[] = [ + MysteryEncounterType.DEPARTMENT_STORE_SALE, + MysteryEncounterType.PART_TIMER, + MysteryEncounterType.FUN_AND_GAMES, + MysteryEncounterType.GLOBAL_TRADE_SYSTEM +]; + +/** + * To add an encounter to every biome possible, use this array + */ +const anyBiomeEncounters: MysteryEncounterType[] = [ + MysteryEncounterType.FIGHT_OR_FLIGHT, + MysteryEncounterType.DARK_DEAL, + MysteryEncounterType.MYSTERIOUS_CHEST, + MysteryEncounterType.TRAINING_SESSION, + MysteryEncounterType.DELIBIRDY, + MysteryEncounterType.A_TRAINERS_TEST, + MysteryEncounterType.TRASH_TO_TREASURE, + MysteryEncounterType.BERRIES_ABOUND, + MysteryEncounterType.CLOWNING_AROUND, + MysteryEncounterType.WEIRD_DREAM, + MysteryEncounterType.TELEPORTING_HIJINKS, + MysteryEncounterType.BUG_TYPE_SUPERFAN, + MysteryEncounterType.UNCOMMON_BREED +]; + +/** + * ENCOUNTER BIOME MAPPING + * To add an Encounter to a biome group, instead of cluttering the map, use the biome group arrays above + * + * Adding specific Encounters to the mysteryEncountersByBiome map is for specific cases and special circumstances + * that biome groups do not cover + */ +export const mysteryEncountersByBiome = new Map([ + [Biome.TOWN, []], + [Biome.PLAINS, [ + MysteryEncounterType.SLUMBERING_SNORLAX, + MysteryEncounterType.ABSOLUTE_AVARICE + ]], + [Biome.GRASS, [ + MysteryEncounterType.SLUMBERING_SNORLAX, + MysteryEncounterType.ABSOLUTE_AVARICE + ]], + [Biome.TALL_GRASS, [ + MysteryEncounterType.ABSOLUTE_AVARICE + ]], + [Biome.METROPOLIS, []], + [Biome.FOREST, [ + MysteryEncounterType.SAFARI_ZONE, + MysteryEncounterType.ABSOLUTE_AVARICE + ]], + + [Biome.SEA, [ + MysteryEncounterType.LOST_AT_SEA + ]], + [Biome.SWAMP, [ + MysteryEncounterType.SAFARI_ZONE + ]], + [Biome.BEACH, []], + [Biome.LAKE, []], + [Biome.SEABED, []], + [Biome.MOUNTAIN, []], + [Biome.BADLANDS, [ + MysteryEncounterType.DANCING_LESSONS + ]], + [Biome.CAVE, [ + MysteryEncounterType.THE_STRONG_STUFF + ]], + [Biome.DESERT, [ + MysteryEncounterType.DANCING_LESSONS + ]], + [Biome.ICE_CAVE, []], + [Biome.MEADOW, []], + [Biome.POWER_PLANT, []], + [Biome.VOLCANO, [ + MysteryEncounterType.FIERY_FALLOUT, + MysteryEncounterType.DANCING_LESSONS + ]], + [Biome.GRAVEYARD, []], + [Biome.DOJO, []], + [Biome.FACTORY, []], + [Biome.RUINS, []], + [Biome.WASTELAND, [ + MysteryEncounterType.DANCING_LESSONS + ]], + [Biome.ABYSS, [ + MysteryEncounterType.DANCING_LESSONS + ]], + [Biome.SPACE, []], + [Biome.CONSTRUCTION_SITE, []], + [Biome.JUNGLE, [ + MysteryEncounterType.SAFARI_ZONE + ]], + [Biome.FAIRY_CAVE, []], + [Biome.TEMPLE, []], + [Biome.SLUM, []], + [Biome.SNOWY_FOREST, []], + [Biome.ISLAND, []], + [Biome.LABORATORY, []] +]); + +export function initMysteryEncounters() { + allMysteryEncounters[MysteryEncounterType.MYSTERIOUS_CHALLENGERS] = MysteriousChallengersEncounter; + allMysteryEncounters[MysteryEncounterType.MYSTERIOUS_CHEST] = MysteriousChestEncounter; + allMysteryEncounters[MysteryEncounterType.DARK_DEAL] = DarkDealEncounter; + allMysteryEncounters[MysteryEncounterType.FIGHT_OR_FLIGHT] = FightOrFlightEncounter; + allMysteryEncounters[MysteryEncounterType.TRAINING_SESSION] = TrainingSessionEncounter; + allMysteryEncounters[MysteryEncounterType.SLUMBERING_SNORLAX] = SlumberingSnorlaxEncounter; + allMysteryEncounters[MysteryEncounterType.DEPARTMENT_STORE_SALE] = DepartmentStoreSaleEncounter; + allMysteryEncounters[MysteryEncounterType.SHADY_VITAMIN_DEALER] = ShadyVitaminDealerEncounter; + allMysteryEncounters[MysteryEncounterType.FIELD_TRIP] = FieldTripEncounter; + allMysteryEncounters[MysteryEncounterType.SAFARI_ZONE] = SafariZoneEncounter; + allMysteryEncounters[MysteryEncounterType.LOST_AT_SEA] = LostAtSeaEncounter; + allMysteryEncounters[MysteryEncounterType.FIERY_FALLOUT] = FieryFalloutEncounter; + allMysteryEncounters[MysteryEncounterType.THE_STRONG_STUFF] = TheStrongStuffEncounter; + allMysteryEncounters[MysteryEncounterType.THE_POKEMON_SALESMAN] = ThePokemonSalesmanEncounter; + allMysteryEncounters[MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE] = AnOfferYouCantRefuseEncounter; + allMysteryEncounters[MysteryEncounterType.DELIBIRDY] = DelibirdyEncounter; + allMysteryEncounters[MysteryEncounterType.ABSOLUTE_AVARICE] = AbsoluteAvariceEncounter; + allMysteryEncounters[MysteryEncounterType.A_TRAINERS_TEST] = ATrainersTestEncounter; + allMysteryEncounters[MysteryEncounterType.TRASH_TO_TREASURE] = TrashToTreasureEncounter; + allMysteryEncounters[MysteryEncounterType.BERRIES_ABOUND] = BerriesAboundEncounter; + allMysteryEncounters[MysteryEncounterType.CLOWNING_AROUND] = ClowningAroundEncounter; + allMysteryEncounters[MysteryEncounterType.PART_TIMER] = PartTimerEncounter; + allMysteryEncounters[MysteryEncounterType.DANCING_LESSONS] = DancingLessonsEncounter; + allMysteryEncounters[MysteryEncounterType.WEIRD_DREAM] = WeirdDreamEncounter; + allMysteryEncounters[MysteryEncounterType.THE_WINSTRATE_CHALLENGE] = TheWinstrateChallengeEncounter; + allMysteryEncounters[MysteryEncounterType.TELEPORTING_HIJINKS] = TeleportingHijinksEncounter; + allMysteryEncounters[MysteryEncounterType.BUG_TYPE_SUPERFAN] = BugTypeSuperfanEncounter; + allMysteryEncounters[MysteryEncounterType.FUN_AND_GAMES] = FunAndGamesEncounter; + allMysteryEncounters[MysteryEncounterType.UNCOMMON_BREED] = UncommonBreedEncounter; + allMysteryEncounters[MysteryEncounterType.GLOBAL_TRADE_SYSTEM] = GlobalTradeSystemEncounter; + + // Add extreme encounters to biome map + extremeBiomeEncounters.forEach(encounter => { + EXTREME_ENCOUNTER_BIOMES.forEach(biome => { + const encountersForBiome = mysteryEncountersByBiome.get(biome); + if (encountersForBiome && !encountersForBiome.includes(encounter)) { + encountersForBiome.push(encounter); + } + }); + }); + // Add non-extreme encounters to biome map + nonExtremeBiomeEncounters.forEach(encounter => { + NON_EXTREME_ENCOUNTER_BIOMES.forEach(biome => { + const encountersForBiome = mysteryEncountersByBiome.get(biome); + if (encountersForBiome && !encountersForBiome.includes(encounter)) { + encountersForBiome.push(encounter); + } + }); + }); + // Add human encounters to biome map + humanTransitableBiomeEncounters.forEach(encounter => { + HUMAN_TRANSITABLE_BIOMES.forEach(biome => { + const encountersForBiome = mysteryEncountersByBiome.get(biome); + if (encountersForBiome && !encountersForBiome.includes(encounter)) { + encountersForBiome.push(encounter); + } + }); + }); + // Add civilization encounters to biome map + civilizationBiomeEncounters.forEach(encounter => { + CIVILIZATION_ENCOUNTER_BIOMES.forEach(biome => { + const encountersForBiome = mysteryEncountersByBiome.get(biome); + if (encountersForBiome && !encountersForBiome.includes(encounter)) { + encountersForBiome.push(encounter); + } + }); + }); + + // Add ANY biome encounters to biome map + mysteryEncountersByBiome.forEach(biomeEncounters => { + anyBiomeEncounters.forEach(encounter => { + if (!biomeEncounters.includes(encounter)) { + biomeEncounters.push(encounter); + } + }); + }); +} diff --git a/src/data/mystery-encounters/requirements/can-learn-move-requirement.ts b/src/data/mystery-encounters/requirements/can-learn-move-requirement.ts new file mode 100644 index 000000000000..bbdf2ee5a4d4 --- /dev/null +++ b/src/data/mystery-encounters/requirements/can-learn-move-requirement.ts @@ -0,0 +1,91 @@ +import BattleScene from "#app/battle-scene"; +import { Moves } from "#app/enums/moves"; +import { PlayerPokemon, PokemonMove } from "#app/field/pokemon"; +import { isNullOrUndefined } from "#app/utils"; +import { EncounterPokemonRequirement } from "../mystery-encounter-requirements"; + +/** + * {@linkcode CanLearnMoveRequirement} options + */ +export interface CanLearnMoveRequirementOptions { + excludeLevelMoves?: boolean; + excludeTmMoves?: boolean; + excludeEggMoves?: boolean; + includeFainted?: boolean; + minNumberOfPokemon?: number; + invertQuery?: boolean; +} + +/** + * Requires that a pokemon can learn a specific move/moveset. + */ +export class CanLearnMoveRequirement extends EncounterPokemonRequirement { + private readonly requiredMoves: Moves[]; + private readonly excludeLevelMoves?: boolean; + private readonly excludeTmMoves?: boolean; + private readonly excludeEggMoves?: boolean; + private readonly includeFainted?: boolean; + + constructor(requiredMoves: Moves | Moves[], options: CanLearnMoveRequirementOptions = {}) { + super(); + this.requiredMoves = Array.isArray(requiredMoves) ? requiredMoves : [requiredMoves]; + + this.excludeLevelMoves = options.excludeLevelMoves ?? false; + this.excludeTmMoves = options.excludeTmMoves ?? false; + this.excludeEggMoves = options.excludeEggMoves ?? false; + this.includeFainted = options.includeFainted ?? false; + this.minNumberOfPokemon = options.minNumberOfPokemon ?? 1; + this.invertQuery = options.invertQuery ?? false; + } + + override meetsRequirement(scene: BattleScene): boolean { + const partyPokemon = scene.getParty().filter((pkm) => (this.includeFainted ? pkm.isAllowed() : pkm.isAllowedInBattle())); + + if (isNullOrUndefined(partyPokemon) || this.requiredMoves?.length < 0) { + return false; + } + + return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + if (!this.invertQuery) { + return partyPokemon.filter((pokemon) => + // every required move should be included + this.requiredMoves.every((requiredMove) => this.getAllPokemonMoves(pokemon).includes(requiredMove)) + ); + } else { + return partyPokemon.filter( + (pokemon) => + // none of the "required" moves should be included + !this.requiredMoves.some((requiredMove) => this.getAllPokemonMoves(pokemon).includes(requiredMove)) + ); + } + } + + override getDialogueToken(_scene: BattleScene, _pokemon?: PlayerPokemon): [string, string] { + return ["requiredMoves", this.requiredMoves.map(m => new PokemonMove(m).getName()).join(", ")]; + } + + private getPokemonLevelMoves(pkm: PlayerPokemon): Moves[] { + return pkm.getLevelMoves().map(([_level, move]) => move); + } + + private getAllPokemonMoves(pkm: PlayerPokemon): Moves[] { + const allPokemonMoves: Moves[] = []; + + if (!this.excludeLevelMoves) { + allPokemonMoves.push(...(this.getPokemonLevelMoves(pkm) ?? [])); + } + + if (!this.excludeTmMoves) { + allPokemonMoves.push(...(pkm.compatibleTms ?? [])); + } + + if (!this.excludeEggMoves) { + allPokemonMoves.push(...(pkm.getEggMoves() ?? [])); + } + + return allPokemonMoves; + } +} diff --git a/src/data/mystery-encounters/requirements/requirement-groups.ts b/src/data/mystery-encounters/requirements/requirement-groups.ts new file mode 100644 index 000000000000..63c899fc5e97 --- /dev/null +++ b/src/data/mystery-encounters/requirements/requirement-groups.ts @@ -0,0 +1,120 @@ +import { Moves } from "#enums/moves"; +import { Abilities } from "#enums/abilities"; + +/** + * Moves that "steal" things + */ +export const STEALING_MOVES = [ + Moves.PLUCK, + Moves.COVET, + Moves.KNOCK_OFF, + Moves.THIEF, + Moves.TRICK, + Moves.SWITCHEROO +]; + +/** + * Moves that "charm" someone + */ +export const CHARMING_MOVES = [ + Moves.CHARM, + Moves.FLATTER, + Moves.DRAGON_CHEER, + Moves.ALLURING_VOICE, + Moves.ATTRACT, + Moves.SWEET_SCENT, + Moves.CAPTIVATE, + Moves.AROMATIC_MIST +]; + +/** + * Moves for the Dancer ability + */ +export const DANCING_MOVES = [ + Moves.AQUA_STEP, + Moves.CLANGOROUS_SOUL, + Moves.DRAGON_DANCE, + Moves.FEATHER_DANCE, + Moves.FIERY_DANCE, + Moves.LUNAR_DANCE, + Moves.PETAL_DANCE, + Moves.REVELATION_DANCE, + Moves.QUIVER_DANCE, + Moves.SWORDS_DANCE, + Moves.TEETER_DANCE, + Moves.VICTORY_DANCE +]; + +/** + * Moves that can distract someone/something + */ +export const DISTRACTION_MOVES = [ + Moves.FAKE_OUT, + Moves.FOLLOW_ME, + Moves.TAUNT, + Moves.ROAR, + Moves.TELEPORT, + Moves.CHARM, + Moves.FAKE_TEARS, + Moves.TICKLE, + Moves.CAPTIVATE, + Moves.RAGE_POWDER, + Moves.SUBSTITUTE, + Moves.SHED_TAIL +]; + +/** + * Moves that protect in some way + */ +export const PROTECTING_MOVES = [ + Moves.PROTECT, + Moves.WIDE_GUARD, + Moves.MAX_GUARD, + Moves.SAFEGUARD, + Moves.REFLECT, + Moves.BARRIER, + Moves.QUICK_GUARD, + Moves.FLOWER_SHIELD, + Moves.KINGS_SHIELD, + Moves.CRAFTY_SHIELD, + Moves.SPIKY_SHIELD, + Moves.OBSTRUCT, + Moves.DETECT +]; + +/** + * Moves that (loosely) can be used to trap/rob someone + */ +export const EXTORTION_MOVES = [ + Moves.BIND, + Moves.CLAMP, + Moves.INFESTATION, + Moves.SAND_TOMB, + Moves.SNAP_TRAP, + Moves.THUNDER_CAGE, + Moves.WRAP, + Moves.SPIRIT_SHACKLE, + Moves.MEAN_LOOK, + Moves.JAW_LOCK, + Moves.BLOCK, + Moves.SPIDER_WEB, + Moves.ANCHOR_SHOT, + Moves.OCTOLOCK, + Moves.PURSUIT, + Moves.CONSTRICT, + Moves.BEAT_UP, + Moves.COIL, + Moves.WRING_OUT, + Moves.STRING_SHOT, +]; + +/** + * Abilities that (loosely) can be used to trap/rob someone + */ +export const EXTORTION_ABILITIES = [ + Abilities.INTIMIDATE, + Abilities.ARENA_TRAP, + Abilities.SHADOW_TAG, + Abilities.SUCTION_CUPS, + Abilities.STICKY_HOLD +]; diff --git a/src/data/mystery-encounters/utils/encounter-dialogue-utils.ts b/src/data/mystery-encounters/utils/encounter-dialogue-utils.ts new file mode 100644 index 000000000000..494a45f69f5c --- /dev/null +++ b/src/data/mystery-encounters/utils/encounter-dialogue-utils.ts @@ -0,0 +1,86 @@ +import BattleScene from "#app/battle-scene"; +import { getTextWithColors, TextStyle } from "#app/ui/text"; +import { UiTheme } from "#enums/ui-theme"; +import { isNullOrUndefined } from "#app/utils"; +import i18next from "i18next"; + +/** + * Will inject all relevant dialogue tokens that exist in the {@linkcode BattleScene.currentBattle.mysteryEncounter.dialogueTokens}, into i18n text. + * Also adds BBCodeText fragments for colored text, if applicable + * @param scene + * @param keyOrString + * @param primaryStyle Can define a text style to be applied to the entire string. Must be defined for BBCodeText styles to be applied correctly + * @param uiTheme + */ +export function getEncounterText(scene: BattleScene, keyOrString?: string, primaryStyle?: TextStyle, uiTheme: UiTheme = UiTheme.DEFAULT): string | null { + if (isNullOrUndefined(keyOrString)) { + return null; + } + + let textString: string | null = getTextWithDialogueTokens(scene, keyOrString!); + + // Can only color the text if a Primary Style is defined + // primaryStyle is applied to all text that does not have its own specified style + if (primaryStyle && textString) { + textString = getTextWithColors(textString, primaryStyle, uiTheme); + } + + return textString; +} + +/** + * Helper function to inject {@linkcode BattleScene.currentBattle.mysteryEncounter.dialogueTokens} into a given content string + * @param scene + * @param keyOrString + */ +function getTextWithDialogueTokens(scene: BattleScene, keyOrString: string): string | null { + const tokens = scene.currentBattle?.mysteryEncounter?.dialogueTokens; + + if (i18next.exists(keyOrString, tokens)) { + return i18next.t(keyOrString, tokens) as string; + } + + return keyOrString ?? null; +} + +/** + * Will queue a message in UI with injected encounter data tokens + * @param scene + * @param contentKey + */ +export function queueEncounterMessage(scene: BattleScene, contentKey: string): void { + const text: string | null = getEncounterText(scene, contentKey); + scene.queueMessage(text ?? "", null, true); +} + +/** + * Will display a message in UI with injected encounter data tokens + * @param scene + * @param contentKey + * @param delay + * @param prompt + * @param callbackDelay + * @param promptDelay + */ +export function showEncounterText(scene: BattleScene, contentKey: string, delay: number | null = null, callbackDelay: number = 0, prompt: boolean = true, promptDelay: number | null = null): Promise { + return new Promise(resolve => { + const text: string | null = getEncounterText(scene, contentKey); + scene.ui.showText(text ?? "", delay, () => resolve(), callbackDelay, prompt, promptDelay); + }); +} + +/** + * Will display a dialogue (with speaker title) in UI with injected encounter data tokens + * @param scene + * @param textContentKey + * @param delay + * @param speakerContentKey + * @param callbackDelay + */ +export function showEncounterDialogue(scene: BattleScene, textContentKey: string, speakerContentKey: string, delay: number | null = null, callbackDelay: number = 0): Promise { + return new Promise(resolve => { + const text: string | null = getEncounterText(scene, textContentKey); + const speaker: string | null = getEncounterText(scene, speakerContentKey); + scene.ui.showDialogue(text ?? "", speaker ?? "", delay, () => resolve(), callbackDelay); + }); +} diff --git a/src/data/mystery-encounters/utils/encounter-phase-utils.ts b/src/data/mystery-encounters/utils/encounter-phase-utils.ts new file mode 100644 index 000000000000..2cd369fbaad3 --- /dev/null +++ b/src/data/mystery-encounters/utils/encounter-phase-utils.ts @@ -0,0 +1,1056 @@ +import Battle, { BattlerIndex, BattleType } from "#app/battle"; +import { biomeLinks, BiomePoolTier } from "#app/data/biomes"; +import MysteryEncounterOption from "#app/data/mystery-encounters/mystery-encounter-option"; +import { AVERAGE_ENCOUNTERS_PER_RUN_TARGET, WEIGHT_INCREMENT_ON_SPAWN_MISS } from "#app/data/mystery-encounters/mystery-encounters"; +import { showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import Pokemon, { FieldPosition, PlayerPokemon, PokemonMove, PokemonSummonData } from "#app/field/pokemon"; +import { CustomModifierSettings, ModifierPoolType, ModifierType, ModifierTypeGenerator, ModifierTypeOption, modifierTypes, regenerateModifierPoolThresholds } from "#app/modifier/modifier-type"; +import { MysteryEncounterBattlePhase, MysteryEncounterBattleStartCleanupPhase, MysteryEncounterPhase, MysteryEncounterRewardsPhase } from "#app/phases/mystery-encounter-phases"; +import PokemonData from "#app/system/pokemon-data"; +import { OptionSelectConfig, OptionSelectItem } from "#app/ui/abstact-option-select-ui-handler"; +import { PartyOption, PartyUiMode, PokemonSelectFilter } from "#app/ui/party-ui-handler"; +import { Mode } from "#app/ui/ui"; +import * as Utils from "#app/utils"; +import { isNullOrUndefined } from "#app/utils"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { Biome } from "#enums/biome"; +import { TrainerType } from "#enums/trainer-type"; +import i18next from "i18next"; +import BattleScene from "#app/battle-scene"; +import Trainer, { TrainerVariant } from "#app/field/trainer"; +import { Gender } from "#app/data/gender"; +import { Nature } from "#app/data/nature"; +import { Moves } from "#enums/moves"; +import { initMoveAnim, loadMoveAnimAssets } from "#app/data/battle-anims"; +import { MysteryEncounterMode } from "#enums/mystery-encounter-mode"; +import { Status, StatusEffect } from "#app/data/status-effect"; +import { TrainerConfig, trainerConfigs, TrainerSlot } from "#app/data/trainer-config"; +import PokemonSpecies from "#app/data/pokemon-species"; +import { Egg, IEggOptions } from "#app/data/egg"; +import { MysteryEncounterPokemonData } from "#app/data/mystery-encounters/mystery-encounter-pokemon-data"; +import HeldModifierConfig from "#app/interfaces/held-modifier-config"; +import { MovePhase } from "#app/phases/move-phase"; +import { EggLapsePhase } from "#app/phases/egg-lapse-phase"; +import { TrainerVictoryPhase } from "#app/phases/trainer-victory-phase"; +import { BattleEndPhase } from "#app/phases/battle-end-phase"; +import { GameOverPhase } from "#app/phases/game-over-phase"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; +import { PartyExpPhase } from "#app/phases/party-exp-phase"; + +/** + * Animates exclamation sprite over trainer's head at start of encounter + * @param scene + */ +export function doTrainerExclamation(scene: BattleScene) { + const exclamationSprite = scene.add.sprite(0, 0, "exclaim"); + exclamationSprite.setName("exclamation"); + scene.field.add(exclamationSprite); + scene.field.moveTo(exclamationSprite, scene.field.getAll().length - 1); + exclamationSprite.setVisible(true); + exclamationSprite.setPosition(110, 68); + scene.tweens.add({ + targets: exclamationSprite, + y: "-=25", + ease: "Cubic.easeOut", + duration: 300, + yoyo: true, + onComplete: () => { + scene.time.delayedCall(800, () => { + scene.field.remove(exclamationSprite, true); + }); + } + }); + + scene.playSound("battle_anims/GEN8- Exclaim", { volume: 0.7 }); +} + +export interface EnemyPokemonConfig { + species: PokemonSpecies; + isBoss: boolean; + bossSegments?: number; + bossSegmentModifier?: number; // Additive to the determined segment number + mysteryEncounterPokemonData?: MysteryEncounterPokemonData; + formIndex?: number; + abilityIndex?: number; + level?: number; + gender?: Gender; + passive?: boolean; + moveSet?: Moves[]; + nature?: Nature; + ivs?: [number, number, number, number, number, number]; + shiny?: boolean; + /** Can set just the status, or pass a timer on the status turns */ + status?: StatusEffect | [StatusEffect, number]; + mysteryEncounterBattleEffects?: (pokemon: Pokemon) => void; + modifierConfigs?: HeldModifierConfig[]; + tags?: BattlerTagType[]; + dataSource?: PokemonData; +} + +export interface EnemyPartyConfig { + /** Formula for enemy: level += waveIndex / 10 * levelAdditive */ + levelAdditiveMultiplier?: number; + doubleBattle?: boolean; + /** Generates trainer battle solely off trainer type */ + trainerType?: TrainerType; + /** More customizable option for configuring trainer battle */ + trainerConfig?: TrainerConfig; + pokemonConfigs?: EnemyPokemonConfig[]; + /** True for female trainer, false for male */ + female?: boolean; + /** True will prevent player from switching */ + disableSwitch?: boolean; +} + +/** + * Generates an enemy party for a mystery encounter battle + * This will override and replace any standard encounter generation logic + * Useful for tailoring specific battles to mystery encounters + * @param scene Battle Scene + * @param partyConfig Can pass various customizable attributes for the enemy party, see EnemyPartyConfig + */ +export async function initBattleWithEnemyConfig(scene: BattleScene, partyConfig: EnemyPartyConfig): Promise { + const loaded: boolean = false; + const loadEnemyAssets: Promise[] = []; + + const battle: Battle = scene.currentBattle; + + let doubleBattle: boolean = partyConfig?.doubleBattle ?? false; + + // Trainer + const trainerType = partyConfig?.trainerType; + const partyTrainerConfig = partyConfig?.trainerConfig; + let trainerConfig: TrainerConfig; + if (!isNullOrUndefined(trainerType) || partyTrainerConfig) { + scene.currentBattle.mysteryEncounter!.encounterMode = MysteryEncounterMode.TRAINER_BATTLE; + if (scene.currentBattle.trainer) { + scene.currentBattle.trainer.setVisible(false); + scene.currentBattle.trainer.destroy(); + } + + trainerConfig = partyConfig?.trainerConfig ? partyConfig?.trainerConfig : trainerConfigs[trainerType!]; + + const doubleTrainer = trainerConfig.doubleOnly || (trainerConfig.hasDouble && !!partyConfig.doubleBattle); + doubleBattle = doubleTrainer; + const trainerFemale = isNullOrUndefined(partyConfig.female) ? !!(Utils.randSeedInt(2)) : partyConfig.female; + const newTrainer = new Trainer(scene, trainerConfig.trainerType, doubleTrainer ? TrainerVariant.DOUBLE : trainerFemale ? TrainerVariant.FEMALE : TrainerVariant.DEFAULT, undefined, undefined, undefined, trainerConfig); + newTrainer.x += 300; + newTrainer.setVisible(false); + scene.field.add(newTrainer); + scene.currentBattle.trainer = newTrainer; + loadEnemyAssets.push(newTrainer.loadAssets()); + + battle.enemyLevels = scene.currentBattle.trainer.getPartyLevels(scene.currentBattle.waveIndex); + } else { + // Wild + scene.currentBattle.mysteryEncounter!.encounterMode = MysteryEncounterMode.WILD_BATTLE; + const numEnemies = partyConfig?.pokemonConfigs && partyConfig.pokemonConfigs.length > 0 ? partyConfig?.pokemonConfigs?.length : doubleBattle ? 2 : 1; + battle.enemyLevels = new Array(numEnemies).fill(null).map(() => scene.currentBattle.getLevelForWave()); + } + + scene.getEnemyParty().forEach(enemyPokemon => { + scene.field.remove(enemyPokemon, true); + }); + battle.enemyParty = []; + battle.double = doubleBattle; + + // ME levels are modified by an additive value that scales with wave index + // Base scaling: Every 10 waves, modifier gets +1 level + // This can be amplified or counteracted by setting levelAdditiveMultiplier in config + // levelAdditiveMultiplier value of 0.5 will halve the modifier scaling, 2 will double it, etc. + // Leaving null/undefined will disable level scaling + const mult: number = !isNullOrUndefined(partyConfig.levelAdditiveMultiplier) ? partyConfig.levelAdditiveMultiplier! : 0; + const additive = Math.max(Math.round((scene.currentBattle.waveIndex / 10) * mult), 0); + battle.enemyLevels = battle.enemyLevels.map(level => level + additive); + + battle.enemyLevels.forEach((level, e) => { + let enemySpecies; + let dataSource; + let isBoss = false; + if (!loaded) { + if ((!isNullOrUndefined(trainerType) || trainerConfig) && battle.trainer) { + // Allows overriding a trainer's pokemon to use specific species/data + if (partyConfig?.pokemonConfigs && e < partyConfig.pokemonConfigs.length) { + const config = partyConfig.pokemonConfigs[e]; + level = config.level ? config.level : level; + dataSource = config.dataSource; + enemySpecies = config.species; + isBoss = config.isBoss; + battle.enemyParty[e] = scene.addEnemyPokemon(enemySpecies, level, TrainerSlot.TRAINER, isBoss, dataSource); + } else { + battle.enemyParty[e] = battle.trainer.genPartyMember(e); + } + } else { + if (partyConfig?.pokemonConfigs && e < partyConfig.pokemonConfigs.length) { + const config = partyConfig.pokemonConfigs[e]; + level = config.level ? config.level : level; + dataSource = config.dataSource; + enemySpecies = config.species; + isBoss = config.isBoss; + if (isBoss) { + scene.currentBattle.mysteryEncounter!.encounterMode = MysteryEncounterMode.BOSS_BATTLE; + } + } else { + enemySpecies = scene.randomSpecies(battle.waveIndex, level, true); + } + + battle.enemyParty[e] = scene.addEnemyPokemon(enemySpecies, level, TrainerSlot.NONE, isBoss, dataSource); + } + } + + const enemyPokemon = scene.getEnemyParty()[e]; + + // Make sure basic data is clean + enemyPokemon.hp = enemyPokemon.getMaxHp(); + enemyPokemon.status = null; + enemyPokemon.passive = false; + + if (e < (doubleBattle ? 2 : 1)) { + enemyPokemon.setX(-66 + enemyPokemon.getFieldPositionOffset()[0]); + enemyPokemon.resetSummonData(); + } + + if (!loaded) { + scene.gameData.setPokemonSeen(enemyPokemon, true, !!(trainerType || trainerConfig)); + } + + if (partyConfig?.pokemonConfigs && e < partyConfig.pokemonConfigs.length) { + const config = partyConfig.pokemonConfigs[e]; + + // Generate new id, reset status and HP in case using data source + if (config.dataSource) { + enemyPokemon.id = Utils.randSeedInt(4294967296); + } + + // Set form + if (!isNullOrUndefined(config.formIndex)) { + enemyPokemon.formIndex = config.formIndex!; + } + + // Set shiny + if (!isNullOrUndefined(config.shiny)) { + enemyPokemon.shiny = config.shiny!; + } + + // Set custom mystery encounter data fields (such as sprite scale, custom abilities, types, etc.) + if (!isNullOrUndefined(config.mysteryEncounterPokemonData)) { + enemyPokemon.mysteryEncounterPokemonData = config.mysteryEncounterPokemonData!; + } + + // Set Boss + if (config.isBoss) { + let segments = !isNullOrUndefined(config.bossSegments) ? config.bossSegments! : scene.getEncounterBossSegments(scene.currentBattle.waveIndex, level, enemySpecies, true); + if (!isNullOrUndefined(config.bossSegmentModifier)) { + segments += config.bossSegmentModifier!; + } + enemyPokemon.setBoss(true, segments); + } + + // Set Passive + if (config.passive) { + enemyPokemon.passive = true; + } + + // Set Nature + if (config.nature) { + enemyPokemon.nature = config.nature; + } + + // Set IVs + if (config.ivs) { + enemyPokemon.ivs = config.ivs; + } + + // Set Status + const statusEffects = config.status; + if (statusEffects) { + // Default to cureturn 3 for sleep + const status = Array.isArray(statusEffects) ? statusEffects[0] : statusEffects; + const cureTurn = Array.isArray(statusEffects) ? statusEffects[1] : statusEffects === StatusEffect.SLEEP ? 3 : undefined; + enemyPokemon.status = new Status(status, 0, cureTurn); + } + + // Set summon data fields + if (!enemyPokemon.summonData) { + enemyPokemon.summonData = new PokemonSummonData(); + } + + // Set ability + if (!isNullOrUndefined(config.abilityIndex)) { + enemyPokemon.abilityIndex = config.abilityIndex!; + } + + // Set gender + if (!isNullOrUndefined(config.gender)) { + enemyPokemon.gender = config.gender!; + enemyPokemon.summonData.gender = config.gender!; + } + + // Set moves + if (config?.moveSet && config.moveSet.length > 0) { + const moves = config.moveSet.map(m => new PokemonMove(m)); + enemyPokemon.moveset = moves; + enemyPokemon.summonData.moveset = moves; + } + + // Set tags + if (config.tags && config.tags.length > 0) { + const tags = config.tags; + tags.forEach(tag => enemyPokemon.addTag(tag)); + } + + // mysteryEncounterBattleEffects will only be used IFF MYSTERY_ENCOUNTER_POST_SUMMON tag is applied + if (config.mysteryEncounterBattleEffects) { + enemyPokemon.mysteryEncounterBattleEffects = config.mysteryEncounterBattleEffects; + } + + // Requires re-priming summon data to update everything properly + enemyPokemon.primeSummonData(enemyPokemon.summonData); + + enemyPokemon.initBattleInfo(); + enemyPokemon.getBattleInfo().initInfo(enemyPokemon); + enemyPokemon.generateName(); + } + + loadEnemyAssets.push(enemyPokemon.loadAssets()); + + console.log(enemyPokemon.name, enemyPokemon.species.speciesId, enemyPokemon.stats); + }); + + scene.pushPhase(new MysteryEncounterBattlePhase(scene, partyConfig.disableSwitch)); + + await Promise.all(loadEnemyAssets); + battle.enemyParty.forEach((enemyPokemon_2, e_1) => { + if (e_1 < (doubleBattle ? 2 : 1)) { + enemyPokemon_2.setVisible(false); + if (battle.double) { + enemyPokemon_2.setFieldPosition(e_1 ? FieldPosition.RIGHT : FieldPosition.LEFT); + } + // Spawns at current visible field instead of on "next encounter" field (off screen to the left) + enemyPokemon_2.x += 300; + } + }); + if (!loaded) { + regenerateModifierPoolThresholds(scene.getEnemyField(), battle.battleType === BattleType.TRAINER ? ModifierPoolType.TRAINER : ModifierPoolType.WILD); + const customModifierTypes = partyConfig?.pokemonConfigs + ?.filter(config => config?.modifierConfigs) + .map(config => config.modifierConfigs!); + scene.generateEnemyModifiers(customModifierTypes); + } +} + +/** + * Load special move animations/sfx for hard-coded encounter-specific moves that a pokemon uses at the start of an encounter + * See: [startOfBattleEffects](IMysteryEncounter.startOfBattleEffects) for more details + * + * This promise does not need to be awaited on if called in an encounter onInit (will just load lazily) + * @param scene + * @param moves + */ +export function loadCustomMovesForEncounter(scene: BattleScene, moves: Moves | Moves[]) { + moves = Array.isArray(moves) ? moves : [moves]; + return Promise.all(moves.map(move => initMoveAnim(scene, move))) + .then(() => loadMoveAnimAssets(scene, moves)); +} + +/** + * Will update player money, and animate change (sound optional) + * @param scene + * @param changeValue + * @param playSound + * @param showMessage + */ +export function updatePlayerMoney(scene: BattleScene, changeValue: number, playSound: boolean = true, showMessage: boolean = true) { + scene.money = Math.min(Math.max(scene.money + changeValue, 0), Number.MAX_SAFE_INTEGER); + scene.updateMoneyText(); + scene.animateMoneyChanged(false); + if (playSound) { + scene.playSound("se/buy"); + } + if (showMessage) { + if (changeValue < 0) { + scene.queueMessage(i18next.t("mysteryEncounterMessages:paid_money", { amount: -changeValue }), null, true); + } else { + scene.queueMessage(i18next.t("mysteryEncounterMessages:receive_money", { amount: changeValue }), null, true); + } + } +} + +/** + * Converts modifier bullshit to an actual item + * @param scene Battle Scene + * @param modifier + * @param pregenArgs Can specify BerryType for berries, TM for TMs, AttackBoostType for item, etc. + */ +export function generateModifierType(scene: BattleScene, modifier: () => ModifierType, pregenArgs?: any[]): ModifierType | null { + const modifierId = Object.keys(modifierTypes).find(k => modifierTypes[k] === modifier); + if (!modifierId) { + return null; + } + + let result: ModifierType = modifierTypes[modifierId](); + + // Populates item id and tier (order matters) + result = result + .withIdFromFunc(modifierTypes[modifierId]) + .withTierFromPool(); + + return result instanceof ModifierTypeGenerator ? result.generateType(scene.getParty(), pregenArgs) : result; +} + +/** + * Converts modifier bullshit to an actual item + * @param scene - Battle Scene + * @param modifier + * @param pregenArgs - can specify BerryType for berries, TM for TMs, AttackBoostType for item, etc. + */ +export function generateModifierTypeOption(scene: BattleScene, modifier: () => ModifierType, pregenArgs?: any[]): ModifierTypeOption | null { + const result = generateModifierType(scene, modifier, pregenArgs); + if (result) { + return new ModifierTypeOption(result, 0); + } + return result; +} + +/** + * This function is intended for use inside onPreOptionPhase() of an encounter option + * @param scene + * @param onPokemonSelected - Any logic that needs to be performed when Pokemon is chosen + * If a second option needs to be selected, onPokemonSelected should return a OptionSelectItem[] object + * @param onPokemonNotSelected - Any logic that needs to be performed if no Pokemon is chosen + * @param selectablePokemonFilter + */ +export function selectPokemonForOption(scene: BattleScene, onPokemonSelected: (pokemon: PlayerPokemon) => void | OptionSelectItem[], onPokemonNotSelected?: () => void, selectablePokemonFilter?: PokemonSelectFilter): Promise { + return new Promise(resolve => { + const modeToSetOnExit = scene.ui.getMode(); + + // Open party screen to choose pokemon + scene.ui.setMode(Mode.PARTY, PartyUiMode.SELECT, -1, (slotIndex: number, option: PartyOption) => { + if (slotIndex < scene.getParty().length) { + scene.ui.setMode(modeToSetOnExit).then(() => { + const pokemon = scene.getParty()[slotIndex]; + const secondaryOptions = onPokemonSelected(pokemon); + if (!secondaryOptions) { + scene.currentBattle.mysteryEncounter!.setDialogueToken("selectedPokemon", pokemon.getNameToRender()); + resolve(true); + return; + } + + // There is a second option to choose after selecting the Pokemon + scene.ui.setMode(Mode.MESSAGE).then(() => { + const displayOptions = () => { + // Always appends a cancel option to bottom of options + const fullOptions = secondaryOptions.map(option => { + // Update handler to resolve promise + const onSelect = option.handler; + option.handler = () => { + onSelect(); + scene.currentBattle.mysteryEncounter!.setDialogueToken("selectedPokemon", pokemon.getNameToRender()); + resolve(true); + return true; + }; + return option; + }).concat({ + label: i18next.t("menu:cancel"), + handler: () => { + scene.ui.clearText(); + scene.ui.setMode(modeToSetOnExit); + resolve(false); + return true; + }, + onHover: () => { + showEncounterText(scene, i18next.t("mysteryEncounterMessages:cancel_option"), 0, 0, false); + } + }); + + const config: OptionSelectConfig = { + options: fullOptions, + maxOptions: 7, + yOffset: 0, + supportHover: true + }; + + // Do hover over the starting selection option + if (fullOptions[0].onHover) { + fullOptions[0].onHover(); + } + scene.ui.setModeWithoutClear(Mode.OPTION_SELECT, config, null, true); + }; + + const textPromptKey = scene.currentBattle.mysteryEncounter?.selectedOption?.dialogue?.secondOptionPrompt; + if (!textPromptKey) { + displayOptions(); + } else { + showEncounterText(scene, textPromptKey).then(() => displayOptions()); + } + }); + }); + } else { + scene.ui.setMode(modeToSetOnExit).then(() => { + if (onPokemonNotSelected) { + onPokemonNotSelected(); + } + resolve(false); + }); + } + }, selectablePokemonFilter); + }); +} + +interface PokemonAndOptionSelected { + selectedPokemonIndex: number; + selectedOptionIndex: number; +} + +/** + * This function is intended for use inside onPreOptionPhase() of an encounter option + * @param scene + * If a second option needs to be selected, onPokemonSelected should return a OptionSelectItem[] object + * @param options + * @param optionSelectPromptKey + * @param selectablePokemonFilter + * @param onHoverOverCancelOption + */ +export function selectOptionThenPokemon(scene: BattleScene, options: OptionSelectItem[], optionSelectPromptKey: string, selectablePokemonFilter?: PokemonSelectFilter, onHoverOverCancelOption?: () => void): Promise { + return new Promise(resolve => { + const modeToSetOnExit = scene.ui.getMode(); + + const displayOptions = (config: OptionSelectConfig) => { + scene.ui.setMode(Mode.MESSAGE).then(() => { + if (!optionSelectPromptKey) { + // Do hover over the starting selection option + if (fullOptions[0].onHover) { + fullOptions[0].onHover(); + } + scene.ui.setMode(Mode.OPTION_SELECT, config); + } else { + showEncounterText(scene, optionSelectPromptKey).then(() => { + // Do hover over the starting selection option + if (fullOptions[0].onHover) { + fullOptions[0].onHover(); + } + scene.ui.setMode(Mode.OPTION_SELECT, config); + }); + } + }); + }; + + const selectPokemonAfterOption = (selectedOptionIndex: number) => { + // Open party screen to choose a Pokemon + scene.ui.setMode(Mode.PARTY, PartyUiMode.SELECT, -1, (slotIndex: number, option: PartyOption) => { + if (slotIndex < scene.getParty().length) { + // Pokemon and option selected + scene.ui.setMode(modeToSetOnExit).then(() => { + const result: PokemonAndOptionSelected = { selectedPokemonIndex: slotIndex, selectedOptionIndex: selectedOptionIndex }; + resolve(result); + }); + } else { + // Back to first option select screen + displayOptions(config); + } + }, selectablePokemonFilter); + }; + + // Always appends a cancel option to bottom of options + const fullOptions = options.map((option, index) => { + // Update handler to resolve promise + const onSelect = option.handler; + option.handler = () => { + onSelect(); + selectPokemonAfterOption(index); + return true; + }; + return option; + }).concat({ + label: i18next.t("menu:cancel"), + handler: () => { + scene.ui.clearText(); + scene.ui.setMode(modeToSetOnExit); + resolve(null); + return true; + }, + onHover: () => { + if (onHoverOverCancelOption) { + onHoverOverCancelOption(); + } + showEncounterText(scene, i18next.t("mysteryEncounterMessages:cancel_option"), 0, 0, false); + } + }); + + const config: OptionSelectConfig = { + options: fullOptions, + maxOptions: 7, + yOffset: 0, + supportHover: true + }; + + displayOptions(config); + }); +} + +/** + * Will initialize reward phases to follow the mystery encounter + * Can have shop displayed or skipped + * @param scene - Battle Scene + * @param customShopRewards - adds a shop phase with the specified rewards / reward tiers + * @param eggRewards + * @param preRewardsCallback - can execute an arbitrary callback before the new phases if necessary (useful for updating items/party/injecting new phases before {@linkcode MysteryEncounterRewardsPhase}) + */ +export function setEncounterRewards(scene: BattleScene, customShopRewards?: CustomModifierSettings, eggRewards?: IEggOptions[], preRewardsCallback?: Function) { + scene.currentBattle.mysteryEncounter!.doEncounterRewards = (scene: BattleScene) => { + if (preRewardsCallback) { + preRewardsCallback(); + } + + if (customShopRewards) { + scene.unshiftPhase(new SelectModifierPhase(scene, 0, undefined, customShopRewards)); + } else { + scene.tryRemovePhase(p => p instanceof SelectModifierPhase); + } + + if (eggRewards) { + eggRewards.forEach(eggOptions => { + const egg = new Egg(eggOptions); + egg.addEggToGameData(scene); + }); + } + + return true; + }; +} + +/** + * Will initialize exp phases into the phase queue (these are in addition to any combat or other exp earned) + * Exp Share and Exp Balance will still function as normal + * @param scene - Battle Scene + * @param participantId - id/s of party pokemon that get full exp value. Other party members will receive Exp Share amounts + * @param baseExpValue - gives exp equivalent to a pokemon of the wave index's level. + * Guidelines: + * 36 - Sunkern (lowest in game) + * 62-64 - regional starter base evos + * 100 - Scyther + * 170 - Spiritomb + * 250 - Gengar + * 290 - trio legendaries + * 340 - box legendaries + * 608 - Blissey (highest in game) + * https://bulbapedia.bulbagarden.net/wiki/List_of_Pok%C3%A9mon_by_effort_value_yield_(Generation_IX) + * @param useWaveIndex - set to false when directly passing the the full exp value instead of baseExpValue + */ +export function setEncounterExp(scene: BattleScene, participantId: number | number[], baseExpValue: number, useWaveIndex: boolean = true) { + const participantIds = Array.isArray(participantId) ? participantId : [participantId]; + + scene.currentBattle.mysteryEncounter!.doEncounterExp = (scene: BattleScene) => { + scene.unshiftPhase(new PartyExpPhase(scene, baseExpValue, useWaveIndex, new Set(participantIds))); + + return true; + }; +} + +export class OptionSelectSettings { + hideDescription?: boolean; + slideInDescription?: boolean; + overrideTitle?: string; + overrideDescription?: string; + overrideQuery?: string; + overrideOptions?: MysteryEncounterOption[]; + startingCursorIndex?: number; +} + +/** + * Can be used to queue a new series of Options to select for an Encounter + * MUST be used only in onOptionPhase, will not work in onPreOptionPhase or onPostOptionPhase + * @param scene + * @param optionSelectSettings + */ +export function initSubsequentOptionSelect(scene: BattleScene, optionSelectSettings: OptionSelectSettings) { + scene.pushPhase(new MysteryEncounterPhase(scene, optionSelectSettings)); +} + +/** + * Can be used to exit an encounter without any battles or followup + * Will skip any shops and rewards, and queue the next encounter phase as normal + * @param scene + * @param addHealPhase - when true, will add a shop phase to end of encounter with 0 rewards but healing items are available + * @param encounterMode - Can set custom encounter mode if necessary (may be required for forcing Pokemon to return before next phase) + */ +export function leaveEncounterWithoutBattle(scene: BattleScene, addHealPhase: boolean = false, encounterMode: MysteryEncounterMode = MysteryEncounterMode.NO_BATTLE) { + scene.currentBattle.mysteryEncounter!.encounterMode = encounterMode; + scene.clearPhaseQueue(); + scene.clearPhaseQueueSplice(); + handleMysteryEncounterVictory(scene, addHealPhase); +} + +/** + * + * @param scene + * @param addHealPhase - Adds an empty shop phase to allow player to purchase healing items + * @param doNotContinue - default `false`. If set to true, will not end the battle and continue to next wave + */ +export function handleMysteryEncounterVictory(scene: BattleScene, addHealPhase: boolean = false, doNotContinue: boolean = false) { + const allowedPkm = scene.getParty().filter((pkm) => pkm.isAllowedInBattle()); + + if (allowedPkm.length === 0) { + scene.clearPhaseQueue(); + scene.unshiftPhase(new GameOverPhase(scene)); + return; + } + + // If in repeated encounter variant, do nothing + // Variant must eventually be swapped in order to handle "true" end of the encounter + const encounter = scene.currentBattle.mysteryEncounter!; + if (encounter.continuousEncounter || doNotContinue) { + return; + } else if (encounter.encounterMode === MysteryEncounterMode.NO_BATTLE) { + scene.pushPhase(new EggLapsePhase(scene)); + scene.pushPhase(new MysteryEncounterRewardsPhase(scene, addHealPhase)); + } else if (!scene.getEnemyParty().find(p => encounter.encounterMode !== MysteryEncounterMode.TRAINER_BATTLE ? p.isOnField() : !p?.isFainted(true))) { + scene.pushPhase(new BattleEndPhase(scene)); + if (encounter.encounterMode === MysteryEncounterMode.TRAINER_BATTLE) { + scene.pushPhase(new TrainerVictoryPhase(scene)); + } + if (scene.gameMode.isEndless || !scene.gameMode.isWaveFinal(scene.currentBattle.waveIndex)) { + if (!encounter.doContinueEncounter) { + // Only lapse eggs once for multi-battle encounters + scene.pushPhase(new EggLapsePhase(scene)); + } + scene.pushPhase(new MysteryEncounterRewardsPhase(scene, addHealPhase)); + } + } +} + +/** + * + * @param scene + * @param hide - If true, performs ease out and hide visuals. If false, eases in visuals. Defaults to true + * @param destroy - If true, will destroy visuals ONLY ON HIDE TRANSITION. Does nothing on show. Defaults to true + * @param duration + */ +export function transitionMysteryEncounterIntroVisuals(scene: BattleScene, hide: boolean = true, destroy: boolean = true, duration: number = 750): Promise { + return new Promise(resolve => { + const introVisuals = scene.currentBattle.mysteryEncounter!.introVisuals; + const enemyPokemon = scene.getEnemyField(); + if (enemyPokemon) { + scene.currentBattle.enemyParty = []; + } + if (introVisuals) { + if (!hide) { + // Make sure visuals are in proper state for showing + introVisuals.setVisible(true); + introVisuals.x = 244; + introVisuals.y = 60; + introVisuals.alpha = 0; + } + + // Transition + scene.tweens.add({ + targets: [introVisuals, enemyPokemon], + x: `${hide? "+" : "-"}=16`, + y: `${hide ? "-" : "+"}=16`, + alpha: hide ? 0 : 1, + ease: "Sine.easeInOut", + duration, + onComplete: () => { + if (hide && destroy) { + scene.field.remove(introVisuals, true); + + enemyPokemon.forEach(pokemon => { + scene.field.remove(pokemon, true); + }); + + scene.currentBattle.mysteryEncounter!.introVisuals = undefined; + } + resolve(true); + } + }); + } else { + resolve(true); + } + }); +} + +/** + * Will queue moves for any pokemon to use before the first CommandPhase of a battle + * Mostly useful for allowing {@linkcode MysteryEncounter} enemies to "cheat" and use moves before the first turn + * @param scene + */ +export function handleMysteryEncounterBattleStartEffects(scene: BattleScene) { + const encounter = scene.currentBattle.mysteryEncounter; + if (scene.currentBattle.battleType === BattleType.MYSTERY_ENCOUNTER && encounter && encounter.encounterMode !== MysteryEncounterMode.NO_BATTLE && !encounter.startOfBattleEffectsComplete) { + const effects = encounter.startOfBattleEffects; + effects.forEach(effect => { + let source; + if (effect.sourcePokemon) { + source = effect.sourcePokemon; + } else if (!isNullOrUndefined(effect.sourceBattlerIndex)) { + if (effect.sourceBattlerIndex === BattlerIndex.ATTACKER) { + source = scene.getEnemyField()[0]; + } else if (effect.sourceBattlerIndex === BattlerIndex.ENEMY) { + source = scene.getEnemyField()[0]; + } else if (effect.sourceBattlerIndex === BattlerIndex.ENEMY_2) { + source = scene.getEnemyField()[1]; + } else if (effect.sourceBattlerIndex === BattlerIndex.PLAYER) { + source = scene.getPlayerField()[0]; + } else if (effect.sourceBattlerIndex === BattlerIndex.PLAYER_2) { + source = scene.getPlayerField()[1]; + } + } else { + source = scene.getEnemyField()[0]; + } + scene.pushPhase(new MovePhase(scene, source, effect.targets, effect.move, effect.followUp, effect.ignorePp)); + }); + + // Pseudo turn end phase to reset flinch states, Endure, etc. + scene.pushPhase(new MysteryEncounterBattleStartCleanupPhase(scene)); + + encounter.startOfBattleEffectsComplete = true; + } +} + +/** + * Can queue extra phases or logic during {@linkcode TurnInitPhase} + * Should mostly just be used for injecting custom phases into the battle system on turn start + * @param scene + * @return boolean - if true, will skip the remainder of the {@linkcode TurnInitPhase} + */ +export function handleMysteryEncounterTurnStartEffects(scene: BattleScene): boolean { + const encounter = scene.currentBattle.mysteryEncounter; + if (scene.currentBattle.battleType === BattleType.MYSTERY_ENCOUNTER && encounter && encounter.onTurnStart) { + return encounter.onTurnStart(scene); + } + + return false; +} + +/** + * TODO: remove once encounter spawn rate is finalized + * Just a helper function to calculate aggregate stats for MEs in a Classic run + * @param scene + * @param baseSpawnWeight + */ +export function calculateMEAggregateStats(scene: BattleScene, baseSpawnWeight: number) { + const numRuns = 1000; + let run = 0; + const biomes = Object.keys(Biome).filter(key => isNaN(Number(key))); + const alwaysPickTheseBiomes = [Biome.ISLAND, Biome.ABYSS, Biome.WASTELAND, Biome.FAIRY_CAVE, Biome.TEMPLE, Biome.LABORATORY, Biome.SPACE, Biome.WASTELAND]; + + const calculateNumEncounters = (): any[] => { + let encounterRate = baseSpawnWeight; // BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT + const numEncounters = [0, 0, 0, 0]; + let mostRecentEncounterWave = 0; + const encountersByBiome = new Map(biomes.map(b => [b, 0])); + const validMEfloorsByBiome = new Map(biomes.map(b => [b, 0])); + let currentBiome = Biome.TOWN; + let currentArena = scene.newArena(currentBiome); + scene.setSeed(Utils.randomString(24)); + scene.resetSeed(); + for (let i = 10; i < 180; i++) { + // Boss + if (i % 10 === 0) { + continue; + } + + // New biome + if (i % 10 === 1) { + if (Array.isArray(biomeLinks[currentBiome])) { + let biomes: Biome[]; + scene.executeWithSeedOffset(() => { + biomes = (biomeLinks[currentBiome] as (Biome | [Biome, number])[]) + .filter(b => { + return !Array.isArray(b) || !Utils.randSeedInt(b[1]); + }) + .map(b => !Array.isArray(b) ? b : b[0]); + }, i * 100); + if (biomes! && biomes.length > 0) { + const specialBiomes = biomes.filter(b => alwaysPickTheseBiomes.includes(b)); + if (specialBiomes.length > 0) { + currentBiome = specialBiomes[Utils.randSeedInt(specialBiomes.length)]; + } else { + currentBiome = biomes[Utils.randSeedInt(biomes.length)]; + } + } + } else if (biomeLinks.hasOwnProperty(currentBiome)) { + currentBiome = (biomeLinks[currentBiome] as Biome); + } else { + if (!(i % 50)) { + currentBiome = Biome.END; + } else { + currentBiome = scene.generateRandomBiome(i); + } + } + + currentArena = scene.newArena(currentBiome); + } + + // Fixed battle + if (scene.gameMode.isFixedBattle(i)) { + continue; + } + + // Trainer + if (scene.gameMode.isWaveTrainer(i, currentArena)) { + continue; + } + + // Otherwise, roll encounter + + const roll = Utils.randSeedInt(256); + validMEfloorsByBiome.set(Biome[currentBiome], (validMEfloorsByBiome.get(Biome[currentBiome]) ?? 0) + 1); + + // If total number of encounters is lower than expected for the run, slightly favor a new encounter + // Do the reverse as well + const expectedEncountersByFloor = AVERAGE_ENCOUNTERS_PER_RUN_TARGET / (180 - 10) * (i - 10); + const currentRunDiffFromAvg = expectedEncountersByFloor - numEncounters.reduce((a, b) => a + b); + const favoredEncounterRate = encounterRate + currentRunDiffFromAvg * 15; + + // If the most recent ME was 3 or fewer waves ago, can never spawn a ME + const canSpawn = (i - mostRecentEncounterWave) > 3; + + if (canSpawn && roll < favoredEncounterRate) { + mostRecentEncounterWave = i; + encounterRate = baseSpawnWeight; + + // Calculate encounter rarity + // Common / Uncommon / Rare / Super Rare (base is out of 128) + const tierWeights = [66, 40, 19, 3]; + + // Adjust tier weights by currently encountered events (pity system that lowers odds of multiple Common/Great) + tierWeights[0] = tierWeights[0] - 6 * numEncounters[0]; + tierWeights[1] = tierWeights[1] - 4 * numEncounters[1]; + + const totalWeight = tierWeights.reduce((a, b) => a + b); + const tierValue = Utils.randSeedInt(totalWeight); + const commonThreshold = totalWeight - tierWeights[0]; // 64 - 32 = 32 + const uncommonThreshold = totalWeight - tierWeights[0] - tierWeights[1]; // 64 - 32 - 16 = 16 + const rareThreshold = totalWeight - tierWeights[0] - tierWeights[1] - tierWeights[2]; // 64 - 32 - 16 - 10 = 6 + + tierValue > commonThreshold ? ++numEncounters[0] : tierValue > uncommonThreshold ? ++numEncounters[1] : tierValue > rareThreshold ? ++numEncounters[2] : ++numEncounters[3]; + encountersByBiome.set(Biome[currentBiome], (encountersByBiome.get(Biome[currentBiome]) ?? 0) + 1); + } else { + encounterRate += WEIGHT_INCREMENT_ON_SPAWN_MISS; + } + } + + return [numEncounters, encountersByBiome, validMEfloorsByBiome]; + }; + + const encounterRuns: number[][] = []; + const encountersByBiomeRuns: Map[] = []; + const validFloorsByBiome: Map[] = []; + while (run < numRuns) { + scene.executeWithSeedOffset(() => { + const [numEncounters, encountersByBiome, validMEfloorsByBiome] = calculateNumEncounters(); + encounterRuns.push(numEncounters); + encountersByBiomeRuns.push(encountersByBiome); + validFloorsByBiome.push(validMEfloorsByBiome); + }, 1000 * run); + run++; + } + + const n = encounterRuns.length; + const totalEncountersInRun = encounterRuns.map(run => run.reduce((a, b) => a + b)); + const totalMean = totalEncountersInRun.reduce((a, b) => a + b) / n; + const totalStd = Math.sqrt(totalEncountersInRun.map(x => Math.pow(x - totalMean, 2)).reduce((a, b) => a + b) / n); + const commonMean = encounterRuns.reduce((a, b) => a + b[0], 0) / n; + const uncommonMean = encounterRuns.reduce((a, b) => a + b[1], 0) / n; + const rareMean = encounterRuns.reduce((a, b) => a + b[2], 0) / n; + const superRareMean = encounterRuns.reduce((a, b) => a + b[3], 0) / n; + + const encountersPerRunPerBiome = encountersByBiomeRuns.reduce((a, b) => { + for (const biome of a.keys()) { + a.set(biome, a.get(biome)! + b.get(biome)!); + } + return a; + }); + const meanEncountersPerRunPerBiome: Map = new Map(); + encountersPerRunPerBiome.forEach((value, key) => { + meanEncountersPerRunPerBiome.set(key, value / n); + }); + + const validMEFloorsPerRunPerBiome = validFloorsByBiome.reduce((a, b) => { + for (const biome of a.keys()) { + a.set(biome, a.get(biome)! + b.get(biome)!); + } + return a; + }); + const meanMEFloorsPerRunPerBiome: Map = new Map(); + validMEFloorsPerRunPerBiome.forEach((value, key) => { + meanMEFloorsPerRunPerBiome.set(key, value / n); + }); + + let stats = `Starting weight: ${baseSpawnWeight}\nAverage MEs per run: ${totalMean}\nStandard Deviation: ${totalStd}\nAvg Commons: ${commonMean}\nAvg Greats: ${uncommonMean}\nAvg Ultras: ${rareMean}\nAvg Rogues: ${superRareMean}\n`; + + const meanEncountersPerRunPerBiomeSorted = [...meanEncountersPerRunPerBiome.entries()].sort((e1, e2) => e2[1] - e1[1]); + meanEncountersPerRunPerBiomeSorted.forEach(value => stats = stats + `${value[0]}: avg valid floors ${meanMEFloorsPerRunPerBiome.get(value[0])}, avg MEs ${value[1]},\n`); + + console.log(stats); +} + + +/** + * TODO: remove once encounter spawn rate is finalized + * Just a helper function to calculate aggregate stats for MEs in a Classic run + * @param scene + * @param luckValue - 0 to 14 + */ +export function calculateRareSpawnAggregateStats(scene: BattleScene, luckValue: number) { + const numRuns = 1000; + let run = 0; + + const calculateNumRareEncounters = (): any[] => { + const bossEncountersByRarity = [0, 0, 0, 0]; + scene.setSeed(Utils.randomString(24)); + scene.resetSeed(); + // There are 12 wild boss floors + for (let i = 0; i < 12; i++) { + // Roll boss tier + // luck influences encounter rarity + let luckModifier = 0; + if (!isNaN(luckValue)) { + luckModifier = luckValue * 0.5; + } + const tierValue = Utils.randSeedInt(64 - luckModifier); + const tier = tierValue >= 20 ? BiomePoolTier.BOSS : tierValue >= 6 ? BiomePoolTier.BOSS_RARE : tierValue >= 1 ? BiomePoolTier.BOSS_SUPER_RARE : BiomePoolTier.BOSS_ULTRA_RARE; + + switch (tier) { + default: + case BiomePoolTier.BOSS: + ++bossEncountersByRarity[0]; + break; + case BiomePoolTier.BOSS_RARE: + ++bossEncountersByRarity[1]; + break; + case BiomePoolTier.BOSS_SUPER_RARE: + ++bossEncountersByRarity[2]; + break; + case BiomePoolTier.BOSS_ULTRA_RARE: + ++bossEncountersByRarity[3]; + break; + } + } + + return bossEncountersByRarity; + }; + + const encounterRuns: number[][] = []; + while (run < numRuns) { + scene.executeWithSeedOffset(() => { + const bossEncountersByRarity = calculateNumRareEncounters(); + encounterRuns.push(bossEncountersByRarity); + }, 1000 * run); + run++; + } + + const n = encounterRuns.length; + // const totalEncountersInRun = encounterRuns.map(run => run.reduce((a, b) => a + b)); + // const totalMean = totalEncountersInRun.reduce((a, b) => a + b) / n; + // const totalStd = Math.sqrt(totalEncountersInRun.map(x => Math.pow(x - totalMean, 2)).reduce((a, b) => a + b) / n); + const commonMean = encounterRuns.reduce((a, b) => a + b[0], 0) / n; + const rareMean = encounterRuns.reduce((a, b) => a + b[1], 0) / n; + const superRareMean = encounterRuns.reduce((a, b) => a + b[2], 0) / n; + const ultraRareMean = encounterRuns.reduce((a, b) => a + b[3], 0) / n; + + const stats = `Avg Commons: ${commonMean}\nAvg Rare: ${rareMean}\nAvg Super Rare: ${superRareMean}\nAvg Ultra Rare: ${ultraRareMean}\n`; + + console.log(stats); +} diff --git a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts new file mode 100644 index 000000000000..bac8bded9bab --- /dev/null +++ b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts @@ -0,0 +1,723 @@ +import BattleScene from "#app/battle-scene"; +import i18next from "i18next"; +import { isNullOrUndefined, randSeedInt } from "#app/utils"; +import { PokemonHeldItemModifier } from "#app/modifier/modifier"; +import Pokemon, { EnemyPokemon, PlayerPokemon } from "#app/field/pokemon"; +import { doPokeballBounceAnim, getPokeballAtlasKey, getPokeballCatchMultiplier, getPokeballTintColor, PokeballType } from "#app/data/pokeball"; +import { PlayerGender } from "#enums/player-gender"; +import { addPokeballCaptureStars, addPokeballOpenParticles } from "#app/field/anims"; +import { getStatusEffectCatchRateMultiplier, StatusEffect } from "#app/data/status-effect"; +import { achvs } from "#app/system/achv"; +import { Mode } from "#app/ui/ui"; +import { PartyOption, PartyUiMode } from "#app/ui/party-ui-handler"; +import { Species } from "#enums/species"; +import { Type } from "#app/data/type"; +import PokemonSpecies, { getPokemonSpecies, speciesStarters } from "#app/data/pokemon-species"; +import { queueEncounterMessage, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { getPokemonNameWithAffix } from "#app/messages"; +import { modifierTypes, PokemonHeldItemModifierType } from "#app/modifier/modifier-type"; +import { Gender } from "#app/data/gender"; +import { PermanentStat } from "#enums/stat"; +import { VictoryPhase } from "#app/phases/victory-phase"; + +/** + * Gets the sprite key and file root for a given PokemonSpecies (accounts for gender, shiny, variants, forms, and experimental) + * @param species + * @param female + * @param formIndex + * @param shiny + * @param variant + */ +export function getSpriteKeysFromSpecies(species: Species, female?: boolean, formIndex?: number, shiny?: boolean, variant?: number): { spriteKey: string, fileRoot: string } { + const spriteKey = getPokemonSpecies(species).getSpriteKey(female ?? false, formIndex ?? 0, shiny ?? false, variant ?? 0); + const fileRoot = getPokemonSpecies(species).getSpriteAtlasPath(female ?? false, formIndex ?? 0, shiny ?? false, variant ?? 0); + return { spriteKey, fileRoot }; +} + +/** + * Gets the sprite key and file root for a given Pokemon (accounts for gender, shiny, variants, forms, and experimental) + * @param pokemon + */ +export function getSpriteKeysFromPokemon(pokemon: Pokemon): { spriteKey: string, fileRoot: string } { + const spriteKey = pokemon.getSpeciesForm().getSpriteKey(pokemon.getGender() === Gender.FEMALE, pokemon.formIndex, pokemon.shiny, pokemon.variant); + const fileRoot = pokemon.getSpeciesForm().getSpriteAtlasPath(pokemon.getGender() === Gender.FEMALE, pokemon.formIndex, pokemon.shiny, pokemon.variant); + + return { spriteKey, fileRoot }; +} + +/** + * + * Will never remove the player's last non-fainted Pokemon (if they only have 1) + * Otherwise, picks a Pokemon completely at random and removes from the party + * @param scene + * @param isAllowedInBattle Default false. If true, only picks from unfainted mons. If there is only 1 unfainted mon left and doNotReturnLastAbleMon is also true, will return fainted mon + * @param doNotReturnLastAbleMon Default false. If true, will never return the last unfainted pokemon in the party. Useful when this function is being used to determine what Pokemon to remove from the party (Don't want to remove last unfainted) + * @returns + */ +export function getRandomPlayerPokemon(scene: BattleScene, isAllowedInBattle: boolean = false, doNotReturnLastAbleMon: boolean = false): PlayerPokemon { + const party = scene.getParty(); + let chosenIndex: number; + let chosenPokemon: PlayerPokemon; + const unfaintedMons = party.filter(p => p.isAllowedInBattle()); + const faintedMons = party.filter(p => !p.isAllowedInBattle()); + + if (doNotReturnLastAbleMon && unfaintedMons.length === 1) { + chosenIndex = randSeedInt(faintedMons.length); + chosenPokemon = faintedMons[chosenIndex]; + } else if (isAllowedInBattle) { + chosenIndex = randSeedInt(unfaintedMons.length); + chosenPokemon = unfaintedMons[chosenIndex]; + } else { + chosenIndex = randSeedInt(party.length); + chosenPokemon = party[chosenIndex]; + } + + return chosenPokemon; +} + +/** + * Ties are broken by whatever mon is closer to the front of the party + * @param scene + * @param unfainted Default false. If true, only picks from unfainted mons. + * @returns + */ +export function getHighestLevelPlayerPokemon(scene: BattleScene, unfainted: boolean = false): PlayerPokemon { + const party = scene.getParty(); + let pokemon: PlayerPokemon | null = null; + + for (const p of party) { + if (unfainted && p.isFainted()) { + continue; + } + + pokemon = pokemon ? pokemon?.level < p?.level ? p : pokemon : p; + } + + return pokemon!; +} + +/** + * Ties are broken by whatever mon is closer to the front of the party + * @param scene + * @param stat Stat to search for + * @param unfainted Default false. If true, only picks from unfainted mons. + * @returns + */ +export function getHighestStatPlayerPokemon(scene: BattleScene, stat: PermanentStat, unfainted: boolean = false): PlayerPokemon { + const party = scene.getParty(); + let pokemon: PlayerPokemon | null = null; + + for (const p of party) { + if (unfainted && p.isFainted()) { + continue; + } + + pokemon = pokemon ? pokemon.getStat(stat) < p?.getStat(stat) ? p : pokemon : p; + } + + return pokemon!; +} + +/** + * Ties are broken by whatever mon is closer to the front of the party + * @param scene + * @param unfainted - default false. If true, only picks from unfainted mons. + * @returns + */ +export function getLowestLevelPlayerPokemon(scene: BattleScene, unfainted: boolean = false): PlayerPokemon { + const party = scene.getParty(); + let pokemon: PlayerPokemon | null = null; + + for (const p of party) { + if (unfainted && p.isFainted()) { + continue; + } + + pokemon = pokemon ? pokemon?.level > p?.level ? p : pokemon : p; + } + + return pokemon!; +} + +/** + * Ties are broken by whatever mon is closer to the front of the party + * @param scene + * @param unfainted - default false. If true, only picks from unfainted mons. + * @returns + */ +export function getHighestStatTotalPlayerPokemon(scene: BattleScene, unfainted: boolean = false): PlayerPokemon { + const party = scene.getParty(); + let pokemon: PlayerPokemon | null = null; + + for (const p of party) { + if (unfainted && p.isFainted()) { + continue; + } + + pokemon = pokemon ? pokemon?.stats.reduce((a, b) => a + b) < p?.stats.reduce((a, b) => a + b) ? p : pokemon : p; + } + + return pokemon!; +} + +/** + * + * NOTE: This returns ANY random species, including those locked behind eggs, etc. + * @param starterTiers + * @param excludedSpecies + * @param types + * @returns + */ +export function getRandomSpeciesByStarterTier(starterTiers: number | [number, number], excludedSpecies?: Species[], types?: Type[]): Species { + let min = Array.isArray(starterTiers) ? starterTiers[0] : starterTiers; + let max = Array.isArray(starterTiers) ? starterTiers[1] : starterTiers; + + let filteredSpecies: [PokemonSpecies, number][] = Object.keys(speciesStarters) + .map(s => [parseInt(s) as Species, speciesStarters[s] as number]) + .filter(s => getPokemonSpecies(s[0]) && (!excludedSpecies || !excludedSpecies.includes(s[0]))) + .map(s => [getPokemonSpecies(s[0]), s[1]]); + + if (types && types.length > 0) { + filteredSpecies = filteredSpecies.filter(s => types.includes(s[0].type1) || (!isNullOrUndefined(s[0].type2) && types.includes(s[0].type2!))); + } + + // If no filtered mons exist at specified starter tiers, will expand starter search range until there are + // Starts by decrementing starter tier min until it is 0, then increments tier max up to 10 + let tryFilterStarterTiers: [PokemonSpecies, number][] = filteredSpecies.filter(s => (s[1] >= min && s[1] <= max)); + while (tryFilterStarterTiers.length === 0 && (min !== 0 && max !== 10)) { + if (min > 0) { + min--; + } else { + max++; + } + + tryFilterStarterTiers = filteredSpecies.filter(s => s[1] >= min && s[1] <= max); + } + + if (tryFilterStarterTiers.length > 0) { + const index = randSeedInt(tryFilterStarterTiers.length); + return Phaser.Math.RND.shuffle(tryFilterStarterTiers)[index][0].speciesId; + } + + return Species.BULBASAUR; +} + +/** + * Takes care of handling player pokemon KO (with all its side effects) + * + * @param scene the battle scene + * @param pokemon the player pokemon to KO + */ +export function koPlayerPokemon(scene: BattleScene, pokemon: PlayerPokemon) { + pokemon.hp = 0; + pokemon.trySetStatus(StatusEffect.FAINT); + pokemon.updateInfo(); + queueEncounterMessage(scene, i18next.t("battle:fainted", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })); +} + +/** + * Handles applying hp changes to a player pokemon. + * Takes care of not going below `0`, above max-hp, adding `FNT` status correctly and updating the pokemon info. + * TODO: should we handle special cases like wonder-guard/shedinja? + * @param scene the battle scene + * @param pokemon the player pokemon to apply the hp change to + * @param value the hp change amount. Positive for heal. Negative for damage + * + */ +function applyHpChangeToPokemon(scene: BattleScene, pokemon: PlayerPokemon, value: number) { + const hpChange = Math.round(pokemon.hp + value); + const nextHp = Math.max(Math.min(hpChange, pokemon.getMaxHp()), 0); + if (nextHp === 0) { + koPlayerPokemon(scene, pokemon); + } else { + pokemon.hp = nextHp; + } +} + +/** + * Handles applying damage to a player pokemon + * @param scene the battle scene + * @param pokemon the player pokemon to apply damage to + * @param damage the amount of damage to apply + * @see {@linkcode applyHpChangeToPokemon} + */ +export function applyDamageToPokemon(scene: BattleScene, pokemon: PlayerPokemon, damage: number) { + if (damage <= 0) { + console.warn("Healing pokemon with `applyDamageToPokemon` is not recommended! Please use `applyHealToPokemon` instead."); + } + + applyHpChangeToPokemon(scene, pokemon, -damage); +} + +/** + * Handles applying heal to a player pokemon + * @param scene the battle scene + * @param pokemon the player pokemon to apply heal to + * @param heal the amount of heal to apply + * @see {@linkcode applyHpChangeToPokemon} + */ +export function applyHealToPokemon(scene: BattleScene, pokemon: PlayerPokemon, heal: number) { + if (heal <= 0) { + console.warn("Damaging pokemon with `applyHealToPokemon` is not recommended! Please use `applyDamageToPokemon` instead."); + } + + applyHpChangeToPokemon(scene, pokemon, heal); +} + +/** + * Will modify all of a Pokemon's base stats by a flat value + * Base stats can never go below 1 + * @param pokemon + * @param value + */ +export async function modifyPlayerPokemonBST(pokemon: PlayerPokemon, value: number) { + const modType = modifierTypes.MYSTERY_ENCOUNTER_SHUCKLE_JUICE().generateType(pokemon.scene.getParty(), [value]); + const modifier = modType?.newModifier(pokemon); + if (modifier) { + await pokemon.scene.addModifier(modifier, false, false, false, true); + pokemon.calculateStats(); + } +} + +/** + * Will attempt to add a new modifier to a Pokemon. + * If the Pokemon already has max stacks of that item, it will instead apply 'fallbackModifierType', if specified. + * @param scene + * @param pokemon + * @param modType + * @param fallbackModifierType + */ +export async function applyModifierTypeToPlayerPokemon(scene: BattleScene, pokemon: PlayerPokemon, modType: PokemonHeldItemModifierType, fallbackModifierType?: PokemonHeldItemModifierType) { + // Check if the Pokemon has max stacks of that item already + const existing = scene.findModifier(m => ( + m instanceof PokemonHeldItemModifier && + m.type.id === modType.id && + m.pokemonId === pokemon.id + )) as PokemonHeldItemModifier; + + // At max stacks + if (existing && existing.getStackCount() >= existing.getMaxStackCount(scene)) { + if (!fallbackModifierType) { + return; + } + + // Apply fallback + return applyModifierTypeToPlayerPokemon(scene, pokemon, fallbackModifierType); + } + + const modifier = modType.newModifier(pokemon); + await scene.addModifier(modifier, false, false, false, true); +} + +/** + * Alternative to using AttemptCapturePhase + * Assumes player sprite is visible on the screen (this is intended for non-combat uses) + * + * Can await returned promise to wait for throw animation completion before continuing + * + * @param scene + * @param pokemon + * @param pokeballType + * @param ballTwitchRate - can pass custom ball catch rates (for special events, like safari) + */ +export function trainerThrowPokeball(scene: BattleScene, pokemon: EnemyPokemon, pokeballType: PokeballType, ballTwitchRate?: number): Promise { + const originalY: number = pokemon.y; + + if (!ballTwitchRate) { + const _3m = 3 * pokemon.getMaxHp(); + const _2h = 2 * pokemon.hp; + const catchRate = pokemon.species.catchRate; + const pokeballMultiplier = getPokeballCatchMultiplier(this.pokeballType); + const statusMultiplier = pokemon.status ? getStatusEffectCatchRateMultiplier(pokemon.status.effect) : 1; + const x = Math.round((((_3m - _2h) * catchRate * pokeballMultiplier) / _3m) * statusMultiplier); + ballTwitchRate = Math.round(65536 / Math.sqrt(Math.sqrt(255 / x))); + } + + const fpOffset = pokemon.getFieldPositionOffset(); + const pokeballAtlasKey = getPokeballAtlasKey(pokeballType); + const pokeball: Phaser.GameObjects.Sprite = scene.addFieldSprite(16 + 75, 80 + 25, "pb", pokeballAtlasKey); + pokeball.setOrigin(0.5, 0.625); + scene.field.add(pokeball); + + scene.time.delayedCall(300, () => { + scene.field.moveBelow(pokeball as Phaser.GameObjects.GameObject, pokemon); + }); + + return new Promise(resolve => { + scene.trainer.setTexture(`trainer_${scene.gameData.gender === PlayerGender.FEMALE ? "f" : "m"}_back_pb`); + scene.time.delayedCall(512, () => { + scene.playSound("se/pb_throw"); + + // Trainer throw frames + scene.trainer.setFrame("2"); + scene.time.delayedCall(256, () => { + scene.trainer.setFrame("3"); + scene.time.delayedCall(768, () => { + scene.trainer.setTexture(`trainer_${scene.gameData.gender === PlayerGender.FEMALE ? "f" : "m"}_back`); + }); + }); + + // Pokeball move and catch logic + scene.tweens.add({ + targets: pokeball, + x: { value: 236 + fpOffset[0], ease: "Linear" }, + y: { value: 16 + fpOffset[1], ease: "Cubic.easeOut" }, + duration: 500, + onComplete: () => { + pokeball.setTexture("pb", `${pokeballAtlasKey}_opening`); + scene.time.delayedCall(17, () => pokeball.setTexture("pb", `${pokeballAtlasKey}_open`)); + scene.playSound("se/pb_rel"); + pokemon.tint(getPokeballTintColor(pokeballType)); + + addPokeballOpenParticles(scene, pokeball.x, pokeball.y, pokeballType); + + scene.tweens.add({ + targets: pokemon, + duration: 500, + ease: "Sine.easeIn", + scale: 0.25, + y: 20, + onComplete: () => { + pokeball.setTexture("pb", `${pokeballAtlasKey}_opening`); + pokemon.setVisible(false); + scene.playSound("se/pb_catch"); + scene.time.delayedCall(17, () => pokeball.setTexture("pb", `${pokeballAtlasKey}`)); + + const doShake = () => { + let shakeCount = 0; + const pbX = pokeball.x; + const shakeCounter = scene.tweens.addCounter({ + from: 0, + to: 1, + repeat: 4, + yoyo: true, + ease: "Cubic.easeOut", + duration: 250, + repeatDelay: 500, + onUpdate: t => { + if (shakeCount && shakeCount < 4) { + const value = t.getValue(); + const directionMultiplier = shakeCount % 2 === 1 ? 1 : -1; + pokeball.setX(pbX + value * 4 * directionMultiplier); + pokeball.setAngle(value * 27.5 * directionMultiplier); + } + }, + onRepeat: () => { + if (!pokemon.species.isObtainable()) { + shakeCounter.stop(); + failCatch(scene, pokemon, originalY, pokeball, pokeballType).then(() => resolve(false)); + } else if (shakeCount++ < 3) { + if (randSeedInt(65536) < ballTwitchRate) { + scene.playSound("se/pb_move"); + } else { + shakeCounter.stop(); + failCatch(scene, pokemon, originalY, pokeball, pokeballType).then(() => resolve(false)); + } + } else { + scene.playSound("se/pb_lock"); + addPokeballCaptureStars(scene, pokeball); + + const pbTint = scene.add.sprite(pokeball.x, pokeball.y, "pb", "pb"); + pbTint.setOrigin(pokeball.originX, pokeball.originY); + pbTint.setTintFill(0); + pbTint.setAlpha(0); + scene.field.add(pbTint); + scene.tweens.add({ + targets: pbTint, + alpha: 0.375, + duration: 200, + easing: "Sine.easeOut", + onComplete: () => { + scene.tweens.add({ + targets: pbTint, + alpha: 0, + duration: 200, + easing: "Sine.easeIn", + onComplete: () => pbTint.destroy() + }); + } + }); + } + }, + onComplete: () => { + catchPokemon(scene, pokemon, pokeball, pokeballType).then(() => resolve(true)); + } + }); + }; + + scene.time.delayedCall(250, () => doPokeballBounceAnim(scene, pokeball, 16, 72, 350, doShake)); + } + }); + } + }); + }); + }); +} + +/** + * Animates pokeball opening and messages when an attempted catch fails + * @param scene + * @param pokemon + * @param originalY + * @param pokeball + * @param pokeballType + */ +function failCatch(scene: BattleScene, pokemon: EnemyPokemon, originalY: number, pokeball: Phaser.GameObjects.Sprite, pokeballType: PokeballType) { + return new Promise(resolve => { + scene.playSound("se/pb_rel"); + pokemon.setY(originalY); + if (pokemon.status?.effect !== StatusEffect.SLEEP) { + pokemon.cry(pokemon.getHpRatio() > 0.25 ? undefined : { rate: 0.85 }); + } + pokemon.tint(getPokeballTintColor(pokeballType)); + pokemon.setVisible(true); + pokemon.untint(250, "Sine.easeOut"); + + const pokeballAtlasKey = getPokeballAtlasKey(pokeballType); + pokeball.setTexture("pb", `${pokeballAtlasKey}_opening`); + scene.time.delayedCall(17, () => pokeball.setTexture("pb", `${pokeballAtlasKey}_open`)); + + scene.tweens.add({ + targets: pokemon, + duration: 250, + ease: "Sine.easeOut", + scale: 1 + }); + + scene.currentBattle.lastUsedPokeball = pokeballType; + removePb(scene, pokeball); + + scene.ui.showText(i18next.t("battle:pokemonBrokeFree", { pokemonName: pokemon.getNameToRender() }), null, () => resolve(), null, true); + }); +} + +/** + * + * @param scene + * @param pokemon + * @param pokeball + * @param pokeballType + * @param showCatchObtainMessage + * @param isObtain + */ +export async function catchPokemon(scene: BattleScene, pokemon: EnemyPokemon, pokeball: Phaser.GameObjects.Sprite | null, pokeballType: PokeballType, showCatchObtainMessage: boolean = true, isObtain: boolean = false): Promise { + scene.unshiftPhase(new VictoryPhase(scene, pokemon.id, true)); + + const speciesForm = !pokemon.fusionSpecies ? pokemon.getSpeciesForm() : pokemon.getFusionSpeciesForm(); + + if (speciesForm.abilityHidden && (pokemon.fusionSpecies ? pokemon.fusionAbilityIndex : pokemon.abilityIndex) === speciesForm.getAbilityCount() - 1) { + scene.validateAchv(achvs.HIDDEN_ABILITY); + } + + if (pokemon.species.subLegendary) { + scene.validateAchv(achvs.CATCH_SUB_LEGENDARY); + } + + if (pokemon.species.legendary) { + scene.validateAchv(achvs.CATCH_LEGENDARY); + } + + if (pokemon.species.mythical) { + scene.validateAchv(achvs.CATCH_MYTHICAL); + } + + scene.pokemonInfoContainer.show(pokemon, true); + + scene.gameData.updateSpeciesDexIvs(pokemon.species.getRootSpeciesId(true), pokemon.ivs); + + return new Promise(resolve => { + const doPokemonCatchMenu = () => { + const end = () => { + scene.pokemonInfoContainer.hide(); + if (pokeball) { + removePb(scene, pokeball); + } + resolve(); + }; + const removePokemon = () => { + if (pokemon) { + scene.field.remove(pokemon, true); + } + }; + const addToParty = () => { + const newPokemon = pokemon.addToParty(pokeballType); + const modifiers = scene.findModifiers(m => m instanceof PokemonHeldItemModifier, false); + if (scene.getParty().filter(p => p.isShiny()).length === 6) { + scene.validateAchv(achvs.SHINY_PARTY); + } + Promise.all(modifiers.map(m => scene.addModifier(m, true))).then(() => { + scene.updateModifiers(true); + removePokemon(); + if (newPokemon) { + newPokemon.loadAssets().then(end); + } else { + end(); + } + }); + }; + Promise.all([pokemon.hideInfo(), scene.gameData.setPokemonCaught(pokemon)]).then(() => { + if (scene.getParty().length === 6) { + const promptRelease = () => { + scene.ui.showText(i18next.t("battle:partyFull", { pokemonName: pokemon.getNameToRender() }), null, () => { + scene.pokemonInfoContainer.makeRoomForConfirmUi(); + scene.ui.setMode(Mode.CONFIRM, () => { + scene.ui.setMode(Mode.PARTY, PartyUiMode.RELEASE, 0, (slotIndex: number, _option: PartyOption) => { + scene.ui.setMode(Mode.MESSAGE).then(() => { + if (slotIndex < 6) { + addToParty(); + } else { + promptRelease(); + } + }); + }); + }, () => { + scene.ui.setMode(Mode.MESSAGE).then(() => { + removePokemon(); + end(); + }); + }); + }); + }; + promptRelease(); + } else { + addToParty(); + } + }); + }; + + if (showCatchObtainMessage) { + scene.ui.showText(i18next.t(isObtain ? "battle:pokemonObtained" : "battle:pokemonCaught", { pokemonName: pokemon.getNameToRender() }), null, doPokemonCatchMenu, 0, true); + } else { + doPokemonCatchMenu(); + } + }); +} + +/** + * Animates pokeball disappearing then destroys the object + * @param scene + * @param pokeball + */ +function removePb(scene: BattleScene, pokeball: Phaser.GameObjects.Sprite) { + if (pokeball) { + scene.tweens.add({ + targets: pokeball, + duration: 250, + delay: 250, + ease: "Sine.easeIn", + alpha: 0, + onComplete: () => { + pokeball.destroy(); + } + }); + } +} + +/** + * Animates a wild pokemon "fleeing", including sfx and messaging + * @param scene + * @param pokemon + */ +export async function doPokemonFlee(scene: BattleScene, pokemon: EnemyPokemon): Promise { + await new Promise(resolve => { + scene.playSound("se/flee"); + // Ease pokemon out + scene.tweens.add({ + targets: pokemon, + x: "+=16", + y: "-=16", + alpha: 0, + duration: 1000, + ease: "Sine.easeIn", + scale: pokemon.getSpriteScale(), + onComplete: () => { + pokemon.setVisible(false); + scene.field.remove(pokemon, true); + showEncounterText(scene, i18next.t("battle:pokemonFled", { pokemonName: pokemon.getNameToRender() }), null, 600, false) + .then(() => { + resolve(); + }); + } + }); + }); +} + +/** + * Handles the player fleeing from a wild pokemon, including sfx and messaging + * @param scene + * @param pokemon + */ +export function doPlayerFlee(scene: BattleScene, pokemon: EnemyPokemon): Promise { + return new Promise(resolve => { + // Ease pokemon out + scene.tweens.add({ + targets: pokemon, + x: "+=16", + y: "-=16", + alpha: 0, + duration: 1000, + ease: "Sine.easeIn", + scale: pokemon.getSpriteScale(), + onComplete: () => { + pokemon.setVisible(false); + scene.field.remove(pokemon, true); + showEncounterText(scene, i18next.t("battle:playerFled", { pokemonName: pokemon.getNameToRender() }), null, 600, false) + .then(() => { + resolve(); + }); + } + }); + }); +} + +/** + * Bug Species and their corresponding weights + */ +const GOLDEN_BUG_NET_SPECIES_POOL: [Species, number][] = [ + [Species.SCYTHER, 40], + [Species.SCIZOR, 40], + [Species.KLEAVOR, 40], + [Species.PINSIR, 40], + [Species.HERACROSS, 40], + [Species.YANMA, 40], + [Species.YANMEGA, 40], + [Species.SHUCKLE, 40], + [Species.ANORITH, 40], + [Species.ARMALDO, 40], + [Species.ESCAVALIER, 40], + [Species.ACCELGOR, 40], + [Species.JOLTIK, 40], + [Species.GALVANTULA, 40], + [Species.DURANT, 40], + [Species.LARVESTA, 40], + [Species.VOLCARONA, 40], + [Species.DEWPIDER, 40], + [Species.ARAQUANID, 40], + [Species.WIMPOD, 40], + [Species.GOLISOPOD, 40], + [Species.SIZZLIPEDE, 40], + [Species.CENTISKORCH, 40], + [Species.NYMBLE, 40], + [Species.LOKIX, 40], + [Species.BUZZWOLE, 1], + [Species.PHEROMOSA, 1], +]; + +/** + * Will randomly return one of the species from GOLDEN_BUG_NET_SPECIES_POOL, based on their weights + */ +export function getGoldenBugNetSpecies(): PokemonSpecies { + const totalWeight = GOLDEN_BUG_NET_SPECIES_POOL.reduce((a, b) => a + b[1], 0); + const roll = randSeedInt(totalWeight); + + let w = 0; + for (const species of GOLDEN_BUG_NET_SPECIES_POOL) { + w += species[1]; + if (roll < w) { + return getPokemonSpecies(species); + } + } + + // Defaults to Scyther + return getPokemonSpecies(Species.SCYTHER); +} diff --git a/src/data/mystery-encounters/utils/encounter-transformation-sequence.ts b/src/data/mystery-encounters/utils/encounter-transformation-sequence.ts new file mode 100644 index 000000000000..fd9d43829e58 --- /dev/null +++ b/src/data/mystery-encounters/utils/encounter-transformation-sequence.ts @@ -0,0 +1,392 @@ +import BattleScene from "#app/battle-scene"; +import { PlayerPokemon } from "#app/field/pokemon"; +import { getFrameMs } from "#app/utils"; +import { cos, sin } from "#app/field/anims"; +import { getTypeRgb } from "#app/data/type"; + +export enum TransformationScreenPosition { + CENTER, + LEFT, + RIGHT +} + +/** + * Initiates an "evolution-like" animation to transform a previousPokemon (presumably from the player's party) into a new one, not necessarily an evolution species. + * @param scene + * @param previousPokemon + * @param transformPokemon + * @param screenPosition + */ +export function doPokemonTransformationSequence(scene: BattleScene, previousPokemon: PlayerPokemon, transformPokemon: PlayerPokemon, screenPosition: TransformationScreenPosition) { + return new Promise(resolve => { + const transformationContainer = scene.fieldUI.getByName("Dream Background") as Phaser.GameObjects.Container; + const transformationBaseBg = scene.add.image(0, 0, "default_bg"); + transformationBaseBg.setOrigin(0, 0); + transformationBaseBg.setVisible(false); + transformationContainer.add(transformationBaseBg); + + let pokemonSprite: Phaser.GameObjects.Sprite; + let pokemonTintSprite: Phaser.GameObjects.Sprite; + let pokemonEvoSprite: Phaser.GameObjects.Sprite; + let pokemonEvoTintSprite: Phaser.GameObjects.Sprite; + + const xOffset = screenPosition === TransformationScreenPosition.CENTER ? 0 : + screenPosition === TransformationScreenPosition.RIGHT ? 100 : -100; + // Centered transformations occur at a lower y Position + const yOffset = screenPosition !== TransformationScreenPosition.CENTER ? -15 : 0; + + const getPokemonSprite = () => { + const ret = scene.addPokemonSprite(previousPokemon, transformationBaseBg.displayWidth / 2 + xOffset, transformationBaseBg.displayHeight / 2 + yOffset, "pkmn__sub"); + ret.setPipeline(scene.spritePipeline, { tone: [ 0.0, 0.0, 0.0, 0.0 ], ignoreTimeTint: true }); + return ret; + }; + + transformationContainer.add((pokemonSprite = getPokemonSprite())); + transformationContainer.add((pokemonTintSprite = getPokemonSprite())); + transformationContainer.add((pokemonEvoSprite = getPokemonSprite())); + transformationContainer.add((pokemonEvoTintSprite = getPokemonSprite())); + + pokemonSprite.setAlpha(0); + pokemonTintSprite.setAlpha(0); + pokemonTintSprite.setTintFill(0xFFFFFF); + pokemonEvoSprite.setVisible(false); + pokemonEvoTintSprite.setVisible(false); + pokemonEvoTintSprite.setTintFill(0xFFFFFF); + + [ pokemonSprite, pokemonTintSprite, pokemonEvoSprite, pokemonEvoTintSprite ].map(sprite => { + sprite.play(previousPokemon.getSpriteKey(true)); + sprite.setPipeline(scene.spritePipeline, { tone: [ 0.0, 0.0, 0.0, 0.0 ], hasShadow: false, teraColor: getTypeRgb(previousPokemon.getTeraType()) }); + sprite.setPipelineData("ignoreTimeTint", true); + sprite.setPipelineData("spriteKey", previousPokemon.getSpriteKey()); + sprite.setPipelineData("shiny", previousPokemon.shiny); + sprite.setPipelineData("variant", previousPokemon.variant); + [ "spriteColors", "fusionSpriteColors" ].map(k => { + if (previousPokemon.summonData?.speciesForm) { + k += "Base"; + } + sprite.pipelineData[k] = previousPokemon.getSprite().pipelineData[k]; + }); + }); + + [ pokemonEvoSprite, pokemonEvoTintSprite ].map(sprite => { + sprite.play(transformPokemon.getSpriteKey(true)); + sprite.setPipelineData("ignoreTimeTint", true); + sprite.setPipelineData("spriteKey", transformPokemon.getSpriteKey()); + sprite.setPipelineData("shiny", transformPokemon.shiny); + sprite.setPipelineData("variant", transformPokemon.variant); + [ "spriteColors", "fusionSpriteColors" ].map(k => { + if (transformPokemon.summonData?.speciesForm) { + k += "Base"; + } + sprite.pipelineData[k] = transformPokemon.getSprite().pipelineData[k]; + }); + }); + + scene.tweens.add({ + targets: pokemonSprite, + alpha: 1, + ease: "Cubic.easeInOut", + duration: 2000, + onComplete: () => { + doSpiralUpward(scene, transformationBaseBg, transformationContainer, xOffset, yOffset); + scene.tweens.addCounter({ + from: 0, + to: 1, + duration: 1000, + onUpdate: t => { + pokemonTintSprite.setAlpha(t.getValue()); + }, + onComplete: () => { + pokemonSprite.setVisible(false); + scene.time.delayedCall(700, () => { + doArcDownward(scene, transformationBaseBg, transformationContainer, xOffset, yOffset); + scene.time.delayedCall(1000, () => { + pokemonEvoTintSprite.setScale(0.25); + pokemonEvoTintSprite.setVisible(true); + doCycle(scene, 2, 6, pokemonTintSprite, pokemonEvoTintSprite).then(() => { + pokemonEvoSprite.setVisible(true); + doCircleInward(scene, transformationBaseBg, transformationContainer, xOffset, yOffset); + + scene.time.delayedCall(900, () => { + scene.tweens.add({ + targets: pokemonEvoTintSprite, + alpha: 0, + duration: 1500, + delay: 150, + easing: "Sine.easeIn", + onComplete: () => { + scene.time.delayedCall(2500, () => { + resolve(); + scene.tweens.add({ + targets: pokemonEvoSprite, + alpha: 0, + duration: 2000, + delay: 150, + easing: "Sine.easeIn", + onComplete: () => { + previousPokemon.destroy(); + transformPokemon.setVisible(false); + transformPokemon.setAlpha(1); + } + }); + }); + } + }); + }); + }); + }); + }); + } + }); + } + }); + }); +} + +/** + * Animates particles that "spiral" upwards at start of transform animation + * @param scene + * @param transformationBaseBg + * @param transformationContainer + * @param xOffset + * @param yOffset + */ +function doSpiralUpward(scene: BattleScene, transformationBaseBg: Phaser.GameObjects.Image, transformationContainer: Phaser.GameObjects.Container, xOffset: number, yOffset: number) { + let f = 0; + + scene.tweens.addCounter({ + repeat: 64, + duration: getFrameMs(1), + onRepeat: () => { + if (f < 64) { + if (!(f & 7)) { + for (let i = 0; i < 4; i++) { + doSpiralUpwardParticle(scene, (f & 120) * 2 + i * 64, transformationBaseBg, transformationContainer, xOffset, yOffset); + } + } + f++; + } + } + }); +} + +/** + * Animates particles that arc downwards after the upwards spiral + * @param scene + * @param transformationBaseBg + * @param transformationContainer + * @param xOffset + * @param yOffset + */ +function doArcDownward(scene: BattleScene, transformationBaseBg: Phaser.GameObjects.Image, transformationContainer: Phaser.GameObjects.Container, xOffset: number, yOffset: number) { + let f = 0; + + scene.tweens.addCounter({ + repeat: 96, + duration: getFrameMs(1), + onRepeat: () => { + if (f < 96) { + if (f < 6) { + for (let i = 0; i < 9; i++) { + doArcDownParticle(scene, i * 16, transformationBaseBg, transformationContainer, xOffset, yOffset); + } + } + f++; + } + } + }); +} + +/** + * Animates the transformation between the old pokemon form and new pokemon form + * @param scene + * @param l + * @param lastCycle + * @param pokemonTintSprite + * @param pokemonEvoTintSprite + */ +function doCycle(scene: BattleScene, l: number, lastCycle: number, pokemonTintSprite: Phaser.GameObjects.Sprite, pokemonEvoTintSprite: Phaser.GameObjects.Sprite): Promise { + return new Promise(resolve => { + const isLastCycle = l === lastCycle; + scene.tweens.add({ + targets: pokemonTintSprite, + scale: 0.25, + ease: "Cubic.easeInOut", + duration: 500 / l, + yoyo: !isLastCycle + }); + scene.tweens.add({ + targets: pokemonEvoTintSprite, + scale: 1, + ease: "Cubic.easeInOut", + duration: 500 / l, + yoyo: !isLastCycle, + onComplete: () => { + if (l < lastCycle) { + doCycle(scene, l + 0.5, lastCycle, pokemonTintSprite, pokemonEvoTintSprite).then(success => resolve(success)); + } else { + pokemonTintSprite.setVisible(false); + resolve(true); + } + } + }); + }); +} + +/** + * Animates particles in a circle pattern + * @param scene + * @param transformationBaseBg + * @param transformationContainer + * @param xOffset + * @param yOffset + */ +function doCircleInward(scene: BattleScene, transformationBaseBg: Phaser.GameObjects.Image, transformationContainer: Phaser.GameObjects.Container, xOffset: number, yOffset: number) { + let f = 0; + + scene.tweens.addCounter({ + repeat: 48, + duration: getFrameMs(1), + onRepeat: () => { + if (!f) { + for (let i = 0; i < 16; i++) { + doCircleInwardParticle(scene, i * 16, 4, transformationBaseBg, transformationContainer, xOffset, yOffset); + } + } else if (f === 32) { + for (let i = 0; i < 16; i++) { + doCircleInwardParticle(scene, i * 16, 8, transformationBaseBg, transformationContainer, xOffset, yOffset); + } + } + f++; + } + }); +} + +/** + * Helper function for {@linkcode doSpiralUpward}, handles a single particle + * @param scene + * @param trigIndex + * @param transformationBaseBg + * @param transformationContainer + * @param xOffset + * @param yOffset + */ +function doSpiralUpwardParticle(scene: BattleScene, trigIndex: number, transformationBaseBg: Phaser.GameObjects.Image, transformationContainer: Phaser.GameObjects.Container, xOffset: number, yOffset: number) { + const initialX = transformationBaseBg.displayWidth / 2 + xOffset; + const particle = scene.add.image(initialX, 0, "evo_sparkle"); + transformationContainer.add(particle); + + let f = 0; + let amp = 48; + + const particleTimer = scene.tweens.addCounter({ + repeat: -1, + duration: getFrameMs(1), + onRepeat: () => { + updateParticle(); + } + }); + + const updateParticle = () => { + if (!f || particle.y > 8) { + particle.setPosition(initialX, 88 - (f * f) / 80 + yOffset); + particle.y += sin(trigIndex, amp) / 4; + particle.x += cos(trigIndex, amp); + particle.setScale(1 - (f / 80)); + trigIndex += 4; + if (f & 1) { + amp--; + } + f++; + } else { + particle.destroy(); + particleTimer.remove(); + } + }; + + updateParticle(); +} + +/** + * Helper function for {@linkcode doArcDownward}, handles a single particle + * @param scene + * @param trigIndex + * @param transformationBaseBg + * @param transformationContainer + * @param xOffset + * @param yOffset + */ +function doArcDownParticle(scene: BattleScene, trigIndex: number, transformationBaseBg: Phaser.GameObjects.Image, transformationContainer: Phaser.GameObjects.Container, xOffset: number, yOffset: number) { + const initialX = transformationBaseBg.displayWidth / 2 + xOffset; + const particle = scene.add.image(initialX, 0, "evo_sparkle"); + particle.setScale(0.5); + transformationContainer.add(particle); + + let f = 0; + let amp = 8; + + const particleTimer = scene.tweens.addCounter({ + repeat: -1, + duration: getFrameMs(1), + onRepeat: () => { + updateParticle(); + } + }); + + const updateParticle = () => { + if (!f || particle.y < 88) { + particle.setPosition(initialX, 8 + (f * f) / 5 + yOffset); + particle.y += sin(trigIndex, amp) / 4; + particle.x += cos(trigIndex, amp); + amp = 8 + sin(f * 4, 40); + f++; + } else { + particle.destroy(); + particleTimer.remove(); + } + }; + + updateParticle(); +} + +/** + * Helper function for @{link doCircleInward}, handles a single particle + * @param scene + * @param trigIndex + * @param speed + * @param transformationBaseBg + * @param transformationContainer + * @param xOffset + * @param yOffset + */ +function doCircleInwardParticle(scene: BattleScene, trigIndex: number, speed: number, transformationBaseBg: Phaser.GameObjects.Image, transformationContainer: Phaser.GameObjects.Container, xOffset: number, yOffset: number) { + const initialX = transformationBaseBg.displayWidth / 2 + xOffset; + const initialY = transformationBaseBg.displayHeight / 2 + yOffset; + const particle = scene.add.image(initialX, initialY, "evo_sparkle"); + transformationContainer.add(particle); + + let amp = 120; + + const particleTimer = scene.tweens.addCounter({ + repeat: -1, + duration: getFrameMs(1), + onRepeat: () => { + updateParticle(); + } + }); + + const updateParticle = () => { + if (amp > 8) { + particle.setPosition(initialX, initialY); + particle.y += sin(trigIndex, amp); + particle.x += cos(trigIndex, amp); + amp -= speed; + trigIndex += 4; + } else { + particle.destroy(); + particleTimer.remove(); + } + }; + + updateParticle(); +} diff --git a/src/data/pokemon-species.ts b/src/data/pokemon-species.ts index aa85c29f5517..d2f63275a5e8 100644 --- a/src/data/pokemon-species.ts +++ b/src/data/pokemon-species.ts @@ -243,16 +243,24 @@ export abstract class PokemonSpeciesForm { return false; } + /** + * Gets the BST for the species + * @returns The species' BST. + */ + getBaseStatTotal(): number { + return this.baseStats.reduce((i, n) => n + i); + } + /** * Gets the species' base stat amount for the given stat. * @param stat The desired stat. * @returns The species' base stat amount. */ - getBaseStat(stat: Stat): integer { + getBaseStat(stat: Stat): number { return this.baseStats[stat]; } - getBaseExp(): integer { + getBaseExp(): number { let ret = this.baseExp; switch (this.getFormSpriteKey()) { case SpeciesFormKey.MEGA: diff --git a/src/data/trainer-config.ts b/src/data/trainer-config.ts index ac33f26de9e5..0323c6d43c43 100644 --- a/src/data/trainer-config.ts +++ b/src/data/trainer-config.ts @@ -1,16 +1,16 @@ -import BattleScene, {startingWave} from "../battle-scene"; -import {ModifierTypeFunc, modifierTypes} from "../modifier/modifier-type"; -import {EnemyPokemon} from "../field/pokemon"; +import BattleScene, { startingWave } from "../battle-scene"; +import { ModifierTypeFunc, modifierTypes } from "../modifier/modifier-type"; +import { EnemyPokemon } from "../field/pokemon"; import * as Utils from "../utils"; -import {PokeballType} from "./pokeball"; -import {pokemonEvolutions, pokemonPrevolutions} from "./pokemon-evolutions"; -import PokemonSpecies, {getPokemonSpecies, PokemonSpeciesFilter} from "./pokemon-species"; -import {tmSpecies} from "./tms"; -import {Type} from "./type"; -import {doubleBattleDialogue} from "./dialogue"; -import {PersistentModifier} from "../modifier/modifier"; -import {TrainerVariant} from "../field/trainer"; -import {getIsInitialized, initI18n} from "#app/plugins/i18n"; +import { PokeballType } from "./pokeball"; +import { pokemonEvolutions, pokemonPrevolutions } from "./pokemon-evolutions"; +import PokemonSpecies, { getPokemonSpecies, PokemonSpeciesFilter } from "./pokemon-species"; +import { tmSpecies } from "./tms"; +import { Type } from "./type"; +import { doubleBattleDialogue } from "./dialogue"; +import { PersistentModifier } from "../modifier/modifier"; +import { TrainerVariant } from "../field/trainer"; +import { getIsInitialized, initI18n } from "#app/plugins/i18n"; import i18next from "i18next"; import {Moves} from "#enums/moves"; import {PartyMemberStrength} from "#enums/party-member-strength"; @@ -355,9 +355,9 @@ export class TrainerConfig { /** * Sets the configuration for trainers with genders, including the female name and encounter background music (BGM). - * @param {string} [nameFemale] - The name of the female trainer. If 'Ivy', a localized name will be assigned. - * @param {TrainerType | string} [femaleEncounterBgm] - The encounter BGM for the female trainer, which can be a TrainerType or a string. - * @returns {TrainerConfig} - The updated TrainerConfig instance. + * @param {string} [nameFemale] The name of the female trainer. If 'Ivy', a localized name will be assigned. + * @param {TrainerType | string} [femaleEncounterBgm] The encounter BGM for the female trainer, which can be a TrainerType or a string. + * @returns {TrainerConfig} The updated TrainerConfig instance. **/ setHasGenders(nameFemale?: string, femaleEncounterBgm?: TrainerType | string): TrainerConfig { // If the female name is 'Ivy' (the rival), assign a localized name. @@ -392,9 +392,9 @@ export class TrainerConfig { /** * Sets the configuration for trainers with double battles, including the name of the double trainer and the encounter BGM. - * @param nameDouble - The name of the double trainer (e.g., "Ace Duo" for Trainer Class Doubles or "red_blue_double" for NAMED trainer doubles). - * @param doubleEncounterBgm - The encounter BGM for the double trainer, which can be a TrainerType or a string. - * @returns {TrainerConfig} - The updated TrainerConfig instance. + * @param nameDouble The name of the double trainer (e.g., "Ace Duo" for Trainer Class Doubles or "red_blue_double" for NAMED trainer doubles). + * @param doubleEncounterBgm The encounter BGM for the double trainer, which can be a TrainerType or a string. + * @returns {TrainerConfig} The updated TrainerConfig instance. */ setHasDouble(nameDouble: string, doubleEncounterBgm?: TrainerType | string): TrainerConfig { this.hasDouble = true; @@ -407,8 +407,8 @@ export class TrainerConfig { /** * Sets the trainer type for double battles. - * @param trainerTypeDouble - The TrainerType of the partner in a double battle. - * @returns {TrainerConfig} - The updated TrainerConfig instance. + * @param trainerTypeDouble The TrainerType of the partner in a double battle. + * @returns {TrainerConfig} The updated TrainerConfig instance. */ setDoubleTrainerType(trainerTypeDouble: TrainerType): TrainerConfig { this.trainerTypeDouble = trainerTypeDouble; @@ -432,8 +432,8 @@ export class TrainerConfig { /** * Sets the title for double trainers - * @param titleDouble - the key for the title in the i18n file. (e.g., "champion_double"). - * @returns {TrainerConfig} - The updated TrainerConfig instance. + * @param titleDouble The key for the title in the i18n file. (e.g., "champion_double"). + * @returns {TrainerConfig} The updated TrainerConfig instance. */ setDoubleTitle(titleDouble: string): TrainerConfig { // First check if i18n is initialized @@ -625,10 +625,10 @@ export class TrainerConfig { /** * Initializes the trainer configuration for an evil team admin. - * @param title - The title of the evil team admin. - * @param poolName - The evil team the admin belongs to. - * @param {Species | Species[]} signatureSpecies - The signature species for the evil team leader. - * @returns {TrainerConfig} - The updated TrainerConfig instance. + * @param title The title of the evil team admin. + * @param poolName The evil team the admin belongs to. + * @param {Species | Species[]} signatureSpecies The signature species for the evil team leader. + * @returns {TrainerConfig} The updated TrainerConfig instance. * **/ initForEvilTeamAdmin(title: string, poolName: string, signatureSpecies: (Species | Species[])[],): TrainerConfig { if (!getIsInitialized()) { @@ -659,12 +659,49 @@ export class TrainerConfig { return this; } + /** + * Initializes the trainer configuration for a Stat Trainer, as part of the Trainer's Test Mystery Encounter. + * @param {Species | Species[]} signatureSpecies The signature species for the Elite Four member. + * @param {Type[]} specialtyTypes The specialty types for the Stat Trainer. + * @param isMale Whether the Elite Four Member is Male or Female (for localization of the title). + * @returns {TrainerConfig} The updated TrainerConfig instance. + **/ + initForStatTrainer(signatureSpecies: (Species | Species[])[], isMale: boolean, ...specialtyTypes: Type[]): TrainerConfig { + if (!getIsInitialized()) { + initI18n(); + } + + this.setPartyTemplates(trainerPartyTemplates.ELITE_FOUR); + + signatureSpecies.forEach((speciesPool, s) => { + if (!Array.isArray(speciesPool)) { + speciesPool = [speciesPool]; + } + this.setPartyMemberFunc(-(s + 1), getRandomPartyMemberFunc(speciesPool)); + }); + if (specialtyTypes.length) { + this.setSpeciesFilter(p => specialtyTypes.find(t => p.isOfType(t)) !== undefined); + this.setSpecialtyTypes(...specialtyTypes); + } + const nameForCall = this.name.toLowerCase().replace(/\s/g, "_"); + this.name = i18next.t(`trainerNames:${nameForCall}`); + this.setMoneyMultiplier(2); + this.setBoss(); + this.setStaticParty(); + + // TODO: replace with more suitable music? + this.setBattleBgm("battle_trainer"); + this.setVictoryBgm("victory_trainer"); + + return this; + } + /** * Initializes the trainer configuration for an evil team leader. Temporarily hardcoding evil leader teams though. - * @param {Species | Species[]} signatureSpecies - The signature species for the evil team leader. - * @param {Type[]} specialtyTypes - The specialty types for the evil team Leader. - * @param boolean whether or not this is the rematch fight - * @returns {TrainerConfig} - The updated TrainerConfig instance. + * @param {Species | Species[]} signatureSpecies The signature species for the evil team leader. + * @param {Type[]} specialtyTypes The specialty types for the evil team Leader. + * @param boolean Whether or not this is the rematch fight + * @returns {TrainerConfig} The updated TrainerConfig instance. * **/ initForEvilTeamLeader(title: string, signatureSpecies: (Species | Species[])[], rematch: boolean = false, ...specialtyTypes: Type[]): TrainerConfig { if (!getIsInitialized()) { @@ -700,10 +737,10 @@ export class TrainerConfig { /** * Initializes the trainer configuration for a Gym Leader. - * @param {Species | Species[]} signatureSpecies - The signature species for the Gym Leader. - * @param {Type[]} specialtyTypes - The specialty types for the Gym Leader. - * @param isMale - Whether the Gym Leader is Male or Not (for localization of the title). - * @returns {TrainerConfig} - The updated TrainerConfig instance. + * @param {Species | Species[]} signatureSpecies The signature species for the Gym Leader. + * @param {Type[]} specialtyTypes The specialty types for the Gym Leader. + * @param isMale Whether the Gym Leader is Male or Not (for localization of the title). + * @returns {TrainerConfig} The updated TrainerConfig instance. * **/ initForGymLeader(signatureSpecies: (Species | Species[])[], isMale: boolean, ...specialtyTypes: Type[]): TrainerConfig { // Check if the internationalization (i18n) system is initialized. @@ -757,10 +794,10 @@ export class TrainerConfig { /** * Initializes the trainer configuration for an Elite Four member. - * @param {Species | Species[]} signatureSpecies - The signature species for the Elite Four member. - * @param {Type[]} specialtyTypes - The specialty types for the Elite Four member. - * @param isMale - Whether the Elite Four Member is Male or Female (for localization of the title). - * @returns {TrainerConfig} - The updated TrainerConfig instance. + * @param {Species | Species[]} signatureSpecies The signature species for the Elite Four member. + * @param {Type[]} specialtyTypes The specialty types for the Elite Four member. + * @param isMale Whether the Elite Four Member is Male or Female (for localization of the title). + * @returns {TrainerConfig} The updated TrainerConfig instance. **/ initForEliteFour(signatureSpecies: (Species | Species[])[], isMale: boolean, ...specialtyTypes: Type[]): TrainerConfig { // Check if the internationalization (i18n) system is initialized. @@ -813,9 +850,9 @@ export class TrainerConfig { /** * Initializes the trainer configuration for a Champion. - * @param {Species | Species[]} signatureSpecies - The signature species for the Champion. - * @param isMale - Whether the Champion is Male or Female (for localization of the title). - * @returns {TrainerConfig} - The updated TrainerConfig instance. + * @param {Species | Species[]} signatureSpecies The signature species for the Champion. + * @param isMale Whether the Champion is Male or Female (for localization of the title). + * @returns {TrainerConfig} The updated TrainerConfig instance. **/ initForChampion(signatureSpecies: (Species | Species[])[], isMale: boolean): TrainerConfig { // Check if the internationalization (i18n) system is initialized. @@ -956,6 +993,66 @@ export class TrainerConfig { } }); } + + /** + * Creates a shallow copy of a trainer config so that it can be modified without affecting the {@link trainerConfigs} source map + */ + clone(): TrainerConfig { + let clone = new TrainerConfig(this.trainerType); + clone = this.trainerTypeDouble ? clone.setDoubleTrainerType(this.trainerTypeDouble) : clone; + clone = this.name ? clone.setName(this.name) : clone; + clone = this.hasGenders ? clone.setHasGenders(this.nameFemale, this.femaleEncounterBgm) : clone; + clone = this.hasDouble ? clone.setHasDouble(this.nameDouble, this.doubleEncounterBgm) : clone; + clone = this.title ? clone.setTitle(this.title) : clone; + clone = this.titleDouble ? clone.setDoubleTitle(this.titleDouble) : clone; + clone = this.hasCharSprite ? clone.setHasCharSprite() : clone; + clone = this.doubleOnly ? clone.setDoubleOnly() : clone; + clone = this.moneyMultiplier ? clone.setMoneyMultiplier(this.moneyMultiplier) : clone; + clone = this.isBoss ? clone.setBoss() : clone; + clone = this.hasStaticParty ? clone.setStaticParty() : clone; + clone = this.useSameSeedForAllMembers ? clone.setUseSameSeedForAllMembers() : clone; + clone = this.battleBgm ? clone.setBattleBgm(this.battleBgm) : clone; + clone = this.encounterBgm ? clone.setEncounterBgm(this.encounterBgm) : clone; + clone = this.victoryBgm ? clone.setVictoryBgm(this.victoryBgm) : clone; + clone = this.genModifiersFunc ? clone.setGenModifiersFunc(this.genModifiersFunc) : clone; + + if (this.modifierRewardFuncs) { + // Clones array instead of passing ref + clone.modifierRewardFuncs = this.modifierRewardFuncs.slice(0); + } + + if (this.partyTemplates) { + clone.partyTemplates = this.partyTemplates.slice(0); + } + + clone = this.partyTemplateFunc ? clone.setPartyTemplateFunc(this.partyTemplateFunc) : clone; + + if (this.partyMemberFuncs) { + Object.keys(this.partyMemberFuncs).forEach((index) => { + clone = clone.setPartyMemberFunc(parseInt(index, 10), this.partyMemberFuncs[index]); + }); + } + + clone = this.speciesPools ? clone.setSpeciesPools(this.speciesPools) : clone; + clone = this.speciesFilter ? clone.setSpeciesFilter(this.speciesFilter) : clone; + if (this.specialtyTypes) { + clone.specialtyTypes = this.specialtyTypes.slice(0); + } + + clone.encounterMessages = this.encounterMessages?.slice(0); + clone.victoryMessages = this.victoryMessages?.slice(0); + clone.defeatMessages = this.defeatMessages?.slice(0); + + clone.femaleEncounterMessages = this.femaleEncounterMessages?.slice(0); + clone.femaleVictoryMessages = this.femaleVictoryMessages?.slice(0); + clone.femaleDefeatMessages = this.femaleDefeatMessages?.slice(0); + + clone.doubleEncounterMessages = this.doubleEncounterMessages?.slice(0); + clone.doubleVictoryMessages = this.doubleVictoryMessages?.slice(0); + clone.doubleDefeatMessages = this.doubleDefeatMessages?.slice(0); + + return clone; + } } let t = 0; @@ -2041,4 +2138,153 @@ export const trainerConfigs: TrainerConfigs = { p.generateName(); p.pokeball = PokeballType.ULTRA_BALL; })), + [TrainerType.BUCK]: new TrainerConfig(++t).setName("Buck").initForStatTrainer([], true) + .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.CLAYDOL ], TrainerSlot.TRAINER, true, p => { + p.setBoss(true, 3); + p.generateAndPopulateMoveset(); + p.pokeball = PokeballType.ULTRA_BALL; + })) + .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.VENUSAUR, Species.COALOSSAL ], TrainerSlot.TRAINER, true, p => { + p.generateAndPopulateMoveset(); + p.pokeball = PokeballType.GREAT_BALL; + if (p.species.speciesId === Species.VENUSAUR) { + p.formIndex = 2; // Gmax + p.abilityIndex = 2; // Venusaur gets Chlorophyll + } else { + p.formIndex = 1; // Gmax + } + p.generateName(); + })) + .setPartyMemberFunc(2, getRandomPartyMemberFunc([ Species.AGGRON ], TrainerSlot.TRAINER, true, p => { + p.generateAndPopulateMoveset(); + p.formIndex = 1; // Mega + p.generateName(); + })) + .setPartyMemberFunc(3, getRandomPartyMemberFunc([ Species.TORKOAL ], TrainerSlot.TRAINER, true, p => { + p.generateAndPopulateMoveset(); + p.abilityIndex = 1; // Drought + })) + .setPartyMemberFunc(4, getRandomPartyMemberFunc([ Species.GREAT_TUSK ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(5, getRandomPartyMemberFunc([ Species.HEATRAN ], TrainerSlot.TRAINER, true, p => { + p.setBoss(true, 2); + p.generateAndPopulateMoveset(); + p.pokeball = PokeballType.MASTER_BALL; + })), + [TrainerType.CHERYL]: new TrainerConfig(++t).setName("Cheryl").initForStatTrainer([], false) + .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.BLISSEY ], TrainerSlot.TRAINER, true, p => { + p.setBoss(true, 3); + p.generateAndPopulateMoveset(); + p.pokeball = PokeballType.ULTRA_BALL; + })) + .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.SNORLAX, Species.LAPRAS ], TrainerSlot.TRAINER, true, p => { + p.generateAndPopulateMoveset(); + p.pokeball = PokeballType.GREAT_BALL; + p.formIndex = 1; // Gmax + p.generateName(); + })) + .setPartyMemberFunc(2, getRandomPartyMemberFunc([ Species.AUDINO ], TrainerSlot.TRAINER, true, p => { + p.generateAndPopulateMoveset(); + p.formIndex = 1; // Mega + p.generateName(); + })) + .setPartyMemberFunc(3, getRandomPartyMemberFunc([ Species.GOODRA ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(4, getRandomPartyMemberFunc([ Species.IRON_HANDS ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(5, getRandomPartyMemberFunc([ Species.CRESSELIA, Species.ENAMORUS ], TrainerSlot.TRAINER, true, p => { + p.setBoss(true, 2); + p.generateAndPopulateMoveset(); + if (p.species.speciesId === Species.ENAMORUS) { + p.formIndex = 1; // Therian + p.generateName(); + } + p.pokeball = PokeballType.MASTER_BALL; + })), + [TrainerType.MARLEY]: new TrainerConfig(++t).setName("Marley").initForStatTrainer([], false) + .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.ARCANINE ], TrainerSlot.TRAINER, true, p => { + p.setBoss(true, 3); + p.generateAndPopulateMoveset(); + p.pokeball = PokeballType.ULTRA_BALL; + })) + .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.CINDERACE, Species.INTELEON ], TrainerSlot.TRAINER, true, p => { + p.generateAndPopulateMoveset(); + p.pokeball = PokeballType.GREAT_BALL; + p.formIndex = 1; // Gmax + p.generateName(); + })) + .setPartyMemberFunc(2, getRandomPartyMemberFunc([ Species.AERODACTYL ], TrainerSlot.TRAINER, true, p => { + p.generateAndPopulateMoveset(); + p.formIndex = 1; // Mega + p.generateName(); + })) + .setPartyMemberFunc(3, getRandomPartyMemberFunc([ Species.DRAGAPULT ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(4, getRandomPartyMemberFunc([ Species.IRON_BUNDLE ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(5, getRandomPartyMemberFunc([ Species.REGIELEKI ], TrainerSlot.TRAINER, true, p => { + p.setBoss(true, 2); + p.generateAndPopulateMoveset(); + p.pokeball = PokeballType.MASTER_BALL; + })), + [TrainerType.MIRA]: new TrainerConfig(++t).setName("Mira").initForStatTrainer([], false) + .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.ALAKAZAM ], TrainerSlot.TRAINER, true, p => { + p.setBoss(true, 2); + p.generateAndPopulateMoveset(); + p.formIndex = 1; + p.pokeball = PokeballType.ULTRA_BALL; + p.generateName(); + })) + .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.GENGAR, Species.HATTERENE ], TrainerSlot.TRAINER, true, p => { + p.generateAndPopulateMoveset(); + p.pokeball = PokeballType.GREAT_BALL; + p.formIndex = p.species.speciesId === Species.GENGAR ? 2 : 1; // Gmax + p.generateName(); + })) + .setPartyMemberFunc(2, getRandomPartyMemberFunc([ Species.FLUTTER_MANE ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(3, getRandomPartyMemberFunc([ Species.HYDREIGON ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(4, getRandomPartyMemberFunc([ Species.MAGNEZONE ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(5, getRandomPartyMemberFunc([ Species.LATIOS, Species.LATIAS ], TrainerSlot.TRAINER, true, p => { + p.setBoss(true, 2); + p.generateAndPopulateMoveset(); + p.pokeball = PokeballType.MASTER_BALL; + })), + [TrainerType.RILEY]: new TrainerConfig(++t).setName("Riley").initForStatTrainer([], true) + .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.LUCARIO ], TrainerSlot.TRAINER, true, p => { + p.setBoss(true, 2); + p.generateAndPopulateMoveset(); + p.formIndex = 1; + p.pokeball = PokeballType.ULTRA_BALL; + p.generateName(); + })) + .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.RILLABOOM, Species.CENTISKORCH ], TrainerSlot.TRAINER, true, p => { + p.generateAndPopulateMoveset(); + p.pokeball = PokeballType.GREAT_BALL; + p.formIndex = 1; // Gmax + p.generateName(); + })) + .setPartyMemberFunc(2, getRandomPartyMemberFunc([ Species.TYRANITAR ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(3, getRandomPartyMemberFunc([ Species.ROARING_MOON ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(4, getRandomPartyMemberFunc([ Species.URSALUNA ], TrainerSlot.TRAINER, true)) + .setPartyMemberFunc(5, getRandomPartyMemberFunc([ Species.REGIGIGAS, Species.LANDORUS ], TrainerSlot.TRAINER, true, p => { + p.setBoss(true, 2); + p.generateAndPopulateMoveset(); + if (p.species.speciesId === Species.LANDORUS) { + p.formIndex = 1; // Therian + p.generateName(); + } + p.pokeball = PokeballType.MASTER_BALL; + })), + [TrainerType.VICTOR]: new TrainerConfig(++t).setName("Victor").setTitle("The Winstrates") + .setMoneyMultiplier(1) // The Winstrate trainers have total money multiplier of 6 + .setPartyTemplates(trainerPartyTemplates.ONE_AVG_ONE_STRONG), + [TrainerType.VICTORIA]: new TrainerConfig(++t).setName("Victoria").setTitle("The Winstrates") + .setMoneyMultiplier(1) + .setPartyTemplates(trainerPartyTemplates.ONE_AVG_ONE_STRONG), + [TrainerType.VIVI]: new TrainerConfig(++t).setName("Vivi").setTitle("The Winstrates") + .setMoneyMultiplier(1) + .setPartyTemplates(trainerPartyTemplates.TWO_AVG_ONE_STRONG), + [TrainerType.VICKY]: new TrainerConfig(++t).setName("Vicky").setTitle("The Winstrates") + .setMoneyMultiplier(1) + .setPartyTemplates(trainerPartyTemplates.ONE_AVG), + [TrainerType.VITO]: new TrainerConfig(++t).setName("Vito").setTitle("The Winstrates") + .setMoneyMultiplier(2) + .setPartyTemplates(new TrainerPartyCompoundTemplate(new TrainerPartyTemplate(3, PartyMemberStrength.AVERAGE), new TrainerPartyTemplate(2, PartyMemberStrength.STRONG))), + [TrainerType.BUG_TYPE_SUPERFAN]: new TrainerConfig(++t).setMoneyMultiplier(2.25).setEncounterBgm(TrainerType.ACE_TRAINER) + .setPartyTemplates(new TrainerPartyTemplate(2, PartyMemberStrength.AVERAGE)) }; diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index 657f0d47375b..f367b1b56c0e 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -79,4 +79,5 @@ export enum BattlerTagType { TAR_SHOT = "TAR_SHOT", BURNED_UP = "BURNED_UP", DOUBLE_SHOCKED = "DOUBLE_SHOCKED", + MYSTERY_ENCOUNTER_POST_SUMMON = "MYSTERY_ENCOUNTER_POST_SUMMON", } diff --git a/src/enums/encounter-anims.ts b/src/enums/encounter-anims.ts new file mode 100644 index 000000000000..bd1461473c91 --- /dev/null +++ b/src/enums/encounter-anims.ts @@ -0,0 +1,11 @@ +/** + * Animations used for Mystery Encounters + * These are custom animations that may or may not work in any other circumstance + * Use at your own risk + */ +export enum EncounterAnim { + MAGMA_BG, + MAGMA_SPOUT, + SMOKESCREEN, + DANCE +} diff --git a/src/enums/mystery-encounter-mode.ts b/src/enums/mystery-encounter-mode.ts new file mode 100644 index 000000000000..f1e98ca5b18c --- /dev/null +++ b/src/enums/mystery-encounter-mode.ts @@ -0,0 +1,12 @@ +export enum MysteryEncounterMode { + /** {@linkcode MysteryEncounter} will always begin in this mode, but will always swap modes when an option is selected */ + DEFAULT, + /** If the {@linkcode MysteryEncounter} battle is a trainer type battle */ + TRAINER_BATTLE, + /** If the {@linkcode MysteryEncounter} battle is a wild type battle */ + WILD_BATTLE, + /** Enables special boss music during encounter */ + BOSS_BATTLE, + /** If there is no battle in the {@linkcode MysteryEncounter} or option selected */ + NO_BATTLE +} diff --git a/src/enums/mystery-encounter-option-mode.ts b/src/enums/mystery-encounter-option-mode.ts new file mode 100644 index 000000000000..a994c30581b9 --- /dev/null +++ b/src/enums/mystery-encounter-option-mode.ts @@ -0,0 +1,10 @@ +export enum MysteryEncounterOptionMode { + /** Default style */ + DEFAULT, + /** Disabled on requirements not met, default style on requirements met */ + DISABLED_OR_DEFAULT, + /** Default style on requirements not met, special style on requirements met */ + DEFAULT_OR_SPECIAL, + /** Disabled on requirements not met, special style on requirements met */ + DISABLED_OR_SPECIAL +} diff --git a/src/enums/mystery-encounter-tier.ts b/src/enums/mystery-encounter-tier.ts new file mode 100644 index 000000000000..484acc7aba94 --- /dev/null +++ b/src/enums/mystery-encounter-tier.ts @@ -0,0 +1,11 @@ +/** + * Enum values are base spawn weights of each tier. + * The weights aim for 46.25/31.25/18.5/4% spawn ratios, AFTER accounting for anti-variance and pity mechanisms + */ +export enum MysteryEncounterTier { + COMMON = 66, + GREAT = 40, + ULTRA = 19, + ROGUE = 3, + MASTER = 0 // Not currently used +} diff --git a/src/enums/mystery-encounter-type.ts b/src/enums/mystery-encounter-type.ts new file mode 100644 index 000000000000..39a8087599cf --- /dev/null +++ b/src/enums/mystery-encounter-type.ts @@ -0,0 +1,32 @@ +export enum MysteryEncounterType { + MYSTERIOUS_CHALLENGERS, + MYSTERIOUS_CHEST, + DARK_DEAL, + FIGHT_OR_FLIGHT, + SLUMBERING_SNORLAX, + TRAINING_SESSION, + DEPARTMENT_STORE_SALE, + SHADY_VITAMIN_DEALER, + FIELD_TRIP, + SAFARI_ZONE, + LOST_AT_SEA, + FIERY_FALLOUT, + THE_STRONG_STUFF, + THE_POKEMON_SALESMAN, + AN_OFFER_YOU_CANT_REFUSE, + DELIBIRDY, + ABSOLUTE_AVARICE, + A_TRAINERS_TEST, + TRASH_TO_TREASURE, + BERRIES_ABOUND, + CLOWNING_AROUND, + PART_TIMER, + DANCING_LESSONS, + WEIRD_DREAM, + THE_WINSTRATE_CHALLENGE, + TELEPORTING_HIJINKS, + BUG_TYPE_SUPERFAN, + FUN_AND_GAMES, + UNCOMMON_BREED, + GLOBAL_TRADE_SYSTEM +} diff --git a/src/enums/trainer-type.ts b/src/enums/trainer-type.ts index 835a2c9d039e..cfc52b70eb07 100644 --- a/src/enums/trainer-type.ts +++ b/src/enums/trainer-type.ts @@ -1,235 +1,246 @@ export enum TrainerType { - UNKNOWN, + UNKNOWN, - ACE_TRAINER, - ARTIST, - BACKERS, - BACKPACKER, - BAKER, - BEAUTY, - BIKER, - BLACK_BELT, - BREEDER, - CLERK, - CYCLIST, - DANCER, - DEPOT_AGENT, - DOCTOR, - FIREBREATHER, - FISHERMAN, - GUITARIST, - HARLEQUIN, - HIKER, - HOOLIGANS, - HOOPSTER, - INFIELDER, - JANITOR, - LINEBACKER, - MAID, - MUSICIAN, - HEX_MANIAC, - NURSERY_AIDE, - OFFICER, - PARASOL_LADY, - PILOT, - POKEFAN, - PRESCHOOLER, - PSYCHIC, - RANGER, - RICH, - RICH_KID, - ROUGHNECK, - SAILOR, - SCIENTIST, - SMASHER, - SNOW_WORKER, - STRIKER, - SCHOOL_KID, - SWIMMER, - TWINS, - VETERAN, - WAITER, - WORKER, - YOUNGSTER, - ROCKET_GRUNT, - ARCHER, - ARIANA, - PROTON, - PETREL, - MAGMA_GRUNT, - TABITHA, - COURTNEY, - AQUA_GRUNT, - MATT, - SHELLY, - GALACTIC_GRUNT, - JUPITER, - MARS, - SATURN, - PLASMA_GRUNT, - ZINZOLIN, - ROOD, - FLARE_GRUNT, - BRYONY, - XEROSIC, - AETHER_GRUNT, - FABA, - SKULL_GRUNT, - PLUMERIA, - MACRO_GRUNT, - OLEANA, - ROCKET_BOSS_GIOVANNI_1, - ROCKET_BOSS_GIOVANNI_2, - MAXIE, - MAXIE_2, - ARCHIE, - ARCHIE_2, - CYRUS, - CYRUS_2, - GHETSIS, - GHETSIS_2, - LYSANDRE, - LYSANDRE_2, - LUSAMINE, - LUSAMINE_2, - GUZMA, - GUZMA_2, - ROSE, - ROSE_2, + ACE_TRAINER, + ARTIST, + BACKERS, + BACKPACKER, + BAKER, + BEAUTY, + BIKER, + BLACK_BELT, + BREEDER, + CLERK, + CYCLIST, + DANCER, + DEPOT_AGENT, + DOCTOR, + FIREBREATHER, + FISHERMAN, + GUITARIST, + HARLEQUIN, + HIKER, + HOOLIGANS, + HOOPSTER, + INFIELDER, + JANITOR, + LINEBACKER, + MAID, + MUSICIAN, + HEX_MANIAC, + NURSERY_AIDE, + OFFICER, + PARASOL_LADY, + PILOT, + POKEFAN, + PRESCHOOLER, + PSYCHIC, + RANGER, + RICH, + RICH_KID, + ROUGHNECK, + SAILOR, + SCIENTIST, + SMASHER, + SNOW_WORKER, + STRIKER, + SCHOOL_KID, + SWIMMER, + TWINS, + VETERAN, + WAITER, + WORKER, + YOUNGSTER, + ROCKET_GRUNT, + ARCHER, + ARIANA, + PROTON, + PETREL, + MAGMA_GRUNT, + TABITHA, + COURTNEY, + AQUA_GRUNT, + MATT, + SHELLY, + GALACTIC_GRUNT, + JUPITER, + MARS, + SATURN, + PLASMA_GRUNT, + ZINZOLIN, + ROOD, + FLARE_GRUNT, + BRYONY, + XEROSIC, + AETHER_GRUNT, + FABA, + SKULL_GRUNT, + PLUMERIA, + MACRO_GRUNT, + OLEANA, + ROCKET_BOSS_GIOVANNI_1, + ROCKET_BOSS_GIOVANNI_2, + MAXIE, + MAXIE_2, + ARCHIE, + ARCHIE_2, + CYRUS, + CYRUS_2, + GHETSIS, + GHETSIS_2, + LYSANDRE, + LYSANDRE_2, + LUSAMINE, + LUSAMINE_2, + GUZMA, + GUZMA_2, + ROSE, + ROSE_2, + BUCK, + CHERYL, + MARLEY, + MIRA, + RILEY, + VICTOR, + VICTORIA, + VIVI, + VICKY, + VITO, + BUG_TYPE_SUPERFAN, - BROCK = 200, - MISTY, - LT_SURGE, - ERIKA, - JANINE, - SABRINA, - BLAINE, - GIOVANNI, - FALKNER, - BUGSY, - WHITNEY, - MORTY, - CHUCK, - JASMINE, - PRYCE, - CLAIR, - ROXANNE, - BRAWLY, - WATTSON, - FLANNERY, - NORMAN, - WINONA, - TATE, - LIZA, - JUAN, - ROARK, - GARDENIA, - MAYLENE, - CRASHER_WAKE, - FANTINA, - BYRON, - CANDICE, - VOLKNER, - CILAN, - CHILI, - CRESS, - CHEREN, - LENORA, - ROXIE, - BURGH, - ELESA, - CLAY, - SKYLA, - BRYCEN, - DRAYDEN, - MARLON, - VIOLA, - GRANT, - KORRINA, - RAMOS, - CLEMONT, - VALERIE, - OLYMPIA, - WULFRIC, - MILO, - NESSA, - KABU, - BEA, - ALLISTER, - OPAL, - BEDE, - GORDIE, - MELONY, - PIERS, - MARNIE, - RAIHAN, - KATY, - BRASSIUS, - IONO, - KOFU, - LARRY, - RYME, - TULIP, - GRUSHA, - LORELEI = 300, - BRUNO, - AGATHA, - LANCE, - WILL, - KOGA, - KAREN, - SIDNEY, - PHOEBE, - GLACIA, - DRAKE, - AARON, - BERTHA, - FLINT, - LUCIAN, - SHAUNTAL, - MARSHAL, - GRIMSLEY, - CAITLIN, - MALVA, - SIEBOLD, - WIKSTROM, - DRASNA, - HALA, - MOLAYNE, - OLIVIA, - ACEROLA, - KAHILI, - MARNIE_ELITE, - NESSA_ELITE, - BEA_ELITE, - ALLISTER_ELITE, - RAIHAN_ELITE, - RIKA, - POPPY, - LARRY_ELITE, - HASSEL, - CRISPIN, - AMARYS, - LACEY, - DRAYTON, - BLUE = 350, - RED, - LANCE_CHAMPION, - STEVEN, - WALLACE, - CYNTHIA, - ALDER, - IRIS, - DIANTHA, - HAU, - LEON, - GEETA, - NEMONA, - KIERAN, - RIVAL = 375, - RIVAL_2, - RIVAL_3, - RIVAL_4, - RIVAL_5, - RIVAL_6 + BROCK = 200, + MISTY, + LT_SURGE, + ERIKA, + JANINE, + SABRINA, + BLAINE, + GIOVANNI, + FALKNER, + BUGSY, + WHITNEY, + MORTY, + CHUCK, + JASMINE, + PRYCE, + CLAIR, + ROXANNE, + BRAWLY, + WATTSON, + FLANNERY, + NORMAN, + WINONA, + TATE, + LIZA, + JUAN, + ROARK, + GARDENIA, + MAYLENE, + CRASHER_WAKE, + FANTINA, + BYRON, + CANDICE, + VOLKNER, + CILAN, + CHILI, + CRESS, + CHEREN, + LENORA, + ROXIE, + BURGH, + ELESA, + CLAY, + SKYLA, + BRYCEN, + DRAYDEN, + MARLON, + VIOLA, + GRANT, + KORRINA, + RAMOS, + CLEMONT, + VALERIE, + OLYMPIA, + WULFRIC, + MILO, + NESSA, + KABU, + BEA, + ALLISTER, + OPAL, + BEDE, + GORDIE, + MELONY, + PIERS, + MARNIE, + RAIHAN, + KATY, + BRASSIUS, + IONO, + KOFU, + LARRY, + RYME, + TULIP, + GRUSHA, + LORELEI = 300, + BRUNO, + AGATHA, + LANCE, + WILL, + KOGA, + KAREN, + SIDNEY, + PHOEBE, + GLACIA, + DRAKE, + AARON, + BERTHA, + FLINT, + LUCIAN, + SHAUNTAL, + MARSHAL, + GRIMSLEY, + CAITLIN, + MALVA, + SIEBOLD, + WIKSTROM, + DRASNA, + HALA, + MOLAYNE, + OLIVIA, + ACEROLA, + KAHILI, + MARNIE_ELITE, + NESSA_ELITE, + BEA_ELITE, + ALLISTER_ELITE, + RAIHAN_ELITE, + RIKA, + POPPY, + LARRY_ELITE, + HASSEL, + CRISPIN, + AMARYS, + LACEY, + DRAYTON, + BLUE = 350, + RED, + LANCE_CHAMPION, + STEVEN, + WALLACE, + CYNTHIA, + ALDER, + IRIS, + DIANTHA, + HAU, + LEON, + GEETA, + NEMONA, + KIERAN, + RIVAL = 375, + RIVAL_2, + RIVAL_3, + RIVAL_4, + RIVAL_5, + RIVAL_6 } diff --git a/src/field/arena.ts b/src/field/arena.ts index 9f0a9691dee2..0466c01c82be 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -76,21 +76,21 @@ export class Arena { } } - randomSpecies(waveIndex: integer, level: integer, attempt?: integer, luckValue?: integer): PokemonSpecies { + randomSpecies(waveIndex: integer, level: integer, attempt?: integer, luckValue?: integer, isBoss?: boolean): PokemonSpecies { const overrideSpecies = this.scene.gameMode.getOverrideSpecies(waveIndex); if (overrideSpecies) { return overrideSpecies; } - const isBoss = !!this.scene.getEncounterBossSegments(waveIndex, level) && !!this.pokemonPool[BiomePoolTier.BOSS].length + const isBossSpecies = !!this.scene.getEncounterBossSegments(waveIndex, level) && !!this.pokemonPool[BiomePoolTier.BOSS].length && (this.biomeType !== Biome.END || this.scene.gameMode.isClassic || this.scene.gameMode.isWaveFinal(waveIndex)); - const randVal = isBoss ? 64 : 512; + const randVal = isBossSpecies ? 64 : 512; // luck influences encounter rarity let luckModifier = 0; if (typeof luckValue !== "undefined") { - luckModifier = luckValue * (isBoss ? 0.5 : 2); + luckModifier = luckValue * (isBossSpecies ? 0.5 : 2); } const tierValue = Utils.randSeedInt(randVal - luckModifier); - let tier = !isBoss + let tier = !isBossSpecies ? tierValue >= 156 ? BiomePoolTier.COMMON : tierValue >= 32 ? BiomePoolTier.UNCOMMON : tierValue >= 6 ? BiomePoolTier.RARE : tierValue >= 1 ? BiomePoolTier.SUPER_RARE : BiomePoolTier.ULTRA_RARE : tierValue >= 20 ? BiomePoolTier.BOSS : tierValue >= 6 ? BiomePoolTier.BOSS_RARE : tierValue >= 1 ? BiomePoolTier.BOSS_SUPER_RARE : BiomePoolTier.BOSS_ULTRA_RARE; console.log(BiomePoolTier[tier]); @@ -149,7 +149,7 @@ export class Arena { return this.randomSpecies(waveIndex, level, (attempt || 0) + 1); } - const newSpeciesId = ret.getWildSpeciesForLevel(level, true, isBoss, this.scene.gameMode); + const newSpeciesId = ret.getWildSpeciesForLevel(level, true, isBoss ?? isBossSpecies, this.scene.gameMode); if (newSpeciesId !== ret.speciesId) { console.log("Replaced", Species[ret.speciesId], "with", Species[newSpeciesId]); ret = getPokemonSpecies(newSpeciesId); @@ -157,12 +157,12 @@ export class Arena { return ret; } - randomTrainerType(waveIndex: integer): TrainerType { - const isBoss = !!this.trainerPool[BiomePoolTier.BOSS].length - && this.scene.gameMode.isTrainerBoss(waveIndex, this.biomeType, this.scene.offsetGym); + randomTrainerType(waveIndex: integer, isBoss: boolean = false): TrainerType { + const isTrainerBoss = !!this.trainerPool[BiomePoolTier.BOSS].length + && (this.scene.gameMode.isTrainerBoss(waveIndex, this.biomeType, this.scene.offsetGym) || isBoss); console.log(isBoss, this.trainerPool); - const tierValue = Utils.randSeedInt(!isBoss ? 512 : 64); - let tier = !isBoss + const tierValue = Utils.randSeedInt(!isTrainerBoss ? 512 : 64); + let tier = !isTrainerBoss ? tierValue >= 156 ? BiomePoolTier.COMMON : tierValue >= 32 ? BiomePoolTier.UNCOMMON : tierValue >= 6 ? BiomePoolTier.RARE : tierValue >= 1 ? BiomePoolTier.SUPER_RARE : BiomePoolTier.ULTRA_RARE : tierValue >= 20 ? BiomePoolTier.BOSS : tierValue >= 6 ? BiomePoolTier.BOSS_RARE : tierValue >= 1 ? BiomePoolTier.BOSS_SUPER_RARE : BiomePoolTier.BOSS_ULTRA_RARE; console.log(BiomePoolTier[tier]); @@ -320,7 +320,7 @@ export class Arena { this.eventTarget.dispatchEvent(new WeatherChangedEvent(oldWeatherType, this.weather?.weatherType!, this.weather?.turnsLeft!)); // TODO: is this bang correct? if (this.weather) { - this.scene.unshiftPhase(new CommonAnimPhase(this.scene, undefined, undefined, CommonAnim.SUNNY + (weather - 1))); + this.scene.unshiftPhase(new CommonAnimPhase(this.scene, undefined, undefined, CommonAnim.SUNNY + (weather - 1), true)); this.scene.queueMessage(getWeatherStartMessage(weather)!); // TODO: is this bang correct? } else { this.scene.queueMessage(getWeatherClearMessage(oldWeatherType)!); // TODO: is this bang correct? diff --git a/src/field/mystery-encounter-intro.ts b/src/field/mystery-encounter-intro.ts new file mode 100644 index 000000000000..7c58a4946992 --- /dev/null +++ b/src/field/mystery-encounter-intro.ts @@ -0,0 +1,456 @@ +import { GameObjects } from "phaser"; +import BattleScene from "../battle-scene"; +import MysteryEncounter from "../data/mystery-encounters/mystery-encounter"; +import { Species } from "#enums/species"; +import { isNullOrUndefined } from "#app/utils"; +import { getSpriteKeysFromSpecies } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import PlayAnimationConfig = Phaser.Types.Animations.PlayAnimationConfig; + +type KnownFileRoot = + | "arenas" + | "battle_anims" + | "cg" + | "character" + | "effect" + | "egg" + | "events" + | "inputs" + | "items" + | "mystery-encounters" + | "pokeball" + | "pokemon" + | "pokemon/back" + | "pokemon/exp" + | "pokemon/female" + | "pokemon/icons" + | "pokemon/input" + | "pokemon/shiny" + | "pokemon/variant" + | "statuses" + | "trainer" + | "ui"; + +export class MysteryEncounterSpriteConfig { + /** The sprite key (which is the image file name). e.g. "ace_trainer_f" */ + spriteKey: string; + /** Refer to [/public/images](../../public/images) directorty for all folder names */ + fileRoot: KnownFileRoot & string | string; + /** Optional replacement for `spriteKey`/`fileRoot`. Just know this defaults to male/genderless, form 0, no shiny */ + species?: Species; + /** Enable shadow. Defaults to `false` */ + hasShadow?: boolean = false; + /** Disable animation. Defaults to `false` */ + disableAnimation?: boolean = false; + /** Repeat the animation. Defaults to `false` */ + repeat?: boolean = false; + /** What frame of the animation to start on. Defaults to 0 */ + startFrame?: number = 0; + /** Hidden at start of encounter. Defaults to `false` */ + hidden?: boolean = false; + /** Tint color. `0` - `1`. Higher means darker tint. */ + tint?: number; + /** X offset */ + x?: number; + /** Y offset */ + y?: number; + /** Y shadow offset */ + yShadow?: number; + /** Sprite scale. `0` - `n` */ + scale?: number; + /** If you are using a Pokemon sprite, set to `true`. This will ensure variant, form, gender, shiny sprites are loaded properly */ + isPokemon?: boolean; + /** If you are using an item sprite, set to `true` */ + isItem?: boolean; + /** The sprites alpha. `0` - `1` The lower the number, the more transparent */ + alpha?: number; +} + +/** + * When a mystery encounter spawns, there are visuals (mainly sprites) tied to the field for the new encounter to inform the player of the type of encounter + * These slide in with the field as part of standard field change cycle, and will typically be hidden after the player has selected an option for the encounter + * Note: intro visuals are not "Trainers" or any other specific game object, though they may contain trainer sprites + */ +export default class MysteryEncounterIntroVisuals extends Phaser.GameObjects.Container { + public encounter: MysteryEncounter; + public spriteConfigs: MysteryEncounterSpriteConfig[]; + public enterFromRight: boolean; + + constructor(scene: BattleScene, encounter: MysteryEncounter) { + super(scene, -72, 76); + this.encounter = encounter; + this.enterFromRight = encounter.enterIntroVisualsFromRight ?? false; + // Shallow copy configs to allow visual config updates at runtime without dirtying master copy of Encounter + this.spriteConfigs = encounter.spriteConfigs.map(config => { + const result = { + ...config + }; + + if (!isNullOrUndefined(result.species)) { + const keys = getSpriteKeysFromSpecies(result.species!); + result.spriteKey = keys.spriteKey; + result.fileRoot = keys.fileRoot; + result.isPokemon = true; + } + + return result; + }); + if (!this.spriteConfigs) { + return; + } + + const getSprite = (spriteKey: string, hasShadow?: boolean, yShadow?: number) => { + const ret = this.scene.addFieldSprite(0, 0, spriteKey); + ret.setOrigin(0.5, 1); + ret.setPipeline(this.scene.spritePipeline, { tone: [0.0, 0.0, 0.0, 0.0], hasShadow: !!hasShadow, yShadowOffset: yShadow ?? 0 }); + return ret; + }; + + const getItemSprite = (spriteKey: string, hasShadow?: boolean, yShadow?: number) => { + const icon = this.scene.add.sprite(-19, 2, "items", spriteKey); + icon.setOrigin(0.5, 1); + icon.setPipeline(this.scene.spritePipeline, { tone: [0.0, 0.0, 0.0, 0.0], hasShadow: !!hasShadow, yShadowOffset: yShadow ?? 0 }); + return icon; + }; + + // Depending on number of sprites added, should space them to be on the circular field sprite + const minX = -40; + const maxX = 40; + const origin = 4; + let n = 0; + // Sprites with custom X or Y defined will not count for normal spacing requirements + const spacingValue = Math.round((maxX - minX) / Math.max(this.spriteConfigs.filter(s => !s.x && !s.y).length, 1)); + + this.spriteConfigs?.forEach((config) => { + const { spriteKey, isItem, hasShadow, scale, x, y, yShadow, alpha } = config; + + let sprite: GameObjects.Sprite; + let tintSprite: GameObjects.Sprite; + + if (!isItem) { + sprite = getSprite(spriteKey, hasShadow, yShadow); + tintSprite = getSprite(spriteKey); + } else { + sprite = getItemSprite(spriteKey, hasShadow, yShadow); + tintSprite = getItemSprite(spriteKey); + } + + sprite.setVisible(!config.hidden); + tintSprite.setVisible(false); + + if (scale) { + sprite.setScale(scale); + tintSprite.setScale(scale); + } + + // Sprite offset from origin + if (x || y) { + if (x) { + sprite.setPosition(origin + x, sprite.y); + tintSprite.setPosition(origin + x, tintSprite.y); + } + if (y) { + sprite.setPosition(sprite.x, sprite.y + y); + tintSprite.setPosition(tintSprite.x, tintSprite.y + y); + } + } else { + // Single sprite + if (this.spriteConfigs.length === 1) { + sprite.x = origin; + tintSprite.x = origin; + } else { + // Do standard sprite spacing (not including offset sprites) + sprite.x = minX + (n + 0.5) * spacingValue + origin; + tintSprite.x = minX + (n + 0.5) * spacingValue + origin; + n++; + } + } + + if (!isNullOrUndefined(alpha)) { + sprite.setAlpha(alpha); + tintSprite.setAlpha(alpha); + } + + this.add(sprite); + this.add(tintSprite); + }); + } + + /** + * Loads the assets that were defined on construction (async) + */ + loadAssets(): Promise { + return new Promise(resolve => { + if (!this.spriteConfigs) { + resolve(); + } + + this.spriteConfigs.forEach((config) => { + if (config.isPokemon) { + this.scene.loadPokemonAtlas(config.spriteKey, config.fileRoot); + } else if (config.isItem) { + this.scene.loadAtlas("items", ""); + } else { + this.scene.loadAtlas(config.spriteKey, config.fileRoot); + } + }); + + this.scene.load.once(Phaser.Loader.Events.COMPLETE, () => { + this.spriteConfigs.every((config) => { + if (config.isItem) { + return true; + } + + const originalWarn = console.warn; + + // Ignore warnings for missing frames, because there will be a lot + console.warn = () => { + }; + const frameNames = this.scene.anims.generateFrameNames(config.spriteKey, { zeroPad: 4, suffix: ".png", start: 1, end: 128 }); + + console.warn = originalWarn; + if (!(this.scene.anims.exists(config.spriteKey))) { + this.scene.anims.create({ + key: config.spriteKey, + frames: frameNames, + frameRate: 12, + repeat: -1 + }); + } + + return true; + }); + + resolve(); + }); + + if (!this.scene.load.isLoading()) { + this.scene.load.start(); + } + }); + } + + /** + * Sets the initial frames and tint of sprites after load + */ + initSprite(): void { + if (!this.spriteConfigs) { + return; + } + + this.getSprites().map((sprite, i) => { + if (!this.spriteConfigs[i].isItem) { + sprite.setTexture(this.spriteConfigs[i].spriteKey).setFrame(0); + } + }); + this.getTintSprites().map((tintSprite, i) => { + if (!this.spriteConfigs[i].isItem) { + tintSprite.setTexture(this.spriteConfigs[i].spriteKey).setFrame(0); + } + }); + + this.spriteConfigs.every((config, i) => { + if (!config.tint) { + return true; + } + + const tintSprite = this.getAt(i * 2 + 1); + this.tint(tintSprite, 0, config.tint); + + return true; + }); + } + + /** + * Attempts to animate a given set of {@linkcode Phaser.GameObjects.Sprite} + * @see {@linkcode Phaser.GameObjects.Sprite.play} + * @param sprite {@linkcode Phaser.GameObjects.Sprite} to animate + * @param tintSprite {@linkcode Phaser.GameObjects.Sprite} placed on top of the sprite to add a color tint + * @param animConfig {@linkcode Phaser.Types.Animations.PlayAnimationConfig} to pass to {@linkcode Phaser.GameObjects.Sprite.play} + * @returns true if the sprite was able to be animated + */ + tryPlaySprite(sprite: Phaser.GameObjects.Sprite, tintSprite: Phaser.GameObjects.Sprite, animConfig: Phaser.Types.Animations.PlayAnimationConfig): boolean { + // Show an error in the console if there isn't a texture loaded + if (sprite.texture.key === "__MISSING") { + console.error(`No texture found for '${animConfig.key}'!`); + + return false; + } + // Don't try to play an animation when there isn't one + if (sprite.texture.frameTotal <= 1) { + console.warn(`No animation found for '${animConfig.key}'. Is this intentional?`); + + return false; + } + + sprite.play(animConfig); + tintSprite.play(animConfig); + + return true; + } + + /** + * For sprites with animation and that do not have animation disabled, will begin frame animation + */ + playAnim(): void { + if (!this.spriteConfigs) { + return; + } + + const sprites = this.getSprites(); + const tintSprites = this.getTintSprites(); + this.spriteConfigs.forEach((config, i) => { + if (!config.disableAnimation) { + const trainerAnimConfig: PlayAnimationConfig = { + key: config.spriteKey, + repeat: config?.repeat ? -1 : 0, + startFrame: config?.startFrame ?? 0 + }; + + this.tryPlaySprite(sprites[i], tintSprites[i], trainerAnimConfig); + } + }); + } + + /** + * Returns a Sprite/TintSprite pair + * @param index + */ + getSpriteAtIndex(index: number): Phaser.GameObjects.Sprite[] { + if (!this.spriteConfigs) { + return []; + } + + const ret: Phaser.GameObjects.Sprite[] = []; + ret.push(this.getAt(index * 2)); // Sprite + ret.push(this.getAt(index * 2 + 1)); // Tint Sprite + + return ret; + } + + /** + * Gets all non-tint sprites (these are the "real" unmodified sprites) + */ + getSprites(): Phaser.GameObjects.Sprite[] { + if (!this.spriteConfigs) { + return []; + } + + const ret: Phaser.GameObjects.Sprite[] = []; + this.spriteConfigs.forEach((config, i) => { + ret.push(this.getAt(i * 2)); + }); + return ret; + } + + /** + * Gets all tint sprites (duplicate sprites that have different alpha and fill values) + */ + getTintSprites(): Phaser.GameObjects.Sprite[] { + if (!this.spriteConfigs) { + return []; + } + + const ret: Phaser.GameObjects.Sprite[] = []; + this.spriteConfigs.forEach((config, i) => { + ret.push(this.getAt(i * 2 + 1)); + }); + + return ret; + } + + /** + * Tints a single sprite + * @param sprite + * @param color + * @param alpha + * @param duration + * @param ease + */ + private tint(sprite, color: number, alpha?: number, duration?: integer, ease?: string): void { + // const tintSprites = this.getTintSprites(); + sprite.setTintFill(color); + sprite.setVisible(true); + + if (duration) { + sprite.setAlpha(0); + + this.scene.tweens.add({ + targets: sprite, + alpha: alpha || 1, + duration: duration, + ease: ease || "Linear" + }); + } else { + sprite.setAlpha(alpha); + } + } + + /** + * Tints all sprites + * @param color + * @param alpha + * @param duration + * @param ease + */ + tintAll(color: number, alpha?: number, duration?: integer, ease?: string): void { + const tintSprites = this.getTintSprites(); + tintSprites.map(tintSprite => { + this.tint(tintSprite, color, alpha, duration, ease); + }); + } + + /** + * Untints a single sprite over a duration + * @param sprite + * @param duration + * @param ease + */ + private untint(sprite, duration: integer, ease?: string): void { + if (duration) { + this.scene.tweens.add({ + targets: sprite, + alpha: 0, + duration: duration, + ease: ease || "Linear", + onComplete: () => { + sprite.setVisible(false); + sprite.setAlpha(1); + } + }); + } else { + sprite.setVisible(false); + sprite.setAlpha(1); + } + } + + /** + * Untints all sprites + * @param sprite + * @param duration + * @param ease + */ + untintAll(duration: integer, ease?: string): void { + const tintSprites = this.getTintSprites(); + tintSprites.map(tintSprite => { + this.untint(tintSprite, duration, ease); + }); + } + + /** + * Sets container and all child sprites to visible + * @param value - true for visible, false for hidden + */ + setVisible(value: boolean): this { + this.getSprites().forEach(sprite => { + sprite.setVisible(value); + }); + return super.setVisible(value); + } +} + +/** + * Interface is required so as not to override {@link Phaser.GameObjects.Container.scene} + */ +export default interface MysteryEncounterIntroVisuals { + scene: BattleScene +} diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index c648ff485b76..ea1b7fba3721 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -5,12 +5,12 @@ import { variantData } from "#app/data/variant"; import BattleInfo, { PlayerBattleInfo, EnemyBattleInfo } from "../ui/battle-info"; import Move, { HighCritAttr, HitsTagAttr, applyMoveAttrs, FixedDamageAttr, VariableAtkAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, VariableMoveTypeAttr, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatStagesAttr, SacrificialAttr, VariableMoveCategoryAttr, CounterDamageAttr, StatStageChangeAttr, RechargeAttr, ChargeAttr, IgnoreWeatherTypeDebuffAttr, BypassBurnDamageReductionAttr, SacrificialAttrOnHit, OneHitKOAccuracyAttr, RespectAttackTypeImmunityAttr } from "../data/move"; import { default as PokemonSpecies, PokemonSpeciesForm, SpeciesFormKey, getFusedSpeciesName, getPokemonSpecies, getPokemonSpeciesForm, getStarterValueFriendshipCap, speciesStarters, starterPassiveAbilities } from "../data/pokemon-species"; -import { Constructor } from "#app/utils"; +import { Constructor, isNullOrUndefined, randSeedInt } from "#app/utils"; import * as Utils from "../utils"; import { Type, TypeDamageMultiplier, getTypeDamageMultiplier, getTypeRgb } from "../data/type"; import { getLevelTotalExp } from "../data/exp"; import { Stat, type PermanentStat, type BattleStat, type EffectiveStat, PERMANENT_STATS, BATTLE_STATS, EFFECTIVE_STATS } from "#enums/stat"; -import { DamageMoneyRewardModifier, EnemyDamageBoosterModifier, EnemyDamageReducerModifier, EnemyEndureChanceModifier, EnemyFusionChanceModifier, HiddenAbilityRateBoosterModifier, BaseStatModifier, PokemonFriendshipBoosterModifier, PokemonHeldItemModifier, PokemonNatureWeightModifier, ShinyRateBoosterModifier, SurviveDamageModifier, TempStatStageBoosterModifier, TempCritBoosterModifier, StatBoosterModifier, CritBoosterModifier, TerastallizeModifier } from "../modifier/modifier"; +import { DamageMoneyRewardModifier, EnemyDamageBoosterModifier, EnemyDamageReducerModifier, EnemyEndureChanceModifier, EnemyFusionChanceModifier, HiddenAbilityRateBoosterModifier, BaseStatModifier, PokemonFriendshipBoosterModifier, PokemonHeldItemModifier, PokemonNatureWeightModifier, ShinyRateBoosterModifier, SurviveDamageModifier, TempStatStageBoosterModifier, TempCritBoosterModifier, StatBoosterModifier, CritBoosterModifier, TerastallizeModifier, PokemonBaseStatFlatModifier, PokemonBaseStatTotalModifier, PokemonIncrementingStatModifier } from "../modifier/modifier"; import { PokeballType } from "../data/pokeball"; import { Gender } from "../data/gender"; import { initMoveAnim, loadMoveAnimAssets } from "../data/battle-anims"; @@ -60,6 +60,7 @@ import { ToggleDoublePositionPhase } from "#app/phases/toggle-double-position-ph import { Challenges } from "#enums/challenges"; import { PokemonAnimType } from "#app/enums/pokemon-anim-type"; import { PLAYER_PARTY_MAX_SIZE } from "#app/constants"; +import { MysteryEncounterPokemonData } from "#app/data/mystery-encounters/mystery-encounter-pokemon-data"; export enum FieldPosition { CENTER, @@ -113,6 +114,10 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { public battleData: PokemonBattleData; public battleSummonData: PokemonBattleSummonData; public turnData: PokemonTurnData; + public mysteryEncounterPokemonData: MysteryEncounterPokemonData; + + /** Used by Mystery Encounters to execute pokemon-specific logic (such as stat boosts) at start of battle */ + public mysteryEncounterBattleEffects?: (pokemon: Pokemon) => void; public fieldPosition: FieldPosition; @@ -198,6 +203,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { this.fusionGender = dataSource.fusionGender; this.fusionLuck = dataSource.fusionLuck; this.usedTMs = dataSource.usedTMs ?? []; + this.mysteryEncounterPokemonData = new MysteryEncounterPokemonData(dataSource.mysteryEncounterPokemonData); } else { this.id = Utils.randSeedInt(4294967296); this.ivs = ivs || Utils.getIvsFromId(this.id); @@ -218,6 +224,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { this.variant = this.shiny ? this.generateVariant() : 0; } + this.mysteryEncounterPokemonData = new MysteryEncounterPokemonData(); + if (nature !== undefined) { this.setNature(nature); } else { @@ -319,9 +327,18 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * @returns {boolean} True if pokemon is allowed in battle */ isAllowedInBattle(): boolean { + return !this.isFainted() && this.isAllowed(); + } + + /** + * Check if this pokemon is allowed (no challenge exclusion) + * This is frequently a better alternative to {@link isFainted} + * @returns {boolean} True if pokemon is allowed in battle + */ + isAllowed(): boolean { const challengeAllowed = new Utils.BooleanHolder(true); applyChallenges(this.scene.gameMode, ChallengeType.POKEMON_IN_BATTLE, this, challengeAllowed); - return !this.isFainted() && !this.wildFlee && challengeAllowed.value; + return !this.wildFlee && challengeAllowed.value; } isActive(onField?: boolean): boolean { @@ -532,10 +549,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { if (!ignoreOverride && this.summonData?.speciesForm) { return this.summonData.speciesForm; } - if (!this.species.forms?.length) { - return this.species; + if (this.species.forms && this.species.forms.length > 0) { + return this.species.forms[this.formIndex]; } - return this.species.forms[this.formIndex]; + + return this.species; } getFusionSpeciesForm(ignoreOverride?: boolean): PokemonSpeciesForm { @@ -563,6 +581,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { const formKey = this.getFormKey(); if (formKey.indexOf(SpeciesFormKey.GIGANTAMAX) > -1 || formKey.indexOf(SpeciesFormKey.ETERNAMAX) > -1) { return 1.5; + } else if (this.mysteryEncounterPokemonData.spriteScale > 0) { + return this.mysteryEncounterPokemonData.spriteScale; } return 1; } @@ -915,19 +935,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } // Get and manipulate base stats - const baseStats = this.getSpeciesForm(true).baseStats.slice(); - if (this.isFusion()) { - const fusionBaseStats = this.getFusionSpeciesForm(true).baseStats; - for (const s of PERMANENT_STATS) { - baseStats[s] = Math.ceil((baseStats[s] + fusionBaseStats[s]) / 2); - } - } else if (this.scene.gameMode.isSplicedOnly) { - for (const s of PERMANENT_STATS) { - baseStats[s] = Math.ceil(baseStats[s] / 2); - } - } - this.scene.applyModifiers(BaseStatModifier, this.isPlayer(), this, baseStats); - + const baseStats = this.calculateBaseStats(); // Using base stats, calculate and store stats one by one for (const s of PERMANENT_STATS) { let value = Math.floor(((2 * baseStats[s] + this.ivs[s]) * this.level) * 0.01); @@ -955,6 +963,29 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { this.setStat(s, value); } + this.scene.applyModifier(PokemonIncrementingStatModifier, this.isPlayer(), this, this.stats); + } + + calculateBaseStats(): number[] { + const baseStats = this.getSpeciesForm(true).baseStats.slice(0); + // Shuckle Juice + this.scene.applyModifiers(PokemonBaseStatTotalModifier, this.isPlayer(), this, baseStats); + // Old Gateau + this.scene.applyModifiers(PokemonBaseStatFlatModifier, this.isPlayer(), this, baseStats); + if (this.isFusion()) { + const fusionBaseStats = this.getFusionSpeciesForm(true).baseStats; + for (const s of PERMANENT_STATS) { + baseStats[s] = Math.ceil((baseStats[s] + fusionBaseStats[s]) / 2); + } + } else if (this.scene.gameMode.isSplicedOnly) { + for (const s of PERMANENT_STATS) { + baseStats[s] = Math.ceil(baseStats[s] / 2); + } + } + // Vitamins + this.scene.applyModifiers(BaseStatModifier, this.isPlayer(), this, baseStats); + + return baseStats; } getNature(): Nature { @@ -1122,7 +1153,10 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } if (!types.length || !includeTeraType) { - if (!ignoreOverride && this.summonData?.types && this.summonData.types.length !== 0) { + if (this.mysteryEncounterPokemonData.types && this.mysteryEncounterPokemonData.types.length > 0) { + // "Permanent" override for a Pokemon's normal types, currently only used by Mystery Encounters + this.mysteryEncounterPokemonData.types.forEach(t => types.push(t)); + } else if (!ignoreOverride && this.summonData?.types && this.summonData.types.length > 0) { this.summonData.types.forEach(t => types.push(t)); } else { const speciesForm = this.getSpeciesForm(ignoreOverride); @@ -1183,6 +1217,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { if (Overrides.OPP_ABILITY_OVERRIDE && !this.isPlayer()) { return allAbilities[Overrides.OPP_ABILITY_OVERRIDE]; } + if (!isNullOrUndefined(this.mysteryEncounterPokemonData.ability) && this.mysteryEncounterPokemonData.ability !== -1) { + return allAbilities[this.mysteryEncounterPokemonData.ability]; + } if (this.isFusion()) { return allAbilities[this.getFusionSpeciesForm(ignoreOverride).getAbility(this.fusionAbilityIndex)]; } @@ -1207,6 +1244,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { if (Overrides.OPP_PASSIVE_ABILITY_OVERRIDE && !this.isPlayer()) { return allAbilities[Overrides.OPP_PASSIVE_ABILITY_OVERRIDE]; } + if (!isNullOrUndefined(this.mysteryEncounterPokemonData.passive) && this.mysteryEncounterPokemonData.passive !== -1) { + return allAbilities[this.mysteryEncounterPokemonData.passive]; + } let starterSpeciesId = this.species.speciesId; while (pokemonPrevolutions.hasOwnProperty(starterSpeciesId)) { @@ -1697,6 +1737,15 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } + /** + * Get a list of all egg moves + * + * @returns list of egg moves + */ + getEggMoves() : Moves[] { + return speciesEggMoves[this.species.speciesId]; + } + setMove(moveIndex: integer, moveId: Moves): void { const move = moveId ? new PokemonMove(moveId) : null; this.moveset[moveIndex] = move; @@ -1751,6 +1800,42 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return this.shiny; } + /** + * Function that tries to set a Pokemon shiny based on seed. + * For manual use only, usually to roll a Pokemon's shiny chance a second time. + * + * The base shiny odds are {@linkcode baseShinyChance} / 65536 + * @param thresholdOverride number that is divided by 2^16 (65536) to get the shiny chance, overrides {@linkcode shinyThreshold} if set (bypassing shiny rate modifiers such as Shiny Charm) + * @param applyModifiersToOverride If {@linkcode thresholdOverride} is set and this is true, will apply Shiny Charm and event modifiers to {@linkcode thresholdOverride} + * @returns true if the Pokemon has been set as a shiny, false otherwise + */ + trySetShinySeed(thresholdOverride?: integer, applyModifiersToOverride?: boolean): boolean { + /** `64/65536 -> 1/1024` */ + const baseShinyChance = 64; + const shinyThreshold = new Utils.IntegerHolder(baseShinyChance); + if (thresholdOverride === undefined || applyModifiersToOverride) { + if (thresholdOverride !== undefined && applyModifiersToOverride) { + shinyThreshold.value = thresholdOverride; + } + if (this.scene.eventManager.isEventActive()) { + shinyThreshold.value *= this.scene.eventManager.getShinyMultiplier(); + } + if (!this.hasTrainer()) { + this.scene.applyModifiers(ShinyRateBoosterModifier, true, shinyThreshold); + } + } else { + shinyThreshold.value = thresholdOverride; + } + + this.shiny = randSeedInt(65536) < shinyThreshold.value; + + if (this.shiny) { + this.initShinySparkle(); + } + + return this.shiny; + } + /** * Generates a variant * Has a 10% of returning 2 (epic variant) @@ -2034,7 +2119,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { hideInfo(): Promise { return new Promise(resolve => { - if (this.battleInfo.visible) { + if (this.battleInfo && this.battleInfo.visible) { this.scene.tweens.add({ targets: [ this.battleInfo, this.battleInfo.expMaskRect ], x: this.isPlayer() ? "+=150" : `-=${!this.isBoss() ? 150 : 246}`, @@ -4626,6 +4711,7 @@ export class PokemonSummonData { public speciesForm: PokemonSpeciesForm | null; public fusionSpeciesForm: PokemonSpeciesForm; public ability: Abilities = Abilities.NONE; + public passiveAbility: Abilities = Abilities.NONE; public gender: Gender; public fusionGender: Gender; public stats: number[] = [ 0, 0, 0, 0, 0, 0 ]; diff --git a/src/field/trainer.ts b/src/field/trainer.ts index 326ef0edefbb..1973d7216a6a 100644 --- a/src/field/trainer.ts +++ b/src/field/trainer.ts @@ -35,11 +35,16 @@ export default class Trainer extends Phaser.GameObjects.Container { public name: string; public partnerName: string; - constructor(scene: BattleScene, trainerType: TrainerType, variant: TrainerVariant, partyTemplateIndex?: integer, name?: string, partnerName?: string) { + constructor(scene: BattleScene, trainerType: TrainerType, variant: TrainerVariant, partyTemplateIndex?: integer, name?: string, partnerName?: string, trainerConfigOverride?: TrainerConfig) { super(scene, -72, 80); this.config = trainerConfigs.hasOwnProperty(trainerType) ? trainerConfigs[trainerType] : trainerConfigs[TrainerType.ACE_TRAINER]; + + if (trainerConfigOverride) { + this.config = trainerConfigOverride; + } + this.variant = variant; this.partyTemplateIndex = Math.min(partyTemplateIndex !== undefined ? partyTemplateIndex : Utils.randSeedWeightedItem(this.config.partyTemplates.map((_, i) => i)), this.config.partyTemplates.length - 1); diff --git a/src/game-mode.ts b/src/game-mode.ts index f5dadad6f1b4..a2d61d7cffff 100644 --- a/src/game-mode.ts +++ b/src/game-mode.ts @@ -29,8 +29,13 @@ interface GameModeConfig { hasRandomBosses?: boolean; isSplicedOnly?: boolean; isChallenge?: boolean; + hasMysteryEncounters?: boolean; } +// Describes min and max waves for MEs in specific game modes +export const CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES: [number, number] = [10, 180]; +export const CHALLENGE_MODE_MYSTERY_ENCOUNTER_WAVES: [number, number] = [10, 180]; + export class GameMode implements GameModeConfig { public modeId: GameModes; public isClassic: boolean; @@ -45,6 +50,9 @@ export class GameMode implements GameModeConfig { public isChallenge: boolean; public challenges: Challenge[]; public battleConfig: FixedBattleConfigs; + public hasMysteryEncounters: boolean; + public minMysteryEncounterWave: number; + public maxMysteryEncounterWave: number; constructor(modeId: GameModes, config: GameModeConfig, battleConfig?: FixedBattleConfigs) { this.modeId = modeId; @@ -317,6 +325,20 @@ export class GameMode implements GameModeConfig { } } + /** + * Returns the wave range where MEs can spawn for the game mode [min, max] + */ + getMysteryEncounterLegalWaves(): [number, number] { + switch (this.modeId) { + default: + return [0, 0]; + case GameModes.CLASSIC: + return CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES; + case GameModes.CHALLENGE: + return CHALLENGE_MODE_MYSTERY_ENCOUNTER_WAVES; + } + } + static getModeName(modeId: GameModes): string { switch (modeId) { case GameModes.CLASSIC: @@ -336,7 +358,7 @@ export class GameMode implements GameModeConfig { export function getGameMode(gameMode: GameModes): GameMode { switch (gameMode) { case GameModes.CLASSIC: - return new GameMode(GameModes.CLASSIC, { isClassic: true, hasTrainers: true }, classicFixedBattles); + return new GameMode(GameModes.CLASSIC, { isClassic: true, hasTrainers: true, hasMysteryEncounters: true }, classicFixedBattles); case GameModes.ENDLESS: return new GameMode(GameModes.ENDLESS, { isEndless: true, hasShortBiomes: true, hasRandomBosses: true }); case GameModes.SPLICED_ENDLESS: @@ -344,6 +366,6 @@ export function getGameMode(gameMode: GameModes): GameMode { case GameModes.DAILY: return new GameMode(GameModes.DAILY, { isDaily: true, hasTrainers: true, hasNoShop: true }); case GameModes.CHALLENGE: - return new GameMode(GameModes.CHALLENGE, { isClassic: true, hasTrainers: true, isChallenge: true }, classicFixedBattles); + return new GameMode(GameModes.CHALLENGE, { isClassic: true, hasTrainers: true, isChallenge: true, hasMysteryEncounters: true }, classicFixedBattles); } } diff --git a/src/interfaces/held-modifier-config.ts b/src/interfaces/held-modifier-config.ts new file mode 100644 index 000000000000..2285babdbfdb --- /dev/null +++ b/src/interfaces/held-modifier-config.ts @@ -0,0 +1,8 @@ +import { PokemonHeldItemModifierType } from "#app/modifier/modifier-type"; +import { PokemonHeldItemModifier } from "#app/modifier/modifier"; + +export default interface HeldModifierConfig { + modifier: PokemonHeldItemModifierType | PokemonHeldItemModifier; + stackCount?: number; + isTransferable?: boolean; +} diff --git a/src/loading-scene.ts b/src/loading-scene.ts index 6de441fb162f..d0818aa1e194 100644 --- a/src/loading-scene.ts +++ b/src/loading-scene.ts @@ -22,6 +22,7 @@ import { initStatsKeys } from "./ui/game-stats-ui-handler"; import { initVouchers } from "./system/voucher"; import { Biome } from "#enums/biome"; import { TrainerType } from "#enums/trainer-type"; +import {initMysteryEncounters} from "#app/data/mystery-encounters/mystery-encounters"; export class LoadingScene extends SceneBase { public static readonly KEY = "loading"; @@ -286,6 +287,9 @@ export class LoadingScene extends SceneBase { } } + // Load Mystery Encounter dex progress icon + this.loadImage("encounter_radar", "mystery-encounters"); + this.loadAtlas("dualshock", "inputs"); this.loadAtlas("xbox", "inputs"); this.loadAtlas("keyboard", "inputs"); @@ -362,6 +366,7 @@ export class LoadingScene extends SceneBase { initMoves(); initAbilities(); initChallenges(); + initMysteryEncounters(); } loadLoadingScreen() { diff --git a/src/locales/en/battle.json b/src/locales/en/battle.json index 217c77422d19..d04dd3eac1f7 100644 --- a/src/locales/en/battle.json +++ b/src/locales/en/battle.json @@ -14,6 +14,10 @@ "moneyWon": "You got\n₽{{moneyAmount}} for winning!", "moneyPickedUp": "You picked up ₽{{moneyAmount}}!", "pokemonCaught": "{{pokemonName}} was caught!", + "pokemonObtained": "You got {{pokemonName}}!", + "pokemonBrokeFree": "Oh no!\nThe Pokémon broke free!", + "pokemonFled": "The wild {{pokemonName}} fled!", + "playerFled": "You fled from the {{pokemonName}}!", "addedAsAStarter": "{{pokemonName}} has been\nadded as a starter!", "partyFull": "Your party is full.\nRelease a Pokémon to make room for {{pokemonName}}?", "pokemon": "Pokémon", @@ -52,6 +56,7 @@ "noPokeballTrainer": "You can't catch\nanother trainer's Pokémon!", "noPokeballMulti": "You can only throw a Poké Ball\nwhen there is one Pokémon remaining!", "noPokeballStrong": "The target Pokémon is too strong to be caught!\nYou need to weaken it first!", + "noPokeballMysteryEncounter": "You aren't able to\ncatch this Pokémon!", "noEscapeForce": "An unseen force\nprevents escape.", "noEscapeTrainer": "You can't run\nfrom a trainer battle!", "noEscapePokemon": "{{pokemonName}}'s {{moveName}}\nprevents {{escapeVerb}}!", @@ -99,5 +104,6 @@ "unlockedSomething": "{{unlockedThing}}\nhas been unlocked.", "congratulations": "Congratulations!", "beatModeFirstTime": "{{speciesName}} beat {{gameMode}} Mode for the first time!\nYou received {{newModifier}}!", - "ppReduced": "It reduced the PP of {{targetName}}'s\n{{moveName}} by {{reduction}}!" + "ppReduced": "It reduced the PP of {{targetName}}'s\n{{moveName}} by {{reduction}}!", + "mysteryEncounterAppeared": "What's this?" } \ No newline at end of file diff --git a/src/locales/en/bgm-name.json b/src/locales/en/bgm-name.json index 8838942c8a64..c2babed4dffb 100644 --- a/src/locales/en/bgm-name.json +++ b/src/locales/en/bgm-name.json @@ -146,5 +146,10 @@ "encounter_youngster": "BW Trainers' Eyes Meet (Youngster)", "heal": "BW Pokémon Heal", "menu": "PMD EoS Welcome to the World of Pokémon!", - "title": "PMD EoS Top Menu Theme" + "title": "PMD EoS Top Menu Theme", + + "mystery_encounter_weird_dream": "PMD EoS Temporal Spire", + "mystery_encounter_fun_and_games": "PMD EoS Guildmaster Wigglytuff", + "mystery_encounter_gen_5_gts": "BW GTS", + "mystery_encounter_gen_6_gts": "XY GTS" } diff --git a/src/locales/en/config.ts b/src/locales/en/config.ts index 024f7f101083..f83fec5be269 100644 --- a/src/locales/en/config.ts +++ b/src/locales/en/config.ts @@ -53,7 +53,48 @@ import terrain from "./terrain.json"; import modifierSelectUiHandler from "./modifier-select-ui-handler.json"; import moveTriggers from "./move-trigger.json"; import runHistory from "./run-history.json"; +import mysteryEncounterMessages from "./mystery-encounter-messages.json"; +import lostAtSea from "./mystery-encounters/lost-at-sea-dialogue.json"; +import mysteriousChest from "#app/locales/en/mystery-encounters/mysterious-chest-dialogue.json"; +import mysteriousChallengers from "#app/locales/en/mystery-encounters/mysterious-challengers-dialogue.json"; +import darkDeal from "#app/locales/en/mystery-encounters/dark-deal-dialogue.json"; +import departmentStoreSale from "#app/locales/en/mystery-encounters/department-store-sale-dialogue.json"; +import fieldTrip from "#app/locales/en/mystery-encounters/field-trip-dialogue.json"; +import fieryFallout from "#app/locales/en/mystery-encounters/fiery-fallout-dialogue.json"; +import fightOrFlight from "#app/locales/en/mystery-encounters/fight-or-flight-dialogue.json"; +import safariZone from "#app/locales/en/mystery-encounters/safari-zone-dialogue.json"; +import shadyVitaminDealer from "#app/locales/en/mystery-encounters/shady-vitamin-dealer-dialogue.json"; +import slumberingSnorlax from "#app/locales/en/mystery-encounters/slumbering-snorlax-dialogue.json"; +import trainingSession from "#app/locales/en/mystery-encounters/training-session-dialogue.json"; +import theStrongStuff from "#app/locales/en/mystery-encounters/the-strong-stuff-dialogue.json"; +import pokemonSalesman from "#app/locales/en/mystery-encounters/the-pokemon-salesman-dialogue.json"; +import offerYouCantRefuse from "#app/locales/en/mystery-encounters/an-offer-you-cant-refuse-dialogue.json"; +import delibirdy from "#app/locales/en/mystery-encounters/delibirdy-dialogue.json"; +import absoluteAvarice from "#app/locales/en/mystery-encounters/absolute-avarice-dialogue.json"; +import aTrainersTest from "#app/locales/en/mystery-encounters/a-trainers-test-dialogue.json"; +import trashToTreasure from "#app/locales/en/mystery-encounters/trash-to-treasure-dialogue.json"; +import berriesAbound from "#app/locales/en/mystery-encounters/berries-abound-dialogue.json"; +import clowningAround from "#app/locales/en/mystery-encounters/clowning-around-dialogue.json"; +import partTimer from "#app/locales/en/mystery-encounters/part-timer-dialogue.json"; +import dancingLessons from "#app/locales/en/mystery-encounters/dancing-lessons-dialogue.json"; +import weirdDream from "#app/locales/en/mystery-encounters/weird-dream-dialogue.json"; +import theWinstrateChallenge from "#app/locales/en/mystery-encounters/the-winstrate-challenge-dialogue.json"; +import teleportingHijinks from "#app/locales/en/mystery-encounters/teleporting-hijinks-dialogue.json"; +import bugTypeSuperfan from "#app/locales/en/mystery-encounters/bug-type-superfan-dialogue.json"; +import funAndGames from "#app/locales/en/mystery-encounters/fun-and-games-dialogue.json"; +import uncommonBreed from "#app/locales/en/mystery-encounters/uncommon-breed-dialogue.json"; +import globalTradeSystem from "#app/locales/en/mystery-encounters/global-trade-system-dialogue.json"; +/** + * Dialogue/Text token injection patterns that can be used: + * - `$` will be treated as a new line for Message and Dialogue strings. + * - `@d{}` will add a time delay to text animation for Message and Dialogue strings. + * - `@s{}` will play a specified sound effect for Message and Dialogue strings. + * - `@f{}` will fade the screen to black for the given duration, then fade back in for Message and Dialogue strings. + * - `{{}}` (MYSTERY ENCOUNTERS ONLY) will auto-inject the matching dialogue token value that is stored in {@link IMysteryEncounter.dialogueTokens}. + * - (see [i18next interpolations](https://www.i18next.com/translation-function/interpolation)) for more details. + * - `@[]{}` (STATIC TEXT ONLY, NOT USEABLE WITH {@link UI.showText()} OR {@link UI.showDialogue()}) will auto-color the given text to a specified {@link TextStyle} (e.g. `TextStyle.SUMMARY_GREEN`). + */ export const enConfig = { ability, abilityTriggers, @@ -109,5 +150,40 @@ export const enConfig = { partyUiHandler, modifierSelectUiHandler, moveTriggers, - runHistory + runHistory, + mysteryEncounter: { + // DO NOT REMOVE + "unit_test_dialogue": "{{test}}{{test}} {{test{{test}}}} {{test1}} {{test\}} {{test\\}} {{test\\\}} {test}}", + mysteriousChallengers, + mysteriousChest, + darkDeal, + fightOrFlight, + slumberingSnorlax, + trainingSession, + departmentStoreSale, + shadyVitaminDealer, + fieldTrip, + safariZone, + lostAtSea, + fieryFallout, + theStrongStuff, + pokemonSalesman, + offerYouCantRefuse, + delibirdy, + absoluteAvarice, + aTrainersTest, + trashToTreasure, + berriesAbound, + clowningAround, + partTimer, + dancingLessons, + weirdDream, + theWinstrateChallenge, + teleportingHijinks, + bugTypeSuperfan, + funAndGames, + uncommonBreed, + globalTradeSystem + }, + mysteryEncounterMessages }; diff --git a/src/locales/en/dialogue.json b/src/locales/en/dialogue.json index 5565d2258c29..39a4238355c4 100644 --- a/src/locales/en/dialogue.json +++ b/src/locales/en/dialogue.json @@ -985,6 +985,116 @@ "1": "I suppose it must seem that I am doing something terrible. I don't expect you to understand.\n$But I must provide the Galar region with limitless energy to ensure everlasting prosperity." } }, + "stat_trainer_buck": { + "encounter": { + "1": "...I'm telling you right now. I'm seriously tough. Act surprised!", + "2": "I can feel my Pokémon shivering inside their Pokéballs!" + }, + "victory": { + "1": "Heeheehee!\nSo hot, you!", + "2": "Heeheehee!\nSo hot, you!" + }, + "defeat": { + "1": "Whoa! You're all out of gas, I guess.", + "2": "Whoa! You're all out of gas, I guess." + } + }, + "stat_trainer_cheryl": { + "encounter": { + "1": "My Pokémon have been itching for a battle.", + "2": "I should warn you, my Pokémon can be quite rambunctious." + }, + "victory": { + "1": "Striking the right balance of offense and defense... It's not easy to do.", + "2": "Striking the right balance of offense and defense... It's not easy to do." + }, + "defeat": { + "1": "Do your Pokémon need any healing?", + "2": "Do your Pokémon need any healing?" + } + }, + "stat_trainer_marley": { + "encounter": { + "1": "... OK.\nI'll do my best.", + "2": "... OK.\nI... won't lose...!" + }, + "victory": { + "1": "... Awww.", + "2": "... Awww." + }, + "defeat": { + "1": "... Goodbye.", + "2": "... Goodbye." + } + }, + "stat_trainer_mira": { + "encounter": { + "1": "You will be shocked by Mira!", + "2": "Mira will show you that Mira doesn't get lost anymore!" + }, + "victory": { + "1": "Mira wonders if she can get very far in this land.", + "2": "Mira wonders if she can get very far in this land." + }, + "defeat": { + "1": "Mira knew she would win!", + "2": "Mira knew she would win!" + } + }, + "stat_trainer_riley": { + "encounter": { + "1": "Battling is our way of greeting!", + "2": "We're pulling out all the stops to put your Pokémon down." + }, + "victory": { + "1": "At times we battle, and sometimes we team up...$It's great how Trainers can interact.", + "2": "At times we battle, and sometimes we team up...$It's great how Trainers can interact." + }, + "defeat": { + "1": "You put up quite the display.\nBetter luck next time.", + "2": "You put up quite the display.\nBetter luck next time." + } + }, + "winstrates_victor": { + "encounter": { + "1": "That's the spirit! I like you!" + }, + "victory": { + "1": "A-ha! You're stronger than I thought!" + } + }, + "winstrates_victoria": { + "encounter": { + "1": "My goodness! Aren't you young?$You must be quite the trainer to beat my husband, though.$Now I suppose it's my turn to battle!" + }, + "victory": { + "1": "Uwah! Just how strong are you?!" + } + }, + "winstrates_vivi": { + "encounter": { + "1": "You're stronger than Mom? Wow!$But I'm strong, too!\nReally! Honestly!" + }, + "victory": { + "1": "Huh? Did I really lose?\nSnivel... Grandmaaa!" + } + }, + "winstrates_vicky": { + "encounter": { + "1": "How dare you make my precious\ngranddaughter cry!$I see I need to teach you a lesson.\nPrepare to feel the sting of defeat!" + }, + "victory": { + "1": "Whoa! So strong!\nMy granddaughter wasn't lying." + } + }, + "winstrates_vito": { + "encounter": { + "1": "I trained together with my whole family,\nevery one of us!$I'm not losing to anyone!" + }, + "victory": { + "1": "I was better than everyone in my family.\nI've never lost before..." + } + }, "brock": { "encounter": { "1": "My expertise on Rock-type Pokémon will take you down! Come on!", diff --git a/src/locales/en/egg.json b/src/locales/en/egg.json index 8a5e061d883d..d6b352fca1e2 100644 --- a/src/locales/en/egg.json +++ b/src/locales/en/egg.json @@ -11,6 +11,7 @@ "gachaTypeLegendary": "Legendary Rate Up", "gachaTypeMove": "Rare Egg Move Rate Up", "gachaTypeShiny": "Shiny Rate Up", + "eventType": "Mystery Event", "selectMachine": "Select a machine.", "notEnoughVouchers": "You don't have enough vouchers!", "tooManyEggs": "You have too many eggs!", diff --git a/src/locales/en/modifier-select-ui-handler.json b/src/locales/en/modifier-select-ui-handler.json index bc49ce259310..15c930fb65e0 100644 --- a/src/locales/en/modifier-select-ui-handler.json +++ b/src/locales/en/modifier-select-ui-handler.json @@ -8,5 +8,7 @@ "lockRaritiesDesc": "Lock item rarities on reroll (affects reroll cost).", "checkTeamDesc": "Check your team or use a form changing item.", "rerollCost": "₽{{formattedMoney}}", - "itemCost": "₽{{formattedMoney}}" + "itemCost": "₽{{formattedMoney}}", + "continueNextWaveButton": "Continue", + "continueNextWaveDescription": "Continue to the next wave." } \ No newline at end of file diff --git a/src/locales/en/modifier-type.json b/src/locales/en/modifier-type.json index babad57b81b7..b57073325dc6 100644 --- a/src/locales/en/modifier-type.json +++ b/src/locales/en/modifier-type.json @@ -68,6 +68,20 @@ "BaseStatBoosterModifierType": { "description": "Increases the holder's base {{stat}} by 10%. The higher your IVs, the higher the stack limit." }, + "PokemonBaseStatTotalModifierType": { + "name": "Shuckle Juice", + "description": "{{increaseDecrease}} all of the holder's base stats by {{statValue}}. You were {{blessCurse}} by the Shuckle.", + "extra": { + "increase": "Increases", + "decrease": "Decreases", + "blessed": "blessed", + "cursed": "cursed" + } + }, + "PokemonBaseStatFlatModifierType": { + "name": "Old Gateau", + "description": "Increases the holder's {{stats}} base stats by {{statValue}}. Found after a strange dream." + }, "AllPokemonFullHpRestoreModifierType": { "description": "Restores 100% HP for all Pokémon." }, @@ -247,7 +261,13 @@ "ENEMY_ATTACK_BURN_CHANCE": { "name": "Burn Token" }, "ENEMY_STATUS_EFFECT_HEAL_CHANCE": { "name": "Full Heal Token", "description": "Adds a 2.5% chance every turn to heal a status condition." }, "ENEMY_ENDURE_CHANCE": { "name": "Endure Token" }, - "ENEMY_FUSED_CHANCE": { "name": "Fusion Token", "description": "Adds a 1% chance that a wild Pokémon will be a fusion." } + "ENEMY_FUSED_CHANCE": { "name": "Fusion Token", "description": "Adds a 1% chance that a wild Pokémon will be a fusion." }, + + "MYSTERY_ENCOUNTER_SHUCKLE_JUICE": { "name": "Shuckle Juice" }, + "MYSTERY_ENCOUNTER_BLACK_SLUDGE": { "name": "Black Sludge", "description": "The stench is so powerful that shops will only sell you items at a steep cost increase." }, + "MYSTERY_ENCOUNTER_MACHO_BRACE": { "name": "Macho Brace", "description": "Defeating a Pokémon grants the holder a Macho Brace stack. Each stack slightly boosts stats, with an extra bonus at max stacks." }, + "MYSTERY_ENCOUNTER_OLD_GATEAU": { "name": "Old Gateau", "description": "Increases the holder's {{stats}} stats by {{statValue}}." }, + "MYSTERY_ENCOUNTER_GOLDEN_BUG_NET": { "name": "Golden Bug Net", "description": "Imbues the owner with luck to find Bug Type Pokémon more often. Has a strange heft to it." } }, "SpeciesBoosterItem": { "LIGHT_BALL": { "name": "Light Ball", "description": "It's a mysterious orb that boosts Pikachu's Attack and Sp. Atk stats." }, diff --git a/src/locales/en/mystery-encounter-messages.json b/src/locales/en/mystery-encounter-messages.json new file mode 100644 index 000000000000..3b81c8e46f0a --- /dev/null +++ b/src/locales/en/mystery-encounter-messages.json @@ -0,0 +1,7 @@ +{ + "paid_money": "You paid ₽{{amount, number}}.", + "receive_money": "You received ₽{{amount, number}}!", + "affects_pokedex": "Affects Pokédex Data", + "cancel_option": "Return to encounter option select.", + "view_party_button": "View Party" +} diff --git a/src/locales/en/mystery-encounters/a-trainers-test-dialogue.json b/src/locales/en/mystery-encounters/a-trainers-test-dialogue.json new file mode 100644 index 000000000000..c96c0d5f3277 --- /dev/null +++ b/src/locales/en/mystery-encounters/a-trainers-test-dialogue.json @@ -0,0 +1,47 @@ +{ + "intro": "An extremely strong trainer approaches you...", + "buck": { + "intro_dialogue": "Yo, trainer! My name's Buck.$I have a super awesome proposal\nfor a strong trainer such as yourself!$I'm carrying two rare Pokémon Eggs with me,\nbut I'd like someone else to care for one.$If you can prove your strength as a trainer to me,\nI'll give you the rarer egg!", + "accept": "Whoooo, I'm getting fired up!", + "decline": "Darn, it looks like your\nteam isn't in peak condition.$Here, let me help with that." + }, + "cheryl": { + "intro_dialogue": "Hello, my name's Cheryl.$I have a particularly interesting request,\nfor a strong trainer such as yourself.$I'm carrying two rare Pokémon Eggs with me,\nbut I'd like someone else to care for one.$If you can prove your strength as a trainer to me,\nI'll give you the rarer Egg!", + "accept": "I hope you're ready!", + "decline": "I understand, it looks like your team\nisn't in the best condition at the moment.$Here, let me help with that." + }, + "marley": { + "intro_dialogue": "...@d{64} I'm Marley.$I have an offer for you...$I'm carrying two Pokémon Eggs with me,\nbut I'd like someone else to care for one.$If you're stronger than me,\nI'll give you the rarer Egg.", + "accept": "... I see.", + "decline": "... I see.$Your Pokémon look hurt...\nLet me help." + }, + "mira": { + "intro_dialogue": "Hi! I'm Mira!$Mira has a request\nfor a strong trainer like you!$Mira has two rare Pokémon Eggs,\nbut Mira wants someone else to take one!$If you show Mira that you're strong,\nMira will give you the rarer Egg!", + "accept": "You'll battle Mira?\nYay!", + "decline": "Aww, no battle?\nThat's okay!$Here, Mira will heal your team!" + }, + "riley": { + "intro_dialogue": "I'm Riley.$I have an odd proposal\nfor a strong trainer such as yourself.$I'm carrying two rare Pokémon Eggs with me,\nbut I'd like to give one to another trainer.$If you can prove your strength to me,\nI'll give you the rarer Egg!", + "accept": "That look you have...\nLet's do this.", + "decline": "I understand, your team looks beat up.$Here, let me help with that." + }, + "title": "A Trainer's Test", + "description": "It seems this trainer is willing to give you an Egg regardless of your decision. However, if you can manage to defeat this strong trainer, you'll receive a much rarer Egg.", + "query": "What will you do?", + "option": { + "1": { + "label": "Accept the Challenge", + "tooltip": "(-) Tough Battle\n(+) Gain a @[TOOLTIP_TITLE]{Very Rare Egg}" + }, + "2": { + "label": "Refuse the Challenge", + "tooltip": "(+) Full Heal Party\n(+) Gain an @[TOOLTIP_TITLE]{Egg}" + } + }, + "eggTypes": { + "rare": "a Rare Egg", + "epic": "an Epic Egg", + "legendary": "a Legendary Egg" + }, + "outro": "{{statTrainerName}} gave you {{eggType}}!" +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/absolute-avarice-dialogue.json b/src/locales/en/mystery-encounters/absolute-avarice-dialogue.json new file mode 100644 index 000000000000..1d675d936609 --- /dev/null +++ b/src/locales/en/mystery-encounters/absolute-avarice-dialogue.json @@ -0,0 +1,25 @@ +{ + "intro": "A {{greedentName}} ambushes you\nand steals your party's berries!", + "title": "Absolute Avarice", + "description": "The {{greedentName}} has caught you totally off guard now all your berries are gone!\n\nThe {{greedentName}} looks like it's about to eat them when it pauses to look at you, interested.", + "query": "What will you do?", + "option": { + "1": { + "label": "Battle It", + "tooltip": "(-) Tough Battle\n(+) Rewards from its Berry Hoard", + "selected": "The {{greedentName}} stuffs its cheeks\nand prepares for battle!", + "boss_enraged": "{{greedentName}}'s fierce love for food has it incensed!", + "food_stash": "It looks like the {{greedentName}} was guarding an enormous stash of food!$@s{item_fanfare}Each Pokémon in your party gains a {{foodReward}}!" + }, + "2": { + "label": "Reason with It", + "tooltip": "(+) Regain Some Lost Berries", + "selected": "Your pleading strikes a chord with the {{greedentName}}.$It doesn't give all your berries back, but still tosses a few in your direction." + }, + "3": { + "label": "Let It Have the Food", + "tooltip": "(-) Lose All Berries\n(?) The {{greedentName}} Will Like You", + "selected": "The {{greedentName}} devours the entire\nstash of berries in a flash!$Patting its stomach,\nit looks at you appreciatively.$Perhaps you could feed it\nmore berries on your adventure...$@s{level_up_fanfare}The {{greedentName}} wants to join your party!" + } + } +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/an-offer-you-cant-refuse-dialogue.json b/src/locales/en/mystery-encounters/an-offer-you-cant-refuse-dialogue.json new file mode 100644 index 000000000000..6dd54d302abe --- /dev/null +++ b/src/locales/en/mystery-encounters/an-offer-you-cant-refuse-dialogue.json @@ -0,0 +1,26 @@ +{ + "intro": "You're stopped by a rich looking boy.", + "speaker": "Rich Boy", + "intro_dialogue": "Good day to you.$I can't help but notice that your\n{{strongestPokemon}} looks positively divine!$I've always wanted to have a pet like that!$I'd pay you handsomely,\nand also give you this old bauble!", + "title": "An Offer You Can't Refuse", + "description": "You're being offered a @[TOOLTIP_TITLE]{Shiny Charm} and {{price, money}} for your {{strongestPokemon}}!\n\nIt's an extremely good deal, but can you really bear to part with such a strong team member?", + "query": "What will you do?", + "option": { + "1": { + "label": "Accept the Deal", + "tooltip": "(-) Lose {{strongestPokemon}}\n(+) Gain a @[TOOLTIP_TITLE]{Shiny Charm}\n(+) Gain {{price, money}}", + "selected": "Wonderful!@d{32} Come along, {{strongestPokemon}}!$It's time to show you off to everyone at the yacht club!$They'll be so jealous!" + }, + "2": { + "label": "Extort the Kid", + "tooltip": "(+) {{option2PrimaryName}} uses {{moveOrAbility}}\n(+) Gain {{price, money}}", + "tooltip_disabled": "Your Pokémon need to have certain moves or abilities to choose this", + "selected": "My word, we're being robbed, {{liepardName}}!$You'll be hearing from my lawyers for this!" + }, + "3": { + "label": "Leave", + "tooltip": "(-) No Rewards", + "selected": "What a rotten day...$Ah, well. Let's return to the yacht club then, {{liepardName}}." + } + } +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/berries-abound-dialogue.json b/src/locales/en/mystery-encounters/berries-abound-dialogue.json new file mode 100644 index 000000000000..26eae2c6b880 --- /dev/null +++ b/src/locales/en/mystery-encounters/berries-abound-dialogue.json @@ -0,0 +1,26 @@ +{ + "intro": "There's a huge berry bush\nnear that Pokémon!", + "title": "Berries Abound", + "description": "It looks like there's a strong Pokémon guarding a berry bush. Battling is the straightforward approach, but it looks strong. Perhaps a fast Pokémon could grab some berries without getting caught?", + "query": "What will you do?", + "berries": "Berries!", + "option": { + "1": { + "label": "Battle the Pokémon", + "tooltip": "(-) Hard Battle\n(+) Gain Berries", + "selected": "You approach the\nPokémon without fear." + }, + "2": { + "label": "Race to the Bush", + "tooltip": "(-) {{fastestPokemon}} Uses its Speed\n(+) Gain Berries", + "selected": "Your {{fastestPokemon}} races for the berry bush!$It manages to nab {{numBerries}} before the {{enemyPokemon}} can react!$You quickly retreat with your newfound prize.", + "selected_bad": "Your {{fastestPokemon}} races for the berry bush!$Oh no! The {{enemyPokemon}} was faster and blocked off the approach!", + "boss_enraged": "The opposing {{enemyPokemon}} has become enraged!" + }, + "3": { + "label": "Leave", + "tooltip": "(-) No Rewards", + "selected": "You leave the strong Pokémon\nwith its prize and continue on." + } + } +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/bug-type-superfan-dialogue.json b/src/locales/en/mystery-encounters/bug-type-superfan-dialogue.json new file mode 100644 index 000000000000..7df01326aed5 --- /dev/null +++ b/src/locales/en/mystery-encounters/bug-type-superfan-dialogue.json @@ -0,0 +1,38 @@ +{ + "intro": "An unusual trainer with all kinds of Bug paraphernalia blocks your way!", + "intro_dialogue": "Hey, trainer! I'm on a mission to find the rarest Bug Pokémon in existence!$You must love Bug Pokémon too, right?\nEveryone loves Bug Pokémon!", + "title": "The Bug-Type Superfan", + "speaker": "Bug-Type Superfan", + "description": "The trainer prattles, not even waiting for a response...\n\nIt seems the only way to get out of this situation is by catching the trainer's attention!", + "query": "What will you do?", + "option": { + "1": { + "label": "Offer to Battle", + "tooltip": "(-) Challenging Battle\n(+) Teach a Pokémon a Bug Type Move", + "selected": "A challenge, eh?\nMy bugs are more than ready for you!" + }, + "2": { + "label": "Show Your Bug Types", + "tooltip": "(+) Receive a Gift Item", + "disabled_tooltip": "You need at least 1 Bug Type Pokémon on your team to select this.", + "selected": "You show the trainer all your Bug Type Pokémon...", + "selected_0_to_1": "Huh? You only have {{numBugTypes}}...$Guess I'm wasting my breath on someone like you...", + "selected_2_to_3": "Hey, you've got {{numBugTypes}} Bug Types!\nNot bad.$Here, this might help you on your journey to catch more!", + "selected_4_to_5": "What? You have {{numBugTypes}} Bug Types?\nNice!$You're not quite at my level, but I can see shades of myself in you!\n$Take this, my young apprentice!", + "selected_6": "Whoa! {{numBugTypes}} Bug Types!\n$You must love Bug Types almost as much as I do!$Here, take this as a token of our camaraderie!" + }, + "3": { + "label": "Gift a Bug Item", + "tooltip": "(-) Give the trainer a {{requiredBugItems}}\n(+) Receive a Gift Item", + "disabled_tooltip": "You need to have a {{requiredBugItems}} to select this.", + "select_prompt": "Select an item to give.", + "invalid_selection": "Pokémon doesn't have that kind of item.", + "selected": "You hand the trainer a {{selectedItem}}.", + "selected_dialogue": "Whoa! A {{selectedItem}}, for me?\nYou're not so bad, kid!$As a token of my appreciation,\nI want you to have this special gift!$It's been passed all through my family, and now I want you to have it!" + } + }, + "battle_won": "Your knowledge and skill were perfect at exploiting our weaknesses!$In exchange for the valuable lesson,\nallow me to teach one of your Pokémon a Bug Type Move!", + "teach_move_prompt": "Select a move to teach a Pokémon.", + "confirm_no_teach": "You sure you don't want to learn one of these great moves?", + "outro": "I see great Bug Pokémon in your future!\nMay our paths cross again!$Bug out!" +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/clowning-around-dialogue.json b/src/locales/en/mystery-encounters/clowning-around-dialogue.json new file mode 100644 index 000000000000..177812408387 --- /dev/null +++ b/src/locales/en/mystery-encounters/clowning-around-dialogue.json @@ -0,0 +1,34 @@ +{ + "intro": "It's...@d{64} a clown?", + "speaker": "Clown", + "intro_dialogue": "Bumbling buffoon, brace for a brilliant battle!\nYou'll be beaten by this brawling busker!", + "title": "Clowning Around", + "description": "Something is off about this encounter. The clown seems eager to goad you into a battle, but to what end?\n\nThe {{blacephalonName}} is especially strange, like it has @[TOOLTIP_TITLE]{weird types and ability.}", + "query": "What will you do?", + "option": { + "1": { + "label": "Battle the Clown", + "tooltip": "(-) Strange Battle\n(?) Affects Pokémon Abilities", + "selected": "Your pitiful Pokémon are poised for a pathetic performance!", + "apply_ability_dialogue": "A sensational showcase!\nYour savvy suits a sensational skill as spoils!", + "apply_ability_message": "The clown is offering to permanently Skill Swap one of your Pokémon's ability to {{ability}}!", + "ability_prompt": "Would you like to permanently teach a Pokémon the {{ability}} ability?", + "ability_gained": "@s{level_up_fanfare}{{chosenPokemon}} gained the {{ability}} ability!" + }, + "2": { + "label": "Remain Unprovoked", + "tooltip": "(-) Upsets the Clown\n(?) Affects Pokémon Items", + "selected": "Dismal dodger, you deny a delightful duel?\nFeel my fury!", + "selected_2": "The clown's {{blacephalonName}} uses Trick!\nAll of your {{switchPokemon}}'s items were randomly swapped!", + "selected_3": "Flustered fool, fall for my flawless deception!" + }, + "3": { + "label": "Return the Insults", + "tooltip": "(-) Upsets the Clown\n(?) Affects Pokémon Types", + "selected": "Dismal dodger, you deny a delightful duel?\nFeel my fury!", + "selected_2": "The clown's {{blacephalonName}} uses a strange move!\nAll of your team's types were randomly swapped!", + "selected_3": "Flustered fool, fall for my flawless deception!" + } + }, + "outro": "The clown and his cohorts\ndisappear in a puff of smoke." +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/dancing-lessons-dialogue.json b/src/locales/en/mystery-encounters/dancing-lessons-dialogue.json new file mode 100644 index 000000000000..8e2883ecb161 --- /dev/null +++ b/src/locales/en/mystery-encounters/dancing-lessons-dialogue.json @@ -0,0 +1,27 @@ +{ + "intro": "An {{oricorioName}} dances sadly alone, without a partner.", + "title": "Dancing Lessons", + "description": "The {{oricorioName}} doesn't seem aggressive, if anything it seems sad.\n\nMaybe it just wants someone to dance with...", + "query": "What will you do?", + "option": { + "1": { + "label": "Battle It", + "tooltip": "(-) Tough Battle\n(+) Gain a Baton", + "selected": "The {{oricorioName}} is distraught and moves to defend itself!", + "boss_enraged": "The {{oricorioName}}'s fear boosted its stats!" + }, + "2": { + "label": "Learn Its Dance", + "tooltip": "(+) Teach a Pokémon Revelation Dance", + "selected": "You watch the {{oricorioName}} closely as it performs its dance...$@s{level_up_fanfare}Your {{selectedPokemon}} learned from the {{oricorioName}}!" + }, + "3": { + "label": "Show It a Dance", + "tooltip": "(-) Teach the {{oricorioName}} a Dance Move\n(+) The {{oricorioName}} Will Like You", + "disabled_tooltip": "Your Pokémon need to know a Dance move for this.", + "select_prompt": "Select a Dance type move to use.", + "selected": "The {{oricorioName}} watches in fascination as\n{{selectedPokemon}} shows off {{selectedMove}}!$It loves the display!$@s{level_up_fanfare}The {{oricorioName}} wants to join your party!" + } + }, + "invalid_selection": "This Pokémon doesn't know a Dance move" +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/dark-deal-dialogue.json b/src/locales/en/mystery-encounters/dark-deal-dialogue.json new file mode 100644 index 000000000000..3086ebb0f9b7 --- /dev/null +++ b/src/locales/en/mystery-encounters/dark-deal-dialogue.json @@ -0,0 +1,24 @@ + + +{ + "intro": "A strange man in a tattered coat\nstands in your way...", + "speaker": "Shady Guy", + "intro_dialogue": "Hey, you!$I've been working on a new device\nto bring out a Pokémon's latent power!$It completely rebinds the Pokémon's atoms\nat a molecular level into a far more powerful form.$Hehe...@d{64} I just need some sac-@d{32}\nErr, test subjects, to prove it works.", + "title": "Dark Deal", + "description": "The disturbing fellow holds up some Pokéballs.\n\"I'll make it worth your while! You can have these strong Pokéballs as payment, All I need is a Pokémon from your team! Hehe...\"", + "query": "What will you do?", + "option": { + "1": { + "label": "Accept", + "tooltip": "(+) 5 Rogue Balls\n(?) Enhance a Random Pokémon", + "selected_dialogue": "Let's see, that {{pokeName}} will do nicely!$Remember, I'm not responsible\nif anything bad happens!@d{32} Hehe...", + "selected_message": "The man hands you 5 Rogue Balls.${{pokeName}} hops into the strange machine...$Flashing lights and weird noises\nstart coming from the machine!$...@d{96} Something emerges\nfrom the device, raging wildly!" + }, + "2": { + "label": "Refuse", + "tooltip": "(-) No Rewards", + "selected": "Not gonna help a poor fellow out?\nPah!" + } + }, + "outro": "After the harrowing encounter,\nyou collect yourself and depart." +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/delibirdy-dialogue.json b/src/locales/en/mystery-encounters/delibirdy-dialogue.json new file mode 100644 index 000000000000..ca1fefa3a39c --- /dev/null +++ b/src/locales/en/mystery-encounters/delibirdy-dialogue.json @@ -0,0 +1,29 @@ + + +{ + "intro": "A pack of {{delibirdName}} have appeared!", + "title": "Delibir-dy", + "description": "The {{delibirdName}}s are looking at you expectantly, as if they want something. Perhaps giving them an item or some money would satisfy them?", + "query": "What will you give them?", + "invalid_selection": "Pokémon doesn't have that kind of item.", + "option": { + "1": { + "label": "Give Money", + "tooltip": "(-) Give the {{delibirdName}}s {{money, money}}\n(+) Receive a Gift Item", + "selected": "You toss the money to the {{delibirdName}}s,\nwho chatter amongst themselves excitedly.$They turn back to you and happily give you a present!" + }, + "2": { + "label": "Give Food", + "tooltip": "(-) Give the {{delibirdName}}s a Berry or Reviver Seed\n(+) Receive a Gift Item", + "select_prompt": "Select an item to give.", + "selected": "You toss the {{chosenItem}} to the {{delibirdName}}s,\nwho chatter amongst themselves excitedly.$They turn back to you and happily give you a present!" + }, + "3": { + "label": "Give an Item", + "tooltip": "(-) Give the {{delibirdName}}s a Held Item\n(+) Receive a Gift Item", + "select_prompt": "Select an item to give.", + "selected": "You toss the {{chosenItem}} to the {{delibirdName}}s,\nwho chatter amongst themselves excitedly.$They turn back to you and happily give you a present!" + } + }, + "outro": "The {{delibirdName}} pack happily waddles off into the distance.$What a curious little exchange!" +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/department-store-sale-dialogue.json b/src/locales/en/mystery-encounters/department-store-sale-dialogue.json new file mode 100644 index 000000000000..d651f32665a1 --- /dev/null +++ b/src/locales/en/mystery-encounters/department-store-sale-dialogue.json @@ -0,0 +1,27 @@ +{ + "intro": "It's a lady with a ton of shopping bags.", + "speaker": "Shopper", + "intro_dialogue": "Hello! Are you here for\nthe amazing sales too?$There's a special coupon that you can\nredeem for a free item during the sale!$I have an extra one. Here you go!", + "title": "Department Store Sale", + "description": "There is merchandise in every direction! It looks like there are 4 counters where you can redeem the coupon for various items. The possibilities are endless!", + "query": "Which counter will you go to?", + "option": { + "1": { + "label": "TM Counter", + "tooltip": "(+) TM Shop" + }, + "2": { + "label": "Vitamin Counter", + "tooltip": "(+) Vitamin Shop" + }, + "3": { + "label": "Battle Item Counter", + "tooltip": "(+) X Item Shop" + }, + "4": { + "label": "Pokéball Counter", + "tooltip": "(+) Pokéball Shop" + } + }, + "outro": "What a deal! You should shop there more often." +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/field-trip-dialogue.json b/src/locales/en/mystery-encounters/field-trip-dialogue.json new file mode 100644 index 000000000000..61900d56cd7c --- /dev/null +++ b/src/locales/en/mystery-encounters/field-trip-dialogue.json @@ -0,0 +1,31 @@ +{ + "intro": "It's a teacher and some school children!", + "speaker": "Teacher", + "intro_dialogue": "Hello, there! Would you be able to\nspare a minute for my students?$I'm teaching them about Pokémon moves\nand would love to show them a demonstration.$Would you mind showing us one of\nthe moves your Pokémon can use?", + "title": "Field Trip", + "description": "A teacher is requesting a move demonstration from a Pokémon. Depending on the move you choose, she might have something useful for you in exchange.", + "query": "Which move category will you show off?", + "option": { + "1": { + "label": "A Physical Move", + "tooltip": "(+) Physical Item Rewards" + }, + "2": { + "label": "A Special Move", + "tooltip": "(+) Special Item Rewards" + }, + "3": { + "label": "A Status Move", + "tooltip": "(+) Status Item Rewards" + }, + "selected": "{{pokeName}} shows off an awesome display of {{move}}!" + }, + "second_option_prompt": "Choose a move for your Pokémon to use.", + "incorrect": "...$That isn't a {{moveCategory}} move!\nI'm sorry, but I can't give you anything.$Come along children, we'll\nfind a better demonstration elsewhere.", + "incorrect_exp": "Looks like you learned a valuable lesson?$Your Pokémon also gained some experience.", + "correct": "Thank you so much for your kindness!\nI hope these items might be of use to you!", + "correct_exp": "{{pokeName}} also gained some valuable experience!", + "status": "Status", + "physical": "Physical", + "special": "Special" +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/fiery-fallout-dialogue.json b/src/locales/en/mystery-encounters/fiery-fallout-dialogue.json new file mode 100644 index 000000000000..a1644d89a3fb --- /dev/null +++ b/src/locales/en/mystery-encounters/fiery-fallout-dialogue.json @@ -0,0 +1,26 @@ +{ + "intro": "You encounter a blistering storm of smoke and ash!", + "title": "Fiery Fallout", + "description": "The whirling ash and embers have cut visibility to nearly zero. It seems like there might be some... source that is causing these conditions. But what could be behind a phenomenon of this magnitude?", + "query": "What will you do?", + "option": { + "1": { + "label": "Find the Source", + "tooltip": "(?) Discover the source\n(-) Hard Battle", + "selected": "You push through the storm, and find two {{volcaronaName}}s in the middle of a mating dance!$They don't take kindly to the interruption and attack!" + }, + "2": { + "label": "Hunker Down", + "tooltip": "(-) Suffer the effects of the weather", + "selected": "The weather effects cause significant\nharm as you struggle to find shelter!$Your party takes 20% Max HP damage!", + "target_burned": "Your {{burnedPokemon}} also became burned!" + }, + "3": { + "label": "Your Fire Types Help", + "tooltip": "(+) End the conditions\n(+) Gain a Charcoal", + "disabled_tooltip": "You need at least 2 Fire Type Pokémon to choose this", + "selected": "Your {{option3PrimaryName}} and {{option3SecondaryName}} guide you to where two {{volcaronaName}}s are in the middle of a mating dance!$Thankfully, your Pokémon are able to calm them,\nand they depart without issue." + } + }, + "found_charcoal": "After the weather clears,\nyour {{leadPokemon}} spots something on the ground.$@s{item_fanfare}{{leadPokemon}} gained a Charcoal!" +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/fight-or-flight-dialogue.json b/src/locales/en/mystery-encounters/fight-or-flight-dialogue.json new file mode 100644 index 000000000000..3eb6cb87c165 --- /dev/null +++ b/src/locales/en/mystery-encounters/fight-or-flight-dialogue.json @@ -0,0 +1,25 @@ +{ + "intro": "Something shiny is sparkling\non the ground near that Pokémon!", + "title": "Fight or Flight", + "description": "It looks like there's a strong Pokémon guarding an item. Battling is the straightforward approach, but it looks strong. Perhaps you could steal the item, if you have the right Pokémon for the job.", + "query": "What will you do?", + "option": { + "1": { + "label": "Battle the Pokémon", + "tooltip": "(-) Hard Battle\n(+) New Item", + "selected": "You approach the\nPokémon without fear.", + "stat_boost": "The {{enemyPokemon}}'s latent strength boosted one of its stats!" + }, + "2": { + "label": "Steal the Item", + "disabled_tooltip": "Your Pokémon need to know certain moves to choose this", + "tooltip": "(+) {{option2PrimaryName}} uses {{option2PrimaryMove}}", + "selected": ".@d{32}.@d{32}.@d{32}$Your {{option2PrimaryName}} helps you out and uses {{option2PrimaryMove}}!$You nabbed the item!" + }, + "3": { + "label": "Leave", + "tooltip": "(-) No Rewards", + "selected": "You leave the strong Pokémon\nwith its prize and continue on." + } + } +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/fun-and-games-dialogue.json b/src/locales/en/mystery-encounters/fun-and-games-dialogue.json new file mode 100644 index 000000000000..f5d7d6e8ff8d --- /dev/null +++ b/src/locales/en/mystery-encounters/fun-and-games-dialogue.json @@ -0,0 +1,30 @@ +{ + "intro_dialogue": "Step right up, folks! Try your luck\non the brand new {{wobbuffetName}} Whack-o-matic!", + "speaker": "Showman", + "title": "Fun And Games!", + "description": "You've encountered a traveling show with a prize game! You will have @[TOOLTIP_TITLE]{3 turns} to bring the {{wobbuffetName}} as close to @[TOOLTIP_TITLE]{1 HP} as possible @[TOOLTIP_TITLE]{without KOing it} so it can wind up a huge Counter on the bell-ringing machine.\nBut be careful! If you KO the {{wobbuffetName}}, you'll have to pay for the cost of reviving it!", + "query": "Would you like to play?", + "option": { + "1": { + "label": "Play the Game", + "tooltip": "(-) Pay {{option1Money, money}}\n(+) Play {{wobbuffetName}} Whack-o-matic", + "selected": "Time to test your luck!" + }, + "2": { + "label": "Leave", + "tooltip": "(-) No Rewards", + "selected": "You hurry along your way,\nwith a slight feeling of regret." + } + }, + "ko": "Oh no! The {{wobbuffetName}} fainted!$You lose the game and\nhave to pay for the revive cost...", + "charging_continue": "The Wubboffet keeps charging its counter-swing!", + "turn_remaining_3": "Three turns remaining!", + "turn_remaining_2": "Two turns remaining!", + "turn_remaining_1": "One turn remaining!", + "end_game": "Time's up!$The {{wobbuffetName}} winds up to counter-swing and@d{16}.@d{16}.@d{16}.", + "best_result": "The {{wobbuffetName}} smacks the button so hard\nthe bell breaks off the top!$You win the grand prize!", + "great_result": "The {{wobbuffetName}} smacks the button, nearly hitting the bell!$So close!\nYou earn the second tier prize!", + "good_result": "The {{wobbuffetName}} hits the button hard enough to go midway up the scale!$You earn the third tier prize!", + "bad_result": "The {{wobbuffetName}} barely taps the button and nothing happens...$Oh no!\nYou don't win anything!", + "outro": "That was a fun little game!" +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/global-trade-system-dialogue.json b/src/locales/en/mystery-encounters/global-trade-system-dialogue.json new file mode 100644 index 000000000000..1cc420355b75 --- /dev/null +++ b/src/locales/en/mystery-encounters/global-trade-system-dialogue.json @@ -0,0 +1,32 @@ +{ + "intro": "It's an interface for the Global Trade System!", + "title": "The GTS", + "description": "Ah, the GTS! A technological wonder, you can connect with anyone else around the globe to trade Pokémon with them! Will fortune smile upon your trade today?", + "query": "What will you do?", + "option": { + "1": { + "label": "Check Trade Offers", + "tooltip": "(+) Select a trade offer for one of your Pokémon", + "trade_options_prompt": "Select a Pokémon to receive through trade." + }, + "2": { + "label": "Wonder Trade", + "tooltip": "(+) Send one of your Pokémon to the GTS and get a random Pokémon in return" + }, + "3": { + "label": "Trade an Item", + "trade_options_prompt": "Select an item to send.", + "invalid_selection": "This Pokémon doesn't have legal items to trade.", + "tooltip": "(+) Send one of your Items to the GTS and get a random new Item" + }, + "4": { + "label": "Leave", + "tooltip": "(-) No Rewards", + "selected": "No time to trade today!\nYou continue on." + } + }, + "pokemon_trade_selected": "{{tradedPokemon}} will be sent to {{tradeTrainerName}}.", + "pokemon_trade_goodbye": "Goodbye, {{tradedPokemon}}!", + "item_trade_selected": "{{chosenItem}} will be sent to {{tradeTrainerName}}.$.@d{64}.@d{64}.@d{64}\n@s{level_up_fanfare}Trade complete!$You received a {{itemName}} from {{tradeTrainerName}}!", + "trade_received": "@s{evolution_fanfare}{{tradeTrainerName}} sent over {{received}}!" +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/lost-at-sea-dialogue.json b/src/locales/en/mystery-encounters/lost-at-sea-dialogue.json new file mode 100644 index 000000000000..41709c66799e --- /dev/null +++ b/src/locales/en/mystery-encounters/lost-at-sea-dialogue.json @@ -0,0 +1,28 @@ +{ + "intro": "Wandering aimlessly through the sea, you've effectively gotten nowhere.", + "title": "Lost at Sea", + "description": "The sea is turbulent in this area, and you're running out of energy.\nThis is bad. Is there a way out of the situation?", + "query": "What will you do?", + "option": { + "1": { + "label": "{{option1PrimaryName}} Might Help", + "label_disabled": "Can't {{option1RequiredMove}}", + "tooltip": "(+) {{option1PrimaryName}} saves you\n(+) {{option1PrimaryName}} gains some EXP", + "tooltip_disabled": "You have no Pokémon to {{option1RequiredMove}} on", + "selected": "{{option1PrimaryName}} swims ahead, guiding you back on track.${{option1PrimaryName}} seems to also have gotten stronger in this time of need!" + }, + "2": { + "label": "{{option2PrimaryName}} Might Help", + "label_disabled": "Can't {{option2RequiredMove}}", + "tooltip": "(+) {{option2PrimaryName}} saves you\n(+) {{option2PrimaryName}} gains some EXP", + "tooltip_disabled": "You have no Pokémon to {{option2RequiredMove}} with", + "selected": "{{option2PrimaryName}} flies ahead of your boat, guiding you back on track.${{option2PrimaryName}} seems to also have gotten stronger in this time of need!" + }, + "3": { + "label": "Wander Aimlessly", + "tooltip": "(-) Each of your Pokémon lose {{damagePercentage}}% of their total HP", + "selected": "You float about in the boat, steering without direction until you finally spot a landmark you remember.$You and your Pokémon are fatigued from the whole ordeal." + } + }, + "outro": "You are back on track." +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/mysterious-challengers-dialogue.json b/src/locales/en/mystery-encounters/mysterious-challengers-dialogue.json new file mode 100644 index 000000000000..01f4e6092ebb --- /dev/null +++ b/src/locales/en/mystery-encounters/mysterious-challengers-dialogue.json @@ -0,0 +1,22 @@ +{ + "intro": "Mysterious challengers have appeared!", + "title": "Mysterious Challengers", + "description": "If you defeat a challenger, you might impress them enough to receive a boon. But some look tough, are you up to the challenge?", + "query": "Who will you battle?", + "option": { + "1": { + "label": "A Clever, Mindful Foe", + "tooltip": "(-) Standard Battle\n(+) Move Item Rewards" + }, + "2": { + "label": "A Strong Foe", + "tooltip": "(-) Hard Battle\n(+) Good Rewards" + }, + "3": { + "label": "The Mightiest Foe", + "tooltip": "(-) Brutal Battle\n(+) Great Rewards" + }, + "selected": "The trainer steps forward..." + }, + "outro": "The mysterious challenger was defeated!" +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/mysterious-chest-dialogue.json b/src/locales/en/mystery-encounters/mysterious-chest-dialogue.json new file mode 100644 index 000000000000..1de7a5992eda --- /dev/null +++ b/src/locales/en/mystery-encounters/mysterious-chest-dialogue.json @@ -0,0 +1,23 @@ +{ + "intro": "You found...@d{32} a chest?", + "title": "The Mysterious Chest", + "description": "A beautifully ornamented chest stands on the ground. There must be something good inside... right?", + "query": "Will you open it?", + "option": { + "1": { + "label": "Open It", + "tooltip": "@[SUMMARY_BLUE]{(35%) Something terrible}\n@[SUMMARY_GREEN]{(40%) Okay Rewards}\n@[SUMMARY_GREEN]{(20%) Good Rewards}\n@[SUMMARY_GREEN]{(4%) Great Rewards}\n@[SUMMARY_GREEN]{(1%) Amazing Rewards}", + "selected": "You open the chest to find...", + "normal": "Just some normal tools and items.", + "good": "Some pretty nice tools and items.", + "great": "A couple great tools and items!", + "amazing": "Whoa! An amazing item!", + "bad": "Oh no!@d{32}\nThe chest was actually a {{gimmighoulName}} in disguise!$Your {{pokeName}} jumps in front of you\nbut is KOed in the process!" + }, + "2": { + "label": "Too Risky, Leave", + "tooltip": "(-) No Rewards", + "selected": "You hurry along your way,\nwith a slight feeling of regret." + } + } +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/part-timer-dialogue.json b/src/locales/en/mystery-encounters/part-timer-dialogue.json new file mode 100644 index 000000000000..614f1818e3f8 --- /dev/null +++ b/src/locales/en/mystery-encounters/part-timer-dialogue.json @@ -0,0 +1,31 @@ +{ + "intro": "A busy worker flags you down.", + "speaker": "Worker", + "intro_dialogue": "You look like someone with lots of capable Pokémon!$We can pay you if you're able to help us with some part-time work!", + "title": "Part-Timer", + "description": "Looks like there are plenty of tasks that need to be done. Depending how well-suited your Pokémon is to a task, they might earn more or less money.", + "query": "Which job will you choose?", + "invalid_selection": "Pokémon must be healthy enough.", + "option": { + "1": { + "label": "Make Deliveries", + "tooltip": "(-) Your Pokémon Uses its Speed\n(+) Earn @[MONEY]{Money}", + "selected": "Your {{selectedPokemon}} works a shift delivering orders to customers." + }, + "2": { + "label": "Warehouse Work", + "tooltip": "(-) Your Pokémon Uses its Strength and Endurance\n(+) Earn @[MONEY]{Money}", + "selected": "Your {{selectedPokemon}} works a shift moving items around the warehouse." + }, + "3": { + "label": "Sales Assistant", + "tooltip": "(-) Your {{option3PrimaryName}} uses {{option3PrimaryMove}}\n(+) Earn @[MONEY]{Money}", + "disabled_tooltip": "Your Pokémon need to know certain moves for this job", + "selected": "Your {{option3PrimaryName}} spends the day using {{option3PrimaryMove}} to attract customers to the business!" + } + }, + "job_complete_good": "Thanks for the assistance!\nYour {{selectedPokemon}} was incredibly helpful!$Here's your check for the day.", + "job_complete_bad": "Your {{selectedPokemon}} helped us out a bit!$Here's your check for the day.", + "pokemon_tired": "Your {{selectedPokemon}} is worn out!\nThe PP of all its moves was reduced to 2!", + "outro": "Come back and help out again sometime!" +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/safari-zone-dialogue.json b/src/locales/en/mystery-encounters/safari-zone-dialogue.json new file mode 100644 index 000000000000..8869f2055e5f --- /dev/null +++ b/src/locales/en/mystery-encounters/safari-zone-dialogue.json @@ -0,0 +1,46 @@ +{ + "intro": "It's a safari zone!", + "title": "The Safari Zone", + "description": "There are all kinds of rare and special Pokémon that can be found here!\nIf you choose to enter, you'll have a time limit of 3 wild encounters where you can try to catch these special Pokémon.\n\nBeware, though. These Pokémon may flee before you're able to catch them!", + "query": "Would you like to enter?", + "option": { + "1": { + "label": "Enter", + "tooltip": "(-) Pay {{option1Money, money}}\n@[SUMMARY_GREEN]{(?) Safari Zone}", + "selected": "Time to test your luck!" + }, + "2": { + "label": "Leave", + "tooltip": "(-) No Rewards", + "selected": "You hurry along your way,\nwith a slight feeling of regret." + } + }, + "safari": { + "1": { + "label": "Throw a Pokéball", + "tooltip": "(+) Throw a Pokéball", + "selected": "You throw a Pokéball!" + }, + "2": { + "label": "Throw Bait", + "tooltip": "(+) Increases Capture Rate\n(-) Chance to Increase Flee Rate", + "selected": "You throw some bait!" + }, + "3": { + "label": "Throw Mud", + "tooltip": "(+) Decreases Flee Rate\n(-) Chance to Decrease Capture Rate", + "selected": "You throw some mud!" + }, + "4": { + "label": "Flee", + "tooltip": "(?) Flee from this Pokémon" + }, + "watching": "{{pokemonName}} is watching carefully!", + "eating": "{{pokemonName}} is eating!", + "busy_eating": "{{pokemonName}} is busy eating!", + "angry": "{{pokemonName}} is angry!", + "beside_itself_angry": "{{pokemonName}} is beside itself with anger!", + "remaining_count": "{{remainingCount}} Pokémon remaining!" + }, + "outro": "That was a fun little excursion!" +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/shady-vitamin-dealer-dialogue.json b/src/locales/en/mystery-encounters/shady-vitamin-dealer-dialogue.json new file mode 100644 index 000000000000..d0003de07f1d --- /dev/null +++ b/src/locales/en/mystery-encounters/shady-vitamin-dealer-dialogue.json @@ -0,0 +1,27 @@ +{ + "intro": "A man in a dark coat approaches you.", + "speaker": "Shady Salesman", + "intro_dialogue": ".@d{16}.@d{16}.@d{16}$I've got the goods if you've got the money.$Make sure your Pokémon can handle it though.", + "title": "The Vitamin Dealer", + "description": "The man opens his jacket to reveal some Pokémon vitamins. The numbers he quotes seem like a really good deal. Almost too good...\nHe offers two package deals to choose from.", + "query": "Which deal will you choose?", + "invalid_selection": "Pokémon must be healthy enough.", + "option": { + "1": { + "label": "The Cheap Deal", + "tooltip": "(-) Pay {{option1Money, money}}\n(-) Side Effects?\n(+) Chosen Pokémon Gains 2 Random Vitamins" + }, + "2": { + "label": "The Pricey Deal", + "tooltip": "(-) Pay {{option2Money, money}}\n(+) Chosen Pokémon Gains 2 Random Vitamins" + }, + "3": { + "label": "Leave", + "tooltip": "(-) No Rewards", + "selected": "Heh, wouldn't have figured you for a coward." + }, + "selected": "The man hands you two bottles and quickly disappears.${{selectedPokemon}} gained {{boost1}} and {{boost2}} boosts!" + }, + "cheap_side_effects": "But the medicine had some side effects!$Your {{selectedPokemon}} takes some damage,\nand its Nature is changed to {{newNature}}!", + "no_bad_effects": "Looks like there were no side-effects from the medicine!" +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/slumbering-snorlax-dialogue.json b/src/locales/en/mystery-encounters/slumbering-snorlax-dialogue.json new file mode 100644 index 000000000000..cd3bb7465c48 --- /dev/null +++ b/src/locales/en/mystery-encounters/slumbering-snorlax-dialogue.json @@ -0,0 +1,25 @@ +{ + "intro": "As you walk down a narrow pathway, you see a towering silhouette blocking your path.$You get closer to see a {{snorlaxName}} sleeping peacefully.\nIt seems like there's no way around it.", + "title": "Slumbering {{snorlaxName}}", + "description": "You could attack it to try and get it to move, or simply wait for it to wake up. Who knows how long that could take, though...", + "query": "What will you do?", + "option": { + "1": { + "label": "Battle It", + "tooltip": "(-) Fight Sleeping {{snorlaxName}}\n(+) Special Reward", + "selected": "You approach the\nPokémon without fear." + }, + "2": { + "label": "Wait for It to Move", + "tooltip": "(-) Wait a Long Time\n(+) Recover Party", + "selected": ".@d{32}.@d{32}.@d{32}$You wait for a time, but the {{snorlaxName}}'s yawns make your party sleepy...", + "rest_result": "When you all awaken, the {{snorlaxName}} is no where to be found -\nbut your Pokémon are all healed!" + }, + "3": { + "label": "Steal Its Item", + "tooltip": "(+) {{option3PrimaryName}} uses {{option3PrimaryMove}}\n(+) Special Reward", + "disabled_tooltip": "Your Pokémon need to know certain moves to choose this", + "selected": "Your {{option3PrimaryName}} uses {{option3PrimaryMove}}!$@s{item_fanfare}It steals Leftovers off the sleeping\n{{snorlaxName}} and you make out like bandits!" + } + } +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/teleporting-hijinks-dialogue.json b/src/locales/en/mystery-encounters/teleporting-hijinks-dialogue.json new file mode 100644 index 000000000000..c295867f5218 --- /dev/null +++ b/src/locales/en/mystery-encounters/teleporting-hijinks-dialogue.json @@ -0,0 +1,27 @@ +{ + "intro": "It's a strange machine, whirring noisily...", + "title": "Teleportating Hijinks", + "description": "The machine has a sign on it that reads:\n \"To use, insert money then step into the capsule.\"\n\nPerhaps it can transport you somewhere...", + "query": "What will you do?", + "option": { + "1": { + "label": "Put Money In", + "tooltip": "(-) Pay {{price, money}}\n(?) Teleport to New Biome", + "selected": "You insert some money, and the capsule opens.\nYou step inside..." + }, + "2": { + "label": "A Pokémon Helps", + "tooltip": "(-) {{option2PrimaryName}} Helps\n(+) {{option2PrimaryName}} gains EXP\n(?) Teleport to New Biome", + "disabled_tooltip": "You need a Steel or Electric Type Pokémon to choose this", + "selected": "{{option2PrimaryName}}'s Type allows it to bypass the machine's paywall!$The capsule opens, and you step inside..." + }, + "3": { + "label": "Inspect the Machine", + "tooltip": "(-) Pokémon Battle", + "selected": "You are drawn in by the blinking lights\nand strange noises coming from the machine...$You don't even notice as a wild\nPokémon sneaks up and ambushes you!" + } + }, + "transport": "The machine shakes violently,\nmaking all sorts of strange noises!$Just as soon as it had started, it quiets once more.", + "attacked": "You step out into a completely new area, startling a wild Pokémon!$The wild Pokémon attacks!", + "boss_enraged": "The opposing {{enemyPokemon}} has become enraged!" +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/the-pokemon-salesman-dialogue.json b/src/locales/en/mystery-encounters/the-pokemon-salesman-dialogue.json new file mode 100644 index 000000000000..7e8091bbfff0 --- /dev/null +++ b/src/locales/en/mystery-encounters/the-pokemon-salesman-dialogue.json @@ -0,0 +1,23 @@ +{ + "intro": "A chipper elderly man approaches you.", + "speaker": "Gentleman", + "intro_dialogue": "Hello there! Have I got a deal just for YOU!", + "title": "The Pokémon Salesman", + "description": "\"This {{purchasePokemon}} is extremely unique and carries an ability not normally found in its species! I'll let you have this swell {{purchasePokemon}} for just {{price, money}}!\"\n\n\"What do you say?\"", + "description_shiny": "\"This {{purchasePokemon}} is extremely unique and has a pigment not normally found in its species! I'll let you have this swell {{purchasePokemon}} for just {{price, money}}!\"\n\n\"What do you say?\"", + "query": "What will you do?", + "option": { + "1": { + "label": "Accept", + "tooltip": "(-) Pay {{price, money}}\n(+) Gain a {{purchasePokemon}} with its Hidden Ability", + "tooltip_shiny": "(-) Pay {{price, money}}\n(+) Gain a shiny {{purchasePokemon}}", + "selected_message": "You paid an outrageous sum and bought the {{purchasePokemon}}.", + "selected_dialogue": "Excellent choice!$I can see you've a keen eye for business.$Oh, yeah...@d{64} Returns not accepted, got that?" + }, + "2": { + "label": "Refuse", + "tooltip": "(-) No Rewards", + "selected": "No?@d{32} You say no?$I'm only doing this as a favor to you!" + } + } +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/the-strong-stuff-dialogue.json b/src/locales/en/mystery-encounters/the-strong-stuff-dialogue.json new file mode 100644 index 000000000000..b5403616c9b5 --- /dev/null +++ b/src/locales/en/mystery-encounters/the-strong-stuff-dialogue.json @@ -0,0 +1,21 @@ +{ + "intro": "It's a massive {{shuckleName}} and what appears\nto be a large stash of... juice?", + "title": "The Strong Stuff", + "description": "The {{shuckleName}} that blocks your path looks incredibly strong. Meanwhile, the juice next to it is emanating power of some kind.\n\nThe {{shuckleName}} extends its feelers in your direction. It seems like it wants to do something...", + "query": "What will you do?", + "option": { + "1": { + "label": "Approach the {{shuckleName}}", + "tooltip": "(?) Something awful or amazing might happen", + "selected": "You black out.", + "selected_2": "@f{150}When you awaken, the {{shuckleName}} is gone\nand juice stash completely drained.${{highBstPokemon1}} and {{highBstPokemon2}}\nfeel a terrible lethargy come over them!$Their base stats were reduced by {{reductionValue}}!$Your remaining Pokémon feel an incredible vigor, though!\nTheir base stats are increased by {{increaseValue}}!" + }, + "2": { + "label": "Battle the {{shuckleName}}", + "tooltip": "(-) Hard Battle\n(+) Special Rewards", + "selected": "Enraged, the {{shuckleName}} drinks some of its juice and attacks!", + "stat_boost": "The {{shuckleName}}'s juice boosts its stats!" + } + }, + "outro": "What a bizarre turn of events." +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/the-winstrate-challenge-dialogue.json b/src/locales/en/mystery-encounters/the-winstrate-challenge-dialogue.json new file mode 100644 index 000000000000..37807a91667e --- /dev/null +++ b/src/locales/en/mystery-encounters/the-winstrate-challenge-dialogue.json @@ -0,0 +1,22 @@ +{ + "intro": "It's a family standing outside their house!", + "speaker": "The Winstrates", + "intro_dialogue": "We're the Winstrates!$What do you say to taking on our family in a series of Pokémon battles?", + "title": "The Winstrate Challenge", + "description": "The Winstrates are a family of 5 trainers, and they want to battle! If you beat all of them back-to-back, they'll give you a grand prize. But can you handle the heat?", + "query": "What will you do?", + "option": { + "1": { + "label": "Accept the Challenge", + "tooltip": "(-) Brutal Battle\n(+) Special Item Reward", + "selected": "Let the challenge begin!" + }, + "2": { + "label": "Refuse the Challenge", + "tooltip": "(+) Full Heal Party\n(+) Gain a Rarer Candy", + "selected": "That's too bad. Say, your team looks worn out, why don't you stay awhile and rest?" + } + }, + "victory": "Congratulations on beating our challenge!$First off, we'd like you to have this Voucher.", + "victory_2": "Also, our family uses this Macho Brace to strengthen\nour Pokémon more effectively during training.$You may not need it considering that you beat the whole lot of us, but we hope you'll accept it anyway!" +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/training-session-dialogue.json b/src/locales/en/mystery-encounters/training-session-dialogue.json new file mode 100644 index 000000000000..f018018fe4e2 --- /dev/null +++ b/src/locales/en/mystery-encounters/training-session-dialogue.json @@ -0,0 +1,33 @@ +{ + "intro": "You've come across some\ntraining tools and supplies.", + "title": "Training Session", + "description": "These supplies look like they could be used to train a member of your party! There are a few ways you could train your Pokémon, by battling against it with the rest of your team.", + "query": "How should you train?", + "invalid_selection": "Pokémon must be healthy enough.", + "option": { + "1": { + "label": "Light Training", + "tooltip": "(-) Light Battle\n(+) Improve 2 Random IVs of Pokémon", + "finished": "{{selectedPokemon}} returns, feeling\nworn out but accomplished!$Its {{stat1}} and {{stat2}} IVs were improved!" + }, + "2": { + "label": "Moderate Training", + "tooltip": "(-) Moderate Battle\n(+) Change Pokémon's Nature", + "select_prompt": "Select a new nature\nto train your Pokémon in.", + "finished": "{{selectedPokemon}} returns, feeling\nworn out but accomplished!$Its nature was changed to {{nature}}!" + }, + "3": { + "label": "Heavy Training", + "tooltip": "(-) Harsh Battle\n(+) Change Pokémon's Ability", + "select_prompt": "Select a new ability\nto train your Pokémon in.", + "finished": "{{selectedPokemon}} returns, feeling\nworn out but accomplished!$Its ability was changed to {{ability}}!" + }, + "4": { + "label": "Leave", + "tooltip": "(-) No Rewards", + "selected": "You've no time for training.\nTime to move on." + }, + "selected": "{{selectedPokemon}} moves across\nthe clearing to face you..." + }, + "outro": "That was a successful training session!" +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/trash-to-treasure-dialogue.json b/src/locales/en/mystery-encounters/trash-to-treasure-dialogue.json new file mode 100644 index 000000000000..ae6e63ed8007 --- /dev/null +++ b/src/locales/en/mystery-encounters/trash-to-treasure-dialogue.json @@ -0,0 +1,19 @@ +{ + "intro": "It's a massive pile of garbage!\nWhere did this come from?", + "title": "Trash to Treasure", + "description": "The garbage heap looms over you, and you can spot some items of value buried amidst the refuse. Are you sure you want to get covered in filth to get them, though?", + "query": "What will you do?", + "option": { + "1": { + "label": "Dig for Valuables", + "tooltip": "(-) Lose Healing Items in Shops\n(+) Gain Amazing Items", + "selected": "You wade through the garbage pile, becoming mired in filth.$There's no way any respectable shopkeepers\nwill sell you anything in your grimy state!$You'll just have to make do without shop healing items.$However, you found some incredible items in the garbage!" + }, + "2": { + "label": "Investigate Further", + "tooltip": "(?) Find the Source of the Garbage", + "selected": "You wander around the heap, searching for any indication as to how this might have appeared here...", + "selected_2": "Suddenly, the garbage shifts! It wasn't just garbage, it was a Pokémon!" + } + } +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/uncommon-breed-dialogue.json b/src/locales/en/mystery-encounters/uncommon-breed-dialogue.json new file mode 100644 index 000000000000..e6f5b3d3fcd5 --- /dev/null +++ b/src/locales/en/mystery-encounters/uncommon-breed-dialogue.json @@ -0,0 +1,26 @@ +{ + "intro": "That isn't just an ordinary Pokémon!", + "title": "Uncommon Breed", + "description": "That {{enemyPokemon}} looks special compared to others of its kind. @[TOOLTIP_TITLE]{Perhaps it knows a special move?} You could battle and catch it outright, but there might also be a way to befriend it.", + "query": "What will you do?", + "option": { + "1": { + "label": "Battle the Pokémon", + "tooltip": "(-) Tricky Battle\n(+) Strong Catchable Foe", + "selected": "You approach the\n{{enemyPokemon}} without fear.", + "stat_boost": "The {{enemyPokemon}}'s heightened abilities boost its stats!" + }, + "2": { + "label": "Give It Food", + "disabled_tooltip": "You need 4 berry items to choose this", + "tooltip": "(-) Give 4 Berries\n(+) The {{enemyPokemon}} Likes You", + "selected": "You toss the berries at the {{enemyPokemon}}!$It eats them happily!$The {{enemyPokemon}} wants to join your party!" + }, + "3": { + "label": "Befriend It", + "disabled_tooltip": "Your Pokémon need to know certain moves to choose this", + "tooltip": "(+) {{option3PrimaryName}} uses {{option3PrimaryMove}}\n(+) The {{enemyPokemon}} Likes You", + "selected": "Your {{option3PrimaryName}} uses {{option3PrimaryMove}} to charm the {{enemyPokemon}}!$The {{enemyPokemon}} wants to join your party!" + } + } +} \ No newline at end of file diff --git a/src/locales/en/mystery-encounters/weird-dream-dialogue.json b/src/locales/en/mystery-encounters/weird-dream-dialogue.json new file mode 100644 index 000000000000..44acde84002c --- /dev/null +++ b/src/locales/en/mystery-encounters/weird-dream-dialogue.json @@ -0,0 +1,22 @@ +{ + "intro": "A shadowy woman blocks your path.\nSomething about her is unsettling...", + "speaker": "Woman", + "intro_dialogue": "I have seen your futures, your pasts...$Child, do you see them too?", + "title": "???", + "description": "The woman's words echo in your head. It wasn't just a singular voice, but a vast multitude, from all timelines and realities. You begin to feel dizzy, the question lingering on your mind...\n\n@[TOOLTIP_TITLE]{\"I have seen your futures, your pasts... Child, do you see them too?\"}", + "query": "What will you do?", + "option": { + "1": { + "label": "\"I See Them\"", + "tooltip": "@[SUMMARY_GREEN]{(?) Affects your Pokémon}", + "selected": "Her hand reaches out to touch you,\nand everything goes black.$Then...@d{64} You see everything.\nEvery timeline, all your different selves,\n past and future.$Everything that has made you,\neverything you will become...@d{64}", + "cutscene": "You see your Pokémon,@d{32} converging from\nevery reality to become something new...@d{64}", + "dream_complete": "When you awaken, the woman - was it a woman or a ghost? - is gone...$.@d{32}.@d{32}.@d{32}$Your Pokémon team has changed...\nOr is it the same team you've always had?" + }, + "2": { + "label": "Quickly Leave", + "tooltip": "(-) Affects your Pokémon", + "selected": "You tear your mind from a numbing grip, and hastily depart.$When you finally stop to collect yourself, you check the Pokémon in your team.$For some reason, all of their levels have decreased!" + } + } +} \ No newline at end of file diff --git a/src/locales/en/party-ui-handler.json b/src/locales/en/party-ui-handler.json index 9c2b3f30e5ef..338bdfaec80e 100644 --- a/src/locales/en/party-ui-handler.json +++ b/src/locales/en/party-ui-handler.json @@ -15,6 +15,7 @@ "UNPAUSE_EVOLUTION": "Unpause Evolution", "REVIVE": "Revive", "RENAME": "Rename", + "SELECT": "Select", "choosePokemon": "Choose a Pokémon.", "doWhatWithThisPokemon": "Do what with this Pokémon?", "noEnergy": "{{pokemonName}} has no energy\nleft to battle!", diff --git a/src/locales/en/trainer-names.json b/src/locales/en/trainer-names.json index 50a2ce18f347..467ed03e0446 100644 --- a/src/locales/en/trainer-names.json +++ b/src/locales/en/trainer-names.json @@ -160,5 +160,17 @@ "alder_iris_double": "Alder & Iris", "iris_alder_double": "Iris & Alder", "marnie_piers_double": "Marnie & Piers", - "piers_marnie_double": "Piers & Marnie" + "piers_marnie_double": "Piers & Marnie", + + "buck": "Buck", + "cheryl": "Cheryl", + "marley": "Marley", + "mira": "Mira", + "riley": "Riley", + "victor": "Victor", + "victoria": "Victoria", + "vivi": "Vivi", + "vicky": "Vicky", + "vito": "Vito", + "bug_type_superfan": "Bug-Type Superfan" } diff --git a/src/locales/en/trainer-titles.json b/src/locales/en/trainer-titles.json index b9c919022be9..7ef715d115f2 100644 --- a/src/locales/en/trainer-titles.json +++ b/src/locales/en/trainer-titles.json @@ -34,5 +34,7 @@ "flare_admin_female": "Team Flare Admin", "aether_admin": "Aether Foundation Admin", "skull_admin": "Team Skull Admin", - "macro_admin": "Macro Cosmos" + "macro_admin": "Macro Cosmos", + + "the_winstrates": "The Winstrates'" } diff --git a/src/modifier/modifier-type.ts b/src/modifier/modifier-type.ts index 48c0d66fc45f..ec600a622105 100644 --- a/src/modifier/modifier-type.ts +++ b/src/modifier/modifier-type.ts @@ -1,6 +1,7 @@ import * as Modifiers from "./modifier"; -import { AttackMove, allMoves, selfStatLowerMoves } from "../data/move"; -import { MAX_PER_TYPE_POKEBALLS, PokeballType, getPokeballCatchMultiplier, getPokeballName } from "../data/pokeball"; +import { MoneyMultiplierModifier } from "./modifier"; +import { allMoves, AttackMove, selfStatLowerMoves } from "../data/move"; +import { getPokeballCatchMultiplier, getPokeballName, MAX_PER_TYPE_POKEBALLS, PokeballType } from "../data/pokeball"; import Pokemon, { EnemyPokemon, PlayerPokemon, PokemonMove } from "../field/pokemon"; import { EvolutionItem, pokemonEvolutions } from "../data/pokemon-evolutions"; import { tmPoolTiers, tmSpecies } from "../data/tms"; @@ -9,17 +10,16 @@ import PartyUiHandler, { PokemonMoveSelectFilter, PokemonSelectFilter } from ".. import * as Utils from "../utils"; import { getBerryEffectDescription, getBerryName } from "../data/berry"; import { Unlockables } from "../system/unlockables"; -import { StatusEffect, getStatusEffectDescriptor } from "../data/status-effect"; +import { getStatusEffectDescriptor, StatusEffect } from "../data/status-effect"; import { SpeciesFormKey } from "../data/pokemon-species"; import BattleScene from "../battle-scene"; -import { VoucherType, getVoucherTypeIcon, getVoucherTypeName } from "../system/voucher"; -import { FormChangeItem, SpeciesFormChangeCondition, SpeciesFormChangeItemTrigger, pokemonFormChanges } from "../data/pokemon-forms"; +import { getVoucherTypeIcon, getVoucherTypeName, VoucherType } from "../system/voucher"; +import { FormChangeItem, pokemonFormChanges, SpeciesFormChangeCondition, SpeciesFormChangeItemTrigger } from "../data/pokemon-forms"; import { ModifierTier } from "./modifier-tier"; -import { Nature, getNatureName, getNatureStatMultiplier } from "#app/data/nature"; +import { getNatureName, getNatureStatMultiplier, Nature } from "#app/data/nature"; import i18next from "i18next"; import { getModifierTierTextTint } from "#app/ui/text"; import Overrides from "#app/overrides"; -import { MoneyMultiplierModifier } from "./modifier"; import { Abilities } from "#enums/abilities"; import { BattlerTagType } from "#enums/battler-tag-type"; import { BerryType } from "#enums/berry-type"; @@ -109,28 +109,31 @@ export class ModifierType { return null; } + /** + * Populates item id for ModifierType instance + * @param func + */ withIdFromFunc(func: ModifierTypeFunc): ModifierType { this.id = Object.keys(modifierTypes).find(k => modifierTypes[k] === func)!; // TODO: is this bang correct? return this; } /** - * Populates the tier field by performing a reverse lookup on the modifier pool specified by {@linkcode poolType} using the - * {@linkcode ModifierType}'s id. - * @param poolType the {@linkcode ModifierPoolType} to look into to derive the item's tier; defaults to {@linkcode ModifierPoolType.PLAYER} + * Populates item tier for ModifierType instance + * Tier is a necessary field for items that appear in player shop (determines the Pokeball visual they use) + * To find the tier, this function performs a reverse lookup of the item type in modifier pools + * @param poolType Default 'ModifierPoolType.PLAYER'. Which pool to lookup item tier from */ withTierFromPool(poolType: ModifierPoolType = ModifierPoolType.PLAYER): ModifierType { for (const tier of Object.values(getModifierPoolForType(poolType))) { for (const modifier of tier) { if (this.id === modifier.modifierType.id) { this.tier = modifier.modifierType.tier; - break; + return this; } } - if (this.tier) { - break; - } } + return this; } @@ -644,6 +647,55 @@ export class BaseStatBoosterModifierType extends PokemonHeldItemModifierType imp } } +/** + * Shuckle Juice item + */ +export class PokemonBaseStatTotalModifierType extends PokemonHeldItemModifierType implements GeneratedPersistentModifierType { + private readonly statModifier: integer; + + constructor(statModifier: integer) { + super("modifierType:ModifierType.MYSTERY_ENCOUNTER_SHUCKLE_JUICE", "berry_juice", (_type, args) => new Modifiers.PokemonBaseStatTotalModifier(this, (args[0] as Pokemon).id, this.statModifier)); + this.statModifier = statModifier; + } + + override getDescription(scene: BattleScene): string { + return i18next.t("modifierType:ModifierType.PokemonBaseStatTotalModifierType.description", { + increaseDecrease: i18next.t(this.statModifier >= 0 ? "modifierType:ModifierType.PokemonBaseStatTotalModifierType.extra.increase" : "modifierType:ModifierType.PokemonBaseStatTotalModifierType.extra.decrease"), + blessCurse: i18next.t(this.statModifier >= 0 ? "modifierType:ModifierType.PokemonBaseStatTotalModifierType.extra.blessed" : "modifierType:ModifierType.PokemonBaseStatTotalModifierType.extra.cursed"), + statValue: this.statModifier, + }); + } + + public getPregenArgs(): any[] { + return [ this.statModifier ]; + } +} + +/** + * Old Gateau item + */ +export class PokemonBaseStatFlatModifierType extends PokemonHeldItemModifierType implements GeneratedPersistentModifierType { + private readonly statModifier: integer; + private readonly stats: Stat[]; + + constructor(statModifier: integer, stats: Stat[]) { + super("modifierType:ModifierType.MYSTERY_ENCOUNTER_OLD_GATEAU", "old_gateau", (_type, args) => new Modifiers.PokemonBaseStatFlatModifier(this, (args[0] as Pokemon).id, this.statModifier, this.stats)); + this.statModifier = statModifier; + this.stats = stats; + } + + override getDescription(scene: BattleScene): string { + return i18next.t("modifierType:ModifierType.PokemonBaseStatFlatModifierType.description", { + stats: this.stats.map(stat => i18next.t(getStatKey(stat))).join("/"), + statValue: this.statModifier, + }); + } + + public getPregenArgs(): any[] { + return [ this.statModifier, this.stats ]; + } +} + class AllPokemonFullHpRestoreModifierType extends ModifierType { private descriptionKey: string; @@ -1505,6 +1557,22 @@ export const modifierTypes = { ENEMY_STATUS_EFFECT_HEAL_CHANCE: () => new ModifierType("modifierType:ModifierType.ENEMY_STATUS_EFFECT_HEAL_CHANCE", "wl_full_heal", (type, _args) => new Modifiers.EnemyStatusEffectHealChanceModifier(type, 2.5, 10)), ENEMY_ENDURE_CHANCE: () => new EnemyEndureChanceModifierType("modifierType:ModifierType.ENEMY_ENDURE_CHANCE", "wl_reset_urge", 2), ENEMY_FUSED_CHANCE: () => new ModifierType("modifierType:ModifierType.ENEMY_FUSED_CHANCE", "wl_custom_spliced", (type, _args) => new Modifiers.EnemyFusionChanceModifier(type, 1)), + + MYSTERY_ENCOUNTER_SHUCKLE_JUICE: () => new ModifierTypeGenerator((party: Pokemon[], pregenArgs?: any[]) => { + if (pregenArgs) { + return new PokemonBaseStatTotalModifierType(pregenArgs[0] as integer); + } + return new PokemonBaseStatTotalModifierType(Utils.randSeedInt(20)); + }), + MYSTERY_ENCOUNTER_OLD_GATEAU: () => new ModifierTypeGenerator((party: Pokemon[], pregenArgs?: any[]) => { + if (pregenArgs) { + return new PokemonBaseStatFlatModifierType(pregenArgs[0] as integer, pregenArgs[1] as Stat[]); + } + return new PokemonBaseStatFlatModifierType(Utils.randSeedInt(20), [Stat.HP, Stat.ATK, Stat.DEF]); + }), + MYSTERY_ENCOUNTER_BLACK_SLUDGE: () => new ModifierType("modifierType:ModifierType.MYSTERY_ENCOUNTER_BLACK_SLUDGE", "black_sludge", (type, _args) => new Modifiers.HealShopCostModifier(type)), + MYSTERY_ENCOUNTER_MACHO_BRACE: () => new PokemonHeldItemModifierType("modifierType:ModifierType.MYSTERY_ENCOUNTER_MACHO_BRACE", "macho_brace", (type, args) => new Modifiers.PokemonIncrementingStatModifier(type, (args[0] as Pokemon).id)), + MYSTERY_ENCOUNTER_GOLDEN_BUG_NET: () => new ModifierType("modifierType:ModifierType.MYSTERY_ENCOUNTER_GOLDEN_BUG_NET", "golden_net", (type, _args) => new Modifiers.BoostBugSpawnModifier(type)), }; interface ModifierPool { @@ -1975,29 +2043,107 @@ export function regenerateModifierPoolThresholds(party: Pokemon[], poolType: Mod } } +export interface CustomModifierSettings { + guaranteedModifierTiers?: ModifierTier[]; + guaranteedModifierTypeOptions?: ModifierTypeOption[]; + guaranteedModifierTypeFuncs?: ModifierTypeFunc[]; + fillRemaining?: boolean; + /** Set to negative value to disable rerolls completely in shop */ + rerollMultiplier?: number; + allowLuckUpgrades?: boolean; +} + export function getModifierTypeFuncById(id: string): ModifierTypeFunc { return modifierTypes[id]; } -export function getPlayerModifierTypeOptions(count: integer, party: PlayerPokemon[], modifierTiers?: ModifierTier[]): ModifierTypeOption[] { +/** + * Generates modifier options for a {@linkcode SelectModifierPhase} + * @param count Determines the number of items to generate + * @param party Party is required for generating proper modifier pools + * @param modifierTiers (Optional) If specified, rolls items in the specified tiers. Commonly used for tier-locking with Lock Capsule. + * @param customModifierSettings (Optional) If specified, can customize the item shop rewards further. + * - `guaranteedModifierTypeOptions?: ModifierTypeOption[]` If specified, will override the first X items to be specific modifier options (these should be pre-genned). + * - `guaranteedModifierTypeFuncs?: ModifierTypeFunc[]` If specified, will override the next X items to be auto-generated from specific modifier functions (these don't have to be pre-genned). + * - `guaranteedModifierTiers?: ModifierTier[]` If specified, will override the next X items to be the specified tier. These can upgrade with luck. + * - `fillRemaining?: boolean` Default 'false'. If set to true, will fill the remainder of shop items that were not overridden by the 3 options above, up to the 'count' param value. + * - Example: `count = 4`, `customModifierSettings = { guaranteedModifierTiers: [ModifierTier.GREAT], fillRemaining: true }`, + * - The first item in the shop will be `GREAT` tier, and the remaining 3 items will be generated normally. + * - If `fillRemaining = false` in the same scenario, only 1 `GREAT` tier item will appear in the shop (regardless of `count` value). + * - `rerollMultiplier?: number` If specified, can adjust the amount of money required for a shop reroll. If set to a negative value, the shop will not allow rerolls at all. + * - `allowLuckUpgrades?: boolean` Default `true`, if `false` will prevent set item tiers from upgrading via luck + */ +export function getPlayerModifierTypeOptions(count: integer, party: PlayerPokemon[], modifierTiers?: ModifierTier[], customModifierSettings?: CustomModifierSettings): ModifierTypeOption[] { const options: ModifierTypeOption[] = []; const retryCount = Math.min(count * 5, 50); - new Array(count).fill(0).map((_, i) => { - let candidate = getNewModifierTypeOption(party, ModifierPoolType.PLAYER, modifierTiers && modifierTiers.length > i ? modifierTiers[i] : undefined); - let r = 0; - while (options.length && ++r < retryCount && options.filter(o => o.type?.name === candidate?.type?.name || o.type?.group === candidate?.type?.group).length) { - candidate = getNewModifierTypeOption(party, ModifierPoolType.PLAYER, candidate?.type?.tier, candidate?.upgradeCount); + if (!customModifierSettings) { + new Array(count).fill(0).map((_, i) => { + options.push(getModifierTypeOptionWithRetry(options, retryCount, party, modifierTiers && modifierTiers.length > i ? modifierTiers[i] : undefined)); + }); + } else { + // Guaranteed mod options first + if (customModifierSettings?.guaranteedModifierTypeOptions && customModifierSettings.guaranteedModifierTypeOptions.length > 0) { + options.push(...customModifierSettings.guaranteedModifierTypeOptions!); } - if (candidate) { - options.push(candidate); + + // Guaranteed mod functions second + if (customModifierSettings.guaranteedModifierTypeFuncs && customModifierSettings.guaranteedModifierTypeFuncs.length > 0) { + customModifierSettings.guaranteedModifierTypeFuncs!.forEach((mod, i) => { + const modifierId = Object.keys(modifierTypes).find(k => modifierTypes[k] === mod) as string; + let guaranteedMod: ModifierType = modifierTypes[modifierId]?.(); + + // Populates item id and tier + guaranteedMod = guaranteedMod + .withIdFromFunc(modifierTypes[modifierId]) + .withTierFromPool(); + + const modType = guaranteedMod instanceof ModifierTypeGenerator ? guaranteedMod.generateType(party) : guaranteedMod; + if (modType) { + const option = new ModifierTypeOption(modType, 0); + options.push(option); + } + }); } - }); + + // Guaranteed tiers third + if (customModifierSettings.guaranteedModifierTiers && customModifierSettings.guaranteedModifierTiers.length > 0) { + const allowLuckUpgrades = customModifierSettings.allowLuckUpgrades ?? true; + customModifierSettings.guaranteedModifierTiers.forEach((tier) => { + options.push(getModifierTypeOptionWithRetry(options, retryCount, party, tier, allowLuckUpgrades)); + }); + } + + // Fill remaining + if (options.length < count && customModifierSettings.fillRemaining) { + while (options.length < count) { + options.push(getModifierTypeOptionWithRetry(options, retryCount, party, undefined)); + } + } + } overridePlayerModifierTypeOptions(options, party); return options; } +/** + * Will generate a {@linkcode ModifierType} from the {@linkcode ModifierPoolType.PLAYER} pool, attempting to retry duplicated items up to retryCount + * @param existingOptions Currently generated options + * @param retryCount How many times to retry before allowing a dupe item + * @param party Current player party, used to calculate items in the pool + * @param tier If specified will generate item of tier + * @param allowLuckUpgrades `true` to allow items to upgrade tiers (the little animation that plays and is affected by luck) + */ +function getModifierTypeOptionWithRetry(existingOptions: ModifierTypeOption[], retryCount: integer, party: PlayerPokemon[], tier?: ModifierTier, allowLuckUpgrades?: boolean): ModifierTypeOption { + allowLuckUpgrades = allowLuckUpgrades ?? true; + let candidate = getNewModifierTypeOption(party, ModifierPoolType.PLAYER, tier, undefined, 0, allowLuckUpgrades); + let r = 0; + while (existingOptions.length && ++r < retryCount && existingOptions.filter(o => o.type.name === candidate?.type.name || o.type.group === candidate?.type.group).length) { + candidate = getNewModifierTypeOption(party, ModifierPoolType.PLAYER, candidate?.type.tier ?? tier, candidate?.upgradeCount, 0, allowLuckUpgrades); + } + return candidate!; +} + /** * Replaces the {@linkcode ModifierType} of the entries within {@linkcode options} with any * {@linkcode ModifierOverride} entries listed in {@linkcode Overrides.ITEM_REWARD_OVERRIDE} @@ -2123,7 +2269,16 @@ export function getDailyRunStarterModifiers(party: PlayerPokemon[]): Modifiers.P return ret; } -function getNewModifierTypeOption(party: Pokemon[], poolType: ModifierPoolType, tier?: ModifierTier, upgradeCount?: integer, retryCount: integer = 0): ModifierTypeOption | null { +/** + * Generates a ModifierType from the specified pool + * @param party party of the trainer using the item + * @param poolType PLAYER/WILD/TRAINER + * @param tier If specified, will override the initial tier of an item (can still upgrade with luck) + * @param upgradeCount If defined, means that this is a new ModifierType being generated to override another via luck upgrade. Used for recursive logic + * @param retryCount Max allowed tries before the next tier down is checked for a valid ModifierType + * @param allowLuckUpgrades Default true. If false, will not allow ModifierType to randomly upgrade to next tier + */ +function getNewModifierTypeOption(party: Pokemon[], poolType: ModifierPoolType, tier?: ModifierTier, upgradeCount?: integer, retryCount: integer = 0, allowLuckUpgrades: boolean = true): ModifierTypeOption | null { const player = !poolType; const pool = getModifierPoolForType(poolType); let thresholds: object; @@ -2149,7 +2304,7 @@ function getNewModifierTypeOption(party: Pokemon[], poolType: ModifierPoolType, if (!upgradeCount) { upgradeCount = 0; } - if (player && tierValue) { + if (player && tierValue && allowLuckUpgrades) { const partyLuckValue = getPartyLuckValue(party); const upgradeOdds = Math.floor(128 / ((partyLuckValue + 4) / 4)); let upgraded = false; @@ -2182,7 +2337,7 @@ function getNewModifierTypeOption(party: Pokemon[], poolType: ModifierPoolType, } } else if (upgradeCount === undefined && player) { upgradeCount = 0; - if (tier < ModifierTier.MASTER) { + if (tier < ModifierTier.MASTER && allowLuckUpgrades) { const partyShinyCount = party.filter(p => p.isShiny() && !p.isFainted()).length; const upgradeOdds = Math.floor(32 / ((partyShinyCount + 2) / 2)); while (modifierPool.hasOwnProperty(tier + upgradeCount + 1) && modifierPool[tier + upgradeCount + 1].length) { diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index 4b3d0a852800..b87d89e3733c 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -841,6 +841,157 @@ export class BaseStatModifier extends PokemonHeldItemModifier { } } +/** + * Currently used by Shuckle Juice item + */ +export class PokemonBaseStatTotalModifier extends PokemonHeldItemModifier { + private statModifier: integer; + readonly isTransferrable: boolean = false; + + constructor(type: ModifierTypes.PokemonBaseStatTotalModifierType, pokemonId: integer, statModifier: integer, stackCount?: integer) { + super(type, pokemonId, stackCount); + this.statModifier = statModifier; + } + + override matchType(modifier: Modifier): boolean { + return modifier instanceof PokemonBaseStatTotalModifier; + } + + override clone(): PersistentModifier { + return new PokemonBaseStatTotalModifier(this.type as ModifierTypes.PokemonBaseStatTotalModifierType, this.pokemonId, this.statModifier, this.stackCount); + } + + override getArgs(): any[] { + return super.getArgs().concat(this.statModifier); + } + + override shouldApply(args: any[]): boolean { + return super.shouldApply(args) && args.length === 2 && args[1] instanceof Array; + } + + override apply(args: any[]): boolean { + // Modifies the passed in baseStats[] array + args[1].forEach((v, i) => { + // HP is affected by half as much as other stats + const newVal = i === 0 ? Math.floor(v + this.statModifier / 2) : Math.floor(v + this.statModifier); + args[1][i] = Math.min(Math.max(newVal, 1), 999999); + }); + + return true; + } + + override getScoreMultiplier(): number { + return 1.2; + } + + override getMaxHeldItemCount(pokemon: Pokemon): integer { + return 2; + } +} + +/** + * Currently used by Old Gateau item + */ +export class PokemonBaseStatFlatModifier extends PokemonHeldItemModifier { + private statModifier: integer; + private stats: Stat[]; + readonly isTransferrable: boolean = false; + + constructor (type: ModifierType, pokemonId: integer, statModifier: integer, stats: Stat[], stackCount?: integer) { + super(type, pokemonId, stackCount); + + this.statModifier = statModifier; + this.stats = stats; + } + + override matchType(modifier: Modifier): boolean { + return modifier instanceof PokemonBaseStatFlatModifier; + } + + override clone(): PersistentModifier { + return new PokemonBaseStatFlatModifier(this.type, this.pokemonId, this.statModifier, this.stats, this.stackCount); + } + + override getArgs(): any[] { + return super.getArgs().concat(this.statModifier, this.stats); + } + + override shouldApply(args: any[]): boolean { + return super.shouldApply(args) && args.length === 2 && args[1] instanceof Array; + } + + override apply(args: any[]): boolean { + // Modifies the passed in baseStats[] array by a flat value, only if the stat is specified in this.stats + args[1].forEach((v, i) => { + if (this.stats.includes(i)) { + const newVal = Math.floor(v + this.statModifier); + args[1][i] = Math.min(Math.max(newVal, 1), 999999); + } + }); + + return true; + } + + override getScoreMultiplier(): number { + return 1.1; + } + + override getMaxHeldItemCount(pokemon: Pokemon): integer { + return 1; + } +} + +/** + * Currently used by Macho Brace item + */ +export class PokemonIncrementingStatModifier extends PokemonHeldItemModifier { + readonly isTransferrable: boolean = false; + + constructor (type: ModifierType, pokemonId: integer, stackCount?: integer) { + super(type, pokemonId, stackCount); + } + + matchType(modifier: Modifier): boolean { + return modifier instanceof PokemonIncrementingStatModifier; + } + + clone(): PersistentModifier { + return new PokemonIncrementingStatModifier(this.type, this.pokemonId); + } + + getArgs(): any[] { + return super.getArgs(); + } + + shouldApply(args: any[]): boolean { + return super.shouldApply(args) && args.length === 2 && args[1] instanceof Array; + } + + apply(args: any[]): boolean { + // Modifies the passed in stats[] array by +1 per stack for HP, +2 per stack for other stats + // If the Macho Brace is at max stacks (50), adds additional 5% to total HP and 10% to other stats + args[1].forEach((v, i) => { + const isHp = i === 0; + let mult = 1; + if (this.stackCount === this.getMaxHeldItemCount()) { + mult = isHp ? 1.05 : 1.1; + } + const newVal = Math.floor((v + this.stackCount * (isHp ? 1 : 2)) * mult); + args[1][i] = Math.min(Math.max(newVal, 1), 999999); + }); + + return true; + } + + getScoreMultiplier(): number { + return 1.2; + } + + getMaxHeldItemCount(pokemon?: Pokemon): integer { + return 50; + } +} + /** * Modifier used for held items that apply {@linkcode Stat} boost(s) * using a multiplier. @@ -2378,6 +2529,55 @@ export class LockModifierTiersModifier extends PersistentModifier { } } +/** + * Black Sludge item + */ +export class HealShopCostModifier extends PersistentModifier { + constructor(type: ModifierType, stackCount?: integer) { + super(type, stackCount); + } + + match(modifier: Modifier): boolean { + return modifier instanceof HealShopCostModifier; + } + + clone(): HealShopCostModifier { + return new HealShopCostModifier(this.type, this.stackCount); + } + + apply(args: any[]): boolean { + (args[0] as Utils.IntegerHolder).value *= Math.pow(3, this.getStackCount()); + + return true; + } + + getMaxStackCount(scene: BattleScene): integer { + return 1; + } +} + +export class BoostBugSpawnModifier extends PersistentModifier { + constructor(type: ModifierType, stackCount?: integer) { + super(type, stackCount); + } + + match(modifier: Modifier): boolean { + return modifier instanceof BoostBugSpawnModifier; + } + + clone(): HealShopCostModifier { + return new BoostBugSpawnModifier(this.type, this.stackCount); + } + + apply(args: any[]): boolean { + return true; + } + + getMaxStackCount(scene: BattleScene): integer { + return 1; + } +} + export class SwitchEffectTransferModifier extends PokemonHeldItemModifier { constructor(type: ModifierType, pokemonId: integer, stackCount?: integer) { super(type, pokemonId, stackCount); diff --git a/src/overrides.ts b/src/overrides.ts index d1597dfdee86..6b550d152c21 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -10,9 +10,10 @@ import { VariantTier } from "#enums/variant-tiers"; import { WeatherType } from "#enums/weather-type"; import { type PokeballCounts } from "./battle-scene"; import { Gender } from "./data/gender"; -import { allSpecies } from "./data/pokemon-species"; // eslint-disable-line @typescript-eslint/no-unused-vars import { Variant } from "./data/variant"; import { type ModifierOverride } from "./modifier/modifier-type"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; /** * Overrides that are using when testing different in game situations @@ -135,6 +136,15 @@ class DefaultOverrides { readonly EGG_FREE_GACHA_PULLS_OVERRIDE: boolean = false; readonly EGG_GACHA_PULL_COUNT_OVERRIDE: number = 0; + // ------------------------- + // MYSTERY ENCOUNTER OVERRIDES + // ------------------------- + + /** 1 to 256, set to null to ignore */ + readonly MYSTERY_ENCOUNTER_RATE_OVERRIDE: number | null = null; + readonly MYSTERY_ENCOUNTER_TIER_OVERRIDE: MysteryEncounterTier | null = null; + readonly MYSTERY_ENCOUNTER_OVERRIDE: MysteryEncounterType | null = null; + // ------------------------- // MODIFIER / ITEM OVERRIDES // ------------------------- diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index 47d212aa5985..86e42acb26b0 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -15,6 +15,7 @@ import { Mode } from "#app/ui/ui"; import i18next from "i18next"; import { FieldPhase } from "./field-phase"; import { SelectTargetPhase } from "./select-target-phase"; +import { MysteryEncounterMode } from "#enums/mystery-encounter-mode"; export class CommandPhase extends FieldPhase { protected fieldIndex: integer; @@ -68,7 +69,12 @@ export class CommandPhase extends FieldPhase { } } } else { - this.scene.ui.setMode(Mode.COMMAND, this.fieldIndex); + if (this.scene.currentBattle.battleType === BattleType.MYSTERY_ENCOUNTER && this.scene.currentBattle.mysteryEncounter?.skipToFightInput) { + this.scene.ui.clearText(); + this.scene.ui.setMode(Mode.FIGHT, this.fieldIndex); + } else { + this.scene.ui.setMode(Mode.COMMAND, this.fieldIndex); + } } } @@ -134,6 +140,13 @@ export class CommandPhase extends FieldPhase { this.scene.ui.showText("", 0); this.scene.ui.setMode(Mode.COMMAND, this.fieldIndex); }, null, true); + } else if (this.scene.currentBattle.battleType === BattleType.MYSTERY_ENCOUNTER && !this.scene.currentBattle.mysteryEncounter!.catchAllowed) { + this.scene.ui.setMode(Mode.COMMAND, this.fieldIndex); + this.scene.ui.setMode(Mode.MESSAGE); + this.scene.ui.showText(i18next.t("battle:noPokeballMysteryEncounter"), null, () => { + this.scene.ui.showText("", 0); + this.scene.ui.setMode(Mode.COMMAND, this.fieldIndex); + }, null, true); } else { const targets = this.scene.getEnemyField().filter(p => p.isActive(true)).map(p => p.getBattlerIndex()); if (targets.length > 1) { @@ -173,7 +186,7 @@ export class CommandPhase extends FieldPhase { this.scene.ui.showText("", 0); this.scene.ui.setMode(Mode.COMMAND, this.fieldIndex); }, null, true); - } else if (!isSwitch && this.scene.currentBattle.battleType === BattleType.TRAINER) { + } else if (!isSwitch && (this.scene.currentBattle.battleType === BattleType.TRAINER || this.scene.currentBattle.mysteryEncounter?.encounterMode === MysteryEncounterMode.TRAINER_BATTLE)) { this.scene.ui.setMode(Mode.COMMAND, this.fieldIndex); this.scene.ui.setMode(Mode.MESSAGE); this.scene.ui.showText(i18next.t("battle:noEscapeTrainer"), null, () => { diff --git a/src/phases/common-anim-phase.ts b/src/phases/common-anim-phase.ts index 66299064bb29..c4071488eefb 100644 --- a/src/phases/common-anim-phase.ts +++ b/src/phases/common-anim-phase.ts @@ -6,12 +6,14 @@ import { PokemonPhase } from "./pokemon-phase"; export class CommonAnimPhase extends PokemonPhase { private anim: CommonAnim | null; private targetIndex: integer | undefined; + private playOnEmptyField: boolean; - constructor(scene: BattleScene, battlerIndex?: BattlerIndex, targetIndex?: BattlerIndex | undefined, anim?: CommonAnim) { + constructor(scene: BattleScene, battlerIndex?: BattlerIndex, targetIndex?: BattlerIndex | undefined, anim?: CommonAnim, playOnEmptyField: boolean = false) { super(scene, battlerIndex); this.anim = anim!; // TODO: is this bang correct? this.targetIndex = targetIndex; + this.playOnEmptyField = playOnEmptyField; } setAnimation(anim: CommonAnim) { @@ -19,7 +21,8 @@ export class CommonAnimPhase extends PokemonPhase { } start() { - new CommonBattleAnim(this.anim, this.getPokemon(), this.targetIndex !== undefined ? (this.player ? this.scene.getEnemyField() : this.scene.getPlayerField())[this.targetIndex] : this.getPokemon()).play(this.scene, false, () => { + const target = this.targetIndex !== undefined ? (this.player ? this.scene.getEnemyField() : this.scene.getPlayerField())[this.targetIndex] : this.getPokemon(); + new CommonBattleAnim(this.anim, this.getPokemon(), target).play(this.scene, false, () => { this.end(); }); } diff --git a/src/phases/encounter-phase.ts b/src/phases/encounter-phase.ts index 3f37095569a7..1d9567ee9b3a 100644 --- a/src/phases/encounter-phase.ts +++ b/src/phases/encounter-phase.ts @@ -1,5 +1,5 @@ import BattleScene from "#app/battle-scene"; -import { BattleType, BattlerIndex } from "#app/battle"; +import { BattlerIndex, BattleType } from "#app/battle"; import { applyAbAttrs, SyncEncounterNatureAbAttr } from "#app/data/ability"; import { getCharVariantFromDialogue } from "#app/data/dialogue"; import { TrainerSlot } from "#app/data/trainer-config"; @@ -10,14 +10,15 @@ import { Species } from "#app/enums/species"; import { EncounterPhaseEvent } from "#app/events/battle-scene"; import Pokemon, { FieldPosition } from "#app/field/pokemon"; import { getPokemonNameWithAffix } from "#app/messages"; -import { regenerateModifierPoolThresholds, ModifierPoolType } from "#app/modifier/modifier-type"; -import { IvScannerModifier, TurnHeldItemTransferModifier } from "#app/modifier/modifier"; +import { ModifierPoolType, regenerateModifierPoolThresholds } from "#app/modifier/modifier-type"; +import { BoostBugSpawnModifier, IvScannerModifier, TurnHeldItemTransferModifier } from "#app/modifier/modifier"; import { achvs } from "#app/system/achv"; import { handleTutorial, Tutorial } from "#app/tutorial"; import { Mode } from "#app/ui/ui"; import i18next from "i18next"; import { BattlePhase } from "./battle-phase"; import * as Utils from "#app/utils"; +import { randSeedInt } from "#app/utils"; import { CheckSwitchPhase } from "./check-switch-phase"; import { GameOverPhase } from "./game-over-phase"; import { PostSummonPhase } from "./post-summon-phase"; @@ -27,6 +28,12 @@ import { ShinySparklePhase } from "./shiny-sparkle-phase"; import { SummonPhase } from "./summon-phase"; import { ToggleDoublePositionPhase } from "./toggle-double-position-phase"; import Overrides from "#app/overrides"; +import { initEncounterAnims, loadEncounterAnimAssets } from "#app/data/battle-anims"; +import { MysteryEncounterMode } from "#enums/mystery-encounter-mode"; +import { doTrainerExclamation } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { getEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; +import { getGoldenBugNetSpecies } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; export class EncounterPhase extends BattlePhase { private loaded: boolean; @@ -55,14 +62,45 @@ export class EncounterPhase extends BattlePhase { const battle = this.scene.currentBattle; + // Init Mystery Encounter if there is one + const mysteryEncounter = battle.mysteryEncounter; + if (mysteryEncounter) { + // If ME has an onInit() function, call it + // Usually used for calculating rand data before initializing anything visual + // Also prepopulates any dialogue tokens from encounter/option requirements + this.scene.executeWithSeedOffset(() => { + if (mysteryEncounter.onInit) { + mysteryEncounter.onInit(this.scene); + } + mysteryEncounter.populateDialogueTokensFromRequirements(this.scene); + }, this.scene.currentBattle.waveIndex); + + // Add any special encounter animations to load + if (mysteryEncounter.encounterAnimations && mysteryEncounter.encounterAnimations.length > 0) { + loadEnemyAssets.push(initEncounterAnims(this.scene, mysteryEncounter.encounterAnimations).then(() => loadEncounterAnimAssets(this.scene, true))); + } + + // Add intro visuals for mystery encounter + mysteryEncounter.initIntroVisuals(this.scene); + this.scene.field.add(mysteryEncounter.introVisuals!); + } + let totalBst = 0; - battle.enemyLevels?.forEach((level, e) => { + battle.enemyLevels?.every((level, e) => { + if (battle.battleType === BattleType.MYSTERY_ENCOUNTER) { + // Skip enemy loading for MEs, those are loaded elsewhere + return false; + } if (!this.loaded) { if (battle.battleType === BattleType.TRAINER) { battle.enemyParty[e] = battle.trainer?.genPartyMember(e)!; // TODO:: is the bang correct here? } else { - const enemySpecies = this.scene.randomSpecies(battle.waveIndex, level, true); + let enemySpecies = this.scene.randomSpecies(battle.waveIndex, level, true); + // If player has golden bug net, rolls 10% chance to replace with species from the golden bug net bug pool + if (this.scene.findModifier(m => m instanceof BoostBugSpawnModifier) && randSeedInt(10) === 0) { + enemySpecies = getGoldenBugNetSpecies(); + } battle.enemyParty[e] = this.scene.addEnemyPokemon(enemySpecies, level, TrainerSlot.NONE, !!this.scene.getEncounterBossSegments(battle.waveIndex, level, enemySpecies)); if (this.scene.currentBattle.battleSpec === BattleSpec.FINAL_BOSS) { battle.enemyParty[e].ivs = new Array(6).fill(31); @@ -79,7 +117,7 @@ export class EncounterPhase extends BattlePhase { } if (!this.loaded) { - this.scene.gameData.setPokemonSeen(enemyPokemon, true, battle.battleType === BattleType.TRAINER); + this.scene.gameData.setPokemonSeen(enemyPokemon, true, battle.battleType === BattleType.TRAINER || battle?.mysteryEncounter?.encounterMode === MysteryEncounterMode.TRAINER_BATTLE); } if (enemyPokemon.species.speciesId === Species.ETERNATUS) { @@ -104,6 +142,7 @@ export class EncounterPhase extends BattlePhase { loadEnemyAssets.push(enemyPokemon.loadAssets()); console.log(getPokemonNameWithAffix(enemyPokemon), enemyPokemon.species.speciesId, enemyPokemon.stats); + return true; }); if (this.scene.getParty().filter(p => p.isShiny()).length === 6) { @@ -112,6 +151,25 @@ export class EncounterPhase extends BattlePhase { if (battle.battleType === BattleType.TRAINER) { loadEnemyAssets.push(battle.trainer?.loadAssets().then(() => battle.trainer?.initSprite())!); // TODO: is this bang correct? + } else if (battle.battleType === BattleType.MYSTERY_ENCOUNTER) { + if (!battle.mysteryEncounter) { + battle.mysteryEncounter = this.scene.getMysteryEncounter(mysteryEncounter?.encounterType); + } + if (battle.mysteryEncounter.introVisuals) { + loadEnemyAssets.push(battle.mysteryEncounter.introVisuals.loadAssets().then(() => battle.mysteryEncounter!.introVisuals!.initSprite())); + } + if (battle.mysteryEncounter.loadAssets.length > 0) { + loadEnemyAssets.push(...battle.mysteryEncounter.loadAssets); + } + // Load Mystery Encounter Exclamation bubble and sfx + loadEnemyAssets.push(new Promise(resolve => { + this.scene.loadSe("GEN8- Exclaim", "battle_anims", "GEN8- Exclaim.wav"); + this.scene.loadImage("exclaim", "mystery-encounters"); + this.scene.load.once(Phaser.Loader.Events.COMPLETE, () => resolve()); + if (!this.scene.load.isLoading()) { + this.scene.load.start(); + } + })); } else { const overridedBossSegments = Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE > 1; // for double battles, reduce the health segments for boss Pokemon unless there is an override @@ -127,7 +185,10 @@ export class EncounterPhase extends BattlePhase { } Promise.all(loadEnemyAssets).then(() => { - battle.enemyParty.forEach((enemyPokemon, e) => { + battle.enemyParty.every((enemyPokemon, e) => { + if (battle.battleType === BattleType.MYSTERY_ENCOUNTER) { + return false; + } if (e < (battle.double ? 2 : 1)) { if (battle.battleType === BattleType.WILD) { this.scene.field.add(enemyPokemon); @@ -145,9 +206,10 @@ export class EncounterPhase extends BattlePhase { enemyPokemon.setFieldPosition(e ? FieldPosition.RIGHT : FieldPosition.LEFT); } } + return true; }); - if (!this.loaded) { + if (!this.loaded && battle.battleType !== BattleType.MYSTERY_ENCOUNTER) { regenerateModifierPoolThresholds(this.scene.getEnemyField(), battle.battleType === BattleType.TRAINER ? ModifierPoolType.TRAINER : ModifierPoolType.WILD); this.scene.generateEnemyModifiers(); } @@ -203,6 +265,19 @@ export class EncounterPhase extends BattlePhase { } } }); + + const encounterIntroVisuals = this.scene.currentBattle?.mysteryEncounter?.introVisuals; + if (encounterIntroVisuals) { + const enterFromRight = encounterIntroVisuals.enterFromRight; + if (enterFromRight) { + encounterIntroVisuals.x += 500; + } + this.scene.tweens.add({ + targets: encounterIntroVisuals, + x: enterFromRight ? "-=200" : "+=300", + duration: 2000 + }); + } } getEncounterMessage(): string { @@ -289,6 +364,63 @@ export class EncounterPhase extends BattlePhase { showDialogueAndSummon(); } } + } else if (this.scene.currentBattle.battleType === BattleType.MYSTERY_ENCOUNTER && this.scene.currentBattle.mysteryEncounter) { + const encounter = this.scene.currentBattle.mysteryEncounter; + const introVisuals = encounter.introVisuals; + introVisuals?.playAnim(); + + if (encounter.onVisualsStart) { + encounter.onVisualsStart(this.scene); + } + + const doEncounter = () => { + const doShowEncounterOptions = () => { + this.scene.ui.clearText(); + this.scene.ui.getMessageHandler().hideNameText(); + + this.scene.unshiftPhase(new MysteryEncounterPhase(this.scene)); + this.end(); + }; + + if (showEncounterMessage) { + const introDialogue = encounter.dialogue.intro; + if (!introDialogue) { + doShowEncounterOptions(); + } else { + const FIRST_DIALOGUE_PROMPT_DELAY = 750; + let i = 0; + const showNextDialogue = () => { + const nextAction = i === introDialogue.length - 1 ? doShowEncounterOptions : showNextDialogue; + const dialogue = introDialogue[i]; + const title = getEncounterText(this.scene, dialogue?.speaker); + const text = getEncounterText(this.scene, dialogue.text)!; + i++; + if (title) { + this.scene.ui.showDialogue(text, title, null, nextAction, 0, i === 1 ? FIRST_DIALOGUE_PROMPT_DELAY : 0); + } else { + this.scene.ui.showText(text, null, nextAction, i === 1 ? FIRST_DIALOGUE_PROMPT_DELAY : 0, true); + } + }; + + if (introDialogue.length > 0) { + showNextDialogue(); + } + } + } else { + doShowEncounterOptions(); + } + }; + + const encounterMessage = i18next.t("battle:mysteryEncounterAppeared"); + + if (!encounterMessage) { + doEncounter(); + } else { + doTrainerExclamation(this.scene); + this.scene.ui.showDialogue(encounterMessage, "???", null, () => { + this.scene.charSprite.hide().then(() => this.scene.hideFieldOverlay(250).then(() => doEncounter())); + }); + } } } @@ -301,7 +433,7 @@ export class EncounterPhase extends BattlePhase { } }); - if (this.scene.currentBattle.battleType !== BattleType.TRAINER) { + if (![BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(this.scene.currentBattle.battleType)) { enemyField.map(p => this.scene.pushConditionalPhase(new PostSummonPhase(this.scene, p.getBattlerIndex()), () => { // if there is not a player party, we can't continue if (!this.scene.getParty()?.length) { diff --git a/src/phases/enemy-command-phase.ts b/src/phases/enemy-command-phase.ts index 91ee0456cd42..b4503a7b059e 100644 --- a/src/phases/enemy-command-phase.ts +++ b/src/phases/enemy-command-phase.ts @@ -14,11 +14,15 @@ import { FieldPhase } from "./field-phase"; */ export class EnemyCommandPhase extends FieldPhase { protected fieldIndex: integer; + protected skipTurn: boolean = false; constructor(scene: BattleScene, fieldIndex: integer) { super(scene); this.fieldIndex = fieldIndex; + if (this.scene.currentBattle.mysteryEncounter?.skipEnemyBattleTurns) { + this.skipTurn = true; + } } start() { @@ -57,7 +61,7 @@ export class EnemyCommandPhase extends FieldPhase { const index = trainer.getNextSummonIndex(enemyPokemon.trainerSlot, partyMemberScores); battle.turnCommands[this.fieldIndex + BattlerIndex.ENEMY] = - { command: Command.POKEMON, cursor: index, args: [false] }; + { command: Command.POKEMON, cursor: index, args: [false], skip: this.skipTurn }; battle.enemySwitchCounter++; @@ -71,7 +75,7 @@ export class EnemyCommandPhase extends FieldPhase { const nextMove = enemyPokemon.getNextMove(); this.scene.currentBattle.turnCommands[this.fieldIndex + BattlerIndex.ENEMY] = - { command: Command.FIGHT, move: nextMove }; + { command: Command.FIGHT, move: nextMove, skip: this.skipTurn }; this.scene.currentBattle.enemySwitchCounter = Math.max(this.scene.currentBattle.enemySwitchCounter - 1, 0); diff --git a/src/phases/faint-phase.ts b/src/phases/faint-phase.ts index 2b63dcdd14bc..ccb3262b1bbb 100644 --- a/src/phases/faint-phase.ts +++ b/src/phases/faint-phase.ts @@ -110,7 +110,7 @@ export class FaintPhase extends PokemonPhase { } } else { this.scene.unshiftPhase(new VictoryPhase(this.scene, this.battlerIndex)); - if (this.scene.currentBattle.battleType === BattleType.TRAINER) { + if ([BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(this.scene.currentBattle.battleType)) { const hasReservePartyMember = !!this.scene.getEnemyParty().filter(p => p.isActive() && !p.isOnField() && p.trainerSlot === (pokemon as EnemyPokemon).trainerSlot).length; if (hasReservePartyMember) { this.scene.pushPhase(new SwitchSummonPhase(this.scene, this.fieldIndex, -1, false, false, false)); diff --git a/src/phases/game-over-phase.ts b/src/phases/game-over-phase.ts index 17805e90f0f3..8ab191324c67 100644 --- a/src/phases/game-over-phase.ts +++ b/src/phases/game-over-phase.ts @@ -237,7 +237,9 @@ export class GameOverPhase extends BattlePhase { trainer: this.scene.currentBattle.battleType === BattleType.TRAINER ? new TrainerData(this.scene.currentBattle.trainer) : null, gameVersion: this.scene.game.config.gameVersion, timestamp: new Date().getTime(), - challenges: this.scene.gameMode.challenges.map(c => new ChallengeData(c)) + challenges: this.scene.gameMode.challenges.map(c => new ChallengeData(c)), + mysteryEncounterType: this.scene.currentBattle.mysteryEncounter?.encounterType, + mysteryEncounterSaveData: this.scene.mysteryEncounterSaveData } as SessionSaveData; } } diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index e3c8216b65ac..ffd9d45b4bdc 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -119,8 +119,9 @@ export class MoveEffectPhase extends PokemonPhase { /** All move effect attributes are chained together in this array to be applied asynchronously. */ const applyAttrs: Promise[] = []; + const playOnEmptyField = this.scene.currentBattle?.mysteryEncounter?.hasBattleAnimationsWithoutTargets ?? false; // Move animation only needs one target - new MoveAnim(move.id as Moves, user, this.getTarget()!.getBattlerIndex()).play(this.scene, move.hitsSubstitute(user, this.getTarget()!), () => { + new MoveAnim(move.id as Moves, user, this.getTarget()!.getBattlerIndex()!, playOnEmptyField).play(this.scene, move.hitsSubstitute(user, this.getTarget()!), () => { /** Has the move successfully hit a target (for damage) yet? */ let hasHit: boolean = false; for (const target of targets) { diff --git a/src/phases/mystery-encounter-phases.ts b/src/phases/mystery-encounter-phases.ts new file mode 100644 index 000000000000..6c9d3fd8c1df --- /dev/null +++ b/src/phases/mystery-encounter-phases.ts @@ -0,0 +1,605 @@ +import i18next from "i18next"; +import BattleScene from "../battle-scene"; +import { Phase } from "../phase"; +import { Mode } from "../ui/ui"; +import { transitionMysteryEncounterIntroVisuals, OptionSelectSettings } from "../data/mystery-encounters/utils/encounter-phase-utils"; +import MysteryEncounterOption, { OptionPhaseCallback } from "../data/mystery-encounters/mystery-encounter-option"; +import { getCharVariantFromDialogue } from "../data/dialogue"; +import { TrainerSlot } from "../data/trainer-config"; +import { BattleSpec } from "#enums/battle-spec"; +import { IvScannerModifier } from "../modifier/modifier"; +import * as Utils from "../utils"; +import { isNullOrUndefined } from "../utils"; +import { getEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { BattlerTagLapseType } from "#app/data/battler-tags"; +import { MysteryEncounterMode } from "#enums/mystery-encounter-mode"; +import { PostTurnStatusEffectPhase } from "#app/phases/post-turn-status-effect-phase"; +import { SummonPhase } from "#app/phases/summon-phase"; +import { ScanIvsPhase } from "#app/phases/scan-ivs-phase"; +import { ToggleDoublePositionPhase } from "#app/phases/toggle-double-position-phase"; +import { ReturnPhase } from "#app/phases/return-phase"; +import { CheckSwitchPhase } from "#app/phases/check-switch-phase"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; +import { NewBattlePhase } from "#app/phases/new-battle-phase"; +import { GameOverPhase } from "#app/phases/game-over-phase"; +import { SwitchPhase } from "#app/phases/switch-phase"; +import { SeenEncounterData } from "#app/data/mystery-encounters/mystery-encounter-save-data"; + +/** + * Will handle (in order): + * - Clearing of phase queues to enter the Mystery Encounter game state + * - Management of session data related to MEs + * - Initialization of ME option select menu and UI + * - Execute {@linkcode MysteryEncounter.onPreOptionPhase} logic if it exists for the selected option + * - Display any `OptionTextDisplay.selected` type dialogue that is set in the {@linkcode MysteryEncounterDialogue} dialogue tree for selected option + * - Queuing of the {@linkcode MysteryEncounterOptionSelectedPhase} + */ +export class MysteryEncounterPhase extends Phase { + private readonly FIRST_DIALOGUE_PROMPT_DELAY = 300; + optionSelectSettings?: OptionSelectSettings; + + /** + * Mostly useful for having repeated queries during a single encounter, where the queries and options may differ each time + * @param scene + * @param optionSelectSettings allows overriding the typical options of an encounter with new ones + */ + constructor(scene: BattleScene, optionSelectSettings?: OptionSelectSettings) { + super(scene); + this.optionSelectSettings = optionSelectSettings; + } + + /** + * Updates seed offset, sets seen encounter session data, sets UI mode + */ + start() { + super.start(); + + // Clears out queued phases that are part of standard battle + this.scene.clearPhaseQueue(); + this.scene.clearPhaseQueueSplice(); + + const encounter = this.scene.currentBattle.mysteryEncounter!; + encounter.updateSeedOffset(this.scene); + + if (!this.optionSelectSettings) { + // Sets flag that ME was encountered, only if this is not a followup option select phase + // Can be used in later MEs to check for requirements to spawn, run history, etc. + this.scene.mysteryEncounterSaveData.encounteredEvents.push(new SeenEncounterData(encounter.encounterType, encounter.encounterTier, this.scene.currentBattle.waveIndex)); + } + + // Initiates encounter dialogue window and option select + this.scene.ui.setMode(Mode.MYSTERY_ENCOUNTER, this.optionSelectSettings); + } + + /** + * Triggers after a player selects an option for the encounter + * @param option + * @param index + */ + handleOptionSelect(option: MysteryEncounterOption, index: number): boolean { + // Set option selected flag + this.scene.currentBattle.mysteryEncounter!.selectedOption = option; + + if (!this.optionSelectSettings) { + // Saves the selected option in the ME save data, only if this is not a followup option select phase + // Can be used for analytics purposes to track what options are popular on certain encounters + const encounterSaveData = this.scene.mysteryEncounterSaveData.encounteredEvents[this.scene.mysteryEncounterSaveData.encounteredEvents.length - 1]; + if (encounterSaveData.type === this.scene.currentBattle.mysteryEncounter?.encounterType) { + encounterSaveData.selectedOption = index; + } + } + + if (!option.onOptionPhase) { + return false; + } + + // Populate dialogue tokens for option requirements + this.scene.currentBattle.mysteryEncounter!.populateDialogueTokensFromRequirements(this.scene); + + if (option.onPreOptionPhase) { + this.scene.executeWithSeedOffset(async () => { + return await option.onPreOptionPhase!(this.scene) + .then((result) => { + if (isNullOrUndefined(result) || result) { + this.continueEncounter(); + } + }); + }, this.scene.currentBattle.mysteryEncounter?.getSeedOffset()); + } else { + this.continueEncounter(); + } + + return true; + } + + /** + * Queues {@linkcode MysteryEncounterOptionSelectedPhase}, displays option.selected dialogue and ends phase + */ + continueEncounter() { + const endDialogueAndContinueEncounter = () => { + this.scene.pushPhase(new MysteryEncounterOptionSelectedPhase(this.scene)); + this.end(); + }; + + const optionSelectDialogue = this.scene.currentBattle?.mysteryEncounter?.selectedOption?.dialogue; + if (optionSelectDialogue?.selected && optionSelectDialogue.selected.length > 0) { + // Handle intermediate dialogue (between player selection event and the onOptionSelect logic) + this.scene.ui.setMode(Mode.MESSAGE); + const selectedDialogue = optionSelectDialogue.selected; + let i = 0; + const showNextDialogue = () => { + const nextAction = i === selectedDialogue.length - 1 ? endDialogueAndContinueEncounter : showNextDialogue; + const dialogue = selectedDialogue[i]; + let title: string | null = null; + const text: string | null = getEncounterText(this.scene, dialogue.text); + if (dialogue.speaker) { + title = getEncounterText(this.scene, dialogue.speaker); + } + + i++; + if (title) { + this.scene.ui.showDialogue(text ?? "", title, null, nextAction, 0, i === 1 ? this.FIRST_DIALOGUE_PROMPT_DELAY : 0); + } else { + this.scene.ui.showText(text ?? "", null, nextAction, i === 1 ? this.FIRST_DIALOGUE_PROMPT_DELAY : 0, true); + } + }; + + showNextDialogue(); + } else { + endDialogueAndContinueEncounter(); + } + } + + /** + * Ends phase + */ + end() { + this.scene.ui.setMode(Mode.MESSAGE).then(() => super.end()); + } +} + +/** + * Will handle (in order): + * - Execute {@linkcode MysteryEncounter.onOptionSelect} logic if it exists for the selected option + * + * It is important to point out that no phases are directly queued by any logic within this phase + * Any phase that is meant to follow this one MUST be queued via the onOptionSelect() logic of the selected option + */ +export class MysteryEncounterOptionSelectedPhase extends Phase { + onOptionSelect: OptionPhaseCallback; + + constructor(scene: BattleScene) { + super(scene); + this.onOptionSelect = this.scene.currentBattle.mysteryEncounter!.selectedOption!.onOptionPhase; + } + + /** + * Will handle (in order): + * - Execute {@linkcode MysteryEncounter.onOptionSelect} logic if it exists for the selected option + * + * It is important to point out that no phases are directly queued by any logic within this phase. + * Any phase that is meant to follow this one MUST be queued via the {@linkcode MysteryEncounter.onOptionSelect} logic of the selected option. + */ + start() { + super.start(); + if (this.scene.currentBattle.mysteryEncounter?.autoHideIntroVisuals) { + transitionMysteryEncounterIntroVisuals(this.scene).then(() => { + this.scene.executeWithSeedOffset(() => { + this.onOptionSelect(this.scene).finally(() => { + this.end(); + }); + }, this.scene.currentBattle.mysteryEncounter?.getSeedOffset() * 500); + }); + } else { + this.scene.executeWithSeedOffset(() => { + this.onOptionSelect(this.scene).finally(() => { + this.end(); + }); + }, this.scene.currentBattle.mysteryEncounter?.getSeedOffset() * 500); + } + } +} + +/** + * Runs at the beginning of an Encounter's battle + * Will clean up any residual flinches, Endure, etc. that are left over from {@linkcode MysteryEncounter.startOfBattleEffects} + * Will also handle Game Overs, switches, etc. that could happen from {@linkcode handleMysteryEncounterBattleStartEffects} + * See {@linkcode TurnEndPhase} for more details + */ +export class MysteryEncounterBattleStartCleanupPhase extends Phase { + constructor(scene: BattleScene) { + super(scene); + } + + /** + * Cleans up `TURN_END` tags, any {@linkcode PostTurnStatusEffectPhase}s, checks for Pokemon switches, then continues + */ + start() { + super.start(); + + const field = this.scene.getField(true).filter(p => p.summonData); + field.forEach(pokemon => { + pokemon.lapseTags(BattlerTagLapseType.TURN_END); + }); + + // Remove any status tick phases + while (!!this.scene.findPhase(p => p instanceof PostTurnStatusEffectPhase)) { + this.scene.tryRemovePhase(p => p instanceof PostTurnStatusEffectPhase); + } + + // The total number of Pokemon in the player's party that can legally fight + const legalPlayerPokemon = this.scene.getParty().filter(p => p.isAllowedInBattle()); + // The total number of legal player Pokemon that aren't currently on the field + const legalPlayerPartyPokemon = legalPlayerPokemon.filter(p => !p.isActive(true)); + if (!legalPlayerPokemon.length) { + this.scene.unshiftPhase(new GameOverPhase(this.scene)); + return this.end(); + } + + // Check for any KOd player mons and switch + // For each fainted mon on the field, if there is a legal replacement, summon it + const playerField = this.scene.getPlayerField(); + playerField.forEach((pokemon, i) => { + if (!pokemon.isAllowedInBattle() && legalPlayerPartyPokemon.length > i) { + this.scene.unshiftPhase(new SwitchPhase(this.scene, i, true, false)); + } + }); + + // THEN, if is a double battle, and player only has 1 summoned pokemon, center pokemon on field + if (this.scene.currentBattle.double && legalPlayerPokemon.length === 1 && legalPlayerPartyPokemon.length === 0) { + this.scene.unshiftPhase(new ToggleDoublePositionPhase(this.scene, true)); + } + + this.end(); + } +} + +/** + * Will handle (in order): + * - Setting BGM + * - Showing intro dialogue for an enemy trainer or wild Pokemon + * - Sliding in the visuals for enemy trainer or wild Pokemon, as well as handling summoning animations + * - Queue the {@linkcode SummonPhase}s, {@linkcode PostSummonPhase}s, etc., required to initialize the phase queue for a battle + */ +export class MysteryEncounterBattlePhase extends Phase { + disableSwitch: boolean; + + constructor(scene: BattleScene, disableSwitch = false) { + super(scene); + this.disableSwitch = disableSwitch; + } + + /** + * Sets up a ME battle + */ + start() { + super.start(); + + this.doMysteryEncounterBattle(this.scene); + } + + /** + * Gets intro battle message for new battle + * @param scene + * @private + */ + private getBattleMessage(scene: BattleScene): string { + const enemyField = scene.getEnemyField(); + const encounterMode = scene.currentBattle.mysteryEncounter!.encounterMode; + + if (scene.currentBattle.battleSpec === BattleSpec.FINAL_BOSS) { + return i18next.t("battle:bossAppeared", { bossName: enemyField[0].name }); + } + + if (encounterMode === MysteryEncounterMode.TRAINER_BATTLE) { + if (scene.currentBattle.double) { + return i18next.t("battle:trainerAppearedDouble", { trainerName: scene.currentBattle.trainer?.getName(TrainerSlot.NONE, true) }); + + } else { + return i18next.t("battle:trainerAppeared", { trainerName: scene.currentBattle.trainer?.getName(TrainerSlot.NONE, true) }); + } + } + + return enemyField.length === 1 + ? i18next.t("battle:singleWildAppeared", { pokemonName: enemyField[0].name }) + : i18next.t("battle:multiWildAppeared", { pokemonName1: enemyField[0].name, pokemonName2: enemyField[1].name }); + } + + /** + * Queues {@linkcode SummonPhase}s for the new battle, and handles trainer animations/dialogue if it's a Trainer battle + * @param scene + * @private + */ + private doMysteryEncounterBattle(scene: BattleScene) { + const encounterMode = scene.currentBattle.mysteryEncounter!.encounterMode; + if (encounterMode === MysteryEncounterMode.WILD_BATTLE || encounterMode === MysteryEncounterMode.BOSS_BATTLE) { + // Summons the wild/boss Pokemon + if (encounterMode === MysteryEncounterMode.BOSS_BATTLE) { + scene.playBgm(undefined); + } + const availablePartyMembers = scene.getEnemyParty().filter(p => !p.isFainted()).length; + scene.unshiftPhase(new SummonPhase(scene, 0, false)); + if (scene.currentBattle.double && availablePartyMembers > 1) { + scene.unshiftPhase(new SummonPhase(scene, 1, false)); + } + + if (!scene.currentBattle.mysteryEncounter?.hideBattleIntroMessage) { + scene.ui.showText(this.getBattleMessage(scene), null, () => this.endBattleSetup(scene), 0); + } else { + this.endBattleSetup(scene); + } + } else if (encounterMode === MysteryEncounterMode.TRAINER_BATTLE) { + this.showEnemyTrainer(); + const doSummon = () => { + scene.currentBattle.started = true; + scene.playBgm(undefined); + scene.pbTray.showPbTray(scene.getParty()); + scene.pbTrayEnemy.showPbTray(scene.getEnemyParty()); + const doTrainerSummon = () => { + this.hideEnemyTrainer(); + const availablePartyMembers = scene.getEnemyParty().filter(p => !p.isFainted()).length; + scene.unshiftPhase(new SummonPhase(scene, 0, false)); + if (scene.currentBattle.double && availablePartyMembers > 1) { + scene.unshiftPhase(new SummonPhase(scene, 1, false)); + } + this.endBattleSetup(scene); + }; + if (!scene.currentBattle.mysteryEncounter?.hideBattleIntroMessage) { + scene.ui.showText(this.getBattleMessage(scene), null, doTrainerSummon, 1000, true); + } else { + doTrainerSummon(); + } + }; + + const encounterMessages = scene.currentBattle.trainer?.getEncounterMessages(); + + if (!encounterMessages || !encounterMessages.length) { + doSummon(); + } else { + const trainer = this.scene.currentBattle.trainer; + let message: string; + scene.executeWithSeedOffset(() => message = Utils.randSeedItem(encounterMessages), this.scene.currentBattle.mysteryEncounter?.getSeedOffset()); + message = message!; // tell TS compiler it's defined now + const showDialogueAndSummon = () => { + scene.ui.showDialogue(message, trainer?.getName(TrainerSlot.NONE, true), null, () => { + scene.charSprite.hide().then(() => scene.hideFieldOverlay(250).then(() => doSummon())); + }); + }; + if (this.scene.currentBattle.trainer?.config.hasCharSprite && !this.scene.ui.shouldSkipDialogue(message)) { + this.scene.showFieldOverlay(500).then(() => this.scene.charSprite.showCharacter(trainer?.getKey()!, getCharVariantFromDialogue(encounterMessages[0])).then(() => showDialogueAndSummon())); // TODO: is this bang correct? + } else { + showDialogueAndSummon(); + } + } + } + } + + /** + * Initiate {@linkcode SummonPhase}s, {@linkcode ScanIvsPhase}, {@linkcode PostSummonPhase}s, etc. + * @param scene + * @private + */ + private endBattleSetup(scene: BattleScene) { + const enemyField = scene.getEnemyField(); + const encounterMode = scene.currentBattle.mysteryEncounter!.encounterMode; + + // PostSummon and ShinySparkle phases are handled by SummonPhase + + if (encounterMode !== MysteryEncounterMode.TRAINER_BATTLE) { + const ivScannerModifier = this.scene.findModifier(m => m instanceof IvScannerModifier); + if (ivScannerModifier) { + enemyField.map(p => this.scene.pushPhase(new ScanIvsPhase(this.scene, p.getBattlerIndex(), Math.min(ivScannerModifier.getStackCount() * 2, 6)))); + } + } + + const availablePartyMembers = scene.getParty().filter(p => !p.isFainted()); + + if (!availablePartyMembers[0].isOnField()) { + scene.pushPhase(new SummonPhase(scene, 0)); + } + + if (scene.currentBattle.double) { + if (availablePartyMembers.length > 1) { + scene.pushPhase(new ToggleDoublePositionPhase(scene, true)); + if (!availablePartyMembers[1].isOnField()) { + scene.pushPhase(new SummonPhase(scene, 1)); + } + } + } else { + if (availablePartyMembers.length > 1 && availablePartyMembers[1].isOnField()) { + scene.pushPhase(new ReturnPhase(scene, 1)); + } + scene.pushPhase(new ToggleDoublePositionPhase(scene, false)); + } + + if (encounterMode !== MysteryEncounterMode.TRAINER_BATTLE && !this.disableSwitch) { + const minPartySize = scene.currentBattle.double ? 2 : 1; + if (availablePartyMembers.length > minPartySize) { + scene.pushPhase(new CheckSwitchPhase(scene, 0, scene.currentBattle.double)); + if (scene.currentBattle.double) { + scene.pushPhase(new CheckSwitchPhase(scene, 1, scene.currentBattle.double)); + } + } + } + + this.end(); + } + + /** + * Ease in enemy trainer + * @private + */ + private showEnemyTrainer(): void { + // Show enemy trainer + const trainer = this.scene.currentBattle.trainer; + if (!trainer) { + return; + } + trainer.alpha = 0; + trainer.x += 16; + trainer.y -= 16; + trainer.setVisible(true); + this.scene.tweens.add({ + targets: trainer, + x: "-=16", + y: "+=16", + alpha: 1, + ease: "Sine.easeInOut", + duration: 750, + onComplete: () => { + trainer.untint(100, "Sine.easeOut"); + trainer.playAnim(); + } + }); + } + + private hideEnemyTrainer(): void { + this.scene.tweens.add({ + targets: this.scene.currentBattle.trainer, + x: "+=16", + y: "-=16", + alpha: 0, + ease: "Sine.easeInOut", + duration: 750 + }); + } +} + +/** + * Will handle (in order): + * - doContinueEncounter() callback for continuous encounters with back-to-back battles (this should push/shift its own phases as needed) + * + * OR + * + * - Any encounter reward logic that is set within {@linkcode MysteryEncounter.doEncounterExp} + * - Any encounter reward logic that is set within {@linkcode MysteryEncounter.doEncounterRewards} + * - Otherwise, can add a no-reward-item shop with only Potions, etc. if addHealPhase is true + * - Queuing of the {@linkcode PostMysteryEncounterPhase} + */ +export class MysteryEncounterRewardsPhase extends Phase { + addHealPhase: boolean; + + constructor(scene: BattleScene, addHealPhase: boolean = false) { + super(scene); + this.addHealPhase = addHealPhase; + } + + /** + * Runs {@linkcode MysteryEncounter.doContinueEncounter} and ends phase, OR {@linkcode MysteryEncounter.onRewards} then continues encounter + */ + start() { + super.start(); + const encounter = this.scene.currentBattle.mysteryEncounter!; + + if (encounter.doContinueEncounter) { + encounter.doContinueEncounter(this.scene).then(() => { + this.end(); + }); + } else { + this.scene.executeWithSeedOffset(() => { + if (encounter.onRewards) { + encounter.onRewards(this.scene).then(() => { + this.doEncounterRewardsAndContinue(); + }); + } else { + this.doEncounterRewardsAndContinue(); + } + // Do not use ME's seedOffset for rewards, these should always be consistent with waveIndex (once per wave) + }, this.scene.currentBattle.waveIndex * 1000); + } + } + + /** + * Queues encounter EXP and rewards phases, {@linkcode PostMysteryEncounterPhase}, and ends phase + */ + doEncounterRewardsAndContinue() { + const encounter = this.scene.currentBattle.mysteryEncounter!; + + if (encounter.doEncounterExp) { + encounter.doEncounterExp(this.scene); + } + + if (encounter.doEncounterRewards) { + encounter.doEncounterRewards(this.scene); + } else if (this.addHealPhase) { + this.scene.tryRemovePhase(p => p instanceof SelectModifierPhase); + this.scene.unshiftPhase(new SelectModifierPhase(this.scene, 0, undefined, { fillRemaining: false, rerollMultiplier: -1 })); + } + + this.scene.pushPhase(new PostMysteryEncounterPhase(this.scene)); + this.end(); + } +} + +/** + * Will handle (in order): + * - {@linkcode MysteryEncounter.onPostOptionSelect} logic (based on an option that was selected) + * - Showing any outro dialogue messages + * - Cleanup of any leftover intro visuals + * - Queuing of the next wave + */ +export class PostMysteryEncounterPhase extends Phase { + private readonly FIRST_DIALOGUE_PROMPT_DELAY = 750; + onPostOptionSelect?: OptionPhaseCallback; + + constructor(scene: BattleScene) { + super(scene); + this.onPostOptionSelect = this.scene.currentBattle.mysteryEncounter?.selectedOption?.onPostOptionPhase; + } + + /** + * Runs {@linkcode MysteryEncounter.onPostOptionSelect} then continues encounter + */ + start() { + super.start(); + + if (this.onPostOptionSelect) { + this.scene.executeWithSeedOffset(async () => { + return await this.onPostOptionSelect!(this.scene) + .then((result) => { + if (isNullOrUndefined(result) || result) { + this.continueEncounter(); + } + }); + }, this.scene.currentBattle.mysteryEncounter?.getSeedOffset() * 2000); + } else { + this.continueEncounter(); + } + } + + /** + * Queues {@linkcode NewBattlePhase}, plays outro dialogue and ends phase + */ + continueEncounter() { + const endPhase = () => { + this.scene.pushPhase(new NewBattlePhase(this.scene)); + this.end(); + }; + + const outroDialogue = this.scene.currentBattle?.mysteryEncounter?.dialogue?.outro; + if (outroDialogue && outroDialogue.length > 0) { + let i = 0; + const showNextDialogue = () => { + const nextAction = i === outroDialogue.length - 1 ? endPhase : showNextDialogue; + const dialogue = outroDialogue[i]; + let title: string | null = null; + const text: string | null = getEncounterText(this.scene, dialogue.text); + if (dialogue.speaker) { + title = getEncounterText(this.scene, dialogue.speaker); + } + + i++; + this.scene.ui.setMode(Mode.MESSAGE); + if (title) { + this.scene.ui.showDialogue(text ?? "", title, null, nextAction, 0, i === 1 ? this.FIRST_DIALOGUE_PROMPT_DELAY : 0); + } else { + this.scene.ui.showText(text ?? "", null, nextAction, i === 1 ? this.FIRST_DIALOGUE_PROMPT_DELAY : 0, true); + } + }; + + showNextDialogue(); + } else { + endPhase(); + } + } +} diff --git a/src/phases/new-biome-encounter-phase.ts b/src/phases/new-biome-encounter-phase.ts index 9f1209fb7ee5..48d928402dea 100644 --- a/src/phases/new-biome-encounter-phase.ts +++ b/src/phases/new-biome-encounter-phase.ts @@ -24,8 +24,14 @@ export class NewBiomeEncounterPhase extends NextEncounterPhase { } const enemyField = this.scene.getEnemyField(); + const moveTargets: any[] = [this.scene.arenaEnemy, enemyField]; + const mysteryEncounter = this.scene.currentBattle?.mysteryEncounter?.introVisuals; + if (mysteryEncounter) { + moveTargets.push(mysteryEncounter); + } + this.scene.tweens.add({ - targets: [this.scene.arenaEnemy, enemyField].flat(), + targets: moveTargets.flat(), x: "+=300", duration: 2000, onComplete: () => { diff --git a/src/phases/next-encounter-phase.ts b/src/phases/next-encounter-phase.ts index d51aa374b6e0..7de2472dd359 100644 --- a/src/phases/next-encounter-phase.ts +++ b/src/phases/next-encounter-phase.ts @@ -23,8 +23,28 @@ export class NextEncounterPhase extends EncounterPhase { this.scene.arenaNextEnemy.setVisible(true); const enemyField = this.scene.getEnemyField(); + const moveTargets: any[] = [this.scene.arenaEnemy, this.scene.arenaNextEnemy, this.scene.currentBattle.trainer, enemyField, this.scene.lastEnemyTrainer]; + const lastEncounterVisuals = this.scene.lastMysteryEncounter?.introVisuals; + if (lastEncounterVisuals) { + moveTargets.push(lastEncounterVisuals); + } + const nextEncounterVisuals = this.scene.currentBattle.mysteryEncounter?.introVisuals; + if (nextEncounterVisuals) { + const enterFromRight = nextEncounterVisuals.enterFromRight; + if (enterFromRight) { + nextEncounterVisuals.x += 500; + this.scene.tweens.add({ + targets: nextEncounterVisuals, + x: "-=200", + duration: 2000 + }); + } else { + moveTargets.push(nextEncounterVisuals); + } + } + this.scene.tweens.add({ - targets: [this.scene.arenaEnemy, this.scene.arenaNextEnemy, this.scene.currentBattle.trainer, enemyField, this.scene.lastEnemyTrainer].flat(), + targets: moveTargets.flat(), x: "+=300", duration: 2000, onComplete: () => { @@ -36,6 +56,10 @@ export class NextEncounterPhase extends EncounterPhase { if (this.scene.lastEnemyTrainer) { this.scene.lastEnemyTrainer.destroy(); } + if (lastEncounterVisuals) { + this.scene.field.remove(lastEncounterVisuals, true); + this.scene.lastMysteryEncounter!.introVisuals = undefined; + } if (!this.tryOverrideForBattleSpec()) { this.doEncounterCommon(); diff --git a/src/phases/party-exp-phase.ts b/src/phases/party-exp-phase.ts new file mode 100644 index 000000000000..c5a254871cae --- /dev/null +++ b/src/phases/party-exp-phase.ts @@ -0,0 +1,31 @@ +import BattleScene from "#app/battle-scene"; +import { Phase } from "#app/phase"; + +/** + * Provides EXP to the player's party *without* doing any Pokemon defeated checks or queueing extraneous post-battle phases + * Intended to be used as a more 1-off phase to provide exp to the party (such as during MEs), rather than cleanup a battle entirely + */ +export class PartyExpPhase extends Phase { + expValue: number; + useWaveIndexMultiplier?: boolean; + pokemonParticipantIds?: Set; + + constructor(scene: BattleScene, expValue: number, useWaveIndexMultiplier?: boolean, pokemonParticipantIds?: Set) { + super(scene); + + this.expValue = expValue; + this.useWaveIndexMultiplier = useWaveIndexMultiplier; + this.pokemonParticipantIds = pokemonParticipantIds; + } + + /** + * Gives EXP to the party + */ + start() { + super.start(); + + this.scene.applyPartyExp(this.expValue, false, this.useWaveIndexMultiplier, this.pokemonParticipantIds); + + this.end(); + } +} diff --git a/src/phases/post-summon-phase.ts b/src/phases/post-summon-phase.ts index 47a5513f0eb9..e7f6c6ea3db8 100644 --- a/src/phases/post-summon-phase.ts +++ b/src/phases/post-summon-phase.ts @@ -4,6 +4,9 @@ import { applyPostSummonAbAttrs, PostSummonAbAttr } from "#app/data/ability"; import { ArenaTrapTag } from "#app/data/arena-tag"; import { StatusEffect } from "#app/enums/status-effect"; import { PokemonPhase } from "./pokemon-phase"; +import { MysteryEncounterPostSummonTag } from "#app/data/battler-tags"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { BattleType } from "#app/battle"; export class PostSummonPhase extends PokemonPhase { constructor(scene: BattleScene, battlerIndex: BattlerIndex) { @@ -19,6 +22,12 @@ export class PostSummonPhase extends PokemonPhase { pokemon.status.turnCount = 0; } this.scene.arena.applyTags(ArenaTrapTag, pokemon); + + // If this is mystery encounter and has post summon phase tag, apply post summon effects + if (this.scene.currentBattle.battleType === BattleType.MYSTERY_ENCOUNTER && pokemon.findTags(t => t instanceof MysteryEncounterPostSummonTag).length > 0) { + pokemon.lapseTag(BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON); + } + applyPostSummonAbAttrs(PostSummonAbAttr, pokemon).then(() => this.end()); } } diff --git a/src/phases/select-modifier-phase.ts b/src/phases/select-modifier-phase.ts index e14638c5dd2c..39a0da1167f9 100644 --- a/src/phases/select-modifier-phase.ts +++ b/src/phases/select-modifier-phase.ts @@ -9,16 +9,20 @@ import i18next from "i18next"; import * as Utils from "#app/utils"; import { BattlePhase } from "./battle-phase"; import Overrides from "#app/overrides"; +import { CustomModifierSettings } from "#app/modifier/modifier-type"; +import { isNullOrUndefined } from "#app/utils"; export class SelectModifierPhase extends BattlePhase { private rerollCount: integer; - private modifierTiers: ModifierTier[]; + private modifierTiers?: ModifierTier[]; + private customModifierSettings?: CustomModifierSettings; - constructor(scene: BattleScene, rerollCount: integer = 0, modifierTiers?: ModifierTier[]) { + constructor(scene: BattleScene, rerollCount: integer = 0, modifierTiers?: ModifierTier[], customModifierSettings?: CustomModifierSettings) { super(scene); this.rerollCount = rerollCount; - this.modifierTiers = modifierTiers!; // TODO: is this bang correct? + this.modifierTiers = modifierTiers; + this.customModifierSettings = customModifierSettings; } start() { @@ -36,6 +40,20 @@ export class SelectModifierPhase extends BattlePhase { if (this.isPlayer()) { this.scene.applyModifiers(ExtraModifierModifier, true, modifierCount); } + + // If custom modifiers are specified, overrides default item count + if (!!this.customModifierSettings) { + const newItemCount = (this.customModifierSettings.guaranteedModifierTiers?.length || 0) + + (this.customModifierSettings.guaranteedModifierTypeOptions?.length || 0) + + (this.customModifierSettings.guaranteedModifierTypeFuncs?.length || 0); + if (this.customModifierSettings.fillRemaining) { + const originalCount = modifierCount.value; + modifierCount.value = originalCount > newItemCount ? originalCount : newItemCount; + } else { + modifierCount.value = newItemCount; + } + } + const typeOptions: ModifierTypeOption[] = this.getModifierTypeOptions(modifierCount.value); const modifierSelectCallback = (rowCursor: integer, cursor: integer) => { @@ -56,7 +74,7 @@ export class SelectModifierPhase extends BattlePhase { switch (cursor) { case 0: const rerollCost = this.getRerollCost(typeOptions, this.scene.lockModifierTiers); - if (this.scene.money < rerollCost) { + if (rerollCost < 0 || this.scene.money < rerollCost) { this.scene.ui.playError(); return false; } else { @@ -99,6 +117,12 @@ export class SelectModifierPhase extends BattlePhase { } return true; case 1: + if (typeOptions.length === 0) { + this.scene.ui.clearText(); + this.scene.ui.setMode(Mode.MESSAGE); + super.end(); + return true; + } if (typeOptions[cursor].type) { modifierType = typeOptions[cursor].type; } @@ -217,7 +241,18 @@ export class SelectModifierPhase extends BattlePhase { } else { baseValue = 250; } - return Math.min(Math.ceil(this.scene.currentBattle.waveIndex / 10) * baseValue * Math.pow(2, this.rerollCount), Number.MAX_SAFE_INTEGER); + + let multiplier = 1; + if (!isNullOrUndefined(this.customModifierSettings?.rerollMultiplier)) { + if (this.customModifierSettings!.rerollMultiplier! < 0) { + // Completely overrides reroll cost to -1 and early exits + return -1; + } + + // Otherwise, continue with custom multiplier + multiplier = this.customModifierSettings!.rerollMultiplier!; + } + return Math.min(Math.ceil(this.scene.currentBattle.waveIndex / 10) * baseValue * Math.pow(2, this.rerollCount) * multiplier, Number.MAX_SAFE_INTEGER); } getPoolType(): ModifierPoolType { @@ -225,7 +260,7 @@ export class SelectModifierPhase extends BattlePhase { } getModifierTypeOptions(modifierCount: integer): ModifierTypeOption[] { - return getPlayerModifierTypeOptions(modifierCount, this.scene.getParty(), this.scene.lockModifierTiers ? this.modifierTiers : undefined); + return getPlayerModifierTypeOptions(modifierCount, this.scene.getParty(), this.scene.lockModifierTiers ? this.modifierTiers : undefined, this.customModifierSettings); } addModifier(modifier: Modifier): Promise { diff --git a/src/phases/summon-phase.ts b/src/phases/summon-phase.ts index 2645060c5479..d909c5c35016 100644 --- a/src/phases/summon-phase.ts +++ b/src/phases/summon-phase.ts @@ -12,6 +12,7 @@ import { PartyMemberPokemonPhase } from "./party-member-pokemon-phase"; import { PostSummonPhase } from "./post-summon-phase"; import { GameOverPhase } from "./game-over-phase"; import { ShinySparklePhase } from "./shiny-sparkle-phase"; +import { MysteryEncounterMode } from "#enums/mystery-encounter-mode"; export class SummonPhase extends PartyMemberPokemonPhase { private loaded: boolean; @@ -33,8 +34,8 @@ export class SummonPhase extends PartyMemberPokemonPhase { */ preSummon(): void { const partyMember = this.getPokemon(); - // If the Pokemon about to be sent out is fainted or illegal under a challenge, switch to the first non-fainted legal Pokemon - if (!partyMember.isAllowedInBattle()) { + // If the Pokemon about to be sent out is fainted, illegal under a challenge, or no longer in the party for some reason, switch to the first non-fainted legal Pokemon + if (!partyMember.isAllowedInBattle() || (this.player && !this.getParty().some(p => p.id === partyMember.id))) { console.warn("The Pokemon about to be sent out is fainted or illegal under a challenge. Attempting to resolve..."); // First check if they're somehow still in play, if so remove them. @@ -79,16 +80,22 @@ export class SummonPhase extends PartyMemberPokemonPhase { onComplete: () => this.scene.trainer.setVisible(false) }); this.scene.time.delayedCall(750, () => this.summon()); - } else { + } else if (this.scene.currentBattle.battleType === BattleType.TRAINER || this.scene.currentBattle.mysteryEncounter?.encounterMode === MysteryEncounterMode.TRAINER_BATTLE) { const trainerName = this.scene.currentBattle.trainer?.getName(!(this.fieldIndex % 2) ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER); const pokemonName = this.getPokemon().getNameToRender(); const message = i18next.t("battle:trainerSendOut", { trainerName, pokemonName }); this.scene.pbTrayEnemy.hide(); this.scene.ui.showText(message, null, () => this.summon()); + } else if (this.scene.currentBattle.battleType === BattleType.MYSTERY_ENCOUNTER) { + this.scene.pbTrayEnemy.hide(); + this.summonWild(); } } + /** + * Enemy trainer or player trainer will do animations to throw Pokeball and summon a Pokemon to the field. + */ summon(): void { const pokemon = this.getPokemon(); @@ -167,6 +174,63 @@ export class SummonPhase extends PartyMemberPokemonPhase { }); } + /** + * Handles tweening and battle setup for a wild Pokemon that appears outside of the normal screen transition. + * Wild Pokemon will ease and fade in onto the field, then perform standard summon behavior. + * Currently only used by Mystery Encounters, as all other battle types pre-summon wild pokemon before screen transitions. + */ + summonWild(): void { + const pokemon = this.getPokemon(); + + if (this.fieldIndex === 1) { + pokemon.setFieldPosition(FieldPosition.RIGHT, 0); + } else { + const availablePartyMembers = this.getParty().filter(p => !p.isFainted()).length; + pokemon.setFieldPosition(!this.scene.currentBattle.double || availablePartyMembers === 1 ? FieldPosition.CENTER : FieldPosition.LEFT); + } + + this.scene.add.existing(pokemon); + this.scene.field.add(pokemon); + if (!this.player) { + const playerPokemon = this.scene.getPlayerPokemon() as Pokemon; + if (playerPokemon?.visible) { + this.scene.field.moveBelow(pokemon, playerPokemon); + } + this.scene.currentBattle.seenEnemyPartyMemberIds.add(pokemon.id); + } + this.scene.updateModifiers(this.player); + this.scene.updateFieldScale(); + pokemon.showInfo(); + pokemon.playAnim(); + pokemon.setVisible(true); + pokemon.getSprite().setVisible(true); + pokemon.setScale(0.75); + pokemon.tint(getPokeballTintColor(pokemon.pokeball)); + pokemon.untint(250, "Sine.easeIn"); + this.scene.updateFieldScale(); + pokemon.x += 16; + pokemon.y -= 20; + pokemon.alpha = 0; + + // Ease pokemon in + this.scene.tweens.add({ + targets: pokemon, + x: "-=16", + y: "+=16", + alpha: 1, + duration: 1000, + ease: "Sine.easeIn", + scale: pokemon.getSpriteScale(), + onComplete: () => { + pokemon.cry(pokemon.getHpRatio() > 0.25 ? undefined : { rate: 0.85 }); + pokemon.getSprite().clearTint(); + pokemon.resetSummonData(); + this.scene.updateFieldScale(); + this.scene.time.delayedCall(1000, () => this.end()); + } + }); + } + onEnd(): void { const pokemon = this.getPokemon(); @@ -176,7 +240,7 @@ export class SummonPhase extends PartyMemberPokemonPhase { pokemon.resetTurnData(); - if (!this.loaded || this.scene.currentBattle.battleType === BattleType.TRAINER || (this.scene.currentBattle.waveIndex % 10) === 1) { + if (!this.loaded || [BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(this.scene.currentBattle.battleType) || (this.scene.currentBattle.waveIndex % 10) === 1) { this.scene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true); this.queuePostSummon(); } diff --git a/src/phases/turn-init-phase.ts b/src/phases/turn-init-phase.ts index 568cfdc57140..92547878f126 100644 --- a/src/phases/turn-init-phase.ts +++ b/src/phases/turn-init-phase.ts @@ -9,6 +9,7 @@ import { CommandPhase } from "./command-phase"; import { EnemyCommandPhase } from "./enemy-command-phase"; import { GameOverPhase } from "./game-over-phase"; import { TurnStartPhase } from "./turn-start-phase"; +import { handleMysteryEncounterBattleStartEffects, handleMysteryEncounterTurnStartEffects } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; export class TurnInitPhase extends FieldPhase { constructor(scene: BattleScene) { @@ -46,6 +47,14 @@ export class TurnInitPhase extends FieldPhase { //this.scene.pushPhase(new MoveAnimTestPhase(this.scene)); this.scene.eventTarget.dispatchEvent(new TurnInitEvent()); + handleMysteryEncounterBattleStartEffects(this.scene); + + // If true, will skip remainder of current phase (and not queue CommandPhases etc.) + if (handleMysteryEncounterTurnStartEffects(this.scene)) { + this.end(); + return; + } + this.scene.getField().forEach((pokemon, i) => { if (pokemon?.isActive()) { if (pokemon.isPlayer()) { diff --git a/src/phases/victory-phase.ts b/src/phases/victory-phase.ts index 9679a79a37d3..c11dd80b3aab 100644 --- a/src/phases/victory-phase.ts +++ b/src/phases/victory-phase.ts @@ -1,24 +1,25 @@ import BattleScene from "#app/battle-scene"; import { BattlerIndex, BattleType } from "#app/battle"; import { modifierTypes } from "#app/modifier/modifier-type"; -import { ExpShareModifier, ExpBalanceModifier, MultipleParticipantExpBonusModifier, PokemonExpBoosterModifier } from "#app/modifier/modifier"; -import * as Utils from "#app/utils"; -import Overrides from "#app/overrides"; import { BattleEndPhase } from "./battle-end-phase"; import { NewBattlePhase } from "./new-battle-phase"; import { PokemonPhase } from "./pokemon-phase"; import { AddEnemyBuffModifierPhase } from "./add-enemy-buff-modifier-phase"; import { EggLapsePhase } from "./egg-lapse-phase"; -import { ExpPhase } from "./exp-phase"; import { GameOverPhase } from "./game-over-phase"; import { ModifierRewardPhase } from "./modifier-reward-phase"; import { SelectModifierPhase } from "./select-modifier-phase"; -import { ShowPartyExpBarPhase } from "./show-party-exp-bar-phase"; import { TrainerVictoryPhase } from "./trainer-victory-phase"; +import { handleMysteryEncounterVictory } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; export class VictoryPhase extends PokemonPhase { - constructor(scene: BattleScene, battlerIndex: BattlerIndex) { + /** If true, indicates that the phase is intended for EXP purposes only, and not to continue a battle to next phase */ + isExpOnly: boolean; + + constructor(scene: BattleScene, battlerIndex: BattlerIndex | integer, isExpOnly: boolean = false) { super(scene, battlerIndex); + + this.isExpOnly = isExpOnly; } start() { @@ -26,88 +27,15 @@ export class VictoryPhase extends PokemonPhase { this.scene.gameData.gameStats.pokemonDefeated++; - const participantIds = this.scene.currentBattle.playerParticipantIds; - const party = this.scene.getParty(); - const expShareModifier = this.scene.findModifier(m => m instanceof ExpShareModifier) as ExpShareModifier; - const expBalanceModifier = this.scene.findModifier(m => m instanceof ExpBalanceModifier) as ExpBalanceModifier; - const multipleParticipantExpBonusModifier = this.scene.findModifier(m => m instanceof MultipleParticipantExpBonusModifier) as MultipleParticipantExpBonusModifier; - const nonFaintedPartyMembers = party.filter(p => p.hp); - const expPartyMembers = nonFaintedPartyMembers.filter(p => p.level < this.scene.getMaxExpLevel()); - const partyMemberExp: number[] = []; + const expValue = this.getPokemon().getExpValue(); + this.scene.applyPartyExp(expValue, true); - if (participantIds.size) { - let expValue = this.getPokemon().getExpValue(); - if (this.scene.currentBattle.battleType === BattleType.TRAINER) { - expValue = Math.floor(expValue * 1.5); - } - for (const partyMember of nonFaintedPartyMembers) { - const pId = partyMember.id; - const participated = participantIds.has(pId); - if (participated) { - partyMember.addFriendship(2); - } - if (!expPartyMembers.includes(partyMember)) { - continue; - } - if (!participated && !expShareModifier) { - partyMemberExp.push(0); - continue; - } - let expMultiplier = 0; - if (participated) { - expMultiplier += (1 / participantIds.size); - if (participantIds.size > 1 && multipleParticipantExpBonusModifier) { - expMultiplier += multipleParticipantExpBonusModifier.getStackCount() * 0.2; - } - } else if (expShareModifier) { - expMultiplier += (expShareModifier.getStackCount() * 0.2) / participantIds.size; - } - if (partyMember.pokerus) { - expMultiplier *= 1.5; - } - if (Overrides.XP_MULTIPLIER_OVERRIDE !== null) { - expMultiplier = Overrides.XP_MULTIPLIER_OVERRIDE; - } - const pokemonExp = new Utils.NumberHolder(expValue * expMultiplier); - this.scene.applyModifiers(PokemonExpBoosterModifier, true, partyMember, pokemonExp); - partyMemberExp.push(Math.floor(pokemonExp.value)); - } - - if (expBalanceModifier) { - let totalLevel = 0; - let totalExp = 0; - expPartyMembers.forEach((expPartyMember, epm) => { - totalExp += partyMemberExp[epm]; - totalLevel += expPartyMember.level; - }); - - const medianLevel = Math.floor(totalLevel / expPartyMembers.length); - - const recipientExpPartyMemberIndexes: number[] = []; - expPartyMembers.forEach((expPartyMember, epm) => { - if (expPartyMember.level <= medianLevel) { - recipientExpPartyMemberIndexes.push(epm); - } - }); - - const splitExp = Math.floor(totalExp / recipientExpPartyMemberIndexes.length); - - expPartyMembers.forEach((_partyMember, pm) => { - partyMemberExp[pm] = Phaser.Math.Linear(partyMemberExp[pm], recipientExpPartyMemberIndexes.indexOf(pm) > -1 ? splitExp : 0, 0.2 * expBalanceModifier.getStackCount()); - }); - } - - for (let pm = 0; pm < expPartyMembers.length; pm++) { - const exp = partyMemberExp[pm]; - - if (exp) { - const partyMemberIndex = party.indexOf(expPartyMembers[pm]); - this.scene.unshiftPhase(expPartyMembers[pm].isOnField() ? new ExpPhase(this.scene, partyMemberIndex, exp) : new ShowPartyExpBarPhase(this.scene, partyMemberIndex, exp)); - } - } + if (this.scene.currentBattle.battleType === BattleType.MYSTERY_ENCOUNTER) { + handleMysteryEncounterVictory(this.scene, false, this.isExpOnly); + return this.end(); } - if (!this.scene.getEnemyParty().find(p => this.scene.currentBattle.battleType ? !p?.isFainted(true) : p.isOnField())) { + if (!this.scene.getEnemyParty().find(p => this.scene.currentBattle.battleType === BattleType.WILD ? p.isOnField() : !p?.isFainted(true))) { this.scene.pushPhase(new BattleEndPhase(this.scene)); if (this.scene.currentBattle.battleType === BattleType.TRAINER) { this.scene.pushPhase(new TrainerVictoryPhase(this.scene)); diff --git a/src/pipelines/sprite.ts b/src/pipelines/sprite.ts index c9a76dc50a40..88d6ce2d3871 100644 --- a/src/pipelines/sprite.ts +++ b/src/pipelines/sprite.ts @@ -4,6 +4,7 @@ import Pokemon from "../field/pokemon"; import Trainer from "../field/trainer"; import FieldSpritePipeline from "./field-sprite"; import * as Utils from "../utils"; +import MysteryEncounterIntroVisuals from "../field/mystery-encounter-intro"; const spriteFragShader = ` #ifdef GL_FRAGMENT_PRECISION_HIGH @@ -37,6 +38,7 @@ uniform vec2 texFrameUv; uniform vec2 size; uniform vec2 texSize; uniform float yOffset; +uniform float yShadowOffset; uniform vec4 tone; uniform ivec4 baseVariantColors[32]; uniform vec4 variantColors[32]; @@ -251,7 +253,7 @@ void main() { float width = size.x - (yOffset / 2.0); float spriteX = ((floor(outPosition.x / fieldScale) - relPosition.x) / width) + 0.5; - float spriteY = ((floor(outPosition.y / fieldScale) - relPosition.y) / size.y); + float spriteY = ((floor(outPosition.y / fieldScale) - relPosition.y - yShadowOffset) / size.y); if (yCenter == 1) { spriteY += 0.5; @@ -338,6 +340,7 @@ export default class SpritePipeline extends FieldSpritePipeline { this.set2f("size", 0, 0); this.set2f("texSize", 0, 0); this.set1f("yOffset", 0); + this.set1f("yShadowOffset", 0); this.set4fv("tone", this._tone); } @@ -350,10 +353,11 @@ export default class SpritePipeline extends FieldSpritePipeline { const tone = data["tone"] as number[]; const teraColor = data["teraColor"] as integer[] ?? [ 0, 0, 0 ]; const hasShadow = data["hasShadow"] as boolean; + const yShadowOffset = data["yShadowOffset"] as number; const ignoreFieldPos = data["ignoreFieldPos"] as boolean; const ignoreOverride = data["ignoreOverride"] as boolean; - const isEntityObj = sprite.parentContainer instanceof Pokemon || sprite.parentContainer instanceof Trainer; + const isEntityObj = sprite.parentContainer instanceof Pokemon || sprite.parentContainer instanceof Trainer || sprite.parentContainer instanceof MysteryEncounterIntroVisuals; const field = isEntityObj ? sprite.parentContainer.parentContainer : sprite.parentContainer; const position = isEntityObj ? [ sprite.parentContainer.x, sprite.parentContainer.y ] @@ -376,6 +380,7 @@ export default class SpritePipeline extends FieldSpritePipeline { this.set2f("size", sprite.frame.width, sprite.height); this.set2f("texSize", sprite.texture.source[0].width, sprite.texture.source[0].height); this.set1f("yOffset", sprite.height - sprite.frame.height * (isEntityObj ? sprite.parentContainer.scale : sprite.scale)); + this.set1f("yShadowOffset", yShadowOffset ?? 0); this.set4fv("tone", tone); this.bindTexture(this.game.textures.get("tera").source[0].glTexture!, 1); // TODO: is this bang correct? @@ -447,14 +452,15 @@ export default class SpritePipeline extends FieldSpritePipeline { this.set1f("vCutoff", v1); const hasShadow = sprite.pipelineData["hasShadow"] as boolean; + const yShadowOffset = sprite.pipelineData["yShadowOffset"] as number ?? 0; if (hasShadow) { - const isEntityObj = sprite.parentContainer instanceof Pokemon || sprite.parentContainer instanceof Trainer; + const isEntityObj = sprite.parentContainer instanceof Pokemon || sprite.parentContainer instanceof Trainer || sprite.parentContainer instanceof MysteryEncounterIntroVisuals; const field = isEntityObj ? sprite.parentContainer.parentContainer : sprite.parentContainer; const fieldScaleRatio = field.scale / 6; const baseY = (isEntityObj ? sprite.parentContainer.y : sprite.y + sprite.height) * 6 / fieldScaleRatio; - const bottomPadding = Math.ceil(sprite.height * 0.05) * 6 / fieldScaleRatio; + const bottomPadding = Math.ceil(sprite.height * 0.05 + Math.max(yShadowOffset, 0)) * 6 / fieldScaleRatio; const yDelta = (baseY - y1) / field.scale; y2 = y1 = baseY + bottomPadding; const pixelHeight = (v1 - v0) / (sprite.frame.height * (isEntityObj ? sprite.parentContainer.scale : sprite.scale)); diff --git a/src/plugins/i18n.ts b/src/plugins/i18n.ts index 705fd5143a41..ec3fe93c7651 100644 --- a/src/plugins/i18n.ts +++ b/src/plugins/i18n.ts @@ -167,6 +167,25 @@ export async function initI18n(): Promise { postProcess: ["korean-postposition"], }); + // Input: {{myMoneyValue, money}} + // Output: @[MONEY]{₽100,000,000} (useful for BBCode coloring of text) + // If you don't want the BBCode tag applied, just use 'number' formatter + i18next.services.formatter?.add("money", (value, lng, options) => { + const numberFormattedString = Intl.NumberFormat(lng, options).format(value); + switch (lng) { + case "ja": + return `@[MONEY]{${numberFormattedString}}円`; + case "de": + case "es": + case "fr": + case "it": + return `@[MONEY]{${numberFormattedString} ₽}`; + default: + // English and other languages that use same format + return `@[MONEY]{₽${numberFormattedString}}`; + } + }); + await initFonts(localStorage.getItem("prLang") ?? undefined); } diff --git a/src/system/game-data.ts b/src/system/game-data.ts index 677bbe4add6a..04fef4a81da4 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -46,6 +46,8 @@ import { OutdatedPhase } from "#app/phases/outdated-phase"; import { ReloadSessionPhase } from "#app/phases/reload-session-phase"; import { RUN_HISTORY_LIMIT } from "#app/ui/run-history-ui-handler"; import { applySessionDataPatches, applySettingsDataPatches, applySystemDataPatches } from "./version-converter"; +import { MysteryEncounterSaveData } from "../data/mystery-encounters/mystery-encounter-save-data"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; export const defaultStarterSpecies: Species[] = [ Species.BULBASAUR, Species.CHARMANDER, Species.SQUIRTLE, @@ -130,6 +132,8 @@ export interface SessionSaveData { gameVersion: string; timestamp: integer; challenges: ChallengeData[]; + mysteryEncounterType: MysteryEncounterType | -1; // Only defined when current wave is ME, + mysteryEncounterSaveData: MysteryEncounterSaveData; } interface Unlocks { @@ -947,7 +951,9 @@ export class GameData { trainer: scene.currentBattle.battleType === BattleType.TRAINER ? new TrainerData(scene.currentBattle.trainer) : null, gameVersion: scene.game.config.gameVersion, timestamp: new Date().getTime(), - challenges: scene.gameMode.challenges.map(c => new ChallengeData(c)) + challenges: scene.gameMode.challenges.map(c => new ChallengeData(c)), + mysteryEncounterType: scene.currentBattle.mysteryEncounter?.encounterType ?? -1, + mysteryEncounterSaveData: scene.mysteryEncounterSaveData } as SessionSaveData; } @@ -1038,11 +1044,14 @@ export class GameData { scene.score = sessionData.score; scene.updateScoreText(); + scene.mysteryEncounterSaveData = new MysteryEncounterSaveData(sessionData.mysteryEncounterSaveData); + scene.newArena(sessionData.arena.biome); const battleType = sessionData.battleType || 0; const trainerConfig = sessionData.trainer ? trainerConfigs[sessionData.trainer.trainerType] : null; - const battle = scene.newBattle(sessionData.waveIndex, battleType, sessionData.trainer, battleType === BattleType.TRAINER ? trainerConfig?.doubleOnly || sessionData.trainer?.variant === TrainerVariant.DOUBLE : sessionData.enemyParty.length > 1)!; // TODO: is this bang correct? + const mysteryEncounterType = sessionData.mysteryEncounterType !== -1 ? sessionData.mysteryEncounterType : undefined; + const battle = scene.newBattle(sessionData.waveIndex, battleType, sessionData.trainer, battleType === BattleType.TRAINER ? trainerConfig?.doubleOnly || sessionData.trainer?.variant === TrainerVariant.DOUBLE : sessionData.enemyParty.length > 1, mysteryEncounterType)!; // TODO: is this bang correct? battle.enemyLevels = sessionData.enemyParty.map(p => p.level); scene.arena.init(); @@ -1254,6 +1263,14 @@ export class GameData { return ret; } + if (k === "mysteryEncounterType") { + return v as MysteryEncounterType; + } + + if (k === "mysteryEncounterSaveData") { + return new MysteryEncounterSaveData(v); + } + return v; }) as SessionSaveData; @@ -1538,12 +1555,28 @@ export class GameData { } } - setPokemonCaught(pokemon: Pokemon, incrementCount: boolean = true, fromEgg: boolean = false, showMessage: boolean = true): Promise { + /** + * + * @param pokemon + * @param incrementCount + * @param fromEgg + * @param showMessage + * @returns `true` if Pokemon catch unlocked a new starter, `false` if Pokemon catch did not unlock a starter + */ + setPokemonCaught(pokemon: Pokemon, incrementCount: boolean = true, fromEgg: boolean = false, showMessage: boolean = true): Promise { return this.setPokemonSpeciesCaught(pokemon, pokemon.species, incrementCount, fromEgg, showMessage); } - setPokemonSpeciesCaught(pokemon: Pokemon, species: PokemonSpecies, incrementCount: boolean = true, fromEgg: boolean = false, showMessage: boolean = true): Promise { - return new Promise(resolve => { + /** + * + * @param pokemon + * @param incrementCount + * @param fromEgg + * @param showMessage + * @returns `true` if Pokemon catch unlocked a new starter, `false` if Pokemon catch did not unlock a starter + */ + setPokemonSpeciesCaught(pokemon: Pokemon, species: PokemonSpecies, incrementCount: boolean = true, fromEgg: boolean = false, showMessage: boolean = true): Promise { + return new Promise(resolve => { const dexEntry = this.dexData[species.speciesId]; const caughtAttr = dexEntry.caughtAttr; const formIndex = pokemon.formIndex; @@ -1598,24 +1631,24 @@ export class GameData { } } - const checkPrevolution = () => { + const checkPrevolution = (newStarter: boolean) => { if (hasPrevolution) { const prevolutionSpecies = pokemonPrevolutions[species.speciesId]; - this.setPokemonSpeciesCaught(pokemon, getPokemonSpecies(prevolutionSpecies), incrementCount, fromEgg, showMessage).then(() => resolve()); + this.setPokemonSpeciesCaught(pokemon, getPokemonSpecies(prevolutionSpecies), incrementCount, fromEgg, showMessage).then(result => resolve(result)); } else { - resolve(); + resolve(newStarter); } }; if (newCatch && speciesStarters.hasOwnProperty(species.speciesId)) { if (!showMessage) { - resolve(); + resolve(true); return; } this.scene.playSound("level_up_fanfare"); - this.scene.ui.showText(i18next.t("battle:addedAsAStarter", { pokemonName: species.name }), null, () => checkPrevolution(), null, true); + this.scene.ui.showText(i18next.t("battle:addedAsAStarter", { pokemonName: species.name }), null, () => checkPrevolution(true), null, true); } else { - checkPrevolution(); + checkPrevolution(false); } }); } @@ -1657,7 +1690,14 @@ export class GameData { this.starterData[species.speciesId].candyCount += count; } - setEggMoveUnlocked(species: PokemonSpecies, eggMoveIndex: integer, showMessage: boolean = true): Promise { + /** + * + * @param species + * @param eggMoveIndex + * @param showMessage Default true. If true, will display message for unlocked egg move + * @param prependSpeciesToMessage Default false. If true, will change message from "X Egg Move Unlocked!" to "Bulbasaur X Egg Move Unlocked!" + */ + setEggMoveUnlocked(species: PokemonSpecies, eggMoveIndex: integer, showMessage: boolean = true, prependSpeciesToMessage: boolean = false): Promise { return new Promise(resolve => { const speciesId = species.speciesId; if (!speciesEggMoves.hasOwnProperty(speciesId) || !speciesEggMoves[speciesId][eggMoveIndex]) { @@ -1683,9 +1723,10 @@ export class GameData { } this.scene.playSound("level_up_fanfare"); const moveName = allMoves[speciesEggMoves[speciesId][eggMoveIndex]].name; - this.scene.ui.showText(eggMoveIndex === 3 ? i18next.t("egg:rareEggMoveUnlock", { moveName: moveName }) : i18next.t("egg:eggMoveUnlock", { moveName: moveName }), null, (() => { - resolve(true); - }), null, true); + let message = prependSpeciesToMessage ? species.getName() + " " : ""; + message += eggMoveIndex === 3 ? i18next.t("egg:rareEggMoveUnlock", { moveName: moveName }) : i18next.t("egg:eggMoveUnlock", { moveName: moveName }); + + this.scene.ui.showText(message, null, () => resolve(true), null, true); }); } diff --git a/src/system/pokemon-data.ts b/src/system/pokemon-data.ts index 1fafcbf8acc3..92bfc627ddbd 100644 --- a/src/system/pokemon-data.ts +++ b/src/system/pokemon-data.ts @@ -12,6 +12,7 @@ import { loadBattlerTag } from "../data/battler-tags"; import { Biome } from "#enums/biome"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; +import { MysteryEncounterPokemonData } from "#app/data/mystery-encounters/mystery-encounter-pokemon-data"; export default class PokemonData { public id: integer; @@ -56,6 +57,8 @@ export default class PokemonData { public bossSegments?: integer; public summonData: PokemonSummonData; + /** Data that can customize a Pokemon in non-standard ways from its Species */ + public mysteryEncounterPokemonData: MysteryEncounterPokemonData; constructor(source: Pokemon | any, forHistory: boolean = false) { const sourcePokemon = source instanceof Pokemon ? source : null; @@ -101,6 +104,8 @@ export default class PokemonData { this.fusionLuck = source.fusionLuck !== undefined ? source.fusionLuck : (source.fusionShiny ? source.fusionVariant + 1 : 0); this.usedTMs = source.usedTMs ?? []; + this.mysteryEncounterPokemonData = new MysteryEncounterPokemonData(source.mysteryEncounterPokemonData); + if (!forHistory) { this.boss = (source instanceof EnemyPokemon && !!source.bossSegments) || (!this.player && !!source.boss); this.bossSegments = source.bossSegments; diff --git a/src/test/mystery-encounter/encounter-test-utils.ts b/src/test/mystery-encounter/encounter-test-utils.ts new file mode 100644 index 000000000000..a31ee150bfde --- /dev/null +++ b/src/test/mystery-encounter/encounter-test-utils.ts @@ -0,0 +1,176 @@ +import { Button } from "#app/enums/buttons"; +import { MysteryEncounterBattlePhase, MysteryEncounterOptionSelectedPhase, MysteryEncounterPhase, MysteryEncounterRewardsPhase } from "#app/phases/mystery-encounter-phases"; +import MysteryEncounterUiHandler from "#app/ui/mystery-encounter-ui-handler"; +import { Mode } from "#app/ui/ui"; +import GameManager from "../utils/gameManager"; +import MessageUiHandler from "#app/ui/message-ui-handler"; +import { Status, StatusEffect } from "#app/data/status-effect"; +import { expect, vi } from "vitest"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import PartyUiHandler from "#app/ui/party-ui-handler"; +import OptionSelectUiHandler from "#app/ui/settings/option-select-ui-handler"; +import { isNullOrUndefined } from "#app/utils"; +import { CommandPhase } from "#app/phases/command-phase"; +import { VictoryPhase } from "#app/phases/victory-phase"; +import { MessagePhase } from "#app/phases/message-phase"; + +/** + * Runs a {@linkcode MysteryEncounter} to either the start of a battle, or to the {@linkcode MysteryEncounterRewardsPhase}, depending on the option selected + * @param game + * @param optionNo Human number, not index + * @param secondaryOptionSelect + * @param isBattle If selecting option should lead to battle, set to `true` + */ +export async function runMysteryEncounterToEnd(game: GameManager, optionNo: number, secondaryOptionSelect?: { pokemonNo: number, optionNo?: number }, isBattle: boolean = false) { + vi.spyOn(EncounterPhaseUtils, "selectPokemonForOption"); + await runSelectMysteryEncounterOption(game, optionNo, secondaryOptionSelect); + + // run the selected options phase + game.onNextPrompt("MysteryEncounterOptionSelectedPhase", Mode.MESSAGE, () => { + const uiHandler = game.scene.ui.getHandler(); + uiHandler.processInput(Button.ACTION); + }, () => game.isCurrentPhase(MysteryEncounterBattlePhase) || game.isCurrentPhase(MysteryEncounterRewardsPhase)); + + if (isBattle) { + game.onNextPrompt("DamagePhase", Mode.MESSAGE, () => { + game.setMode(Mode.MESSAGE); + game.endPhase(); + }, () => game.isCurrentPhase(CommandPhase)); + + game.onNextPrompt("CheckSwitchPhase", Mode.CONFIRM, () => { + game.setMode(Mode.MESSAGE); + game.endPhase(); + }, () => game.isCurrentPhase(CommandPhase)); + + game.onNextPrompt("CheckSwitchPhase", Mode.MESSAGE, () => { + game.setMode(Mode.MESSAGE); + game.endPhase(); + }, () => game.isCurrentPhase(CommandPhase)); + + // If a battle is started, fast forward to end of the battle + game.onNextPrompt("CommandPhase", Mode.COMMAND, () => { + game.scene.clearPhaseQueue(); + game.scene.clearPhaseQueueSplice(); + game.scene.unshiftPhase(new VictoryPhase(game.scene, 0)); + game.endPhase(); + }); + + // Handle end of battle trainer messages + game.onNextPrompt("TrainerVictoryPhase", Mode.MESSAGE, () => { + const uiHandler = game.scene.ui.getHandler(); + uiHandler.processInput(Button.ACTION); + }); + + // Handle egg hatch dialogue + game.onNextPrompt("EggLapsePhase", Mode.MESSAGE, () => { + const uiHandler = game.scene.ui.getHandler(); + uiHandler.processInput(Button.ACTION); + }); + + await game.phaseInterceptor.to(CommandPhase); + } else { + await game.phaseInterceptor.to(MysteryEncounterRewardsPhase); + } +} + +export async function runSelectMysteryEncounterOption(game: GameManager, optionNo: number, secondaryOptionSelect?: { pokemonNo: number, optionNo?: number }) { + // Handle any eventual queued messages (e.g. weather phase, etc.) + game.onNextPrompt("MessagePhase", Mode.MESSAGE, () => { + const uiHandler = game.scene.ui.getHandler(); + uiHandler.processInput(Button.ACTION); + }, () => game.isCurrentPhase(MysteryEncounterOptionSelectedPhase)); + + if (game.isCurrentPhase(MessagePhase)) { + await game.phaseInterceptor.run(MessagePhase); + } + + // dispose of intro messages + game.onNextPrompt("MysteryEncounterPhase", Mode.MESSAGE, () => { + const uiHandler = game.scene.ui.getHandler(); + uiHandler.processInput(Button.ACTION); + }, () => game.isCurrentPhase(MysteryEncounterOptionSelectedPhase)); + + await game.phaseInterceptor.to(MysteryEncounterPhase, true); + + // select the desired option + const uiHandler = game.scene.ui.getHandler(); + uiHandler.unblockInput(); // input are blocked by 1s to prevent accidental input. Tests need to handle that + + switch (optionNo) { + default: + case 1: + // no movement needed. Default cursor position + break; + case 2: + uiHandler.processInput(Button.RIGHT); + break; + case 3: + uiHandler.processInput(Button.DOWN); + break; + case 4: + uiHandler.processInput(Button.RIGHT); + uiHandler.processInput(Button.DOWN); + break; + } + + if (!isNullOrUndefined(secondaryOptionSelect?.pokemonNo)) { + await handleSecondaryOptionSelect(game, secondaryOptionSelect!.pokemonNo, secondaryOptionSelect!.optionNo); + } else { + uiHandler.processInput(Button.ACTION); + } +} + +async function handleSecondaryOptionSelect(game: GameManager, pokemonNo: number, optionNo?: number) { + // Handle secondary option selections + const partyUiHandler = game.scene.ui.handlers[Mode.PARTY] as PartyUiHandler; + vi.spyOn(partyUiHandler, "show"); + + const encounterUiHandler = game.scene.ui.getHandler(); + encounterUiHandler.processInput(Button.ACTION); + + await vi.waitFor(() => expect(partyUiHandler.show).toHaveBeenCalled()); + + for (let i = 1; i < pokemonNo; i++) { + partyUiHandler.processInput(Button.DOWN); + } + + // Open options on Pokemon + partyUiHandler.processInput(Button.ACTION); + // Click "Select" on Pokemon options + partyUiHandler.processInput(Button.ACTION); + + // If there is a second choice to make after selecting a Pokemon + if (!isNullOrUndefined(optionNo)) { + // Wait for Summary menu to close and second options to spawn + const secondOptionUiHandler = game.scene.ui.handlers[Mode.OPTION_SELECT] as OptionSelectUiHandler; + vi.spyOn(secondOptionUiHandler, "show"); + await vi.waitFor(() => expect(secondOptionUiHandler.show).toHaveBeenCalled()); + + // Navigate down to the correct option + for (let i = 1; i < optionNo!; i++) { + secondOptionUiHandler.processInput(Button.DOWN); + } + + // Select the option + secondOptionUiHandler.processInput(Button.ACTION); + } +} + +/** + * For any {@linkcode MysteryEncounter} that has a battle, can call this to skip battle and proceed to {@linkcode MysteryEncounterRewardsPhase} + * @param game + * @param runRewardsPhase + */ +export async function skipBattleRunMysteryEncounterRewardsPhase(game: GameManager, runRewardsPhase: boolean = true) { + game.scene.clearPhaseQueue(); + game.scene.clearPhaseQueueSplice(); + game.scene.getEnemyParty().forEach(p => { + p.hp = 0; + p.status = new Status(StatusEffect.FAINT); + game.scene.field.remove(p); + }); + game.scene.pushPhase(new VictoryPhase(game.scene, 0)); + game.phaseInterceptor.superEndPhase(); + game.setMode(Mode.MESSAGE); + await game.phaseInterceptor.to(MysteryEncounterRewardsPhase, runRewardsPhase); +} diff --git a/src/test/mystery-encounter/encounters/a-trainers-test-encounter.test.ts b/src/test/mystery-encounter/encounters/a-trainers-test-encounter.test.ts new file mode 100644 index 000000000000..b4cc186864c7 --- /dev/null +++ b/src/test/mystery-encounter/encounters/a-trainers-test-encounter.test.ts @@ -0,0 +1,205 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { HUMAN_TRANSITABLE_BIOMES } from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { runMysteryEncounterToEnd, skipBattleRunMysteryEncounterRewardsPhase } from "#test/mystery-encounter/encounter-test-utils"; +import BattleScene from "#app/battle-scene"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import { ATrainersTestEncounter } from "#app/data/mystery-encounters/encounters/a-trainers-test-encounter"; +import { EggTier } from "#enums/egg-type"; +import { CommandPhase } from "#app/phases/command-phase"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; +import { PartyHealPhase } from "#app/phases/party-heal-phase"; + +const namespace = "mysteryEncounter:aTrainersTest"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("A Trainer's Test - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + const biomeMap = new Map([ + [Biome.VOLCANO, [MysteryEncounterType.MYSTERIOUS_CHALLENGERS]], + ]); + HUMAN_TRANSITABLE_BIOMES.forEach(biome => { + biomeMap.set(biome, [MysteryEncounterType.A_TRAINERS_TEST]); + }); + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue(biomeMap); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.A_TRAINERS_TEST, defaultParty); + + expect(ATrainersTestEncounter.encounterType).toBe(MysteryEncounterType.A_TRAINERS_TEST); + expect(ATrainersTestEncounter.encounterTier).toBe(MysteryEncounterTier.ROGUE); + expect(ATrainersTestEncounter.dialogue).toBeDefined(); + expect(ATrainersTestEncounter.dialogue.intro).toBeDefined(); + expect(ATrainersTestEncounter.dialogue.intro?.[0].speaker).toBeDefined(); + expect(ATrainersTestEncounter.dialogue.intro?.[0].text).toBeDefined(); + expect(ATrainersTestEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(ATrainersTestEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(ATrainersTestEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(ATrainersTestEncounter.options.length).toBe(2); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.A_TRAINERS_TEST); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should initialize fully ", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = ATrainersTestEncounter; + + const { onInit } = ATrainersTestEncounter; + + expect(ATrainersTestEncounter.onInit).toBeDefined(); + + ATrainersTestEncounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + + expect(ATrainersTestEncounter.dialogueTokens?.statTrainerName).toBeDefined(); + expect(ATrainersTestEncounter.misc.trainerType).toBeDefined(); + expect(ATrainersTestEncounter.misc.trainerNameKey).toBeDefined(); + expect(ATrainersTestEncounter.misc.trainerEggDescription).toBeDefined(); + expect(ATrainersTestEncounter.dialogue.intro).toBeDefined(); + expect(ATrainersTestEncounter.options[1].dialogue?.selected).toBeDefined(); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Accept the Challenge", () => { + it("should have the correct properties", () => { + const option = ATrainersTestEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue!.buttonLabel).toStrictEqual(`${namespace}.option.1.label`); + expect(option.dialogue!.buttonTooltip).toStrictEqual(`${namespace}.option.1.tooltip`); + }); + + it("Should start battle against the trainer", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.A_TRAINERS_TEST, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + const enemyField = scene.getEnemyField(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyField.length).toBe(1); + expect(scene.currentBattle.trainer).toBeDefined(); + expect(["buck", "cheryl", "marley", "mira", "riley"].includes(scene.currentBattle.trainer!.config.name.toLowerCase())).toBeTruthy(); + expect(enemyField[0]).toBeDefined(); + }); + + it("Should reward the player with an Epic or Legendary egg", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.A_TRAINERS_TEST, defaultParty); + + const eggsBefore = scene.gameData.eggs; + expect(eggsBefore).toBeDefined(); + const eggsBeforeLength = eggsBefore.length; + + await runMysteryEncounterToEnd(game, 1, undefined, true); + await skipBattleRunMysteryEncounterRewardsPhase(game); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + + const eggsAfter = scene.gameData.eggs; + expect(eggsAfter).toBeDefined(); + expect(eggsBeforeLength + 1).toBe(eggsAfter.length); + const eggTier = eggsAfter[eggsAfter.length - 1].tier; + expect(eggTier === EggTier.ULTRA || eggTier === EggTier.MASTER).toBeTruthy(); + }); + }); + + describe("Option 2 - Decline the Challenge", () => { + beforeEach(() => { + // Mock sound object + vi.spyOn(scene, "playSoundWithoutBgm").mockImplementation(() => { + return { + totalDuration: 1, + destroy: () => null + } as any; + }); + }); + + it("should have the correct properties", () => { + const option = ATrainersTestEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue?.buttonLabel).toStrictEqual(`${namespace}.option.2.label`); + expect(option.dialogue?.buttonTooltip).toStrictEqual(`${namespace}.option.2.tooltip`); + }); + + it("Should fully heal the party", async () => { + const phaseSpy = vi.spyOn(scene, "unshiftPhase"); + + await game.runToMysteryEncounter(MysteryEncounterType.A_TRAINERS_TEST, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + const partyHealPhases = phaseSpy.mock.calls.filter(p => p[0] instanceof PartyHealPhase).map(p => p[0]); + expect(partyHealPhases.length).toBe(1); + }); + + it("Should reward the player with a Rare egg", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.A_TRAINERS_TEST, defaultParty); + + const eggsBefore = scene.gameData.eggs; + expect(eggsBefore).toBeDefined(); + const eggsBeforeLength = eggsBefore.length; + + await runMysteryEncounterToEnd(game, 2); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + + const eggsAfter = scene.gameData.eggs; + expect(eggsAfter).toBeDefined(); + expect(eggsBeforeLength + 1).toBe(eggsAfter.length); + const eggTier = eggsAfter[eggsAfter.length - 1].tier; + expect(eggTier).toBe(EggTier.GREAT); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.A_TRAINERS_TEST, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/absolute-avarice-encounter.test.ts b/src/test/mystery-encounter/encounters/absolute-avarice-encounter.test.ts new file mode 100644 index 000000000000..7cca7abba27b --- /dev/null +++ b/src/test/mystery-encounter/encounters/absolute-avarice-encounter.test.ts @@ -0,0 +1,270 @@ +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { runMysteryEncounterToEnd, skipBattleRunMysteryEncounterRewardsPhase } from "#test/mystery-encounter/encounter-test-utils"; +import BattleScene from "#app/battle-scene"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { BerryModifier, PokemonHeldItemModifier } from "#app/modifier/modifier"; +import { BerryType } from "#enums/berry-type"; +import { AbsoluteAvariceEncounter } from "#app/data/mystery-encounters/encounters/absolute-avarice-encounter"; +import { Moves } from "#enums/moves"; +import { CommandPhase } from "#app/phases/command-phase"; +import { MovePhase } from "#app/phases/move-phase"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; + +const namespace = "mysteryEncounter:absoluteAvarice"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.PLAINS; +const defaultWave = 45; + +describe("Absolute Avarice - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.PLAINS, [MysteryEncounterType.ABSOLUTE_AVARICE]], + [Biome.VOLCANO, [MysteryEncounterType.MYSTERIOUS_CHALLENGERS]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.ABSOLUTE_AVARICE, defaultParty); + + expect(AbsoluteAvariceEncounter.encounterType).toBe(MysteryEncounterType.ABSOLUTE_AVARICE); + expect(AbsoluteAvariceEncounter.encounterTier).toBe(MysteryEncounterTier.GREAT); + expect(AbsoluteAvariceEncounter.dialogue).toBeDefined(); + expect(AbsoluteAvariceEncounter.dialogue.intro).toStrictEqual([{ text: `${namespace}.intro` }]); + expect(AbsoluteAvariceEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(AbsoluteAvariceEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(AbsoluteAvariceEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(AbsoluteAvariceEncounter.options.length).toBe(3); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.ABSOLUTE_AVARICE); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should not spawn outside of proper biomes", async () => { + game.override.mysteryEncounterTier(MysteryEncounterTier.GREAT); + game.override.startingBiome(Biome.VOLCANO); + await game.runToMysteryEncounter(); + + expect(game.scene.currentBattle.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.ABSOLUTE_AVARICE); + }); + + it("should not spawn if player does not have enough berries", async () => { + scene.modifiers = []; + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.ABSOLUTE_AVARICE); + }); + + it("should spawn if player has enough berries", async () => { + game.override.mysteryEncounterTier(MysteryEncounterTier.GREAT); + game.override.startingHeldItems([{name: "BERRY", count: 2, type: BerryType.SITRUS}, {name: "BERRY", count: 3, type: BerryType.GANLON}]); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).toBe(MysteryEncounterType.ABSOLUTE_AVARICE); + }); + + it("should remove all player's berries at the start of the encounter", async () => { + game.override.startingHeldItems([{name: "BERRY", count: 2, type: BerryType.SITRUS}, {name: "BERRY", count: 3, type: BerryType.GANLON}]); + + await game.runToMysteryEncounter(MysteryEncounterType.ABSOLUTE_AVARICE, defaultParty); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).toBe(MysteryEncounterType.ABSOLUTE_AVARICE); + expect(scene.modifiers?.length).toBe(0); + }); + + describe("Option 1 - Fight the Greedent", () => { + it("should have the correct properties", () => { + const option1 = AbsoluteAvariceEncounter.options[0]; + expect(option1.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option1.dialogue).toBeDefined(); + expect(option1.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("should start battle against Greedent", async () => { + const phaseSpy = vi.spyOn(scene, "pushPhase"); + + await game.runToMysteryEncounter(MysteryEncounterType.ABSOLUTE_AVARICE, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + const enemyField = scene.getEnemyField(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyField.length).toBe(1); + expect(enemyField[0].species.speciesId).toBe(Species.GREEDENT); + const moveset = enemyField[0].moveset.map(m => m?.moveId); + expect(moveset?.length).toBe(4); + expect(moveset).toEqual([Moves.THRASH, Moves.BODY_PRESS, Moves.STUFF_CHEEKS, Moves.SLACK_OFF]); + + const movePhases = phaseSpy.mock.calls.filter(p => p[0] instanceof MovePhase).map(p => p[0]); + expect(movePhases.length).toBe(1); + expect(movePhases.filter(p => (p as MovePhase).move.moveId === Moves.STUFF_CHEEKS).length).toBe(1); // Stuff Cheeks used before battle + }); + + it("should give reviver seed to each pokemon after battle", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.ABSOLUTE_AVARICE, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + await skipBattleRunMysteryEncounterRewardsPhase(game); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + + for (const partyPokemon of scene.getParty()) { + const pokemonId = partyPokemon.id; + const pokemonItems = scene.findModifiers(m => m instanceof PokemonHeldItemModifier + && (m as PokemonHeldItemModifier).pokemonId === pokemonId, true) as PokemonHeldItemModifier[]; + const revSeed = pokemonItems.find(i => i.type.name === "Reviver Seed"); + expect(revSeed).toBeDefined; + expect(revSeed?.stackCount).toBe(1); + } + }); + }); + + describe("Option 2 - Reason with It", () => { + it("should have the correct properties", () => { + const option = AbsoluteAvariceEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }); + }); + + it("Should return 3 (2/5ths floored) berries if 8 were stolen", {retry: 5}, async () => { + game.override.startingHeldItems([{name: "BERRY", count: 2, type: BerryType.SITRUS}, {name: "BERRY", count: 3, type: BerryType.GANLON}, {name: "BERRY", count: 3, type: BerryType.APICOT}]); + + await game.runToMysteryEncounter(MysteryEncounterType.ABSOLUTE_AVARICE, defaultParty); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).toBe(MysteryEncounterType.ABSOLUTE_AVARICE); + expect(scene.modifiers?.length).toBe(0); + + await runMysteryEncounterToEnd(game, 2); + + const berriesAfter = scene.findModifiers(m => m instanceof BerryModifier); + const berryCountAfter = berriesAfter.reduce((a, b) => a + b.stackCount, 0); + expect(berriesAfter).toBeDefined(); + expect(berryCountAfter).toBe(3); + }); + + it("Should return 2 (2/5ths floored) berries if 7 were stolen", {retry: 5}, async () => { + game.override.startingHeldItems([{name: "BERRY", count: 2, type: BerryType.SITRUS}, {name: "BERRY", count: 3, type: BerryType.GANLON}, {name: "BERRY", count: 2, type: BerryType.APICOT}]); + + await game.runToMysteryEncounter(MysteryEncounterType.ABSOLUTE_AVARICE, defaultParty); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).toBe(MysteryEncounterType.ABSOLUTE_AVARICE); + expect(scene.modifiers?.length).toBe(0); + + await runMysteryEncounterToEnd(game, 2); + + const berriesAfter = scene.findModifiers(m => m instanceof BerryModifier); + const berryCountAfter = berriesAfter.reduce((a, b) => a + b.stackCount, 0); + expect(berriesAfter).toBeDefined(); + expect(berryCountAfter).toBe(2); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.ABSOLUTE_AVARICE, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 3 - Let it have the food", () => { + it("should have the correct properties", () => { + const option = AbsoluteAvariceEncounter.options[2]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + ], + }); + }); + + it("should add Greedent to the party", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.ABSOLUTE_AVARICE, defaultParty); + const partyCountBefore = scene.getParty().length; + + await runMysteryEncounterToEnd(game, 3); + const partyCountAfter = scene.getParty().length; + + expect(partyCountBefore + 1).toBe(partyCountAfter); + const greedent = scene.getParty()[scene.getParty().length - 1]; + expect(greedent.species.speciesId).toBe(Species.GREEDENT); + const moveset = greedent.moveset.map(m => m?.moveId); + expect(moveset?.length).toBe(4); + expect(moveset).toEqual([Moves.THRASH, Moves.BODY_PRESS, Moves.STUFF_CHEEKS, Moves.SLACK_OFF]); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.ABSOLUTE_AVARICE, defaultParty); + await runMysteryEncounterToEnd(game, 3); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/an-offer-you-cant-refuse-encounter.test.ts b/src/test/mystery-encounter/encounters/an-offer-you-cant-refuse-encounter.test.ts new file mode 100644 index 000000000000..1c68852a63dc --- /dev/null +++ b/src/test/mystery-encounter/encounters/an-offer-you-cant-refuse-encounter.test.ts @@ -0,0 +1,259 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { HUMAN_TRANSITABLE_BIOMES } from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { runMysteryEncounterToEnd } from "#test/mystery-encounter/encounter-test-utils"; +import BattleScene from "#app/battle-scene"; +import { PlayerPokemon, PokemonMove } from "#app/field/pokemon"; +import { AnOfferYouCantRefuseEncounter } from "#app/data/mystery-encounters/encounters/an-offer-you-cant-refuse-encounter"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { Moves } from "#enums/moves"; +import { ShinyRateBoosterModifier } from "#app/modifier/modifier"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; + +const namespace = "mysteryEncounter:offerYouCantRefuse"; +/** Gyarados for Indimidate */ +const defaultParty = [Species.GYARADOS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("An Offer You Can't Refuse - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + const biomeMap = new Map([ + [Biome.VOLCANO, [MysteryEncounterType.MYSTERIOUS_CHALLENGERS]], + ]); + HUMAN_TRANSITABLE_BIOMES.forEach(biome => { + biomeMap.set(biome, [MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE]); + }); + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue(biomeMap); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE, defaultParty); + + expect(AnOfferYouCantRefuseEncounter.encounterType).toBe(MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE); + expect(AnOfferYouCantRefuseEncounter.encounterTier).toBe(MysteryEncounterTier.GREAT); + expect(AnOfferYouCantRefuseEncounter.dialogue).toBeDefined(); + expect(AnOfferYouCantRefuseEncounter.dialogue.intro).toStrictEqual([ + { text: `${namespace}.intro` }, + { speaker: `${namespace}.speaker`, text: `${namespace}.intro_dialogue` } + ]); + expect(AnOfferYouCantRefuseEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(AnOfferYouCantRefuseEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(AnOfferYouCantRefuseEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(AnOfferYouCantRefuseEncounter.options.length).toBe(3); + }); + + it("should not spawn outside of HUMAN_TRANSITABLE_BIOMES", async () => { + game.override.mysteryEncounterTier(MysteryEncounterTier.GREAT); + game.override.startingBiome(Biome.VOLCANO); + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should initialize fully ", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = AnOfferYouCantRefuseEncounter; + + const { onInit } = AnOfferYouCantRefuseEncounter; + + expect(AnOfferYouCantRefuseEncounter.onInit).toBeDefined(); + + AnOfferYouCantRefuseEncounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + + expect(AnOfferYouCantRefuseEncounter.dialogueTokens?.strongestPokemon).toBeDefined(); + expect(AnOfferYouCantRefuseEncounter.dialogueTokens?.price).toBeDefined(); + expect(AnOfferYouCantRefuseEncounter.dialogueTokens?.option2PrimaryAbility).toBe("Intimidate"); + expect(AnOfferYouCantRefuseEncounter.dialogueTokens?.moveOrAbility).toBe("Intimidate"); + expect(AnOfferYouCantRefuseEncounter.misc.pokemon instanceof PlayerPokemon).toBeTruthy(); + expect(AnOfferYouCantRefuseEncounter.misc?.price?.toString()).toBe(AnOfferYouCantRefuseEncounter.dialogueTokens?.price); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Sell your Pokemon for money and a Shiny Charm", () => { + it("should have the correct properties", () => { + const option = AnOfferYouCantRefuseEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("Should update the player's money properly", async () => { + const initialMoney = 20000; + scene.money = initialMoney; + const updateMoneySpy = vi.spyOn(EncounterPhaseUtils, "updatePlayerMoney"); + + await game.runToMysteryEncounter(MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE, defaultParty); + await runMysteryEncounterToEnd(game, 1); + + const price = scene.currentBattle.mysteryEncounter!.misc.price; + + expect(updateMoneySpy).toHaveBeenCalledWith(scene, price); + expect(scene.money).toBe(initialMoney + price); + }); + + it("Should give the player a Shiny Charm", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE, defaultParty); + await runMysteryEncounterToEnd(game, 1); + + const itemModifier = scene.findModifier(m => m instanceof ShinyRateBoosterModifier) as ShinyRateBoosterModifier; + + expect(itemModifier).toBeDefined(); + expect(itemModifier?.stackCount).toBe(1); + }); + + it("Should remove the Pokemon from the party", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE, defaultParty); + + const initialPartySize = scene.getParty().length; + const pokemonName = scene.currentBattle.mysteryEncounter!.misc.pokemon.name; + + await runMysteryEncounterToEnd(game, 1); + + expect(scene.getParty().length).toBe(initialPartySize - 1); + expect(scene.getParty().find(p => p.name === pokemonName)).toBeUndefined(); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE, defaultParty); + await runMysteryEncounterToEnd(game, 1); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 2 - Extort the Kid", () => { + it("should have the correct properties", () => { + const option = AnOfferYouCantRefuseEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + disabledButtonTooltip: `${namespace}.option.2.tooltip_disabled`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.2.selected`, + }, + ], + }); + }); + + it("should award EXP to a pokemon with an ability in EXTORTION_ABILITIES", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE, defaultParty); + const party = scene.getParty(); + const gyarados = party.find((pkm) => pkm.species.speciesId === Species.GYARADOS)!; + const expBefore = gyarados.exp; + + await runMysteryEncounterToEnd(game, 2); + await game.phaseInterceptor.to(SelectModifierPhase, false); + + expect(gyarados.exp).toBe(expBefore + Math.floor(getPokemonSpecies(Species.LIEPARD).baseExp * defaultWave / 5 + 1)); + }); + + it("should award EXP to a pokemon with a move in EXTORTION_MOVES", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE, [Species.ABRA]); + const party = scene.getParty(); + const abra = party.find((pkm) => pkm.species.speciesId === Species.ABRA)!; + abra.moveset = [new PokemonMove(Moves.BEAT_UP)]; + const expBefore = abra.exp; + + await runMysteryEncounterToEnd(game, 2); + await game.phaseInterceptor.to(SelectModifierPhase, false); + + expect(abra.exp).toBe(expBefore + Math.floor(getPokemonSpecies(Species.LIEPARD).baseExp * defaultWave / 5 + 1)); + }); + + it("Should update the player's money properly", async () => { + const initialMoney = 20000; + scene.money = initialMoney; + const updateMoneySpy = vi.spyOn(EncounterPhaseUtils, "updatePlayerMoney"); + + await game.runToMysteryEncounter(MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + const price = scene.currentBattle.mysteryEncounter!.misc.price; + + expect(updateMoneySpy).toHaveBeenCalledWith(scene, price); + expect(scene.money).toBe(initialMoney + price); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 3 - Leave", () => { + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.AN_OFFER_YOU_CANT_REFUSE, defaultParty); + await runMysteryEncounterToEnd(game, 3); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/berries-abound-encounter.test.ts b/src/test/mystery-encounter/encounters/berries-abound-encounter.test.ts new file mode 100644 index 000000000000..73ffad36258e --- /dev/null +++ b/src/test/mystery-encounter/encounters/berries-abound-encounter.test.ts @@ -0,0 +1,245 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runMysteryEncounterToEnd, skipBattleRunMysteryEncounterRewardsPhase } from "#test/mystery-encounter/encounter-test-utils"; +import BattleScene from "#app/battle-scene"; +import { Mode } from "#app/ui/ui"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; +import { BerryModifier } from "#app/modifier/modifier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import { BerriesAboundEncounter } from "#app/data/mystery-encounters/encounters/berries-abound-encounter"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import * as EncounterDialogueUtils from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { CommandPhase } from "#app/phases/command-phase"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; + +const namespace = "mysteryEncounter:berriesAbound"; +const defaultParty = [Species.PYUKUMUKU]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("Berries Abound - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.mysteryEncounterTier(MysteryEncounterTier.COMMON); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + game.override.startingModifier([]); + game.override.startingHeldItems([]); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.CAVE, [MysteryEncounterType.BERRIES_ABOUND]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.BERRIES_ABOUND, defaultParty); + + expect(BerriesAboundEncounter.encounterType).toBe(MysteryEncounterType.BERRIES_ABOUND); + expect(BerriesAboundEncounter.encounterTier).toBe(MysteryEncounterTier.COMMON); + expect(BerriesAboundEncounter.dialogue).toBeDefined(); + expect(BerriesAboundEncounter.dialogue.intro).toStrictEqual([{ text: `${namespace}.intro` }]); + expect(BerriesAboundEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(BerriesAboundEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(BerriesAboundEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(BerriesAboundEncounter.options.length).toBe(3); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.BERRIES_ABOUND); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should initialize fully", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = BerriesAboundEncounter; + + const { onInit } = BerriesAboundEncounter; + + expect(BerriesAboundEncounter.onInit).toBeDefined(); + + BerriesAboundEncounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + + const config = BerriesAboundEncounter.enemyPartyConfigs[0]; + expect(config).toBeDefined(); + expect(config.levelAdditiveMultiplier).toBe(1); + expect(config.pokemonConfigs?.[0].isBoss).toBe(true); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Fight", () => { + it("should have the correct properties", () => { + const option = BerriesAboundEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("should start a fight against the boss", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.BERRIES_ABOUND, defaultParty); + + const config = game.scene.currentBattle.mysteryEncounter!.enemyPartyConfigs[0]; + const speciesToSpawn = config.pokemonConfigs?.[0].species.speciesId; + + await runMysteryEncounterToEnd(game, 1, undefined, true); + + const enemyField = scene.getEnemyField(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyField.length).toBe(1); + expect(enemyField[0].species.speciesId).toBe(speciesToSpawn); + }); + + // TODO: there is some severe test flakiness occurring for this file, needs to be looked at/addressed in separate issue + it.skip("should reward the player with X berries based on wave", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.BERRIES_ABOUND, defaultParty); + + const numBerries = game.scene.currentBattle.mysteryEncounter!.misc.numBerries; + + // Clear out any pesky mods that slipped through test spin-up + scene.modifiers.forEach(mod => { + scene.removeModifier(mod); + }); + + await runMysteryEncounterToEnd(game, 1, undefined, true); + await skipBattleRunMysteryEncounterRewardsPhase(game); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + + const berriesAfter = scene.findModifiers(m => m instanceof BerryModifier) as BerryModifier[]; + const berriesAfterCount = berriesAfter.reduce((a, b) => a + b.stackCount, 0); + + expect(numBerries).toBe(berriesAfterCount); + }); + + it("should spawn a shop with 5 berries", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.BERRIES_ABOUND, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + await skipBattleRunMysteryEncounterRewardsPhase(game); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(5); + for (const option of modifierSelectHandler.options) { + expect(option.modifierTypeOption.type.id).toContain("BERRY"); + } + }); + }); + + describe("Option 2 - Race to the Bush", () => { + it("should have the correct properties", () => { + const option = BerriesAboundEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + }); + }); + + it("should start battle if fastest pokemon is slower than boss", async () => { + const encounterTextSpy = vi.spyOn(EncounterDialogueUtils, "showEncounterText"); + await game.runToMysteryEncounter(MysteryEncounterType.BERRIES_ABOUND, defaultParty); + + const config = game.scene.currentBattle.mysteryEncounter!.enemyPartyConfigs[0]; + const speciesToSpawn = config.pokemonConfigs?.[0].species.speciesId; + // Setting enemy's level arbitrarily high to outspeed + config.pokemonConfigs![0].dataSource!.level = 1000; + + await runMysteryEncounterToEnd(game, 2, undefined, true); + + const enemyField = scene.getEnemyField(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyField.length).toBe(1); + expect(enemyField[0].species.speciesId).toBe(speciesToSpawn); + + // Should be enraged + expect(enemyField[0].summonData.statStages).toEqual([1, 1, 1, 1, 1, 0, 0]); + expect(encounterTextSpy).toHaveBeenCalledWith(expect.any(BattleScene), `${namespace}.option.2.selected_bad`); + }); + + it("Should skip battle when fastest pokemon is faster than boss", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + const encounterTextSpy = vi.spyOn(EncounterDialogueUtils, "showEncounterText"); + + await game.runToMysteryEncounter(MysteryEncounterType.BERRIES_ABOUND, defaultParty); + + // Setting party pokemon's level arbitrarily high to outspeed + const fastestPokemon = scene.getParty()[0]; + fastestPokemon.level = 1000; + fastestPokemon.calculateStats(); + + await runMysteryEncounterToEnd(game, 2); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(5); + for (const option of modifierSelectHandler.options) { + expect(option.modifierTypeOption.type.id).toContain("BERRY"); + } + + expect(encounterTextSpy).toHaveBeenCalledWith(expect.any(BattleScene), `${namespace}.option.2.selected`); + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 3 - Leave", () => { + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.BERRIES_ABOUND, defaultParty); + await runMysteryEncounterToEnd(game, 3); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/bug-type-superfan-encounter.test.ts b/src/test/mystery-encounter/encounters/bug-type-superfan-encounter.test.ts new file mode 100644 index 000000000000..70adf93d502f --- /dev/null +++ b/src/test/mystery-encounter/encounters/bug-type-superfan-encounter.test.ts @@ -0,0 +1,585 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runMysteryEncounterToEnd, runSelectMysteryEncounterOption, skipBattleRunMysteryEncounterRewardsPhase } from "#test/mystery-encounter/encounter-test-utils"; +import { Moves } from "#enums/moves"; +import BattleScene from "#app/battle-scene"; +import { PokemonMove } from "#app/field/pokemon"; +import { Mode } from "#app/ui/ui"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import { TrainerType } from "#enums/trainer-type"; +import { MysteryEncounterPhase, MysteryEncounterRewardsPhase } from "#app/phases/mystery-encounter-phases"; +import { ContactHeldItemTransferChanceModifier } from "#app/modifier/modifier"; +import { CommandPhase } from "#app/phases/command-phase"; +import { BugTypeSuperfanEncounter } from "#app/data/mystery-encounters/encounters/bug-type-superfan-encounter"; +import * as encounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; + +const namespace = "mysteryEncounter:bugTypeSuperfan"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.WEEDLE]; +const defaultBiome = Biome.CAVE; +const defaultWave = 24; + +const POOL_1_POKEMON = [ + Species.PARASECT, + Species.VENOMOTH, + Species.LEDIAN, + Species.ARIADOS, + Species.YANMA, + Species.BEAUTIFLY, + Species.DUSTOX, + Species.MASQUERAIN, + Species.NINJASK, + Species.VOLBEAT, + Species.ILLUMISE, + Species.ANORITH, + Species.KRICKETUNE, + Species.WORMADAM, + Species.MOTHIM, + Species.SKORUPI, + Species.JOLTIK, + Species.LARVESTA, + Species.VIVILLON, + Species.CHARJABUG, + Species.RIBOMBEE, + Species.SPIDOPS, + Species.LOKIX +]; + +const POOL_2_POKEMON = [ + Species.SCYTHER, + Species.PINSIR, + Species.HERACROSS, + Species.FORRETRESS, + Species.SCIZOR, + Species.SHUCKLE, + Species.SHEDINJA, + Species.ARMALDO, + Species.VESPIQUEN, + Species.DRAPION, + Species.YANMEGA, + Species.LEAVANNY, + Species.SCOLIPEDE, + Species.CRUSTLE, + Species.ESCAVALIER, + Species.ACCELGOR, + Species.GALVANTULA, + Species.VIKAVOLT, + Species.ARAQUANID, + Species.ORBEETLE, + Species.CENTISKORCH, + Species.FROSMOTH, + Species.KLEAVOR, +]; + +const POOL_3_POKEMON: { species: Species, formIndex?: number }[] = [ + { + species: Species.PINSIR, + formIndex: 1 + }, + { + species: Species.SCIZOR, + formIndex: 1 + }, + { + species: Species.HERACROSS, + formIndex: 1 + }, + { + species: Species.ORBEETLE, + formIndex: 1 + }, + { + species: Species.CENTISKORCH, + formIndex: 1 + }, + { + species: Species.DURANT, + }, + { + species: Species.VOLCARONA, + }, + { + species: Species.GOLISOPOD, + }, +]; + +const POOL_4_POKEMON = [ + Species.GENESECT, + Species.SLITHER_WING, + Species.BUZZWOLE, + Species.PHEROMOSA +]; + +const PHYSICAL_TUTOR_MOVES = [ + Moves.MEGAHORN, + Moves.X_SCISSOR, + Moves.ATTACK_ORDER, + Moves.PIN_MISSILE, + Moves.FIRST_IMPRESSION +]; + +const SPECIAL_TUTOR_MOVES = [ + Moves.SILVER_WIND, + Moves.BUG_BUZZ, + Moves.SIGNAL_BEAM, + Moves.POLLEN_PUFF +]; + +const STATUS_TUTOR_MOVES = [ + Moves.STRING_SHOT, + Moves.STICKY_WEB, + Moves.SILK_TRAP, + Moves.RAGE_POWDER, + Moves.HEAL_ORDER +]; + +const MISC_TUTOR_MOVES = [ + Moves.BUG_BITE, + Moves.LEECH_LIFE, + Moves.DEFEND_ORDER, + Moves.QUIVER_DANCE, + Moves.TAIL_GLOW, + Moves.INFESTATION, + Moves.U_TURN +]; + +describe("Bug-Type Superfan - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.CAVE, [MysteryEncounterType.BUG_TYPE_SUPERFAN]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty); + + expect(BugTypeSuperfanEncounter.encounterType).toBe(MysteryEncounterType.BUG_TYPE_SUPERFAN); + expect(BugTypeSuperfanEncounter.encounterTier).toBe(MysteryEncounterTier.GREAT); + expect(BugTypeSuperfanEncounter.dialogue).toBeDefined(); + expect(BugTypeSuperfanEncounter.dialogue.intro).toStrictEqual([ + { + text: `${namespace}.intro`, + }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.intro_dialogue`, + }, + ]); + expect(BugTypeSuperfanEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(BugTypeSuperfanEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(BugTypeSuperfanEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(BugTypeSuperfanEncounter.options.length).toBe(3); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.BUG_TYPE_SUPERFAN); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should initialize fully", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = BugTypeSuperfanEncounter; + + const { onInit } = BugTypeSuperfanEncounter; + + expect(BugTypeSuperfanEncounter.onInit).toBeDefined(); + + BugTypeSuperfanEncounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + const config = BugTypeSuperfanEncounter.enemyPartyConfigs[0]; + + expect(config).toBeDefined(); + expect(config.trainerConfig?.trainerType).toBe(TrainerType.BUG_TYPE_SUPERFAN); + expect(config.trainerConfig?.partyTemplates).toBeDefined(); + expect(config.female).toBe(true); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Battle the Bug-Type Superfan", () => { + it("should have the correct properties", () => { + const option = BugTypeSuperfanEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("should start battle against the Bug-Type Superfan with wave 30 party template", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + const enemyParty = scene.getEnemyParty(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyParty.length).toBe(2); + expect(scene.currentBattle.trainer?.config.trainerType).toBe(TrainerType.BUG_TYPE_SUPERFAN); + expect(enemyParty[0].species.speciesId).toBe(Species.BEEDRILL); + expect(enemyParty[1].species.speciesId).toBe(Species.BUTTERFREE); + }); + + it("should start battle against the Bug-Type Superfan with wave 50 party template", async () => { + game.override.startingWave(43); + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + const enemyParty = scene.getEnemyParty(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyParty.length).toBe(3); + expect(scene.currentBattle.trainer?.config.trainerType).toBe(TrainerType.BUG_TYPE_SUPERFAN); + expect(enemyParty[0].species.speciesId).toBe(Species.BEEDRILL); + expect(enemyParty[1].species.speciesId).toBe(Species.BUTTERFREE); + expect(POOL_1_POKEMON.includes(enemyParty[2].species.speciesId)).toBe(true); + }); + + it("should start battle against the Bug-Type Superfan with wave 70 party template", async () => { + game.override.startingWave(61); + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + const enemyParty = scene.getEnemyParty(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyParty.length).toBe(4); + expect(scene.currentBattle.trainer?.config.trainerType).toBe(TrainerType.BUG_TYPE_SUPERFAN); + expect(enemyParty[0].species.speciesId).toBe(Species.BEEDRILL); + expect(enemyParty[1].species.speciesId).toBe(Species.BUTTERFREE); + expect(POOL_1_POKEMON.includes(enemyParty[2].species.speciesId)).toBe(true); + expect(POOL_2_POKEMON.includes(enemyParty[3].species.speciesId)).toBe(true); + }); + + it("should start battle against the Bug-Type Superfan with wave 100 party template", async () => { + game.override.startingWave(81); + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + const enemyParty = scene.getEnemyParty(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyParty.length).toBe(5); + expect(scene.currentBattle.trainer?.config.trainerType).toBe(TrainerType.BUG_TYPE_SUPERFAN); + expect(enemyParty[0].species.speciesId).toBe(Species.BEEDRILL); + expect(enemyParty[1].species.speciesId).toBe(Species.BUTTERFREE); + expect(POOL_1_POKEMON.includes(enemyParty[2].species.speciesId)).toBe(true); + expect(POOL_2_POKEMON.includes(enemyParty[3].species.speciesId)).toBe(true); + expect(POOL_2_POKEMON.includes(enemyParty[4].species.speciesId)).toBe(true); + }); + + it("should start battle against the Bug-Type Superfan with wave 120 party template", async () => { + game.override.startingWave(111); + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + const enemyParty = scene.getEnemyParty(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyParty.length).toBe(5); + expect(scene.currentBattle.trainer?.config.trainerType).toBe(TrainerType.BUG_TYPE_SUPERFAN); + expect(enemyParty[0].species.speciesId).toBe(Species.BEEDRILL); + expect(enemyParty[0].formIndex).toBe(1); + expect(enemyParty[1].species.speciesId).toBe(Species.BUTTERFREE); + expect(enemyParty[1].formIndex).toBe(1); + expect(POOL_2_POKEMON.includes(enemyParty[2].species.speciesId)).toBe(true); + expect(POOL_2_POKEMON.includes(enemyParty[3].species.speciesId)).toBe(true); + expect(POOL_3_POKEMON.some(config => enemyParty[4].species.speciesId === config.species)).toBe(true); + }); + + it("should start battle against the Bug-Type Superfan with wave 140 party template", async () => { + game.override.startingWave(131); + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + const enemyParty = scene.getEnemyParty(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyParty.length).toBe(5); + expect(scene.currentBattle.trainer?.config.trainerType).toBe(TrainerType.BUG_TYPE_SUPERFAN); + expect(enemyParty[0].species.speciesId).toBe(Species.BEEDRILL); + expect(enemyParty[0].formIndex).toBe(1); + expect(enemyParty[1].species.speciesId).toBe(Species.BUTTERFREE); + expect(enemyParty[1].formIndex).toBe(1); + expect(POOL_2_POKEMON.includes(enemyParty[2].species.speciesId)).toBe(true); + expect(POOL_3_POKEMON.some(config => enemyParty[3].species.speciesId === config.species)).toBe(true); + expect(POOL_3_POKEMON.some(config => enemyParty[4].species.speciesId === config.species)).toBe(true); + }); + + it("should start battle against the Bug-Type Superfan with wave 160 party template", async () => { + game.override.startingWave(151); + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + const enemyParty = scene.getEnemyParty(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyParty.length).toBe(5); + expect(scene.currentBattle.trainer?.config.trainerType).toBe(TrainerType.BUG_TYPE_SUPERFAN); + expect(enemyParty[0].species.speciesId).toBe(Species.BEEDRILL); + expect(enemyParty[0].formIndex).toBe(1); + expect(enemyParty[1].species.speciesId).toBe(Species.BUTTERFREE); + expect(enemyParty[1].formIndex).toBe(1); + expect(POOL_2_POKEMON.includes(enemyParty[2].species.speciesId)).toBe(true); + expect(POOL_3_POKEMON.some(config => enemyParty[3].species.speciesId === config.species)).toBe(true); + expect(POOL_4_POKEMON.includes(enemyParty[4].species.speciesId)).toBe(true); + }); + + it("should start battle against the Bug-Type Superfan with wave 180 party template", async () => { + game.override.startingWave(171); + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + const enemyParty = scene.getEnemyParty(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyParty.length).toBe(5); + expect(scene.currentBattle.trainer?.config.trainerType).toBe(TrainerType.BUG_TYPE_SUPERFAN); + expect(enemyParty[0].species.speciesId).toBe(Species.BEEDRILL); + expect(enemyParty[0].formIndex).toBe(1); + expect(enemyParty[0].isBoss()).toBe(true); + expect(enemyParty[0].bossSegments).toBe(2); + expect(enemyParty[1].species.speciesId).toBe(Species.BUTTERFREE); + expect(enemyParty[1].formIndex).toBe(1); + expect(enemyParty[1].isBoss()).toBe(true); + expect(enemyParty[1].bossSegments).toBe(2); + expect(POOL_3_POKEMON.some(config => enemyParty[2].species.speciesId === config.species)).toBe(true); + expect(POOL_3_POKEMON.some(config => enemyParty[3].species.speciesId === config.species)).toBe(true); + expect(POOL_4_POKEMON.includes(enemyParty[4].species.speciesId)).toBe(true); + }); + + it("should let the player learn a Bug move after battle ends", async () => { + const selectOptionSpy = vi.spyOn(encounterPhaseUtils, "selectOptionThenPokemon"); + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + await skipBattleRunMysteryEncounterRewardsPhase(game, false); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterRewardsPhase.name); + game.phaseInterceptor["prompts"] = []; // Clear out prompt handlers + game.onNextPrompt("MysteryEncounterRewardsPhase", Mode.OPTION_SELECT, () => { + game.phaseInterceptor.superEndPhase(); + }); + await game.phaseInterceptor.run(MysteryEncounterRewardsPhase); + + expect(selectOptionSpy).toHaveBeenCalledTimes(1); + const optionData = selectOptionSpy.mock.calls[0][1]; + expect(PHYSICAL_TUTOR_MOVES.some(move => new PokemonMove(move).getName() === optionData[0].label)).toBe(true); + expect(SPECIAL_TUTOR_MOVES.some(move => new PokemonMove(move).getName() === optionData[1].label)).toBe(true); + expect(STATUS_TUTOR_MOVES.some(move => new PokemonMove(move).getName() === optionData[2].label)).toBe(true); + expect(MISC_TUTOR_MOVES.some(move => new PokemonMove(move).getName() === optionData[3].label)).toBe(true); + }); + }); + + describe("Option 2 - Show off Bug Types", () => { + it("should have the correct properties", () => { + const option = BugTypeSuperfanEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + disabledButtonTooltip: `${namespace}.option.2.disabled_tooltip` + }); + }); + + it("should NOT be selectable if the player doesn't have any Bug types", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, [Species.ABRA]); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 2); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + }); + + it("should proceed to rewards screen with 0-1 Bug Types reward options", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(2); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toBe("SUPER_LURE"); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.id).toBe("GREAT_BALL"); + }); + + it("should proceed to rewards screen with 2-3 Bug Types reward options", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, [Species.BUTTERFREE, Species.BEEDRILL]); + await runMysteryEncounterToEnd(game, 2); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(3); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toBe("QUICK_CLAW"); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.id).toBe("MAX_LURE"); + expect(modifierSelectHandler.options[2].modifierTypeOption.type.id).toBe("ULTRA_BALL"); + }); + + it("should proceed to rewards screen with 4-5 Bug Types reward options", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, [Species.BUTTERFREE, Species.BEEDRILL, Species.GALVANTULA, Species.VOLCARONA]); + await runMysteryEncounterToEnd(game, 2); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(3); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toBe("GRIP_CLAW"); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.id).toBe("MAX_LURE"); + expect(modifierSelectHandler.options[2].modifierTypeOption.type.id).toBe("ROGUE_BALL"); + }); + + it("should proceed to rewards screen with 6 Bug Types reward options (including form change item)", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, [Species.BUTTERFREE, Species.BEEDRILL, Species.GALVANTULA, Species.VOLCARONA, Species.ANORITH, Species.GENESECT]); + await runMysteryEncounterToEnd(game, 2); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(3); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toBe("MASTER_BALL"); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.id).toBe("MAX_LURE"); + expect(modifierSelectHandler.options[2].modifierTypeOption.type.id).toBe("FORM_CHANGE_ITEM"); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(encounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 3 - Give a Bug Item", () => { + it("should have the correct properties", () => { + const option = BugTypeSuperfanEncounter.options[2]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + disabledButtonTooltip: `${namespace}.option.3.disabled_tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.3.selected_dialogue`, + }, + ], + secondOptionPrompt: `${namespace}.option.3.select_prompt`, + }); + }); + + it("should NOT be selectable if the player doesn't have any Bug items", async () => { + game.scene.modifiers = []; + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + game.scene.modifiers = []; + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 3); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + }); + + it("should remove the gifted item and proceed to rewards screen", async () => { + game.override.startingHeldItems([{name: "GRIP_CLAW", count: 1}]); + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, [Species.BUTTERFREE]); + + const gripClawCountBefore = scene.findModifier(m => m instanceof ContactHeldItemTransferChanceModifier)?.stackCount ?? 0; + + await runMysteryEncounterToEnd(game, 3, { pokemonNo: 1, optionNo: 1 }); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(2); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toBe("MYSTERY_ENCOUNTER_GOLDEN_BUG_NET"); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.id).toBe("REVIVER_SEED"); + + const gripClawCountAfter = scene.findModifier(m => m instanceof ContactHeldItemTransferChanceModifier)?.stackCount ?? 0; + expect(gripClawCountBefore - 1).toBe(gripClawCountAfter); + }); + + it("should leave encounter without battle", async () => { + game.override.startingHeldItems([{name: "GRIP_CLAW", count: 1}]); + const leaveEncounterWithoutBattleSpy = vi.spyOn(encounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, [Species.BUTTERFREE]); + await runMysteryEncounterToEnd(game, 3, { pokemonNo: 1, optionNo: 1 }); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/clowning-around-encounter.test.ts b/src/test/mystery-encounter/encounters/clowning-around-encounter.test.ts new file mode 100644 index 000000000000..383e3bd3564e --- /dev/null +++ b/src/test/mystery-encounter/encounters/clowning-around-encounter.test.ts @@ -0,0 +1,383 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import * as BattleAnims from "#app/data/battle-anims"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { generateModifierType } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { runMysteryEncounterToEnd, skipBattleRunMysteryEncounterRewardsPhase } from "#test/mystery-encounter/encounter-test-utils"; +import { Moves } from "#enums/moves"; +import BattleScene from "#app/battle-scene"; +import Pokemon, { PokemonMove } from "#app/field/pokemon"; +import { Mode } from "#app/ui/ui"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import { ModifierTier } from "#app/modifier/modifier-tier"; +import { ClowningAroundEncounter } from "#app/data/mystery-encounters/encounters/clowning-around-encounter"; +import { TrainerType } from "#enums/trainer-type"; +import { Abilities } from "#enums/abilities"; +import { PostMysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; +import { Button } from "#enums/buttons"; +import PartyUiHandler from "#app/ui/party-ui-handler"; +import OptionSelectUiHandler from "#app/ui/settings/option-select-ui-handler"; +import { modifierTypes, PokemonHeldItemModifierType } from "#app/modifier/modifier-type"; +import { BerryType } from "#enums/berry-type"; +import { PokemonHeldItemModifier } from "#app/modifier/modifier"; +import { Type } from "#app/data/type"; +import { CommandPhase } from "#app/phases/command-phase"; +import { MovePhase } from "#app/phases/move-phase"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; +import { NewBattlePhase } from "#app/phases/new-battle-phase"; + +const namespace = "mysteryEncounter:clowningAround"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("Clowning Around - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.CAVE, [MysteryEncounterType.CLOWNING_AROUND]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.CLOWNING_AROUND, defaultParty); + + expect(ClowningAroundEncounter.encounterType).toBe(MysteryEncounterType.CLOWNING_AROUND); + expect(ClowningAroundEncounter.encounterTier).toBe(MysteryEncounterTier.ULTRA); + expect(ClowningAroundEncounter.dialogue).toBeDefined(); + expect(ClowningAroundEncounter.dialogue.intro).toStrictEqual([ + { text: `${namespace}.intro` }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.intro_dialogue`, + }, + ]); + expect(ClowningAroundEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(ClowningAroundEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(ClowningAroundEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(ClowningAroundEncounter.options.length).toBe(3); + }); + + it("should not run below wave 80", async () => { + game.override.startingWave(79); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.CLOWNING_AROUND); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should initialize fully", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = ClowningAroundEncounter; + const moveInitSpy = vi.spyOn(BattleAnims, "initMoveAnim"); + const moveLoadSpy = vi.spyOn(BattleAnims, "loadMoveAnimAssets"); + + const { onInit } = ClowningAroundEncounter; + + expect(ClowningAroundEncounter.onInit).toBeDefined(); + + ClowningAroundEncounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + const config = ClowningAroundEncounter.enemyPartyConfigs[0]; + + expect(config.doubleBattle).toBe(true); + expect(config.trainerConfig?.trainerType).toBe(TrainerType.HARLEQUIN); + expect(config.pokemonConfigs?.[0]).toEqual({ + species: getPokemonSpecies(Species.MR_MIME), + isBoss: true, + moveSet: [Moves.TEETER_DANCE, Moves.ALLY_SWITCH, Moves.DAZZLING_GLEAM, Moves.PSYCHIC] + }); + expect(config.pokemonConfigs?.[1]).toEqual({ + species: getPokemonSpecies(Species.BLACEPHALON), + mysteryEncounterPokemonData: expect.anything(), + isBoss: true, + moveSet: [Moves.TRICK, Moves.HYPNOSIS, Moves.SHADOW_BALL, Moves.MIND_BLOWN] + }); + expect(config.pokemonConfigs?.[1].mysteryEncounterPokemonData?.types.length).toBe(2); + expect([ + Abilities.STURDY, + Abilities.PICKUP, + Abilities.INTIMIDATE, + Abilities.GUTS, + Abilities.DROUGHT, + Abilities.DRIZZLE, + Abilities.SNOW_WARNING, + Abilities.SAND_STREAM, + Abilities.ELECTRIC_SURGE, + Abilities.PSYCHIC_SURGE, + Abilities.GRASSY_SURGE, + Abilities.MISTY_SURGE, + Abilities.MAGICIAN, + Abilities.SHEER_FORCE, + Abilities.PRANKSTER + ]).toContain(config.pokemonConfigs?.[1].mysteryEncounterPokemonData?.ability); + expect(ClowningAroundEncounter.misc.ability).toBe(config.pokemonConfigs?.[1].mysteryEncounterPokemonData?.ability); + await vi.waitFor(() => expect(moveInitSpy).toHaveBeenCalled()); + await vi.waitFor(() => expect(moveLoadSpy).toHaveBeenCalled()); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Battle the Clown", () => { + it("should have the correct properties", () => { + const option = ClowningAroundEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("should start double battle against the clown", async () => { + const phaseSpy = vi.spyOn(scene, "pushPhase"); + + await game.runToMysteryEncounter(MysteryEncounterType.CLOWNING_AROUND, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + const enemyField = scene.getEnemyField(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyField.length).toBe(2); + expect(enemyField[0].species.speciesId).toBe(Species.MR_MIME); + expect(enemyField[0].moveset).toEqual([new PokemonMove(Moves.TEETER_DANCE), new PokemonMove(Moves.ALLY_SWITCH), new PokemonMove(Moves.DAZZLING_GLEAM), new PokemonMove(Moves.PSYCHIC)]); + expect(enemyField[1].species.speciesId).toBe(Species.BLACEPHALON); + expect(enemyField[1].moveset).toEqual([new PokemonMove(Moves.TRICK), new PokemonMove(Moves.HYPNOSIS), new PokemonMove(Moves.SHADOW_BALL), new PokemonMove(Moves.MIND_BLOWN)]); + + // Should have used moves pre-battle + const movePhases = phaseSpy.mock.calls.filter(p => p[0] instanceof MovePhase).map(p => p[0]); + expect(movePhases.length).toBe(3); + expect(movePhases.filter(p => (p as MovePhase).move.moveId === Moves.ROLE_PLAY).length).toBe(1); + expect(movePhases.filter(p => (p as MovePhase).move.moveId === Moves.TAUNT).length).toBe(2); + }); + + it("should let the player gain the ability after battle completion", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.CLOWNING_AROUND, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + await skipBattleRunMysteryEncounterRewardsPhase(game); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + const abilityToTrain = scene.currentBattle.mysteryEncounter?.misc.ability; + + game.onNextPrompt("PostMysteryEncounterPhase", Mode.MESSAGE, () => { + game.scene.ui.getHandler().processInput(Button.ACTION); + }); + + // Run to ability train option selection + const optionSelectUiHandler = game.scene.ui.handlers[Mode.OPTION_SELECT] as OptionSelectUiHandler; + vi.spyOn(optionSelectUiHandler, "show"); + const partyUiHandler = game.scene.ui.handlers[Mode.PARTY] as PartyUiHandler; + vi.spyOn(partyUiHandler, "show"); + game.endPhase(); + await game.phaseInterceptor.to(PostMysteryEncounterPhase); + expect(scene.getCurrentPhase()?.constructor.name).toBe(PostMysteryEncounterPhase.name); + + // Wait for Yes/No confirmation to appear + await vi.waitFor(() => expect(optionSelectUiHandler.show).toHaveBeenCalled()); + // Select "Yes" on train ability + optionSelectUiHandler.processInput(Button.ACTION); + // Select first pokemon in party to train + await vi.waitFor(() => expect(partyUiHandler.show).toHaveBeenCalled()); + partyUiHandler.processInput(Button.ACTION); + // Click "Select" on Pokemon + partyUiHandler.processInput(Button.ACTION); + // Stop next battle before it runs + await game.phaseInterceptor.to(NewBattlePhase, false); + + const leadPokemon = scene.getParty()[0]; + expect(leadPokemon.mysteryEncounterPokemonData?.ability).toBe(abilityToTrain); + }); + }); + + describe("Option 2 - Remain Unprovoked", () => { + it("should have the correct properties", () => { + const option = ClowningAroundEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.2.selected`, + }, + { + text: `${namespace}.option.2.selected_2`, + }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.2.selected_3`, + }, + ], + }); + }); + + it("should randomize held items of the Pokemon with the most items, and not the held items of other pokemon", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.CLOWNING_AROUND, defaultParty); + + // Set some moves on party for attack type booster generation + scene.getParty()[0].moveset = [new PokemonMove(Moves.TACKLE), new PokemonMove(Moves.THIEF)]; + + // 2 Sitrus Berries on lead + scene.modifiers = []; + let itemType = generateModifierType(scene, modifierTypes.BERRY, [BerryType.SITRUS]) as PokemonHeldItemModifierType; + await addItemToPokemon(scene, scene.getParty()[0], 2, itemType); + // 2 Ganlon Berries on lead + itemType = generateModifierType(scene, modifierTypes.BERRY, [BerryType.GANLON]) as PokemonHeldItemModifierType; + await addItemToPokemon(scene, scene.getParty()[0], 2, itemType); + // 5 Golden Punch on lead (ultra) + itemType = generateModifierType(scene, modifierTypes.GOLDEN_PUNCH) as PokemonHeldItemModifierType; + await addItemToPokemon(scene, scene.getParty()[0], 5, itemType); + // 5 Lucky Egg on lead (ultra) + itemType = generateModifierType(scene, modifierTypes.LUCKY_EGG) as PokemonHeldItemModifierType; + await addItemToPokemon(scene, scene.getParty()[0], 5, itemType); + // 5 Soul Dew on lead (rogue) + itemType = generateModifierType(scene, modifierTypes.SOUL_DEW) as PokemonHeldItemModifierType; + await addItemToPokemon(scene, scene.getParty()[0], 5, itemType); + // 2 Golden Egg on lead (rogue) + itemType = generateModifierType(scene, modifierTypes.GOLDEN_EGG) as PokemonHeldItemModifierType; + await addItemToPokemon(scene, scene.getParty()[0], 2, itemType); + + // 5 Soul Dew on second party pokemon (these should not change) + itemType = generateModifierType(scene, modifierTypes.SOUL_DEW) as PokemonHeldItemModifierType; + await addItemToPokemon(scene, scene.getParty()[1], 5, itemType); + + await runMysteryEncounterToEnd(game, 2); + + const leadItemsAfter = scene.getParty()[0].getHeldItems(); + const ultraCountAfter = leadItemsAfter + .filter(m => m.type.tier === ModifierTier.ULTRA) + .reduce((a, b) => a + b.stackCount, 0); + const rogueCountAfter = leadItemsAfter + .filter(m => m.type.tier === ModifierTier.ROGUE) + .reduce((a, b) => a + b.stackCount, 0); + expect(ultraCountAfter).toBe(10); + expect(rogueCountAfter).toBe(7); + + const secondItemsAfter = scene.getParty()[1].getHeldItems(); + expect(secondItemsAfter.length).toBe(1); + expect(secondItemsAfter[0].type.id).toBe("SOUL_DEW"); + expect(secondItemsAfter[0]?.stackCount).toBe(5); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.CLOWNING_AROUND, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 3 - Return the Insults", () => { + it("should have the correct properties", () => { + const option = ClowningAroundEncounter.options[2]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.3.selected`, + }, + { + text: `${namespace}.option.3.selected_2`, + }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.3.selected_3`, + }, + ], + }); + }); + + it("should randomize the pokemon types of the party", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.CLOWNING_AROUND, defaultParty); + + // Same type moves on lead + scene.getParty()[0].moveset = [new PokemonMove(Moves.ICE_BEAM), new PokemonMove(Moves.SURF)]; + // Different type moves on second + scene.getParty()[1].moveset = [new PokemonMove(Moves.GRASS_KNOT), new PokemonMove(Moves.ELECTRO_BALL)]; + // No moves on third + scene.getParty()[2].moveset = []; + await runMysteryEncounterToEnd(game, 3); + + const leadTypesAfter = scene.getParty()[0].mysteryEncounterPokemonData?.types; + const secondaryTypesAfter = scene.getParty()[1].mysteryEncounterPokemonData?.types; + const thirdTypesAfter = scene.getParty()[2].mysteryEncounterPokemonData?.types; + + expect(leadTypesAfter.length).toBe(2); + expect(leadTypesAfter[0]).toBe(Type.WATER); + expect([Type.WATER, Type.ICE].includes(leadTypesAfter[1])).toBeFalsy(); + expect(secondaryTypesAfter.length).toBe(2); + expect(secondaryTypesAfter[0]).toBe(Type.GHOST); + expect([Type.GHOST, Type.POISON].includes(secondaryTypesAfter[1])).toBeFalsy(); + expect([Type.GRASS, Type.ELECTRIC].includes(secondaryTypesAfter[1])).toBeTruthy(); + expect(thirdTypesAfter.length).toBe(2); + expect(thirdTypesAfter[0]).toBe(Type.PSYCHIC); + expect(secondaryTypesAfter[1]).not.toBe(Type.PSYCHIC); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.CLOWNING_AROUND, defaultParty); + await runMysteryEncounterToEnd(game, 3); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); + +async function addItemToPokemon(scene: BattleScene, pokemon: Pokemon, stackCount: integer, itemType: PokemonHeldItemModifierType) { + const itemMod = itemType.newModifier(pokemon) as PokemonHeldItemModifier; + itemMod.stackCount = stackCount; + await scene.addModifier(itemMod, true, false, false, true); + await scene.updateModifiers(true); +} diff --git a/src/test/mystery-encounter/encounters/dancing-lessons-encounter.test.ts b/src/test/mystery-encounter/encounters/dancing-lessons-encounter.test.ts new file mode 100644 index 000000000000..5a2512ddaf60 --- /dev/null +++ b/src/test/mystery-encounter/encounters/dancing-lessons-encounter.test.ts @@ -0,0 +1,261 @@ +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { runMysteryEncounterToEnd, runSelectMysteryEncounterOption, skipBattleRunMysteryEncounterRewardsPhase } from "#test/mystery-encounter/encounter-test-utils"; +import BattleScene from "#app/battle-scene"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { Moves } from "#enums/moves"; +import { DancingLessonsEncounter } from "#app/data/mystery-encounters/encounters/dancing-lessons-encounter"; +import { Mode } from "#app/ui/ui"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; +import { PokemonMove } from "#app/field/pokemon"; +import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; +import { CommandPhase } from "#app/phases/command-phase"; +import { MovePhase } from "#app/phases/move-phase"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; +import { LearnMovePhase } from "#app/phases/learn-move-phase"; + +const namespace = "mysteryEncounter:dancingLessons"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.PLAINS; +const defaultWave = 45; + +describe("Dancing Lessons - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.PLAINS, [MysteryEncounterType.DANCING_LESSONS]], + [Biome.SPACE, [MysteryEncounterType.MYSTERIOUS_CHALLENGERS]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DANCING_LESSONS, defaultParty); + + expect(DancingLessonsEncounter.encounterType).toBe(MysteryEncounterType.DANCING_LESSONS); + expect(DancingLessonsEncounter.encounterTier).toBe(MysteryEncounterTier.GREAT); + expect(DancingLessonsEncounter.dialogue).toBeDefined(); + expect(DancingLessonsEncounter.dialogue.intro).toStrictEqual([{ text: `${namespace}.intro` }]); + expect(DancingLessonsEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(DancingLessonsEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(DancingLessonsEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(DancingLessonsEncounter.options.length).toBe(3); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.DANCING_LESSONS); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should not spawn outside of proper biomes", async () => { + game.override.mysteryEncounterTier(MysteryEncounterTier.GREAT); + game.override.startingBiome(Biome.SPACE); + await game.runToMysteryEncounter(); + + expect(game.scene.currentBattle.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.DANCING_LESSONS); + }); + + describe("Option 1 - Fight the Oricorio", () => { + it("should have the correct properties", () => { + const option1 = DancingLessonsEncounter.options[0]; + expect(option1.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option1.dialogue).toBeDefined(); + expect(option1.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("should start battle against Oricorio", async () => { + const phaseSpy = vi.spyOn(scene, "pushPhase"); + + await game.runToMysteryEncounter(MysteryEncounterType.DANCING_LESSONS, defaultParty); + // Make party lead's level arbitrarily high to not get KOed by move + const partyLead = scene.getParty()[0]; + partyLead.level = 1000; + partyLead.calculateStats(); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + const enemyField = scene.getEnemyField(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyField.length).toBe(1); + expect(enemyField[0].species.speciesId).toBe(Species.ORICORIO); + expect(enemyField[0].summonData.statStages).toEqual([1, 1, 1, 1, 0, 0, 0]); + const moveset = enemyField[0].moveset.map(m => m?.moveId); + expect(moveset.some(m => m === Moves.REVELATION_DANCE)).toBeTruthy(); + + const movePhases = phaseSpy.mock.calls.filter(p => p[0] instanceof MovePhase).map(p => p[0]); + expect(movePhases.length).toBe(1); + expect(movePhases.filter(p => (p as MovePhase).move.moveId === Moves.REVELATION_DANCE).length).toBe(1); // Revelation Dance used before battle + }); + + it("should have a Baton in the rewards after battle", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DANCING_LESSONS, defaultParty); + // Make party lead's level arbitrarily high to not get KOed by move + const partyLead = scene.getParty()[0]; + partyLead.level = 1000; + partyLead.calculateStats(); + await runMysteryEncounterToEnd(game, 1, undefined, true); + // For some reason updateModifiers breaks in this test and does not resolve promise + vi.spyOn(game.scene, "updateModifiers").mockImplementation(() => new Promise(resolve => resolve())); + await skipBattleRunMysteryEncounterRewardsPhase(game); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(3); // Should fill remaining + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toContain("BATON"); + }); + }); + + describe("Option 2 - Learn its Dance", () => { + it("should have the correct properties", () => { + const option = DancingLessonsEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }); + }); + + it("Should select a pokemon to learn Revelation Dance", async () => { + const phaseSpy = vi.spyOn(scene, "unshiftPhase"); + + await game.runToMysteryEncounter(MysteryEncounterType.DANCING_LESSONS, defaultParty); + scene.getParty()[0].moveset = []; + await runMysteryEncounterToEnd(game, 2, { pokemonNo: 1 }); + + const movePhases = phaseSpy.mock.calls.filter(p => p[0] instanceof LearnMovePhase).map(p => p[0]); + expect(movePhases.length).toBe(1); + expect(movePhases.filter(p => (p as LearnMovePhase)["moveId"] === Moves.REVELATION_DANCE).length).toBe(1); // Revelation Dance taught to pokemon + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.DANCING_LESSONS, defaultParty); + scene.getParty()[0].moveset = []; + await runMysteryEncounterToEnd(game, 2, { pokemonNo: 1 }); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 3 - Teach it a Dance", () => { + it("should have the correct properties", () => { + const option = DancingLessonsEncounter.options[2]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + disabledButtonTooltip: `${namespace}.option.3.disabled_tooltip`, + secondOptionPrompt: `${namespace}.option.3.select_prompt`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + ], + }); + }); + + it("should add Oricorio to the party", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DANCING_LESSONS, defaultParty); + const partyCountBefore = scene.getParty().length; + scene.getParty()[0].moveset = [new PokemonMove(Moves.DRAGON_DANCE)]; + await runMysteryEncounterToEnd(game, 3, {pokemonNo: 1, optionNo: 1}); + const partyCountAfter = scene.getParty().length; + + expect(partyCountBefore + 1).toBe(partyCountAfter); + const oricorio = scene.getParty()[scene.getParty().length - 1]; + expect(oricorio.species.speciesId).toBe(Species.ORICORIO); + const moveset = oricorio.moveset.map(m => m?.moveId); + expect(moveset?.some(m => m === Moves.REVELATION_DANCE)).toBeTruthy(); + expect(moveset?.some(m => m === Moves.DRAGON_DANCE)).toBeTruthy(); + }); + + it("should NOT be selectable if the player doesn't have a Dance type move", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DANCING_LESSONS, defaultParty); + const partyCountBefore = scene.getParty().length; + scene.getParty().forEach(p => p.moveset = []); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 3); + const partyCountAfter = scene.getParty().length; + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + expect(partyCountBefore).toBe(partyCountAfter); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.DANCING_LESSONS, defaultParty); + scene.getParty()[0].moveset = [new PokemonMove(Moves.DRAGON_DANCE)]; + await runMysteryEncounterToEnd(game, 3, {pokemonNo: 1, optionNo: 1}); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/delibirdy-encounter.test.ts b/src/test/mystery-encounter/encounters/delibirdy-encounter.test.ts new file mode 100644 index 000000000000..969188dca060 --- /dev/null +++ b/src/test/mystery-encounter/encounters/delibirdy-encounter.test.ts @@ -0,0 +1,482 @@ +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { runMysteryEncounterToEnd, runSelectMysteryEncounterOption } from "#test/mystery-encounter/encounter-test-utils"; +import BattleScene from "#app/battle-scene"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { DelibirdyEncounter } from "#app/data/mystery-encounters/encounters/delibirdy-encounter"; +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { MoneyRequirement } from "#app/data/mystery-encounters/mystery-encounter-requirements"; +import { BerryModifier, HealingBoosterModifier, HiddenAbilityRateBoosterModifier, HitHealModifier, LevelIncrementBoosterModifier, PokemonInstantReviveModifier, PokemonNatureWeightModifier, PreserveBerryModifier } from "#app/modifier/modifier"; +import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; +import { generateModifierType } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { modifierTypes } from "#app/modifier/modifier-type"; +import { BerryType } from "#enums/berry-type"; + +const namespace = "mysteryEncounter:delibirdy"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("Delibird-y - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.CAVE, [MysteryEncounterType.DELIBIRDY]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DELIBIRDY, defaultParty); + + expect(DelibirdyEncounter.encounterType).toBe(MysteryEncounterType.DELIBIRDY); + expect(DelibirdyEncounter.encounterTier).toBe(MysteryEncounterTier.GREAT); + expect(DelibirdyEncounter.dialogue).toBeDefined(); + expect(DelibirdyEncounter.dialogue.intro).toStrictEqual([{ text: `${namespace}.intro` }]); + expect(DelibirdyEncounter.dialogue.outro).toStrictEqual([{ text: `${namespace}.outro` }]); + expect(DelibirdyEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(DelibirdyEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(DelibirdyEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(DelibirdyEncounter.options.length).toBe(3); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.DELIBIRDY); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should not spawn if player does not have enough money", async () => { + scene.money = 0; + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.DELIBIRDY); + }); + + describe("Option 1 - Give them money", () => { + it("should have the correct properties", () => { + const option1 = DelibirdyEncounter.options[0]; + expect(option1.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT); + expect(option1.dialogue).toBeDefined(); + expect(option1.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("Should update the player's money properly", async () => { + const initialMoney = 20000; + scene.money = initialMoney; + const updateMoneySpy = vi.spyOn(EncounterPhaseUtils, "updatePlayerMoney"); + + await game.runToMysteryEncounter(MysteryEncounterType.DELIBIRDY, defaultParty); + await runMysteryEncounterToEnd(game, 1); + + const price = (scene.currentBattle.mysteryEncounter?.options[0].requirements[0] as MoneyRequirement).requiredMoney; + + expect(updateMoneySpy).toHaveBeenCalledWith(scene, -price, true, false); + expect(scene.money).toBe(initialMoney - price); + }); + + it("Should give the player a Hidden Ability Charm", async () => { + scene.money = 200000; + await game.runToMysteryEncounter(MysteryEncounterType.DELIBIRDY, defaultParty); + await runMysteryEncounterToEnd(game, 1); + + const itemModifier = scene.findModifier(m => m instanceof HiddenAbilityRateBoosterModifier) as HiddenAbilityRateBoosterModifier; + + expect(itemModifier).toBeDefined(); + expect(itemModifier?.stackCount).toBe(1); + }); + + it("Should give the player a Shell Bell if they have max stacks of Berry Pouches", async () => { + scene.money = 200000; + await game.runToMysteryEncounter(MysteryEncounterType.DELIBIRDY, defaultParty); + + // 5 Healing Charms + scene.modifiers = []; + const abilityCharm = generateModifierType(scene, modifierTypes.ABILITY_CHARM)!.newModifier() as HiddenAbilityRateBoosterModifier; + abilityCharm.stackCount = 4; + await scene.addModifier(abilityCharm, true, false, false, true); + await scene.updateModifiers(true); + + await runMysteryEncounterToEnd(game, 1); + + const abilityCharmAfter = scene.findModifier(m => m instanceof HiddenAbilityRateBoosterModifier); + const shellBellAfter = scene.findModifier(m => m instanceof HitHealModifier); + + expect(abilityCharmAfter).toBeDefined(); + expect(abilityCharmAfter?.stackCount).toBe(4); + expect(shellBellAfter).toBeDefined(); + expect(shellBellAfter?.stackCount).toBe(1); + }); + + it("should be disabled if player does not have enough money", async () => { + scene.money = 0; + await game.runToMysteryEncounter(MysteryEncounterType.DELIBIRDY, defaultParty); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 1); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + }); + + it("should leave encounter without battle", async () => { + scene.money = 200000; + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.DELIBIRDY, defaultParty); + await runMysteryEncounterToEnd(game, 1); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 2 - Give Food", () => { + it("should have the correct properties", () => { + const option = DelibirdyEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + secondOptionPrompt: `${namespace}.option.2.select_prompt`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }); + }); + + it("Should decrease Berry stacks and give the player a Candy Jar", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DELIBIRDY, defaultParty); + + // Set 2 Sitrus berries on party lead + scene.modifiers = []; + const sitrus = generateModifierType(scene, modifierTypes.BERRY, [BerryType.SITRUS])!; + const sitrusMod = sitrus.newModifier(scene.getParty()[0]) as BerryModifier; + sitrusMod.stackCount = 2; + await scene.addModifier(sitrusMod, true, false, false, true); + await scene.updateModifiers(true); + + await runMysteryEncounterToEnd(game, 2, { pokemonNo: 1, optionNo: 1}); + + const sitrusAfter = scene.findModifier(m => m instanceof BerryModifier); + const candyJarAfter = scene.findModifier(m => m instanceof LevelIncrementBoosterModifier); + + expect(sitrusAfter?.stackCount).toBe(1); + expect(candyJarAfter).toBeDefined(); + expect(candyJarAfter?.stackCount).toBe(1); + }); + + it("Should remove Reviver Seed and give the player a Healing Charm", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DELIBIRDY, defaultParty); + + // Set 1 Reviver Seed on party lead + scene.modifiers = []; + const revSeed = generateModifierType(scene, modifierTypes.REVIVER_SEED)!; + const modifier = revSeed.newModifier(scene.getParty()[0]) as PokemonInstantReviveModifier; + modifier.stackCount = 1; + await scene.addModifier(modifier, true, false, false, true); + await scene.updateModifiers(true); + + await runMysteryEncounterToEnd(game, 2, { pokemonNo: 1, optionNo: 1}); + + const reviverSeedAfter = scene.findModifier(m => m instanceof PokemonInstantReviveModifier); + const healingCharmAfter = scene.findModifier(m => m instanceof HealingBoosterModifier); + + expect(reviverSeedAfter).toBeUndefined(); + expect(healingCharmAfter).toBeDefined(); + expect(healingCharmAfter?.stackCount).toBe(1); + }); + + it("Should give the player a Shell Bell if they have max stacks of Candy Jars", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DELIBIRDY, defaultParty); + + // 99 Candy Jars + scene.modifiers = []; + const candyJar = generateModifierType(scene, modifierTypes.CANDY_JAR)!.newModifier() as LevelIncrementBoosterModifier; + candyJar.stackCount = 99; + await scene.addModifier(candyJar, true, false, false, true); + const sitrus = generateModifierType(scene, modifierTypes.BERRY, [BerryType.SITRUS])!; + + // Sitrus berries on party + const sitrusMod = sitrus.newModifier(scene.getParty()[0]) as BerryModifier; + sitrusMod.stackCount = 2; + await scene.addModifier(sitrusMod, true, false, false, true); + await scene.updateModifiers(true); + + await runMysteryEncounterToEnd(game, 2, { pokemonNo: 1, optionNo: 1}); + + const sitrusAfter = scene.findModifier(m => m instanceof BerryModifier); + const candyJarAfter = scene.findModifier(m => m instanceof LevelIncrementBoosterModifier); + const shellBellAfter = scene.findModifier(m => m instanceof HitHealModifier); + + expect(sitrusAfter?.stackCount).toBe(1); + expect(candyJarAfter).toBeDefined(); + expect(candyJarAfter?.stackCount).toBe(99); + expect(shellBellAfter).toBeDefined(); + expect(shellBellAfter?.stackCount).toBe(1); + }); + + it("Should give the player a Shell Bell if they have max stacks of Healing Charms", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DELIBIRDY, defaultParty); + + // 5 Healing Charms + scene.modifiers = []; + const healingCharm = generateModifierType(scene, modifierTypes.HEALING_CHARM)!.newModifier() as HealingBoosterModifier; + healingCharm.stackCount = 5; + await scene.addModifier(healingCharm, true, false, false, true); + + // Set 1 Reviver Seed on party lead + const revSeed = generateModifierType(scene, modifierTypes.REVIVER_SEED)!; + const modifier = revSeed.newModifier(scene.getParty()[0]) as PokemonInstantReviveModifier; + modifier.stackCount = 1; + await scene.addModifier(modifier, true, false, false, true); + await scene.updateModifiers(true); + + await runMysteryEncounterToEnd(game, 2, { pokemonNo: 1, optionNo: 1}); + + const reviverSeedAfter = scene.findModifier(m => m instanceof PokemonInstantReviveModifier); + const healingCharmAfter = scene.findModifier(m => m instanceof HealingBoosterModifier); + const shellBellAfter = scene.findModifier(m => m instanceof HitHealModifier); + + expect(reviverSeedAfter).toBeUndefined(); + expect(healingCharmAfter).toBeDefined(); + expect(healingCharmAfter?.stackCount).toBe(5); + expect(shellBellAfter).toBeDefined(); + expect(shellBellAfter?.stackCount).toBe(1); + }); + + it("should be disabled if player does not have any proper items", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DELIBIRDY, defaultParty); + + // Set 1 Soul Dew on party lead + scene.modifiers = []; + const soulDew = generateModifierType(scene, modifierTypes.SOUL_DEW)!; + const modifier = soulDew.newModifier(scene.getParty()[0]); + await scene.addModifier(modifier, true, false, false, true); + await scene.updateModifiers(true); + + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 2); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.DELIBIRDY, defaultParty); + + // Set 1 Reviver Seed on party lead + const revSeed = generateModifierType(scene, modifierTypes.REVIVER_SEED)!; + const modifier = revSeed.newModifier(scene.getParty()[0]) as PokemonInstantReviveModifier; + modifier.stackCount = 1; + await scene.addModifier(modifier, true, false, false, true); + await scene.updateModifiers(true); + + await runMysteryEncounterToEnd(game, 2, { pokemonNo: 1, optionNo: 1}); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 3 - Give Item", () => { + it("should have the correct properties", () => { + const option = DelibirdyEncounter.options[2]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + secondOptionPrompt: `${namespace}.option.3.select_prompt`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + ], + }); + }); + + it("Should decrease held item stacks and give the player a Berry Pouch", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DELIBIRDY, defaultParty); + + // Set 2 Soul Dew on party lead + scene.modifiers = []; + const soulDew = generateModifierType(scene, modifierTypes.SOUL_DEW)!; + const modifier = soulDew.newModifier(scene.getParty()[0]) as PokemonNatureWeightModifier; + modifier.stackCount = 2; + await scene.addModifier(modifier, true, false, false, true); + await scene.updateModifiers(true); + + await runMysteryEncounterToEnd(game, 3, { pokemonNo: 1, optionNo: 1}); + + const soulDewAfter = scene.findModifier(m => m instanceof PokemonNatureWeightModifier); + const berryPouchAfter = scene.findModifier(m => m instanceof PreserveBerryModifier); + + expect(soulDewAfter?.stackCount).toBe(1); + expect(berryPouchAfter).toBeDefined(); + expect(berryPouchAfter?.stackCount).toBe(1); + }); + + it("Should remove held item and give the player a Berry Pouch", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DELIBIRDY, defaultParty); + + // Set 1 Soul Dew on party lead + scene.modifiers = []; + const soulDew = generateModifierType(scene, modifierTypes.SOUL_DEW)!; + const modifier = soulDew.newModifier(scene.getParty()[0]) as PokemonNatureWeightModifier; + modifier.stackCount = 1; + await scene.addModifier(modifier, true, false, false, true); + await scene.updateModifiers(true); + + await runMysteryEncounterToEnd(game, 3, { pokemonNo: 1, optionNo: 1}); + + const soulDewAfter = scene.findModifier(m => m instanceof PokemonNatureWeightModifier); + const berryPouchAfter = scene.findModifier(m => m instanceof PreserveBerryModifier); + + expect(soulDewAfter).toBeUndefined(); + expect(berryPouchAfter).toBeDefined(); + expect(berryPouchAfter?.stackCount).toBe(1); + }); + + it("Should give the player a Shell Bell if they have max stacks of Berry Pouches", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DELIBIRDY, defaultParty); + + // 5 Healing Charms + scene.modifiers = []; + const healingCharm = generateModifierType(scene, modifierTypes.BERRY_POUCH)!.newModifier() as PreserveBerryModifier; + healingCharm.stackCount = 3; + await scene.addModifier(healingCharm, true, false, false, true); + + // Set 1 Soul Dew on party lead + const soulDew = generateModifierType(scene, modifierTypes.SOUL_DEW)!; + const modifier = soulDew.newModifier(scene.getParty()[0]) as PokemonNatureWeightModifier; + modifier.stackCount = 1; + await scene.addModifier(modifier, true, false, false, true); + await scene.updateModifiers(true); + + await runMysteryEncounterToEnd(game, 3, { pokemonNo: 1, optionNo: 1}); + + const soulDewAfter = scene.findModifier(m => m instanceof PokemonNatureWeightModifier); + const berryPouchAfter = scene.findModifier(m => m instanceof PreserveBerryModifier); + const shellBellAfter = scene.findModifier(m => m instanceof HitHealModifier); + + expect(soulDewAfter).toBeUndefined(); + expect(berryPouchAfter).toBeDefined(); + expect(berryPouchAfter?.stackCount).toBe(3); + expect(shellBellAfter).toBeDefined(); + expect(shellBellAfter?.stackCount).toBe(1); + }); + + it("should be disabled if player does not have any proper items", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DELIBIRDY, defaultParty); + + // Set 1 Reviver Seed on party lead + scene.modifiers = []; + const revSeed = generateModifierType(scene, modifierTypes.REVIVER_SEED)!; + const modifier = revSeed.newModifier(scene.getParty()[0]); + await scene.addModifier(modifier, true, false, false, true); + await scene.updateModifiers(true); + + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 3); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.DELIBIRDY, defaultParty); + + // Set 1 Soul Dew on party lead + scene.modifiers = []; + const soulDew = generateModifierType(scene, modifierTypes.SOUL_DEW)!; + const modifier = soulDew.newModifier(scene.getParty()[0]) as PokemonNatureWeightModifier; + modifier.stackCount = 1; + await scene.addModifier(modifier, true, false, false, true); + await scene.updateModifiers(true); + + await runMysteryEncounterToEnd(game, 3, { pokemonNo: 1, optionNo: 1}); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/department-store-sale-encounter.test.ts b/src/test/mystery-encounter/encounters/department-store-sale-encounter.test.ts new file mode 100644 index 000000000000..f22bd8329649 --- /dev/null +++ b/src/test/mystery-encounter/encounters/department-store-sale-encounter.test.ts @@ -0,0 +1,239 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { runMysteryEncounterToEnd } from "#test/mystery-encounter/encounter-test-utils"; +import BattleScene from "#app/battle-scene"; +import { Mode } from "#app/ui/ui"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; +import { DepartmentStoreSaleEncounter } from "#app/data/mystery-encounters/encounters/department-store-sale-encounter"; +import { CIVILIZATION_ENCOUNTER_BIOMES } from "#app/data/mystery-encounters/mystery-encounters"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; + +const namespace = "mysteryEncounter:departmentStoreSale"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.PLAINS; +const defaultWave = 37; + +describe("Department Store Sale - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + const biomeMap = new Map([ + [Biome.VOLCANO, [MysteryEncounterType.MYSTERIOUS_CHALLENGERS]], + ]); + CIVILIZATION_ENCOUNTER_BIOMES.forEach(biome => { + biomeMap.set(biome, [MysteryEncounterType.DEPARTMENT_STORE_SALE]); + }); + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue(biomeMap); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DEPARTMENT_STORE_SALE, defaultParty); + + expect(DepartmentStoreSaleEncounter.encounterType).toBe(MysteryEncounterType.DEPARTMENT_STORE_SALE); + expect(DepartmentStoreSaleEncounter.encounterTier).toBe(MysteryEncounterTier.COMMON); + expect(DepartmentStoreSaleEncounter.dialogue).toBeDefined(); + expect(DepartmentStoreSaleEncounter.dialogue.intro).toStrictEqual([ + { text: `${namespace}.intro` }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.intro_dialogue`, + } + ]); + expect(DepartmentStoreSaleEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(DepartmentStoreSaleEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(DepartmentStoreSaleEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(DepartmentStoreSaleEncounter.options.length).toBe(4); + }); + + it("should not spawn outside of CIVILIZATION_ENCOUNTER_BIOMES", async () => { + game.override.mysteryEncounterTier(MysteryEncounterTier.COMMON); + game.override.startingBiome(Biome.VOLCANO); + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.DEPARTMENT_STORE_SALE); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.DEPARTMENT_STORE_SALE); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + describe("Option 1 - TM Shop", () => { + it("should have the correct properties", () => { + const option = DepartmentStoreSaleEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + }); + }); + + it("should have shop with only TMs", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DEPARTMENT_STORE_SALE, defaultParty); + await runMysteryEncounterToEnd(game, 1); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(4); + for (const option of modifierSelectHandler.options) { + expect(option.modifierTypeOption.type.id).toContain("TM_"); + } + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.DEPARTMENT_STORE_SALE, defaultParty); + await runMysteryEncounterToEnd(game, 1); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 2 - Vitamin Shop", () => { + it("should have the correct properties", () => { + const option = DepartmentStoreSaleEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + }); + }); + + it("should have shop with only Vitamins", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DEPARTMENT_STORE_SALE, defaultParty); + await runMysteryEncounterToEnd(game, 2); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(3); + for (const option of modifierSelectHandler.options) { + expect(option.modifierTypeOption.type.id.includes("PP_UP") || + option.modifierTypeOption.type.id.includes("BASE_STAT_BOOSTER")).toBeTruthy(); + } + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.DEPARTMENT_STORE_SALE, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 3 - X Item Shop", () => { + it("should have the correct properties", () => { + const option = DepartmentStoreSaleEncounter.options[2]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + }); + }); + + it("should have shop with only X Items", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DEPARTMENT_STORE_SALE, defaultParty); + await runMysteryEncounterToEnd(game, 3); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(5); + for (const option of modifierSelectHandler.options) { + expect(option.modifierTypeOption.type.id.includes("DIRE_HIT") || + option.modifierTypeOption.type.id.includes("TEMP_STAT_STAGE_BOOSTER")).toBeTruthy(); + } + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.DEPARTMENT_STORE_SALE, defaultParty); + await runMysteryEncounterToEnd(game, 3); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 4 - Pokeball Shop", () => { + it("should have the correct properties", () => { + const option = DepartmentStoreSaleEncounter.options[3]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.4.label`, + buttonTooltip: `${namespace}.option.4.tooltip`, + }); + }); + + it("should have shop with only Pokeballs", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.DEPARTMENT_STORE_SALE, defaultParty); + await runMysteryEncounterToEnd(game, 4); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(4); + for (const option of modifierSelectHandler.options) { + expect(option.modifierTypeOption.type.id).toContain("BALL"); + } + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.DEPARTMENT_STORE_SALE, defaultParty); + await runMysteryEncounterToEnd(game, 4); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/field-trip-encounter.test.ts b/src/test/mystery-encounter/encounters/field-trip-encounter.test.ts new file mode 100644 index 000000000000..7a8d951c5da5 --- /dev/null +++ b/src/test/mystery-encounter/encounters/field-trip-encounter.test.ts @@ -0,0 +1,231 @@ +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { runMysteryEncounterToEnd } from "#test/mystery-encounter/encounter-test-utils"; +import BattleScene from "#app/battle-scene"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { FieldTripEncounter } from "#app/data/mystery-encounters/encounters/field-trip-encounter"; +import { Moves } from "#enums/moves"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; +import { Mode } from "#app/ui/ui"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; + +const namespace = "mysteryEncounter:fieldTrip"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("Field Trip - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + game.override.moveset([Moves.TACKLE, Moves.UPROAR, Moves.SWORDS_DANCE]); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.CAVE, [MysteryEncounterType.FIELD_TRIP]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.FIELD_TRIP, defaultParty); + + expect(FieldTripEncounter.encounterType).toBe(MysteryEncounterType.FIELD_TRIP); + expect(FieldTripEncounter.encounterTier).toBe(MysteryEncounterTier.COMMON); + expect(FieldTripEncounter.dialogue).toBeDefined(); + expect(FieldTripEncounter.dialogue.intro).toStrictEqual([ + { + text: `${namespace}.intro` + }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.intro_dialogue` + } + ]); + expect(FieldTripEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(FieldTripEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(FieldTripEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(FieldTripEncounter.options.length).toBe(3); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.FIELD_TRIP); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + describe("Option 1 - Show off a physical move", () => { + it("should have the correct properties", () => { + const option = FieldTripEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + secondOptionPrompt: `${namespace}.second_option_prompt`, + }); + }); + + it("Should give no reward on incorrect option", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.FIELD_TRIP, defaultParty); + await runMysteryEncounterToEnd(game, 1, { pokemonNo: 1, optionNo: 2 }); + await game.phaseInterceptor.to(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(0); + }); + + it("Should give proper rewards on correct Physical move option", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.FIELD_TRIP, defaultParty); + await runMysteryEncounterToEnd(game, 1, { pokemonNo: 1, optionNo: 1 }); + await game.phaseInterceptor.to(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(5); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.name).toBe("X Attack"); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.name).toBe("X Defense"); + expect(modifierSelectHandler.options[2].modifierTypeOption.type.name).toBe("X Speed"); + expect(modifierSelectHandler.options[3].modifierTypeOption.type.name).toBe("Dire Hit"); + expect(modifierSelectHandler.options[4].modifierTypeOption.type.name).toBe("Rarer Candy"); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.FIELD_TRIP, defaultParty); + await runMysteryEncounterToEnd(game, 1, { pokemonNo: 1, optionNo: 1 }); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 2 - Give Food", () => { + it("should have the correct properties", () => { + const option = FieldTripEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + secondOptionPrompt: `${namespace}.second_option_prompt`, + }); + }); + + it("Should give no reward on incorrect option", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.FIELD_TRIP, defaultParty); + await runMysteryEncounterToEnd(game, 2, { pokemonNo: 1, optionNo: 1 }); + await game.phaseInterceptor.to(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(0); + }); + + it("Should give proper rewards on correct Special move option", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.FIELD_TRIP, defaultParty); + await runMysteryEncounterToEnd(game, 2, { pokemonNo: 1, optionNo: 2 }); + await game.phaseInterceptor.to(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(5); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.name).toBe("X Sp. Atk"); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.name).toBe("X Sp. Def"); + expect(modifierSelectHandler.options[2].modifierTypeOption.type.name).toBe("X Speed"); + expect(modifierSelectHandler.options[3].modifierTypeOption.type.name).toBe("Dire Hit"); + expect(modifierSelectHandler.options[4].modifierTypeOption.type.name).toBe("Rarer Candy"); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.FIELD_TRIP, defaultParty); + await runMysteryEncounterToEnd(game, 2, { pokemonNo: 1, optionNo: 2 }); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 3 - Give Item", () => { + it("should have the correct properties", () => { + const option = FieldTripEncounter.options[2]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + secondOptionPrompt: `${namespace}.second_option_prompt`, + }); + }); + + it("Should give no reward on incorrect option", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.FIELD_TRIP, defaultParty); + await runMysteryEncounterToEnd(game, 3, { pokemonNo: 1, optionNo: 1 }); + await game.phaseInterceptor.to(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(0); + }); + + it("Should give proper rewards on correct Special move option", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.FIELD_TRIP, defaultParty); + await runMysteryEncounterToEnd(game, 3, { pokemonNo: 1, optionNo: 3 }); + await game.phaseInterceptor.to(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(5); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.name).toBe("X Accuracy"); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.name).toBe("X Speed"); + expect(modifierSelectHandler.options[2].modifierTypeOption.type.name).toBe("5x Great Ball"); + expect(modifierSelectHandler.options[3].modifierTypeOption.type.name).toBe("IV Scanner"); + expect(modifierSelectHandler.options[4].modifierTypeOption.type.name).toBe("Rarer Candy"); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.FIELD_TRIP, defaultParty); + await runMysteryEncounterToEnd(game, 2, { pokemonNo: 1, optionNo: 3 }); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/fiery-fallout-encounter.test.ts b/src/test/mystery-encounter/encounters/fiery-fallout-encounter.test.ts new file mode 100644 index 000000000000..445ab4491a4b --- /dev/null +++ b/src/test/mystery-encounter/encounters/fiery-fallout-encounter.test.ts @@ -0,0 +1,287 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { FieryFalloutEncounter } from "#app/data/mystery-encounters/encounters/fiery-fallout-encounter"; +import { Gender } from "#app/data/gender"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import * as BattleAnims from "#app/data/battle-anims"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { runMysteryEncounterToEnd, runSelectMysteryEncounterOption, skipBattleRunMysteryEncounterRewardsPhase } from "#test/mystery-encounter/encounter-test-utils"; +import { Moves } from "#enums/moves"; +import BattleScene from "#app/battle-scene"; +import { PokemonHeldItemModifier } from "#app/modifier/modifier"; +import { Type } from "#app/data/type"; +import { Status, StatusEffect } from "#app/data/status-effect"; +import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import { CommandPhase } from "#app/phases/command-phase"; +import { MovePhase } from "#app/phases/move-phase"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; + +const namespace = "mysteryEncounter:fieryFallout"; +/** Arcanine and Ninetails for 2 Fire types. Lapras, Gengar, Abra for burnable mon. */ +const defaultParty = [Species.ARCANINE, Species.NINETALES, Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.VOLCANO; +const defaultWave = 56; + +describe("Fiery Fallout - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.VOLCANO, [MysteryEncounterType.FIERY_FALLOUT]], + [Biome.MOUNTAIN, [MysteryEncounterType.MYSTERIOUS_CHALLENGERS]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.FIERY_FALLOUT, defaultParty); + + expect(FieryFalloutEncounter.encounterType).toBe(MysteryEncounterType.FIERY_FALLOUT); + expect(FieryFalloutEncounter.encounterTier).toBe(MysteryEncounterTier.COMMON); + expect(FieryFalloutEncounter.dialogue).toBeDefined(); + expect(FieryFalloutEncounter.dialogue.intro).toStrictEqual([{ text: `${namespace}.intro` }]); + expect(FieryFalloutEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(FieryFalloutEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(FieryFalloutEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(FieryFalloutEncounter.options.length).toBe(3); + }); + + it("should not spawn outside of volcano biome", async () => { + game.override.startingBiome(Biome.MOUNTAIN); + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.FIERY_FALLOUT); + }); + + it("should not run below wave 41", async () => { + game.override.startingWave(38); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.FIERY_FALLOUT); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should initialize fully ", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = FieryFalloutEncounter; + const weatherSpy = vi.spyOn(scene.arena, "trySetWeather").mockReturnValue(true); + const moveInitSpy = vi.spyOn(BattleAnims, "initMoveAnim"); + const moveLoadSpy = vi.spyOn(BattleAnims, "loadMoveAnimAssets"); + + const { onInit } = FieryFalloutEncounter; + + expect(FieryFalloutEncounter.onInit).toBeDefined(); + + FieryFalloutEncounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + + expect(FieryFalloutEncounter.enemyPartyConfigs).toEqual([ + { + pokemonConfigs: [ + { + species: getPokemonSpecies(Species.VOLCARONA), + isBoss: false, + gender: Gender.MALE + }, + { + species: getPokemonSpecies(Species.VOLCARONA), + isBoss: false, + gender: Gender.FEMALE + } + ], + doubleBattle: true, + disableSwitch: true + } + ]); + expect(weatherSpy).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(moveInitSpy).toHaveBeenCalled()); + await vi.waitFor(() => expect(moveLoadSpy).toHaveBeenCalled()); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Fight 2 Volcarona", () => { + it("should have the correct properties", () => { + const option1 = FieryFalloutEncounter.options[0]; + expect(option1.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option1.dialogue).toBeDefined(); + expect(option1.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("should start battle against 2 Volcarona", async () => { + const phaseSpy = vi.spyOn(scene, "pushPhase"); + + await game.runToMysteryEncounter(MysteryEncounterType.FIERY_FALLOUT, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + const enemyField = scene.getEnemyField(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyField.length).toBe(2); + expect(enemyField[0].species.speciesId).toBe(Species.VOLCARONA); + expect(enemyField[1].species.speciesId).toBe(Species.VOLCARONA); + expect(enemyField[0].gender).not.toEqual(enemyField[1].gender); // Should be opposite gender + + const movePhases = phaseSpy.mock.calls.filter(p => p[0] instanceof MovePhase).map(p => p[0]); + expect(movePhases.length).toBe(4); + expect(movePhases.filter(p => (p as MovePhase).move.moveId === Moves.FIRE_SPIN).length).toBe(2); // Fire spin used twice before battle + expect(movePhases.filter(p => (p as MovePhase).move.moveId === Moves.QUIVER_DANCE).length).toBe(2); // Quiver Dance used twice before battle + }); + + it("should give charcoal to lead pokemon", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.FIERY_FALLOUT, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + await skipBattleRunMysteryEncounterRewardsPhase(game); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + + const leadPokemonId = scene.getParty()?.[0].id; + const leadPokemonItems = scene.findModifiers(m => m instanceof PokemonHeldItemModifier + && (m as PokemonHeldItemModifier).pokemonId === leadPokemonId, true) as PokemonHeldItemModifier[]; + const charcoal = leadPokemonItems.find(i => i.type.name === "Charcoal"); + expect(charcoal).toBeDefined; + }); + }); + + describe("Option 2 - Suffer the weather", () => { + it("should have the correct properties", () => { + const option1 = FieryFalloutEncounter.options[1]; + expect(option1.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option1.dialogue).toBeDefined(); + expect(option1.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }); + }); + + it("should damage all non-fire party PKM by 20% and randomly burn 1", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.FIERY_FALLOUT, defaultParty); + + const party = scene.getParty(); + const lapras = party.find((pkm) => pkm.species.speciesId === Species.LAPRAS)!; + lapras.status = new Status(StatusEffect.POISON); + const abra = party.find((pkm) => pkm.species.speciesId === Species.ABRA)!; + vi.spyOn(abra, "isAllowedInBattle").mockReturnValue(false); + + await runMysteryEncounterToEnd(game, 2); + + const burnablePokemon = party.filter((pkm) => pkm.isAllowedInBattle() && !pkm.getTypes().includes(Type.FIRE)); + const notBurnablePokemon = party.filter((pkm) => !pkm.isAllowedInBattle() || pkm.getTypes().includes(Type.FIRE)); + expect(scene.currentBattle.mysteryEncounter?.dialogueTokens["burnedPokemon"]).toBe("Gengar"); + burnablePokemon.forEach((pkm) => { + expect(pkm.hp, `${pkm.name} should have received 20% damage: ${pkm.hp} / ${pkm.getMaxHp()} HP`).toBe(pkm.getMaxHp() - Math.floor(pkm.getMaxHp() * 0.2)); + }); + expect(burnablePokemon.some(pkm => pkm?.status?.effect === StatusEffect.BURN)).toBeTruthy(); + notBurnablePokemon.forEach((pkm) => expect(pkm.hp, `${pkm.name} should be full hp: ${pkm.hp} / ${pkm.getMaxHp()} HP`).toBe(pkm.getMaxHp())); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.FIERY_FALLOUT, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 3 - use FIRE types", () => { + it("should have the correct properties", () => { + const option1 = FieryFalloutEncounter.options[2]; + expect(option1.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL); + expect(option1.dialogue).toBeDefined(); + expect(option1.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + disabledButtonTooltip: `${namespace}.option.3.disabled_tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + ], + }); + }); + + it("should give charcoal to lead pokemon", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.FIERY_FALLOUT, defaultParty); + await runMysteryEncounterToEnd(game, 3); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + + const leadPokemonId = scene.getParty()?.[0].id; + const leadPokemonItems = scene.findModifiers(m => m instanceof PokemonHeldItemModifier + && (m as PokemonHeldItemModifier).pokemonId === leadPokemonId, true) as PokemonHeldItemModifier[]; + const charcoal = leadPokemonItems.find(i => i.type.name === "Charcoal"); + expect(charcoal).toBeDefined; + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.FIERY_FALLOUT, defaultParty); + await runMysteryEncounterToEnd(game, 3); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + + it("should be disabled if not enough FIRE types are in party", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.FIERY_FALLOUT, [Species.MAGIKARP, Species.ARCANINE]); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const continueEncounterSpy = vi.spyOn((encounterPhase as MysteryEncounterPhase), "continueEncounter"); + + await runSelectMysteryEncounterOption(game, 3); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(continueEncounterSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/fight-or-flight-encounter.test.ts b/src/test/mystery-encounter/encounters/fight-or-flight-encounter.test.ts new file mode 100644 index 000000000000..735dcc709bf8 --- /dev/null +++ b/src/test/mystery-encounter/encounters/fight-or-flight-encounter.test.ts @@ -0,0 +1,222 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runMysteryEncounterToEnd, runSelectMysteryEncounterOption, skipBattleRunMysteryEncounterRewardsPhase } from "#test/mystery-encounter/encounter-test-utils"; +import { Moves } from "#enums/moves"; +import BattleScene from "#app/battle-scene"; +import { PokemonMove } from "#app/field/pokemon"; +import { Mode } from "#app/ui/ui"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { FightOrFlightEncounter } from "#app/data/mystery-encounters/encounters/fight-or-flight-encounter"; +import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; +import { CommandPhase } from "#app/phases/command-phase"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; + +const namespace = "mysteryEncounter:fightOrFlight"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("Fight or Flight - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.CAVE, [MysteryEncounterType.FIGHT_OR_FLIGHT]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.FIGHT_OR_FLIGHT, defaultParty); + + expect(FightOrFlightEncounter.encounterType).toBe(MysteryEncounterType.FIGHT_OR_FLIGHT); + expect(FightOrFlightEncounter.encounterTier).toBe(MysteryEncounterTier.COMMON); + expect(FightOrFlightEncounter.dialogue).toBeDefined(); + expect(FightOrFlightEncounter.dialogue.intro).toStrictEqual([{ text: `${namespace}.intro` }]); + expect(FightOrFlightEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(FightOrFlightEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(FightOrFlightEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(FightOrFlightEncounter.options.length).toBe(3); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.FIGHT_OR_FLIGHT); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should initialize fully", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = FightOrFlightEncounter; + + const { onInit } = FightOrFlightEncounter; + + expect(FightOrFlightEncounter.onInit).toBeDefined(); + + FightOrFlightEncounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + + const config = FightOrFlightEncounter.enemyPartyConfigs[0]; + expect(config).toBeDefined(); + expect(config.levelAdditiveMultiplier).toBe(1); + expect(config.pokemonConfigs?.[0].isBoss).toBe(true); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Fight", () => { + it("should have the correct properties", () => { + const option = FightOrFlightEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("should start a fight against the boss", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.FIGHT_OR_FLIGHT, defaultParty); + + const config = game.scene.currentBattle.mysteryEncounter!.enemyPartyConfigs[0]; + const speciesToSpawn = config.pokemonConfigs?.[0].species.speciesId; + + await runMysteryEncounterToEnd(game, 1, undefined, true); + + const enemyField = scene.getEnemyField(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyField.length).toBe(1); + expect(enemyField[0].species.speciesId).toBe(speciesToSpawn); + }); + + it("should reward the player with the item based on wave", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.FIGHT_OR_FLIGHT, defaultParty); + + const item = game.scene.currentBattle.mysteryEncounter?.misc; + + await runMysteryEncounterToEnd(game, 1, undefined, true); + await skipBattleRunMysteryEncounterRewardsPhase(game); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(1); + expect(item.type.name).toBe(modifierSelectHandler.options[0].modifierTypeOption.type.name); + }); + }); + + describe("Option 2 - Attempt to Steal", () => { + it("should have the correct properties", () => { + const option = FightOrFlightEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + disabledButtonTooltip: `${namespace}.option.2.disabled_tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + } + ], + }); + }); + + it("should NOT be selectable if the player doesn't have a Stealing move", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.FIGHT_OR_FLIGHT, defaultParty); + scene.getParty().forEach(p => p.moveset = []); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 2); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + }); + + it("Should skip fight when player meets requirements", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.FIGHT_OR_FLIGHT, defaultParty); + + // Mock moveset + scene.getParty()[0].moveset = [new PokemonMove(Moves.KNOCK_OFF)]; + const item = game.scene.currentBattle.mysteryEncounter!.misc; + + await runMysteryEncounterToEnd(game, 2); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(1); + expect(item.type.name).toBe(modifierSelectHandler.options[0].modifierTypeOption.type.name); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 3 - Leave", () => { + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.FIGHT_OR_FLIGHT, defaultParty); + await runMysteryEncounterToEnd(game, 3); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/fun-and-games-encounter.test.ts b/src/test/mystery-encounter/encounters/fun-and-games-encounter.test.ts new file mode 100644 index 000000000000..70250350af48 --- /dev/null +++ b/src/test/mystery-encounter/encounters/fun-and-games-encounter.test.ts @@ -0,0 +1,304 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { HUMAN_TRANSITABLE_BIOMES } from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runMysteryEncounterToEnd, runSelectMysteryEncounterOption } from "#test/mystery-encounter/encounter-test-utils"; +import BattleScene from "#app/battle-scene"; +import { Mode } from "#app/ui/ui"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; +import MysteryEncounter from "#app/data/mystery-encounters/mystery-encounter"; +import { Nature } from "#enums/nature"; +import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; +import { CommandPhase } from "#app/phases/command-phase"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; +import { FunAndGamesEncounter } from "#app/data/mystery-encounters/encounters/fun-and-games-encounter"; +import { Moves } from "#enums/moves"; +import { Command } from "#app/ui/command-ui-handler"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; + +const namespace = "mysteryEncounter:funAndGames"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("Fun And Games! - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + const biomeMap = new Map([ + [Biome.VOLCANO, [MysteryEncounterType.FIGHT_OR_FLIGHT]], + ]); + HUMAN_TRANSITABLE_BIOMES.forEach(biome => { + biomeMap.set(biome, [MysteryEncounterType.FUN_AND_GAMES]); + }); + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue(biomeMap); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.FUN_AND_GAMES, defaultParty); + + expect(FunAndGamesEncounter.encounterType).toBe(MysteryEncounterType.FUN_AND_GAMES); + expect(FunAndGamesEncounter.encounterTier).toBe(MysteryEncounterTier.GREAT); + expect(FunAndGamesEncounter.dialogue).toBeDefined(); + expect(FunAndGamesEncounter.dialogue.intro).toStrictEqual([ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.intro_dialogue`, + } + ]); + expect(FunAndGamesEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(FunAndGamesEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(FunAndGamesEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(FunAndGamesEncounter.options.length).toBe(2); + }); + + it("should not spawn outside of CIVILIZATIONN biomes", async () => { + game.override.mysteryEncounterTier(MysteryEncounterTier.GREAT); + game.override.startingBiome(Biome.VOLCANO); + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.FUN_AND_GAMES); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.FUN_AND_GAMES); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should initialize fully", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = new MysteryEncounter(FunAndGamesEncounter); + const encounter = scene.currentBattle.mysteryEncounter!; + scene.currentBattle.waveIndex = defaultWave; + + const { onInit } = encounter; + + expect(encounter.onInit).toBeDefined(); + + const onInitResult = onInit!(scene); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Play the Wobbuffet game", () => { + it("should have the correct properties", () => { + const option = FunAndGamesEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("should NOT be selectable if the player doesn't have enough money", async () => { + game.scene.money = 0; + await game.runToMysteryEncounter(MysteryEncounterType.FUN_AND_GAMES, defaultParty); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 1); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + }); + + it("should get 3 turns to attack the Wobbuffet for a reward", async () => { + scene.money = 20000; + game.override.moveset([Moves.TACKLE]); + await game.runToMysteryEncounter(MysteryEncounterType.FUN_AND_GAMES, defaultParty); + await runMysteryEncounterToEnd(game, 1, { pokemonNo: 1 }, true); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(scene.getEnemyPokemon()?.species.speciesId).toBe(Species.WOBBUFFET); + expect(scene.getEnemyPokemon()?.ivs).toEqual([0, 0, 0, 0, 0, 0]); + expect(scene.getEnemyPokemon()?.nature).toBe(Nature.MILD); + + game.onNextPrompt("MessagePhase", Mode.MESSAGE, () => { + game.endPhase(); + }); + + // Turn 1 + (game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, false); + await game.phaseInterceptor.to(CommandPhase); + + // Turn 2 + (game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, false); + await game.phaseInterceptor.to(CommandPhase); + + // Turn 3 + (game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, false); + await game.phaseInterceptor.to(SelectModifierPhase, false); + + // Rewards + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + }); + + it("should have no items in rewards if Wubboffet doesn't take enough damage", async () => { + scene.money = 20000; + await game.runToMysteryEncounter(MysteryEncounterType.FUN_AND_GAMES, defaultParty); + await runMysteryEncounterToEnd(game, 1, { pokemonNo: 1 }, true); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + game.onNextPrompt("MessagePhase", Mode.MESSAGE, () => { + game.endPhase(); + }); + + // Skip minigame + scene.currentBattle.mysteryEncounter!.misc.turnsRemaining = 0; + (game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, false); + await game.phaseInterceptor.to(SelectModifierPhase, false); + + // Rewards + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(0); + }); + + it("should have Wide Lens item in rewards if Wubboffet is at 15-33% HP remaining", async () => { + scene.money = 20000; + game.override.moveset([Moves.SPLASH]); + await game.runToMysteryEncounter(MysteryEncounterType.FUN_AND_GAMES, defaultParty); + await runMysteryEncounterToEnd(game, 1, { pokemonNo: 1 }, true); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + game.onNextPrompt("MessagePhase", Mode.MESSAGE, () => { + game.endPhase(); + }); + + // Skip minigame + const wobbuffet = scene.getEnemyPokemon()!; + wobbuffet.hp = Math.floor(0.2 * wobbuffet.getMaxHp()); + scene.currentBattle.mysteryEncounter!.misc.turnsRemaining = 0; + (game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, false); + await game.phaseInterceptor.to(SelectModifierPhase, false); + + // Rewards + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(1); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toEqual("WIDE_LENS"); + }); + + it("should have Scope Lens item in rewards if Wubboffet is at 3-15% HP remaining", async () => { + scene.money = 20000; + game.override.moveset([Moves.SPLASH]); + await game.runToMysteryEncounter(MysteryEncounterType.FUN_AND_GAMES, defaultParty); + await runMysteryEncounterToEnd(game, 1, { pokemonNo: 1 }, true); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + game.onNextPrompt("MessagePhase", Mode.MESSAGE, () => { + game.endPhase(); + }); + + // Skip minigame + const wobbuffet = scene.getEnemyPokemon()!; + wobbuffet.hp = Math.floor(0.1 * wobbuffet.getMaxHp()); + scene.currentBattle.mysteryEncounter!.misc.turnsRemaining = 0; + (game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, false); + await game.phaseInterceptor.to(SelectModifierPhase, false); + + // Rewards + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(1); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toEqual("SCOPE_LENS"); + }); + + it("should have Multi Lens item in rewards if Wubboffet is at <3% HP remaining", async () => { + scene.money = 20000; + game.override.moveset([Moves.SPLASH]); + await game.runToMysteryEncounter(MysteryEncounterType.FUN_AND_GAMES, defaultParty); + await runMysteryEncounterToEnd(game, 1, { pokemonNo: 1 }, true); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + game.onNextPrompt("MessagePhase", Mode.MESSAGE, () => { + game.endPhase(); + }); + + // Skip minigame + const wobbuffet = scene.getEnemyPokemon()!; + wobbuffet.hp = 1; + scene.currentBattle.mysteryEncounter!.misc.turnsRemaining = 0; + (game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, false); + await game.phaseInterceptor.to(SelectModifierPhase, false); + + // Rewards + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(1); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toEqual("MULTI_LENS"); + }); + }); + + describe("Option 2 - Leave", () => { + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.FUN_AND_GAMES, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/global-trade-system-encounter.test.ts b/src/test/mystery-encounter/encounters/global-trade-system-encounter.test.ts new file mode 100644 index 000000000000..e91b936cb9d8 --- /dev/null +++ b/src/test/mystery-encounter/encounters/global-trade-system-encounter.test.ts @@ -0,0 +1,270 @@ +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { runMysteryEncounterToEnd } from "#test/mystery-encounter/encounter-test-utils"; +import BattleScene from "#app/battle-scene"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { PokemonNatureWeightModifier } from "#app/modifier/modifier"; +import { generateModifierType } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { modifierTypes } from "#app/modifier/modifier-type"; +import { GlobalTradeSystemEncounter } from "#app/data/mystery-encounters/encounters/global-trade-system-encounter"; +import { CIVILIZATION_ENCOUNTER_BIOMES } from "#app/data/mystery-encounters/mystery-encounters"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; +import { Mode } from "#app/ui/ui"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; +import { ModifierTier } from "#app/modifier/modifier-tier"; + +const namespace = "mysteryEncounter:globalTradeSystem"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("Global Trade System - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + const biomeMap = new Map([ + [Biome.VOLCANO, [MysteryEncounterType.MYSTERIOUS_CHALLENGERS]], + ]); + CIVILIZATION_ENCOUNTER_BIOMES.forEach(biome => { + biomeMap.set(biome, [MysteryEncounterType.GLOBAL_TRADE_SYSTEM]); + }); + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue(biomeMap); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.GLOBAL_TRADE_SYSTEM, defaultParty); + + expect(GlobalTradeSystemEncounter.encounterType).toBe(MysteryEncounterType.GLOBAL_TRADE_SYSTEM); + expect(GlobalTradeSystemEncounter.encounterTier).toBe(MysteryEncounterTier.COMMON); + expect(GlobalTradeSystemEncounter.dialogue).toBeDefined(); + expect(GlobalTradeSystemEncounter.dialogue.intro).toStrictEqual([{ text: `${namespace}.intro` }]); + expect(GlobalTradeSystemEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(GlobalTradeSystemEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(GlobalTradeSystemEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(GlobalTradeSystemEncounter.options.length).toBe(4); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.GLOBAL_TRADE_SYSTEM); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should not spawn outside of CIVILIZATION_ENCOUNTER_BIOMES", async () => { + game.override.mysteryEncounterTier(MysteryEncounterTier.COMMON); + game.override.startingBiome(Biome.VOLCANO); + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.GLOBAL_TRADE_SYSTEM); + }); + + describe("Option 1 - Check Trade Offers", () => { + it("should have the correct properties", () => { + const option = GlobalTradeSystemEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + secondOptionPrompt: `${namespace}.option.1.trade_options_prompt`, + }); + }); + + it("Should trade a Pokemon from the player's party for the first of 3 Pokemon options", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.GLOBAL_TRADE_SYSTEM, defaultParty); + + const speciesBefore = scene.getParty()[0].species.speciesId; + await runMysteryEncounterToEnd(game, 1, { pokemonNo: 1, optionNo: 1 }); + + const speciesAfter = scene.getParty().at(-1)?.species.speciesId; + + expect(speciesAfter).toBeDefined(); + expect(speciesBefore).not.toBe(speciesAfter); + expect(defaultParty.includes(speciesAfter!)).toBeFalsy(); + }); + + it("Should trade a Pokemon from the player's party for the second of 3 Pokemon options", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.GLOBAL_TRADE_SYSTEM, defaultParty); + + const speciesBefore = scene.getParty()[1].species.speciesId; + await runMysteryEncounterToEnd(game, 1, { pokemonNo: 2, optionNo: 2 }); + + const speciesAfter = scene.getParty().at(-1)?.species.speciesId; + + expect(speciesAfter).toBeDefined(); + expect(speciesBefore).not.toBe(speciesAfter); + expect(defaultParty.includes(speciesAfter!)).toBeFalsy(); + }); + + it("Should trade a Pokemon from the player's party for the third of 3 Pokemon options", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.GLOBAL_TRADE_SYSTEM, defaultParty); + + const speciesBefore = scene.getParty()[2].species.speciesId; + await runMysteryEncounterToEnd(game, 1, { pokemonNo: 3, optionNo: 3 }); + + const speciesAfter = scene.getParty().at(-1)?.species.speciesId; + + expect(speciesAfter).toBeDefined(); + expect(speciesBefore).not.toBe(speciesAfter); + expect(defaultParty.includes(speciesAfter!)).toBeFalsy(); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.GLOBAL_TRADE_SYSTEM, defaultParty); + await runMysteryEncounterToEnd(game, 1, { pokemonNo: 1, optionNo: 1 }); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 2 - Wonder Trade", () => { + it("should have the correct properties", () => { + const option = GlobalTradeSystemEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip` + }); + }); + + it("Should trade a Pokemon from the player's party for a random wonder trade Pokemon", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.GLOBAL_TRADE_SYSTEM, defaultParty); + + const speciesBefore = scene.getParty()[2].species.speciesId; + await runMysteryEncounterToEnd(game, 2, { pokemonNo: 1 }); + + const speciesAfter = scene.getParty().at(-1)?.species.speciesId; + + expect(speciesAfter).toBeDefined(); + expect(speciesBefore).not.toBe(speciesAfter); + expect(defaultParty.includes(speciesAfter!)).toBeFalsy(); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.GLOBAL_TRADE_SYSTEM, defaultParty); + await runMysteryEncounterToEnd(game, 2, { pokemonNo: 2 }); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 3 - Trade an Item", () => { + it("should have the correct properties", () => { + const option = GlobalTradeSystemEncounter.options[2]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + secondOptionPrompt: `${namespace}.option.3.trade_options_prompt`, + }); + }); + + it("should decrease item stacks of chosen item and have a tiered up item in rewards", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.GLOBAL_TRADE_SYSTEM, defaultParty); + + // Set 2 Soul Dew on party lead + scene.modifiers = []; + const soulDew = generateModifierType(scene, modifierTypes.SOUL_DEW)!; + const modifier = soulDew.newModifier(scene.getParty()[0]) as PokemonNatureWeightModifier; + modifier.stackCount = 2; + await scene.addModifier(modifier, true, false, false, true); + await scene.updateModifiers(true); + + await runMysteryEncounterToEnd(game, 3, { pokemonNo: 1, optionNo: 1}); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(1); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.tier).toBe(ModifierTier.MASTER); + const soulDewAfter = scene.findModifier(m => m instanceof PokemonNatureWeightModifier); + expect(soulDewAfter?.stackCount).toBe(1); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.GLOBAL_TRADE_SYSTEM, defaultParty); + + // Set 1 Soul Dew on party lead + scene.modifiers = []; + const soulDew = generateModifierType(scene, modifierTypes.SOUL_DEW)!; + const modifier = soulDew.newModifier(scene.getParty()[0]) as PokemonNatureWeightModifier; + modifier.stackCount = 1; + await scene.addModifier(modifier, true, false, false, true); + await scene.updateModifiers(true); + + await runMysteryEncounterToEnd(game, 3, { pokemonNo: 1, optionNo: 1}); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 4 - Leave", () => { + it("should have the correct properties", () => { + const option = GlobalTradeSystemEncounter.options[3]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.4.label`, + buttonTooltip: `${namespace}.option.4.tooltip`, + selected: [ + { + text: `${namespace}.option.4.selected`, + }, + ], + }); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.GLOBAL_TRADE_SYSTEM, defaultParty); + await runMysteryEncounterToEnd(game, 4); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/lost-at-sea-encounter.test.ts b/src/test/mystery-encounter/encounters/lost-at-sea-encounter.test.ts new file mode 100644 index 000000000000..5d43172f6c0d --- /dev/null +++ b/src/test/mystery-encounter/encounters/lost-at-sea-encounter.test.ts @@ -0,0 +1,284 @@ +import { LostAtSeaEncounter } from "#app/data/mystery-encounters/encounters/lost-at-sea-encounter"; +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { Biome } from "#app/enums/biome"; +import { Moves } from "#app/enums/moves"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runMysteryEncounterToEnd, runSelectMysteryEncounterOption } from "../encounter-test-utils"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import BattleScene from "#app/battle-scene"; +import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; +import { PartyExpPhase } from "#app/phases/party-exp-phase"; + +const namespace = "mysteryEncounter:lostAtSea"; +/** Blastoise for surf. Pidgeot for fly. Abra for none. */ +const defaultParty = [Species.BLASTOISE, Species.PIDGEOT, Species.ABRA]; +const defaultBiome = Biome.SEA; +const defaultWave = 33; + +describe("Lost at Sea - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.SEA, [MysteryEncounterType.LOST_AT_SEA]], + [Biome.MOUNTAIN, [MysteryEncounterType.MYSTERIOUS_CHALLENGERS]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.LOST_AT_SEA, defaultParty); + + expect(LostAtSeaEncounter.encounterType).toBe(MysteryEncounterType.LOST_AT_SEA); + expect(LostAtSeaEncounter.encounterTier).toBe(MysteryEncounterTier.COMMON); + expect(LostAtSeaEncounter.dialogue).toBeDefined(); + expect(LostAtSeaEncounter.dialogue.intro).toStrictEqual([{ text: `${namespace}.intro` }]); + expect(LostAtSeaEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(LostAtSeaEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(LostAtSeaEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(LostAtSeaEncounter.options.length).toBe(3); + }); + + it("should not spawn outside of sea biome", async () => { + game.override.mysteryEncounterTier(MysteryEncounterTier.COMMON); + game.override.startingBiome(Biome.MOUNTAIN); + await game.runToMysteryEncounter(); + + expect(game.scene.currentBattle.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.LOST_AT_SEA); + }); + + it("should not run below wave 11", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(game.scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(game.scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should initialize fully", () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = LostAtSeaEncounter; + + const { onInit } = LostAtSeaEncounter; + + expect(LostAtSeaEncounter.onInit).toBeDefined(); + + LostAtSeaEncounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + + expect(LostAtSeaEncounter.dialogueTokens?.damagePercentage).toBe("25"); + expect(LostAtSeaEncounter.dialogueTokens?.option1RequiredMove).toBe(Moves[Moves.SURF]); + expect(LostAtSeaEncounter.dialogueTokens?.option2RequiredMove).toBe(Moves[Moves.FLY]); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Surf", () => { + it("should have the correct properties", () => { + const option1 = LostAtSeaEncounter.options[0]; + expect(option1.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT); + expect(option1.dialogue).toBeDefined(); + expect(option1.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + disabledButtonLabel: `${namespace}.option.1.label_disabled`, + buttonTooltip: `${namespace}.option.1.tooltip`, + disabledButtonTooltip: `${namespace}.option.1.tooltip_disabled`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("should award exp to surfable PKM (Blastoise)", async () => { + const laprasSpecies = getPokemonSpecies(Species.LAPRAS); + + await game.runToMysteryEncounter(MysteryEncounterType.LOST_AT_SEA, defaultParty); + const party = game.scene.getParty(); + const blastoise = party.find((pkm) => pkm.species.speciesId === Species.BLASTOISE); + const expBefore = blastoise!.exp; + + await runMysteryEncounterToEnd(game, 1); + await game.phaseInterceptor.to(PartyExpPhase); + + expect(blastoise?.exp).toBe(expBefore + Math.floor(laprasSpecies.baseExp * defaultWave / 5 + 1)); + }); + + it("should leave encounter without battle", async () => { + game.override.startingWave(33); + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.LOST_AT_SEA, defaultParty); + await runMysteryEncounterToEnd(game, 1); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + + it("should be disabled if no surfable PKM is in party", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.LOST_AT_SEA, [Species.ARCANINE]); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 1); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + }); + }); + + describe("Option 2 - Fly", () => { + it("should have the correct properties", () => { + const option2 = LostAtSeaEncounter.options[1]; + + expect(option2.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT); + expect(option2.dialogue).toBeDefined(); + expect(option2.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + disabledButtonLabel: `${namespace}.option.2.label_disabled`, + buttonTooltip: `${namespace}.option.2.tooltip`, + disabledButtonTooltip: `${namespace}.option.2.tooltip_disabled`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }); + }); + + it("should award exp to flyable PKM (Pidgeot)", async () => { + const laprasBaseExp = 187; + const wave = 33; + game.override.startingWave(wave); + + await game.runToMysteryEncounter(MysteryEncounterType.LOST_AT_SEA, defaultParty); + const party = game.scene.getParty(); + const pidgeot = party.find((pkm) => pkm.species.speciesId === Species.PIDGEOT); + const expBefore = pidgeot!.exp; + + await runMysteryEncounterToEnd(game, 2); + await game.phaseInterceptor.to(PartyExpPhase); + + expect(pidgeot!.exp).toBe(expBefore + Math.floor(laprasBaseExp * defaultWave / 5 + 1)); + }); + + it("should leave encounter without battle", async () => { + game.override.startingWave(33); + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.LOST_AT_SEA, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + + it("should be disabled if no flyable PKM is in party", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.LOST_AT_SEA, [Species.ARCANINE]); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 2); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + }); + }); + + describe("Option 3 - Wander aimlessy", () => { + it("should have the correct properties", () => { + const option3 = LostAtSeaEncounter.options[2]; + + expect(option3.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option3.dialogue).toBeDefined(); + expect(option3.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + ], + }); + }); + + it("should damage all (allowed in battle) party PKM by 25%", async () => { + game.override.startingWave(33); + + await game.runToMysteryEncounter(MysteryEncounterType.LOST_AT_SEA, defaultParty); + + const party = game.scene.getParty(); + const abra = party.find((pkm) => pkm.species.speciesId === Species.ABRA)!; + vi.spyOn(abra, "isAllowedInBattle").mockReturnValue(false); + + await runMysteryEncounterToEnd(game, 3); + + const allowedPkm = party.filter((pkm) => pkm.isAllowedInBattle()); + const notAllowedPkm = party.filter((pkm) => !pkm.isAllowedInBattle()); + allowedPkm.forEach((pkm) => + expect(pkm.hp, `${pkm.name} should have receivd 25% damage: ${pkm.hp} / ${pkm.getMaxHp()} HP`).toBe(pkm.getMaxHp() - Math.floor(pkm.getMaxHp() * 0.25)) + ); + + notAllowedPkm.forEach((pkm) => expect(pkm.hp, `${pkm.name} should be full hp: ${pkm.hp} / ${pkm.getMaxHp()} HP`).toBe(pkm.getMaxHp())); + }); + + it("should leave encounter without battle", async () => { + game.override.startingWave(33); + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.LOST_AT_SEA, defaultParty); + await runMysteryEncounterToEnd(game, 3); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/mysterious-challengers-encounter.test.ts b/src/test/mystery-encounter/encounters/mysterious-challengers-encounter.test.ts new file mode 100644 index 000000000000..de527538711b --- /dev/null +++ b/src/test/mystery-encounter/encounters/mysterious-challengers-encounter.test.ts @@ -0,0 +1,270 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { HUMAN_TRANSITABLE_BIOMES } from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runMysteryEncounterToEnd, skipBattleRunMysteryEncounterRewardsPhase } from "#test/mystery-encounter/encounter-test-utils"; +import BattleScene from "#app/battle-scene"; +import { Mode } from "#app/ui/ui"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import { ModifierTier } from "#app/modifier/modifier-tier"; +import { MysteriousChallengersEncounter } from "#app/data/mystery-encounters/encounters/mysterious-challengers-encounter"; +import { TrainerConfig, TrainerPartyCompoundTemplate, TrainerPartyTemplate } from "#app/data/trainer-config"; +import { PartyMemberStrength } from "#enums/party-member-strength"; +import { MysteryEncounterMode } from "#enums/mystery-encounter-mode"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; +import MysteryEncounter from "#app/data/mystery-encounters/mystery-encounter"; +import { CommandPhase } from "#app/phases/command-phase"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; + +const namespace = "mysteryEncounter:mysteriousChallengers"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("Mysterious Challengers - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + const biomeMap = new Map([ + [Biome.VOLCANO, [MysteryEncounterType.FIGHT_OR_FLIGHT]], + ]); + HUMAN_TRANSITABLE_BIOMES.forEach(biome => { + biomeMap.set(biome, [MysteryEncounterType.MYSTERIOUS_CHALLENGERS]); + }); + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue(biomeMap); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.MYSTERIOUS_CHALLENGERS, defaultParty); + + expect(MysteriousChallengersEncounter.encounterType).toBe(MysteryEncounterType.MYSTERIOUS_CHALLENGERS); + expect(MysteriousChallengersEncounter.encounterTier).toBe(MysteryEncounterTier.GREAT); + expect(MysteriousChallengersEncounter.dialogue).toBeDefined(); + expect(MysteriousChallengersEncounter.dialogue.intro).toStrictEqual([{ text: `${namespace}.intro` }]); + expect(MysteriousChallengersEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(MysteriousChallengersEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(MysteriousChallengersEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(MysteriousChallengersEncounter.options.length).toBe(3); + }); + + it("should not spawn outside of HUMAN_TRANSITABLE_BIOMES", async () => { + game.override.mysteryEncounterTier(MysteryEncounterTier.GREAT); + game.override.startingBiome(Biome.VOLCANO); + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.MYSTERIOUS_CHALLENGERS); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.MYSTERIOUS_CHALLENGERS); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should initialize fully", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = new MysteryEncounter(MysteriousChallengersEncounter); + const encounter = scene.currentBattle.mysteryEncounter!; + scene.currentBattle.waveIndex = defaultWave; + + const { onInit } = encounter; + + expect(encounter.onInit).toBeDefined(); + + encounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + + expect(encounter.enemyPartyConfigs).toBeDefined(); + expect(encounter.enemyPartyConfigs.length).toBe(3); + expect(encounter.enemyPartyConfigs).toEqual([ + { + trainerConfig: expect.any(TrainerConfig), + female: expect.any(Boolean), + }, + { + trainerConfig: expect.any(TrainerConfig), + levelAdditiveMultiplier: 1, + female: expect.any(Boolean), + }, + { + trainerConfig: expect.any(TrainerConfig), + levelAdditiveMultiplier: 1.5, + female: expect.any(Boolean), + } + ]); + expect(encounter.enemyPartyConfigs[1].trainerConfig?.partyTemplates[0]).toEqual(new TrainerPartyCompoundTemplate( + new TrainerPartyTemplate(1, PartyMemberStrength.STRONGER, false, true), + new TrainerPartyTemplate(3, PartyMemberStrength.AVERAGE, false, true) + )); + expect(encounter.enemyPartyConfigs[2].trainerConfig?.partyTemplates[0]).toEqual(new TrainerPartyCompoundTemplate( + new TrainerPartyTemplate(2, PartyMemberStrength.AVERAGE), + new TrainerPartyTemplate(3, PartyMemberStrength.STRONG), + new TrainerPartyTemplate(1, PartyMemberStrength.STRONGER)) + ); + expect(encounter.spriteConfigs).toBeDefined(); + expect(encounter.spriteConfigs.length).toBe(3); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Normal Battle", () => { + it("should have the correct properties", () => { + const option = MysteriousChallengersEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.selected`, + }, + ], + }); + }); + + it("should start battle against the trainer", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.MYSTERIOUS_CHALLENGERS, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(scene.currentBattle.trainer).toBeDefined(); + expect(scene.currentBattle.mysteryEncounter?.encounterMode).toBe(MysteryEncounterMode.TRAINER_BATTLE); + }); + + it("should have normal trainer rewards after battle", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.MYSTERIOUS_CHALLENGERS, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + await skipBattleRunMysteryEncounterRewardsPhase(game); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(3); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toContain("TM_COMMON"); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.id).toContain("TM_GREAT"); + expect(modifierSelectHandler.options[2].modifierTypeOption.type.id).toContain("MEMORY_MUSHROOM"); + }); + }); + + describe("Option 2 - Hard Battle", () => { + it("should have the correct properties", () => { + const option = MysteriousChallengersEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.selected`, + }, + ], + }); + }); + + it("should start battle against the trainer", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.MYSTERIOUS_CHALLENGERS, defaultParty); + await runMysteryEncounterToEnd(game, 2, undefined, true); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(scene.currentBattle.trainer).toBeDefined(); + expect(scene.currentBattle.mysteryEncounter?.encounterMode).toBe(MysteryEncounterMode.TRAINER_BATTLE); + }); + + it("should have hard trainer rewards after battle", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.MYSTERIOUS_CHALLENGERS, defaultParty); + await runMysteryEncounterToEnd(game, 2, undefined, true); + await skipBattleRunMysteryEncounterRewardsPhase(game); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(4); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.tier - modifierSelectHandler.options[0].modifierTypeOption.upgradeCount).toBe(ModifierTier.ULTRA); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.tier - modifierSelectHandler.options[1].modifierTypeOption.upgradeCount).toBe(ModifierTier.ULTRA); + expect(modifierSelectHandler.options[2].modifierTypeOption.type.tier - modifierSelectHandler.options[2].modifierTypeOption.upgradeCount).toBe(ModifierTier.GREAT); + expect(modifierSelectHandler.options[3].modifierTypeOption.type.tier - modifierSelectHandler.options[3].modifierTypeOption.upgradeCount).toBe(ModifierTier.GREAT); + }); + }); + + describe("Option 3 - Brutal Battle", () => { + it("should have the correct properties", () => { + const option = MysteriousChallengersEncounter.options[2]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + selected: [ + { + text: `${namespace}.option.selected`, + }, + ], + }); + }); + + it("should start battle against the trainer", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.MYSTERIOUS_CHALLENGERS, defaultParty); + await runMysteryEncounterToEnd(game, 3, undefined, true); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(scene.currentBattle.trainer).toBeDefined(); + expect(scene.currentBattle.mysteryEncounter?.encounterMode).toBe(MysteryEncounterMode.TRAINER_BATTLE); + }); + + it("should have brutal trainer rewards after battle", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.MYSTERIOUS_CHALLENGERS, defaultParty); + await runMysteryEncounterToEnd(game, 3, undefined, true); + await skipBattleRunMysteryEncounterRewardsPhase(game); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(4); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.tier - modifierSelectHandler.options[0].modifierTypeOption.upgradeCount).toBe(ModifierTier.ROGUE); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.tier - modifierSelectHandler.options[1].modifierTypeOption.upgradeCount).toBe(ModifierTier.ROGUE); + expect(modifierSelectHandler.options[2].modifierTypeOption.type.tier - modifierSelectHandler.options[2].modifierTypeOption.upgradeCount).toBe(ModifierTier.ULTRA); + expect(modifierSelectHandler.options[3].modifierTypeOption.type.tier - modifierSelectHandler.options[3].modifierTypeOption.upgradeCount).toBe(ModifierTier.GREAT); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/part-timer-encounter.test.ts b/src/test/mystery-encounter/encounters/part-timer-encounter.test.ts new file mode 100644 index 000000000000..f73c1f437d08 --- /dev/null +++ b/src/test/mystery-encounter/encounters/part-timer-encounter.test.ts @@ -0,0 +1,295 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { runMysteryEncounterToEnd, runSelectMysteryEncounterOption } from "#test/mystery-encounter/encounter-test-utils"; +import BattleScene from "#app/battle-scene"; +import { CIVILIZATION_ENCOUNTER_BIOMES } from "#app/data/mystery-encounters/mystery-encounters"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { PartTimerEncounter } from "#app/data/mystery-encounters/encounters/part-timer-encounter"; +import { PokemonMove } from "#app/field/pokemon"; +import { Moves } from "#enums/moves"; +import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; + +const namespace = "mysteryEncounter:partTimer"; +// Pyukumuku for lowest speed, Regieleki for highest speed, Feebas for lowest "bulk", Melmetal for highest "bulk" +const defaultParty = [Species.PYUKUMUKU, Species.REGIELEKI, Species.FEEBAS, Species.MELMETAL]; +const defaultBiome = Biome.PLAINS; +const defaultWave = 37; + +describe("Part-Timer - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + const biomeMap = new Map([ + [Biome.VOLCANO, [MysteryEncounterType.MYSTERIOUS_CHALLENGERS]], + ]); + CIVILIZATION_ENCOUNTER_BIOMES.forEach(biome => { + biomeMap.set(biome, [MysteryEncounterType.PART_TIMER]); + }); + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue(biomeMap); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.PART_TIMER, defaultParty); + + expect(PartTimerEncounter.encounterType).toBe(MysteryEncounterType.PART_TIMER); + expect(PartTimerEncounter.encounterTier).toBe(MysteryEncounterTier.COMMON); + expect(PartTimerEncounter.dialogue).toBeDefined(); + expect(PartTimerEncounter.dialogue.intro).toStrictEqual([ + { text: `${namespace}.intro` }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.intro_dialogue`, + } + ]); + expect(PartTimerEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(PartTimerEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(PartTimerEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(PartTimerEncounter.options.length).toBe(3); + }); + + it("should not spawn outside of CIVILIZATION_ENCOUNTER_BIOMES", async () => { + game.override.mysteryEncounterTier(MysteryEncounterTier.COMMON); + game.override.startingBiome(Biome.VOLCANO); + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.PART_TIMER); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.PART_TIMER); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + describe("Option 1 - Make Deliveries", () => { + it("should have the correct properties", () => { + const option = PartTimerEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected` + } + ] + }); + }); + + it("should give the player 1x money multiplier money with max slowest Pokemon", async () => { + vi.spyOn(EncounterPhaseUtils, "updatePlayerMoney"); + + await game.runToMysteryEncounter(MysteryEncounterType.PART_TIMER, defaultParty); + // Override party levels to 50 so stats can be fully reflective + scene.getParty().forEach(p => { + p.level = 50; + p.calculateStats(); + }); + await runMysteryEncounterToEnd(game, 1, { pokemonNo: 1 }); + + expect(EncounterPhaseUtils.updatePlayerMoney).toHaveBeenCalledWith(scene, scene.getWaveMoneyAmount(1), true, false); + // Expect PP of mon's moves to have been reduced to 2 + const moves = scene.getParty()[0].moveset; + for (const move of moves) { + expect((move?.getMovePp() ?? 0) - (move?.ppUsed ?? 0)).toBe(2); + } + }); + + it("should give the player 4x money multiplier money with max fastest Pokemon", async () => { + vi.spyOn(EncounterPhaseUtils, "updatePlayerMoney"); + + await game.runToMysteryEncounter(MysteryEncounterType.PART_TIMER, defaultParty); + // Override party levels to 50 so stats can be fully reflective + scene.getParty().forEach(p => { + p.level = 50; + p.ivs = [20, 20, 20, 20, 20, 20]; + p.calculateStats(); + }); + await runMysteryEncounterToEnd(game, 1, { pokemonNo: 2 }); + + expect(EncounterPhaseUtils.updatePlayerMoney).toHaveBeenCalledWith(scene, scene.getWaveMoneyAmount(4), true, false); + // Expect PP of mon's moves to have been reduced to 2 + const moves = scene.getParty()[1].moveset; + for (const move of moves) { + expect((move?.getMovePp() ?? 0) - (move?.ppUsed ?? 0)).toBe(2); + } + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.PART_TIMER, defaultParty); + await runMysteryEncounterToEnd(game, 1, { pokemonNo: 1 }); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 2 - Help in the Warehouse", () => { + it("should have the correct properties", () => { + const option = PartTimerEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected` + } + ] + }); + }); + + it("should give the player 1x money multiplier money with least bulky Pokemon", async () => { + vi.spyOn(EncounterPhaseUtils, "updatePlayerMoney"); + + await game.runToMysteryEncounter(MysteryEncounterType.PART_TIMER, defaultParty); + // Override party levels to 50 so stats can be fully reflective + scene.getParty().forEach(p => { + p.level = 50; + p.calculateStats(); + }); + await runMysteryEncounterToEnd(game, 2, { pokemonNo: 3 }); + + expect(EncounterPhaseUtils.updatePlayerMoney).toHaveBeenCalledWith(scene, scene.getWaveMoneyAmount(1), true, false); + // Expect PP of mon's moves to have been reduced to 2 + const moves = scene.getParty()[2].moveset; + for (const move of moves) { + expect((move?.getMovePp() ?? 0) - (move?.ppUsed ?? 0)).toBe(2); + } + }); + + it("should give the player 4x money multiplier money with bulkiest Pokemon", async () => { + vi.spyOn(EncounterPhaseUtils, "updatePlayerMoney"); + + await game.runToMysteryEncounter(MysteryEncounterType.PART_TIMER, defaultParty); + // Override party levels to 50 so stats can be fully reflective + scene.getParty().forEach(p => { + p.level = 50; + p.ivs = [20, 20, 20, 20, 20, 20]; + p.calculateStats(); + }); + await runMysteryEncounterToEnd(game, 2, { pokemonNo: 4 }); + + expect(EncounterPhaseUtils.updatePlayerMoney).toHaveBeenCalledWith(scene, scene.getWaveMoneyAmount(4), true, false); + // Expect PP of mon's moves to have been reduced to 2 + const moves = scene.getParty()[3].moveset; + for (const move of moves) { + expect((move?.getMovePp() ?? 0) - (move?.ppUsed ?? 0)).toBe(2); + } + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.PART_TIMER, defaultParty); + await runMysteryEncounterToEnd(game, 2, { pokemonNo: 1 }); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 3 - Assist with Sales", () => { + it("should have the correct properties", () => { + const option = PartTimerEncounter.options[2]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + disabledButtonTooltip: `${namespace}.option.3.disabled_tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected` + } + ] + }); + }); + + it("Should NOT be selectable when requirements are not met", async () => { + vi.spyOn(EncounterPhaseUtils, "updatePlayerMoney"); + + await game.runToMysteryEncounter(MysteryEncounterType.PART_TIMER, defaultParty); + // Mock movesets + scene.getParty().forEach(p => p.moveset = []); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 3); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + expect(EncounterPhaseUtils.updatePlayerMoney).not.toHaveBeenCalled(); + }); + + it("should be selectable and give the player 2.5x money multiplier money with requirements met", async () => { + vi.spyOn(EncounterPhaseUtils, "updatePlayerMoney"); + + await game.runToMysteryEncounter(MysteryEncounterType.PART_TIMER, defaultParty); + // Mock moveset + scene.getParty()[0].moveset = [new PokemonMove(Moves.ATTRACT)]; + await runMysteryEncounterToEnd(game, 3); + + expect(EncounterPhaseUtils.updatePlayerMoney).toHaveBeenCalledWith(scene, scene.getWaveMoneyAmount(2.5), true, false); + // Expect PP of mon's moves to have been reduced to 2 + const moves = scene.getParty()[0].moveset; + for (const move of moves) { + expect((move?.getMovePp() ?? 0) - (move?.ppUsed ?? 0)).toBe(2); + } + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.PART_TIMER, defaultParty); + await runMysteryEncounterToEnd(game, 2, { pokemonNo: 1 }); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/teleporting-hijinks-encounter.test.ts b/src/test/mystery-encounter/encounters/teleporting-hijinks-encounter.test.ts new file mode 100644 index 000000000000..13860e83baa3 --- /dev/null +++ b/src/test/mystery-encounter/encounters/teleporting-hijinks-encounter.test.ts @@ -0,0 +1,299 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runMysteryEncounterToEnd, runSelectMysteryEncounterOption, skipBattleRunMysteryEncounterRewardsPhase } from "#test/mystery-encounter/encounter-test-utils"; +import BattleScene from "#app/battle-scene"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; +import { CommandPhase } from "#app/phases/command-phase"; +import { TeleportingHijinksEncounter } from "#app/data/mystery-encounters/encounters/teleporting-hijinks-encounter"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; +import { Mode } from "#app/ui/ui"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; + +const namespace = "mysteryEncounter:teleportingHijinks"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("Teleporting Hijinks - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + scene.money = 20000; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.CAVE, [MysteryEncounterType.TELEPORTING_HIJINKS]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, defaultParty); + + expect(TeleportingHijinksEncounter.encounterType).toBe(MysteryEncounterType.TELEPORTING_HIJINKS); + expect(TeleportingHijinksEncounter.encounterTier).toBe(MysteryEncounterTier.COMMON); + expect(TeleportingHijinksEncounter.dialogue).toBeDefined(); + expect(TeleportingHijinksEncounter.dialogue.intro).toStrictEqual([{ text: `${namespace}.intro` }]); + expect(TeleportingHijinksEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(TeleportingHijinksEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(TeleportingHijinksEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(TeleportingHijinksEncounter.options.length).toBe(3); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.TELEPORTING_HIJINKS); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should run in waves that are X1", async () => { + game.override.startingWave(11); + game.override.mysteryEncounterTier(MysteryEncounterTier.COMMON); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).toBe(MysteryEncounterType.TELEPORTING_HIJINKS); + }); + + it("should run in waves that are X2", async () => { + game.override.startingWave(32); + game.override.mysteryEncounterTier(MysteryEncounterTier.COMMON); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).toBe(MysteryEncounterType.TELEPORTING_HIJINKS); + }); + + it("should run in waves that are X3", async () => { + game.override.startingWave(23); + game.override.mysteryEncounterTier(MysteryEncounterTier.COMMON); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).toBe(MysteryEncounterType.TELEPORTING_HIJINKS); + }); + + it("should NOT run in waves that are not X1, X2, or X3", async () => { + game.override.startingWave(54); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).not.toBe(MysteryEncounterType.TELEPORTING_HIJINKS); + }); + + it("should initialize fully", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = TeleportingHijinksEncounter; + + const { onInit } = TeleportingHijinksEncounter; + + expect(TeleportingHijinksEncounter.onInit).toBeDefined(); + + TeleportingHijinksEncounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + + expect(TeleportingHijinksEncounter.misc.price).toBeDefined(); + expect(TeleportingHijinksEncounter.dialogueTokens.price).toBeDefined(); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Pay Money", () => { + it("should have the correct properties", () => { + const option = TeleportingHijinksEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("should NOT be selectable if the player doesn't have enough money", async () => { + game.scene.money = 0; + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, defaultParty); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 1); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + }); + + it("should be selectable if the player has enough money", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + }); + + it("should transport to a new area", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, defaultParty); + + const previousBiome = scene.arena.biomeType; + + await runMysteryEncounterToEnd(game, 1, undefined, true); + + expect(previousBiome).not.toBe(scene.arena.biomeType); + expect([Biome.SPACE, Biome.ISLAND, Biome.LABORATORY, Biome.FAIRY_CAVE]).toContain(scene.arena.biomeType); + }); + + it("should start a battle against an enraged boss", { retry: 5 }, async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + const enemyField = scene.getEnemyField(); + expect(enemyField[0].summonData.statStages).toEqual([1, 1, 1, 1, 1, 0, 0]); + expect(enemyField[0].isBoss()).toBe(true); + }); + }); + + describe("Option 2 - Use Electric/Steel Typing", () => { + it("should have the correct properties", () => { + const option = TeleportingHijinksEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + disabledButtonTooltip: `${namespace}.option.2.disabled_tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + } + ], + }); + }); + + it("should NOT be selectable if the player doesn't the right type pokemon", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, [Species.BLASTOISE]); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 2); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + }); + + it("should be selectable if the player has the right type pokemon", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, [Species.METAGROSS]); + await runMysteryEncounterToEnd(game, 2, undefined, true); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + }); + + it("should transport to a new area", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, [Species.PIKACHU]); + + const previousBiome = scene.arena.biomeType; + + await runMysteryEncounterToEnd(game, 2, undefined, true); + + expect(previousBiome).not.toBe(scene.arena.biomeType); + expect([Biome.SPACE, Biome.ISLAND, Biome.LABORATORY, Biome.FAIRY_CAVE]).toContain(scene.arena.biomeType); + }); + + it("should start a battle against an enraged boss", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, [Species.PIKACHU]); + await runMysteryEncounterToEnd(game, 2, undefined, true); + const enemyField = scene.getEnemyField(); + expect(enemyField[0].summonData.statStages).toEqual([1, 1, 1, 1, 1, 0, 0]); + expect(enemyField[0].isBoss()).toBe(true); + }); + }); + + describe("Option 3 - Inspect the Machine", () => { + it("should have the correct properties", () => { + const option = TeleportingHijinksEncounter.options[2]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + ], + }); + }); + + it("should start a battle against a boss", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, defaultParty); + await runMysteryEncounterToEnd(game, 3, undefined, true); + const enemyField = scene.getEnemyField(); + expect(enemyField[0].summonData.statStages).toEqual([0, 0, 0, 0, 0, 0, 0]); + expect(enemyField[0].isBoss()).toBe(true); + }); + + it("should have Magnet and Metal Coat in rewards after battle", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, defaultParty); + await runMysteryEncounterToEnd(game, 3, undefined, true); + await skipBattleRunMysteryEncounterRewardsPhase(game); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.some(opt => opt.modifierTypeOption.type.name === "Metal Coat")).toBe(true); + expect(modifierSelectHandler.options.some(opt => opt.modifierTypeOption.type.name === "Magnet")).toBe(true); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/the-pokemon-salesman-encounter.test.ts b/src/test/mystery-encounter/encounters/the-pokemon-salesman-encounter.test.ts new file mode 100644 index 000000000000..c43577337dae --- /dev/null +++ b/src/test/mystery-encounter/encounters/the-pokemon-salesman-encounter.test.ts @@ -0,0 +1,208 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { runMysteryEncounterToEnd, runSelectMysteryEncounterOption } from "#test/mystery-encounter/encounter-test-utils"; +import BattleScene from "#app/battle-scene"; +import { PlayerPokemon } from "#app/field/pokemon"; +import { HUMAN_TRANSITABLE_BIOMES } from "#app/data/mystery-encounters/mystery-encounters"; +import { ThePokemonSalesmanEncounter } from "#app/data/mystery-encounters/encounters/the-pokemon-salesman-encounter"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; + +const namespace = "mysteryEncounter:pokemonSalesman"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("The Pokemon Salesman - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + const biomeMap = new Map([ + [Biome.VOLCANO, [MysteryEncounterType.MYSTERIOUS_CHALLENGERS]], + ]); + HUMAN_TRANSITABLE_BIOMES.forEach(biome => { + biomeMap.set(biome, [MysteryEncounterType.THE_POKEMON_SALESMAN]); + }); + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue(biomeMap); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.THE_POKEMON_SALESMAN, defaultParty); + + expect(ThePokemonSalesmanEncounter.encounterType).toBe(MysteryEncounterType.THE_POKEMON_SALESMAN); + expect(ThePokemonSalesmanEncounter.encounterTier).toBe(MysteryEncounterTier.ULTRA); + expect(ThePokemonSalesmanEncounter.dialogue).toBeDefined(); + expect(ThePokemonSalesmanEncounter.dialogue.intro).toStrictEqual([ + { text: `${namespace}.intro` }, + { speaker: `${namespace}.speaker`, text: `${namespace}.intro_dialogue` } + ]); + expect(ThePokemonSalesmanEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(ThePokemonSalesmanEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(ThePokemonSalesmanEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(ThePokemonSalesmanEncounter.options.length).toBe(2); + }); + + it("should not spawn outside of HUMAN_TRANSITABLE_BIOMES", async () => { + game.override.mysteryEncounterTier(MysteryEncounterTier.ULTRA); + game.override.startingBiome(Biome.VOLCANO); + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.THE_POKEMON_SALESMAN); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.THE_POKEMON_SALESMAN); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should initialize fully ", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = ThePokemonSalesmanEncounter; + + const { onInit } = ThePokemonSalesmanEncounter; + + expect(ThePokemonSalesmanEncounter.onInit).toBeDefined(); + + ThePokemonSalesmanEncounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + + expect(ThePokemonSalesmanEncounter.dialogueTokens?.purchasePokemon).toBeDefined(); + expect(ThePokemonSalesmanEncounter.dialogueTokens?.price).toBeDefined(); + expect(ThePokemonSalesmanEncounter.misc.pokemon instanceof PlayerPokemon).toBeTruthy(); + expect(ThePokemonSalesmanEncounter.misc?.price?.toString()).toBe(ThePokemonSalesmanEncounter.dialogueTokens?.price); + expect(onInitResult).toBe(true); + }); + + it("should not spawn if player does not have enough money", async () => { + scene.money = 0; + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.THE_POKEMON_SALESMAN); + }); + + describe("Option 1 - Purchase the pokemon", () => { + it("should have the correct properties", () => { + const option = ThePokemonSalesmanEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected_message`, + }, + ], + }); + }); + + it("Should update the player's money properly", async () => { + const initialMoney = 20000; + scene.money = initialMoney; + const updateMoneySpy = vi.spyOn(EncounterPhaseUtils, "updatePlayerMoney"); + + await game.runToMysteryEncounter(MysteryEncounterType.THE_POKEMON_SALESMAN, defaultParty); + await runMysteryEncounterToEnd(game, 1); + + const price = scene.currentBattle.mysteryEncounter!.misc.price; + + expect(updateMoneySpy).toHaveBeenCalledWith(scene, -price, true, false); + expect(scene.money).toBe(initialMoney - price); + }); + + it("Should add the Pokemon to the party", async () => { + scene.money = 20000; + await game.runToMysteryEncounter(MysteryEncounterType.THE_POKEMON_SALESMAN, defaultParty); + + const initialPartySize = scene.getParty().length; + const pokemonName = scene.currentBattle.mysteryEncounter!.misc.pokemon.name; + + await runMysteryEncounterToEnd(game, 1); + + expect(scene.getParty().length).toBe(initialPartySize + 1); + + const newlyPurchasedPokemon = scene.getParty().find(p => p.name === pokemonName); + expect(newlyPurchasedPokemon).toBeDefined(); + expect(newlyPurchasedPokemon!.moveset.length > 0).toBeTruthy(); + }); + + it("should be disabled if player does not have enough money", async () => { + scene.money = 0; + await game.runToMysteryEncounter(MysteryEncounterType.THE_POKEMON_SALESMAN, defaultParty); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 1); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + }); + + it("should leave encounter without battle", async () => { + scene.money = 20000; + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.THE_POKEMON_SALESMAN, defaultParty); + await runMysteryEncounterToEnd(game, 1); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 2 - Leave", () => { + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.THE_POKEMON_SALESMAN, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/the-strong-stuff-encounter.test.ts b/src/test/mystery-encounter/encounters/the-strong-stuff-encounter.test.ts new file mode 100644 index 000000000000..be35ec31784c --- /dev/null +++ b/src/test/mystery-encounter/encounters/the-strong-stuff-encounter.test.ts @@ -0,0 +1,240 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import * as BattleAnims from "#app/data/battle-anims"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { runMysteryEncounterToEnd, skipBattleRunMysteryEncounterRewardsPhase } from "#test/mystery-encounter/encounter-test-utils"; +import { Moves } from "#enums/moves"; +import BattleScene from "#app/battle-scene"; +import { TheStrongStuffEncounter } from "#app/data/mystery-encounters/encounters/the-strong-stuff-encounter"; +import { Nature } from "#app/data/nature"; +import { BerryType } from "#enums/berry-type"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { PokemonMove } from "#app/field/pokemon"; +import { Mode } from "#app/ui/ui"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; +import { BerryModifier, PokemonBaseStatTotalModifier } from "#app/modifier/modifier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import { MysteryEncounterPokemonData } from "#app/data/mystery-encounters/mystery-encounter-pokemon-data"; +import { CommandPhase } from "#app/phases/command-phase"; +import { MovePhase } from "#app/phases/move-phase"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; + +const namespace = "mysteryEncounter:theStrongStuff"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("The Strong Stuff - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.CAVE, [MysteryEncounterType.THE_STRONG_STUFF]], + [Biome.MOUNTAIN, [MysteryEncounterType.MYSTERIOUS_CHALLENGERS]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.THE_STRONG_STUFF, defaultParty); + + expect(TheStrongStuffEncounter.encounterType).toBe(MysteryEncounterType.THE_STRONG_STUFF); + expect(TheStrongStuffEncounter.encounterTier).toBe(MysteryEncounterTier.GREAT); + expect(TheStrongStuffEncounter.dialogue).toBeDefined(); + expect(TheStrongStuffEncounter.dialogue.intro).toStrictEqual([{ text: `${namespace}.intro` }]); + expect(TheStrongStuffEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(TheStrongStuffEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(TheStrongStuffEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(TheStrongStuffEncounter.options.length).toBe(2); + }); + + it("should not spawn outside of CAVE biome", async () => { + game.override.mysteryEncounterTier(MysteryEncounterTier.COMMON); + game.override.startingBiome(Biome.MOUNTAIN); + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.THE_STRONG_STUFF); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.THE_STRONG_STUFF); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should initialize fully ", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = TheStrongStuffEncounter; + const moveInitSpy = vi.spyOn(BattleAnims, "initMoveAnim"); + const moveLoadSpy = vi.spyOn(BattleAnims, "loadMoveAnimAssets"); + + const { onInit } = TheStrongStuffEncounter; + + expect(TheStrongStuffEncounter.onInit).toBeDefined(); + + TheStrongStuffEncounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + + expect(TheStrongStuffEncounter.enemyPartyConfigs).toEqual([ + { + levelAdditiveMultiplier: 1, + disableSwitch: true, + pokemonConfigs: [ + { + species: getPokemonSpecies(Species.SHUCKLE), + isBoss: true, + bossSegments: 5, + mysteryEncounterPokemonData: new MysteryEncounterPokemonData({ spriteScale: 1.25 }), + nature: Nature.BOLD, + moveSet: [Moves.INFESTATION, Moves.SALT_CURE, Moves.GASTRO_ACID, Moves.HEAL_ORDER], + modifierConfigs: expect.any(Array), + tags: [BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON], + mysteryEncounterBattleEffects: expect.any(Function) + } + ], + } + ]); + await vi.waitFor(() => expect(moveInitSpy).toHaveBeenCalled()); + await vi.waitFor(() => expect(moveLoadSpy).toHaveBeenCalled()); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Power Swap BSTs", () => { + it("should have the correct properties", () => { + const option1 = TheStrongStuffEncounter.options[0]; + expect(option1.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option1.dialogue).toBeDefined(); + expect(option1.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("should lower stats of 2 highest BST and raise stats for rest of party", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.THE_STRONG_STUFF, defaultParty); + + const bstsPrior = scene.getParty().map(p => p.getSpeciesForm().getBaseStatTotal()); + await runMysteryEncounterToEnd(game, 1); + + const bstsAfter = scene.getParty().map(p => { + const baseStats = p.getSpeciesForm().baseStats.slice(0); + scene.applyModifiers(PokemonBaseStatTotalModifier, true, p, baseStats); + return baseStats.reduce((a, b) => a + b); + }); + + // HP stat changes are halved compared to other values + expect(bstsAfter[0]).toEqual(bstsPrior[0] - 15 * 5 - 8); + expect(bstsAfter[1]).toEqual(bstsPrior[1] - 15 * 5 - 8); + expect(bstsAfter[2]).toEqual(bstsPrior[2] + 10 * 5 + 5); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.THE_STRONG_STUFF, defaultParty); + await runMysteryEncounterToEnd(game, 1); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 2 - battle the Shuckle", () => { + it("should have the correct properties", () => { + const option1 = TheStrongStuffEncounter.options[1]; + expect(option1.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option1.dialogue).toBeDefined(); + expect(option1.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }); + }); + + it("should start battle against Shuckle", async () => { + const phaseSpy = vi.spyOn(scene, "pushPhase"); + + await game.runToMysteryEncounter(MysteryEncounterType.THE_STRONG_STUFF, defaultParty); + await runMysteryEncounterToEnd(game, 2, undefined, true); + + const enemyField = scene.getEnemyField(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyField.length).toBe(1); + expect(enemyField[0].species.speciesId).toBe(Species.SHUCKLE); + expect(enemyField[0].summonData.statStages).toEqual([0, 2, 0, 2, 0, 0, 0]); + const shuckleItems = enemyField[0].getHeldItems(); + expect(shuckleItems.length).toBe(5); + expect(shuckleItems.find(m => m instanceof BerryModifier && m.berryType === BerryType.SITRUS)?.stackCount).toBe(1); + expect(shuckleItems.find(m => m instanceof BerryModifier && m.berryType === BerryType.ENIGMA)?.stackCount).toBe(1); + expect(shuckleItems.find(m => m instanceof BerryModifier && m.berryType === BerryType.GANLON)?.stackCount).toBe(1); + expect(shuckleItems.find(m => m instanceof BerryModifier && m.berryType === BerryType.APICOT)?.stackCount).toBe(1); + expect(shuckleItems.find(m => m instanceof BerryModifier && m.berryType === BerryType.LUM)?.stackCount).toBe(2); + expect(enemyField[0].moveset).toEqual([new PokemonMove(Moves.INFESTATION), new PokemonMove(Moves.SALT_CURE), new PokemonMove(Moves.GASTRO_ACID), new PokemonMove(Moves.HEAL_ORDER)]); + + // Should have used moves pre-battle + const movePhases = phaseSpy.mock.calls.filter(p => p[0] instanceof MovePhase).map(p => p[0]); + expect(movePhases.length).toBe(2); + expect(movePhases.filter(p => (p as MovePhase).move.moveId === Moves.GASTRO_ACID).length).toBe(1); + expect(movePhases.filter(p => (p as MovePhase).move.moveId === Moves.STEALTH_ROCK).length).toBe(1); + }); + + it("should have Soul Dew in rewards", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.THE_STRONG_STUFF, defaultParty); + await runMysteryEncounterToEnd(game, 2, undefined, true); + await skipBattleRunMysteryEncounterRewardsPhase(game); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(3); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toEqual("SOUL_DEW"); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/the-winstrate-challenge-encounter.test.ts b/src/test/mystery-encounter/encounters/the-winstrate-challenge-encounter.test.ts new file mode 100644 index 000000000000..0c6422250316 --- /dev/null +++ b/src/test/mystery-encounter/encounters/the-winstrate-challenge-encounter.test.ts @@ -0,0 +1,388 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { HUMAN_TRANSITABLE_BIOMES } from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runMysteryEncounterToEnd } from "#test/mystery-encounter/encounter-test-utils"; +import BattleScene from "#app/battle-scene"; +import { Mode } from "#app/ui/ui"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import { MysteryEncounterMode } from "#enums/mystery-encounter-mode"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; +import MysteryEncounter from "#app/data/mystery-encounters/mystery-encounter"; +import { TrainerType } from "#enums/trainer-type"; +import { Nature } from "#enums/nature"; +import { Moves } from "#enums/moves"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { TheWinstrateChallengeEncounter } from "#app/data/mystery-encounters/encounters/the-winstrate-challenge-encounter"; +import { Status, StatusEffect } from "#app/data/status-effect"; +import { MysteryEncounterRewardsPhase } from "#app/phases/mystery-encounter-phases"; +import { CommandPhase } from "#app/phases/command-phase"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; +import { PartyHealPhase } from "#app/phases/party-heal-phase"; +import { VictoryPhase } from "#app/phases/victory-phase"; + +const namespace = "mysteryEncounter:theWinstrateChallenge"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("The Winstrate Challenge - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + const biomeMap = new Map([ + [Biome.VOLCANO, [MysteryEncounterType.FIGHT_OR_FLIGHT]], + ]); + HUMAN_TRANSITABLE_BIOMES.forEach(biome => { + biomeMap.set(biome, [MysteryEncounterType.THE_WINSTRATE_CHALLENGE]); + }); + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue(biomeMap); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.THE_WINSTRATE_CHALLENGE, defaultParty); + + expect(TheWinstrateChallengeEncounter.encounterType).toBe(MysteryEncounterType.THE_WINSTRATE_CHALLENGE); + expect(TheWinstrateChallengeEncounter.encounterTier).toBe(MysteryEncounterTier.ROGUE); + expect(TheWinstrateChallengeEncounter.dialogue).toBeDefined(); + expect(TheWinstrateChallengeEncounter.dialogue.intro).toStrictEqual([ + { text: `${namespace}.intro` }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.intro_dialogue`, + } + ]); + expect(TheWinstrateChallengeEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(TheWinstrateChallengeEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(TheWinstrateChallengeEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(TheWinstrateChallengeEncounter.options.length).toBe(2); + }); + + it("should not spawn outside of HUMAN_TRANSITABLE_BIOMES", async () => { + game.override.mysteryEncounterTier(MysteryEncounterTier.GREAT); + game.override.startingBiome(Biome.VOLCANO); + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.THE_WINSTRATE_CHALLENGE); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.THE_WINSTRATE_CHALLENGE); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should initialize fully", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = new MysteryEncounter(TheWinstrateChallengeEncounter); + const encounter = scene.currentBattle.mysteryEncounter!; + scene.currentBattle.waveIndex = defaultWave; + + const { onInit } = encounter; + + expect(encounter.onInit).toBeDefined(); + + encounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + + expect(encounter.enemyPartyConfigs).toBeDefined(); + expect(encounter.enemyPartyConfigs.length).toBe(5); + expect(encounter.enemyPartyConfigs).toEqual([ + { + trainerType: TrainerType.VITO, + pokemonConfigs: [ + { + species: getPokemonSpecies(Species.HISUI_ELECTRODE), + isBoss: false, + abilityIndex: 0, // Soundproof + nature: Nature.MODEST, + moveSet: [Moves.THUNDERBOLT, Moves.GIGA_DRAIN, Moves.FOUL_PLAY, Moves.THUNDER_WAVE], + modifierConfigs: expect.any(Array) + }, + { + species: getPokemonSpecies(Species.SWALOT), + isBoss: false, + abilityIndex: 2, // Gluttony + nature: Nature.QUIET, + moveSet: [Moves.SLUDGE_BOMB, Moves.GIGA_DRAIN, Moves.ICE_BEAM, Moves.EARTHQUAKE], + modifierConfigs: expect.any(Array) + }, + { + species: getPokemonSpecies(Species.DODRIO), + isBoss: false, + abilityIndex: 2, // Tangled Feet + nature: Nature.JOLLY, + moveSet: [Moves.DRILL_PECK, Moves.QUICK_ATTACK, Moves.THRASH, Moves.KNOCK_OFF], + modifierConfigs: expect.any(Array) + }, + { + species: getPokemonSpecies(Species.ALAKAZAM), + isBoss: false, + formIndex: 1, + nature: Nature.BOLD, + moveSet: [Moves.PSYCHIC, Moves.SHADOW_BALL, Moves.FOCUS_BLAST, Moves.THUNDERBOLT], + modifierConfigs: expect.any(Array) + }, + { + species: getPokemonSpecies(Species.DARMANITAN), + isBoss: false, + abilityIndex: 0, // Sheer Force + nature: Nature.IMPISH, + moveSet: [Moves.EARTHQUAKE, Moves.U_TURN, Moves.FLARE_BLITZ, Moves.ROCK_SLIDE], + modifierConfigs: expect.any(Array) + } + ] + }, + { + trainerType: TrainerType.VICKY, + pokemonConfigs: [ + { + species: getPokemonSpecies(Species.MEDICHAM), + isBoss: false, + formIndex: 1, + nature: Nature.IMPISH, + moveSet: [Moves.AXE_KICK, Moves.ICE_PUNCH, Moves.ZEN_HEADBUTT, Moves.BULLET_PUNCH], + modifierConfigs: expect.any(Array) + } + ] + }, + { + trainerType: TrainerType.VIVI, + pokemonConfigs: [ + { + species: getPokemonSpecies(Species.SEAKING), + isBoss: false, + abilityIndex: 3, // Lightning Rod + nature: Nature.ADAMANT, + moveSet: [Moves.WATERFALL, Moves.MEGAHORN, Moves.KNOCK_OFF, Moves.REST], + modifierConfigs: expect.any(Array) + }, + { + species: getPokemonSpecies(Species.BRELOOM), + isBoss: false, + abilityIndex: 1, // Poison Heal + nature: Nature.JOLLY, + moveSet: [Moves.SPORE, Moves.SWORDS_DANCE, Moves.SEED_BOMB, Moves.DRAIN_PUNCH], + modifierConfigs: expect.any(Array) + }, + { + species: getPokemonSpecies(Species.CAMERUPT), + isBoss: false, + formIndex: 1, + nature: Nature.CALM, + moveSet: [Moves.EARTH_POWER, Moves.FIRE_BLAST, Moves.YAWN, Moves.PROTECT], + modifierConfigs: expect.any(Array) + } + ] + }, + { + trainerType: TrainerType.VICTORIA, + pokemonConfigs: [ + { + species: getPokemonSpecies(Species.ROSERADE), + isBoss: false, + abilityIndex: 0, // Natural Cure + nature: Nature.CALM, + moveSet: [Moves.SYNTHESIS, Moves.SLUDGE_BOMB, Moves.GIGA_DRAIN, Moves.SLEEP_POWDER], + modifierConfigs: expect.any(Array) + }, + { + species: getPokemonSpecies(Species.GARDEVOIR), + isBoss: false, + formIndex: 1, + nature: Nature.TIMID, + moveSet: [Moves.PSYSHOCK, Moves.MOONBLAST, Moves.SHADOW_BALL, Moves.WILL_O_WISP], + modifierConfigs: expect.any(Array) + } + ] + }, + { + trainerType: TrainerType.VICTOR, + pokemonConfigs: [ + { + species: getPokemonSpecies(Species.SWELLOW), + isBoss: false, + abilityIndex: 0, // Guts + nature: Nature.ADAMANT, + moveSet: [Moves.FACADE, Moves.BRAVE_BIRD, Moves.PROTECT, Moves.QUICK_ATTACK], + modifierConfigs: expect.any(Array) + }, + { + species: getPokemonSpecies(Species.OBSTAGOON), + isBoss: false, + abilityIndex: 1, // Guts + nature: Nature.ADAMANT, + moveSet: [Moves.FACADE, Moves.OBSTRUCT, Moves.NIGHT_SLASH, Moves.FIRE_PUNCH], + modifierConfigs: expect.any(Array) + } + ] + } + ]); + expect(encounter.spriteConfigs).toBeDefined(); + expect(encounter.spriteConfigs.length).toBe(5); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Normal Battle", () => { + it("should have the correct properties", () => { + const option = TheWinstrateChallengeEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("should battle all 5 trainers for a Macho Brace reward", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.THE_WINSTRATE_CHALLENGE, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(scene.currentBattle.trainer).toBeDefined(); + expect(scene.currentBattle.trainer!.config.trainerType).toBe(TrainerType.VICTOR); + expect(scene.currentBattle.mysteryEncounter?.enemyPartyConfigs.length).toBe(4); + expect(scene.currentBattle.mysteryEncounter?.encounterMode).toBe(MysteryEncounterMode.TRAINER_BATTLE); + + await skipBattleToNextBattle(game); + expect(scene.currentBattle.trainer).toBeDefined(); + expect(scene.currentBattle.trainer!.config.trainerType).toBe(TrainerType.VICTORIA); + expect(scene.currentBattle.mysteryEncounter?.enemyPartyConfigs.length).toBe(3); + expect(scene.currentBattle.mysteryEncounter?.encounterMode).toBe(MysteryEncounterMode.TRAINER_BATTLE); + + await skipBattleToNextBattle(game); + expect(scene.currentBattle.trainer).toBeDefined(); + expect(scene.currentBattle.trainer!.config.trainerType).toBe(TrainerType.VIVI); + expect(scene.currentBattle.mysteryEncounter?.enemyPartyConfigs.length).toBe(2); + expect(scene.currentBattle.mysteryEncounter?.encounterMode).toBe(MysteryEncounterMode.TRAINER_BATTLE); + + await skipBattleToNextBattle(game); + expect(scene.currentBattle.trainer).toBeDefined(); + expect(scene.currentBattle.trainer!.config.trainerType).toBe(TrainerType.VICKY); + expect(scene.currentBattle.mysteryEncounter?.enemyPartyConfigs.length).toBe(1); + expect(scene.currentBattle.mysteryEncounter?.encounterMode).toBe(MysteryEncounterMode.TRAINER_BATTLE); + + await skipBattleToNextBattle(game); + expect(scene.currentBattle.trainer).toBeDefined(); + expect(scene.currentBattle.trainer!.config.trainerType).toBe(TrainerType.VITO); + expect(scene.currentBattle.mysteryEncounter?.enemyPartyConfigs.length).toBe(0); + expect(scene.currentBattle.mysteryEncounter?.encounterMode).toBe(MysteryEncounterMode.TRAINER_BATTLE); + + // Should have Macho Brace in the rewards + await skipBattleToNextBattle(game, true); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(1); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toBe("MYSTERY_ENCOUNTER_MACHO_BRACE"); + }, 15000); + }); + + describe("Option 2 - Refuse the Challenge", () => { + it("should have the correct properties", () => { + const option = TheWinstrateChallengeEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.2.selected`, + }, + ], + }); + }); + + it("Should fully heal the party", async () => { + const phaseSpy = vi.spyOn(scene, "unshiftPhase"); + + await game.runToMysteryEncounter(MysteryEncounterType.THE_WINSTRATE_CHALLENGE, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + const partyHealPhases = phaseSpy.mock.calls.filter(p => p[0] instanceof PartyHealPhase).map(p => p[0]); + expect(partyHealPhases.length).toBe(1); + }); + + it("should have a Rarer Candy in the rewards", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.THE_WINSTRATE_CHALLENGE, defaultParty); + await runMysteryEncounterToEnd(game, 2); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(1); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toBe("RARER_CANDY"); + }); + }); +}); + +/** + * For any {@linkcode MysteryEncounter} that has a battle, can call this to skip battle and proceed to MysteryEncounterRewardsPhase + * @param game + * @param isFinalBattle + */ +async function skipBattleToNextBattle(game: GameManager, isFinalBattle: boolean = false) { + game.scene.clearPhaseQueue(); + game.scene.clearPhaseQueueSplice(); + const commandUiHandler = game.scene.ui.handlers[Mode.COMMAND]; + commandUiHandler.clear(); + game.scene.getEnemyParty().forEach(p => { + p.hp = 0; + p.status = new Status(StatusEffect.FAINT); + game.scene.field.remove(p); + }); + game.phaseInterceptor["onHold"] = []; + game.scene.pushPhase(new VictoryPhase(game.scene, 0)); + game.phaseInterceptor.superEndPhase(); + if (isFinalBattle) { + await game.phaseInterceptor.to(MysteryEncounterRewardsPhase); + } else { + await game.phaseInterceptor.to(CommandPhase); + } +} diff --git a/src/test/mystery-encounter/encounters/trash-to-treasure-encounter.test.ts b/src/test/mystery-encounter/encounters/trash-to-treasure-encounter.test.ts new file mode 100644 index 000000000000..a4bfaea659a5 --- /dev/null +++ b/src/test/mystery-encounter/encounters/trash-to-treasure-encounter.test.ts @@ -0,0 +1,220 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import * as BattleAnims from "#app/data/battle-anims"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { runMysteryEncounterToEnd, skipBattleRunMysteryEncounterRewardsPhase } from "#test/mystery-encounter/encounter-test-utils"; +import { Moves } from "#enums/moves"; +import BattleScene from "#app/battle-scene"; +import { PokemonMove } from "#app/field/pokemon"; +import { Mode } from "#app/ui/ui"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; +import { HitHealModifier, HealShopCostModifier, TurnHealModifier } from "#app/modifier/modifier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import { TrashToTreasureEncounter } from "#app/data/mystery-encounters/encounters/trash-to-treasure-encounter"; +import { ModifierTier } from "#app/modifier/modifier-tier"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; +import { CommandPhase } from "#app/phases/command-phase"; +import { MovePhase } from "#app/phases/move-phase"; + +const namespace = "mysteryEncounter:trashToTreasure"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("Trash to Treasure - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.CAVE, [MysteryEncounterType.TRASH_TO_TREASURE]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TRASH_TO_TREASURE, defaultParty); + + expect(TrashToTreasureEncounter.encounterType).toBe(MysteryEncounterType.TRASH_TO_TREASURE); + expect(TrashToTreasureEncounter.encounterTier).toBe(MysteryEncounterTier.ULTRA); + expect(TrashToTreasureEncounter.dialogue).toBeDefined(); + expect(TrashToTreasureEncounter.dialogue.intro).toStrictEqual([{ text: `${namespace}.intro` }]); + expect(TrashToTreasureEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(TrashToTreasureEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(TrashToTreasureEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(TrashToTreasureEncounter.options.length).toBe(2); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.TRASH_TO_TREASURE); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should initialize fully", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = TrashToTreasureEncounter; + const moveInitSpy = vi.spyOn(BattleAnims, "initMoveAnim"); + const moveLoadSpy = vi.spyOn(BattleAnims, "loadMoveAnimAssets"); + + const { onInit } = TrashToTreasureEncounter; + + expect(TrashToTreasureEncounter.onInit).toBeDefined(); + + TrashToTreasureEncounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + + expect(TrashToTreasureEncounter.enemyPartyConfigs).toEqual([ + { + levelAdditiveMultiplier: 1, + disableSwitch: true, + pokemonConfigs: [ + { + species: getPokemonSpecies(Species.GARBODOR), + isBoss: true, + formIndex: 1, + bossSegmentModifier: 1, + moveSet: [Moves.PAYBACK, Moves.GUNK_SHOT, Moves.STOMPING_TANTRUM, Moves.DRAIN_PUNCH], + } + ], + } + ]); + await vi.waitFor(() => expect(moveInitSpy).toHaveBeenCalled()); + await vi.waitFor(() => expect(moveLoadSpy).toHaveBeenCalled()); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Dig for Valuables", () => { + it("should have the correct properties", () => { + const option1 = TrashToTreasureEncounter.options[0]; + expect(option1.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option1.dialogue).toBeDefined(); + expect(option1.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("should give 2 Leftovers, 2 Shell Bell, and Black Sludge", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TRASH_TO_TREASURE, defaultParty); + await runMysteryEncounterToEnd(game, 1); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + + const leftovers = scene.findModifier(m => m instanceof TurnHealModifier) as TurnHealModifier; + expect(leftovers).toBeDefined(); + expect(leftovers?.stackCount).toBe(2); + + const shellBell = scene.findModifier(m => m instanceof HitHealModifier) as HitHealModifier; + expect(shellBell).toBeDefined(); + expect(shellBell?.stackCount).toBe(2); + + const blackSludge = scene.findModifier(m => m instanceof HealShopCostModifier) as HealShopCostModifier; + expect(blackSludge).toBeDefined(); + expect(blackSludge?.stackCount).toBe(1); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.TRASH_TO_TREASURE, defaultParty); + await runMysteryEncounterToEnd(game, 1); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 2 - Battle Garbodor", () => { + it("should have the correct properties", () => { + const option1 = TrashToTreasureEncounter.options[1]; + expect(option1.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option1.dialogue).toBeDefined(); + expect(option1.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }); + }); + + it("should start battle against Garbodor", async () => { + const phaseSpy = vi.spyOn(scene, "pushPhase"); + + await game.runToMysteryEncounter(MysteryEncounterType.TRASH_TO_TREASURE, defaultParty); + await runMysteryEncounterToEnd(game, 2, undefined, true); + + const enemyField = scene.getEnemyField(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyField.length).toBe(1); + expect(enemyField[0].species.speciesId).toBe(Species.GARBODOR); + expect(enemyField[0].moveset).toEqual([new PokemonMove(Moves.PAYBACK), new PokemonMove(Moves.GUNK_SHOT), new PokemonMove(Moves.STOMPING_TANTRUM), new PokemonMove(Moves.DRAIN_PUNCH)]); + + // Should have used moves pre-battle + const movePhases = phaseSpy.mock.calls.filter(p => p[0] instanceof MovePhase).map(p => p[0]); + expect(movePhases.length).toBe(2); + expect(movePhases.filter(p => (p as MovePhase).move.moveId === Moves.TOXIC).length).toBe(1); + expect(movePhases.filter(p => (p as MovePhase).move.moveId === Moves.AMNESIA).length).toBe(1); + }); + + it("should have 2 Rogue, 1 Ultra, 1 Great in rewards", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TRASH_TO_TREASURE, defaultParty); + await runMysteryEncounterToEnd(game, 2, undefined, true); + await skipBattleRunMysteryEncounterRewardsPhase(game); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(4); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.tier - modifierSelectHandler.options[0].modifierTypeOption.upgradeCount).toEqual(ModifierTier.ROGUE); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.tier - modifierSelectHandler.options[1].modifierTypeOption.upgradeCount).toEqual(ModifierTier.ROGUE); + expect(modifierSelectHandler.options[2].modifierTypeOption.type.tier - modifierSelectHandler.options[2].modifierTypeOption.upgradeCount).toEqual(ModifierTier.ULTRA); + expect(modifierSelectHandler.options[3].modifierTypeOption.type.tier - modifierSelectHandler.options[3].modifierTypeOption.upgradeCount).toEqual(ModifierTier.GREAT); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/uncommon-breed-encounter.test.ts b/src/test/mystery-encounter/encounters/uncommon-breed-encounter.test.ts new file mode 100644 index 000000000000..ce9e37b8d68d --- /dev/null +++ b/src/test/mystery-encounter/encounters/uncommon-breed-encounter.test.ts @@ -0,0 +1,265 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runMysteryEncounterToEnd, runSelectMysteryEncounterOption } from "#test/mystery-encounter/encounter-test-utils"; +import { Moves } from "#enums/moves"; +import BattleScene from "#app/battle-scene"; +import { PokemonMove } from "#app/field/pokemon"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { generateModifierType } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; +import { CommandPhase } from "#app/phases/command-phase"; +import { UncommonBreedEncounter } from "#app/data/mystery-encounters/encounters/uncommon-breed-encounter"; +import { MovePhase } from "#app/phases/move-phase"; +import { speciesEggMoves } from "#app/data/egg-moves"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { BerryType } from "#enums/berry-type"; +import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; +import { Stat } from "#enums/stat"; +import { BerryModifier } from "#app/modifier/modifier"; +import { modifierTypes } from "#app/modifier/modifier-type"; + +const namespace = "mysteryEncounter:uncommonBreed"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("Uncommon Breed - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.mysteryEncounterTier(MysteryEncounterTier.COMMON); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.CAVE, [MysteryEncounterType.UNCOMMON_BREED]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.UNCOMMON_BREED, defaultParty); + + expect(UncommonBreedEncounter.encounterType).toBe(MysteryEncounterType.UNCOMMON_BREED); + expect(UncommonBreedEncounter.encounterTier).toBe(MysteryEncounterTier.COMMON); + expect(UncommonBreedEncounter.dialogue).toBeDefined(); + expect(UncommonBreedEncounter.dialogue.intro).toStrictEqual([{ text: `${namespace}.intro` }]); + expect(UncommonBreedEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(UncommonBreedEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(UncommonBreedEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(UncommonBreedEncounter.options.length).toBe(3); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.UNCOMMON_BREED); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should initialize fully", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = UncommonBreedEncounter; + + const { onInit } = UncommonBreedEncounter; + + expect(UncommonBreedEncounter.onInit).toBeDefined(); + + UncommonBreedEncounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + + const config = UncommonBreedEncounter.enemyPartyConfigs[0]; + expect(config).toBeDefined(); + expect(config.pokemonConfigs?.[0].isBoss).toBe(false); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Fight", () => { + it("should have the correct properties", () => { + const option = UncommonBreedEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("should start a fight against the boss", async () => { + const phaseSpy = vi.spyOn(scene, "pushPhase"); + const unshiftPhaseSpy = vi.spyOn(scene, "unshiftPhase"); + await game.runToMysteryEncounter(MysteryEncounterType.UNCOMMON_BREED, defaultParty); + + const config = game.scene.currentBattle.mysteryEncounter!.enemyPartyConfigs[0]; + const speciesToSpawn = config.pokemonConfigs?.[0].species.speciesId; + + await runMysteryEncounterToEnd(game, 1, undefined, true); + + const enemyField = scene.getEnemyField(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(enemyField.length).toBe(1); + expect(enemyField[0].species.speciesId).toBe(speciesToSpawn); + + const statStagePhases = unshiftPhaseSpy.mock.calls.filter(p => p[0] instanceof StatStageChangePhase)[0][0] as any; + expect(statStagePhases.stats).toEqual([Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD]); + + // Should have used its egg move pre-battle + const movePhases = phaseSpy.mock.calls.filter(p => p[0] instanceof MovePhase).map(p => p[0]); + expect(movePhases.length).toBe(1); + const eggMoves: Moves[] = speciesEggMoves[getPokemonSpecies(speciesToSpawn).getRootSpeciesId()]; + const usedMove = (movePhases[0] as MovePhase).move.moveId; + expect(eggMoves.includes(usedMove)).toBe(true); + }); + }); + + describe("Option 2 - Give it Food", () => { + it("should have the correct properties", () => { + const option = UncommonBreedEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + disabledButtonTooltip: `${namespace}.option.2.disabled_tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + } + ], + }); + }); + + // TODO: there is some severe test flakiness occurring for this file, needs to be looked at/addressed in separate issue + it.skip("should NOT be selectable if the player doesn't have enough berries", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.UNCOMMON_BREED, defaultParty); + // Clear out any pesky mods that slipped through test spin-up + scene.modifiers.forEach(mod => { + scene.removeModifier(mod); + }); + await scene.updateModifiers(true); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 2); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + }); + + // TODO: there is some severe test flakiness occurring for this file, needs to be looked at/addressed in separate issue + it.skip("Should skip fight when player meets requirements", { retry: 5 }, async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.UNCOMMON_BREED, defaultParty); + + // Berries on party lead + const sitrus = generateModifierType(scene, modifierTypes.BERRY, [BerryType.SITRUS])!; + const sitrusMod = sitrus.newModifier(scene.getParty()[0]) as BerryModifier; + sitrusMod.stackCount = 2; + await scene.addModifier(sitrusMod, true, false, false, true); + const ganlon = generateModifierType(scene, modifierTypes.BERRY, [BerryType.GANLON])!; + const ganlonMod = ganlon.newModifier(scene.getParty()[0]) as BerryModifier; + ganlonMod.stackCount = 3; + await scene.addModifier(ganlonMod, true, false, false, true); + await scene.updateModifiers(true); + + await runMysteryEncounterToEnd(game, 2); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 3 - Use an Attracting Move", () => { + it("should have the correct properties", () => { + const option = UncommonBreedEncounter.options[2]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + disabledButtonTooltip: `${namespace}.option.3.disabled_tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + } + ], + }); + }); + + it("should NOT be selectable if the player doesn't have an Attracting move", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.UNCOMMON_BREED, defaultParty); + scene.getParty().forEach(p => p.moveset = []); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 3); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + }); + + it("Should skip fight when player meets requirements", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + await game.runToMysteryEncounter(MysteryEncounterType.UNCOMMON_BREED, defaultParty); + // Mock moveset + scene.getParty()[0].moveset = [new PokemonMove(Moves.CHARM)]; + await runMysteryEncounterToEnd(game, 3); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/weird-dream-encounter.test.ts b/src/test/mystery-encounter/encounters/weird-dream-encounter.test.ts new file mode 100644 index 000000000000..ef014c6949b5 --- /dev/null +++ b/src/test/mystery-encounter/encounters/weird-dream-encounter.test.ts @@ -0,0 +1,219 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { runMysteryEncounterToEnd } from "#test/mystery-encounter/encounter-test-utils"; +import BattleScene from "#app/battle-scene"; +import { Mode } from "#app/ui/ui"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import { WeirdDreamEncounter } from "#app/data/mystery-encounters/encounters/weird-dream-encounter"; +import * as EncounterTransformationSequence from "#app/data/mystery-encounters/utils/encounter-transformation-sequence"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; + +const namespace = "mysteryEncounter:weirdDream"; +const defaultParty = [Species.MAGBY, Species.HAUNTER, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("Weird Dream - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + vi.spyOn(EncounterTransformationSequence, "doPokemonTransformationSequence").mockImplementation(() => new Promise(resolve => resolve())); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.CAVE, [MysteryEncounterType.WEIRD_DREAM]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.WEIRD_DREAM, defaultParty); + + expect(WeirdDreamEncounter.encounterType).toBe(MysteryEncounterType.WEIRD_DREAM); + expect(WeirdDreamEncounter.encounterTier).toBe(MysteryEncounterTier.ROGUE); + expect(WeirdDreamEncounter.dialogue).toBeDefined(); + expect(WeirdDreamEncounter.dialogue.intro).toStrictEqual([ + { + text: `${namespace}.intro` + }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.intro_dialogue`, + }, + ]); + expect(WeirdDreamEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(WeirdDreamEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(WeirdDreamEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(WeirdDreamEncounter.options.length).toBe(2); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.WEIRD_DREAM); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should initialize fully", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = WeirdDreamEncounter; + const loadBgmSpy = vi.spyOn(scene, "loadBgm"); + + const { onInit } = WeirdDreamEncounter; + + expect(WeirdDreamEncounter.onInit).toBeDefined(); + + WeirdDreamEncounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + + expect(loadBgmSpy).toHaveBeenCalled(); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Accept Transformation", () => { + it("should have the correct properties", () => { + const option = WeirdDreamEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("should transform the new party into new species, 2 at +90/+110, the rest at +40/50 BST", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.WEIRD_DREAM, defaultParty); + + const pokemonPrior = scene.getParty().map(pokemon => pokemon); + const bstsPrior = pokemonPrior.map(species => species.getSpeciesForm().getBaseStatTotal()); + + await runMysteryEncounterToEnd(game, 1); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + + const pokemonAfter = scene.getParty(); + const bstsAfter = pokemonAfter.map(pokemon => pokemon.getSpeciesForm().getBaseStatTotal()); + const bstDiff = bstsAfter.map((bst, index) => bst - bstsPrior[index]); + + for (let i = 0; i < pokemonAfter.length; i++) { + const newPokemon = pokemonAfter[i]; + expect(newPokemon.getSpeciesForm().speciesId).not.toBe(pokemonPrior[i].getSpeciesForm().speciesId); + expect(newPokemon.mysteryEncounterPokemonData?.types.length).toBe(2); + } + + const plus90To110 = bstDiff.filter(bst => bst > 80); + const plus40To50 = bstDiff.filter(bst => bst < 80); + + expect(plus90To110.length).toBe(2); + expect(plus40To50.length).toBe(1); + }); + + it("should have 1 Memory Mushroom, 5 Rogue Balls, and 2 Mints in rewards", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.WEIRD_DREAM, defaultParty); + await runMysteryEncounterToEnd(game, 1); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(4); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toEqual("MEMORY_MUSHROOM"); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.id).toEqual("ROGUE_BALL"); + expect(modifierSelectHandler.options[2].modifierTypeOption.type.id).toEqual("MINT"); + expect(modifierSelectHandler.options[3].modifierTypeOption.type.id).toEqual("MINT"); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.WEIRD_DREAM, defaultParty); + await runMysteryEncounterToEnd(game, 1); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 2 - Leave", () => { + it("should have the correct properties", () => { + const option = WeirdDreamEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + }, + ], + }); + }); + + it("should reduce party levels by 20%", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.WEIRD_DREAM, defaultParty); + const levelsPrior = scene.getParty().map(p => p.level); + await runMysteryEncounterToEnd(game, 2); + + const levelsAfter = scene.getParty().map(p => p.level); + + for (let i = 0; i < levelsPrior.length; i++) { + expect(Math.max(Math.ceil(0.8 * levelsPrior[i]), 1)).toBe(levelsAfter[i]); + expect(scene.getParty()[i].levelExp).toBe(0); + } + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.WEIRD_DREAM, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/mystery-encounter-utils.test.ts b/src/test/mystery-encounter/mystery-encounter-utils.test.ts new file mode 100644 index 000000000000..61eb1eaffd10 --- /dev/null +++ b/src/test/mystery-encounter/mystery-encounter-utils.test.ts @@ -0,0 +1,341 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import GameManager from "#app/test/utils/gameManager"; +import Phaser from "phaser"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import { Species } from "#enums/species"; +import BattleScene from "#app/battle-scene"; +import { StatusEffect } from "#app/data/status-effect"; +import MysteryEncounter from "#app/data/mystery-encounters/mystery-encounter"; +import { getPokemonSpecies, speciesStarters } from "#app/data/pokemon-species"; +import { Type } from "#app/data/type"; +import { getHighestLevelPlayerPokemon, getLowestLevelPlayerPokemon, getRandomPlayerPokemon, getRandomSpeciesByStarterTier, koPlayerPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { getEncounterText, queueEncounterMessage, showEncounterDialogue, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { MessagePhase } from "#app/phases/message-phase"; + +describe("Mystery Encounter Utils", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + scene = game.scene; + initSceneWithoutEncounterPhase(game.scene, [Species.ARCEUS, Species.MANAPHY]); + }); + + describe("getRandomPlayerPokemon", () => { + it("gets a random pokemon from player party", () => { + // Seeds are calculated to return index 0 first, 1 second (if both pokemon are legal) + game.override.seed("random"); + + let result = getRandomPlayerPokemon(scene); + expect(result.species.speciesId).toBe(Species.MANAPHY); + + game.override.seed("random2"); + + result = getRandomPlayerPokemon(scene); + expect(result.species.speciesId).toBe(Species.ARCEUS); + }); + + it("gets a fainted pokemon from player party if isAllowedInBattle is false", () => { + // Both pokemon fainted + scene.getParty().forEach(p => { + p.hp = 0; + p.trySetStatus(StatusEffect.FAINT); + p.updateInfo(); + }); + + // Seeds are calculated to return index 0 first, 1 second (if both pokemon are legal) + game.override.seed("random"); + + let result = getRandomPlayerPokemon(scene); + expect(result.species.speciesId).toBe(Species.MANAPHY); + + game.override.seed("random2"); + + result = getRandomPlayerPokemon(scene); + expect(result.species.speciesId).toBe(Species.ARCEUS); + }); + + it("gets an unfainted pokemon from player party if isAllowedInBattle is true", () => { + // Only faint 1st pokemon + const party = scene.getParty(); + party[0].hp = 0; + party[0].trySetStatus(StatusEffect.FAINT); + party[0].updateInfo(); + + // Seeds are calculated to return index 0 first, 1 second (if both pokemon are legal) + game.override.seed("random"); + + let result = getRandomPlayerPokemon(scene, true); + expect(result.species.speciesId).toBe(Species.MANAPHY); + + game.override.seed("random2"); + + result = getRandomPlayerPokemon(scene, true); + expect(result.species.speciesId).toBe(Species.MANAPHY); + }); + + it("returns last unfainted pokemon if doNotReturnLastAbleMon is false", () => { + // Only faint 1st pokemon + const party = scene.getParty(); + party[0].hp = 0; + party[0].trySetStatus(StatusEffect.FAINT); + party[0].updateInfo(); + + // Seeds are calculated to return index 0 first, 1 second (if both pokemon are legal) + game.override.seed("random"); + + let result = getRandomPlayerPokemon(scene, true, false); + expect(result.species.speciesId).toBe(Species.MANAPHY); + + game.override.seed("random2"); + + result = getRandomPlayerPokemon(scene, true, false); + expect(result.species.speciesId).toBe(Species.MANAPHY); + }); + + it("never returns last unfainted pokemon if doNotReturnLastAbleMon is true", () => { + // Only faint 1st pokemon + const party = scene.getParty(); + party[0].hp = 0; + party[0].trySetStatus(StatusEffect.FAINT); + party[0].updateInfo(); + + // Seeds are calculated to return index 0 first, 1 second (if both pokemon are legal) + game.override.seed("random"); + + let result = getRandomPlayerPokemon(scene, true, true); + expect(result.species.speciesId).toBe(Species.ARCEUS); + + game.override.seed("random2"); + + result = getRandomPlayerPokemon(scene, true, true); + expect(result.species.speciesId).toBe(Species.ARCEUS); + }); + }); + + describe("getHighestLevelPlayerPokemon", () => { + it("gets highest level pokemon", () => { + const party = scene.getParty(); + party[0].level = 100; + + const result = getHighestLevelPlayerPokemon(scene); + expect(result.species.speciesId).toBe(Species.ARCEUS); + }); + + it("gets highest level pokemon at different index", () => { + const party = scene.getParty(); + party[1].level = 100; + + const result = getHighestLevelPlayerPokemon(scene); + expect(result.species.speciesId).toBe(Species.MANAPHY); + }); + + it("breaks ties by getting returning lower index", () => { + const party = scene.getParty(); + party[0].level = 100; + party[1].level = 100; + + const result = getHighestLevelPlayerPokemon(scene); + expect(result.species.speciesId).toBe(Species.ARCEUS); + }); + + it("returns highest level unfainted if unfainted is true", () => { + const party = scene.getParty(); + party[0].level = 100; + party[0].hp = 0; + party[0].trySetStatus(StatusEffect.FAINT); + party[0].updateInfo(); + party[1].level = 10; + + const result = getHighestLevelPlayerPokemon(scene, true); + expect(result.species.speciesId).toBe(Species.MANAPHY); + }); + }); + + describe("getLowestLevelPokemon", () => { + it("gets lowest level pokemon", () => { + const party = scene.getParty(); + party[0].level = 100; + + const result = getLowestLevelPlayerPokemon(scene); + expect(result.species.speciesId).toBe(Species.MANAPHY); + }); + + it("gets lowest level pokemon at different index", () => { + const party = scene.getParty(); + party[1].level = 100; + + const result = getLowestLevelPlayerPokemon(scene); + expect(result.species.speciesId).toBe(Species.ARCEUS); + }); + + it("breaks ties by getting returning lower index", () => { + const party = scene.getParty(); + party[0].level = 100; + party[1].level = 100; + + const result = getLowestLevelPlayerPokemon(scene); + expect(result.species.speciesId).toBe(Species.ARCEUS); + }); + + it("returns lowest level unfainted if unfainted is true", () => { + const party = scene.getParty(); + party[0].level = 10; + party[0].hp = 0; + party[0].trySetStatus(StatusEffect.FAINT); + party[0].updateInfo(); + party[1].level = 100; + + const result = getLowestLevelPlayerPokemon(scene, true); + expect(result.species.speciesId).toBe(Species.MANAPHY); + }); + }); + + describe("getRandomSpeciesByStarterTier", () => { + it("gets species for a starter tier", () => { + const result = getRandomSpeciesByStarterTier(5); + const pokeSpecies = getPokemonSpecies(result); + + expect(pokeSpecies.speciesId).toBe(result); + expect(speciesStarters[result]).toBe(5); + }); + + it("gets species for a starter tier range", () => { + const result = getRandomSpeciesByStarterTier([5, 8]); + const pokeSpecies = getPokemonSpecies(result); + + expect(pokeSpecies.speciesId).toBe(result); + expect(speciesStarters[result]).toBeGreaterThanOrEqual(5); + expect(speciesStarters[result]).toBeLessThanOrEqual(8); + }); + + it("excludes species from search", () => { + // Only 9 tiers are: Koraidon, Miraidon, Arceus, Rayquaza, Kyogre, Groudon, Zacian + const result = getRandomSpeciesByStarterTier(9, [Species.KORAIDON, Species.MIRAIDON, Species.ARCEUS, Species.RAYQUAZA, Species.KYOGRE, Species.GROUDON]); + const pokeSpecies = getPokemonSpecies(result); + expect(pokeSpecies.speciesId).toBe(Species.ZACIAN); + }); + + it("gets species of specified types", () => { + // Only 9 tiers are: Koraidon, Miraidon, Arceus, Rayquaza, Kyogre, Groudon, Zacian + const result = getRandomSpeciesByStarterTier(9, undefined, [Type.GROUND]); + const pokeSpecies = getPokemonSpecies(result); + expect(pokeSpecies.speciesId).toBe(Species.GROUDON); + }); + }); + + describe("koPlayerPokemon", () => { + it("KOs a pokemon", () => { + const party = scene.getParty(); + const arceus = party[0]; + arceus.hp = 100; + expect(arceus.isAllowedInBattle()).toBe(true); + + koPlayerPokemon(scene, arceus); + expect(arceus.isAllowedInBattle()).toBe(false); + }); + }); + + describe("getTextWithEncounterDialogueTokens", () => { + it("injects dialogue tokens and color styling", () => { + scene.currentBattle.mysteryEncounter = new MysteryEncounter(null); + scene.currentBattle.mysteryEncounter.setDialogueToken("test", "value"); + + const result = getEncounterText(scene, "mysteryEncounter:unit_test_dialogue"); + expect(result).toEqual("valuevalue {{testvalue}} {{test1}} {{test}} {{test\\}} {{test\\}} {test}}"); + }); + + it("can perform nested dialogue token injection", () => { + scene.currentBattle.mysteryEncounter = new MysteryEncounter(null); + scene.currentBattle.mysteryEncounter.setDialogueToken("test", "value"); + scene.currentBattle.mysteryEncounter.setDialogueToken("testvalue", "new"); + + const result = getEncounterText(scene, "mysteryEncounter:unit_test_dialogue"); + expect(result).toEqual("valuevalue {{testvalue}} {{test1}} {{test}} {{test\\}} {{test\\}} {test}}"); + }); + }); + + describe("queueEncounterMessage", () => { + it("queues a message with encounter dialogue tokens", async () => { + scene.currentBattle.mysteryEncounter = new MysteryEncounter(null); + scene.currentBattle.mysteryEncounter.setDialogueToken("test", "value"); + const spy = vi.spyOn(game.scene, "queueMessage"); + const phaseSpy = vi.spyOn(game.scene, "unshiftPhase"); + + queueEncounterMessage(scene, "mysteryEncounter:unit_test_dialogue"); + expect(spy).toHaveBeenCalledWith("valuevalue {{testvalue}} {{test1}} {{test}} {{test\\}} {{test\\}} {test}}", null, true); + expect(phaseSpy).toHaveBeenCalledWith(expect.any(MessagePhase)); + }); + }); + + describe("showEncounterText", () => { + it("showText with dialogue tokens", async () => { + scene.currentBattle.mysteryEncounter = new MysteryEncounter(null); + scene.currentBattle.mysteryEncounter.setDialogueToken("test", "value"); + const spy = vi.spyOn(game.scene.ui, "showText"); + + await showEncounterText(scene, "mysteryEncounter:unit_test_dialogue"); + expect(spy).toHaveBeenCalledWith("valuevalue {{testvalue}} {{test1}} {{test}} {{test\\}} {{test\\}} {test}}", null, expect.any(Function), 0, true, null); + }); + }); + + describe("showEncounterDialogue", () => { + it("showText with dialogue tokens", async () => { + scene.currentBattle.mysteryEncounter = new MysteryEncounter(null); + scene.currentBattle.mysteryEncounter.setDialogueToken("test", "value"); + const spy = vi.spyOn(game.scene.ui, "showDialogue"); + + await showEncounterDialogue(scene, "mysteryEncounter:unit_test_dialogue", "mysteryEncounter:unit_test_dialogue"); + expect(spy).toHaveBeenCalledWith("valuevalue {{testvalue}} {{test1}} {{test}} {{test\\}} {{test\\}} {test}}", "valuevalue {{testvalue}} {{test1}} {{test}} {{test\\}} {{test\\}} {test}}", null, expect.any(Function), 0); + }); + }); + + describe("initBattleWithEnemyConfig", () => { + it("", () => { + + }); + }); + + describe("setCustomEncounterRewards", () => { + it("", () => { + + }); + }); + + describe("selectPokemonForOption", () => { + it("", () => { + + }); + }); + + describe("setEncounterExp", () => { + it("", () => { + + }); + }); + + describe("leaveEncounterWithoutBattle", () => { + it("", () => { + + }); + }); + + describe("handleMysteryEncounterVictory", () => { + it("", () => { + + }); + }); +}); + diff --git a/src/test/mystery-encounter/mystery-encounter.test.ts b/src/test/mystery-encounter/mystery-encounter.test.ts new file mode 100644 index 000000000000..d2a2e7f9d92f --- /dev/null +++ b/src/test/mystery-encounter/mystery-encounter.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeAll, beforeEach, expect, describe, it } from "vitest"; +import GameManager from "#app/test/utils/gameManager"; +import Phaser from "phaser"; +import { Species } from "#enums/species"; +import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; + +describe("Mystery Encounters", () => { + 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.startingWave(11); + game.override.mysteryEncounterChance(100); + }); + + it("Spawns a mystery encounter", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.MYSTERIOUS_CHALLENGERS, [Species.CHARIZARD, Species.VOLCARONA]); + + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + expect(game.scene.getCurrentPhase()!.constructor.name).toBe(MysteryEncounterPhase.name); + }); + + it("", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.MYSTERIOUS_CHALLENGERS, [Species.CHARIZARD, Species.VOLCARONA]); + + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + expect(game.scene.getCurrentPhase()!.constructor.name).toBe(MysteryEncounterPhase.name); + }); + + it("spawns mysterious challengers encounter", async () => { + }); + + it("spawns mysterious chest encounter", async () => { + }); + + it("spawns dark deal encounter", async () => { + }); + + it("spawns fight or flight encounter", async () => { + }); +}); + diff --git a/src/test/phases/mystery-encounter-phase.test.ts b/src/test/phases/mystery-encounter-phase.test.ts new file mode 100644 index 000000000000..0a99cd00db30 --- /dev/null +++ b/src/test/phases/mystery-encounter-phase.test.ts @@ -0,0 +1,154 @@ +import {afterEach, beforeAll, beforeEach, expect, describe, it, vi } from "vitest"; +import GameManager from "#app/test/utils/gameManager"; +import Phaser from "phaser"; +import {Species} from "#enums/species"; +import { MysteryEncounterOptionSelectedPhase, MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; +import {Mode} from "#app/ui/ui"; +import {Button} from "#enums/buttons"; +import MysteryEncounterUiHandler from "#app/ui/mystery-encounter-ui-handler"; +import {MysteryEncounterType} from "#enums/mystery-encounter-type"; +import MessageUiHandler from "#app/ui/message-ui-handler"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; + +describe("Mystery Encounter Phases", () => { + 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.startingWave(11); + game.override.mysteryEncounterChance(100); + // Seed guarantees wild encounter to be replaced by ME + game.override.seed("test"); + }); + + describe("MysteryEncounterPhase", () => { + it("Runs to MysteryEncounterPhase", async() => { + await game.runToMysteryEncounter(MysteryEncounterType.MYSTERIOUS_CHALLENGERS, [Species.CHARIZARD, Species.VOLCARONA]); + + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + expect(game.scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + }); + + it("Runs MysteryEncounterPhase", async() => { + await game.runToMysteryEncounter(MysteryEncounterType.MYSTERIOUS_CHALLENGERS, [Species.CHARIZARD, Species.VOLCARONA]); + + game.onNextPrompt("MysteryEncounterPhase", Mode.MYSTERY_ENCOUNTER, () => { + // End phase early for test + game.phaseInterceptor.superEndPhase(); + }); + await game.phaseInterceptor.run(MysteryEncounterPhase); + + expect(game.scene.mysteryEncounterSaveData.encounteredEvents.length).toBeGreaterThan(0); + expect(game.scene.mysteryEncounterSaveData.encounteredEvents[0].type).toEqual(MysteryEncounterType.MYSTERIOUS_CHALLENGERS); + expect(game.scene.mysteryEncounterSaveData.encounteredEvents[0].tier).toEqual(MysteryEncounterTier.GREAT); + expect(game.scene.ui.getMode()).toBe(Mode.MYSTERY_ENCOUNTER); + }); + + it("Selects an option for MysteryEncounterPhase", async() => { + const dialogueSpy = vi.spyOn(game.scene.ui, "showDialogue"); + const messageSpy = vi.spyOn(game.scene.ui, "showText"); + await game.runToMysteryEncounter(MysteryEncounterType.MYSTERIOUS_CHALLENGERS, [Species.CHARIZARD, Species.VOLCARONA]); + + game.onNextPrompt("MysteryEncounterPhase", Mode.MESSAGE, () => { + const handler = game.scene.ui.getHandler() as MessageUiHandler; + handler.processInput(Button.ACTION); + }); + + await game.phaseInterceptor.run(MysteryEncounterPhase); + + // Select option 1 for encounter + const handler = game.scene.ui.getHandler() as MysteryEncounterUiHandler; + handler.unblockInput(); + handler.processInput(Button.ACTION); + + // Waitfor required so that option select messages and preOptionPhase logic are handled + await vi.waitFor(() => expect(game.scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterOptionSelectedPhase.name)); + expect(game.scene.ui.getMode()).toBe(Mode.MESSAGE); + expect(dialogueSpy).toHaveBeenCalledTimes(1); + expect(messageSpy).toHaveBeenCalledTimes(2); + expect(dialogueSpy).toHaveBeenCalledWith("What's this?", "???", null, expect.any(Function)); + expect(messageSpy).toHaveBeenCalledWith("Mysterious challengers have appeared!", null, expect.any(Function), 750, true); + expect(messageSpy).toHaveBeenCalledWith("The trainer steps forward...", null, expect.any(Function), 300, true); + }); + }); + + describe("MysteryEncounterOptionSelectedPhase", () => { + it("runs phase", () => { + + }); + + it("handles onOptionSelect execution", () => { + + }); + + it("hides intro visuals", () => { + + }); + + it("does not hide intro visuals if option disabled", () => { + + }); + }); + + describe("MysteryEncounterBattlePhase", () => { + it("runs phase", () => { + + }); + + it("handles TRAINER_BATTLE variant", () => { + + }); + + it("handles BOSS_BATTLE variant", () => { + + }); + + it("handles WILD_BATTLE variant", () => { + + }); + + it("handles double battle", () => { + + }); + }); + + describe("MysteryEncounterRewardsPhase", () => { + it("runs phase", () => { + + }); + + it("handles doEncounterRewards", () => { + + }); + + it("handles heal phase if enabled", () => { + + }); + }); + + describe("PostMysteryEncounterPhase", () => { + it("runs phase", () => { + + }); + + it("handles onPostOptionSelect execution", () => { + + }); + + it("runs to next EncounterPhase", () => { + + }); + }); +}); + diff --git a/src/test/phases/select-modifier-phase.test.ts b/src/test/phases/select-modifier-phase.test.ts new file mode 100644 index 000000000000..d946c850ae38 --- /dev/null +++ b/src/test/phases/select-modifier-phase.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import Phaser from "phaser"; +import GameManager from "#app/test/utils/gameManager"; +import { initSceneWithoutEncounterPhase } from "#app/test/utils/gameManagerUtils"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; +import { ModifierTier } from "#app/modifier/modifier-tier"; +import * as Utils from "#app/utils"; +import { CustomModifierSettings, ModifierTypeOption, modifierTypes } from "#app/modifier/modifier-type"; +import BattleScene from "#app/battle-scene"; +import { Species } from "#enums/species"; +import { Mode } from "#app/ui/ui"; +import { PlayerPokemon } from "#app/field/pokemon"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; + +describe("SelectModifierPhase", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + scene = game.scene; + + initSceneWithoutEncounterPhase(scene, [Species.ABRA, Species.VOLCARONA]); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + + vi.clearAllMocks(); + }); + + it("should start a select modifier phase", async () => { + const selectModifierPhase = new SelectModifierPhase(scene); + scene.pushPhase(selectModifierPhase); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + }); + + it("should generate random modifiers", async () => { + const selectModifierPhase = new SelectModifierPhase(scene); + scene.pushPhase(selectModifierPhase); + await game.phaseInterceptor.run(SelectModifierPhase); + + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(3); + }); + + it("should modify reroll cost", async () => { + const options = [ + new ModifierTypeOption(modifierTypes.POTION(), 0, 100), + new ModifierTypeOption(modifierTypes.ETHER(), 0, 400), + new ModifierTypeOption(modifierTypes.REVIVE(), 0, 1000) + ]; + + const selectModifierPhase1 = new SelectModifierPhase(scene); + const selectModifierPhase2 = new SelectModifierPhase(scene, 0, undefined, { rerollMultiplier: 2 }); + + const cost1 = selectModifierPhase1.getRerollCost(options, false); + const cost2 = selectModifierPhase2.getRerollCost(options, false); + expect(cost2).toEqual(cost1 * 2); + }); + + it("should generate random modifiers from reroll", async () => { + let selectModifierPhase = new SelectModifierPhase(scene); + scene.pushPhase(selectModifierPhase); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(3); + + // Simulate selecting reroll + selectModifierPhase = new SelectModifierPhase(scene, 1, [ModifierTier.COMMON, ModifierTier.COMMON, ModifierTier.COMMON]); + scene.unshiftPhase(selectModifierPhase); + scene.ui.setMode(Mode.MESSAGE).then(() => game.endPhase()); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + expect(modifierSelectHandler.options.length).toEqual(3); + }); + + it("should generate random modifiers of same tier for reroll with reroll lock", async () => { + // Just use fully random seed for this test + vi.spyOn(scene, "resetSeed").mockImplementation(() => { + scene.waveSeed = Utils.shiftCharCodes(scene.seed, 5); + Phaser.Math.RND.sow([scene.waveSeed]); + console.log("Wave Seed:", scene.waveSeed, 5); + scene.rngCounter = 0; + }); + + let selectModifierPhase = new SelectModifierPhase(scene); + scene.pushPhase(selectModifierPhase); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(3); + const firstRollTiers: ModifierTier[] = modifierSelectHandler.options.map(o => o.modifierTypeOption.type.tier); + + // Simulate selecting reroll with lock + scene.lockModifierTiers = true; + scene.reroll = true; + selectModifierPhase = new SelectModifierPhase(scene, 1, firstRollTiers); + scene.unshiftPhase(selectModifierPhase); + scene.ui.setMode(Mode.MESSAGE).then(() => game.endPhase()); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + expect(modifierSelectHandler.options.length).toEqual(3); + // Reroll with lock can still upgrade + expect(modifierSelectHandler.options[0].modifierTypeOption.type.tier - modifierSelectHandler.options[0].modifierTypeOption.upgradeCount).toEqual(firstRollTiers[0]); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.tier - modifierSelectHandler.options[1].modifierTypeOption.upgradeCount).toEqual(firstRollTiers[1]); + expect(modifierSelectHandler.options[2].modifierTypeOption.type.tier - modifierSelectHandler.options[2].modifierTypeOption.upgradeCount).toEqual(firstRollTiers[2]); + }); + + it("should generate custom modifiers", async () => { + const customModifiers: CustomModifierSettings = { + guaranteedModifierTypeFuncs: [modifierTypes.MEMORY_MUSHROOM, modifierTypes.TM_ULTRA, modifierTypes.LEFTOVERS, modifierTypes.AMULET_COIN, modifierTypes.GOLDEN_PUNCH] + }; + const selectModifierPhase = new SelectModifierPhase(scene, 0, undefined, customModifiers); + scene.pushPhase(selectModifierPhase); + await game.phaseInterceptor.run(SelectModifierPhase); + + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(5); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toEqual("MEMORY_MUSHROOM"); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.id).toEqual("TM_ULTRA"); + expect(modifierSelectHandler.options[2].modifierTypeOption.type.id).toEqual("LEFTOVERS"); + expect(modifierSelectHandler.options[3].modifierTypeOption.type.id).toEqual("AMULET_COIN"); + expect(modifierSelectHandler.options[4].modifierTypeOption.type.id).toEqual("GOLDEN_PUNCH"); + }); + + it("should generate custom modifier tiers that can upgrade from luck", async () => { + const customModifiers: CustomModifierSettings = { + guaranteedModifierTiers: [ModifierTier.COMMON, ModifierTier.GREAT, ModifierTier.ULTRA, ModifierTier.ROGUE, ModifierTier.MASTER] + }; + const pokemon = new PlayerPokemon(scene, getPokemonSpecies(Species.BULBASAUR), 10, undefined, 0, undefined, true, 2, undefined, undefined, undefined); + + // Fill party with max shinies + while (scene.getParty().length > 0) { + scene.getParty().pop(); + } + scene.getParty().push(pokemon, pokemon, pokemon, pokemon, pokemon, pokemon); + + const selectModifierPhase = new SelectModifierPhase(scene, 0, undefined, customModifiers); + scene.pushPhase(selectModifierPhase); + await game.phaseInterceptor.run(SelectModifierPhase); + + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(5); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.tier - modifierSelectHandler.options[0].modifierTypeOption.upgradeCount).toEqual(ModifierTier.COMMON); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.tier - modifierSelectHandler.options[1].modifierTypeOption.upgradeCount).toEqual(ModifierTier.GREAT); + expect(modifierSelectHandler.options[2].modifierTypeOption.type.tier - modifierSelectHandler.options[2].modifierTypeOption.upgradeCount).toEqual(ModifierTier.ULTRA); + expect(modifierSelectHandler.options[3].modifierTypeOption.type.tier - modifierSelectHandler.options[3].modifierTypeOption.upgradeCount).toEqual(ModifierTier.ROGUE); + expect(modifierSelectHandler.options[4].modifierTypeOption.type.tier - modifierSelectHandler.options[4].modifierTypeOption.upgradeCount).toEqual(ModifierTier.MASTER); + }); + + it("should generate custom modifiers and modifier tiers together", async () => { + const customModifiers: CustomModifierSettings = { + guaranteedModifierTypeFuncs: [modifierTypes.MEMORY_MUSHROOM, modifierTypes.TM_COMMON], + guaranteedModifierTiers: [ModifierTier.MASTER, ModifierTier.MASTER] + }; + const selectModifierPhase = new SelectModifierPhase(scene, 0, undefined, customModifiers); + scene.pushPhase(selectModifierPhase); + await game.phaseInterceptor.run(SelectModifierPhase); + + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(4); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toEqual("MEMORY_MUSHROOM"); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.id).toEqual("TM_COMMON"); + expect(modifierSelectHandler.options[2].modifierTypeOption.type.tier).toEqual(ModifierTier.MASTER); + expect(modifierSelectHandler.options[3].modifierTypeOption.type.tier).toEqual(ModifierTier.MASTER); + }); + + it("should fill remaining modifiers if fillRemaining is true with custom modifiers", async () => { + const customModifiers: CustomModifierSettings = { + guaranteedModifierTypeFuncs: [modifierTypes.MEMORY_MUSHROOM], + guaranteedModifierTiers: [ModifierTier.MASTER], + fillRemaining: true + }; + const selectModifierPhase = new SelectModifierPhase(scene, 0, undefined, customModifiers); + scene.pushPhase(selectModifierPhase); + await game.phaseInterceptor.run(SelectModifierPhase); + + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(3); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toEqual("MEMORY_MUSHROOM"); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.tier).toEqual(ModifierTier.MASTER); + }); +}); diff --git a/src/test/utils/TextInterceptor.ts b/src/test/utils/TextInterceptor.ts index 507161eb6d00..466bcbf80520 100644 --- a/src/test/utils/TextInterceptor.ts +++ b/src/test/utils/TextInterceptor.ts @@ -1,3 +1,6 @@ +/** + * Class will intercept any text or dialogue message calls and log them for test purposes + */ export default class TextInterceptor { private scene; public logs: string[] = []; diff --git a/src/test/utils/gameManager.ts b/src/test/utils/gameManager.ts index cc364a74b831..a10ed70b97e7 100644 --- a/src/test/utils/gameManager.ts +++ b/src/test/utils/gameManager.ts @@ -49,6 +49,11 @@ import { OverridesHelper } from "./helpers/overridesHelper"; import { SettingsHelper } from "./helpers/settingsHelper"; import { ReloadHelper } from "./helpers/reloadHelper"; import { CheckSwitchPhase } from "#app/phases/check-switch-phase"; +import BattleMessageUiHandler from "#app/ui/battle-message-ui-handler"; +import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; +import { expect } from "vitest"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { isNullOrUndefined } from "#app/utils"; /** * Class to manage the game state and transitions between phases. @@ -88,6 +93,9 @@ export default class GameManager { this.challengeMode = new ChallengeModeHelper(this); this.settings = new SettingsHelper(this); this.reload = new ReloadHelper(this); + + // Disables Mystery Encounters on all tests (can be overridden at test level) + this.override.mysteryEncounterChance(0); } /** @@ -178,6 +186,39 @@ export default class GameManager { console.log("===finished run to final boss encounter==="); } + /** + * Runs the game to a mystery encounter phase. + * @param encounterType if specified, will expect encounter to have been spawned + * @param species Optional array of species for party. + * @returns A promise that resolves when the EncounterPhase ends. + */ + async runToMysteryEncounter(encounterType?: MysteryEncounterType, species?: Species[]) { + if (!isNullOrUndefined(encounterType)) { + this.override.disableTrainerWaves(); + this.override.mysteryEncounter(encounterType!); + } + + await this.runToTitle(); + + this.onNextPrompt("TitlePhase", Mode.TITLE, () => { + this.scene.gameMode = getGameMode(GameModes.CLASSIC); + const starters = generateStarter(this.scene, species); + const selectStarterPhase = new SelectStarterPhase(this.scene); + this.scene.pushPhase(new EncounterPhase(this.scene, false)); + selectStarterPhase.initBattle(starters); + }, () => this.isCurrentPhase(EncounterPhase)); + + this.onNextPrompt("EncounterPhase", Mode.MESSAGE, () => { + const handler = this.scene.ui.getHandler() as BattleMessageUiHandler; + handler.processInput(Button.ACTION); + }, () => this.isCurrentPhase(MysteryEncounterPhase), true); + + await this.phaseInterceptor.run(EncounterPhase); + if (!isNullOrUndefined(encounterType)) { + expect(this.scene.currentBattle?.mysteryEncounter?.encounterType).toBe(encounterType); + } + } + /** * @deprecated Use `game.classicMode.startBattle()` or `game.dailyMode.startBattle()` instead * diff --git a/src/test/utils/gameManagerUtils.ts b/src/test/utils/gameManagerUtils.ts index 20a3fd179fdb..700d93082d8c 100644 --- a/src/test/utils/gameManagerUtils.ts +++ b/src/test/utils/gameManagerUtils.ts @@ -7,6 +7,7 @@ import { PlayerPokemon } from "#app/field/pokemon"; import { GameModes, getGameMode } from "#app/game-mode"; import { Starter } from "#app/ui/starter-select-ui-handler"; import { Species } from "#enums/species"; +import Battle, { BattleType } from "#app/battle"; /** Function to convert Blob to string */ export function blobToString(blob) { @@ -89,3 +90,23 @@ export function getMovePosition(scene: BattleScene, pokemonIndex: 0 | 1, move: M console.log(`Move position for ${Moves[move]} (=${move}):`, index); return index; } + +/** + * Useful for populating party, wave index, etc. without having to spin up and run through an entire EncounterPhase + * @param scene + * @param species + */ +export function initSceneWithoutEncounterPhase(scene: BattleScene, species?: Species[]) { + const starters = generateStarter(scene, species); + starters.forEach((starter) => { + const starterProps = scene.gameData.getSpeciesDexAttrProps(starter.species, starter.dexAttr); + const starterFormIndex = Math.min(starterProps.formIndex, Math.max(starter.species.forms.length - 1, 0)); + const starterGender = Gender.MALE; + const starterIvs = scene.gameData.dexData[starter.species.speciesId].ivs.slice(0); + const starterPokemon = scene.addPlayerPokemon(starter.species, scene.gameMode.getStartingLevel(), starter.abilityIndex, starterFormIndex, starterGender, starterProps.shiny, starterProps.variant, starterIvs, starter.nature); + starter.moveset && starterPokemon.tryPopulateMoveset(starter.moveset); + scene.getParty().push(starterPokemon); + }); + + scene.currentBattle = new Battle(getGameMode(GameModes.CLASSIC), 5, BattleType.WILD, undefined, false); +} diff --git a/src/test/utils/gameWrapper.ts b/src/test/utils/gameWrapper.ts index 7c0ecac7c128..0ef5c4d46111 100644 --- a/src/test/utils/gameWrapper.ts +++ b/src/test/utils/gameWrapper.ts @@ -229,7 +229,7 @@ export default class GameWrapper { }; this.scene.make = new MockGameObjectCreator(mockTextureManager); this.scene.time = new MockClock(this.scene); - this.scene.remove = vi.fn(); + this.scene.remove = vi.fn(); // TODO: this should be stubbed differently } } diff --git a/src/test/utils/helpers/overridesHelper.ts b/src/test/utils/helpers/overridesHelper.ts index a17b841b6821..686de58e874c 100644 --- a/src/test/utils/helpers/overridesHelper.ts +++ b/src/test/utils/helpers/overridesHelper.ts @@ -10,6 +10,8 @@ import { ModifierOverride } from "#app/modifier/modifier-type"; import Overrides from "#app/overrides"; import { vi } from "vitest"; import { GameManagerHelper } from "./gameManagerHelper"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; /** * Helper to handle overrides in tests @@ -323,6 +325,41 @@ export class OverridesHelper extends GameManagerHelper { return this; } + /** + * Override the encounter chance for a mystery encounter. + * @param percentage the encounter chance in % + * @returns spy instance + */ + mysteryEncounterChance(percentage: number) { + const maxRate: number = 256; // 100% + const rate = maxRate * (percentage / 100); + vi.spyOn(Overrides, "MYSTERY_ENCOUNTER_RATE_OVERRIDE", "get").mockReturnValue(rate); + this.log(`Mystery encounter chance set to ${percentage}% (=${rate})!`); + return this; + } + + /** + * Override the encounter chance for a mystery encounter. + * @returns spy instance + * @param tier + */ + mysteryEncounterTier(tier: MysteryEncounterTier) { + vi.spyOn(Overrides, "MYSTERY_ENCOUNTER_TIER_OVERRIDE", "get").mockReturnValue(tier); + this.log(`Mystery encounter tier set to ${tier}!`); + return this; + } + + /** + * Override the encounter that spawns for the scene + * @param encounterType + * @returns spy instance + */ + mysteryEncounter(encounterType: MysteryEncounterType) { + vi.spyOn(Overrides, "MYSTERY_ENCOUNTER_OVERRIDE", "get").mockReturnValue(encounterType); + this.log(`Mystery encounter override set to ${encounterType}!`); + return this; + } + private log(...params: any[]) { console.log("Overrides:", ...params); } diff --git a/src/test/utils/mocks/mockGameObject.ts b/src/test/utils/mocks/mockGameObject.ts index 9138e0f687a0..4c243ec9ca13 100644 --- a/src/test/utils/mocks/mockGameObject.ts +++ b/src/test/utils/mocks/mockGameObject.ts @@ -1,3 +1,3 @@ export interface MockGameObject { - + name: string; } diff --git a/src/test/utils/mocks/mockTextureManager.ts b/src/test/utils/mocks/mockTextureManager.ts index ca8065bef97b..ce19d6b6432d 100644 --- a/src/test/utils/mocks/mockTextureManager.ts +++ b/src/test/utils/mocks/mockTextureManager.ts @@ -7,7 +7,6 @@ import MockSprite from "#test/utils/mocks/mocksContainer/mockSprite"; import MockText from "#test/utils/mocks/mocksContainer/mockText"; import MockTexture from "#test/utils/mocks/mocksContainer/mockTexture"; import { MockGameObject } from "./mockGameObject"; -import { vi } from "vitest"; import { MockVideoGameObject } from "./mockVideoGameObject"; /** @@ -36,7 +35,7 @@ export default class MockTextureManager { text: this.text.bind(this), bitmapText: this.text.bind(this), displayList: this.displayList, - video: vi.fn(() => new MockVideoGameObject()), + video: () => new MockVideoGameObject(), }; } diff --git a/src/test/utils/mocks/mockVideoGameObject.ts b/src/test/utils/mocks/mockVideoGameObject.ts index d8155e23b6c8..d11fb5a44ce9 100644 --- a/src/test/utils/mocks/mockVideoGameObject.ts +++ b/src/test/utils/mocks/mockVideoGameObject.ts @@ -2,6 +2,8 @@ import { MockGameObject } from "./mockGameObject"; /** Mocks video-related stuff */ export class MockVideoGameObject implements MockGameObject { + public name: string; + constructor() {} public play = () => null; @@ -9,4 +11,5 @@ export class MockVideoGameObject implements MockGameObject { public setOrigin = () => null; public setScale = () => null; public setVisible = () => null; + public setLoop = () => null; } diff --git a/src/test/utils/mocks/mocksContainer/mockContainer.ts b/src/test/utils/mocks/mocksContainer/mockContainer.ts index e13cef0e43ec..05dad327dc64 100644 --- a/src/test/utils/mocks/mocksContainer/mockContainer.ts +++ b/src/test/utils/mocks/mocksContainer/mockContainer.ts @@ -13,7 +13,7 @@ export default class MockContainer implements MockGameObject { public frame; protected textureManager; public list: MockGameObject[] = []; - private name?: string; + public name: string; constructor(textureManager: MockTextureManager, x, y) { this.x = x; @@ -35,6 +35,10 @@ export default class MockContainer implements MockGameObject { // same as remove or destroy } + removeBetween(startIndex, endIndex, destroyChild) { + // Removes multiple children across an index range + } + addedToScene() { // This callback is invoked when this Game Object is added to a Scene. } @@ -151,6 +155,10 @@ export default class MockContainer implements MockGameObject { // Sends this Game Object to the back of its parent's display list. } + moveTo(obj) { + // Moves this Game Object to the given index in the list. + } + moveAbove(obj) { // Moves this Game Object to be above the given Game Object in the display list. } @@ -159,9 +167,9 @@ export default class MockContainer implements MockGameObject { // Moves this Game Object to be below the given Game Object in the display list. } - setName = (name: string) => { + setName(name: string) { this.name = name; - }; + } bringToTop(obj) { // Brings this Game Object to the top of its parents display list. @@ -205,5 +213,9 @@ export default class MockContainer implements MockGameObject { return this.list; } + getByName(key: string) { + return this.list.find(v => v.name === key) ?? new MockContainer(this.textureManager, 0, 0); + } + disableInteractive = () => null; } diff --git a/src/test/utils/mocks/mocksContainer/mockGraphics.ts b/src/test/utils/mocks/mocksContainer/mockGraphics.ts index e026b212e16b..b20faf4ed6a8 100644 --- a/src/test/utils/mocks/mocksContainer/mockGraphics.ts +++ b/src/test/utils/mocks/mocksContainer/mockGraphics.ts @@ -3,6 +3,7 @@ import { MockGameObject } from "../mockGameObject"; export default class MockGraphics implements MockGameObject { private scene; public list: MockGameObject[] = []; + public name: string; constructor(textureManager, config) { this.scene = textureManager.scene; } diff --git a/src/test/utils/mocks/mocksContainer/mockRectangle.ts b/src/test/utils/mocks/mocksContainer/mockRectangle.ts index 26c2f74ea424..48cd2cb1380a 100644 --- a/src/test/utils/mocks/mocksContainer/mockRectangle.ts +++ b/src/test/utils/mocks/mocksContainer/mockRectangle.ts @@ -4,6 +4,7 @@ export default class MockRectangle implements MockGameObject { private fillColor; private scene; public list: MockGameObject[] = []; + public name: string; constructor(textureManager, x, y, width, height, fillColor) { this.fillColor = fillColor; diff --git a/src/test/utils/mocks/mocksContainer/mockSprite.ts b/src/test/utils/mocks/mocksContainer/mockSprite.ts index 83ec39511513..a55b218d0c21 100644 --- a/src/test/utils/mocks/mocksContainer/mockSprite.ts +++ b/src/test/utils/mocks/mocksContainer/mockSprite.ts @@ -14,6 +14,7 @@ export default class MockSprite implements MockGameObject { public scene; public anims; public list: MockGameObject[] = []; + public name: string; constructor(textureManager, x, y, texture) { this.textureManager = textureManager; this.scene = textureManager.scene; diff --git a/src/test/utils/mocks/mocksContainer/mockText.ts b/src/test/utils/mocks/mocksContainer/mockText.ts index 5462056f1e50..f0854fcd90a4 100644 --- a/src/test/utils/mocks/mocksContainer/mockText.ts +++ b/src/test/utils/mocks/mocksContainer/mockText.ts @@ -10,17 +10,18 @@ export default class MockText implements MockGameObject { public list: MockGameObject[] = []; public style; public text = ""; - private name?: string; + public name: string; public color?: string; constructor(textureManager, x, y, content, styleOptions) { this.scene = textureManager.scene; this.textureManager = textureManager; this.style = {}; - // Phaser.GameObjects.TextStyle.prototype.setStyle = () => null; + // Phaser.GameObjects.TextStyle.prototype.setStyle = () => this; // Phaser.GameObjects.Text.prototype.updateText = () => null; // Phaser.Textures.TextureManager.prototype.addCanvas = () => {}; UI.prototype.showText = this.showText; + UI.prototype.showDialogue = this.showDialogue; this.text = ""; this.phaserText = ""; // super(scene, x, y); @@ -78,13 +79,20 @@ export default class MockText implements MockGameObject { return result; } - showText(text, delay, callback, callbackDelay, prompt, promptDelay) { + showText(text: string, delay?: integer | null, callback?: Function | null, callbackDelay?: integer | null, prompt?: boolean | null, promptDelay?: integer | null) { this.scene.messageWrapper.showText(text, delay, callback, callbackDelay, prompt, promptDelay); if (callback) { callback(); } } + showDialogue(keyOrText: string, name: string | undefined, delay: integer | null = 0, callback: Function, callbackDelay?: integer, promptDelay?: integer) { + this.scene.messageWrapper.showDialogue(keyOrText, name, delay, callback, callbackDelay, promptDelay); + if (callback) { + callback(); + } + } + setScale(scale) { // return this.phaserText.setScale(scale); } @@ -192,9 +200,9 @@ export default class MockText implements MockGameObject { }; } - setColor = (color: string) => { + setColor(color: string) { this.color = color; - }; + } setInteractive = () => null; @@ -222,9 +230,9 @@ export default class MockText implements MockGameObject { // return this.phaserText.setAlpha(alpha); } - setName = (name: string) => { + setName(name: string) { this.name = name; - }; + } setAlign(align) { // return this.phaserText.setAlign(align); @@ -248,6 +256,14 @@ export default class MockText implements MockGameObject { }; } + disableInteractive() { + // Disables interaction with this Game Object. + } + + clearTint() { + // Clears tint on this Game Object. + } + add(obj) { // Adds a child to this Game Object. this.list.push(obj); diff --git a/src/test/utils/mocks/mocksContainer/mockTexture.ts b/src/test/utils/mocks/mocksContainer/mockTexture.ts index cb31480cc602..bedd1d2c84a8 100644 --- a/src/test/utils/mocks/mocksContainer/mockTexture.ts +++ b/src/test/utils/mocks/mocksContainer/mockTexture.ts @@ -12,6 +12,7 @@ export default class MockTexture implements MockGameObject { public source; public frames: object; public firstFrame: string; + public name: string; constructor(manager, key: string, source) { this.manager = manager; diff --git a/src/test/utils/phaseInterceptor.ts b/src/test/utils/phaseInterceptor.ts index a89d1788be9c..cdf0ad41057c 100644 --- a/src/test/utils/phaseInterceptor.ts +++ b/src/test/utils/phaseInterceptor.ts @@ -43,6 +43,23 @@ import { UnavailablePhase } from "#app/phases/unavailable-phase"; import { VictoryPhase } from "#app/phases/victory-phase"; import { PartyHealPhase } from "#app/phases/party-heal-phase"; import UI, { Mode } from "#app/ui/ui"; +import { + MysteryEncounterBattlePhase, + MysteryEncounterOptionSelectedPhase, + MysteryEncounterPhase, + MysteryEncounterRewardsPhase, + PostMysteryEncounterPhase +} from "#app/phases/mystery-encounter-phases"; +import { ModifierRewardPhase } from "#app/phases/modifier-reward-phase"; +import { PartyExpPhase } from "#app/phases/party-exp-phase"; + +export interface PromptHandler { + phaseTarget?: string; + mode?: Mode; + callback?: () => void; + expireFn?: () => void; + awaitingActionInput?: boolean; +} export default class PhaseInterceptor { public scene; @@ -52,7 +69,7 @@ export default class PhaseInterceptor { private interval; private promptInterval; private intervalRun; - private prompts; + private prompts: PromptHandler[]; private phaseFrom; private inProgress; private originalSetMode; @@ -104,10 +121,17 @@ export default class PhaseInterceptor { [EndEvolutionPhase, this.startPhase], [LevelCapPhase, this.startPhase], [AttemptRunPhase, this.startPhase], + [MysteryEncounterPhase, this.startPhase], + [MysteryEncounterOptionSelectedPhase, this.startPhase], + [MysteryEncounterBattlePhase, this.startPhase], + [MysteryEncounterRewardsPhase, this.startPhase], + [PostMysteryEncounterPhase, this.startPhase], + [ModifierRewardPhase, this.startPhase], + [PartyExpPhase, this.startPhase] ]; private endBySetMode = [ - TitlePhase, SelectGenderPhase, CommandPhase + TitlePhase, SelectGenderPhase, CommandPhase, SelectModifierPhase, MysteryEncounterPhase, PostMysteryEncounterPhase ]; /** @@ -320,7 +344,7 @@ export default class PhaseInterceptor { console.log("setMode", `${Mode[mode]} (=${mode})`, args); const ret = this.originalSetMode.apply(instance, [mode, ...args]); if (!this.phases[currentPhase.constructor.name]) { - throw new Error(`missing ${currentPhase.constructor.name} in phaseInterceptior PHASES list`); + throw new Error(`missing ${currentPhase.constructor.name} in phaseInterceptor PHASES list`); } if (this.phases[currentPhase.constructor.name].endBySetMode) { this.inProgress?.callback(); @@ -338,12 +362,15 @@ export default class PhaseInterceptor { const actionForNextPrompt = this.prompts[0]; const expireFn = actionForNextPrompt.expireFn && actionForNextPrompt.expireFn(); const currentMode = this.scene.ui.getMode(); - const currentPhase = this.scene.getCurrentPhase().constructor.name; + const currentPhase = this.scene.getCurrentPhase()?.constructor.name; const currentHandler = this.scene.ui.getHandler(); if (expireFn) { this.prompts.shift(); } else if (currentMode === actionForNextPrompt.mode && currentPhase === actionForNextPrompt.phaseTarget && currentHandler.active && (!actionForNextPrompt.awaitingActionInput || (actionForNextPrompt.awaitingActionInput && currentHandler.awaitingActionInput))) { - this.prompts.shift().callback(); + const prompt = this.prompts.shift(); + if (prompt?.callback) { + prompt.callback(); + } } } }); @@ -355,6 +382,7 @@ export default class PhaseInterceptor { * @param mode - The mode of the UI. * @param callback - The callback function to execute. * @param expireFn - The function to determine if the prompt has expired. + * @param awaitingActionInput */ addToNextPrompt(phaseTarget: string, mode: Mode, callback: () => void, expireFn?: () => void, awaitingActionInput: boolean = false) { this.prompts.push({ diff --git a/src/test/vitest.setup.ts b/src/test/vitest.setup.ts index bf806cd053a4..3bb5c240d940 100644 --- a/src/test/vitest.setup.ts +++ b/src/test/vitest.setup.ts @@ -11,7 +11,7 @@ import { initSpecies } from "#app/data/pokemon-species"; import { initAchievements } from "#app/system/achv"; import { initVouchers } from "#app/system/voucher"; import { initStatsKeys } from "#app/ui/game-stats-ui-handler"; - +import { initMysteryEncounters } from "#app/data/mystery-encounters/mystery-encounters"; import { beforeAll, vi } from "vitest"; /** Mock the override import to always return default values, ignoring any custom overrides. */ @@ -35,6 +35,7 @@ initSpecies(); initMoves(); initAbilities(); initLoggedInUser(); +initMysteryEncounters(); global.testFailed = false; diff --git a/src/ui/fight-ui-handler.ts b/src/ui/fight-ui-handler.ts index 60db9d19eefd..3bf551193353 100644 --- a/src/ui/fight-ui-handler.ts +++ b/src/ui/fight-ui-handler.ts @@ -10,6 +10,7 @@ import i18next from "i18next"; import {Button} from "#enums/buttons"; import Pokemon, { PokemonMove } from "#app/field/pokemon"; import { CommandPhase } from "#app/phases/command-phase"; +import { BattleType } from "#app/battle"; export default class FightUiHandler extends UiHandler { public static readonly MOVES_CONTAINER_NAME = "moves"; @@ -120,8 +121,12 @@ export default class FightUiHandler extends UiHandler { ui.playError(); } } else { - ui.setMode(Mode.COMMAND, this.fieldIndex); - success = true; + // Cannot back out of fight menu if skipToFightInput is enabled + const { battleType, mysteryEncounter } = this.scene.currentBattle; + if (battleType !== BattleType.MYSTERY_ENCOUNTER || !mysteryEncounter?.skipToFightInput) { + ui.setMode(Mode.COMMAND, this.fieldIndex); + success = true; + } } } else { switch (button) { diff --git a/src/ui/message-ui-handler.ts b/src/ui/message-ui-handler.ts index a78887e1581e..93e00cb6b703 100644 --- a/src/ui/message-ui-handler.ts +++ b/src/ui/message-ui-handler.ts @@ -29,10 +29,13 @@ export default abstract class MessageUiHandler extends AwaitableUiHandler { if (delay === null || delay === undefined) { delay = 20; } + + // Pattern matching regex that checks for @c{}, @f{}, @s{}, and @f{} patterns within message text and parses them to their respective behaviors. const charVarMap = new Map(); const delayMap = new Map(); const soundMap = new Map(); - const actionPattern = /@(c|d|s)\{(.*?)\}/; + const fadeMap = new Map(); + const actionPattern = /@(c|d|s|f)\{(.*?)\}/; let actionMatch: RegExpExecArray | null; while ((actionMatch = actionPattern.exec(text))) { switch (actionMatch[1]) { @@ -45,6 +48,9 @@ export default abstract class MessageUiHandler extends AwaitableUiHandler { case "s": soundMap.set(actionMatch.index, actionMatch[2]); break; + case "f": + fadeMap.set(actionMatch.index, parseInt(actionMatch[2])); + break; } text = text.slice(0, actionMatch.index) + text.slice(actionMatch.index + actionMatch[2].length + 4); } @@ -103,6 +109,7 @@ export default abstract class MessageUiHandler extends AwaitableUiHandler { const charVar = charVarMap.get(charIndex); const charSound = soundMap.get(charIndex); const charDelay = delayMap.get(charIndex); + const charFade = fadeMap.get(charIndex); this.message.setText(text.slice(0, charIndex)); const advance = () => { if (charVar) { @@ -134,6 +141,19 @@ export default abstract class MessageUiHandler extends AwaitableUiHandler { advance(); } }); + } else if (charFade) { + this.textTimer!.paused = true; + this.scene.time.delayedCall(150, () => { + this.scene.ui.fadeOut(750).then(() => { + const delay = Utils.getFrameMs(charFade); + this.scene.time.delayedCall(delay, () => { + this.scene.ui.fadeIn(500).then(() => { + this.textTimer!.paused = false; + advance(); + }); + }); + }); + }); } else { advance(); } diff --git a/src/ui/modifier-select-ui-handler.ts b/src/ui/modifier-select-ui-handler.ts index ca5d27f96a4a..c9d3f1957208 100644 --- a/src/ui/modifier-select-ui-handler.ts +++ b/src/ui/modifier-select-ui-handler.ts @@ -4,15 +4,17 @@ import { getPokeballAtlasKey, PokeballType } from "../data/pokeball"; import { addTextObject, getTextStyleOptions, getModifierTierTextTint, getTextColor, TextStyle } from "./text"; import AwaitableUiHandler from "./awaitable-ui-handler"; import { Mode } from "./ui"; -import { LockModifierTiersModifier, PokemonHeldItemModifier } from "../modifier/modifier"; +import { LockModifierTiersModifier, PokemonHeldItemModifier, HealShopCostModifier } from "../modifier/modifier"; import { handleTutorial, Tutorial } from "../tutorial"; -import {Button} from "#enums/buttons"; +import { Button } from "#enums/buttons"; import MoveInfoOverlay from "./move-info-overlay"; import { allMoves } from "../data/move"; import * as Utils from "./../utils"; import Overrides from "#app/overrides"; import i18next from "i18next"; import { ShopCursorTarget } from "#app/enums/shop-cursor-target"; +import { IntegerHolder } from "./../utils"; +import Phaser from "phaser"; export const SHOP_OPTIONS_ROW_LIMIT = 6; @@ -22,13 +24,18 @@ export default class ModifierSelectUiHandler extends AwaitableUiHandler { private lockRarityButtonContainer: Phaser.GameObjects.Container; private transferButtonContainer: Phaser.GameObjects.Container; private checkButtonContainer: Phaser.GameObjects.Container; + private continueButtonContainer: Phaser.GameObjects.Container; private rerollCostText: Phaser.GameObjects.Text; private lockRarityButtonText: Phaser.GameObjects.Text; - private moveInfoOverlay : MoveInfoOverlay; - private moveInfoOverlayActive : boolean = false; + private moveInfoOverlay: MoveInfoOverlay; + private moveInfoOverlayActive: boolean = false; private rowCursor: integer = 0; private player: boolean; + /** + * If reroll cost is negative, it is assumed there are 0 items in the shop. + * It will cause reroll button to be disabled, and a "Continue" button to show in the place of shop items + */ private rerollCost: integer; private transferButtonWidth: integer; private checkButtonWidth: integer; @@ -105,6 +112,15 @@ export default class ModifierSelectUiHandler extends AwaitableUiHandler { this.lockRarityButtonText.setOrigin(0, 0); this.lockRarityButtonContainer.add(this.lockRarityButtonText); + this.continueButtonContainer = this.scene.add.container((this.scene.game.canvas.width / 12), -(this.scene.game.canvas.height / 12)); + this.continueButtonContainer.setVisible(false); + ui.add(this.continueButtonContainer); + + // Create continue button + const continueButtonText = addTextObject(this.scene, -24, 5, i18next.t("modifierSelectUiHandler:continueNextWaveButton"), TextStyle.MESSAGE); + continueButtonText.setName("text-continue-btn"); + this.continueButtonContainer.add(continueButtonText); + // prepare move overlay const overlayScale = 1; this.moveInfoOverlay = new MoveInfoOverlay(this.scene, { @@ -113,7 +129,7 @@ export default class ModifierSelectUiHandler extends AwaitableUiHandler { onSide: true, right: true, x: 1, - y: -MoveInfoOverlay.getHeight(overlayScale, true) -1, + y: -MoveInfoOverlay.getHeight(overlayScale, true) - 1, width: (this.scene.game.canvas.width / 6) - 2, }); ui.add(this.moveInfoOverlay); @@ -134,7 +150,7 @@ export default class ModifierSelectUiHandler extends AwaitableUiHandler { return false; } - if (args.length !== 4 || !(args[1] instanceof Array) || !args[1].length || !(args[2] instanceof Function)) { + if (args.length !== 4 || !(args[1] instanceof Array) || !(args[2] instanceof Function)) { return false; } @@ -159,6 +175,9 @@ export default class ModifierSelectUiHandler extends AwaitableUiHandler { this.lockRarityButtonContainer.setVisible(false); this.lockRarityButtonContainer.setAlpha(0); + this.continueButtonContainer.setVisible(false); + this.continueButtonContainer.setAlpha(0); + this.rerollButtonContainer.setPositionRelative(this.lockRarityButtonContainer, 0, canLockRarities ? -12 : 0); this.rerollCost = args[3] as integer; @@ -166,8 +185,11 @@ export default class ModifierSelectUiHandler extends AwaitableUiHandler { this.updateRerollCostText(); const typeOptions = args[1] as ModifierTypeOption[]; - const shopTypeOptions = !this.scene.gameMode.hasNoShop - ? getPlayerShopModifierTypeOptionsForWave(this.scene.currentBattle.waveIndex, this.scene.getWaveMoneyAmount(1)) + const removeHealShop = this.scene.gameMode.hasNoShop; + const baseShopCost = new IntegerHolder(this.scene.getWaveMoneyAmount(1)); + this.scene.applyModifier(HealShopCostModifier, true, baseShopCost); + const shopTypeOptions = !removeHealShop + ? getPlayerShopModifierTypeOptionsForWave(this.scene.currentBattle.waveIndex, baseShopCost.value) : []; const optionsYOffset = shopTypeOptions.length >= SHOP_OPTIONS_ROW_LIMIT ? -8 : -24; @@ -180,6 +202,11 @@ export default class ModifierSelectUiHandler extends AwaitableUiHandler { this.options.push(option); } + // Set "Continue" button height based on number of rows in healing items shop + const continueButton = this.continueButtonContainer.getAt(0); + continueButton.y = optionsYOffset - 5; + continueButton.setVisible(this.options.length === 0); + for (let m = 0; m < shopTypeOptions.length; m++) { const row = m < SHOP_OPTIONS_ROW_LIMIT ? 0 : 1; const col = m < SHOP_OPTIONS_ROW_LIMIT ? m : m - SHOP_OPTIONS_ROW_LIMIT; @@ -243,16 +270,24 @@ export default class ModifierSelectUiHandler extends AwaitableUiHandler { this.rerollButtonContainer.setAlpha(0); this.checkButtonContainer.setAlpha(0); this.lockRarityButtonContainer.setAlpha(0); + this.continueButtonContainer.setAlpha(0); this.rerollButtonContainer.setVisible(true); this.checkButtonContainer.setVisible(true); + this.continueButtonContainer.setVisible(this.rerollCost < 0); this.lockRarityButtonContainer.setVisible(canLockRarities); this.scene.tweens.add({ - targets: [ this.rerollButtonContainer, this.lockRarityButtonContainer, this.checkButtonContainer ], + targets: [ this.lockRarityButtonContainer, this.checkButtonContainer, this.continueButtonContainer ], alpha: 1, duration: 250 }); + this.scene.tweens.add({ + targets: [this.rerollButtonContainer], + alpha: this.rerollCost < 0 ? 0.5 : 1, + duration: 250 + }); + const updateCursorTarget = () => { if (this.scene.shopCursorTarget === ShopCursorTarget.CHECK_TEAM) { this.setRowCursor(0); @@ -418,6 +453,14 @@ export default class ModifierSelectUiHandler extends AwaitableUiHandler { // the modifier selection has been updated, always hide the overlay this.moveInfoOverlay.clear(); if (this.rowCursor) { + if (this.rowCursor === 1 && options.length === 0) { + // Continue button when no shop items + this.cursorObj.setScale(1.25); + this.cursorObj.setPosition((this.scene.game.canvas.width / 18) + 23, (-this.scene.game.canvas.height / 12) - (this.shopOptionsRows.length > 1 ? 6 : 22)); + ui.showText(i18next.t("modifierSelectUiHandler:continueNextWaveDescription")); + return ret; + } + const sliceWidth = (this.scene.game.canvas.width / 6) / (options.length + 2); if (this.rowCursor < 2) { this.cursorObj.setPosition(sliceWidth * (cursor + 1) + (sliceWidth * 0.5) - 20, (-this.scene.game.canvas.height / 12) - (this.shopOptionsRows.length > 1 ? 6 : 22)); @@ -435,10 +478,10 @@ export default class ModifierSelectUiHandler extends AwaitableUiHandler { this.cursorObj.setPosition(6, this.lockRarityButtonContainer.visible ? -72 : -60); ui.showText(i18next.t("modifierSelectUiHandler:rerollDesc")); } else if (cursor === 1) { - this.cursorObj.setPosition((this.scene.game.canvas.width - this.transferButtonWidth - this.checkButtonWidth)/6 - 30, -60); + this.cursorObj.setPosition((this.scene.game.canvas.width - this.transferButtonWidth - this.checkButtonWidth) / 6 - 30, -60); ui.showText(i18next.t("modifierSelectUiHandler:transferDesc")); } else if (cursor === 2) { - this.cursorObj.setPosition((this.scene.game.canvas.width - this.checkButtonWidth)/6 - 10, -60); + this.cursorObj.setPosition((this.scene.game.canvas.width - this.checkButtonWidth) / 6 - 10, -60); ui.showText(i18next.t("modifierSelectUiHandler:checkTeamDesc")); } else { this.cursorObj.setPosition(6, -60); @@ -454,7 +497,14 @@ export default class ModifierSelectUiHandler extends AwaitableUiHandler { if (rowCursor !== lastRowCursor) { this.rowCursor = rowCursor; let newCursor = Math.round(this.cursor / Math.max(this.getRowItems(lastRowCursor) - 1, 1) * (this.getRowItems(rowCursor) - 1)); + if (rowCursor === 1 && this.options.length === 0) { + // Handle empty shop + newCursor = 0; + } if (rowCursor === 0) { + if (this.options.length === 0) { + newCursor = 1; + } if (newCursor === 0 && !this.rerollButtonContainer.visible) { newCursor = 1; } @@ -495,6 +545,13 @@ export default class ModifierSelectUiHandler extends AwaitableUiHandler { } updateRerollCostText(): void { + const rerollDisabled = this.rerollCost < 0; + if (rerollDisabled) { + this.rerollCostText.setVisible(false); + return; + } else { + this.rerollCostText.setVisible(true); + } const canReroll = this.scene.money >= this.rerollCost; const formattedMoney = Utils.formatMoney(this.scene.moneyFormat, this.rerollCost); @@ -539,7 +596,7 @@ export default class ModifierSelectUiHandler extends AwaitableUiHandler { onComplete: () => options.forEach(o => o.destroy()) }); - [ this.rerollButtonContainer, this.checkButtonContainer, this.transferButtonContainer, this.lockRarityButtonContainer ].forEach(container => { + [ this.rerollButtonContainer, this.checkButtonContainer, this.transferButtonContainer, this.lockRarityButtonContainer, this.continueButtonContainer ].forEach(container => { if (container.visible) { this.scene.tweens.add({ targets: container, diff --git a/src/ui/mystery-encounter-ui-handler.ts b/src/ui/mystery-encounter-ui-handler.ts new file mode 100644 index 000000000000..307bab0a3af5 --- /dev/null +++ b/src/ui/mystery-encounter-ui-handler.ts @@ -0,0 +1,623 @@ +import BattleScene from "../battle-scene"; +import { addBBCodeTextObject, getBBCodeFrag, TextStyle } from "./text"; +import { Mode } from "./ui"; +import UiHandler from "./ui-handler"; +import { Button } from "#enums/buttons"; +import { addWindow, WindowVariant } from "./ui-theme"; +import { MysteryEncounterPhase } from "../phases/mystery-encounter-phases"; +import { PartyUiMode } from "./party-ui-handler"; +import MysteryEncounterOption from "../data/mystery-encounters/mystery-encounter-option"; +import * as Utils from "../utils"; +import { isNullOrUndefined } from "../utils"; +import { getPokeballAtlasKey } from "../data/pokeball"; +import { OptionSelectSettings } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { getEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import i18next from "i18next"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import BBCodeText from "phaser3-rex-plugins/plugins/bbcodetext"; + +export default class MysteryEncounterUiHandler extends UiHandler { + private cursorContainer: Phaser.GameObjects.Container; + private cursorObj?: Phaser.GameObjects.Image; + + private optionsContainer: Phaser.GameObjects.Container; + // Length = max number of allowable options (4) + private optionScrollTweens: (Phaser.Tweens.Tween | null)[] = new Array(4).fill(null); + + private tooltipWindow: Phaser.GameObjects.NineSlice; + private tooltipContainer: Phaser.GameObjects.Container; + private tooltipScrollTween?: Phaser.Tweens.Tween; + + private descriptionWindow: Phaser.GameObjects.NineSlice; + private descriptionContainer: Phaser.GameObjects.Container; + private descriptionScrollTween?: Phaser.Tweens.Tween; + private rarityBall: Phaser.GameObjects.Sprite; + + private dexProgressWindow: Phaser.GameObjects.NineSlice; + private dexProgressContainer: Phaser.GameObjects.Container; + private showDexProgress: boolean = false; + + private overrideSettings?: OptionSelectSettings; + private encounterOptions: MysteryEncounterOption[] = []; + private optionsMeetsReqs: boolean[]; + + protected viewPartyIndex: integer = 0; + + protected blockInput: boolean = true; + + constructor(scene: BattleScene) { + super(scene, Mode.MYSTERY_ENCOUNTER); + } + + override setup() { + const ui = this.getUi(); + + this.cursorContainer = this.scene.add.container(18, -38.7); + this.cursorContainer.setVisible(false); + ui.add(this.cursorContainer); + this.optionsContainer = this.scene.add.container(12, -38.7); + this.optionsContainer.setVisible(false); + ui.add(this.optionsContainer); + this.dexProgressContainer = this.scene.add.container(214, -43); + this.dexProgressContainer.setVisible(false); + ui.add(this.dexProgressContainer); + this.descriptionContainer = this.scene.add.container(0, -152); + this.descriptionContainer.setVisible(false); + ui.add(this.descriptionContainer); + this.tooltipContainer = this.scene.add.container(210, -48); + this.tooltipContainer.setVisible(false); + ui.add(this.tooltipContainer); + + this.setCursor(this.getCursor()); + + this.descriptionWindow = addWindow(this.scene, 0, 0, 150, 105, false, false, 0, 0, WindowVariant.THIN); + this.descriptionContainer.add(this.descriptionWindow); + + this.tooltipWindow = addWindow(this.scene, 0, 0, 110, 48, false, false, 0, 0, WindowVariant.THIN); + this.tooltipContainer.add(this.tooltipWindow); + + this.dexProgressWindow = addWindow(this.scene, 0, 0, 24, 28, false, false, 0, 0, WindowVariant.THIN); + this.dexProgressContainer.add(this.dexProgressWindow); + + this.rarityBall = this.scene.add.sprite(141, 9, "pb"); + this.rarityBall.setScale(0.75); + this.descriptionContainer.add(this.rarityBall); + + const dexProgressIndicator = this.scene.add.sprite(12, 10, "encounter_radar"); + dexProgressIndicator.setScale(0.80); + this.dexProgressContainer.add(dexProgressIndicator); + this.dexProgressContainer.setInteractive(new Phaser.Geom.Rectangle(0, 0, 24, 28), Phaser.Geom.Rectangle.Contains); + } + + override show(args: any[]): boolean { + super.show(args); + + this.overrideSettings = args[0] as OptionSelectSettings ?? {}; + const showDescriptionContainer = isNullOrUndefined(this.overrideSettings?.hideDescription) ? true : !this.overrideSettings?.hideDescription; + const slideInDescription = isNullOrUndefined(this.overrideSettings?.slideInDescription) ? true : this.overrideSettings?.slideInDescription; + const startingCursorIndex = this.overrideSettings?.startingCursorIndex ?? 0; + + this.cursorContainer.setVisible(true); + this.descriptionContainer.setVisible(showDescriptionContainer); + this.optionsContainer.setVisible(true); + this.dexProgressContainer.setVisible(true); + this.displayEncounterOptions(slideInDescription); + const cursor = this.getCursor(); + if (cursor === (this.optionsContainer?.length || 0) - 1) { + // Always resets cursor on view party button if it was last there + this.setCursor(cursor); + } else { + this.setCursor(startingCursorIndex); + } + if (this.blockInput) { + setTimeout(() => { + this.unblockInput(); + }, 1000); + } + this.displayOptionTooltip(); + + return true; + } + + override processInput(button: Button): boolean { + const ui = this.getUi(); + + let success = false; + + const cursor = this.getCursor(); + + if (button === Button.CANCEL || button === Button.ACTION) { + if (button === Button.ACTION) { + const selected = this.encounterOptions[cursor]; + if (cursor === this.viewPartyIndex) { + // Handle view party + success = true; + const overrideSettings: OptionSelectSettings = { + ...this.overrideSettings, + slideInDescription: false + }; + this.scene.ui.setMode(Mode.PARTY, PartyUiMode.CHECK, -1, () => { + this.scene.ui.setMode(Mode.MYSTERY_ENCOUNTER, overrideSettings); + setTimeout(() => { + this.setCursor(this.viewPartyIndex); + this.unblockInput(); + }, 300); + }); + } else if (this.blockInput || (!this.optionsMeetsReqs[cursor] && (selected.optionMode === MysteryEncounterOptionMode.DISABLED_OR_DEFAULT || selected.optionMode === MysteryEncounterOptionMode.DISABLED_OR_SPECIAL))) { + success = false; + } else { + if ((this.scene.getCurrentPhase() as MysteryEncounterPhase).handleOptionSelect(selected, cursor)) { + success = true; + } else { + ui.playError(); + } + } + } else { + // TODO: If we need to handle cancel option? Maybe default logic to leave/run from encounter idk + } + } else { + switch (this.optionsContainer.getAll()?.length) { + default: + case 3: + success = this.handleTwoOptionMoveInput(button); + break; + case 4: + success = this.handleThreeOptionMoveInput(button); + break; + case 5: + success = this.handleFourOptionMoveInput(button); + break; + } + + this.displayOptionTooltip(); + } + + if (success) { + ui.playSelect(); + } + + return success; + } + + private handleTwoOptionMoveInput(button: Button): boolean { + let success = false; + const cursor = this.getCursor(); + switch (button) { + case Button.UP: + if (cursor < this.viewPartyIndex) { + success = this.setCursor(this.viewPartyIndex); + } + break; + case Button.DOWN: + if (cursor === this.viewPartyIndex) { + success = this.setCursor(1); + } + break; + case Button.LEFT: + if (cursor > 0) { + success = this.setCursor(cursor - 1); + } + break; + case Button.RIGHT: + if (cursor < this.viewPartyIndex) { + success = this.setCursor(cursor + 1); + } + break; + } + + return success; + } + + private handleThreeOptionMoveInput(button: Button): boolean { + let success = false; + const cursor = this.getCursor(); + switch (button) { + case Button.UP: + if (cursor === 2) { + success = this.setCursor(cursor - 2); + } else { + success = this.setCursor(this.viewPartyIndex); + } + break; + case Button.DOWN: + if (cursor === this.viewPartyIndex) { + success = this.setCursor(1); + } else { + success = this.setCursor(2); + } + break; + case Button.LEFT: + if (cursor === this.viewPartyIndex) { + success = this.setCursor(1); + } else if (cursor === 1) { + success = this.setCursor(cursor - 1); + } + break; + case Button.RIGHT: + if (cursor === 1) { + success = this.setCursor(this.viewPartyIndex); + } else if (cursor < 1) { + success = this.setCursor(cursor + 1); + } + break; + } + + return success; + } + + private handleFourOptionMoveInput(button: Button): boolean { + let success = false; + const cursor = this.getCursor(); + switch (button) { + case Button.UP: + if (cursor >= 2 && cursor !== this.viewPartyIndex) { + success = this.setCursor(cursor - 2); + } else { + success = this.setCursor(this.viewPartyIndex); + } + break; + case Button.DOWN: + if (cursor <= 1) { + success = this.setCursor(cursor + 2); + } else if (cursor === this.viewPartyIndex) { + success = this.setCursor(1); + } + break; + case Button.LEFT: + if (cursor === this.viewPartyIndex) { + success = this.setCursor(1); + } else if (cursor % 2 === 1) { + success = this.setCursor(cursor - 1); + } + break; + case Button.RIGHT: + if (cursor === 1) { + success = this.setCursor(this.viewPartyIndex); + } else if (cursor % 2 === 0 && cursor !== this.viewPartyIndex) { + success = this.setCursor(cursor + 1); + } + break; + } + + return success; + } + + /** + * When ME UI first displays, the option buttons will be disabled temporarily to prevent player accidentally clicking through hastily + * This method is automatically called after a short delay but can also be called manually + */ + unblockInput() { + if (this.blockInput) { + this.blockInput = false; + for (let i = 0; i < this.optionsContainer.length - 1; i++) { + const optionMode = this.encounterOptions[i].optionMode; + if (!this.optionsMeetsReqs[i] && (optionMode === MysteryEncounterOptionMode.DISABLED_OR_DEFAULT || optionMode === MysteryEncounterOptionMode.DISABLED_OR_SPECIAL)) { + continue; + } + (this.optionsContainer.getAt(i) as Phaser.GameObjects.Text).setAlpha(1); + } + } + } + + override getCursor(): integer { + return this.cursor ? this.cursor : 0; + } + + override setCursor(cursor: integer): boolean { + const prevCursor = this.getCursor(); + const changed = prevCursor !== cursor; + if (changed) { + this.cursor = cursor; + } + + this.viewPartyIndex = this.optionsContainer.getAll()?.length - 1; + + if (!this.cursorObj) { + this.cursorObj = this.scene.add.image(0, 0, "cursor"); + this.cursorContainer.add(this.cursorObj); + } + + if (cursor === this.viewPartyIndex) { + this.cursorObj.setPosition(246, -17); + } else if (this.optionsContainer.getAll()?.length === 3) { // 2 Options + this.cursorObj.setPosition(-10.5 + (cursor % 2 === 1 ? 100 : 0), 15); + } else if (this.optionsContainer.getAll()?.length === 4) { // 3 Options + this.cursorObj.setPosition(-10.5 + (cursor % 2 === 1 ? 100 : 0), 7 + (cursor > 1 ? 16 : 0)); + } else if (this.optionsContainer.getAll()?.length === 5) { // 4 Options + this.cursorObj.setPosition(-10.5 + (cursor % 2 === 1 ? 100 : 0), 7 + (cursor > 1 ? 16 : 0)); + } + + return changed; + } + + displayEncounterOptions(slideInDescription: boolean = true): void { + this.getUi().clearText(); + const mysteryEncounter = this.scene.currentBattle.mysteryEncounter!; + this.encounterOptions = this.overrideSettings?.overrideOptions ?? mysteryEncounter.options; + this.optionsMeetsReqs = []; + + const titleText: string | null = getEncounterText(this.scene, mysteryEncounter.dialogue.encounterOptionsDialogue?.title, TextStyle.TOOLTIP_TITLE); + const descriptionText: string | null = getEncounterText(this.scene, mysteryEncounter.dialogue.encounterOptionsDialogue?.description, TextStyle.TOOLTIP_CONTENT); + const queryText: string | null = getEncounterText(this.scene, mysteryEncounter.dialogue.encounterOptionsDialogue?.query, TextStyle.TOOLTIP_CONTENT); + + // Clear options container (except cursor) + this.optionsContainer.removeAll(true); + + // Options Window + for (let i = 0; i < this.encounterOptions.length; i++) { + const option = this.encounterOptions[i]; + + let optionText: BBCodeText; + switch (this.encounterOptions.length) { + default: + case 2: + optionText = addBBCodeTextObject(this.scene, i % 2 === 0 ? 0 : 100, 8, "-", TextStyle.WINDOW, { fontSize: "80px", lineSpacing: -8 }); + break; + case 3: + optionText = addBBCodeTextObject(this.scene, i % 2 === 0 ? 0 : 100, i < 2 ? 0 : 16, "-", TextStyle.WINDOW, { fontSize: "80px", lineSpacing: -8 }); + break; + case 4: + optionText = addBBCodeTextObject(this.scene, i % 2 === 0 ? 0 : 100, i < 2 ? 0 : 16, "-", TextStyle.WINDOW, { fontSize: "80px", lineSpacing: -8 }); + break; + } + + this.optionsMeetsReqs.push(option.meetsRequirements(this.scene)); + const optionDialogue = option.dialogue!; + const label = !this.optionsMeetsReqs[i] && optionDialogue.disabledButtonLabel ? optionDialogue.disabledButtonLabel : optionDialogue.buttonLabel; + let text: string | null; + if (option.hasRequirements() && this.optionsMeetsReqs[i] && (option.optionMode === MysteryEncounterOptionMode.DEFAULT_OR_SPECIAL || option.optionMode === MysteryEncounterOptionMode.DISABLED_OR_SPECIAL)) { + // Options with special requirements that are met are automatically colored green + text = getEncounterText(this.scene, label, TextStyle.SUMMARY_GREEN); + } else { + text = getEncounterText(this.scene, label, optionDialogue.style ? optionDialogue.style : TextStyle.WINDOW); + } + + if (text) { + optionText.setText(text); + } + + if (!this.optionsMeetsReqs[i] && (option.optionMode === MysteryEncounterOptionMode.DISABLED_OR_DEFAULT || option.optionMode === MysteryEncounterOptionMode.DISABLED_OR_SPECIAL)) { + optionText.setAlpha(0.5); + } + if (this.blockInput) { + optionText.setAlpha(0.5); + } + + // Sets up the mask that hides the option text to give an illusion of scrolling + const nonScrollWidth = 90; + const optionTextMaskRect = this.scene.make.graphics({}); + optionTextMaskRect.setScale(6); + optionTextMaskRect.fillStyle(0xFFFFFF); + optionTextMaskRect.beginPath(); + optionTextMaskRect.fillRect(optionText.x + 11, optionText.y + 140, nonScrollWidth, 18); + + const optionTextMask = optionTextMaskRect.createGeometryMask(); + optionText.setMask(optionTextMask); + + const optionTextWidth = optionText.displayWidth; + + const tween = this.optionScrollTweens[i]; + if (tween) { + tween.remove(); + this.optionScrollTweens[i] = null; + } + + // Animates the option text scrolling sideways + if (optionTextWidth > nonScrollWidth) { + this.optionScrollTweens[i] = this.scene.tweens.add({ + targets: optionText, + delay: Utils.fixedInt(2000), + loop: -1, + hold: Utils.fixedInt(2000), + duration: Utils.fixedInt((optionTextWidth - nonScrollWidth) / 15 * 2000), + x: `-=${(optionTextWidth - nonScrollWidth)}` + }); + } + + this.optionsContainer.add(optionText); + } + + // View Party Button + const viewPartyText = addBBCodeTextObject(this.scene, 256, -24, getBBCodeFrag(i18next.t("mysteryEncounterMessages:view_party_button"), TextStyle.PARTY), TextStyle.PARTY); + this.optionsContainer.add(viewPartyText); + + // Description Window + const titleTextObject = addBBCodeTextObject(this.scene, 0, 0, titleText ?? "", TextStyle.TOOLTIP_TITLE, { wordWrap: { width: 750 }, align: "center", lineSpacing: -8 }); + this.descriptionContainer.add(titleTextObject); + titleTextObject.setPosition(72 - titleTextObject.displayWidth / 2, 5.5); + + // Rarity of encounter + const index = mysteryEncounter.encounterTier === MysteryEncounterTier.COMMON ? 0 : + mysteryEncounter.encounterTier === MysteryEncounterTier.GREAT ? 1 : + mysteryEncounter.encounterTier === MysteryEncounterTier.ULTRA ? 2 : + mysteryEncounter.encounterTier === MysteryEncounterTier.ROGUE ? 3 : 4; + const ballType = getPokeballAtlasKey(index); + this.rarityBall.setTexture("pb", ballType); + + const descriptionTextObject = addBBCodeTextObject(this.scene, 6, 25, descriptionText ?? "", TextStyle.TOOLTIP_CONTENT, { wordWrap: { width: 830 } }); + + // Sets up the mask that hides the description text to give an illusion of scrolling + const descriptionTextMaskRect = this.scene.make.graphics({}); + descriptionTextMaskRect.setScale(6); + descriptionTextMaskRect.fillStyle(0xFFFFFF); + descriptionTextMaskRect.beginPath(); + descriptionTextMaskRect.fillRect(6, 53, 206, 57); + + const abilityDescriptionTextMask = descriptionTextMaskRect.createGeometryMask(); + + descriptionTextObject.setMask(abilityDescriptionTextMask); + + const descriptionLineCount = Math.floor(descriptionTextObject.displayHeight / 10); + + if (this.descriptionScrollTween) { + this.descriptionScrollTween.remove(); + this.descriptionScrollTween = undefined; + } + + // Animates the description text moving upwards + if (descriptionLineCount > 6) { + this.descriptionScrollTween = this.scene.tweens.add({ + targets: descriptionTextObject, + delay: Utils.fixedInt(2000), + loop: -1, + hold: Utils.fixedInt(2000), + duration: Utils.fixedInt((descriptionLineCount - 6) * 2000), + y: `-=${10 * (descriptionLineCount - 6)}` + }); + } + + this.descriptionContainer.add(descriptionTextObject); + + const queryTextObject = addBBCodeTextObject(this.scene, 0, 0, queryText ?? "", TextStyle.TOOLTIP_CONTENT, { wordWrap: { width: 830 } }); + this.descriptionContainer.add(queryTextObject); + queryTextObject.setPosition(75 - queryTextObject.displayWidth / 2, 90); + + // Slide in description container + if (slideInDescription) { + this.descriptionContainer.x -= 150; + this.scene.tweens.add({ + targets: this.descriptionContainer, + x: "+=150", + ease: "Sine.easeInOut", + duration: 1000 + }); + } + } + + /** + * Updates and displays the tooltip for a given option + * The tooltip will auto wrap and scroll if it is too long + */ + private displayOptionTooltip() { + const cursor = this.getCursor(); + // Clear tooltip box + if (this.tooltipContainer.length > 1) { + this.tooltipContainer.removeBetween(1, this.tooltipContainer.length, true); + } + this.tooltipContainer.setVisible(true); + + if (isNullOrUndefined(cursor) || cursor > this.optionsContainer.length - 2) { + // Ignore hovers on view party button + // Hide dex progress if visible + this.showHideDexProgress(false); + return; + } + + let text: string | null; + const cursorOption = this.encounterOptions[cursor]; + const optionDialogue = cursorOption.dialogue!; + if (!this.optionsMeetsReqs[cursor] && (cursorOption.optionMode === MysteryEncounterOptionMode.DISABLED_OR_DEFAULT || cursorOption.optionMode === MysteryEncounterOptionMode.DISABLED_OR_SPECIAL) && optionDialogue.disabledButtonTooltip) { + text = getEncounterText(this.scene, optionDialogue.disabledButtonTooltip, TextStyle.TOOLTIP_CONTENT); + } else { + text = getEncounterText(this.scene, optionDialogue.buttonTooltip, TextStyle.TOOLTIP_CONTENT); + } + + // Auto-color options green/blue for good/bad by looking for (+)/(-) + if (text) { + const primaryStyleString = [...text.match(new RegExp(/\[color=[^\[]*\]\[shadow=[^\[]*\]/i))!][0]; + text = text.replace(/(\(\+\)[^\(\[]*)/gi, substring => "[/color][/shadow]" + getBBCodeFrag(substring, TextStyle.SUMMARY_GREEN) + "[/color][/shadow]" + primaryStyleString); + text = text.replace(/(\(\-\)[^\(\[]*)/gi, substring => "[/color][/shadow]" + getBBCodeFrag(substring, TextStyle.SUMMARY_BLUE) + "[/color][/shadow]" + primaryStyleString); + } + + if (text) { + const tooltipTextObject = addBBCodeTextObject(this.scene, 6, 7, text, TextStyle.TOOLTIP_CONTENT, { wordWrap: { width: 600 }, fontSize: "72px" }); + this.tooltipContainer.add(tooltipTextObject); + + // Sets up the mask that hides the description text to give an illusion of scrolling + const tooltipTextMaskRect = this.scene.make.graphics({}); + tooltipTextMaskRect.setScale(6); + tooltipTextMaskRect.fillStyle(0xFFFFFF); + tooltipTextMaskRect.beginPath(); + tooltipTextMaskRect.fillRect(this.tooltipContainer.x, this.tooltipContainer.y + 188.5, 150, 32); + + const textMask = tooltipTextMaskRect.createGeometryMask(); + tooltipTextObject.setMask(textMask); + + const tooltipLineCount = Math.floor(tooltipTextObject.displayHeight / 11.2); + + if (this.tooltipScrollTween) { + this.tooltipScrollTween.remove(); + this.tooltipScrollTween = undefined; + } + + // Animates the tooltip text moving upwards + if (tooltipLineCount > 3) { + this.tooltipScrollTween = this.scene.tweens.add({ + targets: tooltipTextObject, + delay: Utils.fixedInt(1200), + loop: -1, + hold: Utils.fixedInt(1200), + duration: Utils.fixedInt((tooltipLineCount - 3) * 1200), + y: `-=${11.2 * (tooltipLineCount - 3)}` + }); + } + } + + // Dex progress indicator + if (cursorOption.hasDexProgress && !this.showDexProgress) { + this.showHideDexProgress(true); + } else if (!cursorOption.hasDexProgress) { + this.showHideDexProgress(false); + } + } + + override clear(): void { + super.clear(); + this.overrideSettings = undefined; + this.optionsContainer.setVisible(false); + this.optionsContainer.removeAll(true); + this.dexProgressContainer.setVisible(false); + this.descriptionContainer.setVisible(false); + this.tooltipContainer.setVisible(false); + // Keeps container background and pokeball + this.descriptionContainer.removeBetween(2, this.descriptionContainer.length, true); + this.getUi().getMessageHandler().clearText(); + this.eraseCursor(); + } + + private eraseCursor(): void { + if (this.cursorObj) { + this.cursorObj.destroy(); + } + this.cursorObj = undefined; + } + + /** + * Will show or hide the Dex progress icon for an option that has dex progress + * @param show - if true does show, if false does hide + */ + private showHideDexProgress(show: boolean) { + if (show && !this.showDexProgress) { + this.showDexProgress = true; + this.scene.tweens.killTweensOf(this.dexProgressContainer); + this.scene.tweens.add({ + targets: this.dexProgressContainer, + y: -63, + ease: "Sine.easeInOut", + duration: 750, + onComplete: () => { + this.dexProgressContainer.on("pointerover", () => { + (this.scene as BattleScene).ui.showTooltip("", i18next.t("mysteryEncounterMessages:affects_pokedex"), true); + }); + this.dexProgressContainer.on("pointerout", () => { + (this.scene as BattleScene).ui.hideTooltip(); + }); + } + }); + } else if (!show && this.showDexProgress) { + this.showDexProgress = false; + this.scene.tweens.killTweensOf(this.dexProgressContainer); + this.scene.tweens.add({ + targets: this.dexProgressContainer, + y: -43, + ease: "Sine.easeInOut", + duration: 750, + onComplete: () => { + this.dexProgressContainer.off("pointerover"); + this.dexProgressContainer.off("pointerout"); + } + }); + } + } +} diff --git a/src/ui/party-ui-handler.ts b/src/ui/party-ui-handler.ts index 270163a3d3ec..aafcdc9bb34a 100644 --- a/src/ui/party-ui-handler.ts +++ b/src/ui/party-ui-handler.ts @@ -90,7 +90,12 @@ export enum PartyUiMode { * Indicates that the party UI is open to check the team. This * type of selection can be cancelled. */ - CHECK + CHECK, + /** + * Indicates that the party UI is open to select a party member for an arbitrary effect. + * This is generally used in for Mystery Encounter or special effects that require the player to select a Pokemon + */ + SELECT } export enum PartyOption { @@ -107,6 +112,7 @@ export enum PartyOption { UNSPLICE, RELEASE, RENAME, + SELECT, SCROLL_UP = 1000, SCROLL_DOWN = 1001, FORM_CHANGE_ITEM = 2000, @@ -210,7 +216,7 @@ export default class PartyUiHandler extends MessageUiHandler { public static NoEffectMessage = i18next.t("partyUiHandler:anyEffect"); - private localizedOptions = [PartyOption.SEND_OUT, PartyOption.SUMMARY, PartyOption.CANCEL, PartyOption.APPLY, PartyOption.RELEASE, PartyOption.TEACH, PartyOption.SPLICE, PartyOption.UNSPLICE, PartyOption.REVIVE, PartyOption.TRANSFER, PartyOption.UNPAUSE_EVOLUTION, PartyOption.PASS_BATON, PartyOption.RENAME]; + private localizedOptions = [PartyOption.SEND_OUT, PartyOption.SUMMARY, PartyOption.CANCEL, PartyOption.APPLY, PartyOption.RELEASE, PartyOption.TEACH, PartyOption.SPLICE, PartyOption.UNSPLICE, PartyOption.REVIVE, PartyOption.TRANSFER, PartyOption.UNPAUSE_EVOLUTION, PartyOption.PASS_BATON, PartyOption.RENAME, PartyOption.SELECT]; constructor(scene: BattleScene) { super(scene, Mode.PARTY); @@ -523,6 +529,9 @@ export default class PartyUiHandler extends MessageUiHandler { return true; } else if (option === PartyOption.CANCEL) { return this.processInput(Button.CANCEL); + } else if (option === PartyOption.SELECT) { + ui.playSelect(); + return true; } } else if (button === Button.CANCEL) { this.clearOptions(); @@ -872,6 +881,9 @@ export default class PartyUiHandler extends MessageUiHandler { } } break; + case PartyUiMode.SELECT: + this.options.push(PartyOption.SELECT); + break; } this.options.push(PartyOption.SUMMARY); diff --git a/src/ui/text.ts b/src/ui/text.ts index 99a0436bba30..58b6343144ac 100644 --- a/src/ui/text.ts +++ b/src/ui/text.ts @@ -226,6 +226,34 @@ export function getBBCodeFrag(content: string, textStyle: TextStyle, uiTheme: Ui return `[color=${getTextColor(textStyle, false, uiTheme)}][shadow=${getTextColor(textStyle, true, uiTheme)}]${content}`; } +/** + * Should only be used with BBCodeText (see {@linkcode addBBCodeTextObject()}) + * This does NOT work with UI showText() or showDialogue() methods. + * Method will do pattern match/replace and apply BBCode color/shadow styling to substrings within the content: + * @[]{} + * + * Example: passing a content string of "@[SUMMARY_BLUE]{blue text} primaryStyle text @[SUMMARY_RED]{red text}" will result in: + * - "blue text" with TextStyle.SUMMARY_BLUE applied + * - " primaryStyle text " with primaryStyle TextStyle applied + * - "red text" with TextStyle.SUMMARY_RED applied + * @param content string with styling that need to be applied for BBCodeTextObject + * @param primaryStyle Primary style is required in order to escape BBCode styling properly. + * @param uiTheme + */ +export function getTextWithColors(content: string, primaryStyle: TextStyle, uiTheme: UiTheme = UiTheme.DEFAULT): string { + // Apply primary styling before anything else + let text = getBBCodeFrag(content, primaryStyle, uiTheme) + "[/color][/shadow]"; + const primaryStyleString = [...text.match(new RegExp(/\[color=[^\[]*\]\[shadow=[^\[]*\]/i))!][0]; + + // Set custom colors + text = text.replace(/@\[([^{]*)\]{([^}]*)}/gi, (substring, textStyle: string, textToColor: string) => { + return "[/color][/shadow]" + getBBCodeFrag(textToColor, TextStyle[textStyle], uiTheme) + "[/color][/shadow]" + primaryStyleString; + }); + + // Remove extra style block at the end + return text.replace(/\[color=[^\[]*\]\[shadow=[^\[]*\]\[\/color\]\[\/shadow\]/gi, ""); +} + export function getTextColor(textStyle: TextStyle, shadow?: boolean, uiTheme: UiTheme = UiTheme.DEFAULT): string { const isLegacyTheme = uiTheme === UiTheme.LEGACY; switch (textStyle) { diff --git a/src/ui/ui.ts b/src/ui/ui.ts index 82b3ee6b4fa5..0f4fa52e41e6 100644 --- a/src/ui/ui.ts +++ b/src/ui/ui.ts @@ -53,6 +53,7 @@ import EggSummaryUiHandler from "./egg-summary-ui-handler"; import TestDialogueUiHandler from "#app/ui/test-dialogue-ui-handler"; import AutoCompleteUiHandler from "./autocomplete-ui-handler"; import { Device } from "#enums/devices"; +import MysteryEncounterUiHandler from "./mystery-encounter-ui-handler"; export enum Mode { MESSAGE, @@ -97,6 +98,7 @@ export enum Mode { TEST_DIALOGUE, AUTO_COMPLETE, ADMIN, + MYSTERY_ENCOUNTER } const transitionModes = [ @@ -137,6 +139,7 @@ const noTransitionModes = [ Mode.TEST_DIALOGUE, Mode.AUTO_COMPLETE, Mode.ADMIN, + Mode.MYSTERY_ENCOUNTER ]; export default class UI extends Phaser.GameObjects.Container { @@ -204,6 +207,7 @@ export default class UI extends Phaser.GameObjects.Container { new TestDialogueUiHandler(scene, Mode.TEST_DIALOGUE), new AutoCompleteUiHandler(scene), new AdminUiHandler(scene), + new MysteryEncounterUiHandler(scene), ]; } diff --git a/src/utils.ts b/src/utils.ts index 7decf9bb4c0b..a8206bf4dcfb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -587,6 +587,14 @@ export function isNullOrUndefined(object: any): boolean { return null === object || undefined === object; } +/** + * Capitalizes the first letter of a string + * @param str + */ +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.