Update to new mo betta code base

This commit is contained in:
justin committed 2026-05-21 11:09:30 -07:00
1 parent fe91155ed6
commit c8ce96d0c0
3 files changed
+476 -266

No files matched your search

+322 -180
View File
@@ -1,4 +1,21 @@
/* ─── NPC ROSTER ─────────────────────────────────────────── */
const CONFIG = {
gameName: 'PokéSQL',
subject: 'SQL',
examName: 'SQL',
townName: 'SQL Town',
saveKey: 'pokesql_save_v1',
introLines: [
"Hello {name}! Welcome to PokéSQL, the world of SQL training! My name is Professor Oak, the SQL Professor!",
"This world is powered by Databases, Tables, Queries, Indexes, and Transactions, mastered through knowledge!",
"Your mission? Journey through each town, challenge SQL masters, and conquer every database concept!",
"Start in SQL Town then travel the SQL world to master JOINs, Aggregations, Indexing, and beyond!",
"100 questions await you. Each correct answer earns you SQL EXP and you'll learn something real!",
"Your progress is saved automatically in your browser so you can pick up right where you left off. Now, {name}... your adventure begins!",
],
namePrompt: "Hello there! Welcome to PokéSQL! My name is Professor Oak — the SQL Professor. Now tell me, what is your name?",
};
const NPC = {
'Professor Oak': '👴',
'Rival Gary': '😤',
@@ -25,14 +42,7 @@ const NPC = {
'Gary': '😤',
};
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:"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:"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:"Your progress is saved automatically in your browser so you can pick up right where you left off. Now, {name}... your adventure begins!" },
];
const INTRO_MSGS = CONFIG.introLines.map(text => ({ speaker:'PROFESSOR OAK', npc:'Professor Oak', text }));
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!"];
@@ -43,7 +53,7 @@ const MILESTONES = {
75: { badge:'🥇 DATA WRANGLER', stars:'★ ★ ★ ★' },
};
const SAVE_KEY = 'pokesql_save_v1';
const SAVE_KEY = CONFIG.saveKey;
/* ─── SAVE / LOAD ────────────────────────────────────────── */
function saveGame(s) {
@@ -55,8 +65,9 @@ function clearSave() { try { localStorage.removeItem(SAVE_KEY); } catch(_){} }
/* ─── BACKGROUND MUSIC ───────────────────────────────────── */
const Music = (() => {
let audio = null;
let rival = null;
let muted = false;
let wantPlay = false; /* true if we tried to play but were blocked */
let wantPlay = false;
function getAudio() {
if (!audio) {
@@ -64,7 +75,6 @@ const Music = (() => {
audio.loop = true;
audio.volume = 0.5;
audio.preload = 'auto';
/* If audio loads and we wanted to play, try again */
audio.addEventListener('canplaythrough', () => {
if (wantPlay && !muted) audio.play().catch(() => {});
});
@@ -72,6 +82,16 @@ const Music = (() => {
return audio;
}
function getRival() {
if (!rival) {
rival = new Audio('./music-rival.mp3');
rival.loop = true;
rival.volume = 0.5;
rival.preload = 'auto';
}
return rival;
}
function updateBtn() {
const btn = document.getElementById('music-toggle');
if (!btn) return;
@@ -83,16 +103,25 @@ const Music = (() => {
play() {
if (muted) return;
wantPlay = true;
if (rival && !rival.paused) return;
const a = getAudio();
if (a.readyState >= 3) {
/* Enough data to play */
if (a.paused) a.play().catch((e) => { console.warn('Music play blocked:', e); });
}
/* else: canplaythrough listener will fire and retry */
},
pause() {
wantPlay = false;
if (audio && !audio.paused) audio.pause();
if (rival && !rival.paused) rival.pause();
},
playBattle() {
if (muted) return;
if (audio && !audio.paused) audio.pause();
const r = getRival();
if (r.paused) { r.currentTime = 0; r.play().catch(() => {}); }
},
stopBattle() {
if (rival && !rival.paused) { rival.pause(); rival.currentTime = 0; }
},
toggle() {
muted = !muted;
@@ -100,7 +129,6 @@ const Music = (() => {
if (muted) Music.pause();
else Music.play();
},
/* Call once on first user interaction to unblock autoplay */
unblock() {
const a = getAudio();
if (wantPlay && !muted && a.paused) {
@@ -120,7 +148,12 @@ const SFX = (() => {
return ctx;
}
/* Resume AudioContext when user returns to the page on mobile */
function unlock() {
if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)();
if (ctx.state === 'suspended') ctx.resume().then(() => {});
}
document.addEventListener('touchstart', unlock, { once: true, passive: true });
document.addEventListener('pointerdown', unlock, { once: true, passive: true });
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && ctx && ctx.state === 'suspended') {
ctx.resume();
@@ -189,7 +222,8 @@ const SFX = (() => {
/* Select / menu move */
select() {
tone(440, 'square', 0.12, 0.005, 0.03, 0.04);
const c = getCtx();
tone(440, 'square', 0.12, 0.005, 0.03, 0.04, c.currentTime + 0.05);
},
/* Correct answer — happy ascending chord */
@@ -244,13 +278,11 @@ class Game {
screen:'boot', playerName:'', questions:[], currentQ:0,
score:0, streak:0, maxStreak:0, correct:0, wrong:0,
answering:false, introStep:0, twTimer:null,
/* map — world coords in px, camera follows player */
worldX: 0, /* player's X in world-px */
worldY: 0, /* player's Y in world-px (0 = path centre) */
npcWorldX: 0, /* NPC world-px X */
npcWorldY: 0,
worldX: 0, worldY: 0,
npcWorldX: 0, npcWorldY: 0,
npcSpawned: false,
pathDir: 'right',
lastDir: 'right',
/* battle cursor */
cursor:0,
};
@@ -275,8 +307,6 @@ class Game {
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]) => {
@@ -354,91 +384,102 @@ class Game {
if (this.state.screen === 'map') {
const p = document.getElementById('map-player');
if (p) {
const dir = this.state.lastDir;
const idleFrames = { right:'-320px', down:'-32px', left:'-128px', up:'-224px' };
const frame = idleFrames[dir] || '-32px';
p.className = 'map-player idle';
/* Show the idle/middle frame for the last direction walked
Frames at 2× scale (32px each): down=32, up=128, left=224, right=320 */
const idleFrames = { down:'-32px', left:'-128px', up:'-224px', right:'-320px' };
p.style.backgroundPositionX = idleFrames[this.state.lastDir] || '-32px';
requestAnimationFrame(() => {
p.style.backgroundPositionX = frame;
});
}
}
}
_getViewport() {
const w = document.getElementById('map-world');
return {
viewW: (w && w.offsetWidth) || 420,
viewH: (w && w.offsetHeight) || 300,
};
}
_walkTick() {
if (this.state.screen !== 'map') return;
const world = document.getElementById('map-inner');
if (!world) return;
const viewW = world.parentElement.offsetWidth || 420;
const viewH = world.parentElement.offsetHeight || 300;
const SPEED = 8; /* px per tick */
const {viewW, viewH} = this._getViewport();
const SPEED = 8;
let dx = 0, dy = 0, dir = '';
if (this._heldKeys.has('right')) { dx = SPEED; dir = 'right'; }
if (this._heldKeys.has('left')) { dx = -SPEED; dir = 'left'; }
if (this._heldKeys.has('up')) { dy = -SPEED; dir = 'up'; }
if (this._heldKeys.has('down')) { dy = SPEED; dir = 'down'; }
if (!dir) return;
/* Clamp world X — left edge is 0, right edge is npcWorldX + half screen
so the player can always walk far enough to reach the NPC */
const maxWorldX = this.state.npcWorldX - Math.round(viewW / 2) + 100;
this.state.worldX = Math.max(0, Math.min(maxWorldX, this.state.worldX + dx));
/* Clamp Y within a narrow vertical band (path area) */
this.state.worldY = Math.max(-30, Math.min(30, this.state.worldY + dy));
this.state.worldX += dx;
this.state.worldY += dy;
this.state.lastDir = dir;
this._applyCamera();
/* Player sprite stays horizontally centred; only Y shifts */
const p = document.getElementById('map-player');
const p = document.getElementById('map-player');
const bub = document.getElementById('player-bubble');
if (p) {
const centreX = viewW / 2 - 16;
const centreY = viewH * 0.65 + this.state.worldY;
const bottomPx = viewH - centreY - 40;
p.style.left = centreX + 'px';
p.style.bottom = bottomPx + 'px';
const cx = viewW / 2 - 16;
const bot = viewH * 0.45;
p.style.left = cx + 'px';
p.style.bottom = bot + 'px';
p.className = `map-player walk-${dir}`;
/* Keep "..." bubble above player */
const bub = document.getElementById('player-bubble');
if (bub) { bub.style.left = (centreX - 4) + 'px'; bub.style.bottom = (bottomPx + 66) + 'px'; }
if (bub) { bub.style.left = (cx - 4) + 'px'; bub.style.bottom = (bot + 66) + 'px'; }
}
this._checkNPCProximity();
}
/* Slide the world and reposition NPC in screen-space every tick */
_applyCamera() {
const world = document.getElementById('map-inner');
if (world) world.style.transform = `translateX(${-this.state.worldX}px)`;
const {viewW, viewH} = this._getViewport();
const spread = Math.max(viewW, viewH) * 5;
const inner = document.getElementById('map-inner');
const tx = -(spread + this.state.worldX - viewW / 2);
const ty = -(spread + this.state.worldY - viewH / 2);
if (inner) inner.style.transform = `translate(${tx}px,${ty}px)`;
this._positionNPCOnScreen();
}
_positionNPCOnScreen() {
const world = document.getElementById('map-inner');
const viewH = world ? world.parentElement.offsetHeight : 300;
const {viewW, viewH} = this._getViewport();
const npcWrap = document.getElementById('map-npc-wrap');
if (npcWrap) {
const screenX = this.state.npcWorldX - this.state.worldX;
npcWrap.style.left = (screenX - 24) + 'px';
npcWrap.style.bottom = (viewH * 0.28) + 'px';
const screenX = viewW / 2 + (this.state.npcWorldX - this.state.worldX) - 24;
const screenY = viewH / 2 + (this.state.npcWorldY - this.state.worldY) - 24;
npcWrap.style.left = screenX + 'px';
npcWrap.style.bottom = (viewH - screenY - 48) + 'px';
}
}
_playerScreenOffset() {
const {viewW, viewH} = this._getViewport();
return { px: viewW / 2, py: viewH - (viewH * 0.45) - 32 };
}
_npcScreenDist() {
const {viewW, viewH} = this._getViewport();
const {px, py} = this._playerScreenOffset();
const npcSX = viewW / 2 + (this.state.npcWorldX - this.state.worldX);
const npcSY = viewH / 2 + (this.state.npcWorldY - this.state.worldY);
return Math.sqrt((npcSX - px) ** 2 + (npcSY - py) ** 2);
}
_checkNPCProximity() {
const world = document.getElementById('map-inner');
const viewW = world ? world.parentElement.offsetWidth : 420;
const viewH = world ? world.parentElement.offsetHeight : 300;
const playerWorldX = this.state.worldX + viewW / 2;
const dx = Math.abs(playerWorldX - this.state.npcWorldX);
const near = dx < 60;
const {viewW, viewH} = this._getViewport();
const dist = this._npcScreenDist();
const near = this.state.npcSpawned && dist < 100;
const hint = document.getElementById('map-talk-hint');
const bub = document.getElementById('npc-bubble');
if (hint) {
const npcSX = viewW / 2 + (this.state.npcWorldX - this.state.worldX);
const npcSY = viewH / 2 + (this.state.npcWorldY - this.state.worldY);
hint.style.display = near ? 'block' : 'none';
hint.style.left = (viewW / 2 - 28) + 'px';
hint.style.bottom = (viewH * 0.50) + 'px';
hint.style.left = (npcSX - 28) + 'px';
hint.style.bottom = (viewH - npcSY + 48) + 'px';
}
if (bub) bub.style.opacity = near ? '0' : '1';
this._updateDirArrow(near);
@@ -448,25 +489,51 @@ class Game {
const arrow = document.getElementById('map-dir-arrow');
if (!arrow) return;
if (nearNPC) { arrow.style.display = 'none'; arrow.style.animation = 'none'; return; }
const world = document.getElementById('map-inner');
const viewH = world ? world.parentElement.offsetHeight : 300;
arrow.textContent = '▶';
arrow.style.display = 'block';
const {viewW, viewH} = this._getViewport();
const dx = this.state.npcWorldX - this.state.worldX;
const dy = this.state.npcWorldY - this.state.worldY;
let pd;
if (Math.abs(dx) >= Math.abs(dy)) {
pd = dx >= 0 ? 'right' : 'left';
} else {
pd = dy >= 0 ? 'down' : 'up';
}
const isLandscape = window.innerWidth > window.innerHeight;
const ctrlH = isLandscape ? 0 : Math.round(Math.min(148, Math.max(110, window.innerWidth * 0.22)));
const safeBottom = ctrlH + 12;
const midY = (viewH - ctrlH) / 2 - 16;
const glyphs = { right:'▶', left:'◀', up:'▲', down:'▼' };
arrow.textContent = glyphs[pd];
arrow.style.display = 'block';
arrow.style.animation = 'arrowPulse .7s ease-in-out infinite';
arrow.style.removeProperty('left');
arrow.style.bottom = (viewH * 0.28) + 'px';
if (pd === 'right') {
arrow.style.removeProperty('left');
arrow.style.right = '12px';
arrow.style.bottom = midY + 'px';
} else if (pd === 'left') {
arrow.style.removeProperty('right');
arrow.style.left = '12px';
arrow.style.bottom = midY + 'px';
} else if (pd === 'up') {
arrow.style.removeProperty('right');
arrow.style.left = (viewW / 2 - 16) + 'px';
arrow.style.bottom = (viewH - ctrlH - 48) + 'px';
} else {
arrow.style.removeProperty('right');
arrow.style.left = (viewW / 2 - 16) + 'px';
arrow.style.bottom = safeBottom + 'px';
}
}
_nearNPC() {
const world = document.getElementById('map-inner');
const viewW = world ? world.parentElement.offsetWidth : 420;
const playerWorldX = this.state.worldX + viewW / 2;
return Math.abs(playerWorldX - this.state.npcWorldX) < 60;
if (!this.state.npcSpawned) return false;
return this._npcScreenDist() < 100;
}
_talkToNPC() {
if (!this._nearNPC()) {
/* Too far away — show "..." bubble above player */
const bub = document.getElementById('player-bubble');
if (bub) {
bub.classList.remove('visible');
@@ -478,28 +545,26 @@ class Game {
return;
}
const arrow = document.getElementById('map-dir-arrow');
if (arrow) arrow.style.display = 'none';
if (arrow) { arrow.style.display = 'none'; arrow.style.animation = 'none'; }
const bub = document.getElementById('player-bubble');
if (bub) bub.classList.remove('visible');
SFX.encounter();
this.startQuestion();
}
/* Place player at viewport centre, NPC via screen-space conversion */
_placeMapSprites() {
const world = document.getElementById('map-inner');
const viewW = world ? world.parentElement.offsetWidth : 420;
const viewH = world ? world.parentElement.offsetHeight : 300;
const p = document.getElementById('map-player');
const {viewW, viewH} = this._getViewport();
const idleFrames = { right:'-320px', down:'-32px', left:'-128px', up:'-224px' };
const p = document.getElementById('map-player');
const bub = document.getElementById('player-bubble');
if (p) {
const left = viewW / 2 - 16;
const bottom = viewH * 0.28;
p.style.left = left + 'px';
p.style.bottom = bottom + 'px';
p.className = 'map-player idle';
const bub = document.getElementById('player-bubble');
if (bub) { bub.style.left = (left - 4) + 'px'; bub.style.bottom = (bottom + 66) + 'px'; }
const cx = viewW / 2 - 16;
const bot = viewH * 0.45;
p.style.left = cx + 'px';
p.style.bottom = bot + 'px';
p.className = 'map-player idle';
p.style.backgroundPositionX = idleFrames[this.state.lastDir] || '-32px';
if (bub) { bub.style.left = (cx - 4) + 'px'; bub.style.bottom = (bot + 66) + 'px'; }
}
this._applyCamera();
const hint = document.getElementById('map-talk-hint');
@@ -510,14 +575,16 @@ class Game {
BATTLE CURSOR — navigate 2×2 grid with D-pad / WASD
══════════════════════════════════════════════════════════ */
_moveCursor(dir) {
const btns = document.querySelectorAll('.choice-btn:not(.disabled):not(.correct):not(.wrong)');
const btns = document.querySelectorAll('.choice-btn:not(.disabled):not(.correct):not(.wrong):not(.confirm-inactive)');
if (!btns.length) return;
const total = btns.length;
let c = this.state.cursor;
const hasConfirm = total === 5;
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);
else if (dir === 'left') c = (c === 1) ? 0 : (c === 3) ? 2 : (c === 4) ? 4 : c;
else if (dir === 'down') c = (c === 0) ? 2 : (c === 1) ? 3 : (hasConfirm && (c === 2 || c === 3)) ? 4 : c;
else if (dir === 'up') c = (c === 2) ? 0 : (c === 3) ? 1 : (c === 4) ? 2 : c;
c = Math.min(c, total - 1);
this.state.cursor = c;
this._renderCursor();
SFX.select();
@@ -531,7 +598,7 @@ class Game {
_confirmCursor() {
if (this.state.answering) return;
const btns = document.querySelectorAll('.choice-btn:not(.disabled)');
const btns = document.querySelectorAll('.choice-btn:not(.disabled):not(.correct):not(.wrong):not(.confirm-inactive)');
const target = btns[this.state.cursor];
if (target) target.click();
}
@@ -802,10 +869,12 @@ class Game {
}
}
_showController(visible) {
const ctrl = document.getElementById('gba-controller');
if (!ctrl) return;
if (visible) ctrl.classList.remove('hidden');
else ctrl.classList.add('hidden');
['gba-left', 'gba-right'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
if (visible) el.classList.remove('hidden');
else el.classList.add('hidden');
});
}
/* ══════════════════════════════════════════════════════════
@@ -861,7 +930,7 @@ class Game {
playerName:save.playerName, currentQ:save.currentQ,
score:save.score, streak:save.streak, maxStreak:save.maxStreak,
correct:save.correct, wrong:save.wrong,
worldX:0, worldY:0, npcWorldX:0, npcWorldY:0,
worldX:0, worldY:0, npcWorldX:0, npcWorldY:0, npcSpawned:false,
});
this.loadQuestions(() => {
if (save.questionOrder && save.questionOrder.length === this.state.questions.length) {
@@ -886,7 +955,7 @@ class Game {
this._showController(false);
this._gpKeyCursor = 0;
this.buildKeyboard();
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?");
this.typeText('name-prompt-text', CONFIG.namePrompt);
document.getElementById('btn-backspace').onclick = () => this.delChar();
document.getElementById('btn-confirm-name').onclick = () => this.confirmName();
}
@@ -976,34 +1045,35 @@ class Game {
/* Generate randomly placed trees across the full scrollable world */
/* Generate trees for the current journey leg — from world origin to just past the NPC.
Called fresh each time so trees always fill the visible path ahead. */
_pickNextDir(currentDir) {
const opposite = { right:'left', left:'right', up:'down', down:'up' };
const opp = opposite[currentDir];
const choices = ['right','left','up','down',currentDir,currentDir].filter(d => d !== opp);
return choices[Math.floor(Math.random() * choices.length)];
}
_generateTrees() {
const container = document.getElementById('map-trees');
if (!container) return;
/* Clear previous leg's trees */
container.innerHTML = '';
const world = document.getElementById('map-inner');
const viewW = world ? world.parentElement.offsetWidth : 420;
const viewH = world ? world.parentElement.offsetHeight : 300;
/* World spans from 0 to just past the NPC */
const worldW = this.state.npcWorldX + viewW;
if (world) world.style.width = worldW + 'px';
const treeTopMax = viewH * 0.50;
const treeBottomMin = viewH * 0.04;
/* One tree roughly every 120px across the leg */
const COUNT = Math.max(6, Math.round(worldW / 120));
const zoneW = worldW / COUNT;
for (let i = 0; i < COUNT; i++) {
const {viewW, viewH} = this._getViewport();
const spread = Math.max(viewW, viewH) * 5;
const inner = document.getElementById('map-inner');
if (inner) { inner.style.width = spread * 2 + 'px'; inner.style.height = spread * 2 + 'px'; }
const cx = spread;
const cy = spread;
const npcX = cx + this.state.npcWorldX;
const npcY = cy + this.state.npcWorldY;
for (let i = 0; i < 300; i++) {
const span = document.createElement('span');
span.className = 'tree';
const size = 28 + Math.random() * 16;
const x = i * zoneW + Math.random() * zoneW * 0.75;
const y = treeBottomMin + Math.random() * (treeTopMax - treeBottomMin - size);
const size = 48 + Math.random() * 24;
let x, y, tries = 0;
do {
x = cx + (Math.random() - 0.5) * spread * 1.8;
y = cy + (Math.random() - 0.5) * spread * 1.8;
tries++;
} while (tries < 10 && Math.abs(x - npcX) < 80 && Math.abs(y - npcY) < 80);
span.textContent = '🌳';
span.style.fontSize = size + 'px';
span.style.left = x + 'px';
@@ -1014,40 +1084,48 @@ class Game {
}
}
/* Called on result screen — pre-builds trees for the next leg */
_preGenerateNextLeg() {
const world = document.getElementById('map-inner');
const viewW = world ? world.parentElement.offsetWidth : 420;
const nextNpcX = Math.round(viewW * 3.0 + Math.random() * viewW * 0.8);
const savedNpcX = this.state.npcWorldX;
this.state.npcWorldX = nextNpcX;
this._generateTrees();
this.state.npcWorldX = savedNpcX;
this._nextLegDir = this._pickNextDir(this.state.pathDir);
this._nextLegLen = null;
}
_npcOffsetForDir(dir, viewW, viewH) {
const dist = dir === 'right' || dir === 'left'
? Math.round(viewW * (3.0 + Math.random() * 0.8))
: Math.round(viewH * (3.0 + Math.random() * 0.8));
return {
nx: dir === 'right' ? dist : dir === 'left' ? -dist : 0,
ny: dir === 'down' ? dist : dir === 'up' ? -dist : 0,
dir,
};
}
showMap() {
this.show('map');
this._showController(true);
/* We need the viewport size — defer a tick so the DOM is laid out */
requestAnimationFrame(() => {
const world = document.getElementById('map-inner');
const viewW = world ? world.parentElement.offsetWidth : 420;
const {viewW, viewH} = this._getViewport();
if (this.state.currentQ === 0) {
this.state.worldX = 0;
this.state.worldY = 0;
this.state.npcWorldX = Math.round(viewW * 3.5);
this.state.npcWorldY = 0;
this.state.pathDir = 'right';
} else if (this._nextLegDir) {
this.state.pathDir = this._nextLegDir;
this._nextLegDir = null;
this._nextLegLen = null;
} else {
/* Always reset camera to 0 each question — keeps trees in view.
NPC spawns off-screen right relative to fresh origin. */
this.state.worldX = 0;
this.state.worldY = 0;
this.state.npcWorldX = Math.round(viewW * 3.0 + Math.random() * viewW * 0.8);
this.state.npcWorldY = 0;
this.state.pathDir = this._pickNextDir(this.state.pathDir);
}
this.state.worldX = 0;
this.state.worldY = 0;
this.state.lastDir = this.state.pathDir;
const {nx, ny} = this._npcOffsetForDir(this.state.pathDir, viewW, viewH);
this.state.npcWorldX = nx;
this.state.npcWorldY = ny;
this.state.npcSpawned = true;
this._generateTrees();
document.getElementById('hud-name').textContent = this.state.playerName||'ASH';
@@ -1058,7 +1136,9 @@ class Game {
if (q && npcEl) npcEl.textContent = NPC[q.npc]||'🧑';
this._placeMapSprites();
this._checkNPCProximity();
this._updateDirArrow(false);
Music.stopBattle();
Music.play();
saveGame(this.state);
this.flashSaveDot();
@@ -1071,6 +1151,7 @@ class Game {
});
}
updateHUD() {
const tot=this.state.questions.length||100, done=this.state.currentQ, pct=(done/tot)*100;
document.getElementById('hud-score').textContent = this.state.score.toLocaleString();
@@ -1096,7 +1177,7 @@ class Game {
this.show('battle');
this._showController(true);
Music.pause();
Music.playBattle();
this.state.answering=false;
this.state.cursor=0;
@@ -1117,53 +1198,78 @@ class Game {
container.innerHTML='';
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 isMulti = Array.isArray(q.answers);
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);
/* Build a map from original option letter (A/B/C/D) to shuffled slot number (1/2/3/4)
so any option text like "Both A and B" gets rewritten to e.g. "Both 2 and 4" */
const letterToSlot = {};
indices.forEach((origIdx, slotIdx) => {
const letter = ['A','B','C','D'][origIdx];
letterToSlot[letter] = slotIdx + 1;
});
const correctShuffledIdx = isMulti ? null : indices.indexOf(q.answer);
const correctShuffledSet = isMulti ? new Set(q.answers.map(a => indices.indexOf(a))) : null;
function rewriteLetterRefs(text) {
/* Only rewrite letters that appear in explicit option-reference patterns:
"Both A and B", "A and B", "Both B and C", etc.
Never replace standalone A/B/C/D in regular sentence text. */
return text.replace(/\bBoth ([ABCD]) and ([ABCD])\b/gi, (_, l1, l2) => {
const n1 = letterToSlot[l1.toUpperCase()];
const n2 = letterToSlot[l2.toUpperCase()];
return `Both ${n1 ?? l1} and ${n2 ?? l2}`;
}).replace(/\b([ABCD]) and ([ABCD])\b/g, (_, l1, l2) => {
const n1 = letterToSlot[l1];
const n2 = letterToSlot[l2];
return `${n1 ?? l1} and ${n2 ?? l2}`;
});
q._correctShuffledSet = correctShuffledSet;
q._indices = indices;
if (isMulti) {
q._correctDisplayText = q.answers.map(a => q.options[a]).join(' AND ');
} else {
q._correctDisplayText = q.options[q.answer];
}
/* Store display text of correct answer with letter refs rewritten */
q._correctDisplayText = rewriteLetterRefs(q.options[q.answer]);
const selectedSlots = new Set();
let confirmBtn = null;
if (isMulti) {
confirmBtn = document.createElement('button');
confirmBtn.className='choice-btn confirm-btn confirm-inactive';
confirmBtn.textContent='✔ CONFIRM';
confirmBtn.addEventListener('click', () => {
if (this.state.answering || selectedSlots.size !== 2) return;
this.pickMulti(selectedSlots, correctShuffledSet, q, container);
});
}
indices.forEach((origIdx, slotIdx) => {
const btn=document.createElement('button');
btn.className='choice-btn';
btn.setAttribute('data-letter', slotIdx + 1);
btn.textContent=rewriteLetterRefs(q.options[origIdx]);
btn.addEventListener('click',()=>this.pick(slotIdx, correctShuffledIdx, q, container));
btn.textContent=q.options[origIdx];
if (isMulti) {
btn.addEventListener('click', () => {
if (this.state.answering) return;
if (btn.classList.contains('selected')) {
btn.classList.remove('selected');
selectedSlots.delete(slotIdx);
} else {
if (selectedSlots.size >= 2) return;
btn.classList.add('selected');
selectedSlots.add(slotIdx);
}
confirmBtn.classList.toggle('confirm-inactive', selectedSlots.size !== 2);
});
} else {
btn.addEventListener('click',()=>this.pick(slotIdx, correctShuffledIdx, q, container));
}
btn.addEventListener('mouseenter', () => {
this.state.cursor = slotIdx;
this._renderCursor();
});
container.appendChild(btn);
});
if (isMulti) {
confirmBtn.addEventListener('mouseenter', () => {
if (confirmBtn.classList.contains('confirm-inactive')) return;
this.state.cursor = 4;
this._renderCursor();
});
container.appendChild(confirmBtn);
container._confirmBtn = confirmBtn;
}
this._renderCursor();
}
@@ -1186,7 +1292,36 @@ class Game {
} else { this.state.streak=0; this.state.wrong++; SFX.wrong(); }
setTimeout(()=>{
/* Pre-generate trees for the next map leg while player reads result */
this._preGenerateNextLeg();
this.showResult(correct,q);
},120);
}
pickMulti(selectedSlots, correctSet, q, container) {
if(this.state.answering)return;
this.state.answering=true;
const btns=Array.from(container.querySelectorAll('.choice-btn:not(.confirm-btn)'));
const correct = selectedSlots.size === correctSet.size &&
[...selectedSlots].every(s => correctSet.has(s));
btns.forEach((b,i)=>{
b.classList.remove('cursor','selected');
b.classList.add('disabled');
if(correctSet.has(i)) b.classList.add('correct');
else if(selectedSlots.has(i) && !correctSet.has(i)) b.classList.add('wrong');
});
const cb = container.querySelector('.confirm-btn');
if(cb) cb.style.display='none';
if(correct){
this.state.streak++; this.state.correct++;
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;
if(this.state.streak>=3) SFX.streak(); else SFX.correct();
} else { this.state.streak=0; this.state.wrong++; SFX.wrong(); }
setTimeout(()=>{
this._preGenerateNextLeg();
this.showResult(correct,q);
},120);
@@ -1196,6 +1331,8 @@ class Game {
showResult(correct,q) {
this.show('result');
this._showController(false);
Music.stopBattle();
Music.pause();
document.getElementById('result-icon').textContent = correct?'✓':'✗';
const lbl=document.getElementById('result-label');
@@ -1210,18 +1347,23 @@ class Game {
ptsEl.style.display='block'; ansEl.classList.remove('visible');
} else {
ptsEl.style.display='none';
ansEl.textContent='✓ Correct answer: '+(q._correctDisplayText||q.options[q.answer]);
const correctText = q._correctDisplayText || (q.answers ? q.answers.map(a=>q.options[a]).join(' AND ') : q.options[q.answer]);
ansEl.textContent='✓ Correct answer'+(q.answers?'s':'')+': '+correctText;
ansEl.classList.add('visible');
}
document.getElementById('result-explanation').textContent=q.explanation;
document.getElementById('btn-result-cont').onclick=()=>{
const contBtn = document.getElementById('btn-result-cont');
const doNext = () => {
this.state.currentQ++;
saveGame(this.state);
if(this.state.currentQ>=this.state.questions.length){ clearSave(); this.showComplete(); }
else if(MILESTONES[this.state.currentQ]) this.showLevelUp();
else this.showMap();
};
contBtn.onclick = doNext;
const resultScreen = document.getElementById('screen-result');
resultScreen.addEventListener('pointerdown', doNext, { once: true });
}
/* ── LEVEL UP ─────────────────────────────────────────── */
+38 -41
View File
@@ -4,10 +4,11 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#0a0520">
<title>PokéSQL — Learn T-SQL</title>
<link rel="icon" type="image/x-icon" href="./favicon.ico">
<title>PokéSQL — Lean SQL</title>
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="style.css">
</head>
<body>
@@ -30,10 +31,10 @@
<div class="title-logo">
<span class="title-poke">POKÉ</span><span class="title-sql">SQL</span>
</div>
<div class="title-edition">★ SQL TRAINER EDITION ★</div>
<div class="title-tap blink">TAP / CLICK TO START</div>
<div class="title-edition">★ SQL TRAINER ★</div>
<div class="title-tap blink">PRESS START</div>
</div>
<div class="title-footer">PokéSQL v1.0 · 100 Questions</div>
<div class="title-footer">PokéSQL v1.0 · SQL · github.com/JustinOros</div>
</div>
</div>
@@ -89,11 +90,13 @@
<div class="map-header">
<div class="map-left">
<span class="map-pin">📍</span>
<span class="map-town-name">PALLET TOWN</span>
<span class="map-town-name" id="map-town-name">SQL TOWN</span>
</div>
<div class="map-right">
<div class="map-center">
<div class="prog-bar-outer"><div class="prog-bar-inner" id="map-prog-bar"></div></div>
<span class="prog-text" id="map-prog-text">0/100</span>
</div>
<div class="map-right">
<span class="music-toggle" id="music-toggle" title="Mute/Unmute Music">🎵</span>
</div>
</div>
@@ -102,8 +105,7 @@
<div class="map-trees" id="map-trees"></div>
</div>
<!-- Path stays fixed to the viewport — does not scroll -->
<div class="map-ground"></div>
<!-- NPC in viewport layer — JS positions it using screen-space X -->
<!-- NPC in viewport layer — JS positions it using screen-space coords -->
<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>
@@ -166,7 +168,7 @@
<div class="lu-stars blink" id="lu-stars">★ BADGE EARNED! ★</div>
<div class="lu-sprite"><div class="lu-player-sprite"></div></div>
<div class="lu-text"><span id="lu-name">ASH</span> gained SQL EXP!</div>
<div class="lu-badge" id="lu-badge">🏅 SQL TRAINEE</div>
<div class="lu-badge" id="lu-badge">🏅 CLOUD TRAINEE</div>
<div class="lu-prog-wrap">
<div class="lu-prog-outer"><div class="lu-prog-inner" id="lu-prog-inner"></div></div>
<div class="lu-prog-label" id="lu-prog-label">25 / 100</div>
@@ -182,48 +184,43 @@
<div class="complete-trophy">🏆</div>
<div class="complete-title">CHAMPION!</div>
<div class="complete-name" id="complete-name">ASH</div>
<p class="complete-msg">You've mastered Microsoft T-SQL fundamentals! You're ready to write real queries, assist DBAs, and analyse data like a pro.</p>
<p class="complete-msg">You've conquered SQL Town and mastered Microsoft T-SQL! Your SQL journey is just beginning — more towns and exams await!</p>
<div class="complete-stats" id="complete-stats"></div>
<div class="complete-badges">
<div class="badge-row">🥉 SQL INITIATE — Q25</div>
<div class="badge-row">🥈 QUERY TRAINER — Q50</div>
<div class="badge-row">🥇 DATA WRANGLER — Q75</div>
<div class="badge-row">🥉 SQL EXPLORER — Q25</div>
<div class="badge-row">🥈 SQL APPRENTICE — Q50</div>
<div class="badge-row">🥇 SQL PRACTITIONER — Q75</div>
<div class="badge-row">🏆 SQL CHAMPION — Q100</div>
<div class="badge-row">🗺️ More towns & exams coming soon...</div>
</div>
<button class="btn-pixel btn-red btn-full" id="btn-play-again">▶ PLAY AGAIN</button>
</div>
</div>
</div>
<div id="gba-left" class="gba-controller hidden">
<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>
<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>
</div>
<div id="gba-right" class="gba-controller hidden">
<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><!-- /#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>
<script src="game.js"></script>
</body>
+116 -45
View File
@@ -180,6 +180,7 @@ html, body {
repeating-linear-gradient(0deg, transparent 0,transparent 31px, rgba(0,80,0,.12) 31px, rgba(0,80,0,.12) 32px),
repeating-linear-gradient(90deg, transparent 0,transparent 31px, rgba(0,80,0,.12) 31px, rgba(0,80,0,.12) 32px),
linear-gradient(180deg, #6cb84e 0%, #58a83e 100%);
background-position: 16px 16px, 16px 16px, 0 0;
}
/* ═══════════════════════════════════════════════════════════
@@ -188,19 +189,11 @@ html, body {
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;
@@ -208,9 +201,40 @@ html, body {
}
.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. */
/* ── PORTRAIT: two panels fixed at bottom ── */
@media (orientation: portrait) {
#gba-left, #gba-right {
position: fixed;
bottom: 0;
height: clamp(110px, 22vw, 148px);
flex-direction: row;
border-top: 2px solid rgba(120,80,255,.35);
}
#gba-left {
left: 0;
width: 65%;
justify-content: space-evenly;
align-items: center;
padding: 0 8px max(8px,var(--safe-bottom)) clamp(8px,3vw,20px);
}
#gba-right {
right: 0;
width: 35%;
justify-content: center;
padding: 0 clamp(8px,3vw,20px) max(8px,var(--safe-bottom)) 8px;
}
#gba-left .gba-mid-btns {
flex-direction: row;
gap: 6px;
margin-top: 0;
width: auto;
justify-content: center;
}
}
@media (min-width: 900px) {
.gba-controller { display: none !important; }
}
@@ -257,6 +281,8 @@ html, body {
gap: 8px;
align-items: center;
justify-content: center;
margin-top: 12px;
width: 100%;
}
.gba-mid-btn {
background: #2a1a5e;
@@ -271,6 +297,7 @@ html, body {
-webkit-tap-highlight-color: transparent;
transition: background .08s;
min-width: 52px;
width: 52px;
text-align: center;
}
.gba-mid-btn:active { background: #3d2f99; }
@@ -348,8 +375,8 @@ html, body {
}
.title-main { text-align:center; z-index:1; padding:24px 16px; }
.title-logo { margin-bottom:16px; 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-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; animation:blueShimmer 3s ease-in-out infinite; animation-delay:.4s; }
.title-poke { font-size:clamp(22px,7.5vw,38px); 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-sql { font-size:clamp(22px,7.5vw,38px); color:#60c8ff; text-shadow:4px 4px 0 #003d70,0 0 24px rgba(96,200,255,.5); display:inline-block; animation:blueShimmer 3s ease-in-out infinite; }
.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; }
@@ -406,10 +433,11 @@ html, body {
/* ─── MAP ────────────────────────────────────────────────── */
.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; }
.map-left { display:flex; align-items:center; gap:6px; }
.map-left { display:flex; align-items:center; gap:6px; flex:1; }
.map-center { display:flex; align-items:center; gap:6px; flex:1; justify-content:center; }
.map-right { display:flex; align-items:center; gap:6px; flex:1; justify-content:flex-end; }
.map-pin { font-size:12px; }
.map-town-name { font-size:clamp(5.5px,1.6vw,7px); color:var(--yellow); letter-spacing:.5px; }
.map-right { display:flex; align-items:center; gap:6px; }
.map-town-name { font-size:clamp(5.5px,1.6vw,7px); color:var(--yellow); letter-spacing:.5px; word-spacing:-1px; }
.prog-bar-outer { width:80px; height:9px; background:var(--black); border:2px solid var(--lightgray); overflow:hidden; }
.prog-bar-inner { height:100%; width:0%; background:var(--hp-green); transition:width .5s ease; }
.prog-text { font-size:6px; color:var(--white); }
@@ -434,8 +462,9 @@ html, body {
position: absolute;
top: 0; bottom: 0; left: 0;
width: 400%;
height: 400%;
will-change: transform;
z-index: 5;
z-index: 25;
}
.map-trees {
position: absolute;
@@ -445,27 +474,11 @@ html, body {
.tree {
position: absolute;
display: inline-block;
animation: treeSway 3s ease-in-out infinite;
filter: drop-shadow(1px 2px 0 rgba(0,0,0,.3));
line-height: 1;
z-index: 60;
}
.tree:nth-child(even) { animation-direction: reverse; }
.map-ground {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 48%;
background:
linear-gradient(180deg,
transparent 0%, transparent 54%,
#3d8a28 54%, #4a9e30 58%, #3d8a28 62%, #4aaa32 66%, #3d8a28 100%
),
linear-gradient(180deg,
#4a9e30 0%, #3d8a28 8%, #2d6e1a 10%,
#c8aa6a 10%, #b89858 30%, #c0a060 50%, #b89858 70%, #a88848 88%,
#2d6e1a 90%, #3d8a28 92%, #4a9e30 100%
);
border-top: 3px solid #2d6e1a;
}
.tree:nth-child(even) { }
/* Directional nudge arrow */
.map-dir-arrow {
position: absolute;
@@ -475,14 +488,13 @@ html, body {
pointer-events: none;
z-index: 50;
filter: drop-shadow(0 0 4px rgba(248,192,48,.6));
right: 12px;
display: none;
bottom: -9999px; /* ensure off-screen when hidden */
bottom: -9999px;
}
/* NPC lives inside .map-inner, positioned in world-px via JS */
.map-npc-wrap { position:absolute; text-align:center; z-index: 10; }
.map-npc-wrap { position:absolute; text-align:center; z-index: 30; }
.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); filter:drop-shadow(2px 3px 0 rgba(0,0,0,.35)); cursor:pointer; }
.map-npc { font-size:clamp(36px,10vw,52px); filter:drop-shadow(2px 3px 0 rgba(0,0,0,.35)); cursor:pointer; animation:npcFloat 2s ease-in-out infinite; }
/* Player is fixed to the viewport centre horizontally; only Y moves */
/* "..." bubble above player when pressing talk away from NPC */
.player-bubble {
@@ -501,6 +513,7 @@ html, body {
position: absolute;
z-index: 100;
box-shadow: 1px 1px 0 rgba(0,0,0,.3);
left: -9999px;
}
.player-bubble::after {
content: '';
@@ -523,18 +536,18 @@ html, body {
background-image: url('./player.png');
background-repeat: no-repeat;
background-size: 768px 64px;
background-position: 0 0;
image-rendering: pixelated;
filter: drop-shadow(2px 3px 0 rgba(0,0,0,.4));
z-index: 10;
z-index: 20;
font-size: 0;
color: transparent;
left: -9999px;
}
.map-player.walk-down { animation: walkDown .32s steps(3) infinite; }
.map-player.walk-up { animation: walkUp .32s steps(3) infinite; }
.map-player.walk-left { animation: walkLeft .32s steps(3) infinite; }
.map-player.walk-right { animation: walkRight .32s steps(3) infinite; }
.map-player.idle { animation: none; background-position-x: 0px; }
.map-player.idle { animation: none; }
/* Talk hint that floats above player when near NPC */
.map-talk-hint {
@@ -546,7 +559,7 @@ html, body {
border: 2px solid var(--black);
padding: 3px 6px;
white-space: nowrap;
z-index: 11;
z-index: 50;
animation: talkBubble .6s ease-in-out infinite;
pointer-events: none;
}
@@ -618,17 +631,24 @@ html, body {
.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.cursor:not(.confirm-btn) { animation:choiceCursor .7s ease-in-out infinite; border-color:var(--yellow) !important; background:#fffae0 !important; }
.choice-btn.confirm-btn.cursor { border-color:var(--yellow) !important; box-shadow:0 0 0 3px var(--yellow); }
.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.disabled{ opacity:.5; pointer-events:none; }
.choice-btn.selected{ background:#c0e0ff; border-color:var(--blue); border-width:4px; box-shadow:3px 3px 0 var(--blue); color:var(--black); }
.choice-btn.selected::after { content:' ✔'; color:var(--blue); font-size:1.6em; vertical-align:middle; }
.choice-btn.confirm-btn { background:var(--blue); color:#fff; border-color:#003080; text-align:center; grid-column: 1 / -1; }
.choice-btn.confirm-btn:hover:not(:disabled) { background:#0050d0; }
.choice-btn.confirm-inactive { opacity:.4; pointer-events:none; }
.choice-btn.confirm-btn::before { content:''; }
.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; }
.kbd-hint { font-size:5px; color:rgba(255,255,255,.3); }
@media (max-width:480px) { .kbd-hint { display:none; } }
/* ─── RESULT ─────────────────────────────────────────────── */
#screen-result { background:rgba(0,0,0,.86); align-items:center; justify-content:center; }
#screen-result { background:var(--dark-bg); align-items:center; justify-content:center; }
.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; }
.result-icon { font-size:clamp(26px,8vw,40px); }
.result-label { font-size:clamp(11px,3.5vw,15px); }
@@ -678,3 +698,54 @@ html, body {
/* ─── 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; }
.hidden { display:none !important; }
/* ─── LANDSCAPE CONTROLLER LAYOUT ───────────────────────── */
@media (orientation: landscape) and (max-height: 500px) {
html, body { align-items: flex-start; justify-content: flex-start; }
#app {
width: 100vw; height: 100dvh;
border: none; border-radius: 0; box-shadow: none;
display: flex; flex-direction: row; align-items: stretch;
}
.screen { position: relative; flex: 1; inset: auto; height: 100dvh; overflow: hidden; min-width: 0; }
#gba-left, #gba-right {
position: relative !important;
bottom: auto !important; left: auto !important; right: auto !important;
height: 100dvh; width: clamp(150px, 22vw, 180px);
flex-direction: column;
justify-content: center;
align-items: center;
border-top: none;
gap: 8px;
flex-shrink: 0;
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);
}
#gba-left {
order: -1;
border-right: 2px solid rgba(120,80,255,.35);
border-left: none; border-top: none;
padding: 8px 8px 8px max(8px, env(safe-area-inset-left, 0px));
}
#gba-right {
order: 1;
border-left: 2px solid rgba(120,80,255,.35);
border-right: none; border-top: none;
padding: 8px max(8px, env(safe-area-inset-right, 0px)) 8px 8px;
}
.gba-controller.hidden { display: none; }
.dpad { width: clamp(90px, 20vh, 120px); height: clamp(90px, 20vh, 120px); }
.gba-mid-btns { margin-top: 16px; }
.ab-cluster { position: relative; width: clamp(90px, 21vw, 120px); height: clamp(90px, 21vw, 120px); flex: none; }
#gba-right .ab-btn { position: absolute; }
#gba-right .ab-b { bottom: 14%; left: 5%; top: auto; transform: none; }
#gba-right .ab-a { top: 14%; right: 5%; transform: none; }
.gba-mid-btns { flex-direction: column; gap: 5px; }
.gba-mid-btn { min-width: 44px; width: 44px; font-size: 5px; padding: 6px 4px; }
}