Update to new mo betta code base
This commit is contained in:
@@ -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
@@ -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,21 +206,21 @@
|
|||||||
<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>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user