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
+754 -819

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
+388 -247
View File
@@ -46,50 +46,25 @@ const MILESTONES = {
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 = {};
@@ -97,89 +72,334 @@ class Game {
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() {
@@ -251,17 +453,16 @@ class Game {
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;
} }
@@ -273,65 +474,64 @@ class Game {
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;
cb();
})
.catch(()=>this.toast('❌ Could not load questions.json')); .catch(()=>this.toast('❌ Could not load questions.json'));
} }
/* ── MAP ──────────────────────────────────────────────── */ /* ── MAP ──────────────────────────────────────────────── */
showMap() { showMap() {
this.show('map'); this.show('map');
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'; 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) { if(bar){ bar.style.width=pct+'%'; bar.style.background=pct<40?'var(--hp-green)':pct<75?'var(--hp-yellow)':'#60c8ff'; }
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() {
this._stopWalkLoop();
this._heldKeys.clear();
const q=this.state.questions[this.state.currentQ]; const q=this.state.questions[this.state.currentQ];
if(!q){ this.showComplete(); return; } if(!q){ this.showComplete(); return; }
this.show('battle'); this.show('battle');
this._showController(true);
this.state.answering=false; 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();
@@ -348,38 +548,44 @@ class Game {
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;
/* Shuffle options so the correct answer lands in a random slot each time.
We track which shuffled index holds the correct answer. */
const indices = [0,1,2,3];
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'); const btn=document.createElement('button');
btn.className='choice-btn'; btn.className='choice-btn';
btn.setAttribute('data-letter', letter); btn.setAttribute('data-letter', slotIdx + 1);
btn.textContent = q.options[i]; btn.textContent=q.options[origIdx];
btn.addEventListener('click', () => this.pick(i, q, container)); 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);
const bonus = this.state.streak >= 5 ? 200 : this.state.streak >= 3 ? 150 : 100; this.state.score+=this.state.streak>=5?200:this.state.streak>=3?150:100;
this.state.score += bonus; } else { this.state.streak=0; this.state.wrong++; }
} else {
this.state.streak = 0;
this.state.wrong++;
}
setTimeout(()=>this.showResult(correct,q),850); setTimeout(()=>this.showResult(correct,q),850);
} }
@@ -387,154 +593,89 @@ class Game {
/* ── RESULT ───────────────────────────────────────────── */ /* ── RESULT ───────────────────────────────────────────── */
showResult(correct,q) { showResult(correct,q) {
this.show('result'); this.show('result');
this._showController(false);
document.getElementById('result-icon').textContent = correct?'✓':'✗';
const lbl=document.getElementById('result-label');
lbl.textContent = correct ? CORRECT_FB[Math.floor(Math.random()*CORRECT_FB.length)]
: WRONG_FB[Math.floor(Math.random()*WRONG_FB.length)];
lbl.className = 'result-label '+(correct?'correct':'wrong');
const icon = document.getElementById('result-icon');
const label = document.getElementById('result-label');
const ansEl=document.getElementById('result-correct-ans'); const ansEl=document.getElementById('result-correct-ans');
const expEl = document.getElementById('result-explanation');
const ptsEl=document.getElementById('result-pts'); const ptsEl=document.getElementById('result-pts');
if(correct){ if(correct){
icon.textContent = '✓'; ptsEl.textContent=this.state.streak>=5?'+200 PTS 🔥 STREAK!':this.state.streak>=3?'+150 PTS 🔥 HOT!':'+100 PTS';
label.textContent = CORRECT_FB[Math.floor(Math.random() * CORRECT_FB.length)]; ptsEl.style.display='block'; ansEl.classList.remove('visible');
label.className = 'result-label correct';
ptsEl.textContent = this.state.streak >= 5 ? '+200 PTS 🔥 STREAK BONUS!'
: this.state.streak >= 3 ? '+150 PTS 🔥 HOT!'
: '+100 PTS';
ptsEl.style.display = 'block';
ansEl.classList.remove('visible');
} else { } else {
icon.textContent = '✗';
label.textContent = WRONG_FB[Math.floor(Math.random() * WRONG_FB.length)];
label.className = 'result-label wrong';
ptsEl.style.display='none'; ptsEl.style.display='none';
ansEl.textContent='✓ Correct answer: '+q.options[q.answer]; 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');
this._showController(false);
const ms=MILESTONES[this.state.currentQ]; const ms=MILESTONES[this.state.currentQ];
const pct=(this.state.currentQ/(this.state.questions.length||100))*100; 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;
el.textContent = '';
let i=0; let i=0;
this.state.twTimer=setInterval(()=>{ this.state.twTimer=setInterval(()=>{
if(i<text.length){el.textContent+=text[i++];} if(i<text.length){el.textContent+=text[i++];}
else { else{clearInterval(this.state.twTimer);this.state.twTimer=null;if(onDone)onDone();}
clearInterval(this.state.twTimer);
this.state.twTimer = null;
if (onDone) onDone();
}
},26); },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>
+270 -546
View File
@@ -21,11 +21,15 @@
--hp-green: #50c020; --hp-green: #50c020;
--hp-yellow: #c8b000; --hp-yellow: #c8b000;
--font: 'Press Start 2P', monospace; --font: 'Press Start 2P', monospace;
/* layout */
--app-w: 420px; --app-w: 420px;
--app-h: 680px; --app-h: 680px;
--safe-bottom: env(safe-area-inset-bottom, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px);
/* GBA controller palette */
--gba-body: #6a5acd;
--gba-dark: #3d2f99;
--gba-ab-a: #e83030;
--gba-ab-b: #2060d0;
--gba-dpad: #1a1a2e;
} }
/* ─── RESET ──────────────────────────────────────────────── */ /* ─── RESET ──────────────────────────────────────────────── */
@@ -52,26 +56,15 @@ html, body {
height: min(var(--app-h), 100dvh); height: min(var(--app-h), 100dvh);
background: var(--dark-bg); background: var(--dark-bg);
overflow: hidden; overflow: hidden;
/* desktop: device frame */
border: 5px solid #111; border: 5px solid #111;
border-radius: 6px; border-radius: 6px;
box-shadow: box-shadow: 0 0 0 2px #333, 0 0 40px rgba(100,80,200,.55), 0 0 90px rgba(80,60,180,.25);
0 0 0 2px #333,
0 0 40px rgba(100,80,200,.55),
0 0 90px rgba(80,60,180,.25);
} }
/* scanline overlay */
#app::after { #app::after {
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
background: repeating-linear-gradient( background: repeating-linear-gradient(0deg, transparent 0px, transparent 2px, rgba(0,0,0,.045) 2px, rgba(0,0,0,.045) 4px);
0deg,
transparent 0px, transparent 2px,
rgba(0,0,0,.045) 2px, rgba(0,0,0,.045) 4px
);
pointer-events: none; pointer-events: none;
z-index: 900; z-index: 900;
} }
@@ -79,22 +72,11 @@ html, body {
/* ─── MOBILE FULL-SCREEN ─────────────────────────────────── */ /* ─── MOBILE FULL-SCREEN ─────────────────────────────────── */
@media (max-width: 480px), (max-height: 720px) { @media (max-width: 480px), (max-height: 720px) {
html, body { align-items: flex-start; } html, body { align-items: flex-start; }
#app { #app { width: 100vw; height: 100dvh; border: none; border-radius: 0; box-shadow: none; }
width: 100vw;
height: 100dvh;
border: none;
border-radius: 0;
box-shadow: none;
}
} }
/* ─── SCREENS ────────────────────────────────────────────── */ /* ─── SCREENS ────────────────────────────────────────────── */
.screen { .screen { position: absolute; inset: 0; display: none; flex-direction: column; }
position: absolute;
inset: 0;
display: none;
flex-direction: column;
}
.screen.active { display: flex; } .screen.active { display: flex; }
/* ─── ANIMATIONS ─────────────────────────────────────────── */ /* ─── ANIMATIONS ─────────────────────────────────────────── */
@@ -108,12 +90,18 @@ html, body {
@keyframes wrongShake { 0%,100%{transform:translateX(0)}25%{transform:translateX(-5px)}75%{transform:translateX(5px)} } @keyframes wrongShake { 0%,100%{transform:translateX(0)}25%{transform:translateX(-5px)}75%{transform:translateX(5px)} }
@keyframes npcFloat { 0%,100%{transform:translateY(0)}50%{transform:translateY(-8px)} } @keyframes npcFloat { 0%,100%{transform:translateY(0)}50%{transform:translateY(-8px)} }
@keyframes treeSway { 0%,100%{transform:rotate(-2deg)}50%{transform:rotate(2deg)} } @keyframes treeSway { 0%,100%{transform:rotate(-2deg)}50%{transform:rotate(2deg)} }
@keyframes walkLeft { 0%,100%{transform:scaleX(-1) translateY(0)}50%{transform:scaleX(-1) translateY(-4px)} }
@keyframes walkRight { 0%,100%{transform:scaleX(1) translateY(0)}50%{transform:scaleX(1) translateY(-4px)} }
@keyframes walkUp { 0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)} }
@keyframes walkDown { 0%,100%{transform:translateY(0)}50%{transform:translateY(4px)} }
@keyframes dpadPress { 0%{transform:scale(1)}50%{transform:scale(.88)}100%{transform:scale(1)} }
@keyframes abPress { 0%{transform:scale(1)}40%{transform:scale(.82)}100%{transform:scale(1)} }
@keyframes choiceCursor { 0%,100%{box-shadow:0 0 0 3px var(--yellow), 2px 2px 0 var(--darkgray)}50%{box-shadow:0 0 0 3px #ffe878, 2px 2px 0 var(--darkgray)} }
@keyframes talkBubble { 0%{transform:translateY(0)}50%{transform:translateY(-3px)} }
.blink { animation: blink 1s step-end infinite; } .blink { animation: blink 1s step-end infinite; }
/* ─── SHARED COMPONENTS ──────────────────────────────────── */ /* ─── DIALOG BOX ─────────────────────────────────────────── */
/* Dialog box — the heart of the Pokémon look */
.dialog-box { .dialog-box {
background: var(--dialog-bg); background: var(--dialog-bg);
border: 4px solid var(--dialog-bd); border: 4px solid var(--dialog-bd);
@@ -147,14 +135,9 @@ html, body {
padding-top: 2px; padding-top: 2px;
min-height: 52px; min-height: 52px;
} }
.dialog-arrow { .dialog-arrow { font-size: 10px; color: var(--black); text-align: right; margin-top: 4px; }
font-size: 10px;
color: var(--black);
text-align: right;
margin-top: 4px;
}
/* Pixel buttons */ /* ─── PIXEL BUTTONS ──────────────────────────────────────── */
.btn-pixel { .btn-pixel {
display: inline-block; display: inline-block;
font-family: var(--font); font-family: var(--font);
@@ -169,19 +152,13 @@ html, body {
user-select: none; user-select: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.btn-pixel:active { .btn-pixel:active { transform: translate(3px,3px); box-shadow: none; }
transform: translate(3px,3px); .btn-blue { background: var(--blue); } .btn-blue:hover { background: var(--blue-hover); }
box-shadow: none; .btn-green { background: var(--green); } .btn-green:hover { background: var(--green-hover); }
} .btn-red { background: var(--red); } .btn-red:hover { background: var(--red-hover); }
.btn-blue { background: var(--blue); }
.btn-blue:hover { background: var(--blue-hover); }
.btn-green { background: var(--green); }
.btn-green:hover { background: var(--green-hover); }
.btn-red { background: var(--red); }
.btn-red:hover { background: var(--red-hover); }
.btn-full { width: 100%; text-align: center; } .btn-full { width: 100%; text-align: center; }
/* Overworld background */ /* ─── OVERWORLD BG ───────────────────────────────────────── */
.overworld-bg { .overworld-bg {
flex: 1; flex: 1;
position: relative; position: relative;
@@ -192,44 +169,151 @@ html, body {
linear-gradient(180deg, #6cb84e 0%, #58a83e 100%); linear-gradient(180deg, #6cb84e 0%, #58a83e 100%);
} }
/* ─── BOOT ───────────────────────────────────────────────── */ /* ═══════════════════════════════════════════════════════════
#screen-boot { GBA CONTROLLER OVERLAY
background: var(--white); Sits outside #app, fixed to viewport bottom.
Shown only when screen === 'map' or 'battle'.
═══════════════════════════════════════════════════════════ */
.gba-controller {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: clamp(110px, 22vw, 148px);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 clamp(8px,3vw,20px) max(8px, var(--safe-bottom));
background: linear-gradient(180deg, rgba(26,10,62,.82) 0%, rgba(14,6,36,.95) 100%);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border-top: 2px solid rgba(120,80,255,.35);
z-index: 800;
user-select: none;
touch-action: none;
pointer-events: auto;
}
.gba-controller.hidden { display: none; }
/* Only show controller on touch / small screens by default.
On desktop we rely on keyboard — but controller still shows
if screen is narrow enough. JS toggles it per screen anyway. */
@media (min-width: 900px) {
.gba-controller { display: none !important; }
}
/* ── D-PAD ───────────────────────────────────────────────── */
.dpad {
position: relative;
width: clamp(90px, 21vw, 120px);
height: clamp(90px, 21vw, 120px);
flex-shrink: 0;
}
.dpad-btn {
position: absolute;
background: var(--gba-dpad);
border: 2px solid rgba(255,255,255,.18);
color: rgba(255,255,255,.75);
font-size: clamp(8px, 2.2vw, 11px);
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
animation: fadeIn .4s ease; cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: none;
transition: background .08s;
} }
.dpad-btn:active, .dpad-btn.pressed {
background: #3a3a6e;
animation: dpadPress .15s ease;
}
/* Cross layout: 3×3 grid conceptually */
.dpad-up { top:0; left:33.3%; width:33.4%; height:33.4%; border-radius:4px 4px 0 0; }
.dpad-down { bottom:0; left:33.3%; width:33.4%; height:33.4%; border-radius:0 0 4px 4px; }
.dpad-left { top:33.3%; left:0; width:33.4%; height:33.4%; border-radius:4px 0 0 4px; }
.dpad-right { top:33.3%; right:0; width:33.4%; height:33.4%; border-radius:0 4px 4px 0; }
.dpad-center{ top:33.3%; left:33.3%; width:33.4%; height:33.4%; background:#111; border:none; cursor:default; }
/* ── SELECT / START (center strip) ───────────────────────── */
.gba-mid-btns {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
}
.gba-mid-btn {
background: #2a1a5e;
border: 2px solid rgba(255,255,255,.2);
border-radius: 20px;
color: rgba(255,255,255,.6);
font-family: var(--font);
font-size: clamp(4.5px, 1.2vw, 6px);
padding: 4px 10px;
cursor: pointer;
letter-spacing: .5px;
-webkit-tap-highlight-color: transparent;
transition: background .08s;
}
.gba-mid-btn:active { background: #3d2f99; }
/* ── A / B BUTTONS ───────────────────────────────────────── */
.ab-cluster {
position: relative;
width: clamp(90px, 21vw, 120px);
height: clamp(90px, 21vw, 120px);
flex-shrink: 0;
}
.ab-btn {
position: absolute;
width: clamp(36px, 9vw, 48px);
height: clamp(36px, 9vw, 48px);
border-radius: 50%;
border: 3px solid rgba(0,0,0,.5);
font-family: var(--font);
font-size: clamp(9px, 2.5vw, 13px);
font-weight: bold;
color: var(--white);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 0 rgba(0,0,0,.4), inset 0 1px 0 rgba(255,255,255,.3);
-webkit-tap-highlight-color: transparent;
touch-action: none;
transition: transform .06s, box-shadow .06s;
}
.ab-btn:active, .ab-btn.pressed {
transform: translateY(3px);
box-shadow: 0 1px 0 rgba(0,0,0,.4), inset 0 1px 0 rgba(255,255,255,.2);
animation: abPress .18s ease;
}
/* B = left, A = right (classic GBA layout) */
.ab-b { background: radial-gradient(circle at 35% 35%, #4080ff, var(--gba-ab-b)); bottom: 14%; left: 5%; }
.ab-a { background: radial-gradient(circle at 35% 35%, #ff5555, var(--gba-ab-a)); top: 14%; right: 5%; }
/* label under each button */
.ab-btn::after {
content: attr(id) == 'btn-a' ? 'A' : 'B';
position: absolute;
bottom: -14px;
font-size: 5px;
color: rgba(255,255,255,.4);
letter-spacing: 1px;
}
/* ─── BOOT ───────────────────────────────────────────────── */
#screen-boot { background: var(--white); align-items: center; justify-content: center; animation: fadeIn .4s ease; }
.boot-logo { text-align: center; } .boot-logo { text-align: center; }
.boot-gameboy { .boot-gameboy { font-size: clamp(8px,2.5vw,11px); color: var(--black); letter-spacing: 4px; margin-bottom: 10px; }
font-size: clamp(8px, 2.5vw, 11px); .boot-ding { font-size: 28px; margin-bottom: 8px; animation: goldShimmer 1.5s ease-in-out infinite; }
color: var(--black);
letter-spacing: 4px;
margin-bottom: 10px;
}
.boot-ding {
font-size: 28px;
margin-bottom: 8px;
animation: goldShimmer 1.5s ease-in-out infinite;
}
.boot-copy { font-size: 6px; color: var(--gray); } .boot-copy { font-size: 6px; color: var(--gray); }
/* ─── TITLE ──────────────────────────────────────────────── */ /* ─── TITLE ──────────────────────────────────────────────── */
#screen-title { #screen-title { background: linear-gradient(180deg,#08031a 0%,#180a44 45%,#240e6a 75%,#120538 100%); overflow: hidden; }
background: linear-gradient(180deg, #08031a 0%, #180a44 45%, #240e6a 75%, #120538 100%); .title-bg { position:relative; width:100%; height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center; }
overflow: hidden;
}
.title-bg {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.title-stars { .title-stars {
position: absolute; position: absolute; inset: 0;
inset: 0;
background-image: background-image:
radial-gradient(1px 1px at 8% 12%, #fff 100%,transparent), radial-gradient(1px 1px at 8% 12%, #fff 100%,transparent),
radial-gradient(1px 1px at 22% 38%, #fff 100%,transparent), radial-gradient(1px 1px at 22% 38%, #fff 100%,transparent),
@@ -242,218 +326,52 @@ html, body {
radial-gradient(2px 2px at 18% 82%, rgba(255,220,100,.8) 100%,transparent), radial-gradient(2px 2px at 18% 82%, rgba(255,220,100,.8) 100%,transparent),
radial-gradient(2px 2px at 78% 78%, rgba(255,220,100,.8) 100%,transparent); radial-gradient(2px 2px at 78% 78%, rgba(255,220,100,.8) 100%,transparent);
} }
.title-main { .title-main { text-align:center; z-index:1; padding:24px 16px; }
text-align: center; .title-logo { margin-bottom:16px; line-height:1; }
z-index: 1; .title-poke { font-size:clamp(30px,11vw,50px); color:var(--yellow); text-shadow:4px 4px 0 #7a5800,0 0 24px rgba(248,192,48,.5); animation:goldShimmer 3s ease-in-out infinite; display:inline-block; }
padding: 24px 16px; .title-sql { font-size:clamp(30px,11vw,50px); color:#60c8ff; text-shadow:4px 4px 0 #003d70,0 0 24px rgba(96,200,255,.5); display:inline-block; }
} .title-sub { font-size:clamp(6px,1.8vw,8px); color:rgba(255,255,255,.75); letter-spacing:1px; margin:10px 0 6px; }
.title-logo { .title-edition { font-size:clamp(5.5px,1.6vw,7px); color:var(--yellow); margin-bottom:24px; animation:goldShimmer 3s ease-in-out infinite; }
margin-bottom: 16px; .title-deco { font-size:clamp(22px,6vw,32px); margin-bottom:28px; display:flex; gap:16px; justify-content:center; }
line-height: 1;
}
.title-poke {
font-size: clamp(30px, 11vw, 50px);
color: var(--yellow);
text-shadow: 4px 4px 0 #7a5800, 0 0 24px rgba(248,192,48,.5);
animation: goldShimmer 3s ease-in-out infinite;
display: inline-block;
}
.title-query {
font-size: clamp(30px, 11vw, 50px);
color: #60c8ff;
text-shadow: 4px 4px 0 #003d70, 0 0 24px rgba(96,200,255,.5);
display: inline-block;
}
.title-sub {
font-size: clamp(6px, 1.8vw, 8px);
color: rgba(255,255,255,.75);
letter-spacing: 1px;
margin: 10px 0 6px;
}
.title-edition {
font-size: clamp(5.5px, 1.6vw, 7px);
color: var(--yellow);
margin-bottom: 24px;
animation: goldShimmer 3s ease-in-out infinite;
}
.title-deco {
font-size: clamp(22px, 6vw, 32px);
margin-bottom: 28px;
display: flex;
gap: 16px;
justify-content: center;
}
.title-deco span:nth-child(1){animation:npcFloat 2.0s ease-in-out infinite;} .title-deco span:nth-child(1){animation:npcFloat 2.0s ease-in-out infinite;}
.title-deco span:nth-child(2){animation:npcFloat 2.4s ease-in-out infinite;} .title-deco span:nth-child(2){animation:npcFloat 2.4s ease-in-out infinite;}
.title-deco span:nth-child(3){animation:npcFloat 2.8s ease-in-out infinite;} .title-deco span:nth-child(3){animation:npcFloat 2.8s ease-in-out infinite;}
.title-tap { .title-tap { font-size:clamp(7px,2vw,9px); color:var(--white); letter-spacing:1px; }
font-size: clamp(7px, 2vw, 9px); .title-footer { position:absolute; bottom:max(12px,var(--safe-bottom)); font-size:6px; color:rgba(255,255,255,.3); letter-spacing:.5px; }
color: var(--white);
letter-spacing: 1px;
}
.title-footer {
position: absolute;
bottom: max(12px, var(--safe-bottom));
font-size: 6px;
color: rgba(255,255,255,.3);
letter-spacing: .5px;
}
/* ─── CONTINUE ───────────────────────────────────────────── */ /* ─── CONTINUE ───────────────────────────────────────────── */
#screen-continue { #screen-continue { background:linear-gradient(180deg,#08031a 0%,#180a44 100%); align-items:center; justify-content:center; }
background: linear-gradient(180deg, #08031a 0%, #180a44 100%); .continue-bg { width:90%; max-width:340px; display:flex; flex-direction:column; align-items:center; gap:16px; animation:fadeIn .5s ease; }
align-items: center; .continue-logo { font-size:clamp(16px,5vw,22px); color:var(--yellow); text-shadow:3px 3px 0 #7a5800; animation:goldShimmer 3s ease-in-out infinite; }
justify-content: center;
}
.continue-bg {
width: 90%;
max-width: 340px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
animation: fadeIn .5s ease;
}
.continue-logo {
font-size: clamp(16px, 5vw, 22px);
color: var(--yellow);
text-shadow: 3px 3px 0 #7a5800;
animation: goldShimmer 3s ease-in-out infinite;
}
.continue-logo span { color:#60c8ff; text-shadow:3px 3px 0 #003d70; } .continue-logo span { color:#60c8ff; text-shadow:3px 3px 0 #003d70; }
.continue-card { .continue-card { width:100%; background:var(--dialog-bg); border:4px solid var(--dialog-bd); padding:16px; display:flex; flex-direction:column; gap:10px; box-shadow:4px 4px 0 rgba(0,0,0,.5); }
width: 100%; .continue-card-header { font-size:8px; color:var(--black); margin-bottom:4px; letter-spacing:1px; }
background: var(--dialog-bg); .save-row { display:flex; justify-content:space-between; align-items:center; font-size:clamp(6.5px,1.8vw,8px); }
border: 4px solid var(--dialog-bd); .save-lbl { color:var(--gray); } .save-val { color:var(--black); }
padding: 16px; .continue-or { font-size:7px; color:rgba(255,255,255,.4); letter-spacing:1px; }
display: flex; .continue-warn{ font-size:6px; color:rgba(255,80,80,.7); text-align:center; line-height:1.8; min-height:14px; }
flex-direction: column;
gap: 10px;
box-shadow: 4px 4px 0 rgba(0,0,0,.5);
}
.continue-card-header {
font-size: 8px;
color: var(--black);
margin-bottom: 4px;
letter-spacing: 1px;
}
.save-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: clamp(6.5px, 1.8vw, 8px);
}
.save-lbl { color: var(--gray); }
.save-val { color: var(--black); }
.continue-or {
font-size: 7px;
color: rgba(255,255,255,.4);
letter-spacing: 1px;
}
.continue-warn {
font-size: 6px;
color: rgba(255,80,80,.7);
text-align: center;
line-height: 1.8;
min-height: 14px;
}
/* ─── NAME ENTRY ─────────────────────────────────────────── */ /* ─── NAME ENTRY ─────────────────────────────────────────── */
#screen-name { #screen-name { background:#c8b88a; padding:14px 10px 10px; gap:10px; }
background: #c8b88a;
padding: 14px 10px 10px;
gap: 10px;
}
.name-dialog { flex-shrink:0; } .name-dialog { flex-shrink:0; }
.name-entry-area { .name-entry-area { display:flex; flex-direction:column; align-items:center; gap:8px; flex:1; }
display: flex; .name-display-box { background:var(--white); border:3px solid var(--black); padding:8px 20px; font-size:clamp(14px,4.5vw,20px); color:var(--black); min-width:180px; text-align:center; letter-spacing:4px; box-shadow:inset 2px 2px 0 rgba(0,0,0,.2); }
flex-direction: column; .keyboard-grid { display:grid; grid-template-columns:repeat(10,1fr); gap:3px; width:100%; }
align-items: center; .key-btn { background:var(--white); border:2px solid var(--black); color:var(--black); font-family:var(--font); font-size:clamp(6px,1.8vw,8px); padding:clamp(5px,1.5vw,8px) 2px; cursor:pointer; text-align:center; box-shadow:2px 2px 0 var(--darkgray); user-select:none; -webkit-tap-highlight-color:transparent; transition:transform .05s,box-shadow .05s; }
gap: 8px;
flex: 1;
}
.name-display-box {
background: var(--white);
border: 3px solid var(--black);
padding: 8px 20px;
font-size: clamp(14px, 4.5vw, 20px);
color: var(--black);
min-width: 180px;
text-align: center;
letter-spacing: 4px;
box-shadow: inset 2px 2px 0 rgba(0,0,0,.2);
}
.keyboard-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 3px;
width: 100%;
}
.key-btn {
background: var(--white);
border: 2px solid var(--black);
color: var(--black);
font-family: var(--font);
font-size: clamp(6px, 1.8vw, 8px);
padding: clamp(5px, 1.5vw, 8px) 2px;
cursor: pointer;
text-align: center;
box-shadow: 2px 2px 0 var(--darkgray);
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: transform .05s, box-shadow .05s;
}
.key-btn:active { transform:translate(2px,2px); box-shadow:none; } .key-btn:active { transform:translate(2px,2px); box-shadow:none; }
.name-controls { .name-controls { display:flex; gap:12px; margin-top:4px; }
display: flex; .btn-del { background:var(--red); } .btn-del:hover { background:var(--red-hover); }
gap: 12px; .btn-ok { background:var(--green); } .btn-ok:hover { background:var(--green-hover); }
margin-top: 4px;
}
.btn-del { background: var(--red); }
.btn-del:hover { background: var(--red-hover); }
.btn-ok { background: var(--green); }
.btn-ok:hover { background: var(--green-hover); }
/* ─── INTRO ──────────────────────────────────────────────── */ /* ─── INTRO ──────────────────────────────────────────────── */
#screen-intro { flex-direction:column-reverse; } #screen-intro { flex-direction:column-reverse; }
.intro-dialog { margin:10px; min-height:90px; } .intro-dialog { margin:10px; min-height:90px; }
.intro-npc { .intro-npc { position:absolute; right:22%; bottom:30%; font-size:clamp(44px,13vw,64px); animation:npcFloat 2s ease-in-out infinite; filter:drop-shadow(2px 3px 0 rgba(0,0,0,.4)); }
position: absolute; .intro-player { position:absolute; left:22%; bottom:28%; font-size:clamp(36px,10vw,52px); animation:bounce 1s ease-in-out infinite; filter:drop-shadow(2px 3px 0 rgba(0,0,0,.4)); }
right: 22%; .tap-hint { font-size:6px; color:var(--black); background:rgba(255,255,255,.6); text-align:center; padding:4px; letter-spacing:1px; flex-shrink:0; }
bottom: 30%;
font-size: clamp(44px, 13vw, 64px);
animation: npcFloat 2s ease-in-out infinite;
filter: drop-shadow(2px 3px 0 rgba(0,0,0,.4));
}
.intro-player {
position: absolute;
left: 22%;
bottom: 28%;
font-size: clamp(36px, 10vw, 52px);
animation: bounce 1s ease-in-out infinite;
filter: drop-shadow(2px 3px 0 rgba(0,0,0,.4));
}
.tap-hint {
font-size: 6px;
color: var(--black);
background: rgba(255,255,255,.6);
text-align: center;
padding: 4px;
letter-spacing: 1px;
flex-shrink: 0;
}
/* ─── MAP ────────────────────────────────────────────────── */ /* ─── MAP ────────────────────────────────────────────────── */
.map-header { .map-header { background:var(--dark-bg); padding:8px 12px; display:flex; align-items:center; justify-content:space-between; gap:8px; border-bottom:3px solid var(--black); flex-shrink:0; }
background: var(--dark-bg);
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border-bottom: 3px solid var(--black);
flex-shrink: 0;
}
.map-left { display:flex; align-items:center; gap:6px; } .map-left { display:flex; align-items:center; gap:6px; }
.map-pin { font-size:12px; } .map-pin { font-size:12px; }
.map-town-name { font-size:clamp(5.5px,1.6vw,7px); color:var(--yellow); letter-spacing:.5px; } .map-town-name { font-size:clamp(5.5px,1.6vw,7px); color:var(--yellow); letter-spacing:.5px; }
@@ -464,134 +382,70 @@ html, body {
.save-dot { font-size:12px; animation:blink 3s ease-in-out infinite; } .save-dot { font-size:12px; animation:blink 3s ease-in-out infinite; }
.map-world { position:relative; } .map-world { position:relative; }
.map-trees { .map-trees { position:absolute; top:12%; left:0; right:0; display:flex; justify-content:space-around; padding:0 8px; }
position: absolute; .tree { font-size:clamp(24px,7vw,36px); display:inline-block; animation:treeSway 3s ease-in-out infinite; filter:drop-shadow(1px 2px 0 rgba(0,0,0,.3)); }
top: 12%; .tree:nth-child(2){animation-delay:.5s;} .tree:nth-child(3){animation-delay:1s;} .tree:nth-child(4){animation-delay:1.5s;} .tree:nth-child(5){animation-delay:2s;}
left: 0; right: 0; .map-ground { position:absolute; bottom:0; left:0; right:0; height:32%; background:linear-gradient(180deg,#c0a060 0%,#a89050 100%); border-top:3px solid #907840; }
display: flex;
justify-content: space-around; /* NPC on map */
padding: 0 8px; .map-npc-wrap { position:absolute; text-align:center; transition: left .4s ease, bottom .4s ease; }
} .npc-bubble { font-size:14px; font-family:var(--font); color:var(--black); background:var(--yellow); border:2px solid var(--black); width:20px; height:20px; display:flex; align-items:center; justify-content:center; margin:0 auto 2px; animation:bounce .7s ease-in-out infinite; }
.tree { .map-npc { font-size:clamp(36px,10vw,52px); filter:drop-shadow(2px 3px 0 rgba(0,0,0,.35)); }
font-size: clamp(24px, 7vw, 36px);
display: inline-block; /* ── PLAYER SPRITE (JS-positioned) ───────────────────────── */
animation: treeSway 3s ease-in-out infinite;
filter: drop-shadow(1px 2px 0 rgba(0,0,0,.3));
}
.tree:nth-child(2) { animation-delay: .5s; }
.tree:nth-child(3) { animation-delay: 1s; }
.tree:nth-child(4) { animation-delay: 1.5s; }
.tree:nth-child(5) { animation-delay: 2s; }
.map-ground {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 32%;
background: linear-gradient(180deg, #c0a060 0%, #a89050 100%);
border-top: 3px solid #907840;
}
.map-npc-wrap {
position: absolute;
right: 20%;
bottom: 30%;
text-align: center;
}
.npc-bubble {
font-size: 14px;
font-family: var(--font);
color: var(--black);
background: var(--yellow);
border: 2px solid var(--black);
width: 20px; height: 20px;
display: flex; align-items: center; justify-content: center;
margin: 0 auto 2px;
animation: bounce .7s ease-in-out infinite;
}
.map-npc {
font-size: clamp(36px, 10vw, 52px);
animation: npcFloat 1.8s ease-in-out infinite;
filter: drop-shadow(2px 3px 0 rgba(0,0,0,.35));
cursor: pointer;
}
.map-player { .map-player {
position: absolute; position: absolute;
left: 22%; font-size: clamp(28px, 8vw, 40px);
bottom: 28%; filter: drop-shadow(2px 3px 0 rgba(0,0,0,.4));
font-size: clamp(32px, 9vw, 46px); transition: left .12s linear, bottom .12s linear;
animation: bounce 1.1s ease-in-out infinite; z-index: 10;
filter: drop-shadow(2px 3px 0 rgba(0,0,0,.35)); line-height: 1;
} }
.map-hud { .map-player.walk-left { animation: walkLeft .25s steps(1) infinite; }
background: var(--dialog-bg); .map-player.walk-right { animation: walkRight .25s steps(1) infinite; }
border-top: 4px solid var(--dialog-bd); .map-player.walk-up { animation: walkUp .25s steps(1) infinite; }
padding: 8px 10px; .map-player.walk-down { animation: walkDown .25s steps(1) infinite; }
display: flex; .map-player.idle { animation: none; }
gap: 0;
flex-shrink: 0; /* Talk hint that floats above player when near NPC */
} .map-talk-hint {
.hud-cell { position: absolute;
flex: 1; font-family: var(--font);
text-align: center; font-size: 6px;
border-right: 2px solid rgba(0,0,0,.15); background: var(--yellow);
padding: 0 4px; color: var(--black);
border: 2px solid var(--black);
padding: 3px 6px;
white-space: nowrap;
z-index: 11;
animation: talkBubble .6s ease-in-out infinite;
pointer-events: none;
} }
/* HUD below map */
.map-hud { background:var(--dialog-bg); border-top:4px solid var(--dialog-bd); padding:8px 10px; display:flex; gap:0; flex-shrink:0; }
.hud-cell { flex:1; text-align:center; border-right:2px solid rgba(0,0,0,.15); padding:0 4px; }
.hud-cell:last-child { border-right:none; } .hud-cell:last-child { border-right:none; }
.hud-lbl { font-size:5.5px; color:var(--gray); margin-bottom:3px; } .hud-lbl { font-size:5.5px; color:var(--gray); margin-bottom:3px; }
.hud-val { font-size:clamp(7px,2vw,9px); color:var(--black); } .hud-val { font-size:clamp(7px,2vw,9px); color:var(--black); }
.btn-walk {
margin: 8px 10px max(8px, var(--safe-bottom));
width: calc(100% - 20px);
font-size: clamp(7.5px, 2.2vw, 9px);
padding: 12px;
text-align: center;
flex-shrink: 0;
}
/* ─── BATTLE ─────────────────────────────────────────────── */ /* ─── BATTLE ─────────────────────────────────────────────── */
#screen-battle { flex-direction:column; } #screen-battle { flex-direction:column; }
.battle-scene { .battle-scene {
display: flex; display:flex; justify-content:space-between; align-items:flex-end;
justify-content: space-between;
align-items: flex-end;
padding:10px 16px 6px; padding:10px 16px 6px;
background:linear-gradient(180deg,#90c8f0 0%,#70b0e0 55%,#c8b880 55%,#b09860 100%); background:linear-gradient(180deg,#90c8f0 0%,#70b0e0 55%,#c8b880 55%,#b09860 100%);
flex-shrink: 0; flex-shrink:0; min-height:clamp(100px,22vh,150px); position:relative;
min-height: clamp(120px, 28vh, 180px);
position: relative;
}
.battle-scene::after {
content: '';
position: absolute;
top: 55%; left: 0; right: 0;
height: 2px;
background: rgba(0,0,0,.18);
}
.battle-npc-side, .battle-player-side {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.battle-sprite {
font-size: clamp(44px, 13vw, 64px);
filter: drop-shadow(2px 3px 0 rgba(0,0,0,.3));
animation: npcFloat 2s ease-in-out infinite;
} }
.battle-scene::after { content:''; position:absolute; top:55%; left:0; right:0; height:2px; background:rgba(0,0,0,.18); }
.battle-npc-side, .battle-player-side { display:flex; flex-direction:column; align-items:center; gap:4px; }
.battle-sprite { font-size:clamp(38px,11vw,56px); filter:drop-shadow(2px 3px 0 rgba(0,0,0,.3)); animation:npcFloat 2s ease-in-out infinite; }
.battle-player-side .battle-sprite { animation:bounce 1s ease-in-out infinite; } .battle-player-side .battle-sprite { animation:bounce 1s ease-in-out infinite; }
.battle-name-tag { .battle-name-tag { font-size:clamp(5.5px,1.6vw,7px); background:rgba(255,255,255,.8); border:2px solid var(--black); padding:2px 6px; color:var(--black); }
font-size: clamp(5.5px, 1.6vw, 7px); .battle-dialog { margin:8px; min-height:72px; }
background: rgba(255,255,255,.8); .battle-choices { display:grid; grid-template-columns:1fr 1fr; gap:5px; padding:5px 8px; flex-shrink:0; }
border: 2px solid var(--black);
padding: 2px 6px; /* choice button */
color: var(--black);
}
.battle-dialog { margin: 8px; min-height: 80px; }
.battle-choices {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5px;
padding: 5px 8px;
flex-shrink: 0;
}
.choice-btn { .choice-btn {
background:var(--dialog-bg); background:var(--dialog-bg);
border:3px solid var(--dialog-bd); border:3px solid var(--dialog-bd);
@@ -605,190 +459,60 @@ html, body {
box-shadow:2px 2px 0 var(--darkgray); box-shadow:2px 2px 0 var(--darkgray);
word-break:break-word; word-break:break-word;
hyphens:auto; hyphens:auto;
transition: transform .06s, box-shadow .06s; transition:transform .06s, box-shadow .06s, border-color .08s;
-webkit-tap-highlight-color:transparent; -webkit-tap-highlight-color:transparent;
position:relative; position:relative;
} }
.choice-btn::before { .choice-btn::before { content:attr(data-letter) ' '; color:var(--blue); }
content: attr(data-letter) ' ';
color: var(--blue);
}
.choice-btn:hover { background:#fffae0; border-color:var(--blue); transform:translate(-1px,-1px); box-shadow:3px 3px 0 var(--darkgray); } .choice-btn:hover { background:#fffae0; border-color:var(--blue); transform:translate(-1px,-1px); box-shadow:3px 3px 0 var(--darkgray); }
.choice-btn:active { transform:translate(2px,2px); box-shadow:none; } .choice-btn:active { transform:translate(2px,2px); box-shadow:none; }
/* D-pad / keyboard cursor highlight */
.choice-btn.cursor { animation:choiceCursor .7s ease-in-out infinite; border-color:var(--yellow) !important; background:#fffae0 !important; }
.choice-btn.correct { background:#c8ffc8; border-color:var(--green); animation:correctPulse .5s ease 3; pointer-events:none; } .choice-btn.correct { background:#c8ffc8; border-color:var(--green); animation:correctPulse .5s ease 3; pointer-events:none; }
.choice-btn.wrong { background:#ffc8c8; border-color:var(--red); animation:wrongShake .35s ease; pointer-events:none; } .choice-btn.wrong { background:#ffc8c8; border-color:var(--red); animation:wrongShake .35s ease; pointer-events:none; }
.choice-btn.disabled{ opacity:.5; pointer-events:none; } .choice-btn.disabled{ opacity:.5; pointer-events:none; }
.battle-footer { .battle-footer { background:var(--dark-bg); border-top:3px solid var(--black); padding:clamp(4px,1.2vw,6px) 12px max(4px,var(--safe-bottom)); display:flex; justify-content:space-between; align-items:center; font-size:clamp(5.5px,1.5vw,7px); color:var(--yellow); flex-shrink:0; }
background: var(--dark-bg); .kbd-hint { font-size:5px; color:rgba(255,255,255,.3); }
border-top: 3px solid var(--black);
padding: clamp(5px,1.5vw,7px) 12px max(6px, var(--safe-bottom));
display: flex;
justify-content: space-between;
align-items: center;
font-size: clamp(6px, 1.7vw, 8px);
color: var(--yellow);
flex-shrink: 0;
}
.kbd-hint { font-size: 5.5px; color: rgba(255,255,255,.35); }
@media (max-width:480px) { .kbd-hint { display:none; } } @media (max-width:480px) { .kbd-hint { display:none; } }
/* ─── RESULT ─────────────────────────────────────────────── */ /* ─── RESULT ─────────────────────────────────────────────── */
#screen-result { #screen-result { background:rgba(0,0,0,.86); align-items:center; justify-content:center; }
background: rgba(0,0,0,.86); .result-box { background:var(--dialog-bg); border:4px solid var(--dialog-bd); padding:clamp(16px,4.5vw,26px) clamp(12px,3.5vw,20px); width:clamp(270px,88vw,390px); text-align:center; box-shadow:6px 6px 0 rgba(0,0,0,.5); animation:pop .4s cubic-bezier(.175,.885,.32,1.275); display:flex; flex-direction:column; gap:9px; }
align-items: center; .result-icon { font-size:clamp(26px,8vw,40px); }
justify-content: center; .result-label { font-size:clamp(11px,3.5vw,15px); }
}
.result-box {
background: var(--dialog-bg);
border: 4px solid var(--dialog-bd);
padding: clamp(18px,5vw,28px) clamp(14px,4vw,22px);
width: clamp(280px, 88vw, 400px);
text-align: center;
box-shadow: 6px 6px 0 rgba(0,0,0,.5);
animation: pop .4s cubic-bezier(.175,.885,.32,1.275);
display: flex;
flex-direction: column;
gap: 10px;
}
.result-icon { font-size: clamp(28px, 9vw, 42px); }
.result-label {
font-size: clamp(12px, 4vw, 16px);
}
.result-label.correct { color:var(--green); text-shadow:2px 2px 0 #003010; } .result-label.correct { color:var(--green); text-shadow:2px 2px 0 #003010; }
.result-label.wrong { color:var(--red); text-shadow:2px 2px 0 #400010; } .result-label.wrong { color:var(--red); text-shadow:2px 2px 0 #400010; }
.result-correct-ans { .result-correct-ans { font-size:clamp(6px,1.8vw,7.5px); color:var(--green); background:rgba(0,160,30,.1); border:2px solid rgba(0,160,30,.3); padding:6px; text-align:left; line-height:1.8; display:none; }
font-size: clamp(6px, 1.8vw, 7.5px);
color: var(--green);
background: rgba(0,160,30,.1);
border: 2px solid rgba(0,160,30,.3);
padding: 6px;
text-align: left;
line-height: 1.8;
display: none;
}
.result-correct-ans.visible { display:block; } .result-correct-ans.visible { display:block; }
.result-explanation { .result-explanation { font-size:clamp(6px,1.8vw,7.5px); color:var(--black); line-height:2; text-align:left; padding:8px; background:rgba(255,255,255,.5); border:2px solid rgba(0,0,0,.15); }
font-size: clamp(6px, 1.8vw, 7.5px); .result-pts { font-size:clamp(9px,2.8vw,12px); color:var(--yellow); text-shadow:2px 2px 0 #604000; animation:goldShimmer 2s ease-in-out infinite; }
color: var(--black);
line-height: 2;
text-align: left;
padding: 8px;
background: rgba(255,255,255,.5);
border: 2px solid rgba(0,0,0,.15);
}
.result-pts {
font-size: clamp(10px, 3vw, 13px);
color: var(--yellow);
text-shadow: 2px 2px 0 #604000;
animation: goldShimmer 2s ease-in-out infinite;
}
/* ─── LEVEL UP ───────────────────────────────────────────── */ /* ─── LEVEL UP ───────────────────────────────────────────── */
#screen-levelup { #screen-levelup { background:linear-gradient(135deg,#0f0630,#220e5e,#0a0a34); align-items:center; justify-content:center; }
background: linear-gradient(135deg, #0f0630, #220e5e, #0a0a34); .levelup-box { text-align:center; padding:clamp(16px,4vw,28px) clamp(14px,3.5vw,22px); width:90%; max-width:360px; display:flex; flex-direction:column; gap:12px; animation:fadeIn .5s ease; }
align-items: center; .lu-stars { font-size:clamp(7px,2vw,9px); color:var(--yellow); text-shadow:0 0 8px var(--yellow); letter-spacing:1px; }
justify-content: center; .lu-sprite { font-size:clamp(52px,15vw,74px); animation:bounce 1s ease-in-out infinite; }
}
.levelup-box {
text-align: center;
padding: clamp(20px,5vw,32px) clamp(16px,4vw,24px);
width: 90%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: 14px;
animation: fadeIn .5s ease;
}
.lu-stars {
font-size: clamp(7px, 2vw, 9px);
color: var(--yellow);
text-shadow: 0 0 8px var(--yellow);
letter-spacing: 1px;
}
.lu-sprite { font-size: clamp(56px, 16vw, 80px); animation: bounce 1s ease-in-out infinite; }
.lu-text { font-size:clamp(7.5px,2.2vw,9px); color:var(--white); line-height:2; } .lu-text { font-size:clamp(7.5px,2.2vw,9px); color:var(--white); line-height:2; }
.lu-badge { .lu-badge { font-size:clamp(7px,2vw,9px); color:var(--yellow); background:rgba(255,255,255,.08); border:2px solid var(--yellow); padding:clamp(8px,2.5vw,12px); }
font-size: clamp(7px, 2vw, 9px);
color: var(--yellow);
background: rgba(255,255,255,.08);
border: 2px solid var(--yellow);
padding: clamp(8px,2.5vw,12px);
}
.lu-prog-wrap{ display:flex; flex-direction:column; gap:5px; align-items:center; } .lu-prog-wrap{ display:flex; flex-direction:column; gap:5px; align-items:center; }
.lu-prog-outer{ width:80%; height:12px; background:var(--black); border:2px solid var(--lightgray); overflow:hidden; } .lu-prog-outer{ width:80%; height:12px; background:var(--black); border:2px solid var(--lightgray); overflow:hidden; }
.lu-prog-inner{ height:100%; background:var(--hp-green); transition:width .8s ease; } .lu-prog-inner{ height:100%; background:var(--hp-green); transition:width .8s ease; }
.lu-prog-label{ font-size:7px; color:var(--white); } .lu-prog-label{ font-size:7px; color:var(--white); }
/* ─── COMPLETE ───────────────────────────────────────────── */ /* ─── COMPLETE ───────────────────────────────────────────── */
#screen-complete { #screen-complete { background:linear-gradient(180deg,#08031a 0%,#180a44 100%); overflow:hidden; }
background: linear-gradient(180deg, #08031a 0%, #180a44 100%);
overflow: hidden;
}
.complete-scroll { overflow-y:auto; height:100%; -webkit-overflow-scrolling:touch; } .complete-scroll { overflow-y:auto; height:100%; -webkit-overflow-scrolling:touch; }
.complete-area { .complete-area { text-align:center; padding:clamp(18px,4.5vw,28px) clamp(12px,3.5vw,18px) max(22px,var(--safe-bottom)); display:flex; flex-direction:column; gap:12px; align-items:center; animation:fadeIn .7s ease; }
text-align: center; .complete-trophy { font-size:clamp(44px,13vw,64px); animation:goldShimmer 2s ease-in-out infinite; }
padding: clamp(20px,5vw,32px) clamp(14px,4vw,20px) max(24px,var(--safe-bottom)); .complete-title { font-size:clamp(16px,5.5vw,24px); color:var(--yellow); text-shadow:3px 3px 0 #604000; animation:goldShimmer 2s ease-in-out infinite; }
display: flex; .complete-name { font-size:clamp(9px,2.8vw,13px); color:var(--white); }
flex-direction: column; .complete-msg { font-size:clamp(6px,1.7vw,7.5px); color:rgba(255,255,255,.8); line-height:2.2; max-width:360px; text-align:left; }
gap: 14px; .complete-stats { font-size:clamp(7px,2vw,8.5px); color:var(--yellow); background:rgba(255,255,255,.08); border:2px solid rgba(255,255,255,.15); padding:clamp(10px,3vw,16px); line-height:2.6; width:100%; max-width:340px; text-align:left; white-space:pre-line; }
align-items: center;
animation: fadeIn .7s ease;
}
.complete-trophy { font-size: clamp(48px, 14vw, 68px); animation: goldShimmer 2s ease-in-out infinite; }
.complete-title {
font-size: clamp(18px, 6vw, 26px);
color: var(--yellow);
text-shadow: 3px 3px 0 #604000;
animation: goldShimmer 2s ease-in-out infinite;
}
.complete-name { font-size: clamp(10px, 3vw, 14px); color: var(--white); }
.complete-msg {
font-size: clamp(6px, 1.7vw, 7.5px);
color: rgba(255,255,255,.8);
line-height: 2.2;
max-width: 360px;
text-align: left;
}
.complete-stats {
font-size: clamp(7px, 2vw, 8.5px);
color: var(--yellow);
background: rgba(255,255,255,.08);
border: 2px solid rgba(255,255,255,.15);
padding: clamp(10px,3vw,16px);
line-height: 2.6;
width: 100%;
max-width: 340px;
text-align: left;
white-space: pre-line;
}
.complete-badges { width:100%; max-width:340px; display:flex; flex-direction:column; gap:6px; } .complete-badges { width:100%; max-width:340px; display:flex; flex-direction:column; gap:6px; }
.badge-row { .badge-row { font-size:clamp(6.5px,1.8vw,8px); color:var(--white); padding:8px 10px; border:2px solid rgba(248,192,48,.35); background:rgba(255,255,255,.04); text-align:left; }
font-size: clamp(6.5px, 1.8vw, 8px);
color: var(--white);
padding: 8px 10px;
border: 2px solid rgba(248,192,48,.35);
background: rgba(255,255,255,.04);
text-align: left;
}
/* ─── TOAST ──────────────────────────────────────────────── */ /* ─── TOAST ──────────────────────────────────────────────── */
.toast { .toast { position:absolute; bottom:max(20px,var(--safe-bottom)); left:50%; transform:translateX(-50%); background:var(--dark-bg); border:3px solid var(--yellow); color:var(--yellow); font-family:var(--font); font-size:clamp(6.5px,1.8vw,8px); padding:10px 18px; z-index:950; animation:slideUp .3s ease; white-space:nowrap; max-width:90%; overflow:hidden; text-overflow:ellipsis; }
position: absolute;
bottom: max(20px, var(--safe-bottom));
left: 50%;
transform: translateX(-50%);
background: var(--dark-bg);
border: 3px solid var(--yellow);
color: var(--yellow);
font-family: var(--font);
font-size: clamp(6.5px, 1.8vw, 8px);
padding: 10px 18px;
z-index: 950;
animation: slideUp .3s ease;
white-space: nowrap;
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
}
.hidden { display:none !important; } .hidden { display:none !important; }