Update to new mo betta code base

This commit is contained in:
Justin Oros
2026-05-21 11:09:30 -07:00
parent fe91155ed6
commit c8ce96d0c0
3 changed files with 476 additions and 266 deletions
+317 -175
View File
@@ -1,4 +1,21 @@
/* ─── NPC ROSTER ─────────────────────────────────────────── */ /* ─── 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 = { const NPC = {
'Professor Oak': '👴', 'Professor Oak': '👴',
'Rival Gary': '😤', 'Rival Gary': '😤',
@@ -25,14 +42,7 @@ const NPC = {
'Gary': '😤', 'Gary': '😤',
}; };
const INTRO_MSGS = [ const INTRO_MSGS = CONFIG.introLines.map(text => ({ speaker:'PROFESSOR OAK', npc:'Professor Oak', text }));
{ 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 CORRECT_FB = ["That's right!","Excellent work!","Perfect!","Outstanding!","Correct!","You're a natural!","Great answer!","Spot on!","Impressive!","Well done!"]; const CORRECT_FB = ["That's right!","Excellent work!","Perfect!","Outstanding!","Correct!","You're a natural!","Great answer!","Spot on!","Impressive!","Well done!"];
const WRONG_FB = ["Not quite...","Hmm, that's not it.","Try again next time!","Almost...","Not this time!"]; const WRONG_FB = ["Not quite...","Hmm, that's not it.","Try again next time!","Almost...","Not this time!"];
@@ -43,7 +53,7 @@ const MILESTONES = {
75: { badge:'🥇 DATA WRANGLER', stars:'★ ★ ★ ★' }, 75: { badge:'🥇 DATA WRANGLER', stars:'★ ★ ★ ★' },
}; };
const SAVE_KEY = 'pokesql_save_v1'; const SAVE_KEY = CONFIG.saveKey;
/* ─── SAVE / LOAD ────────────────────────────────────────── */ /* ─── SAVE / LOAD ────────────────────────────────────────── */
function saveGame(s) { function saveGame(s) {
@@ -55,8 +65,9 @@ function clearSave() { try { localStorage.removeItem(SAVE_KEY); } catch(_){} }
/* ─── BACKGROUND MUSIC ───────────────────────────────────── */ /* ─── BACKGROUND MUSIC ───────────────────────────────────── */
const Music = (() => { const Music = (() => {
let audio = null; let audio = null;
let rival = null;
let muted = false; let muted = false;
let wantPlay = false; /* true if we tried to play but were blocked */ let wantPlay = false;
function getAudio() { function getAudio() {
if (!audio) { if (!audio) {
@@ -64,7 +75,6 @@ const Music = (() => {
audio.loop = true; audio.loop = true;
audio.volume = 0.5; audio.volume = 0.5;
audio.preload = 'auto'; audio.preload = 'auto';
/* If audio loads and we wanted to play, try again */
audio.addEventListener('canplaythrough', () => { audio.addEventListener('canplaythrough', () => {
if (wantPlay && !muted) audio.play().catch(() => {}); if (wantPlay && !muted) audio.play().catch(() => {});
}); });
@@ -72,6 +82,16 @@ const Music = (() => {
return audio; 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() { function updateBtn() {
const btn = document.getElementById('music-toggle'); const btn = document.getElementById('music-toggle');
if (!btn) return; if (!btn) return;
@@ -83,16 +103,25 @@ const Music = (() => {
play() { play() {
if (muted) return; if (muted) return;
wantPlay = true; wantPlay = true;
if (rival && !rival.paused) return;
const a = getAudio(); const a = getAudio();
if (a.readyState >= 3) { if (a.readyState >= 3) {
/* Enough data to play */
if (a.paused) a.play().catch((e) => { console.warn('Music play blocked:', e); }); if (a.paused) a.play().catch((e) => { console.warn('Music play blocked:', e); });
} }
/* else: canplaythrough listener will fire and retry */
}, },
pause() { pause() {
wantPlay = false; wantPlay = false;
if (audio && !audio.paused) audio.pause(); 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() { toggle() {
muted = !muted; muted = !muted;
@@ -100,7 +129,6 @@ const Music = (() => {
if (muted) Music.pause(); if (muted) Music.pause();
else Music.play(); else Music.play();
}, },
/* Call once on first user interaction to unblock autoplay */
unblock() { unblock() {
const a = getAudio(); const a = getAudio();
if (wantPlay && !muted && a.paused) { if (wantPlay && !muted && a.paused) {
@@ -120,7 +148,12 @@ const SFX = (() => {
return ctx; 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', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && ctx && ctx.state === 'suspended') { if (document.visibilityState === 'visible' && ctx && ctx.state === 'suspended') {
ctx.resume(); ctx.resume();
@@ -189,7 +222,8 @@ const SFX = (() => {
/* Select / menu move */ /* Select / menu move */
select() { 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 */ /* Correct answer — happy ascending chord */
@@ -244,13 +278,11 @@ class Game {
screen:'boot', playerName:'', questions:[], currentQ:0, screen:'boot', playerName:'', questions:[], currentQ:0,
score:0, streak:0, maxStreak:0, correct:0, wrong:0, score:0, streak:0, maxStreak:0, correct:0, wrong:0,
answering:false, introStep:0, twTimer:null, answering:false, introStep:0, twTimer:null,
/* map — world coords in px, camera follows player */ worldX: 0, worldY: 0,
worldX: 0, /* player's X in world-px */ npcWorldX: 0, npcWorldY: 0,
worldY: 0, /* player's Y in world-px (0 = path centre) */ npcSpawned: false,
npcWorldX: 0, /* NPC world-px X */ pathDir: 'right',
npcWorldY: 0,
lastDir: 'right', lastDir: 'right',
/* battle cursor */
cursor:0, cursor:0,
}; };
@@ -275,8 +307,6 @@ class Game {
CONTROLLER OVERLAY — wires D-pad, A, B, SELECT, START CONTROLLER OVERLAY — wires D-pad, A, B, SELECT, START
══════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════ */
_bindController() { _bindController() {
const ctrl = document.getElementById('gba-controller');
const dpadMap = { 'dp-up':'up', 'dp-down':'down', 'dp-left':'left', 'dp-right':'right' }; const dpadMap = { 'dp-up':'up', 'dp-down':'down', 'dp-left':'left', 'dp-right':'right' };
Object.entries(dpadMap).forEach(([id, dir]) => { Object.entries(dpadMap).forEach(([id, dir]) => {
@@ -354,91 +384,102 @@ class Game {
if (this.state.screen === 'map') { if (this.state.screen === 'map') {
const p = document.getElementById('map-player'); const p = document.getElementById('map-player');
if (p) { 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'; p.className = 'map-player idle';
/* Show the idle/middle frame for the last direction walked requestAnimationFrame(() => {
Frames at 2× scale (32px each): down=32, up=128, left=224, right=320 */ p.style.backgroundPositionX = frame;
const idleFrames = { down:'-32px', left:'-128px', up:'-224px', right:'-320px' }; });
p.style.backgroundPositionX = idleFrames[this.state.lastDir] || '-32px';
} }
} }
} }
_getViewport() {
const w = document.getElementById('map-world');
return {
viewW: (w && w.offsetWidth) || 420,
viewH: (w && w.offsetHeight) || 300,
};
}
_walkTick() { _walkTick() {
if (this.state.screen !== 'map') return; if (this.state.screen !== 'map') return;
const {viewW, viewH} = this._getViewport();
const world = document.getElementById('map-inner'); const SPEED = 8;
if (!world) return;
const viewW = world.parentElement.offsetWidth || 420;
const viewH = world.parentElement.offsetHeight || 300;
const SPEED = 8; /* px per tick */
let dx = 0, dy = 0, dir = ''; let dx = 0, dy = 0, dir = '';
if (this._heldKeys.has('right')) { dx = SPEED; dir = 'right'; } if (this._heldKeys.has('right')) { dx = SPEED; dir = 'right'; }
if (this._heldKeys.has('left')) { dx = -SPEED; dir = 'left'; } if (this._heldKeys.has('left')) { dx = -SPEED; dir = 'left'; }
if (this._heldKeys.has('up')) { dy = -SPEED; dir = 'up'; } if (this._heldKeys.has('up')) { dy = -SPEED; dir = 'up'; }
if (this._heldKeys.has('down')) { dy = SPEED; dir = 'down'; } if (this._heldKeys.has('down')) { dy = SPEED; dir = 'down'; }
if (!dir) return; if (!dir) return;
/* Clamp world X — left edge is 0, right edge is npcWorldX + half screen this.state.worldX += dx;
so the player can always walk far enough to reach the NPC */ this.state.worldY += dy;
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.lastDir = dir; this.state.lastDir = dir;
this._applyCamera(); this._applyCamera();
/* Player sprite stays horizontally centred; only Y shifts */
const p = document.getElementById('map-player'); const p = document.getElementById('map-player');
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';
p.className = `map-player walk-${dir}`;
/* Keep "..." bubble above player */
const bub = document.getElementById('player-bubble'); const bub = document.getElementById('player-bubble');
if (bub) { bub.style.left = (centreX - 4) + 'px'; bub.style.bottom = (bottomPx + 66) + 'px'; } if (p) {
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}`;
if (bub) { bub.style.left = (cx - 4) + 'px'; bub.style.bottom = (bot + 66) + 'px'; }
} }
this._checkNPCProximity(); this._checkNPCProximity();
} }
/* Slide the world and reposition NPC in screen-space every tick */
_applyCamera() { _applyCamera() {
const world = document.getElementById('map-inner'); const {viewW, viewH} = this._getViewport();
if (world) world.style.transform = `translateX(${-this.state.worldX}px)`; 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(); this._positionNPCOnScreen();
} }
_positionNPCOnScreen() { _positionNPCOnScreen() {
const world = document.getElementById('map-inner'); const {viewW, viewH} = this._getViewport();
const viewH = world ? world.parentElement.offsetHeight : 300;
const npcWrap = document.getElementById('map-npc-wrap'); const npcWrap = document.getElementById('map-npc-wrap');
if (npcWrap) { if (npcWrap) {
const screenX = this.state.npcWorldX - this.state.worldX; const screenX = viewW / 2 + (this.state.npcWorldX - this.state.worldX) - 24;
npcWrap.style.left = (screenX - 24) + 'px'; const screenY = viewH / 2 + (this.state.npcWorldY - this.state.worldY) - 24;
npcWrap.style.bottom = (viewH * 0.28) + 'px'; 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() { _checkNPCProximity() {
const world = document.getElementById('map-inner'); const {viewW, viewH} = this._getViewport();
const viewW = world ? world.parentElement.offsetWidth : 420; const dist = this._npcScreenDist();
const viewH = world ? world.parentElement.offsetHeight : 300; const near = this.state.npcSpawned && dist < 100;
const playerWorldX = this.state.worldX + viewW / 2;
const dx = Math.abs(playerWorldX - this.state.npcWorldX);
const near = dx < 60;
const hint = document.getElementById('map-talk-hint'); const hint = document.getElementById('map-talk-hint');
const bub = document.getElementById('npc-bubble'); const bub = document.getElementById('npc-bubble');
if (hint) { 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.display = near ? 'block' : 'none';
hint.style.left = (viewW / 2 - 28) + 'px'; hint.style.left = (npcSX - 28) + 'px';
hint.style.bottom = (viewH * 0.50) + 'px'; hint.style.bottom = (viewH - npcSY + 48) + 'px';
} }
if (bub) bub.style.opacity = near ? '0' : '1'; if (bub) bub.style.opacity = near ? '0' : '1';
this._updateDirArrow(near); this._updateDirArrow(near);
@@ -448,25 +489,51 @@ class Game {
const arrow = document.getElementById('map-dir-arrow'); const arrow = document.getElementById('map-dir-arrow');
if (!arrow) return; if (!arrow) return;
if (nearNPC) { arrow.style.display = 'none'; arrow.style.animation = 'none'; return; } if (nearNPC) { arrow.style.display = 'none'; arrow.style.animation = 'none'; return; }
const world = document.getElementById('map-inner'); const {viewW, viewH} = this._getViewport();
const viewH = world ? world.parentElement.offsetHeight : 300; const dx = this.state.npcWorldX - this.state.worldX;
arrow.textContent = '▶'; 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.display = 'block';
arrow.style.animation = 'arrowPulse .7s ease-in-out infinite'; arrow.style.animation = 'arrowPulse .7s ease-in-out infinite';
if (pd === 'right') {
arrow.style.removeProperty('left'); arrow.style.removeProperty('left');
arrow.style.bottom = (viewH * 0.28) + 'px'; 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() { _nearNPC() {
const world = document.getElementById('map-inner'); if (!this.state.npcSpawned) return false;
const viewW = world ? world.parentElement.offsetWidth : 420; return this._npcScreenDist() < 100;
const playerWorldX = this.state.worldX + viewW / 2;
return Math.abs(playerWorldX - this.state.npcWorldX) < 60;
} }
_talkToNPC() { _talkToNPC() {
if (!this._nearNPC()) { if (!this._nearNPC()) {
/* Too far away — show "..." bubble above player */
const bub = document.getElementById('player-bubble'); const bub = document.getElementById('player-bubble');
if (bub) { if (bub) {
bub.classList.remove('visible'); bub.classList.remove('visible');
@@ -478,28 +545,26 @@ class Game {
return; return;
} }
const arrow = document.getElementById('map-dir-arrow'); 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'); const bub = document.getElementById('player-bubble');
if (bub) bub.classList.remove('visible'); if (bub) bub.classList.remove('visible');
SFX.encounter(); SFX.encounter();
this.startQuestion(); this.startQuestion();
} }
/* Place player at viewport centre, NPC via screen-space conversion */
_placeMapSprites() { _placeMapSprites() {
const world = document.getElementById('map-inner'); const {viewW, viewH} = this._getViewport();
const viewW = world ? world.parentElement.offsetWidth : 420; const idleFrames = { right:'-320px', down:'-32px', left:'-128px', up:'-224px' };
const viewH = world ? world.parentElement.offsetHeight : 300;
const p = document.getElementById('map-player'); const p = document.getElementById('map-player');
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'); const bub = document.getElementById('player-bubble');
if (bub) { bub.style.left = (left - 4) + 'px'; bub.style.bottom = (bottom + 66) + 'px'; } if (p) {
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(); this._applyCamera();
const hint = document.getElementById('map-talk-hint'); const hint = document.getElementById('map-talk-hint');
@@ -510,14 +575,16 @@ class Game {
BATTLE CURSOR — navigate 2×2 grid with D-pad / WASD BATTLE CURSOR — navigate 2×2 grid with D-pad / WASD
══════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════ */
_moveCursor(dir) { _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; if (!btns.length) return;
const total = btns.length;
let c = this.state.cursor; let c = this.state.cursor;
const hasConfirm = total === 5;
if (dir === 'right') c = (c === 0) ? 1 : (c === 2) ? 3 : c; 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 === 'left') c = (c === 1) ? 0 : (c === 3) ? 2 : (c === 4) ? 4 : c;
else if (dir === 'down') c = (c === 0) ? 2 : (c === 1) ? 3 : 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; else if (dir === 'up') c = (c === 2) ? 0 : (c === 3) ? 1 : (c === 4) ? 2 : c;
c = Math.min(c, btns.length - 1); c = Math.min(c, total - 1);
this.state.cursor = c; this.state.cursor = c;
this._renderCursor(); this._renderCursor();
SFX.select(); SFX.select();
@@ -531,7 +598,7 @@ class Game {
_confirmCursor() { _confirmCursor() {
if (this.state.answering) return; 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]; const target = btns[this.state.cursor];
if (target) target.click(); if (target) target.click();
} }
@@ -802,10 +869,12 @@ class Game {
} }
} }
_showController(visible) { _showController(visible) {
const ctrl = document.getElementById('gba-controller'); ['gba-left', 'gba-right'].forEach(id => {
if (!ctrl) return; const el = document.getElementById(id);
if (visible) ctrl.classList.remove('hidden'); if (!el) return;
else ctrl.classList.add('hidden'); if (visible) el.classList.remove('hidden');
else el.classList.add('hidden');
});
} }
/* ══════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════
@@ -861,7 +930,7 @@ class Game {
playerName:save.playerName, currentQ:save.currentQ, playerName:save.playerName, currentQ:save.currentQ,
score:save.score, streak:save.streak, maxStreak:save.maxStreak, score:save.score, streak:save.streak, maxStreak:save.maxStreak,
correct:save.correct, wrong:save.wrong, 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(() => { this.loadQuestions(() => {
if (save.questionOrder && save.questionOrder.length === this.state.questions.length) { if (save.questionOrder && save.questionOrder.length === this.state.questions.length) {
@@ -886,7 +955,7 @@ class Game {
this._showController(false); this._showController(false);
this._gpKeyCursor = 0; this._gpKeyCursor = 0;
this.buildKeyboard(); 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-backspace').onclick = () => this.delChar();
document.getElementById('btn-confirm-name').onclick = () => this.confirmName(); document.getElementById('btn-confirm-name').onclick = () => this.confirmName();
} }
@@ -976,34 +1045,35 @@ class Game {
/* Generate randomly placed trees across the full scrollable world */ /* Generate randomly placed trees across the full scrollable world */
/* Generate trees for the current journey leg — from world origin to just past the NPC. /* 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. */ 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() { _generateTrees() {
const container = document.getElementById('map-trees'); const container = document.getElementById('map-trees');
if (!container) return; if (!container) return;
/* Clear previous leg's trees */
container.innerHTML = ''; container.innerHTML = '';
const {viewW, viewH} = this._getViewport();
const world = document.getElementById('map-inner'); const spread = Math.max(viewW, viewH) * 5;
const viewW = world ? world.parentElement.offsetWidth : 420; const inner = document.getElementById('map-inner');
const viewH = world ? world.parentElement.offsetHeight : 300; if (inner) { inner.style.width = spread * 2 + 'px'; inner.style.height = spread * 2 + 'px'; }
const cx = spread;
/* World spans from 0 to just past the NPC */ const cy = spread;
const worldW = this.state.npcWorldX + viewW; const npcX = cx + this.state.npcWorldX;
if (world) world.style.width = worldW + 'px'; const npcY = cy + this.state.npcWorldY;
for (let i = 0; i < 300; i++) {
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 span = document.createElement('span'); const span = document.createElement('span');
span.className = 'tree'; span.className = 'tree';
const size = 28 + Math.random() * 16; const size = 48 + Math.random() * 24;
const x = i * zoneW + Math.random() * zoneW * 0.75; let x, y, tries = 0;
const y = treeBottomMin + Math.random() * (treeTopMax - treeBottomMin - size); 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.textContent = '🌳';
span.style.fontSize = size + 'px'; span.style.fontSize = size + 'px';
span.style.left = x + 'px'; span.style.left = x + 'px';
@@ -1014,39 +1084,47 @@ class Game {
} }
} }
/* Called on result screen — pre-builds trees for the next leg */
_preGenerateNextLeg() { _preGenerateNextLeg() {
const world = document.getElementById('map-inner'); this._nextLegDir = this._pickNextDir(this.state.pathDir);
const viewW = world ? world.parentElement.offsetWidth : 420; this._nextLegLen = null;
const nextNpcX = Math.round(viewW * 3.0 + Math.random() * viewW * 0.8); }
const savedNpcX = this.state.npcWorldX;
this.state.npcWorldX = nextNpcX; _npcOffsetForDir(dir, viewW, viewH) {
this._generateTrees(); const dist = dir === 'right' || dir === 'left'
this.state.npcWorldX = savedNpcX; ? 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() { showMap() {
this.show('map'); this.show('map');
this._showController(true); this._showController(true);
/* We need the viewport size — defer a tick so the DOM is laid out */
requestAnimationFrame(() => { requestAnimationFrame(() => {
const world = document.getElementById('map-inner'); const {viewW, viewH} = this._getViewport();
const viewW = world ? world.parentElement.offsetWidth : 420;
if (this.state.currentQ === 0) { if (this.state.currentQ === 0) {
this.state.worldX = 0; this.state.pathDir = 'right';
this.state.worldY = 0; } else if (this._nextLegDir) {
this.state.npcWorldX = Math.round(viewW * 3.5); this.state.pathDir = this._nextLegDir;
this.state.npcWorldY = 0; this._nextLegDir = null;
this._nextLegLen = null;
} else { } else {
/* Always reset camera to 0 each question — keeps trees in view. this.state.pathDir = this._pickNextDir(this.state.pathDir);
NPC spawns off-screen right relative to fresh origin. */ }
this.state.worldX = 0; this.state.worldX = 0;
this.state.worldY = 0; this.state.worldY = 0;
this.state.npcWorldX = Math.round(viewW * 3.0 + Math.random() * viewW * 0.8); this.state.lastDir = this.state.pathDir;
this.state.npcWorldY = 0;
} const {nx, ny} = this._npcOffsetForDir(this.state.pathDir, viewW, viewH);
this.state.npcWorldX = nx;
this.state.npcWorldY = ny;
this.state.npcSpawned = true;
this._generateTrees(); this._generateTrees();
@@ -1058,7 +1136,9 @@ class Game {
if (q && npcEl) npcEl.textContent = NPC[q.npc]||'🧑'; if (q && npcEl) npcEl.textContent = NPC[q.npc]||'🧑';
this._placeMapSprites(); this._placeMapSprites();
this._checkNPCProximity();
this._updateDirArrow(false); this._updateDirArrow(false);
Music.stopBattle();
Music.play(); Music.play();
saveGame(this.state); saveGame(this.state);
this.flashSaveDot(); this.flashSaveDot();
@@ -1071,6 +1151,7 @@ class Game {
}); });
} }
updateHUD() { updateHUD() {
const tot=this.state.questions.length||100, done=this.state.currentQ, pct=(done/tot)*100; const tot=this.state.questions.length||100, done=this.state.currentQ, pct=(done/tot)*100;
document.getElementById('hud-score').textContent = this.state.score.toLocaleString(); document.getElementById('hud-score').textContent = this.state.score.toLocaleString();
@@ -1096,7 +1177,7 @@ class Game {
this.show('battle'); this.show('battle');
this._showController(true); this._showController(true);
Music.pause(); Music.playBattle();
this.state.answering=false; this.state.answering=false;
this.state.cursor=0; this.state.cursor=0;
@@ -1117,53 +1198,78 @@ class Game {
container.innerHTML=''; container.innerHTML='';
this.state.cursor=0; this.state.cursor=0;
/* Shuffle options so the correct answer lands in a random slot each time. const isMulti = Array.isArray(q.answers);
We track which shuffled index holds the correct answer. */
const indices = [0,1,2,3]; const indices = [0,1,2,3];
for (let i = indices.length - 1; i > 0; i--) { for (let i = indices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]]; [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) const correctShuffledIdx = isMulti ? null : indices.indexOf(q.answer);
so any option text like "Both A and B" gets rewritten to e.g. "Both 2 and 4" */ const correctShuffledSet = isMulti ? new Set(q.answers.map(a => indices.indexOf(a))) : null;
const letterToSlot = {};
indices.forEach((origIdx, slotIdx) => {
const letter = ['A','B','C','D'][origIdx];
letterToSlot[letter] = slotIdx + 1;
});
function rewriteLetterRefs(text) { q._correctShuffledSet = correctShuffledSet;
/* Only rewrite letters that appear in explicit option-reference patterns: q._indices = indices;
"Both A and B", "A and B", "Both B and C", etc.
Never replace standalone A/B/C/D in regular sentence text. */ if (isMulti) {
return text.replace(/\bBoth ([ABCD]) and ([ABCD])\b/gi, (_, l1, l2) => { q._correctDisplayText = q.answers.map(a => q.options[a]).join(' AND ');
const n1 = letterToSlot[l1.toUpperCase()]; } else {
const n2 = letterToSlot[l2.toUpperCase()]; q._correctDisplayText = q.options[q.answer];
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}`;
});
} }
/* Store display text of correct answer with letter refs rewritten */ const selectedSlots = new Set();
q._correctDisplayText = rewriteLetterRefs(q.options[q.answer]); 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) => { 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', slotIdx + 1); btn.setAttribute('data-letter', slotIdx + 1);
btn.textContent=rewriteLetterRefs(q.options[origIdx]); 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('click',()=>this.pick(slotIdx, correctShuffledIdx, q, container));
}
btn.addEventListener('mouseenter', () => { btn.addEventListener('mouseenter', () => {
this.state.cursor = slotIdx; this.state.cursor = slotIdx;
this._renderCursor(); this._renderCursor();
}); });
container.appendChild(btn); 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(); this._renderCursor();
} }
@@ -1186,7 +1292,36 @@ class Game {
} else { this.state.streak=0; this.state.wrong++; SFX.wrong(); } } else { this.state.streak=0; this.state.wrong++; SFX.wrong(); }
setTimeout(()=>{ 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._preGenerateNextLeg();
this.showResult(correct,q); this.showResult(correct,q);
},120); },120);
@@ -1196,6 +1331,8 @@ class Game {
showResult(correct,q) { showResult(correct,q) {
this.show('result'); this.show('result');
this._showController(false); this._showController(false);
Music.stopBattle();
Music.pause();
document.getElementById('result-icon').textContent = correct?'✓':'✗'; document.getElementById('result-icon').textContent = correct?'✓':'✗';
const lbl=document.getElementById('result-label'); const lbl=document.getElementById('result-label');
@@ -1210,18 +1347,23 @@ class Game {
ptsEl.style.display='block'; ansEl.classList.remove('visible'); ptsEl.style.display='block'; ansEl.classList.remove('visible');
} else { } else {
ptsEl.style.display='none'; 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'); ansEl.classList.add('visible');
} }
document.getElementById('result-explanation').textContent=q.explanation; 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++; this.state.currentQ++;
saveGame(this.state); saveGame(this.state);
if(this.state.currentQ>=this.state.questions.length){ clearSave(); this.showComplete(); } if(this.state.currentQ>=this.state.questions.length){ clearSave(); this.showComplete(); }
else if(MILESTONES[this.state.currentQ]) this.showLevelUp(); else if(MILESTONES[this.state.currentQ]) this.showLevelUp();
else this.showMap(); else this.showMap();
}; };
contBtn.onclick = doNext;
const resultScreen = document.getElementById('screen-result');
resultScreen.addEventListener('pointerdown', doNext, { once: true });
} }
/* ── LEVEL UP ─────────────────────────────────────────── */ /* ── LEVEL UP ─────────────────────────────────────────── */
+22 -25
View File
@@ -4,10 +4,11 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no"> <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="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="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#0a0520"> <meta name="theme-color" content="#0a0520">
<title>PokéSQL — Learn T-SQL</title> <title>PokéSQL — Lean SQL</title>
<link rel="icon" type="image/x-icon" href="./favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
@@ -30,10 +31,10 @@
<div class="title-logo"> <div class="title-logo">
<span class="title-poke">POKÉ</span><span class="title-sql">SQL</span> <span class="title-poke">POKÉ</span><span class="title-sql">SQL</span>
</div> </div>
<div class="title-edition">★ SQL TRAINER EDITION ★</div> <div class="title-edition">★ SQL TRAINER ★</div>
<div class="title-tap blink">TAP / CLICK TO START</div> <div class="title-tap blink">PRESS START</div>
</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>
</div> </div>
@@ -89,11 +90,13 @@
<div class="map-header"> <div class="map-header">
<div class="map-left"> <div class="map-left">
<span class="map-pin">📍</span> <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>
<div class="map-right"> <div class="map-center">
<div class="prog-bar-outer"><div class="prog-bar-inner" id="map-prog-bar"></div></div> <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> <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> <span class="music-toggle" id="music-toggle" title="Mute/Unmute Music">🎵</span>
</div> </div>
</div> </div>
@@ -102,8 +105,7 @@
<div class="map-trees" id="map-trees"></div> <div class="map-trees" id="map-trees"></div>
</div> </div>
<!-- Path stays fixed to the viewport — does not scroll --> <!-- Path stays fixed to the viewport — does not scroll -->
<div class="map-ground"></div> <!-- NPC in viewport layer — JS positions it using screen-space coords -->
<!-- NPC in viewport layer — JS positions it using screen-space X -->
<div class="map-npc-wrap" id="map-npc-wrap"> <div class="map-npc-wrap" id="map-npc-wrap">
<div class="npc-bubble" id="npc-bubble">!</div> <div class="npc-bubble" id="npc-bubble">!</div>
<div class="map-npc" id="map-npc"></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-stars blink" id="lu-stars">★ BADGE EARNED! ★</div>
<div class="lu-sprite"><div class="lu-player-sprite"></div></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-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-wrap">
<div class="lu-prog-outer"><div class="lu-prog-inner" id="lu-prog-inner"></div></div> <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> <div class="lu-prog-label" id="lu-prog-label">25 / 100</div>
@@ -182,26 +184,21 @@
<div class="complete-trophy">🏆</div> <div class="complete-trophy">🏆</div>
<div class="complete-title">CHAMPION!</div> <div class="complete-title">CHAMPION!</div>
<div class="complete-name" id="complete-name">ASH</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-stats" id="complete-stats"></div>
<div class="complete-badges"> <div class="complete-badges">
<div class="badge-row">🥉 SQL INITIATE — Q25</div> <div class="badge-row">🥉 SQL EXPLORER — Q25</div>
<div class="badge-row">🥈 QUERY TRAINER — Q50</div> <div class="badge-row">🥈 SQL APPRENTICE — Q50</div>
<div class="badge-row">🥇 DATA WRANGLER — Q75</div> <div class="badge-row">🥇 SQL PRACTITIONER — Q75</div>
<div class="badge-row">🏆 SQL CHAMPION — Q100</div> <div class="badge-row">🏆 SQL CHAMPION — Q100</div>
<div class="badge-row">🗺️ More towns & exams coming soon...</div>
</div> </div>
<button class="btn-pixel btn-red btn-full" id="btn-play-again">▶ PLAY AGAIN</button> <button class="btn-pixel btn-red btn-full" id="btn-play-again">▶ PLAY AGAIN</button>
</div> </div>
</div> </div>
</div> </div>
</div><!-- /#app --> <div id="gba-left" class="gba-controller hidden">
<!-- ═══ 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"> <div class="dpad" id="dpad">
<button class="dpad-btn dpad-up" id="dp-up" aria-label="Up">▲</button> <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-left" id="dp-left" aria-label="Left">◀</button>
@@ -209,20 +206,20 @@
<button class="dpad-btn dpad-right" id="dp-right" aria-label="Right">▶</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> <button class="dpad-btn dpad-down" id="dp-down" aria-label="Down">▼</button>
</div> </div>
<!-- SELECT / START (center) -->
<div class="gba-mid-btns"> <div class="gba-mid-btns">
<button class="gba-mid-btn" id="btn-select">SELECT</button> <button class="gba-mid-btn" id="btn-select">SELECT</button>
<button class="gba-mid-btn" id="btn-start">START</button> <button class="gba-mid-btn" id="btn-start">START</button>
</div> </div>
</div>
<!-- A / B BUTTONS (right) --> <div id="gba-right" class="gba-controller hidden">
<div class="ab-cluster"> <div class="ab-cluster">
<button class="ab-btn ab-b" id="btn-b">B</button> <button class="ab-btn ab-b" id="btn-b">B</button>
<button class="ab-btn ab-a" id="btn-a">A</button> <button class="ab-btn ab-a" id="btn-a">A</button>
</div> </div>
</div>
</div> </div><!-- /#app -->
<div id="toast" class="toast hidden"></div> <div id="toast" class="toast hidden"></div>
<script src="game.js"></script> <script src="game.js"></script>
+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(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), 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%); 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'. Shown only when screen === 'map' or 'battle'.
═══════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════ */
.gba-controller { .gba-controller {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: clamp(110px, 22vw, 148px);
display: flex; display: flex;
align-items: center; 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%); background: linear-gradient(180deg, rgba(26,10,62,.82) 0%, rgba(14,6,36,.95) 100%);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
border-top: 2px solid rgba(120,80,255,.35);
z-index: 800; z-index: 800;
user-select: none; user-select: none;
touch-action: none; touch-action: none;
@@ -208,9 +201,40 @@ html, body {
} }
.gba-controller.hidden { display: none; } .gba-controller.hidden { display: none; }
/* Only show controller on touch / small screens by default. /* ── PORTRAIT: two panels fixed at bottom ── */
On desktop we rely on keyboard — but controller still shows @media (orientation: portrait) {
if screen is narrow enough. JS toggles it per screen anyway. */ #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) { @media (min-width: 900px) {
.gba-controller { display: none !important; } .gba-controller { display: none !important; }
} }
@@ -257,6 +281,8 @@ html, body {
gap: 8px; gap: 8px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: 12px;
width: 100%;
} }
.gba-mid-btn { .gba-mid-btn {
background: #2a1a5e; background: #2a1a5e;
@@ -271,6 +297,7 @@ html, body {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
transition: background .08s; transition: background .08s;
min-width: 52px; min-width: 52px;
width: 52px;
text-align: center; text-align: center;
} }
.gba-mid-btn:active { background: #3d2f99; } .gba-mid-btn:active { background: #3d2f99; }
@@ -348,8 +375,8 @@ html, body {
} }
.title-main { text-align:center; z-index:1; padding:24px 16px; } .title-main { text-align:center; z-index:1; padding:24px 16px; }
.title-logo { margin-bottom:16px; line-height:1; } .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-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(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-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-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-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 { font-size:clamp(22px,6vw,32px); margin-bottom:28px; display:flex; gap:16px; justify-content:center; }
@@ -406,10 +433,11 @@ html, body {
/* ─── MAP ────────────────────────────────────────────────── */ /* ─── 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-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-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; word-spacing:-1px; }
.map-right { display:flex; align-items:center; gap:6px; }
.prog-bar-outer { width:80px; height:9px; background:var(--black); border:2px solid var(--lightgray); overflow:hidden; } .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-bar-inner { height:100%; width:0%; background:var(--hp-green); transition:width .5s ease; }
.prog-text { font-size:6px; color:var(--white); } .prog-text { font-size:6px; color:var(--white); }
@@ -434,8 +462,9 @@ html, body {
position: absolute; position: absolute;
top: 0; bottom: 0; left: 0; top: 0; bottom: 0; left: 0;
width: 400%; width: 400%;
height: 400%;
will-change: transform; will-change: transform;
z-index: 5; z-index: 25;
} }
.map-trees { .map-trees {
position: absolute; position: absolute;
@@ -445,27 +474,11 @@ html, body {
.tree { .tree {
position: absolute; position: absolute;
display: inline-block; display: inline-block;
animation: treeSway 3s ease-in-out infinite;
filter: drop-shadow(1px 2px 0 rgba(0,0,0,.3)); filter: drop-shadow(1px 2px 0 rgba(0,0,0,.3));
line-height: 1; line-height: 1;
z-index: 60;
} }
.tree:nth-child(even) { animation-direction: reverse; } .tree:nth-child(even) { }
.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;
}
/* Directional nudge arrow */ /* Directional nudge arrow */
.map-dir-arrow { .map-dir-arrow {
position: absolute; position: absolute;
@@ -475,14 +488,13 @@ html, body {
pointer-events: none; pointer-events: none;
z-index: 50; z-index: 50;
filter: drop-shadow(0 0 4px rgba(248,192,48,.6)); filter: drop-shadow(0 0 4px rgba(248,192,48,.6));
right: 12px;
display: none; display: none;
bottom: -9999px; /* ensure off-screen when hidden */ bottom: -9999px;
} }
/* NPC lives inside .map-inner, positioned in world-px via JS */ /* 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; } .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 */ /* Player is fixed to the viewport centre horizontally; only Y moves */
/* "..." bubble above player when pressing talk away from NPC */ /* "..." bubble above player when pressing talk away from NPC */
.player-bubble { .player-bubble {
@@ -501,6 +513,7 @@ html, body {
position: absolute; position: absolute;
z-index: 100; z-index: 100;
box-shadow: 1px 1px 0 rgba(0,0,0,.3); box-shadow: 1px 1px 0 rgba(0,0,0,.3);
left: -9999px;
} }
.player-bubble::after { .player-bubble::after {
content: ''; content: '';
@@ -523,18 +536,18 @@ html, body {
background-image: url('./player.png'); background-image: url('./player.png');
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: 768px 64px; background-size: 768px 64px;
background-position: 0 0;
image-rendering: pixelated; image-rendering: pixelated;
filter: drop-shadow(2px 3px 0 rgba(0,0,0,.4)); filter: drop-shadow(2px 3px 0 rgba(0,0,0,.4));
z-index: 10; z-index: 20;
font-size: 0; font-size: 0;
color: transparent; color: transparent;
left: -9999px;
} }
.map-player.walk-down { animation: walkDown .32s steps(3) infinite; } .map-player.walk-down { animation: walkDown .32s steps(3) infinite; }
.map-player.walk-up { animation: walkUp .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-left { animation: walkLeft .32s steps(3) infinite; }
.map-player.walk-right { animation: walkRight .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 */ /* Talk hint that floats above player when near NPC */
.map-talk-hint { .map-talk-hint {
@@ -546,7 +559,7 @@ html, body {
border: 2px solid var(--black); border: 2px solid var(--black);
padding: 3px 6px; padding: 3px 6px;
white-space: nowrap; white-space: nowrap;
z-index: 11; z-index: 50;
animation: talkBubble .6s ease-in-out infinite; animation: talkBubble .6s ease-in-out infinite;
pointer-events: none; pointer-events: none;
} }
@@ -618,17 +631,24 @@ html, body {
.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 */ /* 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.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; }
.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; } .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); } .kbd-hint { font-size:5px; color:rgba(255,255,255,.3); }
@media (max-width:480px) { .kbd-hint { display:none; } } @media (max-width:480px) { .kbd-hint { display:none; } }
/* ─── RESULT ─────────────────────────────────────────────── */ /* ─── 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-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-icon { font-size:clamp(26px,8vw,40px); }
.result-label { font-size:clamp(11px,3.5vw,15px); } .result-label { font-size:clamp(11px,3.5vw,15px); }
@@ -678,3 +698,54 @@ html, body {
/* ─── 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; } .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; } .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; }
}