diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b46cde9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + pull_request: + push: + branches: ['**'] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + name: 🔍 Checks + runs-on: ubuntu-latest + steps: + - name: 📥 Checkout + uses: actions/checkout@v4 + + - name: 🥟 Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: 📦 Install dependencies + run: bun install --frozen-lockfile + + - name: 🎨 Check formatting + run: bun run format:check + + - name: 🧹 Lint + run: bun run lint + + - name: 🔤 Typecheck + run: bun run build:types + + - name: 🧪 Test + run: bun run test + + - name: 🏗️ Build + run: bun run build:vite diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6af1d86..346af70 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,35 +16,42 @@ concurrency: jobs: build: + name: 🏗️ Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: 📥 Checkout + uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 + - name: 🥟 Setup Bun + uses: oven-sh/setup-bun@v2 with: bun-version: latest - - name: Install + - name: 📦 Install dependencies run: bun install --frozen-lockfile - - name: Test + - name: 🧪 Test run: bun run test - - name: Build + - name: 🏗️ Build run: bun run build - - uses: actions/configure-pages@v5 + - name: ⚙️ Configure Pages + uses: actions/configure-pages@v5 - - uses: actions/upload-pages-artifact@v3 + - name: 📤 Upload Pages artifact + uses: actions/upload-pages-artifact@v3 with: path: dist deploy: + name: 🚀 Deploy needs: build runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - - id: deployment + - name: 🚀 Deploy to GitHub Pages + id: deployment uses: actions/deploy-pages@v4 diff --git a/public/sprites/anim/baxcalibur-mega.gif b/public/sprites/anim/baxcalibur-mega.gif new file mode 100644 index 0000000..8aa1ff4 Binary files /dev/null and b/public/sprites/anim/baxcalibur-mega.gif differ diff --git a/public/sprites/anim/darkrai-mega.gif b/public/sprites/anim/darkrai-mega.gif new file mode 100644 index 0000000..ceb0028 Binary files /dev/null and b/public/sprites/anim/darkrai-mega.gif differ diff --git a/public/sprites/anim/fezandipiti.gif b/public/sprites/anim/fezandipiti.gif new file mode 100644 index 0000000..7ce9b53 Binary files /dev/null and b/public/sprites/anim/fezandipiti.gif differ diff --git a/public/sprites/anim/golisopod-mega.gif b/public/sprites/anim/golisopod-mega.gif new file mode 100644 index 0000000..d10619f Binary files /dev/null and b/public/sprites/anim/golisopod-mega.gif differ diff --git a/public/sprites/anim/heatran-mega.gif b/public/sprites/anim/heatran-mega.gif new file mode 100644 index 0000000..f5afd26 Binary files /dev/null and b/public/sprites/anim/heatran-mega.gif differ diff --git a/public/sprites/anim/iron-boulder.gif b/public/sprites/anim/iron-boulder.gif new file mode 100644 index 0000000..a8550e7 Binary files /dev/null and b/public/sprites/anim/iron-boulder.gif differ diff --git a/public/sprites/anim/iron-crown.gif b/public/sprites/anim/iron-crown.gif new file mode 100644 index 0000000..3157446 Binary files /dev/null and b/public/sprites/anim/iron-crown.gif differ diff --git a/public/sprites/anim/iron-jugulis.gif b/public/sprites/anim/iron-jugulis.gif new file mode 100644 index 0000000..32127ce Binary files /dev/null and b/public/sprites/anim/iron-jugulis.gif differ diff --git a/public/sprites/anim/iron-leaves.gif b/public/sprites/anim/iron-leaves.gif new file mode 100644 index 0000000..2cc62fc Binary files /dev/null and b/public/sprites/anim/iron-leaves.gif differ diff --git a/public/sprites/anim/iron-treads.gif b/public/sprites/anim/iron-treads.gif new file mode 100644 index 0000000..3fc02e2 Binary files /dev/null and b/public/sprites/anim/iron-treads.gif differ diff --git a/public/sprites/anim/magearna-mega.gif b/public/sprites/anim/magearna-mega.gif new file mode 100644 index 0000000..04a32c7 Binary files /dev/null and b/public/sprites/anim/magearna-mega.gif differ diff --git a/public/sprites/anim/maushold-family-of-three.gif b/public/sprites/anim/maushold-family-of-three.gif new file mode 100644 index 0000000..4cb01c4 Binary files /dev/null and b/public/sprites/anim/maushold-family-of-three.gif differ diff --git a/public/sprites/anim/meowstic-male-mega.gif b/public/sprites/anim/meowstic-male-mega.gif new file mode 100644 index 0000000..b8abd9b Binary files /dev/null and b/public/sprites/anim/meowstic-male-mega.gif differ diff --git a/public/sprites/anim/munkidori.gif b/public/sprites/anim/munkidori.gif new file mode 100644 index 0000000..5b9acb9 Binary files /dev/null and b/public/sprites/anim/munkidori.gif differ diff --git a/public/sprites/anim/ogerpon.gif b/public/sprites/anim/ogerpon.gif new file mode 100644 index 0000000..7d4fc98 Binary files /dev/null and b/public/sprites/anim/ogerpon.gif differ diff --git a/public/sprites/anim/okidogi.gif b/public/sprites/anim/okidogi.gif new file mode 100644 index 0000000..05cfb36 Binary files /dev/null and b/public/sprites/anim/okidogi.gif differ diff --git a/public/sprites/anim/pecharunt.gif b/public/sprites/anim/pecharunt.gif new file mode 100644 index 0000000..b8e8996 Binary files /dev/null and b/public/sprites/anim/pecharunt.gif differ diff --git a/public/sprites/anim/zeraora-mega.gif b/public/sprites/anim/zeraora-mega.gif new file mode 100644 index 0000000..6be39ec Binary files /dev/null and b/public/sprites/anim/zeraora-mega.gif differ diff --git a/public/sprites/anim/zygarde-mega.gif b/public/sprites/anim/zygarde-mega.gif new file mode 100644 index 0000000..7a15210 Binary files /dev/null and b/public/sprites/anim/zygarde-mega.gif differ diff --git a/public/sprites/trainers/aaron.png b/public/sprites/trainers/aaron.png new file mode 100644 index 0000000..f32e6da Binary files /dev/null and b/public/sprites/trainers/aaron.png differ diff --git a/public/sprites/trainers/alder.png b/public/sprites/trainers/alder.png new file mode 100644 index 0000000..4286a8d Binary files /dev/null and b/public/sprites/trainers/alder.png differ diff --git a/public/sprites/trainers/barry.png b/public/sprites/trainers/barry.png new file mode 100644 index 0000000..2927b33 Binary files /dev/null and b/public/sprites/trainers/barry.png differ diff --git a/public/sprites/trainers/bertha.png b/public/sprites/trainers/bertha.png new file mode 100644 index 0000000..93fe426 Binary files /dev/null and b/public/sprites/trainers/bertha.png differ diff --git a/public/sprites/trainers/bianca.png b/public/sprites/trainers/bianca.png new file mode 100644 index 0000000..2176544 Binary files /dev/null and b/public/sprites/trainers/bianca.png differ diff --git a/public/sprites/trainers/blaine.png b/public/sprites/trainers/blaine.png new file mode 100644 index 0000000..8ff9b1c Binary files /dev/null and b/public/sprites/trainers/blaine.png differ diff --git a/public/sprites/trainers/blue.png b/public/sprites/trainers/blue.png new file mode 100644 index 0000000..892e260 Binary files /dev/null and b/public/sprites/trainers/blue.png differ diff --git a/public/sprites/trainers/brawly.png b/public/sprites/trainers/brawly.png new file mode 100644 index 0000000..6cc060c Binary files /dev/null and b/public/sprites/trainers/brawly.png differ diff --git a/public/sprites/trainers/brock.png b/public/sprites/trainers/brock.png new file mode 100644 index 0000000..cf7c702 Binary files /dev/null and b/public/sprites/trainers/brock.png differ diff --git a/public/sprites/trainers/bruno.png b/public/sprites/trainers/bruno.png new file mode 100644 index 0000000..5c33e7e Binary files /dev/null and b/public/sprites/trainers/bruno.png differ diff --git a/public/sprites/trainers/brycen.png b/public/sprites/trainers/brycen.png new file mode 100644 index 0000000..9f11fd9 Binary files /dev/null and b/public/sprites/trainers/brycen.png differ diff --git a/public/sprites/trainers/bugsy.png b/public/sprites/trainers/bugsy.png new file mode 100644 index 0000000..b8a23eb Binary files /dev/null and b/public/sprites/trainers/bugsy.png differ diff --git a/public/sprites/trainers/burgh.png b/public/sprites/trainers/burgh.png new file mode 100644 index 0000000..56a1b09 Binary files /dev/null and b/public/sprites/trainers/burgh.png differ diff --git a/public/sprites/trainers/byron.png b/public/sprites/trainers/byron.png new file mode 100644 index 0000000..f1d7745 Binary files /dev/null and b/public/sprites/trainers/byron.png differ diff --git a/public/sprites/trainers/caitlin.png b/public/sprites/trainers/caitlin.png new file mode 100644 index 0000000..5095a65 Binary files /dev/null and b/public/sprites/trainers/caitlin.png differ diff --git a/public/sprites/trainers/candice.png b/public/sprites/trainers/candice.png new file mode 100644 index 0000000..21797ec Binary files /dev/null and b/public/sprites/trainers/candice.png differ diff --git a/public/sprites/trainers/cheren.png b/public/sprites/trainers/cheren.png new file mode 100644 index 0000000..ab149bc Binary files /dev/null and b/public/sprites/trainers/cheren.png differ diff --git a/public/sprites/trainers/chuck.png b/public/sprites/trainers/chuck.png new file mode 100644 index 0000000..4e173ca Binary files /dev/null and b/public/sprites/trainers/chuck.png differ diff --git a/public/sprites/trainers/cilan.png b/public/sprites/trainers/cilan.png new file mode 100644 index 0000000..6517b29 Binary files /dev/null and b/public/sprites/trainers/cilan.png differ diff --git a/public/sprites/trainers/clair.png b/public/sprites/trainers/clair.png new file mode 100644 index 0000000..51531d6 Binary files /dev/null and b/public/sprites/trainers/clair.png differ diff --git a/public/sprites/trainers/clay.png b/public/sprites/trainers/clay.png new file mode 100644 index 0000000..c55a936 Binary files /dev/null and b/public/sprites/trainers/clay.png differ diff --git a/public/sprites/trainers/crasherwake.png b/public/sprites/trainers/crasherwake.png new file mode 100644 index 0000000..fc13ce2 Binary files /dev/null and b/public/sprites/trainers/crasherwake.png differ diff --git a/public/sprites/trainers/cynthia.png b/public/sprites/trainers/cynthia.png new file mode 100644 index 0000000..aaed83d Binary files /dev/null and b/public/sprites/trainers/cynthia.png differ diff --git a/public/sprites/trainers/drayden.png b/public/sprites/trainers/drayden.png new file mode 100644 index 0000000..0fd3f26 Binary files /dev/null and b/public/sprites/trainers/drayden.png differ diff --git a/public/sprites/trainers/elesa.png b/public/sprites/trainers/elesa.png new file mode 100644 index 0000000..6a4c9b5 Binary files /dev/null and b/public/sprites/trainers/elesa.png differ diff --git a/public/sprites/trainers/erika.png b/public/sprites/trainers/erika.png new file mode 100644 index 0000000..5a579b5 Binary files /dev/null and b/public/sprites/trainers/erika.png differ diff --git a/public/sprites/trainers/falkner.png b/public/sprites/trainers/falkner.png new file mode 100644 index 0000000..938ae54 Binary files /dev/null and b/public/sprites/trainers/falkner.png differ diff --git a/public/sprites/trainers/fantina.png b/public/sprites/trainers/fantina.png new file mode 100644 index 0000000..35c26ba Binary files /dev/null and b/public/sprites/trainers/fantina.png differ diff --git a/public/sprites/trainers/flannery.png b/public/sprites/trainers/flannery.png new file mode 100644 index 0000000..1d33188 Binary files /dev/null and b/public/sprites/trainers/flannery.png differ diff --git a/public/sprites/trainers/flint.png b/public/sprites/trainers/flint.png new file mode 100644 index 0000000..48dc8c1 Binary files /dev/null and b/public/sprites/trainers/flint.png differ diff --git a/public/sprites/trainers/gardenia.png b/public/sprites/trainers/gardenia.png new file mode 100644 index 0000000..ef3e403 Binary files /dev/null and b/public/sprites/trainers/gardenia.png differ diff --git a/public/sprites/trainers/giovanni.png b/public/sprites/trainers/giovanni.png new file mode 100644 index 0000000..b3369e7 Binary files /dev/null and b/public/sprites/trainers/giovanni.png differ diff --git a/public/sprites/trainers/grimsley.png b/public/sprites/trainers/grimsley.png new file mode 100644 index 0000000..5582438 Binary files /dev/null and b/public/sprites/trainers/grimsley.png differ diff --git a/public/sprites/trainers/jasmine.png b/public/sprites/trainers/jasmine.png new file mode 100644 index 0000000..1028f2b Binary files /dev/null and b/public/sprites/trainers/jasmine.png differ diff --git a/public/sprites/trainers/karen.png b/public/sprites/trainers/karen.png new file mode 100644 index 0000000..9117ceb Binary files /dev/null and b/public/sprites/trainers/karen.png differ diff --git a/public/sprites/trainers/koga.png b/public/sprites/trainers/koga.png new file mode 100644 index 0000000..e2b48b8 Binary files /dev/null and b/public/sprites/trainers/koga.png differ diff --git a/public/sprites/trainers/lance.png b/public/sprites/trainers/lance.png new file mode 100644 index 0000000..7dafaa5 Binary files /dev/null and b/public/sprites/trainers/lance.png differ diff --git a/public/sprites/trainers/lenora.png b/public/sprites/trainers/lenora.png new file mode 100644 index 0000000..8a0cf4c Binary files /dev/null and b/public/sprites/trainers/lenora.png differ diff --git a/public/sprites/trainers/ltsurge.png b/public/sprites/trainers/ltsurge.png new file mode 100644 index 0000000..ee038a6 Binary files /dev/null and b/public/sprites/trainers/ltsurge.png differ diff --git a/public/sprites/trainers/lucian.png b/public/sprites/trainers/lucian.png new file mode 100644 index 0000000..973ecaf Binary files /dev/null and b/public/sprites/trainers/lucian.png differ diff --git a/public/sprites/trainers/marshal.png b/public/sprites/trainers/marshal.png new file mode 100644 index 0000000..18fbaa9 Binary files /dev/null and b/public/sprites/trainers/marshal.png differ diff --git a/public/sprites/trainers/maylene.png b/public/sprites/trainers/maylene.png new file mode 100644 index 0000000..874cf5d Binary files /dev/null and b/public/sprites/trainers/maylene.png differ diff --git a/public/sprites/trainers/misty.png b/public/sprites/trainers/misty.png new file mode 100644 index 0000000..075c652 Binary files /dev/null and b/public/sprites/trainers/misty.png differ diff --git a/public/sprites/trainers/morty.png b/public/sprites/trainers/morty.png new file mode 100644 index 0000000..25130eb Binary files /dev/null and b/public/sprites/trainers/morty.png differ diff --git a/public/sprites/trainers/n.png b/public/sprites/trainers/n.png new file mode 100644 index 0000000..310b77a Binary files /dev/null and b/public/sprites/trainers/n.png differ diff --git a/public/sprites/trainers/norman.png b/public/sprites/trainers/norman.png new file mode 100644 index 0000000..b75f293 Binary files /dev/null and b/public/sprites/trainers/norman.png differ diff --git a/public/sprites/trainers/pryce.png b/public/sprites/trainers/pryce.png new file mode 100644 index 0000000..592773d Binary files /dev/null and b/public/sprites/trainers/pryce.png differ diff --git a/public/sprites/trainers/roark.png b/public/sprites/trainers/roark.png new file mode 100644 index 0000000..6828017 Binary files /dev/null and b/public/sprites/trainers/roark.png differ diff --git a/public/sprites/trainers/roxanne.png b/public/sprites/trainers/roxanne.png new file mode 100644 index 0000000..454dd0e Binary files /dev/null and b/public/sprites/trainers/roxanne.png differ diff --git a/public/sprites/trainers/sabrina.png b/public/sprites/trainers/sabrina.png new file mode 100644 index 0000000..2767bf6 Binary files /dev/null and b/public/sprites/trainers/sabrina.png differ diff --git a/public/sprites/trainers/shauntal.png b/public/sprites/trainers/shauntal.png new file mode 100644 index 0000000..39791b7 Binary files /dev/null and b/public/sprites/trainers/shauntal.png differ diff --git a/public/sprites/trainers/silver.png b/public/sprites/trainers/silver.png new file mode 100644 index 0000000..0122f67 Binary files /dev/null and b/public/sprites/trainers/silver.png differ diff --git a/public/sprites/trainers/skyla.png b/public/sprites/trainers/skyla.png new file mode 100644 index 0000000..69b3df3 Binary files /dev/null and b/public/sprites/trainers/skyla.png differ diff --git a/public/sprites/trainers/steven.png b/public/sprites/trainers/steven.png new file mode 100644 index 0000000..ffa28b7 Binary files /dev/null and b/public/sprites/trainers/steven.png differ diff --git a/public/sprites/trainers/tateliza.gif b/public/sprites/trainers/tateliza.gif new file mode 100644 index 0000000..0d9ee5b Binary files /dev/null and b/public/sprites/trainers/tateliza.gif differ diff --git a/public/sprites/trainers/volkner.png b/public/sprites/trainers/volkner.png new file mode 100644 index 0000000..49224f1 Binary files /dev/null and b/public/sprites/trainers/volkner.png differ diff --git a/public/sprites/trainers/wallace.png b/public/sprites/trainers/wallace.png new file mode 100644 index 0000000..5328196 Binary files /dev/null and b/public/sprites/trainers/wallace.png differ diff --git a/public/sprites/trainers/wattson.png b/public/sprites/trainers/wattson.png new file mode 100644 index 0000000..120bade Binary files /dev/null and b/public/sprites/trainers/wattson.png differ diff --git a/public/sprites/trainers/whitney.png b/public/sprites/trainers/whitney.png new file mode 100644 index 0000000..da66fd7 Binary files /dev/null and b/public/sprites/trainers/whitney.png differ diff --git a/public/sprites/trainers/will.png b/public/sprites/trainers/will.png new file mode 100644 index 0000000..f030c51 Binary files /dev/null and b/public/sprites/trainers/will.png differ diff --git a/public/sprites/trainers/winona.png b/public/sprites/trainers/winona.png new file mode 100644 index 0000000..1d7d2ab Binary files /dev/null and b/public/sprites/trainers/winona.png differ diff --git a/src/__tests__/CompetitiveBuild.test.tsx b/src/__tests__/CompetitiveBuild.test.tsx new file mode 100644 index 0000000..470ec5b --- /dev/null +++ b/src/__tests__/CompetitiveBuild.test.tsx @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import CompetitiveBuild from '@/components/CompetitiveBuild'; +import type { ResolvedBuild } from '@/competitive'; +import type { GameId } from '@/trainers'; + +const GAMES: GameId[] = ['firered-leafgreen', 'scarlet-violet']; + +const BUILD: ResolvedBuild = { + pokemonKey: 'Charizard', + tier: 'ou', + buildName: 'Mixed Attacker', + sourceGen: 3, + set: { + moves: ['Flamethrower', ['Fire Blast', 'Overheat']], + item: 'Choice Band', + nature: 'Naive', + evs: { atk: 252, spa: 4, spe: 252 }, + }, +}; + +function renderBuild(overrides: Partial[0]> = {}) { + const onSelectGame = vi.fn(); + render( + , + ); + return onSelectGame; +} + +describe('CompetitiveBuild', () => { + it('renders the set with tier, build name, and gen-accurate source', () => { + renderBuild(); + expect(screen.getByText('[OU]')).toBeInTheDocument(); + expect(screen.getByText('Mixed Attacker')).toBeInTheDocument(); + expect(screen.getByText(/smogon\.com\/dex\/rs/)).toBeInTheDocument(); // gen 3 → rs + }); + + it('offers LATEST plus every game the Pokémon appears in', () => { + renderBuild(); + const select = screen.getByLabelText(/game for competitive build/i); + const options = [...select.querySelectorAll('option')].map((o) => o.textContent); + expect(options[0]).toMatch(/latest/i); + expect(options).toContain('FIRERED / LEAFGREEN'); + expect(options).toContain('SCARLET / VIOLET'); + }); + + it('reports a picked game and switching back to LATEST', async () => { + const onSelectGame = renderBuild(); + const user = userEvent.setup(); + const select = screen.getByLabelText(/game for competitive build/i); + await user.selectOptions(select, 'firered-leafgreen'); + expect(onSelectGame).toHaveBeenCalledWith('firered-leafgreen'); + }); + + it('shows a game-specific empty state when a pinned gen has no set', () => { + renderBuild({ build: null, selectedGame: 'firered-leafgreen' }); + expect( + screen.getByText(/no Smogon set for this entry in FireRed \/ LeafGreen/), + ).toBeInTheDocument(); + // the selector stays available so the user can escape the empty state + expect(screen.getByLabelText(/game for competitive build/i)).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/TrainerCard.test.tsx b/src/__tests__/TrainerCard.test.tsx new file mode 100644 index 0000000..dcb6c53 --- /dev/null +++ b/src/__tests__/TrainerCard.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import TrainerCard from '@/components/TrainerCard'; +import type { Trainer } from '@/trainers'; + +const BROCK: Trainer = { + id: 'rb-brock', + name: 'Brock', + trainerClass: 'Gym Leader', + game: 'red-blue', + location: 'Pewter City Gym', + team: [ + { species: 'geodude', level: 12 }, + { species: 'onix', level: 14 }, + ], +}; + +describe('TrainerCard', () => { + it('renders name, class, game, location, and the roster', () => { + render( + {}} + onSelectPokemon={() => {}} + speciesIndex={[]} + />, + ); + expect(screen.getByText('BROCK')).toBeInTheDocument(); + expect(screen.getByText('GYM LEADER')).toBeInTheDocument(); + expect(screen.getByText('Red / Blue')).toBeInTheDocument(); + expect(screen.getByText('Pewter City Gym')).toBeInTheDocument(); + expect(screen.getByText('Lv 14')).toBeInTheDocument(); + }); + + it('clicking a roster member reports its species slug', async () => { + const onSelectPokemon = vi.fn(); + render( + {}} + onSelectPokemon={onSelectPokemon} + speciesIndex={[]} + />, + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /onix/i })); + expect(onSelectPokemon).toHaveBeenCalledWith('onix'); + }); + + it('back button fires onBack', async () => { + const onBack = vi.fn(); + render( + {}} speciesIndex={[]} />, + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /back/i })); + expect(onBack).toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/TrainerGrid.test.tsx b/src/__tests__/TrainerGrid.test.tsx new file mode 100644 index 0000000..7308c98 --- /dev/null +++ b/src/__tests__/TrainerGrid.test.tsx @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import TrainerGrid from '@/components/TrainerGrid'; +import type { Trainer } from '@/trainers'; + +const TRAINERS: Trainer[] = [ + { + id: 'rb-brock', + name: 'Brock', + trainerClass: 'Gym Leader', + game: 'red-blue', + team: [{ species: 'onix', level: 14 }], + }, + { + id: 'frlg-misty', + name: 'Misty', + trainerClass: 'Gym Leader', + game: 'firered-leafgreen', + team: [{ species: 'starmie', level: 21 }], + }, + { + id: 'gs-falkner', + name: 'Falkner', + trainerClass: 'Gym Leader', + game: 'gold-silver', + team: [{ species: 'pidgeotto', level: 9 }], + }, +]; + +function regionHeadings(): string[] { + return screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent ?? ''); +} + +describe('TrainerGrid', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('groups trainers under regions, expanded by default', () => { + render( {}} />); + const headings = regionHeadings(); + expect(headings[0]).toContain('KANTO'); + expect(headings[1]).toContain('JOHTO'); + expect(screen.getByText('Brock')).toBeInTheDocument(); + expect(screen.getByText('Falkner')).toBeInTheDocument(); + }); + + it('orders games within a region by release date', () => { + render( {}} />); + const cards = screen.getAllByRole('button', { name: /gym leader/i }); + const names = cards.map((c) => c.textContent); + // Red/Blue (1996) before FireRed/LeafGreen (2004) inside Kanto + expect(names.findIndex((n) => n?.includes('Brock'))).toBeLessThan( + names.findIndex((n) => n?.includes('Misty')), + ); + }); + + it('collapse all hides the cards; a region toggle brings its own back', async () => { + render( {}} />); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /collapse all/i })); + expect(screen.queryByText('Brock')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /^[▼▶]KANTO$/ })); + expect(screen.getByText('Brock')).toBeInTheDocument(); + expect(screen.queryByText('Falkner')).not.toBeInTheDocument(); + }); + + it('an active filter auto-expands collapsed regions', async () => { + render( {}} />); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /collapse all/i })); + await user.type(screen.getByLabelText(/search trainer name/i), 'falkner'); + expect(screen.getByText('Falkner')).toBeInTheDocument(); + expect(screen.queryByText('Brock')).not.toBeInTheDocument(); + }); + + it('clicking a trainer card reports the trainer', async () => { + const onSelect = vi.fn(); + render(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /brock/i })); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'rb-brock' })); + }); +}); diff --git a/src/__tests__/useExpandedRegions.test.ts b/src/__tests__/useExpandedRegions.test.ts new file mode 100644 index 0000000..4de855e --- /dev/null +++ b/src/__tests__/useExpandedRegions.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useExpandedRegions } from '@/hooks/useExpandedRegions'; + +const KEY = 'test.regions'; + +describe('useExpandedRegions', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('starts all-collapsed with no default', () => { + const { result } = renderHook(() => useExpandedRegions(KEY)); + expect(result.current.expanded.size).toBe(0); + }); + + it('honours defaultExpanded when nothing is stored', () => { + const { result } = renderHook(() => useExpandedRegions(KEY, ['Kanto', 'Johto'])); + expect([...result.current.expanded]).toEqual(['Kanto', 'Johto']); + }); + + it('does not write to localStorage until the user toggles', () => { + renderHook(() => useExpandedRegions(KEY, ['Kanto'])); + expect(window.localStorage.getItem(KEY)).toBeNull(); + }); + + it('toggle persists and a stored value beats the default', () => { + const { result, unmount } = renderHook(() => useExpandedRegions(KEY)); + act(() => result.current.toggle('Hoenn')); + expect(window.localStorage.getItem(KEY)).toBe('["Hoenn"]'); + unmount(); + const { result: again } = renderHook(() => useExpandedRegions(KEY, ['Kanto'])); + expect([...again.current.expanded]).toEqual(['Hoenn']); + }); + + it('expandAll and collapseAll replace the whole set', () => { + const { result } = renderHook(() => useExpandedRegions(KEY)); + act(() => result.current.expandAll(['Kanto', 'Johto'])); + expect(result.current.expanded.size).toBe(2); + act(() => result.current.collapseAll()); + expect(result.current.expanded.size).toBe(0); + expect(window.localStorage.getItem(KEY)).toBe('[]'); + }); + + it('falls back to the default on a corrupted stored value', () => { + window.localStorage.setItem(KEY, 'not json'); + const { result } = renderHook(() => useExpandedRegions(KEY, ['Kanto'])); + expect([...result.current.expanded]).toEqual(['Kanto']); + }); +}); diff --git a/src/components/MoveList.tsx b/src/components/MoveList.tsx index 0bd5d28..19a8127 100644 --- a/src/components/MoveList.tsx +++ b/src/components/MoveList.tsx @@ -44,7 +44,7 @@ export default function MoveList({ moves, versionGroup }: Props) { : '· '; return (
  • - {prefix} + {prefix}
  • ); diff --git a/src/components/PokemonCard.tsx b/src/components/PokemonCard.tsx index fa94a26..cb3ceaa 100644 --- a/src/components/PokemonCard.tsx +++ b/src/components/PokemonCard.tsx @@ -24,7 +24,7 @@ import { useCompetitiveSet } from '@/hooks/useCompetitiveSet'; import { GAME_GENS, GAME_ORDER, type GameId } from '@/trainers'; import { TYPE_COLORS, TYPES, type PokeType } from '@/typeChart'; import { varietyFromForm, formFromVariety } from '@/routes'; -import { pokeapiToShowdownSlug } from '@/showdownSprite'; +import { localAnimUrl, pokeapiToShowdownSlug } from '@/showdownSprite'; import { cryOverrideFor } from '@/cryOverrides'; import { playGmaxCryWithEffects } from '@/gmaxAudio'; @@ -104,61 +104,58 @@ interface SpritePick { animated: boolean; } -function pickSprite( - p: PokemonResponse, - shiny: boolean, - view: SpriteView, - fallbackLevel: number, -): SpritePick { +function spriteCandidates(p: PokemonResponse, shiny: boolean, view: SpriteView): SpritePick[] { const sdSlug = pokeapiToShowdownSlug(p.name); + // Locally-shipped 2D GIF (no shiny variants — only offer for regular). + const local = shiny ? null : localAnimUrl(p.name); + const list: SpritePick[] = []; if (view === '2d') { - // 2D shows ONLY frame-animated pixel art. All three tiers below are - // animated GIFs; the parent probes them before rendering and hides the 2D - // toggle entirely if none resolves, so there's no static fallback here. - if (fallbackLevel === 0) { + // 2D shows ONLY frame-animated pixel art; the parent probes and hides the + // 2D toggle entirely if none resolves, so there's no static fallback here. + if (local) list.push({ url: local, animated: true }); + if (p.id <= MAX_BW_ID) { // Gen 1-5 base species: BW animated pixel art — iconic 2D experience. - if (p.id <= MAX_BW_ID) { - const url = shiny ? `${BW_BASE}/shiny/${p.id}.gif` : `${BW_BASE}/${p.id}.gif`; - return { url, animated: true }; - } + list.push({ + url: shiny ? `${BW_BASE}/shiny/${p.id}.gif` : `${BW_BASE}/${p.id}.gif`, + animated: true, + }); + } else { // Gen 6+ official: PokeAPI's Showdown mirror, indexed by id. - const url = shiny ? `${SHOWDOWN_BASE}/shiny/${p.id}.gif` : `${SHOWDOWN_BASE}/${p.id}.gif`; - return { url, animated: true }; - } - if (fallbackLevel === 1) { - // Smogon Sprite Project fan animation — covers most Gen 6-9 forms - // including Gmax / Eternamax that the PokeAPI mirror is missing. - const dir = shiny ? SD_ANI_SHINY : SD_ANI; - return { url: `${dir}/${sdSlug}.gif`, animated: true }; + list.push({ + url: shiny ? `${SHOWDOWN_BASE}/shiny/${p.id}.gif` : `${SHOWDOWN_BASE}/${p.id}.gif`, + animated: true, + }); } + // Smogon Sprite Project fan animation — covers most Gen 6-9 forms + // including Gmax / Eternamax that the PokeAPI mirror is missing. + list.push({ url: `${shiny ? SD_ANI_SHINY : SD_ANI}/${sdSlug}.gif`, animated: true }); // Showdown's older `gen5ani/` set — has fan animations for newer DLC - // additions (Terapagos, Miraidon, Terapagos-Terastal) that the main - // `ani/` directory hasn't picked up yet. - const dir = shiny ? SD_GEN5_ANI_SHINY : SD_GEN5_ANI; - return { url: `${dir}/${sdSlug}.gif`, animated: true }; - } - // 3D mode: always a frame-animated Showdown GIF. For Gmax / Eternamax it's - // a true 3D-render animation; for everything else it's pixel art that - // animates. CSS scales the GIF smoothly (no `image-rendering: pixelated`) - // so the upscale reads as a soft 3D-ish render rather than chunky pixels. - if (fallbackLevel === 0) { - const url = shiny ? `${SHOWDOWN_BASE}/shiny/${p.id}.gif` : `${SHOWDOWN_BASE}/${p.id}.gif`; - return { url, animated: true }; - } - if (fallbackLevel === 1) { - // PokeAPI mirror missing — try Showdown's direct ani as another animated source. - const dir = shiny ? SD_ANI_SHINY : SD_ANI; - return { url: `${dir}/${sdSlug}.gif`, animated: true }; + // additions that the main `ani/` directory hasn't picked up yet. + list.push({ + url: `${shiny ? SD_GEN5_ANI_SHINY : SD_GEN5_ANI}/${sdSlug}.gif`, + animated: true, + }); + return list; } + // 3D mode: a frame-animated Showdown GIF whenever one exists. + list.push({ + url: shiny ? `${SHOWDOWN_BASE}/shiny/${p.id}.gif` : `${SHOWDOWN_BASE}/${p.id}.gif`, + animated: true, + }); + // PokeAPI mirror missing — try Showdown's direct ani as another animated source. + list.push({ url: `${shiny ? SD_ANI_SHINY : SD_ANI}/${sdSlug}.gif`, animated: true }); + // Locally-shipped GIF beats a static image even in 3D mode. + if (local) list.push({ url: local, animated: true }); // Last resort — official artwork. Stays truly static (no CSS bob); should // only ever appear for the handful of forms with no animated source at all. const art = p.sprites.other['official-artwork']; - return { + list.push({ url: shiny ? (art.front_shiny ?? p.sprites.front_shiny ?? p.sprites.front_default ?? '') : (art.front_default ?? p.sprites.front_default ?? ''), animated: false, - }; + }); + return list; } interface Particle { @@ -193,15 +190,19 @@ function CardSprite({ const [reacting, setReacting] = useState(false); const [particles, setParticles] = useState([]); const fallback = fallbacks[view]; + const candidates = spriteCandidates(pokemon, shiny, view); const bumpFallback = () => - setFallbacks((prev) => ({ ...prev, [view]: Math.min(2, prev[view] + 1) })); - const sprite = pickSprite(pokemon, shiny, view, fallback); + setFallbacks((prev) => ({ + ...prev, + [view]: Math.min(candidates.length - 1, prev[view] + 1), + })); + const sprite = candidates[Math.min(fallback, candidates.length - 1)]; - // Reset the per-view fallback ladder whenever the Pokemon changes — the new species - // might have sprites in places the previous one didn't. + // Reset the per-view fallback ladder whenever the Pokemon or shiny state + // changes — the candidate list differs (local GIFs have no shiny variant). useEffect(() => { setFallbacks({ '2d': 0, '3d': 0 }); - }, [pokemon.id]); + }, [pokemon.id, shiny]); const cryUrl = cryOverrideFor(pokemon.name) ?? pokemon.cries?.latest ?? pokemon.cries?.legacy ?? null; @@ -393,6 +394,11 @@ export default function PokemonCard({ setHas2D(true); return; } + // A locally-shipped GIF is a known-good 2D animation — no probing needed. + if (localAnimUrl(pokemon.name)) { + setHas2D(true); + return; + } let cancelled = false; setHas2D(true); const probe = (url: string) => diff --git a/src/components/TeamsBrowser.tsx b/src/components/TeamsBrowser.tsx index a7f061b..afd6094 100644 --- a/src/components/TeamsBrowser.tsx +++ b/src/components/TeamsBrowser.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { TEAM_BUILDS, TEAM_REGIONS, type TeamPick } from '@/teams'; import { GAME_LABELS, type GameId } from '@/trainers'; -import { showdownAnimSpriteUrl, showdownSpriteUrl } from '@/showdownSprite'; +import { localAnimUrl, showdownAnimSpriteUrl, showdownSpriteUrl } from '@/showdownSprite'; import { useExpandedRegions } from '@/hooks/useExpandedRegions'; interface Props { @@ -132,31 +132,11 @@ function GameTeamCard({ ); } -/** - * Species with a locally-built 2D pixel GIF in `public/sprites/anim/` — - * built from PokeRogue / GBA fan spritesheets for PokĂ©mon that Showdown only - * covers with 3D-model-style `ani` GIFs (or, like `iron-bundle`, not at all). - */ -const LOCAL_ANIM_SLUGS = new Set([ - 'arcanine-hisui', - 'cinderace', - 'decidueye', - 'decidueye-hisui', - 'garganacl', - 'iron-bundle', - 'meowscarada', - 'salazzle', - 'sneasler', - 'tinkaton', - 'typhlosion-hisui', -]); - /** Animated-GIF sources in preference order: local 2D → gen5ani → ani (3D-style). */ function animSources(species: string): string[] { const sources: string[] = []; - if (LOCAL_ANIM_SLUGS.has(species)) { - sources.push(`${import.meta.env.BASE_URL}sprites/anim/${species}.gif`); - } + const local = localAnimUrl(species); + if (local) sources.push(local); sources.push(showdownAnimSpriteUrl(species), showdownAnimSpriteUrl(species, 'ani')); return sources; } diff --git a/src/components/TrainerCard.tsx b/src/components/TrainerCard.tsx index c9ca763..f4c71e6 100644 --- a/src/components/TrainerCard.tsx +++ b/src/components/TrainerCard.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react'; -import { GAME_LABELS, type Trainer } from '@/trainers'; +import { GAME_LABELS, trainerPortraitUrl, type Trainer } from '@/trainers'; import { GAME_MAX_GEN, pickCounterTeam } from '@/counters'; import Detail from '@/components/Detail'; import { useTypeIndex } from '@/hooks/useTypeIndex'; @@ -101,8 +101,12 @@ export default function TrainerCard({ trainer, onBack, onSelectPokemon, speciesI
    - {trainer.spriteUrl && ( - {trainer.name} + {trainerPortraitUrl(trainer) && ( + {trainer.name} )}
    {trainer.name.toUpperCase()}
    diff --git a/src/components/TrainerGrid.tsx b/src/components/TrainerGrid.tsx index b52ff21..5bd7da2 100644 --- a/src/components/TrainerGrid.tsx +++ b/src/components/TrainerGrid.tsx @@ -1,6 +1,13 @@ import { useMemo, useState } from 'react'; import TrainerFilters from '@/components/TrainerFilters'; -import { GAME_LABELS, GAME_ORDER, GAMES_BY_REGION, type GameId, type Trainer } from '@/trainers'; +import { + GAME_LABELS, + GAME_ORDER, + GAMES_BY_REGION, + trainerPortraitUrl, + type GameId, + type Trainer, +} from '@/trainers'; import { showdownSpriteUrl } from '@/showdownSprite'; import { useExpandedRegions } from '@/hooks/useExpandedRegions'; @@ -140,10 +147,10 @@ export default function TrainerGrid({ trainers, onSelect }: Props) { type="button" onClick={() => onSelect(t)} > - {t.spriteUrl && ( + {trainerPortraitUrl(t) && ( {t.name} diff --git a/src/showdownSprite.ts b/src/showdownSprite.ts index 50f37f6..8dd67f2 100644 --- a/src/showdownSprite.ts +++ b/src/showdownSprite.ts @@ -68,6 +68,15 @@ const DEFAULT_FORM_SLUGS: Record = { 'basculegion-male': 'basculegion', 'indeedee-male': 'indeedee', 'oinkologne-male': 'oinkologne', + // Ride/travel poses of the box legendaries — cosmetic; use the base sprite. + 'koraidon-limited-build': 'koraidon', + 'koraidon-sprinting-build': 'koraidon', + 'koraidon-swimming-build': 'koraidon', + 'koraidon-gliding-build': 'koraidon', + 'miraidon-low-power-mode': 'miraidon', + 'miraidon-drive-mode': 'miraidon', + 'miraidon-aquatic-mode': 'miraidon', + 'miraidon-glide-mode': 'miraidon', }; /** @@ -125,3 +134,56 @@ export function showdownSpriteUrl(pokeapiSlug: string): string { export function showdownAnimSpriteUrl(pokeapiSlug: string, set: 'gen5ani' | 'ani' = 'gen5ani') { return `https://play.pokemonshowdown.com/sprites/${set}/${pokeapiToShowdownSlug(pokeapiSlug)}.gif`; } + +/** + * Species with a locally-built 2D pixel GIF in `public/sprites/anim/` — + * assembled from PokeRogue / GBA fan spritesheets, plus RetroNC's animated + * gen-5-style sprites for gen 9 DLC legendaries and Legends Z-A megas. + * Values are the GIF basename; forms that share art alias the same file. + */ +const LOCAL_ANIM_FILES: Record = { + 'arcanine-hisui': 'arcanine-hisui', + cinderace: 'cinderace', + decidueye: 'decidueye', + 'decidueye-hisui': 'decidueye-hisui', + garganacl: 'garganacl', + 'iron-bundle': 'iron-bundle', + 'iron-jugulis': 'iron-jugulis', + 'iron-treads': 'iron-treads', + 'maushold-family-of-three': 'maushold-family-of-three', + meowscarada: 'meowscarada', + salazzle: 'salazzle', + sneasler: 'sneasler', + tinkaton: 'tinkaton', + 'typhlosion-hisui': 'typhlosion-hisui', + // Gen 9 DLC legendaries (RetroNC) + ogerpon: 'ogerpon', + 'ogerpon-wellspring-mask': 'ogerpon', + 'ogerpon-hearthflame-mask': 'ogerpon', + 'ogerpon-cornerstone-mask': 'ogerpon', + okidogi: 'okidogi', + munkidori: 'munkidori', + fezandipiti: 'fezandipiti', + pecharunt: 'pecharunt', + 'iron-leaves': 'iron-leaves', + 'iron-boulder': 'iron-boulder', + 'iron-crown': 'iron-crown', + // Legends Z-A megas (RetroNC) + 'zygarde-mega': 'zygarde-mega', + 'heatran-mega': 'heatran-mega', + 'darkrai-mega': 'darkrai-mega', + 'zeraora-mega': 'zeraora-mega', + 'baxcalibur-mega': 'baxcalibur-mega', + 'golisopod-mega': 'golisopod-mega', + 'meowstic-male-mega': 'meowstic-male-mega', + 'meowstic-female-mega': 'meowstic-male-mega', + 'magearna-mega': 'magearna-mega', + 'magearna-original-mega': 'magearna-mega', +}; + +/** URL of the local animated GIF for a slug, or null when we don't ship one. */ +export function localAnimUrl(pokeapiSlug: string): string | null { + const file = LOCAL_ANIM_FILES[pokeapiSlug]; + if (!file) return null; + return `${import.meta.env.BASE_URL}sprites/anim/${file}.gif`; +} diff --git a/src/styles/crt.css b/src/styles/crt.css index 2584e94..83a7642 100644 --- a/src/styles/crt.css +++ b/src/styles/crt.css @@ -1170,16 +1170,26 @@ button.crt-type:hover, transform-origin: center bottom; } .crt-card-top img.crt-sprite-3d.is-anim { - /* 3D mode is always a Showdown GIF. Smooth-scale the upscale so the result - * reads as a soft 3D-ish render rather than chunky pixels. */ + /* Showdown's 3D GIFs are small natives. Full-stretch to 220px blurred and + * pixelated chunked (both rejected) — a modest 1.5× zoom of the natural + * size keeps them big without visible smearing. */ image-rendering: auto; + width: auto; + height: auto; + max-width: 150px; + max-height: 150px; + zoom: 1.5; + /* Lift above the `body::after` scanline overlay so the render stays clean. */ + position: relative; + z-index: 10000; transform-origin: center bottom; } /* 3D and 2D sprite sizes are driven by the responsive `.crt-card-top img` * rules at each breakpoint, so 3D no longer needs a separate override. */ .crt-card-top img.crt-sprite-3d.is-static { /* Last-resort static fallback — user rejected the prior "PNG bouncing - * around" CSS animation, so this stays truly still. */ + * around" CSS animation, so this stays truly still. Official artwork is + * high-res, so smooth scaling is correct here (pixelated would jaggy it). */ image-rendering: auto; transform-origin: center 70%; } @@ -2730,8 +2740,8 @@ button.crt-type:hover, flex: 1; } .crt-trainer-detail-portrait { - width: 96px; - height: 96px; + width: 160px; + height: 160px; image-rendering: pixelated; object-fit: contain; flex-shrink: 0; @@ -2744,6 +2754,8 @@ button.crt-type:hover, } .crt-trainer-detail-portrait { align-self: center; + width: 128px; + height: 128px; } } .crt-trainer-detail-name { @@ -3345,6 +3357,17 @@ button.crt-type:hover, .crt-trainer-filter-label, .crt-trainer-list-card-game, .crt-trainer-detail-game, +/* Digit-heavy text — Pixelify's 5 reads like an S */ +.crt-compare-stat-num, +.crt-compare-vitals-row, +.crt-grid-dex, +.crt-card-dex, +.crt-pagination-status, +.crt-section-count, +.crt-trainer-member-level, +.crt-move-prefix, +.crt-build-value, +.crt-footer, .crt-trainer-counter-why, .crt-trainer-counter-vs, .crt-trainer-counter-type, @@ -3360,8 +3383,8 @@ button.crt-type:hover, /* Trainer list-card portrait — small Showdown VS sprite above the name */ .crt-trainer-list-card-portrait { - width: 64px; - height: 64px; + width: 96px; + height: 96px; image-rendering: pixelated; object-fit: contain; margin-bottom: 4px; diff --git a/src/teams.ts b/src/teams.ts index adeac27..074332a 100644 --- a/src/teams.ts +++ b/src/teams.ts @@ -64,39 +64,39 @@ export const TEAM_BUILDS: Partial> = { ], }, 'gold-silver': { - title: 'Type variety', - note: 'Heavy on Gen 2 newcomers so the regional dex actually matters.', + title: 'Best available lineup', + note: 'Johto’s dex is half Kanto — the best team uses both, plus the free Red Gyarados.', team: [ { species: 'typhlosion', role: 'Starter', why: 'Cyndaquil line is the most reliable Johto starter for in-game runs.', }, + { + species: 'gyarados', + role: 'Physical Attacker', + why: 'The Red Gyarados at the Lake of Rage is free, Lv 30, and story-mandatory to meet.', + }, { species: 'ampharos', role: 'Special Tank', why: 'Mareep on Route 32 — early Electric, evolves into a bulky sweeper.', }, { - species: 'crobat', - role: 'Speed Pivot', - why: 'Zubat is free; Crobat is one of the fastest, most evasive sweepers in Johto.', + species: 'nidoking', + role: 'Coverage Wallbreaker', + why: 'Nidoran on Route 35, Moon Stone in town — Earthquake + Thunderbolt off one slot.', }, { - species: 'feraligatr', - role: 'Physical Attacker', - why: 'Optional Totodile trade or restart slot — Crunch + Surf coverage.', + species: 'espeon', + role: 'Special Sweeper', + why: 'Eevee gifted in Goldenrod — daytime friendship evolution; Psychic deletes Bruno and Koga.', }, { species: 'heracross', role: 'Wallbreaker', why: 'Found in headbuttable trees in Azalea — Megahorn is busted in Gen 2.', }, - { - species: 'umbreon', - role: 'Wall', - why: 'Eevee is gifted in Goldenrod; Umbreon trivializes the Kanto rematch run.', - }, ], }, emerald: { @@ -208,44 +208,44 @@ export const TEAM_BUILDS: Partial> = { ], }, 'sword-shield': { - title: 'Dynamax-ready', - note: 'Picks all hit hard under Dynamax / Max Moves.', + title: 'Best available lineup', + note: 'Galar’s dex is full of returning Pokémon — the best run uses them, not just gen 8 debuts.', team: [ { species: 'cinderace', role: 'Starter / Sweeper', why: 'Pyro Ball + Libero hidden ability post-game. Best Galar starter, period.', }, + { + species: 'gyarados', + role: 'Physical Attacker', + why: 'Magikarp on Route 2 from the very start — evolved by the second gym, carries to Leon.', + }, + { + species: 'excadrill', + role: 'Wallbreaker', + why: 'Drilbur in Galar Mine — Earthquake + Iron Head shreds Nessa, Bea, Melony, and Raihan.', + }, { species: 'corviknight', role: 'Tank / Flyer', why: 'Rookidee on Route 1 — Pressure + Bulk Up walks through Bea / Allister.', }, { - species: 'toxapex', - role: 'Wall', - why: 'Mareanie at Outer Spikemuth. Regenerator + Toxic stalls Leon’s Charizard.', + species: 'lucario', + role: 'Special Attacker', + why: 'Riolu at Giant’s Cap — Steel/Fighting coverage under Dynamax handles Piers and Gordie.', }, { species: 'dragapult', role: 'Speed Sweeper', why: 'Dreepy in the Lake of Outrage — fastest Dragon in the game, Dragon Darts is insane.', }, - { - species: 'mr-rime', - role: 'Special Attacker', - why: 'Galarian Mr. Mime evolves — Screens setter + Ice Beam coverage.', - }, - { - species: 'grimmsnarl', - role: 'Disruptor', - why: 'Impidimp in Glimwood Tangle. Prankster + Spirit Break + Thunder Wave under Dynamax.', - }, ], }, 'scarlet-violet': { - title: 'Open-world route', - note: 'Catchable in the first half of the open world. Hard hits, fast clear.', + title: 'Best available lineup', + note: 'Paldea’s open world is full of returning Pokémon — mixed old/new beats an all-gen-9 squad.', team: [ { species: 'meowscarada', @@ -253,65 +253,65 @@ export const TEAM_BUILDS: Partial> = { why: 'Flower Trick is a guaranteed crit + can’t miss. Single-handed sweep button.', }, { - species: 'garganacl', - role: 'Tank', - why: 'Nacli in the South Province — Salt Cure stalls the Elite Four’s top end.', + species: 'annihilape', + role: 'Setup Wallbreaker', + why: 'Primeape learns Rage Fist at 35 — use it 20 times and Annihilape breaks the whole game.', }, { - species: 'gholdengo', - role: 'Wallbreaker', - why: 'Make Believe puzzle in the open world. Make It Rain + Shadow Ball is unfair.', + species: 'arcanine', + role: 'Physical Attacker', + why: 'Growlithe in the first hour, Fire Stone from raids/shops — Intimidate + Flare Blitz all game.', }, { - species: 'iron-bundle', - role: 'Speed Sweeper', - why: 'Area Zero — paradox Pokémon, fastest in the game with insane Ice/Water STAB.', + species: 'gyarados', + role: 'Physical Attacker', + why: 'Magikarp in every river — Moxie Gyarados snowballs gym after gym.', + }, + { + species: 'garganacl', + role: 'Tank', + why: 'Nacli in the South Province — Salt Cure stalls the Elite Four’s top end.', }, { species: 'tinkaton', role: 'Coverage', why: 'Tinkatink on West Province. Gigaton Hammer one-shots most Dragons / Fairies.', }, - { - species: 'koraidon', - role: 'Box Legendary', - why: 'Story-mandatory in Scarlet. Collision Course turns the postgame Academy Ace tournament trivial.', - }, ], }, yellow: { - title: 'Pikachu locked', - note: 'Pikachu is forced; fill the roster with early Kanto workhorses.', + title: 'All three starters', + note: 'Yellow gifts every Kanto starter during the story — the only game where this team is free.', team: [ { species: 'pikachu', role: 'Starter / Electric', - why: 'Story-mandatory and gifted at the start — can’t deposit it, so lean into Thunder.', + why: 'Story-mandatory and gifted at the start — can’t deposit it, so lean into Thunderbolt.', }, { - species: 'vaporeon', - role: 'Bulky Water', - why: 'Eevee gifted in Celadon — Vaporeon patches the Water gap left by the forced Pikachu start.', + species: 'venusaur', + role: 'Special Tank', + why: 'Bulbasaur gifted in Cerulean — Sleep Powder + Razor Leaf carries the early gyms.', }, { - species: 'pidgeot', - role: 'Physical Attacker / Flyer', - why: 'Pidgey is free on Route 1; Pidgeot is the strongest fully evolved Normal flier in Kanto.', + species: 'charizard', + role: 'Special Attacker', + why: 'Charmander gifted on Route 24 — Flamethrower coverage the forced Pikachu can’t offer.', }, { - species: 'alakazam', - role: 'Special Wallbreaker', - why: 'Abra in Cerulean; highest Special in Gen 1 — covers everything Pikachu can’t.', + species: 'blastoise', + role: 'Bulky Water', + why: 'Squirtle gifted in Vermilion — Surf mule with real stats, walls Blaine and Lance’s Gyarados.', }, { - species: 'machamp', - role: 'Physical Tank', - why: 'Machop in Rock Tunnel — Fighting coverage smashes Brock, Koga, and the E4 Dark gap.', + species: 'snorlax', + role: 'Tank', + why: 'Body Slam + Rest + huge HP pool. Only one in the wild — don’t skip Route 12.', }, { - species: 'lapras', - role: 'Coverage / Pivot', - why: 'Free at Silph Co.; Ice Beam + Surf handles Lance and acts as the team’s HM mule.', + species: 'dragonite', + role: 'Late-game Sweeper', + why: 'Dratini from the Safari Zone or Game Corner — outclasses everything by the Elite Four.', }, ], }, @@ -330,9 +330,9 @@ export const TEAM_BUILDS: Partial> = { why: 'Mareep on Route 32; evolves into a bulky Electric sweeper that handles Karen’s Umbreon.', }, { - species: 'crobat', - role: 'Speed Pivot', - why: 'Zubat is everywhere; Crobat’s speed and Cross Poison overwhelm most Kanto Gym Leaders.', + species: 'nidoking', + role: 'Coverage Wallbreaker', + why: 'Nidoran on Route 35, Moon Stone in town — Earthquake + Thunderbolt off one slot.', }, { species: 'suicune', @@ -531,6 +531,42 @@ export const TEAM_BUILDS: Partial> = { }, ], }, + 'x-y': { + title: 'Best available lineup', + note: 'Kalos hands out gift Pokémon constantly — the best team is mostly free.', + team: [ + { + species: 'greninja', + role: 'Starter / Speed Sweeper', + why: 'Protean Froakie is the best starter of its generation — every move gets STAB.', + }, + { + species: 'talonflame', + role: 'Physical Attacker', + why: 'Fletchling on Route 2 — Gale Wings priority Flying moves carry the whole midgame.', + }, + { + species: 'lucario', + role: 'Special Attacker', + why: 'Gifted by Korrina with a Lucarionite — free Mega Evolution the moment you get it.', + }, + { + species: 'aegislash', + role: 'Setup Sweeper', + why: 'Honedge on Route 6, Dusk Stone in Laverre — King’s Shield stance dancing breaks the AI.', + }, + { + species: 'gardevoir', + role: 'Special Attacker', + why: 'Ralts on Route 4 — Fairy typing added in XY makes it the Dragon answer all game.', + }, + { + species: 'snorlax', + role: 'Tank', + why: 'Story encounter on Route 7 — same wall it has always been, free before the second gym.', + }, + ], + }, 'omega-ruby-alpha-sapphire': { title: 'Mega Evolution run', note: 'Mega Stones available mid-game; build around the Mega starter of choice.', @@ -577,9 +613,9 @@ export const TEAM_BUILDS: Partial> = { why: 'Litten’s Darkest Lariat ignores stat boosts — perfect for Totem Pokémon that set up.', }, { - species: 'vikavolt', - role: 'Special Attacker', - why: 'Grubbin on Route 1; Vikavolt’s Thunderbolt + Bug Buzz handles Hala, Olivia, and Nanu.', + species: 'raichu-alola', + role: 'Special Sweeper', + why: 'Pichu on Route 1 — Alolan Raichu’s Psychic/Electric STAB carries trial after trial.', }, { species: 'mudsdale', @@ -597,9 +633,9 @@ export const TEAM_BUILDS: Partial> = { why: 'Salandit in Wela Volcano Park — Corrosion ignores type immunities to Toxic, warping Totem fights.', }, { - species: 'kommo-o', - role: 'Pseudo-Legend', - why: 'Jangmo-o in Vast Poni Canyon — Dragon / Fighting STAB sweeps Hapu and the Elite Four late.', + species: 'magnezone', + role: 'Special Tank', + why: 'Magnemite at the Trainers’ School — Steel/Electric typing walls half of Alola’s threats.', }, ], }, @@ -618,14 +654,14 @@ export const TEAM_BUILDS: Partial> = { why: 'Rockruff from a gift or early route — Accelerock priority + Stone Edge covers Totem Vikavolt.', }, { - species: 'toxapex', - role: 'Wall', - why: 'Mareanie from SOS chaining early; Regenerator + Toxic stalls Ultra Necrozma’s boosted hits.', + species: 'mimikyu', + role: 'Setup Sweeper', + why: 'Thrifty Megamart — Disguise gives a free Swords Dance; sweeps Totems and Ultra Necrozma.', }, { - species: 'kommo-o', - role: 'Pseudo-Legend', - why: 'Jangmo-o in Vast Poni Canyon — Clangorous Soulblaze Z-Move deletes Ultra Recon Squad.', + species: 'mudsdale', + role: 'Physical Tank', + why: 'Mudbray in Paniola Ranch — Stamina snowballs under physical hits; Earthquake stacks.', }, { species: 'magnezone', @@ -633,9 +669,9 @@ export const TEAM_BUILDS: Partial> = { why: 'Magnemite on Blush Mountain; evolves at Vast Poni Canyon — Flash Cannon handles Fairy trials.', }, { - species: 'tapu-koko', - role: 'Electric Terrain Setter', - why: 'Mandatory encounter at Ruins of Conflict post-game; Electric Surge + Dazzling Gleam covers Lusamine.', + species: 'naganadel', + role: 'Special Sweeper', + why: 'Poipole is a story gift from the Ultra Recon Squad — teach Dragon Pulse and it evolves.', }, ], }, diff --git a/src/trainers.ts b/src/trainers.ts index d6f271e..e3fa227 100644 --- a/src/trainers.ts +++ b/src/trainers.ts @@ -72,6 +72,12 @@ export const GAMES_BY_REGION: { region: string; note?: string; games: GameId[] } { region: 'Paldea', games: ['scarlet-violet'] }, ]; +/** Best available portrait — animated APNG when the trainer has one, else the static VS sprite. */ +export function trainerPortraitUrl(t: Trainer): string | undefined { + if (t.animSprite) return `${import.meta.env.BASE_URL}sprites/trainers/${t.animSprite}`; + return t.spriteUrl; +} + /** Region-grouped chronological game order (regions first-appearance, releases within). */ export const GAME_ORDER: GameId[] = GAMES_BY_REGION.flatMap((r) => r.games); @@ -125,6 +131,11 @@ export interface Trainer { location?: string; /** VS-portrait URL. Optional — list cards fall back to a class glyph. */ spriteUrl?: string; + /** + * Filename in `public/sprites/trainers/` — an animated APNG battle sprite + * (B2W2 PWT rips). Preferred over `spriteUrl` when present. + */ + animSprite?: string; team: TrainerPokemon[]; /** * Species slugs (full evolution lines) catchable before this fight. @@ -180,6 +191,7 @@ export const TRAINERS: Trainer[] = [ 'nidoking', ], spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/brock.png', + animSprite: 'brock.png', team: [ { species: 'geodude', level: 12, moves: ['tackle', 'defense-curl'] }, { species: 'onix', level: 14, moves: ['tackle', 'bide'] }, @@ -192,6 +204,7 @@ export const TRAINERS: Trainer[] = [ game: 'red-blue', location: 'Cerulean City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/misty.png', + animSprite: 'misty.png', team: [ { species: 'staryu', level: 18, moves: ['tackle', 'water-gun'] }, { species: 'starmie', level: 21, moves: ['water-gun', 'harden', 'tackle', 'bubble-beam'] }, @@ -204,6 +217,7 @@ export const TRAINERS: Trainer[] = [ game: 'red-blue', location: 'Vermilion City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/ltsurge.png', + animSprite: 'ltsurge.png', team: [ { species: 'voltorb', level: 21, moves: ['tackle', 'screech', 'sonicboom'] }, { species: 'pikachu', level: 18, moves: ['thundershock', 'growl', 'thunder-wave'] }, @@ -217,6 +231,7 @@ export const TRAINERS: Trainer[] = [ game: 'red-blue', location: 'Celadon City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/erika.png', + animSprite: 'erika.png', team: [ { species: 'victreebel', level: 29, moves: ['razor-leaf', 'sleep-powder', 'wrap'] }, { species: 'tangela', level: 24, moves: ['constrict', 'sleep-powder', 'bind'] }, @@ -230,6 +245,7 @@ export const TRAINERS: Trainer[] = [ game: 'red-blue', location: 'Fuchsia City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/koga.png', + animSprite: 'koga.png', team: [ { species: 'koffing', level: 37, moves: ['smog', 'smokescreen', 'tackle'] }, { species: 'muk', level: 39, moves: ['minimize', 'sludge', 'smokescreen'] }, @@ -244,6 +260,7 @@ export const TRAINERS: Trainer[] = [ game: 'red-blue', location: 'Saffron City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/sabrina.png', + animSprite: 'sabrina.png', team: [ { species: 'kadabra', level: 38, moves: ['psybeam', 'disable', 'psych-up'] }, { species: 'mr-mime', level: 37, moves: ['psybeam', 'barrier', 'meditate'] }, @@ -258,6 +275,7 @@ export const TRAINERS: Trainer[] = [ game: 'red-blue', location: 'Cinnabar Island Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/blaine.png', + animSprite: 'blaine.png', team: [ { species: 'growlithe', level: 42, moves: ['ember', 'leer', 'bite'] }, { species: 'ponyta', level: 40, moves: ['ember', 'tail-whip', 'stomp'] }, @@ -272,6 +290,7 @@ export const TRAINERS: Trainer[] = [ game: 'red-blue', location: 'Viridian City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/giovanni.png', + animSprite: 'giovanni.png', team: [ { species: 'rhyhorn', level: 45, moves: ['stomp', 'tail-whip', 'fury-attack'] }, { species: 'dugtrio', level: 42, moves: ['dig', 'growl', 'scratch'] }, @@ -302,6 +321,7 @@ export const TRAINERS: Trainer[] = [ game: 'red-blue', location: 'Indigo Plateau', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/bruno.png', + animSprite: 'bruno.png', team: [ { species: 'onix', level: 53, moves: ['bide', 'slam', 'tackle'] }, { @@ -348,6 +368,7 @@ export const TRAINERS: Trainer[] = [ game: 'red-blue', location: 'Indigo Plateau', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/lance.png', + animSprite: 'lance.png', team: [ { species: 'gyarados', level: 56, moves: ['hydro-pump', 'bite', 'dragon-rage', 'leer'] }, { @@ -375,6 +396,7 @@ export const TRAINERS: Trainer[] = [ game: 'red-blue', location: 'Indigo Plateau', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/blue.png', + animSprite: 'blue.png', team: [ { species: 'pidgeot', @@ -451,6 +473,7 @@ export const TRAINERS: Trainer[] = [ 'sandslash', ], spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/falkner.png', + animSprite: 'falkner.png', team: [ { species: 'pidgey', level: 7, moves: ['tackle', 'sand-attack'] }, { species: 'pidgeotto', level: 9, moves: ['tackle', 'sand-attack', 'gust'] }, @@ -463,6 +486,7 @@ export const TRAINERS: Trainer[] = [ game: 'gold-silver', location: 'Azalea Town Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/bugsy.png', + animSprite: 'bugsy.png', team: [ { species: 'metapod', level: 14, moves: ['harden'] }, { species: 'kakuna', level: 14, moves: ['harden'] }, @@ -476,6 +500,7 @@ export const TRAINERS: Trainer[] = [ game: 'gold-silver', location: 'Goldenrod City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/whitney.png', + animSprite: 'whitney.png', team: [ { species: 'clefairy', level: 17, moves: ['mimic', 'encore', 'doubleslap', 'metronome'] }, { species: 'miltank', level: 19, moves: ['rollout', 'attract', 'stomp', 'milk-drink'] }, @@ -488,6 +513,7 @@ export const TRAINERS: Trainer[] = [ game: 'gold-silver', location: 'Ecruteak City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/morty.png', + animSprite: 'morty.png', team: [ { species: 'gastly', level: 21, moves: ['lick', 'spite', 'hypnosis', 'mean-look'] }, { species: 'haunter', level: 21, moves: ['lick', 'spite', 'hypnosis', 'mean-look'] }, @@ -502,6 +528,7 @@ export const TRAINERS: Trainer[] = [ game: 'gold-silver', location: 'Cianwood City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/chuck.png', + animSprite: 'chuck.png', team: [ { species: 'primeape', level: 27, moves: ['karate-chop', 'thrash', 'leer', 'focus-energy'] }, { species: 'poliwrath', level: 30, moves: ['surf', 'dynamicpunch', 'hypnosis', 'body-slam'] }, @@ -514,6 +541,7 @@ export const TRAINERS: Trainer[] = [ game: 'gold-silver', location: 'Olivine City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/jasmine.png', + animSprite: 'jasmine.png', team: [ { species: 'magnemite', level: 30, moves: ['thundershock', 'sonicboom', 'thunder-wave'] }, { species: 'magnemite', level: 30, moves: ['thundershock', 'sonicboom', 'thunder-wave'] }, @@ -527,6 +555,7 @@ export const TRAINERS: Trainer[] = [ game: 'gold-silver', location: 'Mahogany Town Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/pryce.png', + animSprite: 'pryce.png', team: [ { species: 'seel', level: 27, moves: ['icy-wind', 'aurora-beam', 'rest'] }, { species: 'dewgong', level: 29, moves: ['icy-wind', 'aurora-beam', 'rest', 'take-down'] }, @@ -544,6 +573,7 @@ export const TRAINERS: Trainer[] = [ game: 'gold-silver', location: 'Blackthorn City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/clair.png', + animSprite: 'clair.png', team: [ { species: 'dragonair', level: 37, moves: ['dragon-rage', 'slam', 'thunder-wave', 'surf'] }, { species: 'dragonair', level: 37, moves: ['dragon-rage', 'slam', 'thunder-wave', 'surf'] }, @@ -562,6 +592,7 @@ export const TRAINERS: Trainer[] = [ game: 'gold-silver', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/will.png', + animSprite: 'will.png', team: [ { species: 'xatu', @@ -585,6 +616,7 @@ export const TRAINERS: Trainer[] = [ game: 'gold-silver', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/koga.png', + animSprite: 'koga.png', team: [ { species: 'ariados', @@ -612,6 +644,7 @@ export const TRAINERS: Trainer[] = [ game: 'gold-silver', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/bruno.png', + animSprite: 'bruno.png', team: [ { species: 'hitmontop', @@ -639,6 +672,7 @@ export const TRAINERS: Trainer[] = [ game: 'gold-silver', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/karen.png', + animSprite: 'karen.png', team: [ { species: 'umbreon', @@ -670,6 +704,7 @@ export const TRAINERS: Trainer[] = [ game: 'gold-silver', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/lance.png', + animSprite: 'lance.png', team: [ { species: 'gyarados', level: 44, moves: ['hyper-beam', 'surf', 'dragon-rage', 'bite'] }, { @@ -702,6 +737,7 @@ export const TRAINERS: Trainer[] = [ game: 'gold-silver', location: 'Mt. Silver', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/silver.png', + animSprite: 'silver.png', team: [ { species: 'sneasel', @@ -781,6 +817,7 @@ export const TRAINERS: Trainer[] = [ 'delcatty', ], spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/roxanne.png', + animSprite: 'roxanne.png', team: [ { species: 'geodude', level: 12, ability: 'rock-head', moves: ['tackle', 'defense-curl'] }, { species: 'geodude', level: 12, ability: 'rock-head', moves: ['tackle', 'defense-curl'] }, @@ -799,6 +836,7 @@ export const TRAINERS: Trainer[] = [ game: 'ruby-sapphire', location: 'Dewford Town Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/brawly.png', + animSprite: 'brawly.png', team: [ { species: 'machop', @@ -827,6 +865,7 @@ export const TRAINERS: Trainer[] = [ game: 'ruby-sapphire', location: 'Mauville City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/wattson.png', + animSprite: 'wattson.png', team: [ { species: 'voltorb', @@ -861,6 +900,7 @@ export const TRAINERS: Trainer[] = [ game: 'ruby-sapphire', location: 'Lavaridge Town Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/flannery.png', + animSprite: 'flannery.png', team: [ { species: 'slugma', @@ -889,6 +929,7 @@ export const TRAINERS: Trainer[] = [ game: 'ruby-sapphire', location: 'Petalburg City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/norman.png', + animSprite: 'norman.png', team: [ { species: 'spinda', @@ -923,6 +964,7 @@ export const TRAINERS: Trainer[] = [ game: 'ruby-sapphire', location: 'Fortree City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/winona.png', + animSprite: 'winona.png', team: [ { species: 'swellow', @@ -957,6 +999,7 @@ export const TRAINERS: Trainer[] = [ game: 'ruby-sapphire', location: 'Mossdeep City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/tateandliza-gen3.png', + animSprite: 'tateliza.gif', team: [ { species: 'lunatone', @@ -979,6 +1022,7 @@ export const TRAINERS: Trainer[] = [ game: 'ruby-sapphire', location: 'Sootopolis City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/wallace.png', + animSprite: 'wallace.png', team: [ { species: 'luvdisc', @@ -1179,6 +1223,7 @@ export const TRAINERS: Trainer[] = [ game: 'ruby-sapphire', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/steven.png', + animSprite: 'steven.png', team: [ { species: 'skarmory', @@ -1272,6 +1317,7 @@ export const TRAINERS: Trainer[] = [ 'dustox', ], spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/roark.png', + animSprite: 'roark.png', team: [ { species: 'geodude', @@ -1300,6 +1346,7 @@ export const TRAINERS: Trainer[] = [ game: 'diamond-pearl', location: 'Eterna City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/gardenia.png', + animSprite: 'gardenia.png', team: [ { species: 'cherubi', @@ -1328,6 +1375,7 @@ export const TRAINERS: Trainer[] = [ game: 'diamond-pearl', location: 'Veilstone City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/maylene.png', + animSprite: 'maylene.png', team: [ { species: 'meditite', @@ -1356,6 +1404,7 @@ export const TRAINERS: Trainer[] = [ game: 'diamond-pearl', location: 'Pastoria City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/crasherwake.png', + animSprite: 'crasherwake.png', team: [ { species: 'gyarados', @@ -1384,6 +1433,7 @@ export const TRAINERS: Trainer[] = [ game: 'diamond-pearl', location: 'Hearthome City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/fantina.png', + animSprite: 'fantina.png', team: [ { species: 'drifblim', @@ -1412,6 +1462,7 @@ export const TRAINERS: Trainer[] = [ game: 'diamond-pearl', location: 'Canalave City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/byron.png', + animSprite: 'byron.png', team: [ { species: 'bronzor', @@ -1440,6 +1491,7 @@ export const TRAINERS: Trainer[] = [ game: 'diamond-pearl', location: 'Snowpoint City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/candice.png', + animSprite: 'candice.png', team: [ { species: 'snover', @@ -1474,6 +1526,7 @@ export const TRAINERS: Trainer[] = [ game: 'diamond-pearl', location: 'Sunyshore City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/volkner.png', + animSprite: 'volkner.png', team: [ { species: 'jolteon', @@ -1508,6 +1561,7 @@ export const TRAINERS: Trainer[] = [ game: 'diamond-pearl', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/aaron.png', + animSprite: 'aaron.png', team: [ { species: 'dustox', @@ -1548,6 +1602,7 @@ export const TRAINERS: Trainer[] = [ game: 'diamond-pearl', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/bertha.png', + animSprite: 'bertha.png', team: [ { species: 'whiscash', @@ -1588,6 +1643,7 @@ export const TRAINERS: Trainer[] = [ game: 'diamond-pearl', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/flint.png', + animSprite: 'flint.png', team: [ { species: 'rapidash', @@ -1628,6 +1684,7 @@ export const TRAINERS: Trainer[] = [ game: 'diamond-pearl', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/lucian.png', + animSprite: 'lucian.png', team: [ { species: 'mr-mime', @@ -1668,6 +1725,7 @@ export const TRAINERS: Trainer[] = [ game: 'diamond-pearl', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/cynthia.png', + animSprite: 'cynthia.png', team: [ { species: 'spiritomb', @@ -1715,6 +1773,7 @@ export const TRAINERS: Trainer[] = [ game: 'diamond-pearl', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/barry.png', + animSprite: 'barry.png', team: [ { species: 'staraptor', @@ -1788,6 +1847,7 @@ export const TRAINERS: Trainer[] = [ 'audino', ], spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/cilan.png', + animSprite: 'cilan.png', team: [ { species: 'lillipup', @@ -1810,6 +1870,7 @@ export const TRAINERS: Trainer[] = [ game: 'black-white', location: 'Nacrene City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/lenora.png', + animSprite: 'lenora.png', team: [ { species: 'herdier', @@ -1832,6 +1893,7 @@ export const TRAINERS: Trainer[] = [ game: 'black-white', location: 'Castelia City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/burgh.png', + animSprite: 'burgh.png', team: [ { species: 'whirlipede', @@ -1860,6 +1922,7 @@ export const TRAINERS: Trainer[] = [ game: 'black-white', location: 'Nimbasa City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/elesa.png', + animSprite: 'elesa.png', team: [ { species: 'emolga', @@ -1888,6 +1951,7 @@ export const TRAINERS: Trainer[] = [ game: 'black-white', location: 'Driftveil City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/clay.png', + animSprite: 'clay.png', team: [ { species: 'krokorok', @@ -1916,6 +1980,7 @@ export const TRAINERS: Trainer[] = [ game: 'black-white', location: 'Mistralton City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/skyla.png', + animSprite: 'skyla.png', team: [ { species: 'swoobat', @@ -1944,6 +2009,7 @@ export const TRAINERS: Trainer[] = [ game: 'black-white', location: 'Icirrus City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/brycen.png', + animSprite: 'brycen.png', team: [ { species: 'vanillish', @@ -1972,6 +2038,7 @@ export const TRAINERS: Trainer[] = [ game: 'black-white', location: 'Opelucid City Gym', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/drayden.png', + animSprite: 'drayden.png', team: [ { species: 'fraxure', @@ -2000,6 +2067,7 @@ export const TRAINERS: Trainer[] = [ game: 'black-white', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/shauntal.png', + animSprite: 'shauntal.png', team: [ { species: 'cofagrigus', @@ -2034,6 +2102,7 @@ export const TRAINERS: Trainer[] = [ game: 'black-white', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/marshal.png', + animSprite: 'marshal.png', team: [ { species: 'throh', @@ -2068,6 +2137,7 @@ export const TRAINERS: Trainer[] = [ game: 'black-white', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/grimsley.png', + animSprite: 'grimsley.png', team: [ { species: 'scrafty', @@ -2102,6 +2172,7 @@ export const TRAINERS: Trainer[] = [ game: 'black-white', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/caitlin.png', + animSprite: 'caitlin.png', team: [ { species: 'musharna', @@ -2136,6 +2207,7 @@ export const TRAINERS: Trainer[] = [ game: 'black-white', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/alder.png', + animSprite: 'alder.png', team: [ { species: 'accelgor', @@ -2188,6 +2260,7 @@ export const TRAINERS: Trainer[] = [ game: 'black-white', location: "N's Castle", spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/n.png', + animSprite: 'n.png', team: [ { species: 'zekrom', @@ -2240,6 +2313,7 @@ export const TRAINERS: Trainer[] = [ game: 'black-white', location: 'Pokémon League', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/cheren.png', + animSprite: 'cheren.png', team: [ { species: 'unfezant', @@ -2286,6 +2360,7 @@ export const TRAINERS: Trainer[] = [ game: 'black-white', location: 'Victory Road', spriteUrl: 'https://play.pokemonshowdown.com/sprites/trainers/bianca.png', + animSprite: 'bianca.png', team: [ { species: 'musharna',