first ai implementation
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
33
README.md
Normal file
33
README.md
Normal 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
66
data.ts
Normal 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
13
index.html
Normal 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
1002
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
Normal file
15
package.json
Normal 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
BIN
public/Pokeball.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
5
src/lib/config.ts
Normal file
5
src/lib/config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const config = {
|
||||
wrongWeightNumerator: 1,
|
||||
wrongWeightDenominator: 1,
|
||||
trivialMultiplier: 0.5,
|
||||
};
|
||||
187
src/lib/game.ts
Normal file
187
src/lib/game.ts
Normal 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
107
src/lib/sampler.ts
Normal 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
84
src/lib/types.ts
Normal 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
156
src/main.ts
Normal 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
349
src/style.css
Normal 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
19
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user