first ai implementation

This commit is contained in:
Tobias Christian Nauen
2026-03-05 17:36:08 +01:00
commit 89c3cd91c2
14 changed files with 2040 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.DS_Store
*.log

33
README.md Normal file
View File

@@ -0,0 +1,33 @@
# Pokemon Type Quiz
A fast, keyboard-driven quiz to master Pokemon type effectiveness.
## Quick Start
```bash
npm install
npm run dev
```
Open http://localhost:5173
## How to Play
- **Select answer**: Press `1`-`6` (number of options shown on buttons)
- **Next question**: `Enter` or `Space`
- **Toggle dual types**: Check "Allow Dual Types" for harder questions (6 options)
## Features
- Weighted sampling: wrong answers appear more often, right answers less often
- Tracks ALL history in localStorage for persistent progress
- Shows detailed explanation on wrong guesses
- Monotype (default) and dual-type modes
## Build
```bash
npm run build
```
Output in `dist/`

66
data.ts Normal file
View File

@@ -0,0 +1,66 @@
export class Data {
static types = [
{ name: "Normal", backgroundColor: "#a4acaf", color: "#212124" },
{ name: "Fire", backgroundColor: "#fd7d24", color: "#ffffff" },
{ name: "Water", backgroundColor: "#4792C4", color: "#ffffff" },
{ name: "Electric", backgroundColor: "#eed535", color: "#212121" },
{ name: "Grass", backgroundColor: "#9bcc50", color: "#212121" },
{ name: "Ice", backgroundColor: "#90d5d5", color: "#212121" },
{ name: "Fighting", backgroundColor: "#d56723", color: "#fff" },
{ name: "Poison", backgroundColor: "#b97fc9", color: "#ffffff" },
{ name: "Ground", backgroundColor: "#debb5c", color: "#212121" },
{ name: "Flying", backgroundColor: "#3dc7ef", color: "#212121" },
{ name: "Psychic", backgroundColor: "#f366b9", color: "#ffffff" },
{ name: "Bug", backgroundColor: "#7e9f56", color: "#ffffff" },
{ name: "Rock", backgroundColor: "#a38c21", color: "#ffffff" },
{ name: "Ghost", backgroundColor: "#7b62a3", color: "#ffffff" },
{ name: "Dragon", backgroundColor: "#53a4cf", color: "#ffffff" },
{ name: "Dark", backgroundColor: "#707070", color: "#ffffff" },
{ name: "Steel", backgroundColor: "#9eb7b8", color: "#212121" },
{ name: "Fairy", backgroundColor: "#fdb9e9", color: "#212121" }
];
static effectiveness = [
// See http://pokemondb.net/type
// NOR FIR WAT ELE GRA ICE FIG POI GRO FLY PSY BUG ROC GHO DRA DAR STE FAI
1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 0.50, 0.00, 1.00, 1.00, 0.50, 1.00, // NOR
1.00, 0.50, 0.50, 1.00, 2.00, 2.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 0.50, 1.00, 0.50, 1.00, 2.00, 1.00, // FIR
1.00, 2.00, 0.50, 1.00, 0.50, 1.00, 1.00, 1.00, 2.00, 1.00, 1.00, 1.00, 2.00, 1.00, 0.50, 1.00, 1.00, 1.00, // WAT
1.00, 1.00, 2.00, 0.50, 0.50, 1.00, 1.00, 1.00, 0.00, 2.00, 1.00, 1.00, 1.00, 1.00, 0.50, 1.00, 1.00, 1.00, // ELE
1.00, 0.50, 2.00, 1.00, 0.50, 1.00, 1.00, 0.50, 2.00, 0.50, 1.00, 0.50, 2.00, 1.00, 0.50, 1.00, 0.50, 1.00, // GRA
1.00, 0.50, 0.50, 1.00, 2.00, 0.50, 1.00, 1.00, 2.00, 2.00, 1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 0.50, 1.00, // ICE
2.00, 1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 0.50, 1.00, 0.50, 0.50, 0.50, 2.00, 0.00, 1.00, 2.00, 2.00, 0.50, // FIG
1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 1.00, 0.50, 0.50, 1.00, 1.00, 1.00, 0.50, 0.50, 1.00, 1.00, 0.00, 2.00, // POI
1.00, 2.00, 1.00, 2.00, 0.50, 1.00, 1.00, 2.00, 1.00, 0.00, 1.00, 0.50, 2.00, 1.00, 1.00, 1.00, 2.00, 1.00, // GRO
1.00, 1.00, 1.00, 0.50, 2.00, 1.00, 2.00, 1.00, 1.00, 1.00, 1.00, 2.00, 0.50, 1.00, 1.00, 1.00, 0.50, 1.00, // FLY
1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 2.00, 1.00, 1.00, 0.50, 1.00, 1.00, 1.00, 1.00, 0.00, 0.50, 1.00, // PSY
1.00, 0.50, 1.00, 1.00, 2.00, 1.00, 0.50, 0.50, 1.00, 0.50, 2.00, 1.00, 1.00, 0.50, 1.00, 2.00, 0.50, 0.50, // BUG
1.00, 2.00, 1.00, 1.00, 1.00, 2.00, 0.50, 1.00, 0.50, 2.00, 1.00, 2.00, 1.00, 1.00, 1.00, 1.00, 0.50, 1.00, // ROC
0.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 1.00, 2.00, 1.00, 0.50, 1.00, 1.00, // GHO
1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 0.50, 0.00, // DRA
1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 0.50, 1.00, 1.00, 1.00, 2.00, 1.00, 1.00, 2.00, 1.00, 0.50, 1.00, 0.50, // DAR
1.00, 0.50, 0.50, 0.50, 1.00, 2.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 1.00, 1.00, 0.50, 2.00, // STE
1.00, 0.50, 1.00, 1.00, 1.00, 1.00, 2.00, 0.50, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 2.00, 0.50, 1.00 // FAI
];
}

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/Pokeball.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pokemon Type Quiz</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1002
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "pokemon-type-quiz",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "^5.3.3",
"vite": "^5.0.12"
}
}

