Add GBA controller overlay, walking player, D-pad movement, shuffle answers, 1-4 labels, A/B talk to NPC, E key, localStorage save/resume, continue screen, mobile-first responsive, PokéSQL rebrand

This commit is contained in:
justin committed 2026-05-14 09:38:44 -07:00
1 parent 30f76c4479
commit d334f31b02
4 files changed
+883 -948

No files matched your search

+57 -18
View File
@@ -14,7 +14,7 @@ Play it on your phone, tablet, or PC — progress is saved automatically to your
pokesql/ pokesql/
├── index.html ← Game screens & structure ├── index.html ← Game screens & structure
├── style.css ← GBA Pokémon aesthetic, fully responsive ├── style.css ← GBA Pokémon aesthetic, fully responsive
├── game.js ← Game engine + localStorage save system ├── game.js ← Game engine, controller, walking, save system
├── questions.json ← 100 T-SQL questions & answers ├── questions.json ← 100 T-SQL questions & answers
└── README.md └── README.md
``` ```
@@ -32,15 +32,50 @@ No server, no database, no cost.
--- ---
## 🎮 GBA Controller Overlay
On mobile, a semi-transparent Game Boy Advance-style controller overlays the bottom of the screen:
| Control | Action |
|---|---|
| **D-pad** | Walk the player around the map |
| **A button** (red) | Talk to NPC on map · Confirm answer in battle · Advance dialog |
| **B button** (blue) | Talk to NPC on map · Advance dialog |
| **SELECT / START** | Decorative — reserved for future use |
The controller is hidden on desktop (≥900px wide) — use the keyboard instead.
---
## ⌨️ PC Keyboard Controls
| Key | Action |
|---|---|
| **WASD / Arrow keys** | Walk player on map |
| **E / Enter** | Talk to NPC · Confirm answer · Advance dialog |
| **X** | Talk to NPC · Advance dialog |
| **1 / 2 / 3 / 4** | Quick-pick answer directly |
| **Space** | Advance dialog |
---
## 🗺️ Map & Walking
- The player sprite walks freely around the overworld map using the D-pad or WASD
- Walking animations change based on direction (left, right, up, down)
- Press **A**, **B**, **E**, or **Enter** at any time to talk to the NPC and start the question
- Tapping the NPC sprite directly also triggers the conversation
---
## 💾 Save System ## 💾 Save System
Progress is stored in the player's browser (`localStorage`) automatically: Progress is stored in the player's browser (`localStorage`) automatically:
- **Auto-saves** after every answered question and on map return - **Auto-saves** after every answered question and on map return — a 💾 icon flashes to confirm
- **Continue screen** appears on next visit if an in-progress save exists - **Continue screen** appears on next visit showing trainer name, progress %, and score
- Displays trainer name, question progress, and score on the continue screen - **New Game** option always available — prompts confirmation before erasing save
- **New Game** option is always available (prompts confirmation to prevent accidents) - Save clears automatically on completion so the next run starts fresh
- Save is cleared on game completion — ready for a fresh run
--- ---
@@ -48,16 +83,20 @@ Progress is stored in the player's browser (`localStorage`) automatically:
| Feature | Detail | | Feature | Detail |
|---|---| |---|---|
| GBA Pokémon aesthetic | Press Start 2P font, pixel dialog boxes, scanline overlay | | GBA controller overlay | D-pad + A/B buttons, semi-transparent, mobile only |
| Mobile-first design | Full-screen on phones, framed device on desktop | | Walking player | Moves freely on map, directional walk animations |
| 100 T-SQL questions | Progressive difficulty, multiple choice | | Mobile-first design | Full-screen on phones with safe-area support, framed on desktop |
| localStorage save | Auto-save with continue screen on return visits |
| 100 T-SQL questions | Progressive difficulty, shuffled answer order every time |
| Randomised answer positions | Correct answer shuffled to a random slot — no pattern to exploit |
| Numbered answers | Options labelled 1/2/3/4 (not A/B/C/D) |
| Name entry | On-screen pixel keyboard, just like the real game | | Name entry | On-screen pixel keyboard, just like the real game |
| Typewriter text | Authentic dialog effect with tap-to-skip | | Typewriter text | Authentic dialog effect, tap/press to skip |
| 22 NPC characters | Professor Oak, Misty, Brock, Giovanni, Lance… | | 22 NPC characters | Professor Oak, Misty, Brock, Giovanni, Lance… |
| Streak bonuses | 3× = +150 pts 🔥, 5×+ = +200 pts 🔥 | | Streak bonuses | 3× correct = +150 pts 🔥, 5×+ = +200 pts 🔥 |
| Milestone badges | Earned at Q25, Q50, Q75, Q100 | | Milestone badges | Earned at Q25, Q50, Q75, Q100 |
| Correct answer reveal | Wrong answers show the right answer + explanation | | Correct answer reveal | Wrong answers show the right answer + full explanation |
| Keyboard shortcuts | 1–4 / A–D to answer, Enter to continue | | Scanline overlay | Authentic CRT/GBA screen effect |
--- ---
@@ -90,7 +129,7 @@ Edit `questions.json`, add to the `questions` array inside `levels[0]`:
} }
``` ```
`answer` is the **0-based index** of the correct option (0 = A, 1 = B, 2 = C, 3 = D). `answer` is the **0-based index** of the correct option in the `options` array (0 = first option, 1 = second, etc.). The game shuffles the display order randomly at runtime, so the position shown to the player will vary each time.
--- ---
@@ -111,7 +150,7 @@ Then reference `"npc": "Your NPC Name"` in `questions.json`.
## 📱 Mobile Tips ## 📱 Mobile Tips
- Add to Home Screen on iOS/Android for a full-screen app experience - Add to Home Screen on iOS/Android for a full-screen app-like experience
- The game uses `100dvh` and `safe-area-inset` for notched phones - The game uses `100dvh` and `env(safe-area-inset-bottom)` for notched phones (iPhone X+)
- Tap anywhere on the dialog during intro to skip typewriter / advance - Tap anywhere on the dialog box during the intro to skip the typewriter and advance
- Tap the NPC sprite on the map to start the question (same as the button) - Tap the NPC sprite directly on the map as an alternative to pressing A/B
+460 -319
View File
@@ -26,160 +26,380 @@ const NPC = {
}; };
const INTRO_MSGS = [ const INTRO_MSGS = [
{ speaker: 'PROFESSOR OAK', npc: 'Professor Oak', text: "Hello {name}! Welcome to PokéSQL — the world of T-SQL training! My name is Professor Oak, the SQL Pokémon Professor!" }, { speaker:'PROFESSOR OAK', npc:'Professor Oak', text:"Hello {name}! Welcome to PokéSQL — the world of T-SQL training! My name is Professor Oak, the SQL Pokémon Professor!" },
{ speaker: 'PROFESSOR OAK', npc: 'Professor Oak', text: "This world is inhabited by powerful data — stored in TABLES, organised in DATABASES, and tamed with QUERIES!" }, { speaker:'PROFESSOR OAK', npc:'Professor Oak', text:"This world is inhabited by powerful data — stored in TABLES, organised in DATABASES, and tamed with QUERIES!" },
{ speaker: 'PROFESSOR OAK', npc: 'Professor Oak', text: "Your mission? Journey through Pallet Town, challenge SQL experts, and become a T-SQL Champion!" }, { speaker:'PROFESSOR OAK', npc:'Professor Oak', text:"Your mission? Journey through Pallet Town, challenge SQL experts, and become a T-SQL Champion!" },
{ speaker: 'PROFESSOR OAK', npc: 'Professor Oak', text: "You'll learn Microsoft T-SQL — the language used by DBAs and data professionals worldwide." }, { speaker:'PROFESSOR OAK', npc:'Professor Oak', text:"You'll learn Microsoft T-SQL — the language used by DBAs and data professionals worldwide." },
{ speaker: 'PROFESSOR OAK', npc: 'Professor Oak', text: "100 questions await you in this town alone. Each correct answer earns you SQL EXP — and you'll learn something real!" }, { speaker:'PROFESSOR OAK', npc:'Professor Oak', text:"100 questions await you in this town alone. Each correct answer earns you SQL EXP — and you'll learn something real!" },
{ speaker: 'PROFESSOR OAK', npc: 'Professor Oak', text: "Your progress is saved automatically in your browser so you can pick up right where you left off. Now, {name}... your adventure begins!" }, { speaker:'PROFESSOR OAK', npc:'Professor Oak', text:"Your progress is saved automatically in your browser so you can pick up right where you left off. Now, {name}... your adventure begins!" },
]; ];
const CORRECT_FB = ["That's right!","Excellent work!","Perfect!","Outstanding!","Correct!","You're a natural!","Great answer!","Spot on!","Impressive!","Well done!"]; const CORRECT_FB = ["That's right!","Excellent work!","Perfect!","Outstanding!","Correct!","You're a natural!","Great answer!","Spot on!","Impressive!","Well done!"];
const WRONG_FB = ["Not quite...","Hmm, that's not it.","Try again next time!","Almost...","Not this time!"]; const WRONG_FB = ["Not quite...","Hmm, that's not it.","Try again next time!","Almost...","Not this time!"];
const MILESTONES = { const MILESTONES = {
25: { badge: '🥉 SQL INITIATE', stars: '★ ★ ☆ ☆' }, 25: { badge:'🥉 SQL INITIATE', stars:'★ ★ ☆ ☆' },
50: { badge: '🥈 QUERY TRAINER', stars: '★ ★ ★ ☆' }, 50: { badge:'🥈 QUERY TRAINER', stars:'★ ★ ★ ☆' },
75: { badge: '🥇 DATA WRANGLER', stars: '★ ★ ★ ★' }, 75: { badge:'🥇 DATA WRANGLER', stars:'★ ★ ★ ★' },
}; };
const SAVE_KEY = 'pokesql_save_v1'; const SAVE_KEY = 'pokesql_save_v1';
/* ─── SAVE / LOAD ────────────────────────────────────────── */ /* ─── SAVE / LOAD ────────────────────────────────────────── */
function saveGame(state) { function saveGame(s) {
try { try { localStorage.setItem(SAVE_KEY, JSON.stringify({ playerName:s.playerName, currentQ:s.currentQ, score:s.score, streak:s.streak, maxStreak:s.maxStreak, correct:s.correct, wrong:s.wrong, savedAt:Date.now() })); } catch(_){}
const data = {
playerName: state.playerName,
currentQ: state.currentQ,
score: state.score,
streak: state.streak,
maxStreak: state.maxStreak,
correct: state.correct,
wrong: state.wrong,
savedAt: Date.now(),
};
localStorage.setItem(SAVE_KEY, JSON.stringify(data));
} catch (_) {}
} }
function loadGame() { try { const r=localStorage.getItem(SAVE_KEY); return r?JSON.parse(r):null; } catch(_){return null;} }
function clearSave() { try { localStorage.removeItem(SAVE_KEY); } catch(_){} }
function loadGame() { /* ─── GAME ───────────────────────────────────────────────── */
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch (_) { return null; }
}
function clearSave() {
try { localStorage.removeItem(SAVE_KEY); } catch (_) {}
}
/* ─── GAME CLASS ─────────────────────────────────────────── */
class Game { class Game {
constructor() { constructor() {
this.state = { this.state = {
screen: 'boot', screen:'boot', playerName:'', questions:[], currentQ:0,
playerName: '', score:0, streak:0, maxStreak:0, correct:0, wrong:0,
questions: [], answering:false, introStep:0, twTimer:null,
currentQ: 0, /* map movement */
score: 0, playerX:15, playerY:42, /* % of map-world */
streak: 0, npcX:72, npcY:38,
maxStreak: 0, moving:false, walkDir:'',
correct: 0, /* battle cursor */
wrong: 0, cursor:0,
answering: false,
introStep: 0,
twTimer: null,
}; };
this.screens = {}; this.screens = {};
document.querySelectorAll('.screen').forEach(s => { document.querySelectorAll('.screen').forEach(s => {
this.screens[s.id.replace('screen-', '')] = s; this.screens[s.id.replace('screen-','')] = s;
}); });
this._introClickHandler = null; this._heldKeys = new Set();
this._introKeyHandler = null; this._walkLoop = null;
this._introClick = null;
this._introKey = null;
this.bindGlobalKeys(); this._bindController();
this._bindGlobalKeys();
this.boot(); this.boot();
} }
/* ── BOOT ─────────────────────────────────────────────── */ /* ══════════════════════════════════════════════════════════
CONTROLLER OVERLAY — wires D-pad, A, B, SELECT, START
══════════════════════════════════════════════════════════ */
_bindController() {
const ctrl = document.getElementById('gba-controller');
const dpadMap = { 'dp-up':'up', 'dp-down':'down', 'dp-left':'left', 'dp-right':'right' };
Object.entries(dpadMap).forEach(([id, dir]) => {
const btn = document.getElementById(id);
if (!btn) return;
const start = (e) => { e.preventDefault(); this._dpadStart(dir); btn.classList.add('pressed'); };
const end = (e) => { e.preventDefault(); this._dpadEnd(dir); btn.classList.remove('pressed'); };
btn.addEventListener('pointerdown', start, {passive:false});
btn.addEventListener('pointerup', end, {passive:false});
btn.addEventListener('pointerleave', end, {passive:false});
btn.addEventListener('pointercancel',end, {passive:false});
});
const aBtn = document.getElementById('btn-a');
const bBtn = document.getElementById('btn-b');
aBtn.addEventListener('pointerdown', (e)=>{ e.preventDefault(); aBtn.classList.add('pressed'); this._pressA(); });
aBtn.addEventListener('pointerup', (e)=>{ e.preventDefault(); aBtn.classList.remove('pressed'); });
aBtn.addEventListener('pointercancel',(e)=>{ aBtn.classList.remove('pressed'); });
bBtn.addEventListener('pointerdown', (e)=>{ e.preventDefault(); bBtn.classList.add('pressed'); this._pressB(); });
bBtn.addEventListener('pointerup', (e)=>{ e.preventDefault(); bBtn.classList.remove('pressed'); });
bBtn.addEventListener('pointercancel',(e)=>{ bBtn.classList.remove('pressed'); });
document.getElementById('btn-start').addEventListener('pointerdown', (e)=>{ e.preventDefault(); });
document.getElementById('btn-select').addEventListener('pointerdown', (e)=>{ e.preventDefault(); });
}
/* A — map: talk to NPC | battle: confirm cursor | elsewhere: advance */
_pressA() {
const s = this.state.screen;
if (s === 'map') { this._talkToNPC(); return; }
if (s === 'battle' && !this.state.answering) { this._confirmCursor(); return; }
if (s === 'intro') { this.advanceIntro(); return; }
if (s === 'result') { document.getElementById('btn-result-cont')?.click(); return; }
if (s === 'levelup') { document.getElementById('btn-lu-cont')?.click(); return; }
}
/* B — map: talk to NPC (same as A) | battle: no-op while answering | intro/result: advance */
_pressB() {
const s = this.state.screen;
if (s === 'map') { this._talkToNPC(); return; }
if (s === 'battle' && this.state.answering) { return; }
if (s === 'intro') { this.advanceIntro(); return; }
if (s === 'result') { document.getElementById('btn-result-cont')?.click(); return; }
}
/* D-pad start hold */
_dpadStart(dir) {
this._heldKeys.add(dir);
if (!this._walkLoop) this._startWalkLoop();
const s = this.state.screen;
if (s === 'battle') { this._moveCursor(dir); }
}
_dpadEnd(dir) {
this._heldKeys.delete(dir);
if (this._heldKeys.size === 0) this._stopWalkLoop();
}
/* ══════════════════════════════════════════════════════════
MAP WALKING — 16-step loop, collision, NPC proximity
══════════════════════════════════════════════════════════ */
_startWalkLoop() {
if (this._walkLoop) return;
this._walkLoop = setInterval(() => this._walkTick(), 80);
}
_stopWalkLoop() {
clearInterval(this._walkLoop);
this._walkLoop = null;
if (this.state.screen === 'map') {
const p = document.getElementById('map-player');
if (p) { p.className = 'map-player idle'; }
}
}
_walkTick() {
if (this.state.screen !== 'map') return;
let dx = 0, dy = 0, dir = '';
if (this._heldKeys.has('up')) { dy = 1.8; dir = 'up'; }
if (this._heldKeys.has('down')) { dy = -1.8; dir = 'down'; }
if (this._heldKeys.has('left')) { dx = -2.2; dir = 'left'; }
if (this._heldKeys.has('right')) { dx = 2.2; dir = 'right'; }
if (!dir) return;
let nx = Math.max(5, Math.min(88, this.state.playerX + dx));
let ny = Math.max(30, Math.min(72, this.state.playerY + dy));
this.state.playerX = nx;
this.state.playerY = ny;
const p = document.getElementById('map-player');
if (p) {
p.style.left = nx + '%';
p.style.bottom = ny + '%';
p.className = `map-player walk-${dir}`;
}
this._checkNPCProximity();
}
_checkNPCProximity() {
const dx = Math.abs(this.state.playerX - this.state.npcX);
const dy = Math.abs(this.state.playerY - this.state.npcY);
const near = dx < 12 && dy < 12;
const hint = document.getElementById('map-talk-hint');
const bub = document.getElementById('npc-bubble');
if (hint) {
hint.style.display = near ? 'block' : 'none';
hint.style.left = (this.state.playerX + 4) + '%';
hint.style.bottom= (this.state.playerY + 11) + '%';
}
if (bub) bub.style.opacity = near ? '0' : '1';
}
_nearNPC() {
const dx = Math.abs(this.state.playerX - this.state.npcX);
const dy = Math.abs(this.state.playerY - this.state.npcY);
return dx < 12 && dy < 12;
}
_talkToNPC() { this.startQuestion(); }
/* ── Position player & NPC on map ──────────────────────── */
_placeMapSprites() {
const p = document.getElementById('map-player');
if (p) {
p.style.left = this.state.playerX + '%';
p.style.bottom = this.state.playerY + '%';
p.className = 'map-player idle';
}
const npcWrap = document.getElementById('map-npc-wrap');
if (npcWrap) {
npcWrap.style.left = (this.state.npcX - 3) + '%';
npcWrap.style.bottom = this.state.npcY + '%';
}
const hint = document.getElementById('map-talk-hint');
if (hint) hint.style.display = 'none';
}
/* ══════════════════════════════════════════════════════════
BATTLE CURSOR — navigate 2×2 grid with D-pad / WASD
══════════════════════════════════════════════════════════ */
_moveCursor(dir) {
const btns = document.querySelectorAll('.choice-btn:not(.disabled):not(.correct):not(.wrong)');
if (!btns.length) return;
let c = this.state.cursor;
if (dir === 'right') c = (c === 0) ? 1 : (c === 2) ? 3 : c;
else if (dir === 'left') c = (c === 1) ? 0 : (c === 3) ? 2 : c;
else if (dir === 'down') c = (c === 0) ? 2 : (c === 1) ? 3 : c;
else if (dir === 'up') c = (c === 2) ? 0 : (c === 3) ? 1 : c;
c = Math.min(c, btns.length - 1);
this.state.cursor = c;
this._renderCursor();
}
_renderCursor() {
document.querySelectorAll('.choice-btn').forEach((b,i) => {
b.classList.toggle('cursor', i === this.state.cursor);
});
}
_confirmCursor() {
if (this.state.answering) return;
const btns = document.querySelectorAll('.choice-btn:not(.disabled)');
const target = btns[this.state.cursor];
if (target) target.click();
}
/* ══════════════════════════════════════════════════════════
GLOBAL KEYBOARD
══════════════════════════════════════════════════════════ */
_bindGlobalKeys() {
const DPAD_KEYS = {
'ArrowUp':'up','ArrowDown':'down','ArrowLeft':'left','ArrowRight':'right',
'w':'up','s':'down','a':'left','d':'right',
'W':'up','S':'down','A':'left','D':'right',
};
document.addEventListener('keydown', (e) => {
const s = this.state.screen;
/* Name screen — character input */
if (s === 'name') {
if (e.key==='Backspace') { e.preventDefault(); this.delChar(); }
else if (e.key==='Enter') this.confirmName();
else if (e.key.length===1 && /[A-Z0-9.\-!?]/i.test(e.key)) this.addChar(e.key.toUpperCase());
return;
}
/* Title */
if (s === 'title' && (e.key==='Enter'||e.key===' ')) { document.getElementById('screen-title').click(); return; }
/* Intro */
if (s === 'intro' && (e.key==='Enter'||e.key===' '||e.key==='e'||e.key==='E')) { this.advanceIntro(); return; }
/* D-pad direction keys */
if (DPAD_KEYS[e.key] && !e.repeat) {
const dir = DPAD_KEYS[e.key];
if ((s==='map' || s==='battle') && !this._heldKeys.has(dir)) {
e.preventDefault();
this._heldKeys.add(dir);
if (s==='map' && !this._walkLoop) this._startWalkLoop();
if (s==='battle') this._moveCursor(dir);
}
return;
}
/* A-button equivalent on keyboard */
if ((e.key==='Enter'||e.key==='e'||e.key==='E') && !e.repeat) {
e.preventDefault();
this._pressA();
return;
}
/* B-button */
if ((e.key==='x'||e.key==='X') && !e.repeat) { this._pressB(); return; }
/* Quick-pick numbers on battle */
if (s==='battle' && !this.state.answering) {
const numMap={'1':0,'2':1,'3':2,'4':3};
if (numMap[e.key]!==undefined) {
this.state.cursor = numMap[e.key];
this._renderCursor();
setTimeout(()=>this._confirmCursor(), 80);
}
}
});
document.addEventListener('keyup', (e) => {
const DPAD_KEYS = {
'ArrowUp':'up','ArrowDown':'down','ArrowLeft':'left','ArrowRight':'right',
'w':'up','s':'down','a':'left','d':'right',
'W':'up','S':'down','A':'left','D':'right',
};
const dir = DPAD_KEYS[e.key];
if (dir) {
this._heldKeys.delete(dir);
if (this._heldKeys.size === 0) this._stopWalkLoop();
}
});
}
/* ══════════════════════════════════════════════════════════
CONTROLLER VISIBILITY
══════════════════════════════════════════════════════════ */
_showController(visible) {
const ctrl = document.getElementById('gba-controller');
if (!ctrl) return;
if (visible) ctrl.classList.remove('hidden');
else ctrl.classList.add('hidden');
}
/* ══════════════════════════════════════════════════════════
SCREENS
══════════════════════════════════════════════════════════ */
boot() { boot() {
this.show('boot'); this.show('boot');
this._showController(false);
setTimeout(() => this.showTitle(), 1800); setTimeout(() => this.showTitle(), 1800);
} }
showTitle() { showTitle() {
this.show('title'); this.show('title');
const go = () => { this._showController(false);
document.getElementById('screen-title').removeEventListener('click', go); const go = () => { document.getElementById('screen-title').removeEventListener('click',go); this.showContinueOrName(); };
this.showContinueOrName();
};
document.getElementById('screen-title').addEventListener('click', go); document.getElementById('screen-title').addEventListener('click', go);
} }
/* ── CONTINUE SCREEN ──────────────────────────────────── */
showContinueOrName() { showContinueOrName() {
const save = loadGame(); const save = loadGame();
if (save && save.playerName && save.currentQ > 0 && save.currentQ < 100) { if (save && save.playerName && save.currentQ > 0 && save.currentQ < 100) {
this.show('continue'); this.show('continue');
this.populateContinueScreen(save); this._showController(false);
const pct = Math.round((save.currentQ/100)*100);
document.getElementById('save-name-disp').textContent = save.playerName;
document.getElementById('save-prog-disp').textContent = `Q${save.currentQ}/100 (${pct}%)`;
document.getElementById('save-score-disp').textContent = save.score.toLocaleString();
document.getElementById('btn-continue-save').onclick = () => { document.getElementById('btn-continue-save').onclick = () => this.restoreFromSave(save);
this.restoreFromSave(save);
};
document.getElementById('btn-new-game').onclick = () => { document.getElementById('btn-new-game').onclick = () => {
document.getElementById('continue-warn').textContent = document.getElementById('continue-warn').textContent = 'Starting a new game will erase your saved progress!';
'Starting a new game will erase your saved progress!';
document.getElementById('btn-new-game').textContent = '✦ CONFIRM NEW GAME'; document.getElementById('btn-new-game').textContent = '✦ CONFIRM NEW GAME';
document.getElementById('btn-new-game').onclick = () => { document.getElementById('btn-new-game').onclick = () => { clearSave(); this.startFresh(); };
clearSave();
this.startFresh();
};
}; };
} else { } else {
this.startFresh(); this.startFresh();
} }
} }
populateContinueScreen(save) {
const pct = Math.round((save.currentQ / 100) * 100);
document.getElementById('save-name-disp').textContent = save.playerName;
document.getElementById('save-prog-disp').textContent = `Q${save.currentQ}/100 (${pct}%)`;
document.getElementById('save-score-disp').textContent = save.score.toLocaleString();
}
restoreFromSave(save) { restoreFromSave(save) {
Object.assign(this.state, { Object.assign(this.state, { playerName:save.playerName, currentQ:save.currentQ, score:save.score, streak:save.streak, maxStreak:save.maxStreak, correct:save.correct, wrong:save.wrong });
playerName: save.playerName,
currentQ: save.currentQ,
score: save.score,
streak: save.streak,
maxStreak: save.maxStreak,
correct: save.correct,
wrong: save.wrong,
});
this.loadQuestions(() => this.showMap()); this.loadQuestions(() => this.showMap());
} }
startFresh() { startFresh() {
this.state.playerName = ''; Object.assign(this.state, { playerName:'', currentQ:0, score:0, streak:0, maxStreak:0, correct:0, wrong:0 });
this.state.currentQ = 0;
this.state.score = 0;
this.state.streak = 0;
this.state.maxStreak = 0;
this.state.correct = 0;
this.state.wrong = 0;
this.showNameEntry(); this.showNameEntry();
} }
/* ── NAME ENTRY ───────────────────────────────────────── */ /* ── NAME ─────────────────────────────────────────────── */
showNameEntry() { showNameEntry() {
this.show('name'); this.show('name');
this._showController(false);
this.buildKeyboard(); this.buildKeyboard();
this.typeText('name-prompt-text', this.typeText('name-prompt-text', "Hello there! Welcome to PokéSQL! My name is Professor Oak — the SQL Pokémon Professor. Now tell me, what is your name?");
"Hello there! Welcome to PokéSQL! My name is Professor Oak — the SQL Pokémon Professor. Now tell me, what is your name?");
document.getElementById('btn-backspace').onclick = () => this.delChar(); document.getElementById('btn-backspace').onclick = () => this.delChar();
document.getElementById('btn-confirm-name').onclick = () => this.confirmName(); document.getElementById('btn-confirm-name').onclick = () => this.confirmName();
} }
@@ -190,50 +410,32 @@ class Game {
grid.innerHTML = ''; grid.innerHTML = '';
chars.forEach(ch => { chars.forEach(ch => {
const b = document.createElement('button'); const b = document.createElement('button');
b.className = 'key-btn'; b.className = 'key-btn'; b.textContent = ch;
b.textContent = ch; b.addEventListener('pointerdown', (e)=>{ e.preventDefault(); this.addChar(ch); });
b.addEventListener('pointerdown', (e) => { e.preventDefault(); this.addChar(ch); });
grid.appendChild(b); grid.appendChild(b);
}); });
} }
addChar(ch) { addChar(ch) { if (this.state.playerName.length<10){ this.state.playerName+=ch; this.refreshNameDisplay(); } }
if (this.state.playerName.length >= 10) return; delChar() { this.state.playerName=this.state.playerName.slice(0,-1); this.refreshNameDisplay(); }
this.state.playerName += ch;
this.refreshNameDisplay();
}
delChar() {
this.state.playerName = this.state.playerName.slice(0, -1);
this.refreshNameDisplay();
}
refreshNameDisplay() { refreshNameDisplay() {
const el = document.getElementById('name-display'); const el=document.getElementById('name-display');
el.textContent = this.state.playerName + (this.state.playerName.length < 10 ? '_' : ''); if(el) el.textContent=this.state.playerName+(this.state.playerName.length<10?'_':'');
} }
confirmName() { if(!this.state.playerName.trim()) this.state.playerName='ASH'; this.showIntro(); }
confirmName() { /* ── INTRO ────────────────────────────────────────────── */
if (!this.state.playerName.trim()) this.state.playerName = 'ASH';
this.showIntro();
}
/* ── INTRO CUTSCENE ───────────────────────────────────── */
showIntro() { showIntro() {
this.show('intro'); this.show('intro');
this._showController(false);
this.state.introStep = 0; this.state.introStep = 0;
this.renderIntroMsg(); this.renderIntroMsg();
const advance = () => this.advanceIntro(); const advance = () => this.advanceIntro();
const introEl = document.getElementById('screen-intro'); const introEl = document.getElementById('screen-intro');
if (this._introClick) introEl.removeEventListener('click', this._introClick);
if (this._introClickHandler) introEl.removeEventListener('click', this._introClickHandler); this._introClick = advance;
this._introClickHandler = advance;
introEl.addEventListener('click', advance); introEl.addEventListener('click', advance);
if (this._introKeyHandler) document.removeEventListener('keydown', this._introKeyHandler);
this._introKeyHandler = (e) => { if (e.key === 'Enter' || e.key === ' ') advance(); };
document.addEventListener('keydown', this._introKeyHandler);
} }
renderIntroMsg() { renderIntroMsg() {
@@ -244,24 +446,23 @@ class Game {
document.getElementById('intro-speaker').textContent = msg.speaker; document.getElementById('intro-speaker').textContent = msg.speaker;
document.getElementById('intro-arrow').style.display = 'none'; document.getElementById('intro-arrow').style.display = 'none';
this.typeText('intro-text', this.typeText('intro-text',
msg.text.replace(/{name}/g, this.state.playerName || 'ASH'), msg.text.replace(/{name}/g, this.state.playerName||'ASH'),
() => { document.getElementById('intro-arrow').style.display = 'block'; } ()=>{ document.getElementById('intro-arrow').style.display='block'; }
); );
} }
advanceIntro() { advanceIntro() {
if (this.state.twTimer) { if (this.state.twTimer) {
clearInterval(this.state.twTimer); clearInterval(this.state.twTimer); this.state.twTimer=null;
this.state.twTimer = null;
const msg = INTRO_MSGS[this.state.introStep]; const msg = INTRO_MSGS[this.state.introStep];
document.getElementById('intro-text').textContent = document.getElementById('intro-text').textContent = msg.text.replace(/{name}/g, this.state.playerName||'ASH');
msg.text.replace(/{name}/g, this.state.playerName || 'ASH');
document.getElementById('intro-arrow').style.display = 'block'; document.getElementById('intro-arrow').style.display = 'block';
return; return;
} }
this.state.introStep++; this.state.introStep++;
if (this.state.introStep >= INTRO_MSGS.length) { if (this.state.introStep >= INTRO_MSGS.length) {
document.removeEventListener('keydown', this._introKeyHandler); const el = document.getElementById('screen-intro');
if (this._introClick) el.removeEventListener('click', this._introClick);
this.loadQuestions(() => this.showMap()); this.loadQuestions(() => this.showMap());
return; return;
} }
@@ -270,271 +471,211 @@ class Game {
/* ── LOAD QUESTIONS ───────────────────────────────────── */ /* ── LOAD QUESTIONS ───────────────────────────────────── */
loadQuestions(cb) { loadQuestions(cb) {
if (this.state.questions.length > 0) { cb(); return; } if (this.state.questions.length>0) { cb(); return; }
fetch('questions.json') fetch('questions.json')
.then(r => r.json()) .then(r=>r.json())
.then(data => { .then(data=>{ this.state.questions=data.levels[0].questions; cb(); })
this.state.questions = data.levels[0].questions; .catch(()=>this.toast('❌ Could not load questions.json'));
cb();
})
.catch(() => this.toast('❌ Could not load questions.json'));
} }
/* ── MAP ──────────────────────────────────────────────── */ /* ── MAP ──────────────────────────────────────────────── */
showMap() { showMap() {
this.show('map'); this.show('map');
document.getElementById('hud-name').textContent = this.state.playerName || 'ASH'; this._showController(true);
/* reset player to start if fresh (currentQ 0), else keep position */
if (this.state.currentQ === 0) {
this.state.playerX = 15; this.state.playerY = 42;
}
this.state.npcX = 72; this.state.npcY = 38;
document.getElementById('hud-name').textContent = this.state.playerName||'ASH';
this.updateHUD(); this.updateHUD();
this.updateMapNPC();
const q = this.state.questions[this.state.currentQ];
const npcEl = document.getElementById('map-npc');
if (q && npcEl) npcEl.textContent = NPC[q.npc]||'🧑';
this._placeMapSprites();
saveGame(this.state); saveGame(this.state);
this.flashSaveDot(); this.flashSaveDot();
document.getElementById('btn-walk').onclick = () => this.startQuestion();
const mapNpc = document.getElementById('map-npc');
if (mapNpc) mapNpc.onclick = () => this.startQuestion();
}
updateMapNPC() { /* click NPC directly also triggers talk */
const q = this.state.questions[this.state.currentQ]; const npcEl2 = document.getElementById('map-npc');
if (!q) return; if (npcEl2) npcEl2.onclick = ()=>this._talkToNPC();
const el = document.getElementById('map-npc');
if (el) el.textContent = NPC[q.npc] || '🧑';
} }
updateHUD() { updateHUD() {
const q = this.state.questions; const tot=this.state.questions.length||100, done=this.state.currentQ, pct=(done/tot)*100;
const tot = q.length || 100;
const done = this.state.currentQ;
const pct = (done / tot) * 100;
document.getElementById('hud-score').textContent = this.state.score.toLocaleString(); document.getElementById('hud-score').textContent = this.state.score.toLocaleString();
document.getElementById('hud-streak').textContent = this.state.streak + (this.state.streak >= 3 ? '🔥' : ''); document.getElementById('hud-streak').textContent = this.state.streak+(this.state.streak>=3?'🔥':'');
document.getElementById('hud-correct').textContent= `${this.state.correct}/${done}`; document.getElementById('hud-correct').textContent = `${this.state.correct}/${done}`;
document.getElementById('map-prog-text').textContent = `${done}/${tot}`; document.getElementById('map-prog-text').textContent = `${done}/${tot}`;
const bar=document.getElementById('map-prog-bar');
const bar = document.getElementById('map-prog-bar'); if(bar){ bar.style.width=pct+'%'; bar.style.background=pct<40?'var(--hp-green)':pct<75?'var(--hp-yellow)':'#60c8ff'; }
if (bar) {
bar.style.width = pct + '%';
bar.style.background = pct < 40 ? 'var(--hp-green)' : pct < 75 ? 'var(--hp-yellow)' : '#60c8ff';
}
} }
flashSaveDot() { flashSaveDot() {
const dot = document.getElementById('save-dot'); const dot=document.getElementById('save-dot');
if (!dot) return; if(!dot)return; dot.style.opacity='1';
dot.style.opacity = '1'; setTimeout(()=>{ dot.style.opacity='.4'; },1500);
setTimeout(() => { dot.style.opacity = '.4'; }, 1500);
} }
/* ── BATTLE / QUESTION ────────────────────────────────── */ /* ── BATTLE ───────────────────────────────────────────── */
startQuestion() { startQuestion() {
const q = this.state.questions[this.state.currentQ]; this._stopWalkLoop();
if (!q) { this.showComplete(); return; } this._heldKeys.clear();
const q=this.state.questions[this.state.currentQ];
if(!q){ this.showComplete(); return; }
this.show('battle'); this.show('battle');
this.state.answering = false; this._showController(true);
this.state.answering=false;
this.state.cursor=0;
document.getElementById('battle-npc-sprite').textContent = NPC[q.npc] || '🧑'; document.getElementById('battle-npc-sprite').textContent = NPC[q.npc]||'🧑';
document.getElementById('battle-npc-name').textContent = q.npc.toUpperCase(); document.getElementById('battle-npc-name').textContent = q.npc.toUpperCase();
document.getElementById('battle-speaker').textContent = q.npc.toUpperCase(); document.getElementById('battle-speaker').textContent = q.npc.toUpperCase();
document.getElementById('battle-player-name').textContent= (this.state.playerName || 'ASH').toUpperCase(); document.getElementById('battle-player-name').textContent= (this.state.playerName||'ASH').toUpperCase();
document.getElementById('battle-q-num').textContent = this.state.currentQ + 1; document.getElementById('battle-q-num').textContent = this.state.currentQ+1;
document.getElementById('battle-pts').textContent = this.state.score.toLocaleString(); document.getElementById('battle-pts').textContent = this.state.score.toLocaleString();
document.getElementById('battle-arrow').style.display = 'none'; document.getElementById('battle-arrow').style.display = 'none';
document.getElementById('battle-choices').innerHTML = ''; document.getElementById('battle-choices').innerHTML = '';
this.typeText('battle-text', q.text, () => this.renderChoices(q)); this.typeText('battle-text', q.text, ()=>this.renderChoices(q));
} }
renderChoices(q) { renderChoices(q) {
const container = document.getElementById('battle-choices'); const container=document.getElementById('battle-choices');
container.innerHTML = ''; container.innerHTML='';
['A','B','C','D'].forEach((letter, i) => { this.state.cursor=0;
const btn = document.createElement('button');
btn.className = 'choice-btn'; /* Shuffle options so the correct answer lands in a random slot each time.
btn.setAttribute('data-letter', letter); We track which shuffled index holds the correct answer. */
btn.textContent = q.options[i]; const indices = [0,1,2,3];
btn.addEventListener('click', () => this.pick(i, q, container)); for (let i = indices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]];
}
const correctShuffledIdx = indices.indexOf(q.answer);
indices.forEach((origIdx, slotIdx) => {
const btn=document.createElement('button');
btn.className='choice-btn';
btn.setAttribute('data-letter', slotIdx + 1);
btn.textContent=q.options[origIdx];
btn.addEventListener('click',()=>this.pick(slotIdx, correctShuffledIdx, container));
container.appendChild(btn); container.appendChild(btn);
}); });
this._renderCursor();
} }
pick(chosen, q, container) { pick(chosen, correctIdx, container) {
if (this.state.answering) return; if(this.state.answering)return;
this.state.answering = true; this.state.answering=true;
const btns = container.querySelectorAll('.choice-btn'); const btns=container.querySelectorAll('.choice-btn');
const correct = chosen === q.answer; const correct=chosen===correctIdx;
btns.forEach(b => b.classList.add('disabled')); btns.forEach(b=>{ b.classList.remove('cursor'); b.classList.add('disabled'); });
btns[q.answer].classList.remove('disabled'); btns[correctIdx].classList.remove('disabled'); btns[correctIdx].classList.add('correct');
btns[q.answer].classList.add('correct'); if(!correct) btns[chosen].classList.add('wrong');
if (!correct) btns[chosen].classList.add('wrong');
if (correct) { if(correct){
this.state.streak++; this.state.streak++; this.state.correct++;
this.state.correct++; this.state.maxStreak=Math.max(this.state.maxStreak,this.state.streak);
this.state.maxStreak = Math.max(this.state.maxStreak, this.state.streak); this.state.score+=this.state.streak>=5?200:this.state.streak>=3?150:100;
const bonus = this.state.streak >= 5 ? 200 : this.state.streak >= 3 ? 150 : 100; } else { this.state.streak=0; this.state.wrong++; }
this.state.score += bonus;
} else {
this.state.streak = 0;
this.state.wrong++;
}
setTimeout(() => this.showResult(correct, q), 850); setTimeout(()=>this.showResult(correct,q),850);
} }
/* ── RESULT ───────────────────────────────────────────── */ /* ── RESULT ───────────────────────────────────────────── */
showResult(correct, q) { showResult(correct,q) {
this.show('result'); this.show('result');
this._showController(false);
const icon = document.getElementById('result-icon'); document.getElementById('result-icon').textContent = correct?'✓':'✗';
const label = document.getElementById('result-label'); const lbl=document.getElementById('result-label');
const ansEl = document.getElementById('result-correct-ans'); lbl.textContent = correct ? CORRECT_FB[Math.floor(Math.random()*CORRECT_FB.length)]
const expEl = document.getElementById('result-explanation'); : WRONG_FB[Math.floor(Math.random()*WRONG_FB.length)];
const ptsEl = document.getElementById('result-pts'); lbl.className = 'result-label '+(correct?'correct':'wrong');
if (correct) { const ansEl=document.getElementById('result-correct-ans');
icon.textContent = '✓'; const ptsEl=document.getElementById('result-pts');
label.textContent = CORRECT_FB[Math.floor(Math.random() * CORRECT_FB.length)]; if(correct){
label.className = 'result-label correct'; ptsEl.textContent=this.state.streak>=5?'+200 PTS 🔥 STREAK!':this.state.streak>=3?'+150 PTS 🔥 HOT!':'+100 PTS';
ptsEl.textContent = this.state.streak >= 5 ? '+200 PTS 🔥 STREAK BONUS!' ptsEl.style.display='block'; ansEl.classList.remove('visible');
: this.state.streak >= 3 ? '+150 PTS 🔥 HOT!'
: '+100 PTS';
ptsEl.style.display = 'block';
ansEl.classList.remove('visible');
} else { } else {
icon.textContent = '✗'; ptsEl.style.display='none';
label.textContent = WRONG_FB[Math.floor(Math.random() * WRONG_FB.length)]; ansEl.textContent='✓ Correct answer: '+q.options[q.answer];
label.className = 'result-label wrong';
ptsEl.style.display = 'none';
ansEl.textContent = '✓ Correct answer: ' + q.options[q.answer];
ansEl.classList.add('visible'); ansEl.classList.add('visible');
} }
document.getElementById('result-explanation').textContent=q.explanation;
expEl.textContent = q.explanation; document.getElementById('btn-result-cont').onclick=()=>{
document.getElementById('btn-result-cont').onclick = () => {
this.state.currentQ++; this.state.currentQ++;
saveGame(this.state); saveGame(this.state);
if (this.state.currentQ >= this.state.questions.length) { if(this.state.currentQ>=this.state.questions.length){ clearSave(); this.showComplete(); }
clearSave(); else if(MILESTONES[this.state.currentQ]) this.showLevelUp();
this.showComplete(); else this.showMap();
} else if (MILESTONES[this.state.currentQ]) {
this.showLevelUp();
} else {
this.showMap();
}
}; };
} }
/* ── LEVEL UP ─────────────────────────────────────────── */ /* ── LEVEL UP ─────────────────────────────────────────── */
showLevelUp() { showLevelUp() {
this.show('levelup'); this.show('levelup');
const ms = MILESTONES[this.state.currentQ]; this._showController(false);
const pct = (this.state.currentQ / (this.state.questions.length || 100)) * 100; const ms=MILESTONES[this.state.currentQ];
const pct=(this.state.currentQ/(this.state.questions.length||100))*100;
document.getElementById('lu-name').textContent = this.state.playerName || 'ASH'; document.getElementById('lu-name').textContent = this.state.playerName||'ASH';
document.getElementById('lu-stars').textContent = `${ms.stars} BADGE EARNED! ${ms.stars}`; document.getElementById('lu-stars').textContent = `${ms.stars} BADGE EARNED! ${ms.stars}`;
document.getElementById('lu-badge').textContent = ms.badge; document.getElementById('lu-badge').textContent = ms.badge;
document.getElementById('lu-prog-label').textContent = `${this.state.currentQ} / 100`; document.getElementById('lu-prog-label').textContent=`${this.state.currentQ} / 100`;
const bar=document.getElementById('lu-prog-inner');
const bar = document.getElementById('lu-prog-inner'); if(bar)bar.style.width=pct+'%';
if (bar) bar.style.width = pct + '%'; document.getElementById('btn-lu-cont').onclick=()=>this.showMap();
document.getElementById('btn-lu-cont').onclick = () => this.showMap();
} }
/* ── COMPLETE ─────────────────────────────────────────── */ /* ── COMPLETE ─────────────────────────────────────────── */
showComplete() { showComplete() {
this.show('complete'); this.show('complete');
const name = this.state.playerName || 'ASH'; this._showController(false);
const tot = this.state.questions.length || 100; const name=this.state.playerName||'ASH', tot=this.state.questions.length||100;
const acc = tot > 0 ? Math.round((this.state.correct / tot) * 100) : 0; const acc=tot>0?Math.round((this.state.correct/tot)*100):0;
document.getElementById('complete-name').textContent=name;
document.getElementById('complete-name').textContent = name; document.getElementById('complete-stats').textContent=
document.getElementById('complete-stats').textContent = `FINAL SCORE: ${this.state.score.toLocaleString()}\n`+
`FINAL SCORE: ${this.state.score.toLocaleString()}\n` + `CORRECT: ${this.state.correct} / ${tot}\n`+
`CORRECT: ${this.state.correct} / ${tot}\n` + `ACCURACY: ${acc}%\n`+
`ACCURACY: ${acc}%\n` +
`BEST STREAK: ${this.state.maxStreak} 🔥`; `BEST STREAK: ${this.state.maxStreak} 🔥`;
document.getElementById('btn-play-again').onclick=()=>{ clearSave(); this.startFresh(); };
document.getElementById('btn-play-again').onclick = () => {
clearSave();
this.startFresh();
};
} }
/* ── SCREEN SWITCHER ──────────────────────────────────── */ /* ── SCREEN SWITCH ────────────────────────────────────── */
show(name) { show(name) {
Object.values(this.screens).forEach(s => s.classList.remove('active')); Object.values(this.screens).forEach(s=>s.classList.remove('active'));
const t = this.screens[name]; const t=this.screens[name]; if(t)t.classList.add('active');
if (t) t.classList.add('active'); this.state.screen=name;
this.state.screen = name;
} }
/* ── TYPEWRITER ───────────────────────────────────────── */ /* ── TYPEWRITER ───────────────────────────────────────── */
typeText(id, text, onDone) { typeText(id,text,onDone) {
if (this.state.twTimer) { clearInterval(this.state.twTimer); this.state.twTimer = null; } if(this.state.twTimer){clearInterval(this.state.twTimer);this.state.twTimer=null;}
const el = document.getElementById(id); const el=document.getElementById(id); if(!el)return; el.textContent='';
if (!el) return; let i=0;
el.textContent = ''; this.state.twTimer=setInterval(()=>{
let i = 0; if(i<text.length){el.textContent+=text[i++];}
this.state.twTimer = setInterval(() => { else{clearInterval(this.state.twTimer);this.state.twTimer=null;if(onDone)onDone();}
if (i < text.length) { el.textContent += text[i++]; } },26);
else {
clearInterval(this.state.twTimer);
this.state.twTimer = null;
if (onDone) onDone();
}
}, 26);
} }
/* ── TOAST ────────────────────────────────────────────── */ /* ── TOAST ────────────────────────────────────────────── */
toast(msg, ms = 2600) { toast(msg,ms=2600){
const el = document.getElementById('toast'); const el=document.getElementById('toast');
el.textContent = msg; el.textContent=msg; el.classList.remove('hidden');
el.classList.remove('hidden'); setTimeout(()=>el.classList.add('hidden'),ms);
setTimeout(() => el.classList.add('hidden'), ms);
}
/* ── GLOBAL KEYBOARD ──────────────────────────────────── */
bindGlobalKeys() {
document.addEventListener('keydown', (e) => {
const s = this.state.screen;
if (s === 'name') {
if (e.key === 'Backspace') { e.preventDefault(); this.delChar(); }
else if (e.key === 'Enter') { this.confirmName(); }
else if (e.key.length === 1 && /[A-Z0-9.\-!?]/i.test(e.key)) {
this.addChar(e.key.toUpperCase());
}
return;
}
if (s === 'title' && (e.key === 'Enter' || e.key === ' ')) {
document.getElementById('screen-title').click();
return;
}
if (s === 'battle' && !this.state.answering) {
const map = { '1':0, '2':1, '3':2, '4':3, 'a':0, 'b':1, 'c':2, 'd':3 };
const idx = map[e.key.toLowerCase()];
if (idx !== undefined) {
const btns = document.querySelectorAll('.choice-btn:not(.disabled)');
if (btns[idx]) btns[idx].click();
}
return;
}
if (e.key === 'Enter' || e.key === ' ') {
if (s === 'result') { document.getElementById('btn-result-cont')?.click(); }
if (s === 'levelup') { document.getElementById('btn-lu-cont')?.click(); }
if (s === 'map') { document.getElementById('btn-walk')?.click(); }
}
});
} }
} }
/* ─── INIT ───────────────────────────────────────────────── */ window.addEventListener('DOMContentLoaded',()=>{ new Game(); });
window.addEventListener('DOMContentLoaded', () => { new Game(); });
+39 -8
View File
@@ -27,7 +27,7 @@
<div class="title-stars"></div> <div class="title-stars"></div>
<div class="title-main"> <div class="title-main">
<div class="title-logo"> <div class="title-logo">
<span class="title-poke">POKÉ</span><span class="title-query">SQL</span> <span class="title-poke">POKÉ</span><span class="title-sql">SQL</span>
</div> </div>
<div class="title-sub">Learn Microsoft T-SQL</div> <div class="title-sub">Learn Microsoft T-SQL</div>
<div class="title-edition">★ SQL TRAINER EDITION ★</div> <div class="title-edition">★ SQL TRAINER EDITION ★</div>
@@ -84,7 +84,7 @@
<div class="dialog-text" id="intro-text"></div> <div class="dialog-text" id="intro-text"></div>
<div class="dialog-arrow blink" id="intro-arrow" style="display:none">▼</div> <div class="dialog-arrow blink" id="intro-arrow" style="display:none">▼</div>
</div> </div>
<div class="tap-hint blink">TAP TO CONTINUE</div> <div class="tap-hint blink">TAP / PRESS A TO CONTINUE</div>
</div> </div>
<!-- OVERWORLD MAP --> <!-- OVERWORLD MAP -->
@@ -100,17 +100,22 @@
<span class="save-dot" id="save-dot" title="Auto-saved">💾</span> <span class="save-dot" id="save-dot" title="Auto-saved">💾</span>
</div> </div>
</div> </div>
<div class="overworld-bg map-world"> <div class="overworld-bg map-world" id="map-world">
<div class="map-trees"> <div class="map-trees">
<span class="tree">🌲</span><span class="tree">🌳</span> <span class="tree">🌲</span><span class="tree">🌳</span>
<span class="tree">🌲</span><span class="tree">🌳</span><span class="tree">🌲</span> <span class="tree">🌲</span><span class="tree">🌳</span><span class="tree">🌲</span>
</div> </div>
<!-- Tiled path floor -->
<div class="map-ground"></div> <div class="map-ground"></div>
<div class="map-npc-wrap"> <!-- NPC with ! bubble -->
<div class="npc-bubble">!</div> <div class="map-npc-wrap" id="map-npc-wrap">
<div class="npc-bubble" id="npc-bubble">!</div>
<div class="map-npc" id="map-npc"></div> <div class="map-npc" id="map-npc"></div>
</div> </div>
<div class="map-player">🧢</div> <!-- Pixel player — position driven by JS -->
<div class="map-player" id="map-player">🧢</div>
<!-- interaction speech bubble -->
<div class="map-talk-hint" id="map-talk-hint" style="display:none">Press A</div>
</div> </div>
<div class="map-hud"> <div class="map-hud">
<div class="hud-cell"><div class="hud-lbl">TRAINER</div><div class="hud-val" id="hud-name">ASH</div></div> <div class="hud-cell"><div class="hud-lbl">TRAINER</div><div class="hud-val" id="hud-name">ASH</div></div>
@@ -118,7 +123,6 @@
<div class="hud-cell"><div class="hud-lbl">STREAK</div><div class="hud-val" id="hud-streak">0</div></div> <div class="hud-cell"><div class="hud-lbl">STREAK</div><div class="hud-val" id="hud-streak">0</div></div>
<div class="hud-cell"><div class="hud-lbl">CORRECT</div><div class="hud-val" id="hud-correct">0/0</div></div> <div class="hud-cell"><div class="hud-lbl">CORRECT</div><div class="hud-val" id="hud-correct">0/0</div></div>
</div> </div>
<button class="btn-pixel btn-green btn-walk" id="btn-walk">▶ TALK TO NPC</button>
</div> </div>
<!-- BATTLE / QUESTION --> <!-- BATTLE / QUESTION -->
@@ -141,7 +145,7 @@
<div class="battle-choices" id="battle-choices"></div> <div class="battle-choices" id="battle-choices"></div>
<div class="battle-footer"> <div class="battle-footer">
<span>Q <span id="battle-q-num">1</span>/100</span> <span>Q <span id="battle-q-num">1</span>/100</span>
<span class="kbd-hint">1–4 keys to answer</span> <span class="kbd-hint">Click or tap an answer · 1–4 keys</span>
<span>PTS: <span id="battle-pts">0</span></span> <span>PTS: <span id="battle-pts">0</span></span>
</div> </div>
</div> </div>
@@ -195,6 +199,33 @@
</div><!-- /#app --> </div><!-- /#app -->
<!-- ═══ GBA CONTROLLER OVERLAY ═══════════════════════════════
Shown over map + battle screens. Hidden on all others. -->
<div id="gba-controller" class="gba-controller hidden">
<!-- D-PAD (left) -->
<div class="dpad" id="dpad">
<button class="dpad-btn dpad-up" id="dp-up" aria-label="Up">▲</button>
<button class="dpad-btn dpad-left" id="dp-left" aria-label="Left">◀</button>
<button class="dpad-btn dpad-center" aria-label="Center"></button>
<button class="dpad-btn dpad-right" id="dp-right" aria-label="Right">▶</button>
<button class="dpad-btn dpad-down" id="dp-down" aria-label="Down">▼</button>
</div>
<!-- SELECT / START (center) -->
<div class="gba-mid-btns">
<button class="gba-mid-btn" id="btn-select">SELECT</button>
<button class="gba-mid-btn" id="btn-start">START</button>
</div>
<!-- A / B BUTTONS (right) -->
<div class="ab-cluster">
<button class="ab-btn ab-b" id="btn-b">B</button>
<button class="ab-btn ab-a" id="btn-a">A</button>
</div>
</div>
<div id="toast" class="toast hidden"></div> <div id="toast" class="toast hidden"></div>
<script src="game.js"></script> <script src="game.js"></script>
</body> </body>
+327 -603
View File
File diff suppressed because it is too large. Load diff