├── .eslintrc.js ├── .github └── workflows │ └── stale.yml ├── .gitignore ├── .husky └── pre-commit ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── playwright.config.ts ├── public ├── fonts │ └── cantora-one-fixed.woff └── images │ ├── flags │ ├── cn.svg │ ├── de.svg │ ├── fr.svg │ ├── gb.svg │ └── jp.svg │ └── icons │ ├── chevron-right.svg │ ├── gear.svg │ ├── pause.svg │ ├── stats.svg │ ├── tick.svg │ ├── time.svg │ └── trophy.svg ├── src ├── App.tsx ├── components │ ├── AnswerInput.tsx │ ├── AudioPlayer.tsx │ ├── CountdownLoader.tsx │ ├── GameArea.tsx │ ├── GenerationFinished.tsx │ ├── LanguageSelectorFlags.tsx │ ├── NextPokemonTimer.tsx │ ├── PokemonSilhouette.tsx │ ├── SettingsMenu.tsx │ ├── StatsListener.tsx │ ├── StatsMenu.tsx │ ├── StatsModal.tsx │ └── css │ │ ├── CountdownLoader.module.css │ │ ├── NextPokemonTimer.module.css │ │ └── StatsModal.module.css ├── constants │ ├── index.ts │ ├── lang.ts │ └── pokemon.ts ├── index.css ├── index.tsx ├── store │ ├── actions.ts │ ├── gameSlice.ts │ ├── index.ts │ ├── migrate.ts │ ├── settingsSlice.ts │ └── statsSlice.ts └── util │ ├── hooks.ts │ ├── pokemon.ts │ ├── spelling.ts │ └── stats.ts ├── tests └── base.spec.ts ├── tsconfig.json └── vite.config.mjs /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: [ 5 | '@typescript-eslint', 6 | 'import', 7 | '@stylistic/js', 8 | '@stylistic/ts', 9 | ], 10 | env: { 11 | browser: true, 12 | }, 13 | rules: { 14 | 'comma-dangle': ['error', 'always-multiline'], 15 | 'import/order': ['error', { 16 | alphabetize: { 17 | order: 'asc', 18 | caseInsensitive: true, 19 | }, 20 | groups: [ 21 | ['builtin', 'external'], 22 | ], 23 | 'newlines-between': 'always', 24 | }], 25 | 'no-var': 'error', 26 | 'prefer-const': 'error', 27 | '@stylistic/js/jsx-quotes': ['error', 'prefer-double'], 28 | '@stylistic/js/max-len': ['error', { code: 140, ignoreStrings: true }], 29 | '@stylistic/ts/indent': ['error', 2], 30 | '@stylistic/ts/no-extra-semi': 'error', 31 | '@stylistic/ts/quote-props': ['error', 'as-needed'], 32 | '@stylistic/ts/quotes': ['error', 'single', { avoidEscape: true }], 33 | '@stylistic/ts/semi': ['error', 'always'], 34 | }, 35 | ignorePatterns: ['node_modules', '**/*.css.d.ts'], 36 | }; 37 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | 12 | jobs: 13 | stale: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/stale@v9 17 | with: 18 | stale-issue-message: 'This issue has not been updated for a while and will be closed in 2 weeks. If it is still relevant, reply or remove the stale tag.' 19 | stale-pr-message: 'This pull request has not been updated for a while and will be closed in 2 weeks. If it is still relevant, reply or remove the stale tag.' 20 | close-issue-message: 'This issue has been automatically closed due to lack of recent activity.' 21 | close-pr-message: 'This pull request has been automatically closed due to lack of recent activity.' 22 | days-before-stale: 90 23 | days-before-close: 14 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | index.js 3 | /public/images/artwork 4 | /public/images/sprites 5 | /public/sounds/* 6 | pokemedia 7 | *.zip 8 | .DS_Store 9 | dist/ 10 | *.css.d.ts 11 | 12 | # Directories from the old app structure 13 | /images/ 14 | /sounds/ 15 | /test-results/ 16 | /playwright-report/ 17 | /blob-report/ 18 | /playwright/.cache/ 19 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | echo "Running Typescript typecheck" 2 | npm run typecheck 3 | 4 | echo "Running eslint" 5 | npm run lint 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Who's That Pokémon? 2 | 3 | > See https://gearoid.me/pokemon/ for a live demo. 4 | 5 | A browser-based game in which Pokémon silhouettes are shown to the user and they guess which Pokémon it is. 6 | 7 | * Silhouettes are generated dynamically in-browser using the canvas tag 8 | * Multiple difficulty settings, which choose the image source for generating the silhouette 9 | * User-facing stats including correct streaks and times taken 10 | 11 | ## Development 12 | 13 | You will need [node and npm](https://nodejs.org/en/) set up to get the code running locally. You will also need to download the Pokémon images and sounds, because they are not included in this repository. You can download them from https://gearoid.me/pokemon/downloads/pokemedia.zip. Unzip this and put `images/` and `sounds/` in the `public` directory. 14 | 15 | Once you have done this, install the dependencies by running: 16 | 17 | ``` 18 | npm install 19 | ``` 20 | 21 | To run the site locally, you can then run: 22 | 23 | ``` 24 | npm run serve 25 | ``` 26 | 27 | To build the site to be deployed online, run: 28 | 29 | ``` 30 | npm run build 31 | ``` 32 | 33 | ## Testing 34 | 35 | There is some basic testing set up using [Playwright](https://playwright.dev). It does a full playthrough of the game on default settings in Chrome, Firefox and Safari. To run these tests, run: 36 | 37 | ``` 38 | npm test 39 | ``` 40 | 41 | ## Licence 42 | This code is licensed under a [Creative Commons Attribution-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-sa/4.0/). 43 | 44 | All the Pokémon names, images and sounds are copyrighted by Nintendo. 45 | 46 | Flag icons are from https://github.com/lipis/flag-icons 47 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Who's That Pokémon? 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whosthatpokemon", 3 | "version": "2.0.0", 4 | "description": "A browser-based game in which Pokémon silhouettes are shown to the user and they guess which Pokémon it is.", 5 | "scripts": { 6 | "build": "vite build", 7 | "serve": "tcm --watch -p \"src/**/*.module.css\" & vite", 8 | "typecheck": "npx tsc --noEmit", 9 | "lint": "eslint src --ext .js,.jsx,.ts,.tsx", 10 | "prepare": "husky", 11 | "test": "playwright test" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/Menardi/whosthatpokemon.git" 16 | }, 17 | "author": "Menardi", 18 | "bugs": { 19 | "url": "https://github.com/Menardi/whosthatpokemon/issues" 20 | }, 21 | "homepage": "https://github.com/Menardi/whosthatpokemon#readme", 22 | "devDependencies": { 23 | "@playwright/test": "^1.44.0", 24 | "@preact/preset-vite": "^2.10.1", 25 | "@stylistic/eslint-plugin": "^2.1.0", 26 | "@types/node": "^20.12.11", 27 | "@typescript-eslint/eslint-plugin": "^7.8.0", 28 | "@typescript-eslint/parser": "^7.8.0", 29 | "eslint": "^8.55.0", 30 | "eslint-plugin-import": "^2.26.0", 31 | "husky": "^9.0.11", 32 | "typed-css-modules": "^0.9.1", 33 | "typescript": "^5.8.3", 34 | "vite": "^6.3.3" 35 | }, 36 | "dependencies": { 37 | "@reduxjs/toolkit": "^1.9.7", 38 | "classnames": "^2.5.1", 39 | "preact": "^10.21.0", 40 | "react-redux": "^8.1.3", 41 | "redux-persist": "^6.0.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: './tests', 14 | /* Run tests in files in parallel */ 15 | fullyParallel: true, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 2 : 0, 20 | /* Opt out of parallel tests on CI. */ 21 | workers: process.env.CI ? 1 : undefined, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: 'line', 24 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 25 | use: { 26 | /* Base URL to use in actions like `await page.goto('/')`. */ 27 | // baseURL: 'http://127.0.0.1:3000', 28 | 29 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 30 | trace: 'on-first-retry', 31 | }, 32 | 33 | /* Configure projects for major browsers */ 34 | projects: [ 35 | { 36 | name: 'chromium', 37 | use: { ...devices['Desktop Chrome'] }, 38 | }, 39 | 40 | { 41 | name: 'firefox', 42 | use: { ...devices['Desktop Firefox'] }, 43 | }, 44 | 45 | { 46 | name: 'webkit', 47 | use: { ...devices['Desktop Safari'] }, 48 | }, 49 | 50 | /* Test against mobile viewports. */ 51 | // { 52 | // name: 'Mobile Chrome', 53 | // use: { ...devices['Pixel 5'] }, 54 | // }, 55 | // { 56 | // name: 'Mobile Safari', 57 | // use: { ...devices['iPhone 12'] }, 58 | // }, 59 | 60 | /* Test against branded browsers. */ 61 | // { 62 | // name: 'Microsoft Edge', 63 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 64 | // }, 65 | // { 66 | // name: 'Google Chrome', 67 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 68 | // }, 69 | ], 70 | 71 | /* Run your local dev server before starting the tests */ 72 | webServer: { 73 | command: 'npm run serve', 74 | url: 'http://localhost:5173', 75 | reuseExistingServer: !process.env.CI, 76 | }, 77 | }); 78 | -------------------------------------------------------------------------------- /public/fonts/cantora-one-fixed.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Menardi/whosthatpokemon/836e682ba7fd0eed90c185fcc914aca03ce9d86b/public/fonts/cantora-one-fixed.woff -------------------------------------------------------------------------------- /public/images/flags/cn.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/images/flags/de.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/images/flags/fr.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/images/flags/gb.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/images/flags/jp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/images/icons/chevron-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/images/icons/gear.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 13 | 14 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /public/images/icons/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/images/icons/stats.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/images/icons/tick.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/images/icons/time.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/images/icons/trophy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 16 | 17 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect, useState } from 'preact/hooks'; 2 | 3 | import GameArea from './components/GameArea'; 4 | import LanguageSelectorFlags from './components/LanguageSelectorFlags'; 5 | import SettingsMenu from './components/SettingsMenu'; 6 | import StatsListener from './components/StatsListener'; 7 | import StatsMenu from './components/StatsMenu'; 8 | import { useAppDispatch } from './store'; 9 | import { goToNextPokemon, resetPokemon } from './store/actions'; 10 | import { migrateToRedux } from './store/migrate'; 11 | import { useGameState, useLang, useSettings, useVisualViewportHeight } from './util/hooks'; 12 | 13 | const App = () => { 14 | const lang = useLang(); 15 | 16 | const dispatch = useAppDispatch(); 17 | 18 | const game = useGameState(); 19 | 20 | const [openedMenu, setOpenedMenu] = useState<'settings' | 'stats' | null>(null); 21 | const viewportHeight = useVisualViewportHeight(); 22 | 23 | useLayoutEffect(() => { 24 | if (!game.initialized) { 25 | migrateToRedux(); 26 | dispatch(resetPokemon()); 27 | } else if (game.answered !== null) { 28 | // User has reloaded the page while viewing a correct answer - move to the next Pokémon 29 | dispatch(goToNextPokemon()); 30 | } 31 | }, []); 32 | 33 | // The viewportHeight tends to change when the virtual keyboard is opened on mobile. This useEffect 34 | // ensures that the game remains visible when the keyboard opens, especially on iOS. 35 | useEffect(() => { 36 | window.scrollTo({ top: 0 }); 37 | }, [viewportHeight]); 38 | 39 | if (!game.initialized) return null; 40 | 41 | return ( 42 |
43 |
44 | setOpenedMenu(null)} 47 | /> 48 | 49 |
50 | 51 | 52 |
53 |
54 | 55 | setOpenedMenu(null)} 58 | /> 59 |
60 | 61 |
62 | ); 63 | }; 64 | 65 | export default App; 66 | -------------------------------------------------------------------------------- /src/components/AnswerInput.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; 2 | import type { JSXInternal } from 'preact/src/jsx'; 3 | 4 | import { LANGUAGES } from '../constants/lang'; 5 | import { POKEMON_NAMES } from '../constants/pokemon'; 6 | import { useAppDispatch } from '../store'; 7 | import { goToNextPokemon } from '../store/actions'; 8 | import { revealPokemon } from '../store/gameSlice'; 9 | import { useCurrentPokemonNumber, useGameState, useLang, useSettings } from '../util/hooks'; 10 | import { removeAccents, soundAlike } from '../util/spelling'; 11 | import CountdownLoader from './CountdownLoader'; 12 | import NextPokemonTimer from './NextPokemonTimer'; 13 | 14 | const AnswerInput = () => { 15 | const dispatch = useAppDispatch(); 16 | const lang = useLang(); 17 | const settings = useSettings(); 18 | const gameState = useGameState(); 19 | 20 | // On mobile, when the user taps the "I don't know button", the input gets blurred and the screen 21 | // moves awkwardly. To avoid this, we keep track of the input's focus state, and don't change it to 22 | // false until 200ms after blur. We can then use this to refocus the input when "I don't know" was 23 | // pressed, while not opening the keyboard if the user didn't have it open. 24 | const inputRef = useRef(null); 25 | const [isInputRecentlyFocused, setIsInputRecentlyFocused] = useState(false); 26 | const onInputFocus = useCallback(() => setIsInputRecentlyFocused(true), []); 27 | const onInputBlur = useCallback(() => { 28 | setTimeout(() => setIsInputRecentlyFocused(false), 200); 29 | }, []); 30 | 31 | const [guess, setGuess] = useState(''); 32 | 33 | const number = useCurrentPokemonNumber(); 34 | const pokemonNames = useMemo(() => ( 35 | POKEMON_NAMES.find((pokemon) => pokemon.number === number)!.names 36 | ), [number]); 37 | 38 | const checkGuess = (guess: string) => { 39 | const normalisedGuess = removeAccents(guess.toLowerCase()); 40 | const normalisedAnswer = removeAccents(pokemonNames[settings.language].toLowerCase()); 41 | 42 | if ( 43 | (settings.forgivingSpellingEnabled && settings.language === 'en' && soundAlike(normalisedGuess, normalisedAnswer)) 44 | || (normalisedGuess === normalisedAnswer)) { 45 | dispatch(revealPokemon({ isCorrect: true })); 46 | setGuess(pokemonNames[settings.language]); 47 | } 48 | }; 49 | 50 | const onInput = (ev: JSXInternal.TargetedInputEvent) => { 51 | if (!gameState.answered) { 52 | setGuess(ev.currentTarget.value); 53 | checkGuess(ev.currentTarget.value); 54 | } else { 55 | // Chrome on Android has an issue where calling ev.preventDefault() on keydown doesn't 56 | // prevent the value from being updated, unlike every other browser. If the user is in 57 | // forgiving spelling mode, it's quite possible for them to type too many characters, 58 | // and end up with a confusing answer. This workaround fixes the issue by setting the 59 | // value to the actual answer if the user types anything while in the "correct" state. 60 | ev.currentTarget.value = guess; 61 | } 62 | }; 63 | 64 | const onKeyDown = (ev: JSXInternal.TargetedKeyboardEvent) => { 65 | if (gameState.answered) { 66 | ev.preventDefault(); 67 | 68 | if (ev.key === 'Enter') { 69 | dispatch(goToNextPokemon()); 70 | } 71 | } 72 | }; 73 | 74 | const onGiveUpClick: JSXInternal.MouseEventHandler = (ev) => { 75 | // `detail` on a click event represents the number of mouse clicks or touches. If it's zero, 76 | // it means the click was trigged by keyboard. 77 | const wasTriggeredByKeyboard = ev.detail === 0; 78 | 79 | // Re-focus the input if this button press blurred it, or the give up button was triggered via keyboard 80 | if (isInputRecentlyFocused || wasTriggeredByKeyboard) { 81 | inputRef.current?.focus(); 82 | } 83 | 84 | dispatch(revealPokemon({ isCorrect: false })); 85 | setGuess(pokemonNames[settings.language]); 86 | }; 87 | 88 | // Reset the input when the Pokémon changes. 89 | useEffect(() => { 90 | // This "if" ensures the guess doesn't get cleared when hot reloading during development 91 | if (!gameState.answered) { 92 | setGuess(''); 93 | } 94 | }, [number]); 95 | 96 | return ( 97 |
98 |
99 | 118 | 119 | {gameState.answered && } 120 | 121 | 122 | {`${gameState.pokemon.currentIndex + 1} / ${gameState.pokemon.numbers.length}`} 123 | 124 |
125 | 126 | {gameState.answered ? ( 127 |
128 |

{lang.alsoknownas}

129 |
    130 | {Object.values(LANGUAGES) 131 | .filter((lang) => lang.code !== settings.language) 132 | .map((lang) => ( 133 |
  • 134 | 135 | {pokemonNames[lang.code]} 136 |
  • 137 | ))} 138 |
139 |
140 | ) : ( 141 | 148 | )} 149 | 150 | {!!settings.pendingSettings && ( 151 | {lang['settingsEffect']} 152 | )} 153 |
154 | ); 155 | }; 156 | 157 | export default AnswerInput; 158 | -------------------------------------------------------------------------------- /src/components/AudioPlayer.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'preact/hooks'; 2 | 3 | import { DIFFICULTY } from '../constants'; 4 | import { useAppDispatch } from '../store'; 5 | import { setPokemonLoaded } from '../store/gameSlice'; 6 | import { useCurrentPokemonNumber, useGameState, useLang, useSettings } from '../util/hooks'; 7 | import { getPokemonSoundUrl } from '../util/pokemon'; 8 | 9 | const AudioPlayer = () => { 10 | const dispatch = useAppDispatch(); 11 | 12 | const ref = useRef(null); 13 | const lang = useLang(); 14 | const gameState = useGameState(); 15 | const settings = useSettings(); 16 | const number = useCurrentPokemonNumber(); 17 | 18 | const shouldShowPlayer = settings.difficulty === DIFFICULTY.ELITE; 19 | 20 | useEffect(() => { 21 | if (gameState.answered) { 22 | ref.current?.play().catch(() => {}); 23 | } 24 | }, [gameState.answered]); 25 | 26 | useEffect(() => { 27 | if (shouldShowPlayer) { 28 | ref.current?.play() 29 | .then(() => dispatch(setPokemonLoaded())) 30 | .catch(() => {}); 31 | } 32 | }, [gameState.pokemon.currentIndex]); 33 | 34 | if (!settings.soundEnabled) { 35 | if (shouldShowPlayer) { 36 | return

{lang.eliteNeedsAudio}

; 37 | } 38 | 39 | return null; 40 | } 41 | 42 | return ( 43 | 53 | ); 54 | }; 55 | 56 | export default AudioPlayer; -------------------------------------------------------------------------------- /src/components/CountdownLoader.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'preact/hooks'; 2 | 3 | import { MILLISECONDS_BETWEEN_POKEMON } from '../constants'; 4 | import styles from './css/CountdownLoader.module.css'; 5 | 6 | type CountdownLoaderProps = { 7 | target?: number; 8 | size?: number; 9 | lineWidth?: number; 10 | }; 11 | 12 | const degreesToRadians = (degrees: number) => { 13 | return (Math.PI / 180) * degrees - (Math.PI / 180) * 90; 14 | }; 15 | 16 | /** Shows a loading arc, with the number of seconds remaining in the middle */ 17 | const CountdownLoader = ({ 18 | target = MILLISECONDS_BETWEEN_POKEMON, size = 24, lineWidth = 2, 19 | }: CountdownLoaderProps) => { 20 | const ref = useRef(null); 21 | const canvasRenderSize = size * window.devicePixelRatio; 22 | const lineRenderWidth = lineWidth * window.devicePixelRatio; 23 | 24 | const [count, setCount] = useState(Math.ceil(target / 1000)); 25 | 26 | useEffect(() => { 27 | const ctx = ref.current!.getContext('2d')!; 28 | 29 | const timeStarted = Date.now(); 30 | let animationFrameId: number; 31 | 32 | const drawArc = () => { 33 | const timePassed = Date.now() - timeStarted; 34 | 35 | const progress = Math.min(timePassed / target, 1); 36 | 37 | ctx.clearRect(0, 0, canvasRenderSize, canvasRenderSize); 38 | ctx.beginPath(); 39 | ctx.strokeStyle = '#fff'; 40 | ctx.arc( 41 | canvasRenderSize / 2, 42 | canvasRenderSize / 2, 43 | (canvasRenderSize - lineRenderWidth) / 2, 44 | degreesToRadians(0), 45 | degreesToRadians(360 * progress), 46 | ); 47 | ctx.lineWidth = lineRenderWidth; 48 | ctx.lineCap = 'square'; 49 | ctx.stroke(); 50 | 51 | setCount(Math.ceil((target - timePassed) / 1000)); 52 | 53 | if (progress < 1) { 54 | animationFrameId = window.requestAnimationFrame(drawArc); 55 | } 56 | }; 57 | 58 | drawArc(); 59 | 60 | return () => cancelAnimationFrame(animationFrameId); 61 | }, [target]); 62 | 63 | return ( 64 |
65 | 71 | {count} 72 |
73 | ); 74 | }; 75 | 76 | export default CountdownLoader; 77 | -------------------------------------------------------------------------------- /src/components/GameArea.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'preact/hooks'; 2 | 3 | import { DIFFICULTY } from '../constants'; 4 | import { useLang, useGameState, useSettings } from '../util/hooks'; 5 | import { preloadPokemonMedia } from '../util/pokemon'; 6 | import AnswerInput from './AnswerInput'; 7 | import AudioPlayer from './AudioPlayer'; 8 | import GenerationFinished from './GenerationFinished'; 9 | import PokemonSilhouette from './PokemonSilhouette'; 10 | 11 | type GameAreaProps = { 12 | onMenuOpen: (menu: 'settings' | 'stats') => void; 13 | }; 14 | 15 | const GameArea = ({ onMenuOpen }: GameAreaProps) => { 16 | const lang = useLang(); 17 | const settings = useSettings(); 18 | 19 | const gameState = useGameState(); 20 | 21 | // Handle when the Pokémon is revealed, whether with a correct answer, or by the "I don't know" button 22 | useEffect(() => { 23 | if (gameState.answered) { 24 | if (gameState.pokemon.currentIndex + 1 < gameState.pokemon.numbers.length - 1) { 25 | preloadPokemonMedia( 26 | gameState.pokemon.numbers[gameState.pokemon.currentIndex + 1], 27 | settings.difficulty, 28 | settings.soundEnabled, 29 | ); 30 | } 31 | } 32 | }, [gameState.answered]); 33 | 34 | return ( 35 |
36 |
37 | 40 | 41 |

{lang.title}

42 | 43 | 46 |
47 | 48 | {gameState.pokemon.currentIndex > gameState.pokemon.numbers.length - 1 ? ( 49 | 50 | ) : ( 51 | <> 52 | {settings.difficulty !== DIFFICULTY.ELITE && ( 53 | 54 | )} 55 | 56 | 57 | 58 | )} 59 |
60 | ); 61 | }; 62 | 63 | export default GameArea; 64 | -------------------------------------------------------------------------------- /src/components/GenerationFinished.tsx: -------------------------------------------------------------------------------- 1 | import { useAppDispatch } from '../store'; 2 | import { resetPokemon } from '../store/actions'; 3 | import { useLang, useSettings } from '../util/hooks'; 4 | 5 | const GenerationFinished = () => { 6 | const dispatch = useAppDispatch(); 7 | const lang = useLang(); 8 | 9 | const startAgain = () => { 10 | dispatch(resetPokemon()); 11 | }; 12 | 13 | return ( 14 |
15 |

{lang.genfinished}

16 | 17 | 20 |
21 | ); 22 | }; 23 | 24 | export default GenerationFinished; -------------------------------------------------------------------------------- /src/components/LanguageSelectorFlags.tsx: -------------------------------------------------------------------------------- 1 | import { LANGUAGES } from '../constants/lang'; 2 | import { useAppDispatch } from '../store'; 3 | import { setLanguage } from '../store/settingsSlice'; 4 | import { useSettings } from '../util/hooks'; 5 | 6 | const LanguageSelectorFlags = () => { 7 | const dispatch = useAppDispatch(); 8 | const settings = useSettings(); 9 | 10 | return ( 11 |
    12 | {Object.values(LANGUAGES).map((lang) => ( 13 |
  • 14 | 21 |
  • 22 | ))} 23 |
24 | ); 25 | }; 26 | 27 | export default LanguageSelectorFlags; 28 | -------------------------------------------------------------------------------- /src/components/NextPokemonTimer.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'preact/hooks'; 2 | 3 | import { MILLISECONDS_BETWEEN_POKEMON } from '../constants'; 4 | import { useAppDispatch } from '../store'; 5 | import { goToNextPokemon } from '../store/actions'; 6 | import CountdownLoader from './CountdownLoader'; 7 | import styles from './css/NextPokemonTimer.module.css'; 8 | 9 | const NextPokemonTimer = () => { 10 | const dispatch = useAppDispatch(); 11 | 12 | const [isCounting, setIsCounting] = useState(true); 13 | 14 | useEffect(() => { 15 | if (!isCounting) return; 16 | 17 | const timeoutId = setTimeout(() => { 18 | dispatch(goToNextPokemon()); 19 | }, MILLISECONDS_BETWEEN_POKEMON); 20 | 21 | const keyListener = (ev: KeyboardEvent) => { 22 | if (ev.key === 'Escape') { 23 | setIsCounting(false); 24 | } 25 | }; 26 | 27 | window.addEventListener('keydown', keyListener); 28 | 29 | return () => { 30 | clearTimeout(timeoutId); 31 | window.removeEventListener('keydown', keyListener); 32 | }; 33 | }, [isCounting]); 34 | 35 | return ( 36 |
37 | {isCounting ? ( 38 |
setIsCounting(false)} 42 | > 43 | 44 | 45 |
46 | 47 |
48 |
49 | ) : ( 50 | 56 | )} 57 |
58 | ); 59 | }; 60 | 61 | export default NextPokemonTimer; 62 | -------------------------------------------------------------------------------- /src/components/PokemonSilhouette.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { useEffect, useRef } from 'preact/hooks'; 3 | 4 | import { DIFFICULTY, Difficulty } from '../constants'; 5 | import { useAppDispatch } from '../store'; 6 | import { setPokemonLoaded } from '../store/gameSlice'; 7 | import { useCurrentPokemonNumber, useGameState, useSettings } from '../util/hooks'; 8 | import { getPokemonImageUrl } from '../util/pokemon'; 9 | 10 | const IMAGE_DIRECTORIES = { 11 | [DIFFICULTY.EASY]: 'images/artwork/', 12 | [DIFFICULTY.NORMAL]: 'images/artwork/', 13 | [DIFFICULTY.ULTRA]: 'images/sprites/front/', 14 | [DIFFICULTY.MASTER]: 'images/sprites/back/', 15 | [DIFFICULTY.ELITE]: null, 16 | } as const satisfies { 17 | [key in Difficulty]: string | null; 18 | }; 19 | 20 | /** Sprites get scaled up, but shouldn't be bigger than this size */ 21 | const MAX_SPRITE_SIZE = 400; 22 | 23 | const PokemonSilhouette = () => { 24 | const dispatch = useAppDispatch(); 25 | 26 | const canvasRef = useRef(null); 27 | 28 | const settings = useSettings(); 29 | const gameState = useGameState(); 30 | const number = useCurrentPokemonNumber(); 31 | 32 | const shouldSilhouette = !gameState.answered && settings.difficulty !== DIFFICULTY.EASY; 33 | 34 | useEffect(() => { 35 | if (!IMAGE_DIRECTORIES[settings.difficulty] || !canvasRef.current) return; 36 | 37 | const canvas = canvasRef.current; 38 | const ctx = canvas.getContext('2d', { willReadFrequently: true })!; 39 | 40 | // Wipes the canvas clean. This is useful if a Pokemon is slow to load, as we don't 41 | // want the previous Pokemon still there while the other is loading -- that's confusing to the user. 42 | ctx.clearRect(0, 0, canvas.width, canvas.height); 43 | 44 | const image = new Image(); 45 | image.src = getPokemonImageUrl(number, settings.difficulty); 46 | 47 | image.addEventListener('load', () => { 48 | // On higher difficulties, the images are smaller. This makes them bigger. 49 | if (Math.max(image.width, image.height) <= 200) { 50 | const multiplier = Math.floor(MAX_SPRITE_SIZE / image.width); 51 | canvas.width = image.width * multiplier; 52 | canvas.height = image.height * multiplier; 53 | // scales up sprites without smoothing so they look more crisp 54 | ctx.imageSmoothingEnabled = false; 55 | } else { 56 | canvas.width = image.width; 57 | canvas.height = image.height; 58 | ctx.imageSmoothingQuality = 'high'; 59 | } 60 | 61 | ctx.drawImage(image, 0, 0, canvas.width, canvas.height); 62 | 63 | if (shouldSilhouette) { 64 | const rawImage = ctx.getImageData(0,0,canvas.width,canvas.height); 65 | 66 | for (let i = 0; i < rawImage.data.length; i += 4) { 67 | if(rawImage.data[i+3] >= 100) { 68 | rawImage.data[i] = 30; 69 | rawImage.data[i+1] = 30; 70 | rawImage.data[i+2] = 30; 71 | rawImage.data[i+3] = 255; 72 | } else { 73 | rawImage.data[i+3] = 0; 74 | } 75 | } 76 | 77 | ctx.putImageData(rawImage,0,0); 78 | } 79 | 80 | if (!gameState.answered) { 81 | dispatch(setPokemonLoaded()); 82 | } 83 | }); 84 | }, [number, shouldSilhouette, settings.difficulty]); 85 | 86 | return ( 87 |
96 | 97 |
98 | ); 99 | }; 100 | 101 | export default PokemonSilhouette; 102 | -------------------------------------------------------------------------------- /src/components/SettingsMenu.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | 3 | import { DIFFICULTY, GENERATIONS } from '../constants'; 4 | import { useAppDispatch } from '../store'; 5 | import { setDifficulty, setForgivingSpellingEnabled, setSound, toggleGeneration } from '../store/settingsSlice'; 6 | import { useLang, useSettings } from '../util/hooks'; 7 | import LanguageSelectorFlags from './LanguageSelectorFlags'; 8 | 9 | /** These props are only used in the mobile view. The settings menu is always visible on desktop. */ 10 | type SettingsMenuProps = { 11 | isOpen: boolean; 12 | onClose: () => void; 13 | }; 14 | 15 | const SettingsMenu = ({ isOpen, onClose }: SettingsMenuProps) => { 16 | const dispatch = useAppDispatch(); 17 | const lang = useLang(); 18 | 19 | const settings = useSettings(); 20 | 21 | return ( 22 | <> 23 | {isOpen && ( 24 |
25 | )} 26 |
27 |

{lang.settings}

28 | 29 |
30 |

{lang.generation}

31 | 32 |
33 | {Object.values(GENERATIONS).map((generation) => { 34 | const isActive = settings.generations.includes(generation.id); 35 | const willBeActiveNextRound = !!( 36 | (!settings.pendingSettings?.generations && isActive) 37 | || settings.pendingSettings?.generations?.includes(generation.id) 38 | ); 39 | 40 | return ( 41 | 52 | ); 53 | })} 54 |
55 |
56 | 57 |
58 |

{lang.difficulty}

59 | 60 |
61 | {Object.values(DIFFICULTY).map((difficulty) => { 62 | const isActive = difficulty === settings.difficulty; 63 | const willBecomeActiveNextRound = difficulty === settings.pendingSettings?.difficulty; 64 | 65 | return ( 66 | 75 | ); 76 | })} 77 |
78 |
79 | 80 | {settings.language === 'en' && ( 81 |
82 |

{lang.spelling}

83 | 84 |
85 | 91 | 97 |
98 |
99 | )} 100 | 101 |
102 |

{lang.sound}

103 | 104 |
105 | 111 | 117 |
118 |
119 | 120 |
121 |

{lang.language}

122 | 123 |
124 | 125 |
126 |
127 | 128 | ); 129 | }; 130 | 131 | export default SettingsMenu; 132 | -------------------------------------------------------------------------------- /src/components/StatsListener.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'preact/hooks'; 2 | 3 | import { useAppDispatch } from '../store'; 4 | import { setAnswered } from '../store/statsSlice'; 5 | import { useCurrentPokemonNumber, useGameState, useSettings } from '../util/hooks'; 6 | 7 | /** This component doesn't render anything, but instead listen's to the game's state via Redux, 8 | * and stores stat data when a Pokémon has been revealed. Having this listener means we can 9 | * track stats easily, regardless of why the Pokémon was revealed. */ 10 | const StatsListener = () => { 11 | const dispatch = useAppDispatch(); 12 | 13 | const gameState = useGameState(); 14 | const settings = useSettings(); 15 | const pokemonNumber = useCurrentPokemonNumber(); 16 | 17 | useEffect(() => { 18 | if (gameState.answered) { 19 | dispatch(setAnswered({ 20 | difficulty: settings.difficulty, 21 | isCorrect: gameState.answered === 'correct', 22 | pokemonNumber: pokemonNumber, 23 | timeStarted: gameState.lastLoadedTime, 24 | })); 25 | } 26 | }, [gameState.answered]); 27 | 28 | return null; 29 | }; 30 | 31 | export default StatsListener; 32 | -------------------------------------------------------------------------------- /src/components/StatsMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from 'preact/hooks'; 2 | 3 | import { POKEMON_NAMES } from '../constants/pokemon'; 4 | import { useGameState, useLang, useSettings, useStats } from '../util/hooks'; 5 | import { formatStatTime } from '../util/stats'; 6 | import StatsModal from './StatsModal'; 7 | 8 | /** These props are only used in the mobile view. The stats menu is always visible on desktop. */ 9 | type StatsMenuProps = { 10 | isOpen: boolean; 11 | onClose: () => void; 12 | }; 13 | 14 | const StatsMenu = ({ isOpen, onClose }: StatsMenuProps) => { 15 | const lang = useLang(); 16 | const stats = useStats(); 17 | const settings = useSettings(); 18 | 19 | const [isModalOpen, setIsModalOpen] = useState(false); 20 | 21 | const previousPokemonStats = useMemo(() => { 22 | if (stats.lastSeen.length === 0) return null; 23 | 24 | const number = stats.lastSeen[0].pokemon; 25 | if (number === 0) return null; 26 | 27 | const pokemonStats = stats.pokemon[number]; 28 | if (!pokemonStats) return null; 29 | 30 | return { 31 | name: POKEMON_NAMES.find((pkmn) => pkmn.number === number)!.names[settings.language], 32 | timesSeen: pokemonStats.timesSeen, 33 | percentage: Math.ceil((pokemonStats.timesCorrect / pokemonStats.timesSeen) * 100), 34 | timeTaken: stats.lastSeen[0].time, 35 | averageTime: pokemonStats.timesCorrect > 0 ? pokemonStats.totalTime / pokemonStats.timesCorrect : null, 36 | }; 37 | }, [stats, settings.language]); 38 | 39 | return ( 40 | <> 41 | {isOpen && ( 42 |
43 | )} 44 |
45 |

{lang.stats}

46 | 47 |
48 |

{lang.streak}

49 | 50 |
51 |
52 |

{lang.current}

53 | 54 | {stats.streaks.current[settings.difficulty]} 55 | 56 |
57 | 58 |
59 |

{lang.best}

60 | 61 | {stats.streaks.best[settings.difficulty]} 62 | 63 |
64 |
65 |
66 | 67 | {previousPokemonStats !== null && ( 68 |
69 |

{previousPokemonStats.name}

70 | 71 |
72 |
73 |

{lang.statsTimesCorrect}

74 | 75 | {`${previousPokemonStats.percentage}%`} 76 | 77 |
78 | 79 |
80 |

{lang.statsTime}

81 | 82 | {formatStatTime(previousPokemonStats.timeTaken)} 83 | 84 |
85 | 86 |
87 |

{lang.statsAverageTime}

88 | 89 | {formatStatTime(previousPokemonStats.averageTime)} 90 | 91 |
92 |
93 | 94 | 100 |
101 | )} 102 | 103 | {!!settings.pendingSettings && ( 104 |
105 |
106 | {lang['streakInfo']} 107 |
108 |
109 | )} 110 |
111 | 112 | {isModalOpen && ( 113 | setIsModalOpen(false)} /> 114 | )} 115 | 116 | ); 117 | }; 118 | 119 | export default StatsMenu; 120 | -------------------------------------------------------------------------------- /src/components/StatsModal.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo } from 'preact/hooks'; 2 | import type { JSXInternal } from 'preact/src/jsx'; 3 | 4 | import { POKEMON_NAMES } from '../constants/pokemon'; 5 | import { useAppDispatch, useAppSelector } from '../store'; 6 | import { StatsTableKey, setStatsTableSort } from '../store/statsSlice'; 7 | import { useLang, useSettings, useStats } from '../util/hooks'; 8 | import { formatStatTime } from '../util/stats'; 9 | import styles from './css/StatsModal.module.css'; 10 | 11 | function isNotNull (arg: T): arg is Exclude { 12 | return arg !== null; 13 | } 14 | 15 | type StatsModalProps = { 16 | onClose: () => void; 17 | }; 18 | 19 | const StatsModal = ({ onClose }: StatsModalProps) => { 20 | const dispatch = useAppDispatch(); 21 | 22 | const lang = useLang(); 23 | const settings = useSettings(); 24 | const stats = useStats(); 25 | 26 | useEffect(() => { 27 | const handleKeyDown = (event: KeyboardEvent) => { 28 | if (event.key === 'Escape') { 29 | onClose(); 30 | } 31 | }; 32 | 33 | window.addEventListener('keydown', handleKeyDown); 34 | return () => window.removeEventListener('keydown', handleKeyDown); 35 | }, []); 36 | 37 | const tableData = useMemo(() => { 38 | const sortSign = stats.tableSort.ascending ? -1 : 1; 39 | 40 | const data = Object.values(POKEMON_NAMES) 41 | .map((pokemon) => { 42 | const pokemonStats = stats.pokemon[pokemon.number]; 43 | 44 | if (!pokemonStats) return null; 45 | 46 | return { 47 | [StatsTableKey.number]: pokemon.number, 48 | [StatsTableKey.name]: pokemon.names[settings.language], 49 | [StatsTableKey.correct]: pokemonStats ? Math.ceil((pokemonStats.timesCorrect / pokemonStats.timesSeen) * 100) : -1, 50 | [StatsTableKey.time]: pokemonStats && pokemonStats.timesCorrect > 0 ? pokemonStats.totalTime / pokemonStats.timesCorrect : -1, 51 | [StatsTableKey.seen]: pokemonStats?.timesSeen ?? 0, 52 | }; 53 | }) 54 | .filter(isNotNull) 55 | .sort((row1, row2) => { 56 | return row2[stats.tableSort.key] > row1[stats.tableSort.key] ? sortSign : -sortSign; 57 | }); 58 | 59 | return data; 60 | }, [stats]); 61 | 62 | const onHeaderClick = useCallback((ev: JSXInternal.TargetedMouseEvent) => { 63 | const key = ev.currentTarget.getAttribute('data-sort-key') as StatsTableKey; 64 | const ascending = stats.tableSort.key === key ? !stats.tableSort.ascending : true; 65 | 66 | dispatch(setStatsTableSort({ key, ascending })); 67 | }, [dispatch, stats.tableSort]); 68 | 69 | return ( 70 |
71 |
72 | 73 |
74 | 75 | 76 | 77 | 84 | 91 | 98 | 105 | 112 | 113 | 114 | 115 | {tableData.map((data) => { 116 | return ( 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | ); 125 | })} 126 | 127 |
82 | {lang.statsNumber} 83 | 89 | {lang.statsName} 90 | 96 | {lang.statsTimesCorrect} 97 | 103 | {lang.statsAverageTime} 104 | 110 | {lang.statsTimesSeen} 111 |
{`${data.number.toString().padStart(3, '0')}`}{data.name}{data.correct > -1 ? `${data.correct}%` : '-'}{data.time > - 1 ? formatStatTime(data.time) : '-'}{data.seen}
128 | 129 | 132 |
133 |
134 | ); 135 | }; 136 | 137 | export default StatsModal; 138 | -------------------------------------------------------------------------------- /src/components/css/CountdownLoader.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | position: relative; 6 | } 7 | 8 | .countdownText { 9 | position: absolute; 10 | color: var(--color-text-contrast); 11 | font-size: 80%; 12 | line-height: 1; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/css/NextPokemonTimer.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: absolute; 3 | top: 50%; 4 | transform: translateY(-50%); 5 | right: 8px; 6 | 7 | button { 8 | display: flex; 9 | } 10 | } 11 | 12 | .countdownContainer { 13 | position: relative; 14 | display: flex; 15 | justify-content: center; 16 | align-items: center; 17 | cursor: pointer; 18 | animation: slideInRight .2s ease; 19 | 20 | &:hover { 21 | .iconPauseContainer { 22 | opacity: 1; 23 | } 24 | } 25 | } 26 | 27 | .iconPauseContainer { 28 | position: absolute; 29 | inset: 2px; 30 | display: flex; 31 | justify-content: center; 32 | align-items: center; 33 | background-color: var(--color-positive); 34 | border-radius: 100px; 35 | opacity: 0; 36 | transition: opacity .2s ease; 37 | 38 | img { 39 | width: 16px; 40 | height: 16px; 41 | } 42 | } 43 | 44 | .nextButton { 45 | animation: slideInRight .2s ease; 46 | } 47 | 48 | @keyframes slideInRight { 49 | 0% { transform: translateX(4px) } 50 | 100% { transform: translateX(0px) } 51 | } 52 | -------------------------------------------------------------------------------- /src/components/css/StatsModal.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | z-index: 100; 3 | position: fixed; 4 | top: 0; 5 | right: 0; 6 | bottom: 0; 7 | left: 0; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | } 12 | 13 | .backdrop { 14 | z-index: 101; 15 | position: absolute; 16 | top: 0; 17 | right: 0; 18 | bottom: 0; 19 | left: 0; 20 | background-color: rgba(0,0,0,0.6); 21 | } 22 | 23 | .content { 24 | z-index: 102; 25 | position: relative; 26 | background-color: var(--color-modal-content); 27 | margin: 32px 12px; 28 | border-radius: 8px; 29 | max-width: 100%; 30 | max-height: calc(100% - 64px); 31 | overflow-y: auto; 32 | } 33 | 34 | .table { 35 | text-transform: capitalize; 36 | width: 100%; 37 | background-color: #fafafa; 38 | overflow: hidden; 39 | text-align: left; 40 | 41 | thead tr { 42 | th { 43 | padding: 8px 10px; 44 | background-color: var(--color-primary); 45 | color: var(--color-text-contrast); 46 | cursor: pointer; 47 | 48 | &[data-active=true] { 49 | position: relative; 50 | 51 | &::after { 52 | position: absolute; 53 | right: 0; 54 | top: 50%; 55 | transform: translateY(-50%); 56 | font-size: 60%; 57 | content: '▲'; 58 | } 59 | } 60 | } 61 | 62 | &[data-sort-ascending=false] th[data-active=true]::after { 63 | content: '▼'; 64 | } 65 | } 66 | 67 | tr:nth-child(2n + 1) { 68 | background-color: #eee; 69 | } 70 | 71 | td { 72 | padding: 8px; 73 | 74 | &:first-child { 75 | font-family: monospace; 76 | margin: 0; 77 | background-color: var(--color-primary); 78 | color: var(--color-text-contrast); 79 | } 80 | } 81 | } 82 | 83 | .closeButton { 84 | position: absolute; 85 | top: 0; 86 | right: 0; 87 | width: 20px; 88 | height: 20px; 89 | border-bottom-left-radius: 4px; 90 | display: flex; 91 | align-items: center; 92 | justify-content: center; 93 | background-color: var(--color-side-box); 94 | } 95 | 96 | @media (min-width: 460px) { 97 | .closeButton { 98 | display: none; /* hide the close button since the backdrop is big enough to click */ 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | import type { PokemonNumber } from './pokemon'; 2 | 3 | export const DIFFICULTY = { 4 | EASY: 4, 5 | NORMAL: 0, 6 | ULTRA: 1, 7 | MASTER: 2, 8 | ELITE: 3, 9 | } as const; 10 | 11 | export type DifficultyKey = keyof typeof DIFFICULTY; 12 | export type Difficulty = typeof DIFFICULTY[DifficultyKey]; 13 | 14 | export type GenerationId = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; 15 | 16 | export type Generation = { 17 | id: GenerationId; 18 | start: PokemonNumber; 19 | end: PokemonNumber; 20 | supportedDifficulties: readonly Difficulty[]; 21 | games: string; 22 | }; 23 | 24 | export const GENERATIONS: { [key in GenerationId]: Generation } = { 25 | 1: { 26 | id: 1, 27 | start: 1, 28 | end: 151, 29 | supportedDifficulties: [DIFFICULTY.NORMAL, DIFFICULTY.ULTRA, DIFFICULTY.MASTER, DIFFICULTY.ELITE, DIFFICULTY.EASY], 30 | games: 'Red, Blue, & Yellow', 31 | }, 32 | 2: { 33 | id: 2, 34 | start: 152, 35 | end: 251, 36 | supportedDifficulties: [DIFFICULTY.NORMAL, DIFFICULTY.ULTRA, DIFFICULTY.MASTER, DIFFICULTY.ELITE, DIFFICULTY.EASY], 37 | games: 'Gold, Silver, & Crystal', 38 | }, 39 | 3: { 40 | id: 3, 41 | start: 252, 42 | end: 386, 43 | supportedDifficulties: [DIFFICULTY.NORMAL, DIFFICULTY.ULTRA, DIFFICULTY.MASTER, DIFFICULTY.ELITE, DIFFICULTY.EASY], 44 | games: 'Ruby, Sapphire, & Emerald', 45 | }, 46 | 4: { 47 | id: 4, 48 | start: 387, 49 | end: 493, 50 | supportedDifficulties: [DIFFICULTY.NORMAL, DIFFICULTY.ULTRA, DIFFICULTY.MASTER, DIFFICULTY.ELITE, DIFFICULTY.EASY], 51 | games: 'Diamond, Pearl, & Platinum', 52 | }, 53 | 5: { 54 | id: 5, 55 | start: 494, 56 | end: 649, 57 | supportedDifficulties: [DIFFICULTY.NORMAL, DIFFICULTY.ULTRA, DIFFICULTY.MASTER, DIFFICULTY.ELITE, DIFFICULTY.EASY], 58 | games: 'Black & White', 59 | }, 60 | 6: { 61 | id: 6, 62 | start: 650, 63 | end: 721, 64 | supportedDifficulties: [DIFFICULTY.NORMAL, DIFFICULTY.ULTRA, DIFFICULTY.MASTER, DIFFICULTY.ELITE, DIFFICULTY.EASY], 65 | games: 'X & Y', 66 | }, 67 | 7: { 68 | id: 7, 69 | start: 722, 70 | end: 807, 71 | supportedDifficulties: [DIFFICULTY.NORMAL, DIFFICULTY.ULTRA, DIFFICULTY.MASTER, DIFFICULTY.ELITE, DIFFICULTY.EASY], 72 | games: 'Sun, Moon, Ultra Sun, & Ultra Moon', 73 | }, 74 | 8: { 75 | id: 8, 76 | // Technically gen 8 starts at 810, but 808 and 809 don't have sprites, and so are closer to being gen 8 77 | // than gen 7 (they were introduced in Let's Go) 78 | start: 808, 79 | end: 905, 80 | supportedDifficulties: [DIFFICULTY.NORMAL, DIFFICULTY.ELITE, DIFFICULTY.EASY], 81 | games: 'Let\'s Go, Sword, Shield, & Legends: Arceus', 82 | }, 83 | 9: { 84 | id: 9, 85 | start: 906, 86 | end: 1025, 87 | supportedDifficulties: [DIFFICULTY.NORMAL, DIFFICULTY.ELITE, DIFFICULTY.EASY], 88 | games: 'Scarlet & Violet', 89 | }, 90 | } as const; 91 | 92 | export const MILLISECONDS_BETWEEN_POKEMON = 3000; 93 | -------------------------------------------------------------------------------- /src/constants/lang.ts: -------------------------------------------------------------------------------- 1 | export type LanguageId = 'en' | 'de' | 'fr' | 'ja' | 'zh'; 2 | 3 | export const LANGUAGES = { 4 | en: { 5 | flagUrl: 'images/flags/gb.svg', 6 | code: 'en', 7 | }, 8 | de: { 9 | flagUrl: 'images/flags/de.svg', 10 | code: 'de', 11 | }, 12 | fr: { 13 | flagUrl: 'images/flags/fr.svg', 14 | code: 'fr', 15 | }, 16 | ja: { 17 | flagUrl: 'images/flags/jp.svg', 18 | code: 'ja', 19 | }, 20 | zh: { 21 | flagUrl: 'images/flags/cn.svg', 22 | code: 'zh', 23 | }, 24 | } as const satisfies { 25 | [langId in LanguageId]: { 26 | flagUrl: string; 27 | code: LanguageId; 28 | } 29 | }; 30 | 31 | const LANG_EN = { 32 | title: "Who's That Pokémon?", 33 | dontknow: "I don't know!", 34 | generation: 'Generation', 35 | difficulty: 'Difficulty', 36 | 'difficulty-0': 'Normal', 37 | 'difficulty-1': 'Ultra', 38 | 'difficulty-2': 'Master', 39 | 'difficulty-3': 'Elite', 40 | 'difficulty-4': 'Easy', 41 | spelling: 'Spelling', 42 | 'spelling-exact': 'Exact', 43 | 'spelling-forgiving': 'Forgiving', 44 | sound: 'Sound', 45 | on: 'On', 46 | off: 'Off', 47 | streak: 'Streak', 48 | best: 'Best', 49 | current: 'Current', 50 | previous: 'Previous', 51 | alsoknownas: 'Also Known As', 52 | nextpokemon: 'Next Pokémon in _TIME_ seconds (press Enter to skip)', 53 | loadfail: 'This is taking a while to load. Do you want to try loading another one? It won\'t affect your streak. Load a new Pokémon?', 54 | slowconn: 'Is your connection slow or down? Maybe try a harder difficulty, they load faster.', 55 | loadnew: 'Load a new Pokémon?', 56 | streakInfo: 'Note: Your streaks and times are separate for each difficulty', 57 | settingsEffect: 'Your new settings will take effect for the next Pokémon', 58 | genfinished: "There are no Pokémon left! Why not try a different generation or difficulty? Or you can start again with the same settings. Note that Gen 8 and 9 don't support Ultra and Master difficulties as these games don't have sprites.", 59 | startAgain: 'Start again', 60 | infobox: 'NEW: Indigo Disk support added! Since Gen 8, Pokémon do not have sprites available, so only Easy, Normal and Elite modes are supported. If you find any issues, let me know on GitHub!', 61 | footer: 'Send feedback or contribute at GitHub. Images and sounds contributed by sora10pls.
Japanese data via Tabikana Travel Japanese. Images and sounds © Nintendo.', 62 | language: 'Language', 63 | settings: 'Settings', 64 | eliteNeedsAudio: 'You must set Sound to On to play in Elite mode', 65 | stats: 'Stats', 66 | seeAllStats: 'See all stats', 67 | statsNumber: 'No.', 68 | statsName: 'Name', 69 | statsTime: 'Time Taken', 70 | statsAverageTime: 'Avg Time', 71 | statsTimesSeen: 'Times Seen', 72 | statsTimesCorrect: 'Correct %', 73 | }; 74 | 75 | export type TranslationKey = keyof typeof LANG_EN; 76 | 77 | type TranslationObject = { 78 | [key in TranslationKey]?: string; 79 | }; 80 | 81 | const LANG_FR: TranslationObject = { 82 | title: 'Quel est ce Pokémon ?', 83 | dontknow: 'Je ne sais pas !', 84 | generation: 'Génération', 85 | difficulty: 'Difficulté', 86 | 'difficulty-0': 'Normale', 87 | 'difficulty-1': 'Hyper', 88 | 'difficulty-2': 'Master', 89 | 'difficulty-3': 'Élite', 90 | 'difficulty-4': 'Facile', 91 | spelling: 'Orthographe', 92 | 'spelling-exact': 'Exacte', 93 | 'spelling-forgiving': 'Tolérant', 94 | sound: 'Son', 95 | on: 'Oui', 96 | off: 'Non', 97 | streak: 'Consécutif', 98 | best: 'Meilleur', 99 | current: 'Actuel', 100 | previous: 'Précédent', 101 | alsoknownas: 'Aussi connu sous le nom de', 102 | nextpokemon: 'Pokémon suivante dans _TIME_ secondes (ou Entrée pour sauter)', 103 | loadfail: 'C\'est lent à charger. Essayez un autre ?', 104 | slowconn: 'Connexion lente ?', 105 | loadnew: 'Charger un nouveau Pokémon ?', 106 | streakInfo: 'Vos records sont distincts pour chaque difficulté', 107 | settingsEffect: 'Vos nouveaux paramètres prendront effet pour la prochaine Pokémon', 108 | genfinished: "Il n'y a plus de Pokémon ! Essayez une génération ou une difficulté différente. Les Gen 8 et 9 ne prennent pas en charge les difficultés Hyper et Master car ces jeux n'ont pas de sprites.", 109 | startAgain: 'Start again', 110 | footer: 'Contribuez à Github. Les images et les sons contribué par sora10pls.
Infos japonaises de Tabikana Travel Japanese. Les images et les sons © Nintendo.', 111 | language: 'Langue', 112 | settings: 'Options', 113 | stats: 'Stats', 114 | }; 115 | 116 | const LANG_DE: TranslationObject = { 117 | title: 'Wer ist das Pokémon?', 118 | dontknow: 'Ich weiß nicht', 119 | generation: 'Generation', 120 | difficulty: 'Schwierigkeit', 121 | 'difficulty-0': 'Normale', 122 | 'difficulty-1': 'Hyper', 123 | 'difficulty-2': 'Meister', 124 | 'difficulty-3': 'Elite', 125 | 'difficulty-4': 'Einfach', 126 | spelling: 'Rechtschreibung', 127 | 'spelling-exact': 'Exakt', 128 | 'spelling-forgiving': 'Tolerant', 129 | sound: 'Ton', 130 | on: 'Ja', 131 | off: 'Nein', 132 | streak: 'Konsekutiv', 133 | best: 'Beste', 134 | current: 'Aktuell', 135 | previous: 'Früher', 136 | alsoknownas: 'Also Known As', 137 | nextpokemon: 'Weiter Pokémon in _TIME_ Sekunden (oder Enter zum Überspringen)', 138 | loadfail: 'Dies ist nur langsam geladen. Versuchen Sie ein anderes?', 139 | slowconn: 'Langsame Verbindung?', 140 | loadnew: 'Legen Sie eine neue Pokémon?', 141 | streakInfo: 'Ihre Aufzeichnungen sind für jeden Schwierigkeitsgrad separaten', 142 | settingsEffect: 'Ihre neuen Einstellungen werden für die nächste Pokémon nehmen', 143 | genfinished: 'Es gibt keine Pokémon mehr! Versuchen Sie es mit einer anderen Generation oder Schwierigkeit. Gen 8 und 9 unterstützen keine Hyper- und Meister-Schwierigkeiten, da diese Spiele keine Sprites haben.', 144 | footer: 'Beitragen bei Github. Bilder und Töne beigetragen von sora10pls.
Japanische Infos von Tabikana Travel Japanese. Bilder und Töne © Nintendo.', 145 | language: 'Sprache', 146 | settings: 'Options', 147 | stats: 'Statistik', 148 | }; 149 | 150 | const LANG_JA: TranslationObject = { 151 | title: 'だれだ?', 152 | dontknow: 'わかりません', 153 | generation: '世代', 154 | difficulty: '難度', 155 | 'difficulty-0': '普通', 156 | 'difficulty-1': 'ハイパー', 157 | 'difficulty-2': 'マスター', 158 | 'difficulty-3': '天王', 159 | 'difficulty-4': '簡単', 160 | spelling: 'スペリング', 161 | 'spelling-exact': '正確', 162 | 'spelling-forgiving': '甘い', 163 | sound: 'サウンド', 164 | on: 'オン', 165 | off: 'オフ', 166 | streak: '連勝', 167 | best: 'ベスト', 168 | current: '現在', 169 | previous: '前回', 170 | alsoknownas: '他の言語での名前', 171 | nextpokemon: '_TIME_秒で次のポケモンが登場します(リターンキーを押してスキップします)', 172 | loadfail: '通信エラーが発生したため、新しいポケモンをロードするために、ここにクリックしてください。連勝が影響を受けません。', 173 | slowconn: '接続が遅い可能性があります。ロード時間を改善するために、他の難度をご選択ください。', 174 | loadnew: '新たなポケモンをロードしますか?', 175 | streakInfo: '各難度で連勝や時間が違います。', 176 | settingsEffect: 'この後で新しい設定が有効になります。', 177 | genfinished: 'ポケモンが残っていません! 別の世代または難易度を試してください。 第8世代か第9世代は、これらのゲームにはスプライトがないため、ハイパー および マスター の難易度をサポートしていません。', 178 | footer: 'フィードバック:GitHubsora10plsが収集した画像と音声。
Tabikana Travel Japaneseはカタカナチェックをした。画像と音声 © Nintendo.', 179 | language: 'ランゲージ', 180 | settings: 'オプション', 181 | stats: 'レコード', 182 | }; 183 | 184 | const LANG_ZH: TranslationObject = { 185 | title: '我是谁?', 186 | dontknow: '我不知道!', 187 | generation: '世代', 188 | difficulty: '难度', 189 | 'difficulty-0': '普通', 190 | 'difficulty-1': '超级', 191 | 'difficulty-2': '大师', 192 | 'difficulty-3': '精英', 193 | 'difficulty-4': '简单', 194 | spelling: '拼写', 195 | 'spelling-exact': '准确', 196 | 'spelling-forgiving': '模糊', 197 | sound: '声音', 198 | on: '开', 199 | off: '关', 200 | streak: '连胜', 201 | best: '最佳', 202 | current: '当前', 203 | previous: '之前', 204 | alsoknownas: '也被称作', 205 | nextpokemon: '下一只宝可梦将在 _TIME_ 秒后出现 (按回车键跳过)', 206 | loadfail: '加载时间较长。是否要加载其他的宝可梦?这将不会影响连胜数。加载一个新的宝可梦!', 207 | slowconn: '网络连接似乎有些慢。你可以尝试更高的难度,可以提高加载速度。', 208 | loadnew: '加载一个新的宝可梦!', 209 | streakInfo: '每个难度的连胜次数和时间都是分开的', 210 | settingsEffect: '新设置将对下一只宝可梦生效', 211 | genfinished: '没有宝可梦了! 尝试不同的世代或难度。 第八世代 和 第九世代 不支持 超级 和 大师 难度,因为这些游戏没有精灵。', 212 | footer: '请在 GitHub 上提交反馈或贡献。图像和音频由 sora10pls 提供。
来自Tabikana Travel Japanese的日文信息。图像和音频 © Nintendo。', 213 | language: '语言', 214 | settings: '设置', 215 | stats: '状态', 216 | }; 217 | 218 | export const TRANSLATIONS = { 219 | en: LANG_EN, 220 | fr: LANG_FR, 221 | de: LANG_DE, 222 | ja: LANG_JA, 223 | zh: LANG_ZH, 224 | } as const; 225 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-background: #d7dbdd; 3 | --color-box-background: rgba(255, 255, 255, 0.25); 4 | --color-side-box: #34495e; 5 | --color-side-box-selected: #fff; 6 | --color-side-box-faded: #9dbbd9; 7 | --color-primary: #3498db; 8 | --color-text: #333; 9 | --color-text-contrast: #fff; 10 | --color-text-muted: #666; 11 | --color-text-muted-shadow: #e1e1e1; 12 | --color-positive: #27ae60; 13 | --color-negative: #c0392b; 14 | --color-modal-content: #fff; 15 | --font-family: 'Cantora One', sans-serif; 16 | } 17 | 18 | * { 19 | box-sizing: border-box; 20 | } 21 | 22 | @font-face { 23 | font-family: 'Cantora One'; 24 | font-style: normal; 25 | font-weight: 400; 26 | /* This edited version of Cantora One fixes an issue where "Stufful" appeared as "Stuffhl" */ 27 | src: url(../fonts/cantora-one-fixed.woff); 28 | } 29 | 30 | html, body, #stage, .app { 31 | margin: 0; 32 | padding: 0; 33 | height: 100%; 34 | width: 100%; 35 | } 36 | 37 | body { 38 | background: var(--color-background); 39 | text-align: center; 40 | font-family: var(--font-family); 41 | max-height: 100%; 42 | font-size: 16px; 43 | } 44 | 45 | a { 46 | text-decoration: none; 47 | font-weight: bold; 48 | color: var(--color-text); 49 | cursor: pointer; 50 | } 51 | 52 | button { 53 | border: 0; 54 | padding: 0; 55 | background-color: transparent; 56 | font-family: var(--font-family); 57 | color: var(--color-text-contrast); 58 | cursor: pointer; 59 | } 60 | 61 | h1, h2, h3 { 62 | color: var(--color-text-contrast); 63 | text-shadow: 1px 1px rgba(0,0,0,0.5); 64 | margin: 0; 65 | } 66 | 67 | ul { 68 | list-style: none; 69 | margin: 0; 70 | padding: 0; 71 | } 72 | 73 | .app { 74 | display: flex; 75 | flex-direction: column; 76 | justify-content: center; 77 | align-items: center; 78 | margin: auto; 79 | width: 760px; 80 | } 81 | 82 | .app-inner { 83 | display: flex; 84 | align-self: stretch; 85 | } 86 | 87 | .app-inner-main { 88 | display: flex; 89 | flex-direction: column; 90 | flex: 1; 91 | } 92 | 93 | /* Language selector */ 94 | 95 | .language-selector-flags { 96 | display: flex; 97 | gap: 4px; 98 | align-self: flex-end; 99 | margin-bottom: 10px; 100 | } 101 | 102 | .language-selector-flags button { 103 | display: flex; 104 | justify-content: center; 105 | border-radius: 2px; 106 | overflow: hidden; 107 | } 108 | 109 | .language-selector-flags button.selected { 110 | box-shadow: 0 0 0 2px var(--color-primary); 111 | } 112 | 113 | .language-selector-flags img { 114 | display: block; 115 | width: 20px; 116 | height: 15px; 117 | object-fit: cover; 118 | } 119 | 120 | /* Settings and Stats */ 121 | 122 | .menu { 123 | display: flex; 124 | flex-direction: column; 125 | min-width: 120px; 126 | max-width: 120px; 127 | padding-top: 40px; 128 | } 129 | 130 | .menu h1 { 131 | display: none; 132 | } 133 | 134 | .menu h2 { 135 | font-size: 100%; 136 | margin-bottom: 2px; 137 | color: var(--color-side-box); 138 | text-shadow: 1px 1px var(--color-text-contrast); 139 | text-transform: capitalize; 140 | } 141 | 142 | .menu h3 { 143 | font-size: 90%; 144 | color: var(--color-side-box-faded); 145 | } 146 | 147 | .menu-section-mobile-only { 148 | display: none; 149 | } 150 | 151 | .menu-section button { 152 | padding: 5px; 153 | font-size: 100%; 154 | background-color: var(--color-side-box); 155 | color: var(--color-side-box-faded); 156 | } 157 | 158 | .menu-section button.selected { 159 | background-color: var(--color-side-box-selected); 160 | color: var(--color-side-box); 161 | } 162 | 163 | .menu-section button.pending { 164 | background-color: var(--color-side-box-faded); 165 | color: var(--color-side-box); 166 | } 167 | 168 | .menu-section-inner { 169 | display: flex; 170 | flex-direction: column; 171 | overflow: hidden; 172 | background-color: var(--color-side-box); 173 | color: var(--color-text-contrast); 174 | margin-bottom: 10px; 175 | } 176 | 177 | .settings .menu-section-inner { 178 | border-top-left-radius: 10px; 179 | border-bottom-left-radius: 10px; 180 | } 181 | 182 | .stats .menu-section-inner { 183 | border-top-right-radius: 10px; 184 | border-bottom-right-radius: 10px; 185 | padding: 5px; 186 | gap: 10px; 187 | } 188 | 189 | .menu-credits { 190 | display: none; 191 | padding: 8px; 192 | margin: 16px -4px 0; 193 | background-color: var(--color-box-background); 194 | border-radius: 8px; 195 | color: var(--color-text-contrast); 196 | font-size: 80%; 197 | align-self: flex-end; 198 | justify-self: flex-end; 199 | } 200 | 201 | .menu-credits a { 202 | font-weight: 700; 203 | color: var(--color-text-contrast); 204 | text-decoration: underline; 205 | } 206 | 207 | .stats .streak-note { 208 | font-size: 80%; 209 | } 210 | 211 | .settings-generations-grid { 212 | display: grid; 213 | grid-template-columns: repeat(3, 1fr); 214 | } 215 | 216 | .settings-list-sound { 217 | display: grid; 218 | grid-template-columns: repeat(2, 1fr); 219 | } 220 | 221 | .stats-number { 222 | font-size: 200%; 223 | line-height: 1; 224 | } 225 | 226 | button.see-all-stats-button { 227 | background-color: transparent; 228 | font-size: 80%; 229 | color: var(--color-side-box); 230 | } 231 | 232 | /* Game Area */ 233 | 234 | .game-area { 235 | flex: 1; 236 | display: flex; 237 | flex-direction: column; 238 | gap: 20px; 239 | padding: 15px; 240 | background-color: var(--color-primary); 241 | color: var(--color-text-contrast); 242 | min-height: 640px; 243 | width: 100%; 244 | border-radius: 10px; 245 | } 246 | 247 | header h1 { 248 | margin: 10px 0; 249 | } 250 | 251 | header button { 252 | display: none; 253 | } 254 | 255 | .canvas-container { 256 | display: flex; 257 | flex-direction: column; 258 | justify-content: center; 259 | align-items: center; 260 | min-height: 350px; 261 | } 262 | 263 | .canvas-container-sprite-mode { 264 | image-rendering: pixelated; 265 | } 266 | 267 | @keyframes canvasAnimation { 268 | 0% { 269 | opacity: 0.2; 270 | } 271 | 100% { 272 | opacity: 1; 273 | } 274 | } 275 | 276 | .canvas-container-animated { 277 | animation: canvasAnimation .3s ease; 278 | } 279 | 280 | @media (prefers-reduced-motion) { 281 | .canvas-container-animated { 282 | animation: none; 283 | } 284 | } 285 | 286 | .sound-off-warning { 287 | background-color: var(--color-negative); 288 | align-self: center; 289 | padding: 10px; 290 | border-radius: 4px; 291 | } 292 | 293 | .audio-player { 294 | align-self: center; 295 | } 296 | 297 | .answer-area { 298 | display: flex; 299 | flex-direction: column; 300 | align-items: center; 301 | gap: 7px; 302 | } 303 | 304 | .answer-input-container { 305 | position: relative; 306 | } 307 | 308 | .progress-counter { 309 | position: absolute; 310 | top: 100%; 311 | right: 0; 312 | padding: 2px; 313 | font-size: 80%; 314 | } 315 | 316 | .answer-input { 317 | height: 50px; 318 | font-size: 175%; 319 | min-width: 200px; 320 | border: 0; 321 | border-radius: 10px; 322 | text-align: center; 323 | text-transform: capitalize; 324 | font-family: 'Cantora One', sans-serif; 325 | outline: none; 326 | } 327 | 328 | .answer-input-correct { 329 | transition: background-color .1s; 330 | background-color: var(--color-positive); 331 | color: var(--color-text-contrast); 332 | } 333 | 334 | @media (prefers-reduced-motion) { 335 | .answer-input-correct { 336 | transition: none; 337 | } 338 | } 339 | 340 | .dont-know-button { 341 | text-shadow: 1px 1px var(--color-negative); 342 | font-size: 120%; 343 | padding: 1px 8px; 344 | } 345 | 346 | .also-known-as h2 { 347 | font-size: 100%; 348 | } 349 | 350 | .also-known-as ul { 351 | display: flex; 352 | align-items: center; 353 | justify-content: center; 354 | flex-wrap: wrap; 355 | gap: 4px; 356 | list-style: none; 357 | padding: 0; 358 | margin: 4px 0 0 0; 359 | } 360 | 361 | .also-known-as ul li { 362 | display: flex; 363 | align-items: center; 364 | gap: 4px; 365 | color: var(--color-text-contrast); 366 | background-color: var(--color-box-background); 367 | padding: 4px 8px; 368 | border-radius: 4px; 369 | text-transform: capitalize; 370 | } 371 | 372 | .also-known-as img { 373 | width: 18px; 374 | height: 13px; 375 | border-radius: 2px; 376 | object-fit: cover; 377 | } 378 | 379 | .new-settings-effect { 380 | background-color: var(--color-side-box); 381 | padding: 4px 12px; 382 | border-radius: 8px; 383 | } 384 | 385 | .generation-finished p { 386 | margin: 0; 387 | } 388 | 389 | .generation-finished button { 390 | font-size: 150%; 391 | padding: 5px 10px; 392 | margin-top: 10px; 393 | background-color: var(--color-side-box); 394 | border-radius: 4px; 395 | } 396 | 397 | footer { 398 | color: var(--color-text-muted); 399 | font-size: 75%; 400 | margin: 1em; 401 | text-align: center; 402 | text-shadow: 1px 1px var(--color-text-muted-shadow); 403 | } 404 | 405 | footer a { 406 | color: var(--color-text-muted); 407 | text-decoration: underline; 408 | } 409 | 410 | @media (max-width: 460px) { 411 | body { 412 | background-color: var(--color-primary); 413 | } 414 | 415 | .app, .app-inner, .app-inner-main, .game-area { 416 | width: 100%; 417 | height: 100%; 418 | } 419 | 420 | .game-area { 421 | border-radius: 0; 422 | min-height: 0; 423 | padding: 0; 424 | } 425 | 426 | header { 427 | position: absolute; 428 | top: 0; 429 | right: 0; 430 | left: 0; 431 | display: flex; 432 | align-items: center; 433 | justify-content: space-between; 434 | gap: 4px; 435 | height: 48px; 436 | line-height: 48px; 437 | } 438 | 439 | header h1 { 440 | margin: 0; 441 | font-size: 125%; 442 | } 443 | 444 | header button { 445 | display: block; 446 | height: 36px; 447 | width: 36px; 448 | padding: 6px; 449 | margin: 6px 0; 450 | background-color: rgba(255, 255, 255, 0.25); 451 | box-shadow: 0px 2px 0px rgba(0, 0, 0, 0.2); 452 | } 453 | 454 | header button:first-child { 455 | border-top-right-radius: 4px; 456 | border-bottom-right-radius: 4px; 457 | } 458 | 459 | header button:last-child { 460 | border-top-left-radius: 4px; 461 | border-bottom-left-radius: 4px; 462 | } 463 | 464 | header button img { 465 | width: 100%; 466 | height: 100%; 467 | display: block; 468 | } 469 | 470 | .canvas-container { 471 | min-height: 0; 472 | flex: 1; 473 | padding: 10px; 474 | padding-top: 50px; /* Push canvas below the header text */ 475 | } 476 | 477 | .canvas-container canvas { 478 | height: auto; 479 | max-height: 100%; 480 | max-width: 90%; 481 | min-height: 0; 482 | object-fit: contain; 483 | } 484 | 485 | .generation-finished { 486 | padding: 10px; 487 | padding-top: 50px; 488 | } 489 | 490 | .app-inner-main .language-selector-flags, 491 | footer { 492 | display: none; 493 | } 494 | 495 | .menu { 496 | position: fixed; 497 | top: 0; 498 | height: 100%; 499 | width: 50%; 500 | max-width: none; 501 | padding: 10px; 502 | background-color: rgba(0,0,0,0.8); 503 | box-shadow: 0 0 2px 2px rgba(0,0,0,0.5); 504 | transition: all 0.2s; 505 | overflow-y: auto; 506 | overflow-x: hidden; 507 | z-index: 100; 508 | } 509 | 510 | .menu .language-selector-flags li { 511 | flex: 1; 512 | } 513 | 514 | .menu .language-selector-flags li button { 515 | width: 100%; 516 | } 517 | 518 | .open-menu-overlay { 519 | position: fixed; 520 | top: 0; 521 | right: 0; 522 | bottom: 0; 523 | left: 0; 524 | z-index: 100; 525 | background-color: rgba(0,0,0,0.5); 526 | } 527 | 528 | .menu h1 { 529 | display: block; 530 | margin-bottom: 10px; 531 | } 532 | 533 | .menu h2 { 534 | color: var(--color-text-contrast); 535 | text-shadow: none; 536 | } 537 | 538 | .menu-section-mobile-only { 539 | display: block; 540 | } 541 | 542 | .menu-section-inner { 543 | border-radius: 10px; 544 | } 545 | 546 | .menu.settings { left: -60%; } 547 | .menu.settings.shown { 548 | left: 0; 549 | } 550 | 551 | .menu-credits { 552 | display: block; 553 | } 554 | 555 | .menu.stats { right: -60%; } 556 | .menu.stats.shown { right: 0; } 557 | 558 | .answer-area { 559 | z-index: 10; 560 | width: 100%; 561 | padding: 10px; 562 | background-color: rgba(0, 0, 0, 0.1); 563 | gap: 0; 564 | } 565 | 566 | .answer-input-container { 567 | width: 100%; 568 | } 569 | 570 | .answer-input { 571 | margin: 0 0 5px 0; 572 | width: 100%; 573 | } 574 | 575 | .dont-know-button { 576 | font-size: 100%; 577 | -webkit-user-select: none; 578 | } 579 | 580 | .also-known-as { 581 | width: 100%; 582 | padding-right: 60px; /* try to avoid overlaying the count text */ 583 | } 584 | 585 | .also-known-as h2 { 586 | display: none; 587 | } 588 | 589 | .also-known-as ul { 590 | flex-wrap: nowrap; 591 | overflow-x: auto; 592 | max-width: 100%; 593 | justify-content: flex-start; 594 | gap: 2px; 595 | margin: 0; 596 | } 597 | 598 | .also-known-as ul li { 599 | font-size: 80%; 600 | line-height: 1; 601 | padding: 2px 4px; 602 | border-radius: 2px; 603 | word-break: keep-all; /* don't break the Japanese or Chinese names */ 604 | white-space: nowrap; 605 | } 606 | 607 | .also-known-as img { 608 | width: 12px; 609 | height: 9px; 610 | } 611 | 612 | .new-settings-effect { 613 | margin: 8px 0 0 0; 614 | font-size: 90%; 615 | width: 100%; 616 | } 617 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { render } from 'preact'; 2 | import { Provider } from 'react-redux'; 3 | import { PersistGate } from 'redux-persist/integration/react'; 4 | 5 | import App from './App'; 6 | import { persistor, store } from './store'; 7 | 8 | document.addEventListener('DOMContentLoaded', () => { 9 | render(( 10 | 11 | 12 | 13 | 14 | 15 | ), document.getElementById('stage')!); 16 | }); 17 | -------------------------------------------------------------------------------- /src/store/actions.ts: -------------------------------------------------------------------------------- 1 | import { AppDispatch, RootState } from '.'; 2 | import { getPokemonNumbers } from '../util/pokemon'; 3 | import { goToNextIndex, setNewPokemonList } from './gameSlice'; 4 | import { processPendingSettings } from './settingsSlice'; 5 | 6 | export const resetPokemon = () => ((dispatch: AppDispatch, getState: () => any) => { 7 | // redux-persist doesn't play super nicely with Redux v5, so we define the getState argument above as returning 8 | // any, so our dispatches are satisfied, but then cast it to RootState here, since that's the actual value. 9 | let state = getState() as RootState; 10 | 11 | if (state.settings.pendingSettings) { 12 | dispatch(processPendingSettings()); 13 | state = getState(); // get the new state after changing the settings 14 | } 15 | 16 | dispatch(setNewPokemonList(getPokemonNumbers(state.settings))); 17 | }); 18 | 19 | /** Goes to the next Pokémon. Checks first to see if there are any settings changes. If there are, 20 | * it processes them, and then generates new numbers based on the new settings. */ 21 | export const goToNextPokemon = () => ((dispatch: AppDispatch, getState: () => any) => { 22 | if ((getState() as RootState).settings.pendingSettings) { 23 | dispatch(resetPokemon()); 24 | } else { 25 | dispatch(goToNextIndex()); 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /src/store/gameSlice.ts: -------------------------------------------------------------------------------- 1 | import { PayloadAction, createSlice } from '@reduxjs/toolkit'; 2 | 3 | import type { GenerationId } from '../constants'; 4 | import type { PokemonNumber } from '../constants/pokemon'; 5 | 6 | export type GameState = { 7 | pokemon: { 8 | numbers: PokemonNumber[]; 9 | currentIndex: number; 10 | generations: GenerationId[]; 11 | }; 12 | initialized: boolean; 13 | answered: 'correct' | 'incorrect' | null; 14 | lastLoadedTime: number; 15 | }; 16 | 17 | const initialState: GameState = { 18 | pokemon: { 19 | numbers: [], 20 | generations: [], 21 | currentIndex: 0, 22 | }, 23 | initialized: false, 24 | answered: null, 25 | lastLoadedTime: Date.now(), 26 | }; 27 | 28 | export const gameSlice = createSlice({ 29 | name: 'game', 30 | initialState, 31 | reducers: { 32 | setNewPokemonList: (state, action: PayloadAction>) => { 33 | state.pokemon = { ...action.payload, currentIndex: 0 }; 34 | state.answered = null; 35 | state.initialized = true; 36 | }, 37 | goToNextIndex: (state) => { 38 | state.pokemon.currentIndex += 1; 39 | state.answered = null; 40 | }, 41 | setPokemonLoaded: (state) => { 42 | state.lastLoadedTime = Date.now(); 43 | }, 44 | revealPokemon: (state, action: PayloadAction<{ isCorrect: boolean }>) => { 45 | state.answered = action.payload.isCorrect ? 'correct' : 'incorrect'; 46 | }, 47 | }, 48 | }); 49 | 50 | export const { setNewPokemonList, goToNextIndex, revealPokemon, setPokemonLoaded } = gameSlice.actions; 51 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers, configureStore } from '@reduxjs/toolkit'; 2 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; 3 | import { persistReducer, persistStore, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist'; 4 | import autoMergeLevel2 from 'redux-persist/lib/stateReconciler/autoMergeLevel2'; 5 | import storage from 'redux-persist/lib/storage'; 6 | 7 | import { gameSlice } from './gameSlice'; 8 | import { settingsSlice } from './settingsSlice'; 9 | import { statsSlice } from './statsSlice'; 10 | 11 | const rootReducer = combineReducers({ 12 | [gameSlice.name]: gameSlice.reducer, 13 | [settingsSlice.name]: settingsSlice.reducer, 14 | [statsSlice.name]: statsSlice.reducer, 15 | }); 16 | 17 | const persistedReducer = persistReducer({ 18 | key: 'wtp', 19 | version: 1, 20 | storage, 21 | stateReconciler: autoMergeLevel2, 22 | timeout: 0, 23 | // @ts-expect-error The redux-persist types are not quite right 24 | }, rootReducer); 25 | 26 | export const store = configureStore({ 27 | reducer: persistedReducer, 28 | middleware: (getDefaultMiddleware) => ( 29 | getDefaultMiddleware({ 30 | serializableCheck: { 31 | ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], 32 | }, 33 | }) 34 | ), 35 | }); 36 | 37 | export const persistor = persistStore(store); 38 | 39 | // Infer the `RootState` and `AppDispatch` types from the store itself 40 | export type RootState = ReturnType; 41 | // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} 42 | export type AppDispatch = typeof store.dispatch; 43 | 44 | export const useAppDispatch: () => AppDispatch = useDispatch; 45 | export const useAppSelector: TypedUseSelectorHook = useSelector; 46 | -------------------------------------------------------------------------------- /src/store/migrate.ts: -------------------------------------------------------------------------------- 1 | import { store } from '.'; 2 | import { Difficulty, GenerationId } from '../constants'; 3 | import type { LanguageId } from '../constants/lang'; 4 | import { PokemonNumber } from '../constants/pokemon'; 5 | import { SettingsState, setAllSettings } from './settingsSlice'; 6 | import { StatsPerDifficultyArray, StatsState, setMigratedStats } from './statsSlice'; 7 | 8 | const SETTINGS_LS_KEY = 'wtp_settings'; 9 | const RECORDS_LS_KEY = 'wtp_records'; 10 | const MIGRATION_COMPLETE_LS_KEY = 'redux_migration_complete'; 11 | 12 | type LegacySettings = { 13 | difficulty: Difficulty; 14 | generations: GenerationId[]; 15 | sound: boolean; 16 | forgivingSpelling: boolean; 17 | language: LanguageId; 18 | }; 19 | 20 | type LegacyStats = { 21 | streaks: StatsPerDifficultyArray; 22 | bests: { 23 | time: StatsPerDifficultyArray; 24 | pokemonId: StatsPerDifficultyArray; 25 | }; 26 | totals: { 27 | time: StatsPerDifficultyArray; 28 | guesses: StatsPerDifficultyArray; 29 | } 30 | }; 31 | 32 | /** Migrates data from the old manual storage to Redux */ 33 | export const migrateToRedux = () => { 34 | if (localStorage.getItem(MIGRATION_COMPLETE_LS_KEY)) return; 35 | 36 | const legacySettingsStr = localStorage.getItem(SETTINGS_LS_KEY); 37 | 38 | if (legacySettingsStr) { 39 | const legacySettings = JSON.parse(legacySettingsStr) as LegacySettings; 40 | const migratedSettings: SettingsState = { 41 | difficulty: legacySettings.difficulty, 42 | generations: legacySettings.generations, 43 | soundEnabled: legacySettings.sound, 44 | forgivingSpellingEnabled: legacySettings.forgivingSpelling, 45 | language: legacySettings.language, 46 | pendingSettings: null, 47 | }; 48 | 49 | store.dispatch(setAllSettings(migratedSettings)); 50 | } 51 | 52 | const legacyStatsStr = localStorage.getItem(RECORDS_LS_KEY); 53 | 54 | if (legacyStatsStr) { 55 | const legacyStats = JSON.parse(legacyStatsStr) as LegacyStats; 56 | const migratedStats: Pick = { 57 | streaks: { 58 | current: [0, 0, 0, 0, 0], 59 | best: legacyStats.streaks, 60 | }, 61 | times: { 62 | best: legacyStats.bests.time.map((time, index) => ({ 63 | time, 64 | pokemon: legacyStats.bests.pokemonId[index], 65 | })) as StatsState['times']['best'], 66 | total: legacyStats.totals.time.map((time, index) => ({ 67 | time, 68 | guesses: legacyStats.totals.guesses[index], 69 | })) as StatsState['times']['total'], 70 | previous: { time: 0, pokemon: 0 }, 71 | }, 72 | }; 73 | 74 | store.dispatch(setMigratedStats(migratedStats)); 75 | } 76 | 77 | localStorage.setItem(MIGRATION_COMPLETE_LS_KEY, 'true'); 78 | }; 79 | -------------------------------------------------------------------------------- /src/store/settingsSlice.ts: -------------------------------------------------------------------------------- 1 | import { PayloadAction, createSlice } from '@reduxjs/toolkit'; 2 | 3 | import { DIFFICULTY, type Difficulty, type GenerationId } from '../constants'; 4 | import type { LanguageId } from '../constants/lang'; 5 | 6 | export type SettingsState = { 7 | difficulty: Difficulty; 8 | generations: GenerationId[]; 9 | forgivingSpellingEnabled: boolean; 10 | soundEnabled: boolean; 11 | language: LanguageId; 12 | /** Settings to be applied once the current Pokémon has been guessed */ 13 | pendingSettings: { 14 | difficulty?: Difficulty; 15 | generations?: GenerationId[]; 16 | } | null, 17 | }; 18 | 19 | const initialState: SettingsState = { 20 | difficulty: DIFFICULTY.NORMAL, 21 | generations: [1, 2, 3, 4, 5, 6, 7, 8, 9], 22 | forgivingSpellingEnabled: false, 23 | soundEnabled: false, 24 | language: 'en', 25 | pendingSettings: null, 26 | }; 27 | 28 | const areGenerationsEqual = (g1?: GenerationId[], g2?: GenerationId[]) => { 29 | if (!g1 || !g2) return false; 30 | if (g1.length !== g2.length) return false; 31 | 32 | const gen1 = g1.slice().sort(); 33 | const gen2 = g2.slice().sort(); 34 | 35 | return gen1.every((genId, index) => genId === gen2[index]); 36 | }; 37 | 38 | export const settingsSlice = createSlice({ 39 | name: 'settings', 40 | initialState, 41 | reducers: { 42 | setDifficulty: (state, action: PayloadAction) => { 43 | state.pendingSettings = { 44 | ...state.pendingSettings, 45 | difficulty: action.payload, 46 | }; 47 | 48 | // If this change reverts back to the original difficulty, remove this pending setting 49 | // and clear pendingSettings altogether if nothing else is pending. 50 | if (state.pendingSettings.difficulty === state.difficulty) { 51 | delete state.pendingSettings.difficulty; 52 | 53 | if (Object.keys(state.pendingSettings).length === 0) { 54 | state.pendingSettings = null; 55 | } 56 | } 57 | }, 58 | toggleGeneration: (state, action: PayloadAction) => { 59 | const generations = (state.pendingSettings?.generations ?? state.generations).slice(0); 60 | const index = generations.indexOf(action.payload); 61 | 62 | if (index > -1) { 63 | // Only remove the generation if it's not the last one 64 | if (generations.length > 1) { 65 | generations.splice(index, 1); 66 | } 67 | } else { 68 | generations.push(action.payload); 69 | generations.sort((a, b) => a - b); 70 | } 71 | 72 | state.pendingSettings = { 73 | ...state.pendingSettings, 74 | generations, 75 | }; 76 | 77 | // If this change reverts back to the original generations, remove this pending setting 78 | // and clear pendingSettings altogether if nothing else is pending. 79 | if (areGenerationsEqual(state.generations, state.pendingSettings.generations)) { 80 | delete state.pendingSettings.generations; 81 | 82 | if (Object.keys(state.pendingSettings).length === 0) { 83 | state.pendingSettings = null; 84 | } 85 | } 86 | }, 87 | setSound: (state, action: PayloadAction) => { 88 | state.soundEnabled = action.payload; 89 | }, 90 | setForgivingSpellingEnabled: (state, action: PayloadAction) => { 91 | state.forgivingSpellingEnabled = action.payload; 92 | }, 93 | setLanguage: (state, action: PayloadAction) => { 94 | state.language = action.payload; 95 | }, 96 | setAllSettings: (state, action: PayloadAction) => { 97 | return { 98 | ...state, 99 | ...action.payload, 100 | }; 101 | }, 102 | processPendingSettings: (state) => { 103 | state.difficulty = state.pendingSettings?.difficulty ?? state.difficulty; 104 | state.generations = state.pendingSettings?.generations ?? state.generations; 105 | state.pendingSettings = null; 106 | }, 107 | }, 108 | }); 109 | 110 | export const { 111 | setDifficulty, 112 | toggleGeneration, 113 | setSound, 114 | setForgivingSpellingEnabled, 115 | setLanguage, 116 | setAllSettings, 117 | processPendingSettings, 118 | } = settingsSlice.actions; 119 | -------------------------------------------------------------------------------- /src/store/statsSlice.ts: -------------------------------------------------------------------------------- 1 | import { PayloadAction, createSlice } from '@reduxjs/toolkit'; 2 | 3 | import type { Difficulty } from '../constants'; 4 | import { PokemonNumber } from '../constants/pokemon'; 5 | 6 | export type StatsPerDifficultyArray = [T, T, T, T, T]; 7 | 8 | /** If the time taken is longer than this number of milliseconds, round it down 9 | * to avoid skewing stats with huge numbers. */ 10 | const MAX_TIME_TO_RECORD = 90000; 11 | 12 | type SingleTimeStat = { 13 | time: number; 14 | pokemon: PokemonNumber | 0; 15 | }; 16 | 17 | type AverageTimeStat = { 18 | time: number; 19 | guesses: number; 20 | }; 21 | 22 | type PokemonStat = { 23 | timesSeen: number; 24 | timesCorrect: number; 25 | totalTime: number; 26 | }; 27 | 28 | export enum StatsTableKey { 29 | number = 'number', 30 | name = 'name', 31 | correct = 'correct', 32 | time = 'time', 33 | seen = 'seen', 34 | } 35 | 36 | export type StatsState = { 37 | streaks: { 38 | current: StatsPerDifficultyArray; 39 | best: StatsPerDifficultyArray; 40 | }; 41 | times: { 42 | best: StatsPerDifficultyArray; 43 | total: StatsPerDifficultyArray; 44 | previous: SingleTimeStat; 45 | }; 46 | pokemon: { 47 | [num in PokemonNumber]?: PokemonStat; 48 | }; 49 | /** An array containing up to the last 5 Pokémon seen and their times */ 50 | lastSeen: SingleTimeStat[]; 51 | /** Stores which column the player wants their stats table sorted by, and whether it 52 | * should be ascending or descending. */ 53 | tableSort: { 54 | key: StatsTableKey; 55 | ascending: boolean; 56 | } 57 | }; 58 | 59 | const initialState: StatsState = { 60 | streaks: { 61 | current: [0, 0, 0, 0, 0], 62 | best: [0, 0, 0, 0, 0], 63 | }, 64 | times: { 65 | best: [ 66 | { time: 0, pokemon: 0 }, 67 | { time: 0, pokemon: 0 }, 68 | { time: 0, pokemon: 0 }, 69 | { time: 0, pokemon: 0 }, 70 | { time: 0, pokemon: 0 }, 71 | ], 72 | total: [ 73 | { time: 0, guesses: 0 }, 74 | { time: 0, guesses: 0 }, 75 | { time: 0, guesses: 0 }, 76 | { time: 0, guesses: 0 }, 77 | { time: 0, guesses: 0 }, 78 | ], 79 | previous: { time: 0, pokemon: 0 }, 80 | }, 81 | pokemon: {}, 82 | lastSeen: [], 83 | tableSort: { 84 | key: StatsTableKey.number, 85 | ascending: true, 86 | }, 87 | }; 88 | 89 | type AnswerPayload = { 90 | isCorrect: boolean; 91 | difficulty: Difficulty; 92 | timeStarted: number; 93 | pokemonNumber: PokemonNumber; 94 | }; 95 | 96 | const DEFAULT_POKEMON_STAT: PokemonStat = { 97 | timesCorrect: 0, 98 | timesSeen: 0, 99 | totalTime: 0, 100 | }; 101 | 102 | export const statsSlice = createSlice({ 103 | name: 'stats', 104 | initialState, 105 | reducers: { 106 | setAnswered: (state, { payload }: PayloadAction) => { 107 | const timeTaken = Math.min(Date.now() - payload.timeStarted, MAX_TIME_TO_RECORD); 108 | 109 | // Handle times and streaks 110 | if (payload.isCorrect) { 111 | state.streaks.current[payload.difficulty] += 1; 112 | 113 | if (state.streaks.current[payload.difficulty] > state.streaks.best[payload.difficulty]) { 114 | state.streaks.best[payload.difficulty] = state.streaks.current[payload.difficulty]; 115 | } 116 | 117 | const timeStat: SingleTimeStat = { 118 | time: timeTaken, 119 | pokemon: payload.pokemonNumber, 120 | }; 121 | 122 | state.times.previous = timeStat; 123 | 124 | if (timeStat.time < state.times.best[payload.difficulty].time) { 125 | state.times.best[payload.difficulty] = timeStat; 126 | } 127 | 128 | state.times.total[payload.difficulty].time += timeStat.time; 129 | state.times.total[payload.difficulty].guesses += 1; 130 | } else { 131 | state.streaks.current[payload.difficulty] = 0; 132 | } 133 | 134 | // Handle Pokemon-specific stats 135 | const pokemonStat: PokemonStat = { 136 | ...DEFAULT_POKEMON_STAT, 137 | ...state.pokemon[payload.pokemonNumber], 138 | }; 139 | 140 | pokemonStat.timesSeen += 1; 141 | pokemonStat.totalTime += timeTaken; 142 | 143 | if (payload.isCorrect) { 144 | pokemonStat.timesCorrect += 1; 145 | } 146 | 147 | state.pokemon[payload.pokemonNumber] = pokemonStat; 148 | 149 | // Add the number to the front of the last seen array, and trim the array length to 5 150 | state.lastSeen.unshift({ pokemon: payload.pokemonNumber, time: timeTaken }); 151 | state.lastSeen.length = Math.min(state.lastSeen.length, 5); 152 | }, 153 | setMigratedStats: (state, action: PayloadAction>) => { 154 | return { 155 | ...state, 156 | ...action.payload, 157 | }; 158 | }, 159 | setStatsTableSort: (state, action: PayloadAction) => { 160 | state.tableSort = action.payload; 161 | }, 162 | }, 163 | }); 164 | 165 | export const { setMigratedStats, setAnswered, setStatsTableSort } = statsSlice.actions; 166 | -------------------------------------------------------------------------------- /src/util/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'preact/hooks'; 2 | 3 | import { TRANSLATIONS } from '../constants/lang'; 4 | import { RootState, useAppSelector } from '../store'; 5 | 6 | const settingsSelector = (state: RootState) => state.settings; 7 | export const useSettings = () => useAppSelector(settingsSelector); 8 | 9 | const gameStateSelector = (state: RootState) => state.game; 10 | export const useGameState = () => useAppSelector(gameStateSelector); 11 | 12 | const statsSelector = (state: RootState) => state.stats; 13 | export const useStats = () => useAppSelector(statsSelector); 14 | 15 | export const useLang = () => { 16 | const settings = useAppSelector(settingsSelector); 17 | return { 18 | ...TRANSLATIONS.en, 19 | ...TRANSLATIONS[settings.language], 20 | }; 21 | }; 22 | 23 | export const useCurrentPokemonNumber = () => { 24 | const game = useGameState(); 25 | return game.pokemon.numbers[game.pokemon.currentIndex] ?? null; 26 | }; 27 | 28 | /** The visual viewport resizes when the keyboard is opened on mobile. This hook gives us an easy 29 | * way to react to these changes, so we can change the layout when the keyboard has been opened. */ 30 | export const useVisualViewportHeight = () => { 31 | const [height, setHeight] = useState(window.visualViewport!.height); 32 | 33 | useEffect(() => { 34 | const listener = () => { 35 | setHeight(window.visualViewport!.height); 36 | }; 37 | 38 | window.visualViewport!.addEventListener('resize', listener); 39 | return () => window.visualViewport!.removeEventListener('resize', listener); 40 | }, []); 41 | 42 | return height; 43 | }; 44 | -------------------------------------------------------------------------------- /src/util/pokemon.ts: -------------------------------------------------------------------------------- 1 | import { DIFFICULTY, Difficulty, GENERATIONS } from '../constants'; 2 | import type { PokemonNumber } from '../constants/pokemon'; 3 | import { SettingsState } from '../store/settingsSlice'; 4 | 5 | const getGenerationNumbers = (from: PokemonNumber, to: PokemonNumber): PokemonNumber[] => { 6 | return [...Array((to + 1) - from).keys()].map(k => k + from as PokemonNumber); 7 | }; 8 | 9 | /** Shuffles an array of numbers using a Fisher-Yates shuffle. This function creates a 10 | * copy of the given array, rather than modifying it in place. 11 | * Adapted from code at https://bost.ocks.org/mike/shuffle/ */ 12 | const getShuffledNumbers = (array: PokemonNumber[]) => { 13 | const numbers = array.slice(0); 14 | let currentIndex = numbers.length; 15 | 16 | while (currentIndex) { 17 | const randomIndex = Math.floor(Math.random() * currentIndex--); 18 | 19 | const valueToSwap = numbers[currentIndex]; 20 | numbers[currentIndex] = numbers[randomIndex]; 21 | numbers[randomIndex] = valueToSwap; 22 | } 23 | 24 | return numbers; 25 | }; 26 | 27 | export const getPokemonNumbers = (options: Pick) => { 28 | const numbers = options.generations 29 | .filter((gen) => GENERATIONS[gen].supportedDifficulties.includes(options.difficulty)) 30 | .flatMap((genToInc) => ( 31 | getGenerationNumbers(GENERATIONS[genToInc].start, GENERATIONS[genToInc].end) as PokemonNumber[] 32 | )); 33 | 34 | return { 35 | numbers: getShuffledNumbers(numbers), 36 | generations: options.generations, 37 | }; 38 | }; 39 | 40 | const IMAGE_DIRECTORIES = { 41 | [DIFFICULTY.EASY]: 'images/artwork/', 42 | [DIFFICULTY.NORMAL]: 'images/artwork/', 43 | [DIFFICULTY.ULTRA]: 'images/sprites/front/', 44 | [DIFFICULTY.MASTER]: 'images/sprites/back/', 45 | [DIFFICULTY.ELITE]: null, 46 | } as const satisfies { 47 | [key in Difficulty]: string | null; 48 | }; 49 | 50 | export const getPokemonImageUrl = (number: PokemonNumber, difficulty: Difficulty) => { 51 | return `${IMAGE_DIRECTORIES[difficulty]}${number}.png`; 52 | }; 53 | 54 | export const getPokemonSoundUrl = (number: PokemonNumber) => { 55 | return 'sounds/cries/' + number + '.mp3'; 56 | }; 57 | 58 | export const preloadPokemonMedia = (number: PokemonNumber, difficulty: Difficulty, sound: boolean) => { 59 | if (difficulty !== DIFFICULTY.ELITE) { 60 | const img = new Image(); 61 | img.src = getPokemonImageUrl(number, difficulty); 62 | } 63 | 64 | if (sound) { 65 | const audio = new Audio(); 66 | audio.src = getPokemonSoundUrl(number); 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /src/util/spelling.ts: -------------------------------------------------------------------------------- 1 | import type { LanguageId } from '../constants/lang'; 2 | 3 | /** 4 | * This returns a 'soundex', which gives a general idea of what a word sounds like. 5 | * From https://github.com/kvz/phpjs/blob/master/functions/strings/soundex.js 6 | */ 7 | function soundex (str: string) { 8 | str = (str + '').toUpperCase(); 9 | if (!str) { 10 | return ''; 11 | } 12 | const sdx = [0, 0, 0, 0]; 13 | const m = { 14 | B: 1, F: 1, P: 1, V: 1, 15 | C: 2, G: 2, J: 2, K: 2, Q: 2, S: 2, X: 2, Z: 2, 16 | D: 3, T: 3, 17 | L: 4, 18 | M: 5, N: 5, 19 | R: 6, 20 | }; 21 | let i = 0; 22 | const j = 0; 23 | let s = 0; 24 | let c, p; 25 | 26 | while ((c = str.charAt(i++)) && s < 4) { 27 | // @ts-ignore 28 | if (j === m[c]) { 29 | if (j !== p) { 30 | // @ts-ignore 31 | sdx[s++] = p = j; 32 | } 33 | } else { 34 | // @ts-ignore 35 | s += i === 1; 36 | p = 0; 37 | } 38 | } 39 | 40 | // @ts-ignore 41 | sdx[0] = str.charAt(0); 42 | return sdx.join(''); 43 | } 44 | 45 | /** 46 | * Calculates how many letters are different between two words 47 | * From https://github.com/kvz/phpjs/blob/master/functions/strings/levenshtein.js 48 | */ 49 | function levenshtein (s1: string, s2: string): number { 50 | // http://kevin.vanzonneveld.net 51 | // + original by: Carlos R. L. Rodrigues (http://www.jsfromhell.com) 52 | // + bugfixed by: Onno Marsman 53 | // + revised by: Andrea Giammarchi (http://webreflection.blogspot.com) 54 | // + reimplemented by: Brett Zamir (http://brett-zamir.me) 55 | // + reimplemented by: Alexander M Beedie 56 | // * example 1: levenshtein('Kevin van Zonneveld', 'Kevin van Sommeveld'); 57 | // * returns 1: 3 58 | if (s1 == s2) { 59 | return 0; 60 | } 61 | 62 | const s1_len = s1.length; 63 | const s2_len = s2.length; 64 | if (s1_len === 0) { 65 | return s2_len; 66 | } 67 | if (s2_len === 0) { 68 | return s1_len; 69 | } 70 | 71 | // BEGIN STATIC 72 | let split = false; 73 | try { 74 | split = !('0')[0]; 75 | } catch (e) { 76 | split = true; // Earlier IE may not support access by string index 77 | } 78 | // END STATIC 79 | if (split) { 80 | // @ts-ignore 81 | s1 = s1.split(''); 82 | // @ts-ignore 83 | s2 = s2.split(''); 84 | } 85 | 86 | let v0 = new Array(s1_len + 1); 87 | let v1 = new Array(s1_len + 1); 88 | 89 | let s1_idx = 0, 90 | s2_idx = 0, 91 | cost = 0; 92 | for (s1_idx = 0; s1_idx < s1_len + 1; s1_idx++) { 93 | v0[s1_idx] = s1_idx; 94 | } 95 | let char_s1 = '', 96 | char_s2 = ''; 97 | for (s2_idx = 1; s2_idx <= s2_len; s2_idx++) { 98 | v1[0] = s2_idx; 99 | char_s2 = s2[s2_idx - 1]; 100 | 101 | for (s1_idx = 0; s1_idx < s1_len; s1_idx++) { 102 | char_s1 = s1[s1_idx]; 103 | cost = (char_s1 == char_s2) ? 0 : 1; 104 | let m_min = v0[s1_idx + 1] + 1; 105 | const b = v1[s1_idx] + 1; 106 | const c = v0[s1_idx] + cost; 107 | if (b < m_min) { 108 | m_min = b; 109 | } 110 | if (c < m_min) { 111 | m_min = c; 112 | } 113 | v1[s1_idx + 1] = m_min; 114 | } 115 | const v_tmp = v0; 116 | v0 = v1; 117 | v1 = v_tmp; 118 | } 119 | return v0[s1_len]; 120 | } 121 | 122 | /** 123 | * Returns true if both inputs can be considered to be alike-sounding words, else false. 124 | */ 125 | export function soundAlike(s1: string, s2: string, lang?: LanguageId) { 126 | if(lang === 'fr' || lang === 'de') { 127 | return levenshtein(s1, s2) < 3; 128 | } else { 129 | return soundex(s1) === soundex(s2) && levenshtein(s1, s2) < 3; 130 | } 131 | } 132 | 133 | const ACCENT_MAP = { 134 | â:'a', 135 | ä:'a', 136 | ß:'s', 137 | Ü:'u', 138 | ü:'u', 139 | Ï:'i', 140 | ï:'i', 141 | Ê:'e', 142 | ê:'e', 143 | é:'e', 144 | È:'e', 145 | è:'e', 146 | ô:'o', 147 | Ô:'O', 148 | ç:'c', 149 | Ç:'C', 150 | // Convert full-width Japanese characters for Porygon-Z and Porygon 2 151 | z: 'z', 152 | Z: 'Z', 153 | '2': '2', 154 | } as const; 155 | 156 | // Based on https://github.com/aristus/accent-folding/blob/master/accent-fold.js 157 | 158 | export const removeAccents = (s: string) => { 159 | if (!s) { return ''; } 160 | let ret = ''; 161 | for (let i=0; i { 2 | if (time === null || time === 0 || isNaN(time)) return '-'; 3 | return `${(time / 1000).toFixed(2)}s`; 4 | }; 5 | -------------------------------------------------------------------------------- /tests/base.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | import { POKEMON_NAMES, type PokemonNumber } from '../src/constants/pokemon'; 4 | 5 | const language = 'en' as const; 6 | 7 | // Using the default generation settings, answers every Pokémon and ensures they 8 | // move to the next Pokémon with no issues. 9 | test('every pokemon works', async ({ page }) => { 10 | test.setTimeout(300000); 11 | 12 | await page.goto('http://localhost:5173/'); 13 | 14 | const progressCounter = await page.locator('.progress-counter'); 15 | const lastNumber = parseInt((await progressCounter.innerText()).split(' / ')[1], 10); 16 | 17 | // Switch to the selected language 18 | await (await page.locator(`.app-inner-main .language-selector-flags button[data-lang=${language}]`)).click(); 19 | 20 | const inputEl = await page.locator('input[name=pokemonGuess]'); 21 | 22 | for (let i = 0; i < lastNumber - 1; i++) { 23 | const pokemonNumber = parseInt((await inputEl.getAttribute('data-pokemon-number'))!, 10) as PokemonNumber; 24 | const pokemon = POKEMON_NAMES.find((pokemon => pokemon.number === pokemonNumber))!; 25 | const name = pokemon.names[language]; 26 | 27 | try { 28 | await inputEl.fill(name); 29 | await expect(inputEl).toHaveClass(/answer-input-correct/); 30 | await inputEl.press('Enter'); 31 | } catch (err) { 32 | console.error(`Failed to answer "${name}"`); 33 | throw err; 34 | } 35 | } 36 | 37 | await page.isVisible('.generation-finished'); 38 | }); 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": true, 6 | "isolatedModules": true, 7 | "jsx": "react-jsx", 8 | "jsxImportSource": "preact", 9 | "lib": ["es2020", "dom"], 10 | "moduleResolution": "node", 11 | "noEmit": true, 12 | "strict": true, 13 | "target": "esnext", 14 | "skipLibCheck": true, 15 | "paths": { 16 | "react": ["./node_modules/preact/compat/"], 17 | "react-dom": ["./node_modules/preact/compat/"] 18 | } 19 | }, 20 | "exclude": [ 21 | "node_modules", 22 | ] 23 | } -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | import preact from '@preact/preset-vite'; 2 | import { defineConfig } from 'vite'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | base: './', // allows the site to be deployed on any path, not just the root 7 | plugins: [ 8 | preact(), 9 | ], 10 | }); 11 | --------------------------------------------------------------------------------