BIN
public/Pokeball.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

5
src/lib/config.ts Normal file
View File

@@ -0,0 +1,5 @@
export const config = {
wrongWeightNumerator: 1,
wrongWeightDenominator: 1,
trivialMultiplier: 0.5,
};

187
src/lib/game.ts Normal file
View File

@@ -0,0 +1,187 @@
import { getTypeInfo, getEffectiveness } from './types';
import { Question, Sampler } from './sampler';
const STORAGE_KEY = 'pokemon-type-quiz-data';
export interface GameState {
currentQuestion: Question | null;
selectedAnswer: number | null;
selectedIndex: number;
showResult: boolean;
isCorrect: boolean;
streak: number;
settings: {
allowDualTypes: boolean;
};
}
function effectivenessToLabel(value: number): { label: string; short: string } {
if (value === 0) return { label: 'No Effect (0×)', short: '0×' };
if (value === 0.25) return { label: 'Not Very Effective (¼×)', short: '¼×' };
if (value === 0.5) return { label: 'Not Very Effective (½×)', short: '½×' };
if (value === 1) return { label: 'Neutral (1×)', short: '1×' };
if (value === 2) return { label: 'Super Effective (2×)', short: '2×' };
if (value === 4) return { label: 'Super Effective (4×)', short: '4×' };
return { label: `${value}×`, short: `${value}×` };
}
export class Game {
private sampler: Sampler;
private state: GameState;
private onUpdate: () => void;
constructor(onUpdate: () => void) {
this.onUpdate = onUpdate;
this.sampler = new Sampler();
this.state = {
currentQuestion: null,
selectedAnswer: null,
selectedIndex: -1,
showResult: false,
isCorrect: false,
streak: 0,
settings: {
allowDualTypes: false,
},
};
this.load();
}
private load(): void {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const data = JSON.parse(saved);
this.state.streak = data.streak || 0;
this.state.settings.allowDualTypes = data.settings?.allowDualTypes || false;
this.sampler.setAllowDualTypes(this.state.settings.allowDualTypes);
if (data.history) {
this.sampler.loadHistory(data.history);
}
}
} catch {
// Ignore parse errors
}
}
private save(): void {
try {
const data = {
streak: this.state.streak,
settings: this.state.settings,
history: this.sampler.getHistory(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch {
// Ignore storage errors
}
}
getState(): GameState {
return this.state;
}
getOptions(): { options: { value: number; label: string; short: string }[]; startKey: number } {
if (!this.state.currentQuestion) return { options: [], startKey: 1 };
const options = new Set<number>();
options.add(this.state.currentQuestion.correctAnswer);
const allValues = [0, 0.25, 0.5, 1, 2, 4];
for (const v of allValues) {
if (v !== this.state.currentQuestion.correctAnswer) {
options.add(v);
}
}
const isDual = this.state.currentQuestion.defenderTypes.length > 1;
const monoValues = [2, 1, 0.5, 0];
const dualValues = [4, 2, 1, 0.5, 0.25, 0];
const filtered = isDual
? dualValues.filter(v => options.has(v))
: monoValues.filter(v => options.has(v));
const optionsResult = filtered.map(v => ({ value: v, ...effectivenessToLabel(v) }));
return { options: optionsResult, startKey: isDual ? 1 : 2 };
}
setAllowDualTypes(allow: boolean): void {
this.state.settings.allowDualTypes = allow;
this.sampler.setAllowDualTypes(allow);
this.nextQuestion();
this.save();
}
nextQuestion(): void {
this.state.currentQuestion = this.sampler.generateQuestion();
this.state.selectedAnswer = null;
this.state.selectedIndex = -1;
this.state.showResult = false;
this.onUpdate();
}
selectAnswer(index: number): void {
if (this.state.showResult) return;
const { options } = this.getOptions();
if (index < 0 || index >= options.length) return;
this.state.selectedIndex = index;
this.state.selectedAnswer = options[index].value;
this.state.showResult = true;
this.state.isCorrect = this.state.selectedAnswer === this.state.currentQuestion?.correctAnswer;
if (this.state.isCorrect) {
this.state.streak++;
} else {
this.state.streak = 0;
}
this.sampler.recordGuess(
this.state.currentQuestion!.attackType,
this.state.currentQuestion!.defenderTypes,
this.state.isCorrect
);
this.save();
this.onUpdate();
}
getStats(): { total: number; correct: number; accuracy: number } {
const history = this.sampler.getHistory();
const total = history.length;
const correct = history.filter(h => h.correct).length;
return { total, correct, accuracy: total > 0 ? (correct / total) * 100 : 0 };
}
getExplanation(): string {
if (!this.state.currentQuestion) return '';
const { attackType, defenderTypes } = this.state.currentQuestion;
const attackInfo = getTypeInfo(attackType);
let explanation = `<strong>${attackInfo.name}</strong> against `;
if (defenderTypes.length === 1) {
const defInfo = getTypeInfo(defenderTypes[0]);
explanation += `<strong>${defInfo.name}</strong>`;
} else {
const defInfos = defenderTypes.map(t => getTypeInfo(t));
explanation += `<strong>${defInfos[0].name} / ${defInfos[1].name}</strong>`;
}
const effective = getEffectiveness(attackType, defenderTypes);
const effectLabel = effectivenessToLabel(effective);
explanation += `<br><br>Effectiveness: <strong>${effectLabel.label}</strong>`;
if (defenderTypes.length > 1) {
const first = getEffectiveness(attackType, [defenderTypes[0]]);
const second = getEffectiveness(attackType, [defenderTypes[1]]);
explanation += `<br><small>(${effectivenessToLabel(first).short} × ${effectivenessToLabel(second).short} = ${effectLabel.short})</small>`;
}
return explanation;
}
}

107
src/lib/sampler.ts Normal file
View File

@@ -0,0 +1,107 @@
import { TypeName, TYPES, getEffectiveness, TYPE_COUNT } from './types';
import { config } from './config';
export interface Question {
attackType: TypeName;
defenderTypes: TypeName[];
correctAnswer: number;
}
export interface GuessRecord {
key: string;
correct: boolean;
timestamp: number;
}
function makeKey(attackType: TypeName, defenderTypes: TypeName[]): string {
return `${attackType}:${defenderTypes.sort().join('/')}`;
}
export class Sampler {
private history: GuessRecord[] = [];
private allowDualTypes: boolean = false;
constructor(allowDualTypes: boolean = false) {
this.allowDualTypes = allowDualTypes;
}
setAllowDualTypes(allow: boolean): void {
this.allowDualTypes = allow;
}
recordGuess(attackType: TypeName, defenderTypes: TypeName[], correct: boolean): void {
const key = makeKey(attackType, defenderTypes);
this.history.push({ key, correct, timestamp: Date.now() });
}
getHistory(): GuessRecord[] {
return this.history;
}
loadHistory(history: GuessRecord[]): void {
this.history = history;
}
private calculateWeight(attackType: TypeName, defenderTypes: TypeName[]): number {
const key = makeKey(attackType, defenderTypes);
const records = this.history.filter(r => r.key === key);
const wrong = records.filter(r => !r.correct).length;
const right = records.filter(r => r.correct).length;
let weight = (wrong + config.wrongWeightNumerator) / (right + config.wrongWeightDenominator);
const isTrivial = defenderTypes.every(defType =>
getEffectiveness(attackType, [defType]) === 1
);
if (isTrivial) {
weight *= config.trivialMultiplier;
}
return weight;
}
generateQuestion(): Question {
const candidates: { attackType: TypeName; defenderTypes: TypeName[]; weight: number }[] = [];
for (let i = 0; i < TYPE_COUNT; i++) {
const attackType = TYPES[i].name;
// Single type defenders - all types
for (let j = 0; j < TYPE_COUNT; j++) {
const defenderTypes = [TYPES[j].name];
candidates.push({ attackType, defenderTypes, weight: this.calculateWeight(attackType, defenderTypes) });
}
if (this.allowDualTypes) {
// Dual type defenders
for (let j = 0; j < TYPE_COUNT; j++) {
for (let k = j + 1; k < TYPE_COUNT; k++) {
const defenderTypes = [TYPES[j].name, TYPES[k].name];
candidates.push({ attackType, defenderTypes, weight: this.calculateWeight(attackType, defenderTypes) });
}
}
}
}
const totalWeight = candidates.reduce((sum, c) => sum + c.weight, 0);
let random = Math.random() * totalWeight;
for (const candidate of candidates) {
random -= candidate.weight;
if (random <= 0) {
return {
attackType: candidate.attackType,
defenderTypes: candidate.defenderTypes,
correctAnswer: getEffectiveness(candidate.attackType, candidate.defenderTypes),
};
}
}
return candidates[0] ? {
attackType: candidates[0].attackType,
defenderTypes: candidates[0].defenderTypes,
correctAnswer: getEffectiveness(candidates[0].attackType, candidates[0].defenderTypes),
} : this.generateQuestion();
}
}

84
src/lib/types.ts Normal file
View File

@@ -0,0 +1,84 @@
export type TypeName =
| 'Normal' | 'Fire' | 'Water' | 'Electric' | 'Grass' | 'Ice'
| 'Fighting' | 'Poison' | 'Ground' | 'Flying' | 'Psychic' | 'Bug'
| 'Rock' | 'Ghost' | 'Dragon' | 'Dark' | 'Steel' | 'Fairy';
export interface TypeInfo {
name: TypeName;
backgroundColor: string;
color: string;
}
export const TYPES: TypeInfo[] = [
{ name: 'Normal', backgroundColor: '#a4acaf', color: '#212124' },
{ name: 'Fire', backgroundColor: '#fd7d24', color: '#ffffff' },
{ name: 'Water', backgroundColor: '#4792C4', color: '#ffffff' },
{ name: 'Electric', backgroundColor: '#eed535', color: '#212121' },
{ name: 'Grass', backgroundColor: '#9bcc50', color: '#212121' },
{ name: 'Ice', backgroundColor: '#90d5d5', color: '#212121' },
{ name: 'Fighting', backgroundColor: '#d56723', color: '#ffffff' },
{ name: 'Poison', backgroundColor: '#b97fc9', color: '#ffffff' },
{ name: 'Ground', backgroundColor: '#debb5c', color: '#212121' },
{ name: 'Flying', backgroundColor: '#3dc7ef', color: '#212121' },
{ name: 'Psychic', backgroundColor: '#f366b9', color: '#ffffff' },
{ name: 'Bug', backgroundColor: '#7e9f56', color: '#ffffff' },
{ name: 'Rock', backgroundColor: '#a38c21', color: '#ffffff' },
{ name: 'Ghost', backgroundColor: '#7b62a3', color: '#ffffff' },
{ name: 'Dragon', backgroundColor: '#53a4cf', color: '#ffffff' },
{ name: 'Dark', backgroundColor: '#707070', color: '#ffffff' },
{ name: 'Steel', backgroundColor: '#9eb7b8', color: '#212121' },
{ name: 'Fairy', backgroundColor: '#fdb9e9', color: '#212121' },
];
const TYPE_NAMES = TYPES.map(t => t.name);
const EFFECTIVENESS_FLAT: number[] = [
// NOR FIR WAT ELE GRA ICE FIG POI GRO FLY PSY BUG ROC GHO DRA DAR STE FAI
1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 0.50, 0.00, 1.00, 1.00, 0.50, 1.00, // NOR
1.00, 0.50, 0.50, 1.00, 2.00, 2.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 0.50, 1.00, 0.50, 1.00, 2.00, 1.00, // FIR
1.00, 2.00, 0.50, 1.00, 0.50, 1.00, 1.00, 1.00, 2.00, 1.00, 1.00, 1.00, 2.00, 1.00, 0.50, 1.00, 1.00, 1.00, // WAT
1.00, 1.00, 2.00, 0.50, 0.50, 1.00, 1.00, 1.00, 0.00, 2.00, 1.00, 1.00, 1.00, 1.00, 0.50, 1.00, 1.00, 1.00, // ELE
1.00, 0.50, 2.00, 1.00, 0.50, 1.00, 1.00, 0.50, 2.00, 0.50, 1.00, 0.50, 2.00, 1.00, 0.50, 1.00, 0.50, 1.00, // GRA
1.00, 0.50, 0.50, 1.00, 2.00, 0.50, 1.00, 1.00, 2.00, 2.00, 1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 0.50, 1.00, // ICE
2.00, 1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 0.50, 1.00, 0.50, 0.50, 0.50, 2.00, 0.00, 1.00, 2.00, 2.00, 0.50, // FIG
1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 1.00, 0.50, 0.50, 1.00, 1.00, 1.00, 0.50, 0.50, 1.00, 1.00, 0.00, 2.00, // POI
1.00, 2.00, 1.00, 2.00, 0.50, 1.00, 1.00, 2.00, 1.00, 0.00, 1.00, 0.50, 2.00, 1.00, 1.00, 1.00, 2.00, 1.00, // GRO
1.00, 1.00, 1.00, 0.50, 2.00, 1.00, 2.00, 1.00, 1.00, 1.00, 1.00, 2.00, 0.50, 1.00, 1.00, 1.00, 0.50, 1.00, // FLY
1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 2.00, 1.00, 1.00, 0.50, 1.00, 1.00, 1.00, 1.00, 0.00, 0.50, 1.00, // PSY
1.00, 0.50, 1.00, 1.00, 2.00, 1.00, 0.50, 0.50, 1.00, 0.50, 2.00, 1.00, 1.00, 0.50, 1.00, 2.00, 0.50, 0.50, // BUG
1.00, 2.00, 1.00, 1.00, 1.00, 2.00, 0.50, 1.00, 0.50, 2.00, 1.00, 2.00, 1.00, 1.00, 1.00, 1.00, 0.50, 1.00, // ROC
0.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 1.00, 2.00, 1.00, 0.50, 1.00, 1.00, // GHO
1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 0.50, 0.00, // DRA
1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 0.50, 1.00, 1.00, 1.00, 2.00, 1.00, 1.00, 2.00, 1.00, 0.50, 1.00, 0.50, // DAR
1.00, 0.50, 0.50, 0.50, 1.00, 2.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 1.00, 1.00, 0.50, 2.00, // STE
1.00, 0.50, 1.00, 1.00, 1.00, 1.00, 2.00, 0.50, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 2.00, 0.50, 1.00 // FAI
];
const TYPE_INDEX: Record<TypeName, number> = Object.fromEntries(
TYPE_NAMES.map((name, i) => [name, i])
) as Record<TypeName, number>;
export const EFFECTIVENESS_MATRIX: number[][] = [];
for (let i = 0; i < TYPE_NAMES.length; i++) {
EFFECTIVENESS_MATRIX[i] = EFFECTIVENESS_FLAT.slice(i * 18, (i + 1) * 18);
}
export function getEffectiveness(attackType: TypeName, defenderTypes: TypeName[]): number {
let result = 1;
for (const defType of defenderTypes) {
const attackIdx = TYPE_INDEX[attackType];
const defIdx = TYPE_INDEX[defType];
result *= EFFECTIVENESS_MATRIX[attackIdx][defIdx];
}
return result;
}
export function getTypeInfo(name: TypeName): TypeInfo {
return TYPES[findTypeIndex(name)];
}
export function findTypeIndex(name: TypeName): number {
return TYPE_INDEX[name];
}
export const TYPE_COUNT = TYPES.length;

156
src/main.ts Normal file
View File

@@ -0,0 +1,156 @@
import { Game } from './lib/game';
import { getTypeInfo, TypeName } from './lib/types';
import './style.css';
const app = document.getElementById('app')!;
const game = new Game(render);
game.nextQuestion();
function renderTypeBadge(name: TypeName, size: 'small' | 'large' = 'small'): string {
const info = getTypeInfo(name);
const sizeClass = size === 'large' ? 'type-badge-large' : '';
return `<span class="type-badge ${sizeClass}" style="background: ${info.backgroundColor}; color: ${info.color}">${info.name}</span>`;
}
function render(): void {
const state = game.getState();
const { options, startKey } = game.getOptions();
const stats = game.getStats();
if (!state.currentQuestion) {
app.innerHTML = '<div class="loading">Loading...</div>';
return;
}
const q = state.currentQuestion;
const defenderHtml = q.defenderTypes.length === 1
? renderTypeBadge(q.defenderTypes[0], 'large')
: `${renderTypeBadge(q.defenderTypes[0], 'large')} / ${renderTypeBadge(q.defenderTypes[1], 'large')}`;
const optionsHtml = options.map((opt, i) => {
let cls = 'option-btn';
if (state.showResult) {
if (opt.value === q.correctAnswer) {
cls += ' correct';
} else if (i === state.selectedIndex && !state.isCorrect) {
cls += ' wrong';
}
} else if (i === state.selectedIndex) {
cls += ' selected';
}
const shortcut = `${startKey + i}`;
const disabled = state.showResult ? 'disabled' : '';
return `
<button class="${cls}" data-index="${i}" ${disabled}>
<span class="option-key">${shortcut}</span>
<span class="option-label">${opt.label}</span>
</button>
`;
}).join('');
const resultHtml = state.showResult ? `
<div class="result ${state.isCorrect ? 'correct' : 'wrong'}">
${state.isCorrect
? '<div class="result-title">✓ Correct!</div>'
: `<div class="result-title">✗ Wrong</div>
<div class="explanation">${game.getExplanation()}</div>`
}
<button class="next-btn">Next Question (Enter)</button>
</div>
` : '';
app.innerHTML = `
<div class="game-container">
<header class="header">
<h1>Pokemon Type Quiz</h1>
<div class="stats">
<span class="stat">Streak: ${state.streak}</span>
<span class="stat">Accuracy: ${stats.accuracy.toFixed(1)}%</span>
<span class="stat">Total: ${stats.total}</span>
</div>
</header>
<div class="settings">
<label class="toggle">
<input type="checkbox" ${state.settings.allowDualTypes ? 'checked' : ''} id="dualToggle">
<span class="toggle-label">Allow Dual Types</span>
</label>
</div>
<div class="question">
<div class="question-label">Attacking Type</div>
<div class="attack-type">${renderTypeBadge(q.attackType, 'large')}</div>
<div class="vs">against</div>
<div class="question-label">Defending Type${q.defenderTypes.length > 1 ? 's' : ''}</div>
<div class="defender-types">${defenderHtml}</div>
</div>
<div class="options">
${optionsHtml}
</div>
${resultHtml}
<footer class="footer">
<span class="hint">Press ${startKey}-${startKey + options.length - 1} to select • Enter to continue</span>
</footer>
</div>
`;
attachEventListeners();
}
function attachEventListeners(): void {
document.querySelectorAll('.option-btn').forEach(btn => {
btn.addEventListener('click', () => {
const index = parseInt(btn.getAttribute('data-index')!);
game.selectAnswer(index);
});
});
const dualToggle = document.getElementById('dualToggle') as HTMLInputElement;
if (dualToggle) {
dualToggle.addEventListener('change', () => {
game.setAllowDualTypes(dualToggle.checked);
});
}
const nextBtn = document.querySelector('.next-btn');
if (nextBtn) {
nextBtn.addEventListener('click', () => {
game.nextQuestion();
});
}
}
document.addEventListener('keydown', (e) => {
const state = game.getState();
const { options, startKey } = game.getOptions();
if (state.showResult) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
game.nextQuestion();
}
return;
}
const num = parseInt(e.key);
if (num >= startKey && num < startKey + options.length) {
e.preventDefault();
game.selectAnswer(num - startKey);
}
if (e.key === 'Enter') {
if (state.selectedAnswer !== null) {
e.preventDefault();
}
}
});
render();

