368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
import { Game, CombinationStats, TypeChartStats, FullTypeChartCell } from './lib/game';
|
|
import { getTypeInfo, TypeName } from './lib/types';
|
|
import './style.css';
|
|
|
|
const app = document.getElementById('app')!;
|
|
|
|
let currentView: 'quiz' | 'stats' = 'quiz';
|
|
let typeChartView: 'attacking' | 'defending' = 'attacking';
|
|
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 {
|
|
if (currentView === 'stats') {
|
|
renderStatsPage();
|
|
return;
|
|
}
|
|
|
|
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 = state.showResult ? '' : options.map((opt, i) => {
|
|
let cls = 'option-btn';
|
|
if (i === state.selectedIndex) {
|
|
cls += ' selected';
|
|
}
|
|
|
|
const shortcut = `${startKey + i}`;
|
|
|
|
return `
|
|
<button class="${cls}" data-index="${i}">
|
|
<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>`
|
|
}
|
|
${!state.isCorrect ? '<button class="next-btn">Continue (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>
|
|
<button class="stats-btn" id="statsBtn">View Stats</button>
|
|
</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}
|
|
${resultHtml}
|
|
</div>
|
|
|
|
<footer class="footer">
|
|
<span class="hint">${state.showResult && !state.isCorrect ? 'Press Enter to continue' : `Press ${startKey}-${startKey + options.length - 1} to select`}</span>
|
|
</footer>
|
|
</div>
|
|
`;
|
|
|
|
attachEventListeners();
|
|
}
|
|
|
|
function renderCombinationItem(combo: CombinationStats): string {
|
|
const defenderHtml = combo.defenderTypes.length === 1
|
|
? renderTypeBadge(combo.defenderTypes[0], 'small')
|
|
: `${renderTypeBadge(combo.defenderTypes[0], 'small')} / ${renderTypeBadge(combo.defenderTypes[1], 'small')}`;
|
|
|
|
const accuracyClass = combo.accuracy >= 80 ? 'high' : combo.accuracy >= 50 ? 'medium' : 'low';
|
|
|
|
return `
|
|
<div class="combo-item">
|
|
<div class="combo-types">
|
|
<span class="combo-attack">${renderTypeBadge(combo.attackType, 'small')}</span>
|
|
<span class="combo-vs">vs</span>
|
|
<span class="combo-defend">${defenderHtml}</span>
|
|
</div>
|
|
<div class="combo-stats">
|
|
<span class="combo-accuracy ${accuracyClass}">${combo.accuracy.toFixed(0)}%</span>
|
|
<span class="combo-count">(${combo.correct}/${combo.total})</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderTypeChartCell(typeStat: TypeChartStats): string {
|
|
const info = getTypeInfo(typeStat.attackType);
|
|
const accuracyClass = typeStat.total === 0 ? 'no-data' : typeStat.accuracy >= 80 ? 'high' : typeStat.accuracy >= 50 ? 'medium' : 'low';
|
|
const display = typeStat.total === 0 ? '-' : `${typeStat.accuracy.toFixed(0)}%`;
|
|
|
|
return `
|
|
<div class="type-cell" style="background: ${info.backgroundColor}; color: ${info.color};" title="${typeStat.attackType}: ${typeStat.correct}/${typeStat.total}">
|
|
<span class="type-cell-name">${typeStat.attackType.slice(0, 3)}</span>
|
|
<span class="type-cell-accuracy ${accuracyClass}">${display}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderFullTypeChart(cells: FullTypeChartCell[]): string {
|
|
const defenderTypes = ['Normal', 'Fire', 'Water', 'Electric', 'Grass', 'Ice', 'Fighting', 'Poison', 'Ground', 'Flying', 'Psychic', 'Bug', 'Rock', 'Ghost', 'Dragon', 'Dark', 'Steel', 'Fairy'];
|
|
|
|
// Create a map for quick cell lookup
|
|
const cellMap = new Map<string, FullTypeChartCell>();
|
|
for (const cell of cells) {
|
|
const defenderKey = cell.defenderTypes.join('/');
|
|
const key = `${cell.attackType}:${defenderKey}`;
|
|
cellMap.set(key, cell);
|
|
}
|
|
|
|
// Generate header row
|
|
const headerCells = defenderTypes.map(def => {
|
|
const info = getTypeInfo(def as TypeName);
|
|
return `<div class="full-chart-header" style="background: ${info.backgroundColor}; color: ${info.color};">
|
|
${def.slice(0, 3)}
|
|
</div>`;
|
|
}).join('');
|
|
|
|
// Generate rows
|
|
const rows = defenderTypes.map(rowType => {
|
|
const rowInfo = getTypeInfo(rowType as TypeName);
|
|
const dataCells = defenderTypes.map(colType => {
|
|
const cell = cellMap.get(`${rowType}:${colType}`);
|
|
const total = cell?.total || 0;
|
|
const accuracy = cell?.accuracy || 0;
|
|
|
|
let bgColor = 'var(--bg-accent)';
|
|
let textColor = 'var(--text-secondary)';
|
|
let display = '-';
|
|
|
|
if (total > 0) {
|
|
display = `${accuracy.toFixed(0)}`;
|
|
if (accuracy >= 80) {
|
|
bgColor = 'rgba(74, 222, 128, 0.3)';
|
|
textColor = 'var(--success)';
|
|
} else if (accuracy >= 50) {
|
|
bgColor = 'rgba(251, 191, 36, 0.3)';
|
|
textColor = '#fbbf24';
|
|
} else {
|
|
bgColor = 'rgba(248, 113, 113, 0.3)';
|
|
textColor = 'var(--error)';
|
|
}
|
|
}
|
|
|
|
return `<div class="full-chart-cell" style="background: ${bgColor}; color: ${textColor};" title="${rowType} vs ${colType}: ${cell?.correct || 0}/${total}">
|
|
${display}
|
|
</div>`;
|
|
}).join('');
|
|
|
|
return `
|
|
<div class="full-chart-row">
|
|
<div class="full-chart-row-header" style="background: ${rowInfo.backgroundColor}; color: ${rowInfo.color};">
|
|
${rowType.slice(0, 3)}
|
|
</div>
|
|
${dataCells}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
return `
|
|
<div class="full-type-chart">
|
|
<div class="full-chart-header-row">
|
|
<div class="full-chart-corner"></div>
|
|
${headerCells}
|
|
</div>
|
|
${rows}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderStatsPage(): void {
|
|
const stats = game.getStats();
|
|
const bestCombos = game.getBestCombinations(5);
|
|
const worstCombos = game.getWorstCombinations(5);
|
|
const typeChartStats = typeChartView === 'attacking' ? game.getTypeChartStats() : game.getDefendingTypeStats();
|
|
const fullTypeChartCells = game.getFullTypeChartStats();
|
|
|
|
const hasData = stats.total > 0;
|
|
|
|
const bestCombosHtml = bestCombos.length > 0
|
|
? bestCombos.map(renderCombinationItem).join('')
|
|
: '<p class="no-data-text">Answer at least 3 questions to see stats</p>';
|
|
|
|
const worstCombosHtml = worstCombos.length > 0
|
|
? worstCombos.map(renderCombinationItem).join('')
|
|
: '<p class="no-data-text">Answer at least 3 questions to see stats</p>';
|
|
|
|
const typeChartHtml = typeChartStats.map(renderTypeChartCell).join('');
|
|
|
|
app.innerHTML = `
|
|
<div class="stats-container">
|
|
<header class="stats-header">
|
|
<button class="back-btn" id="backBtn">← Back to Quiz</button>
|
|
<h1>Your Statistics</h1>
|
|
</header>
|
|
|
|
<div class="stats-overview">
|
|
<div class="stat-card">
|
|
<div class="stat-value">${stats.total}</div>
|
|
<div class="stat-label">Total Questions</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">${stats.accuracy.toFixed(1)}%</div>
|
|
<div class="stat-label">Accuracy</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">${stats.correct}</div>
|
|
<div class="stat-label">Correct</div>
|
|
</div>
|
|
</div>
|
|
|
|
${hasData ? `
|
|
<section class="stats-section">
|
|
<h2>Best Combinations</h2>
|
|
<p class="section-subtitle">Highest accuracy (min. 3 attempts)</p>
|
|
<div class="combo-list">
|
|
${bestCombosHtml}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="stats-section">
|
|
<h2>Needs Practice</h2>
|
|
<p class="section-subtitle">Lowest accuracy (min. 3 attempts)</p>
|
|
<div class="combo-list">
|
|
${worstCombosHtml}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="stats-section">
|
|
<h2>Type Chart Performance</h2>
|
|
<div class="chart-toggle-row">
|
|
<button class="chart-toggle ${typeChartView === 'attacking' ? 'active' : ''}" data-view="attacking">Attacking</button>
|
|
<button class="chart-toggle ${typeChartView === 'defending' ? 'active' : ''}" data-view="defending">Defending</button>
|
|
</div>
|
|
<p class="section-subtitle">Accuracy by ${typeChartView} type</p>
|
|
<div class="type-chart">
|
|
${typeChartHtml}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="stats-section full-chart-section">
|
|
<h2>Full Type Chart</h2>
|
|
<p class="section-subtitle">Accuracy for each type combination</p>
|
|
${renderFullTypeChart(fullTypeChartCells)}
|
|
</section>
|
|
` : `
|
|
<div class="no-data-message">
|
|
<p>No data yet. Start playing to track your statistics!</p>
|
|
</div>
|
|
`}
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('backBtn')?.addEventListener('click', () => {
|
|
currentView = 'quiz';
|
|
render();
|
|
});
|
|
|
|
document.querySelectorAll('.chart-toggle').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const view = btn.getAttribute('data-view') as 'attacking' | 'defending';
|
|
typeChartView = view;
|
|
render();
|
|
});
|
|
});
|
|
}
|
|
|
|
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();
|
|
});
|
|
}
|
|
|
|
const statsBtn = document.getElementById('statsBtn');
|
|
if (statsBtn) {
|
|
statsBtn.addEventListener('click', () => {
|
|
currentView = 'stats';
|
|
render();
|
|
});
|
|
}
|
|
}
|
|
|
|
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();
|