349
src/style.css Normal file
View File

@@ -0,0 +1,349 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Outfit:wght@400;600;700&display=swap');
:root {
--bg-dark: #1a1a2e;
--bg-card: #16213e;
--bg-accent: #0f3460;
--text-primary: #eaeaea;
--text-secondary: #a0a0a0;
--accent: #e94560;
--success: #4ade80;
--error: #f87171;
--border-radius: 12px;
--transition: 0.2s ease;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Outfit', sans-serif;
background: var(--bg-dark);
background-image:
radial-gradient(ellipse at top, #1a1a2e 0%, #0f0f1a 100%),
repeating-linear-gradient(0deg, transparent, transparent 50px, rgba(233, 69, 96, 0.03) 50px, rgba(233, 69, 96, 0.03) 51px),
repeating-linear-gradient(90deg, transparent, transparent 50px, rgba(233, 69, 96, 0.03) 50px, rgba(233, 69, 96, 0.03) 51px);
color: var(--text-primary);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 20px;
}
#app {
width: 100%;
max-width: 560px;
}
.game-container {
display: flex;
flex-direction: column;
gap: 24px;
}
.header {
text-align: center;
}
.header h1 {
font-size: 2rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent), #ff6b6b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 12px;
letter-spacing: -0.5px;
}
.stats {
display: flex;
justify-content: center;
gap: 20px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
color: var(--text-secondary);
}
.stat {
padding: 4px 12px;
background: var(--bg-card);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.settings {
display: flex;
justify-content: center;
}
.toggle {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
padding: 8px 16px;
background: var(--bg-card);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.05);
transition: var(--transition);
}
.toggle:hover {
border-color: var(--accent);
}
.toggle input {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.toggle-label {
font-size: 0.9rem;
color: var(--text-secondary);
}
.question {
background: var(--bg-card);
border-radius: var(--border-radius);
padding: 32px 24px;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.question-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-secondary);
margin-bottom: 12px;
}
.attack-type {
margin-bottom: 16px;
}
.type-badge {
display: inline-block;
padding: 8px 20px;
border-radius: 8px;
font-weight: 600;
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.type-badge-large {
padding: 12px 32px;
font-size: 1.2rem;
}
.vs {
font-size: 0.8rem;
color: var(--text-secondary);
margin: 16px 0;
font-family: 'JetBrains Mono', monospace;
}
.defender-types {
display: flex;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
.options {
display: flex;
flex-direction: column;
gap: 10px;
}
.option-btn {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
background: var(--bg-card);
border: 2px solid rgba(255, 255, 255, 0.08);
border-radius: var(--border-radius);
color: var(--text-primary);
font-family: 'Outfit', sans-serif;
font-size: 1rem;
cursor: pointer;
transition: var(--transition);
text-align: left;
}
.option-btn:hover:not(:disabled) {
border-color: var(--accent);
transform: translateX(4px);
}
.option-btn.selected {
border-color: var(--accent);
background: rgba(233, 69, 96, 0.1);
}
.option-btn.correct {
border-color: var(--success);
background: rgba(74, 222, 128, 0.15);
}
.option-btn.wrong {
border-color: var(--error);
background: rgba(248, 113, 113, 0.15);
}
.option-btn:disabled {
cursor: default;
}
.option-key {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
flex-shrink: 0;
}
.option-label {
font-weight: 500;
}
.result {
padding: 24px;
border-radius: var(--border-radius);
text-align: center;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.result.correct {
background: rgba(74, 222, 128, 0.1);
border: 1px solid rgba(74, 222, 128, 0.3);
}
.result.wrong {
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.3);
}
.result-title {
font-size: 1.4rem;
font-weight: 700;
margin-bottom: 8px;
}
.result.correct .result-title {
color: var(--success);
}
.result.wrong .result-title {
color: var(--error);
}
.explanation {
font-size: 0.95rem;
color: var(--text-secondary);
line-height: 1.6;
}
.explanation strong {
color: var(--text-primary);
}
.next-btn {
margin-top: 20px;
padding: 14px 32px;
background: var(--accent);
border: none;
border-radius: var(--border-radius);
color: white;
font-family: 'Outfit', sans-serif;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
}
.next-btn:hover {
background: #ff6b6b;
transform: scale(1.02);
}
.footer {
text-align: center;
}
.hint {
font-size: 0.8rem;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
}
.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
@media (max-width: 480px) {
body {
padding: 12px;
}
.header h1 {
font-size: 1.6rem;
}
.stats {
gap: 10px;
font-size: 0.75rem;
}
.question {
padding: 24px 16px;
}
.type-badge-large {
padding: 10px 24px;
font-size: 1rem;
}
.option-btn {
padding: 14px 16px;
gap: 12px;
}
.option-key {
width: 24px;
height: 24px;
font-size: 0.75rem;
}
.option-label {
font-size: 0.9rem;
}
}

19
tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}