From c8ce96d0c04559ff0f37799e4f32ddc792d0877f Mon Sep 17 00:00:00 2001 From: Justin Oros Date: Thu, 21 May 2026 11:09:30 -0700 Subject: [PATCH] Update to new mo betta code base --- game.js | 502 ++++++++++++++++++++++++++++++++++------------------- index.html | 79 ++++----- style.css | 161 ++++++++++++----- 3 files changed, 476 insertions(+), 266 deletions(-) diff --git a/game.js b/game.js index 5dc7d91..0cdb04f 100644 --- a/game.js +++ b/game.js @@ -1,4 +1,21 @@ /* ─── NPC ROSTER ─────────────────────────────────────────── */ +const CONFIG = { + gameName: 'PokéSQL', + subject: 'SQL', + examName: 'SQL', + townName: 'SQL Town', + saveKey: 'pokesql_save_v1', + introLines: [ + "Hello {name}! Welcome to PokéSQL, the world of SQL training! My name is Professor Oak, the SQL Professor!", + "This world is powered by Databases, Tables, Queries, Indexes, and Transactions, mastered through knowledge!", + "Your mission? Journey through each town, challenge SQL masters, and conquer every database concept!", + "Start in SQL Town then travel the SQL world to master JOINs, Aggregations, Indexing, and beyond!", + "100 questions await you. Each correct answer earns you SQL EXP and you'll learn something real!", + "Your progress is saved automatically in your browser so you can pick up right where you left off. Now, {name}... your adventure begins!", + ], + namePrompt: "Hello there! Welcome to PokéSQL! My name is Professor Oak — the SQL Professor. Now tell me, what is your name?", +}; + const NPC = { 'Professor Oak': '👴', 'Rival Gary': '😤', @@ -25,14 +42,7 @@ const NPC = { 'Gary': '😤', }; -const INTRO_MSGS = [ - { speaker:'PROFESSOR OAK', npc:'Professor Oak', text:"Hello {name}! Welcome to PokéSQL — the world of T-SQL training! My name is Professor Oak, the SQL Pokémon Professor!" }, - { speaker:'PROFESSOR OAK', npc:'Professor Oak', text:"This world is inhabited by powerful data — stored in TABLES, organised in DATABASES, and tamed with QUERIES!" }, - { speaker:'PROFESSOR OAK', npc:'Professor Oak', text:"Your mission? Journey through Pallet Town, challenge SQL experts, and become a T-SQL Champion!" }, - { speaker:'PROFESSOR OAK', npc:'Professor Oak', text:"You'll learn Microsoft T-SQL — the language used by DBAs and data professionals worldwide." }, - { speaker:'PROFESSOR OAK', npc:'Professor Oak', text:"100 questions await you in this town alone. Each correct answer earns you SQL EXP — and you'll learn something real!" }, - { speaker:'PROFESSOR OAK', npc:'Professor Oak', text:"Your progress is saved automatically in your browser so you can pick up right where you left off. Now, {name}... your adventure begins!" }, -]; +const INTRO_MSGS = CONFIG.introLines.map(text => ({ speaker:'PROFESSOR OAK', npc:'Professor Oak', text })); const CORRECT_FB = ["That's right!","Excellent work!","Perfect!","Outstanding!","Correct!","You're a natural!","Great answer!","Spot on!","Impressive!","Well done!"]; const WRONG_FB = ["Not quite...","Hmm, that's not it.","Try again next time!","Almost...","Not this time!"]; @@ -43,7 +53,7 @@ const MILESTONES = { 75: { badge:'🥇 DATA WRANGLER', stars:'★ ★ ★ ★' }, }; -const SAVE_KEY = 'pokesql_save_v1'; +const SAVE_KEY = CONFIG.saveKey; /* ─── SAVE / LOAD ────────────────────────────────────────── */ function saveGame(s) { @@ -55,8 +65,9 @@ function clearSave() { try { localStorage.removeItem(SAVE_KEY); } catch(_){} } /* ─── BACKGROUND MUSIC ───────────────────────────────────── */ const Music = (() => { let audio = null; + let rival = null; let muted = false; - let wantPlay = false; /* true if we tried to play but were blocked */ + let wantPlay = false; function getAudio() { if (!audio) { @@ -64,7 +75,6 @@ const Music = (() => { audio.loop = true; audio.volume = 0.5; audio.preload = 'auto'; - /* If audio loads and we wanted to play, try again */ audio.addEventListener('canplaythrough', () => { if (wantPlay && !muted) audio.play().catch(() => {}); }); @@ -72,6 +82,16 @@ const Music = (() => { return audio; } + function getRival() { + if (!rival) { + rival = new Audio('./music-rival.mp3'); + rival.loop = true; + rival.volume = 0.5; + rival.preload = 'auto'; + } + return rival; + } + function updateBtn() { const btn = document.getElementById('music-toggle'); if (!btn) return; @@ -83,16 +103,25 @@ const Music = (() => { play() { if (muted) return; wantPlay = true; + if (rival && !rival.paused) return; const a = getAudio(); if (a.readyState >= 3) { - /* Enough data to play */ if (a.paused) a.play().catch((e) => { console.warn('Music play blocked:', e); }); } - /* else: canplaythrough listener will fire and retry */ }, pause() { wantPlay = false; if (audio && !audio.paused) audio.pause(); + if (rival && !rival.paused) rival.pause(); + }, + playBattle() { + if (muted) return; + if (audio && !audio.paused) audio.pause(); + const r = getRival(); + if (r.paused) { r.currentTime = 0; r.play().catch(() => {}); } + }, + stopBattle() { + if (rival && !rival.paused) { rival.pause(); rival.currentTime = 0; } }, toggle() { muted = !muted; @@ -100,7 +129,6 @@ const Music = (() => { if (muted) Music.pause(); else Music.play(); }, - /* Call once on first user interaction to unblock autoplay */ unblock() { const a = getAudio(); if (wantPlay && !muted && a.paused) { @@ -120,7 +148,12 @@ const SFX = (() => { return ctx; } - /* Resume AudioContext when user returns to the page on mobile */ + function unlock() { + if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)(); + if (ctx.state === 'suspended') ctx.resume().then(() => {}); + } + document.addEventListener('touchstart', unlock, { once: true, passive: true }); + document.addEventListener('pointerdown', unlock, { once: true, passive: true }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && ctx && ctx.state === 'suspended') { ctx.resume(); @@ -189,7 +222,8 @@ const SFX = (() => { /* Select / menu move */ select() { - tone(440, 'square', 0.12, 0.005, 0.03, 0.04); + const c = getCtx(); + tone(440, 'square', 0.12, 0.005, 0.03, 0.04, c.currentTime + 0.05); }, /* Correct answer — happy ascending chord */ @@ -244,13 +278,11 @@ class Game { screen:'boot', playerName:'', questions:[], currentQ:0, score:0, streak:0, maxStreak:0, correct:0, wrong:0, answering:false, introStep:0, twTimer:null, - /* map — world coords in px, camera follows player */ - worldX: 0, /* player's X in world-px */ - worldY: 0, /* player's Y in world-px (0 = path centre) */ - npcWorldX: 0, /* NPC world-px X */ - npcWorldY: 0, + worldX: 0, worldY: 0, + npcWorldX: 0, npcWorldY: 0, + npcSpawned: false, + pathDir: 'right', lastDir: 'right', - /* battle cursor */ cursor:0, }; @@ -275,8 +307,6 @@ class Game { CONTROLLER OVERLAY — wires D-pad, A, B, SELECT, START ══════════════════════════════════════════════════════════ */ _bindController() { - const ctrl = document.getElementById('gba-controller'); - const dpadMap = { 'dp-up':'up', 'dp-down':'down', 'dp-left':'left', 'dp-right':'right' }; Object.entries(dpadMap).forEach(([id, dir]) => { @@ -354,91 +384,102 @@ class Game { if (this.state.screen === 'map') { const p = document.getElementById('map-player'); if (p) { + const dir = this.state.lastDir; + const idleFrames = { right:'-320px', down:'-32px', left:'-128px', up:'-224px' }; + const frame = idleFrames[dir] || '-32px'; p.className = 'map-player idle'; - /* Show the idle/middle frame for the last direction walked - Frames at 2× scale (32px each): down=32, up=128, left=224, right=320 */ - const idleFrames = { down:'-32px', left:'-128px', up:'-224px', right:'-320px' }; - p.style.backgroundPositionX = idleFrames[this.state.lastDir] || '-32px'; + requestAnimationFrame(() => { + p.style.backgroundPositionX = frame; + }); } } } + _getViewport() { + const w = document.getElementById('map-world'); + return { + viewW: (w && w.offsetWidth) || 420, + viewH: (w && w.offsetHeight) || 300, + }; + } + _walkTick() { if (this.state.screen !== 'map') return; - - const world = document.getElementById('map-inner'); - if (!world) return; - const viewW = world.parentElement.offsetWidth || 420; - const viewH = world.parentElement.offsetHeight || 300; - - const SPEED = 8; /* px per tick */ + const {viewW, viewH} = this._getViewport(); + const SPEED = 8; let dx = 0, dy = 0, dir = ''; - if (this._heldKeys.has('right')) { dx = SPEED; dir = 'right'; } if (this._heldKeys.has('left')) { dx = -SPEED; dir = 'left'; } if (this._heldKeys.has('up')) { dy = -SPEED; dir = 'up'; } if (this._heldKeys.has('down')) { dy = SPEED; dir = 'down'; } if (!dir) return; - /* Clamp world X — left edge is 0, right edge is npcWorldX + half screen - so the player can always walk far enough to reach the NPC */ - const maxWorldX = this.state.npcWorldX - Math.round(viewW / 2) + 100; - this.state.worldX = Math.max(0, Math.min(maxWorldX, this.state.worldX + dx)); - /* Clamp Y within a narrow vertical band (path area) */ - this.state.worldY = Math.max(-30, Math.min(30, this.state.worldY + dy)); + this.state.worldX += dx; + this.state.worldY += dy; this.state.lastDir = dir; this._applyCamera(); - /* Player sprite stays horizontally centred; only Y shifts */ - const p = document.getElementById('map-player'); + const p = document.getElementById('map-player'); + const bub = document.getElementById('player-bubble'); if (p) { - const centreX = viewW / 2 - 16; - const centreY = viewH * 0.65 + this.state.worldY; - const bottomPx = viewH - centreY - 40; - p.style.left = centreX + 'px'; - p.style.bottom = bottomPx + 'px'; + const cx = viewW / 2 - 16; + const bot = viewH * 0.45; + p.style.left = cx + 'px'; + p.style.bottom = bot + 'px'; p.className = `map-player walk-${dir}`; - /* Keep "..." bubble above player */ - const bub = document.getElementById('player-bubble'); - if (bub) { bub.style.left = (centreX - 4) + 'px'; bub.style.bottom = (bottomPx + 66) + 'px'; } + if (bub) { bub.style.left = (cx - 4) + 'px'; bub.style.bottom = (bot + 66) + 'px'; } } - this._checkNPCProximity(); } - /* Slide the world and reposition NPC in screen-space every tick */ _applyCamera() { - const world = document.getElementById('map-inner'); - if (world) world.style.transform = `translateX(${-this.state.worldX}px)`; + const {viewW, viewH} = this._getViewport(); + const spread = Math.max(viewW, viewH) * 5; + const inner = document.getElementById('map-inner'); + const tx = -(spread + this.state.worldX - viewW / 2); + const ty = -(spread + this.state.worldY - viewH / 2); + if (inner) inner.style.transform = `translate(${tx}px,${ty}px)`; this._positionNPCOnScreen(); } _positionNPCOnScreen() { - const world = document.getElementById('map-inner'); - const viewH = world ? world.parentElement.offsetHeight : 300; + const {viewW, viewH} = this._getViewport(); const npcWrap = document.getElementById('map-npc-wrap'); if (npcWrap) { - const screenX = this.state.npcWorldX - this.state.worldX; - npcWrap.style.left = (screenX - 24) + 'px'; - npcWrap.style.bottom = (viewH * 0.28) + 'px'; + const screenX = viewW / 2 + (this.state.npcWorldX - this.state.worldX) - 24; + const screenY = viewH / 2 + (this.state.npcWorldY - this.state.worldY) - 24; + npcWrap.style.left = screenX + 'px'; + npcWrap.style.bottom = (viewH - screenY - 48) + 'px'; } } + _playerScreenOffset() { + const {viewW, viewH} = this._getViewport(); + return { px: viewW / 2, py: viewH - (viewH * 0.45) - 32 }; + } + + _npcScreenDist() { + const {viewW, viewH} = this._getViewport(); + const {px, py} = this._playerScreenOffset(); + const npcSX = viewW / 2 + (this.state.npcWorldX - this.state.worldX); + const npcSY = viewH / 2 + (this.state.npcWorldY - this.state.worldY); + return Math.sqrt((npcSX - px) ** 2 + (npcSY - py) ** 2); + } + _checkNPCProximity() { - const world = document.getElementById('map-inner'); - const viewW = world ? world.parentElement.offsetWidth : 420; - const viewH = world ? world.parentElement.offsetHeight : 300; - const playerWorldX = this.state.worldX + viewW / 2; - const dx = Math.abs(playerWorldX - this.state.npcWorldX); - const near = dx < 60; + const {viewW, viewH} = this._getViewport(); + const dist = this._npcScreenDist(); + const near = this.state.npcSpawned && dist < 100; const hint = document.getElementById('map-talk-hint'); const bub = document.getElementById('npc-bubble'); if (hint) { + const npcSX = viewW / 2 + (this.state.npcWorldX - this.state.worldX); + const npcSY = viewH / 2 + (this.state.npcWorldY - this.state.worldY); hint.style.display = near ? 'block' : 'none'; - hint.style.left = (viewW / 2 - 28) + 'px'; - hint.style.bottom = (viewH * 0.50) + 'px'; + hint.style.left = (npcSX - 28) + 'px'; + hint.style.bottom = (viewH - npcSY + 48) + 'px'; } if (bub) bub.style.opacity = near ? '0' : '1'; this._updateDirArrow(near); @@ -448,25 +489,51 @@ class Game { const arrow = document.getElementById('map-dir-arrow'); if (!arrow) return; if (nearNPC) { arrow.style.display = 'none'; arrow.style.animation = 'none'; return; } - const world = document.getElementById('map-inner'); - const viewH = world ? world.parentElement.offsetHeight : 300; - arrow.textContent = '▶'; - arrow.style.display = 'block'; + const {viewW, viewH} = this._getViewport(); + const dx = this.state.npcWorldX - this.state.worldX; + const dy = this.state.npcWorldY - this.state.worldY; + let pd; + if (Math.abs(dx) >= Math.abs(dy)) { + pd = dx >= 0 ? 'right' : 'left'; + } else { + pd = dy >= 0 ? 'down' : 'up'; + } + + const isLandscape = window.innerWidth > window.innerHeight; + const ctrlH = isLandscape ? 0 : Math.round(Math.min(148, Math.max(110, window.innerWidth * 0.22))); + const safeBottom = ctrlH + 12; + const midY = (viewH - ctrlH) / 2 - 16; + + const glyphs = { right:'▶', left:'◀', up:'▲', down:'▼' }; + arrow.textContent = glyphs[pd]; + arrow.style.display = 'block'; arrow.style.animation = 'arrowPulse .7s ease-in-out infinite'; - arrow.style.removeProperty('left'); - arrow.style.bottom = (viewH * 0.28) + 'px'; + if (pd === 'right') { + arrow.style.removeProperty('left'); + arrow.style.right = '12px'; + arrow.style.bottom = midY + 'px'; + } else if (pd === 'left') { + arrow.style.removeProperty('right'); + arrow.style.left = '12px'; + arrow.style.bottom = midY + 'px'; + } else if (pd === 'up') { + arrow.style.removeProperty('right'); + arrow.style.left = (viewW / 2 - 16) + 'px'; + arrow.style.bottom = (viewH - ctrlH - 48) + 'px'; + } else { + arrow.style.removeProperty('right'); + arrow.style.left = (viewW / 2 - 16) + 'px'; + arrow.style.bottom = safeBottom + 'px'; + } } _nearNPC() { - const world = document.getElementById('map-inner'); - const viewW = world ? world.parentElement.offsetWidth : 420; - const playerWorldX = this.state.worldX + viewW / 2; - return Math.abs(playerWorldX - this.state.npcWorldX) < 60; + if (!this.state.npcSpawned) return false; + return this._npcScreenDist() < 100; } _talkToNPC() { if (!this._nearNPC()) { - /* Too far away — show "..." bubble above player */ const bub = document.getElementById('player-bubble'); if (bub) { bub.classList.remove('visible'); @@ -478,28 +545,26 @@ class Game { return; } const arrow = document.getElementById('map-dir-arrow'); - if (arrow) arrow.style.display = 'none'; + if (arrow) { arrow.style.display = 'none'; arrow.style.animation = 'none'; } const bub = document.getElementById('player-bubble'); if (bub) bub.classList.remove('visible'); SFX.encounter(); this.startQuestion(); } - /* Place player at viewport centre, NPC via screen-space conversion */ _placeMapSprites() { - const world = document.getElementById('map-inner'); - const viewW = world ? world.parentElement.offsetWidth : 420; - const viewH = world ? world.parentElement.offsetHeight : 300; - - const p = document.getElementById('map-player'); + const {viewW, viewH} = this._getViewport(); + const idleFrames = { right:'-320px', down:'-32px', left:'-128px', up:'-224px' }; + const p = document.getElementById('map-player'); + const bub = document.getElementById('player-bubble'); if (p) { - const left = viewW / 2 - 16; - const bottom = viewH * 0.28; - p.style.left = left + 'px'; - p.style.bottom = bottom + 'px'; - p.className = 'map-player idle'; - const bub = document.getElementById('player-bubble'); - if (bub) { bub.style.left = (left - 4) + 'px'; bub.style.bottom = (bottom + 66) + 'px'; } + const cx = viewW / 2 - 16; + const bot = viewH * 0.45; + p.style.left = cx + 'px'; + p.style.bottom = bot + 'px'; + p.className = 'map-player idle'; + p.style.backgroundPositionX = idleFrames[this.state.lastDir] || '-32px'; + if (bub) { bub.style.left = (cx - 4) + 'px'; bub.style.bottom = (bot + 66) + 'px'; } } this._applyCamera(); const hint = document.getElementById('map-talk-hint'); @@ -510,14 +575,16 @@ class Game { BATTLE CURSOR — navigate 2×2 grid with D-pad / WASD ══════════════════════════════════════════════════════════ */ _moveCursor(dir) { - const btns = document.querySelectorAll('.choice-btn:not(.disabled):not(.correct):not(.wrong)'); + const btns = document.querySelectorAll('.choice-btn:not(.disabled):not(.correct):not(.wrong):not(.confirm-inactive)'); if (!btns.length) return; + const total = btns.length; let c = this.state.cursor; + const hasConfirm = total === 5; if (dir === 'right') c = (c === 0) ? 1 : (c === 2) ? 3 : c; - else if (dir === 'left') c = (c === 1) ? 0 : (c === 3) ? 2 : c; - else if (dir === 'down') c = (c === 0) ? 2 : (c === 1) ? 3 : c; - else if (dir === 'up') c = (c === 2) ? 0 : (c === 3) ? 1 : c; - c = Math.min(c, btns.length - 1); + else if (dir === 'left') c = (c === 1) ? 0 : (c === 3) ? 2 : (c === 4) ? 4 : c; + else if (dir === 'down') c = (c === 0) ? 2 : (c === 1) ? 3 : (hasConfirm && (c === 2 || c === 3)) ? 4 : c; + else if (dir === 'up') c = (c === 2) ? 0 : (c === 3) ? 1 : (c === 4) ? 2 : c; + c = Math.min(c, total - 1); this.state.cursor = c; this._renderCursor(); SFX.select(); @@ -531,7 +598,7 @@ class Game { _confirmCursor() { if (this.state.answering) return; - const btns = document.querySelectorAll('.choice-btn:not(.disabled)'); + const btns = document.querySelectorAll('.choice-btn:not(.disabled):not(.correct):not(.wrong):not(.confirm-inactive)'); const target = btns[this.state.cursor]; if (target) target.click(); } @@ -802,10 +869,12 @@ class Game { } } _showController(visible) { - const ctrl = document.getElementById('gba-controller'); - if (!ctrl) return; - if (visible) ctrl.classList.remove('hidden'); - else ctrl.classList.add('hidden'); + ['gba-left', 'gba-right'].forEach(id => { + const el = document.getElementById(id); + if (!el) return; + if (visible) el.classList.remove('hidden'); + else el.classList.add('hidden'); + }); } /* ══════════════════════════════════════════════════════════ @@ -861,7 +930,7 @@ class Game { playerName:save.playerName, currentQ:save.currentQ, score:save.score, streak:save.streak, maxStreak:save.maxStreak, correct:save.correct, wrong:save.wrong, - worldX:0, worldY:0, npcWorldX:0, npcWorldY:0, + worldX:0, worldY:0, npcWorldX:0, npcWorldY:0, npcSpawned:false, }); this.loadQuestions(() => { if (save.questionOrder && save.questionOrder.length === this.state.questions.length) { @@ -886,7 +955,7 @@ class Game { this._showController(false); this._gpKeyCursor = 0; this.buildKeyboard(); - this.typeText('name-prompt-text', "Hello there! Welcome to PokéSQL! My name is Professor Oak — the SQL Pokémon Professor. Now tell me, what is your name?"); + this.typeText('name-prompt-text', CONFIG.namePrompt); document.getElementById('btn-backspace').onclick = () => this.delChar(); document.getElementById('btn-confirm-name').onclick = () => this.confirmName(); } @@ -976,34 +1045,35 @@ class Game { /* Generate randomly placed trees across the full scrollable world */ /* Generate trees for the current journey leg — from world origin to just past the NPC. Called fresh each time so trees always fill the visible path ahead. */ + _pickNextDir(currentDir) { + const opposite = { right:'left', left:'right', up:'down', down:'up' }; + const opp = opposite[currentDir]; + const choices = ['right','left','up','down',currentDir,currentDir].filter(d => d !== opp); + return choices[Math.floor(Math.random() * choices.length)]; + } + _generateTrees() { const container = document.getElementById('map-trees'); if (!container) return; - - /* Clear previous leg's trees */ container.innerHTML = ''; - - const world = document.getElementById('map-inner'); - const viewW = world ? world.parentElement.offsetWidth : 420; - const viewH = world ? world.parentElement.offsetHeight : 300; - - /* World spans from 0 to just past the NPC */ - const worldW = this.state.npcWorldX + viewW; - if (world) world.style.width = worldW + 'px'; - - const treeTopMax = viewH * 0.50; - const treeBottomMin = viewH * 0.04; - - /* One tree roughly every 120px across the leg */ - const COUNT = Math.max(6, Math.round(worldW / 120)); - const zoneW = worldW / COUNT; - - for (let i = 0; i < COUNT; i++) { + const {viewW, viewH} = this._getViewport(); + const spread = Math.max(viewW, viewH) * 5; + const inner = document.getElementById('map-inner'); + if (inner) { inner.style.width = spread * 2 + 'px'; inner.style.height = spread * 2 + 'px'; } + const cx = spread; + const cy = spread; + const npcX = cx + this.state.npcWorldX; + const npcY = cy + this.state.npcWorldY; + for (let i = 0; i < 300; i++) { const span = document.createElement('span'); span.className = 'tree'; - const size = 28 + Math.random() * 16; - const x = i * zoneW + Math.random() * zoneW * 0.75; - const y = treeBottomMin + Math.random() * (treeTopMax - treeBottomMin - size); + const size = 48 + Math.random() * 24; + let x, y, tries = 0; + do { + x = cx + (Math.random() - 0.5) * spread * 1.8; + y = cy + (Math.random() - 0.5) * spread * 1.8; + tries++; + } while (tries < 10 && Math.abs(x - npcX) < 80 && Math.abs(y - npcY) < 80); span.textContent = '🌳'; span.style.fontSize = size + 'px'; span.style.left = x + 'px'; @@ -1014,40 +1084,48 @@ class Game { } } - /* Called on result screen — pre-builds trees for the next leg */ _preGenerateNextLeg() { - const world = document.getElementById('map-inner'); - const viewW = world ? world.parentElement.offsetWidth : 420; - const nextNpcX = Math.round(viewW * 3.0 + Math.random() * viewW * 0.8); - const savedNpcX = this.state.npcWorldX; - this.state.npcWorldX = nextNpcX; - this._generateTrees(); - this.state.npcWorldX = savedNpcX; + this._nextLegDir = this._pickNextDir(this.state.pathDir); + this._nextLegLen = null; + } + + _npcOffsetForDir(dir, viewW, viewH) { + const dist = dir === 'right' || dir === 'left' + ? Math.round(viewW * (3.0 + Math.random() * 0.8)) + : Math.round(viewH * (3.0 + Math.random() * 0.8)); + return { + nx: dir === 'right' ? dist : dir === 'left' ? -dist : 0, + ny: dir === 'down' ? dist : dir === 'up' ? -dist : 0, + dir, + }; } showMap() { this.show('map'); this._showController(true); - /* We need the viewport size — defer a tick so the DOM is laid out */ requestAnimationFrame(() => { - const world = document.getElementById('map-inner'); - const viewW = world ? world.parentElement.offsetWidth : 420; + const {viewW, viewH} = this._getViewport(); if (this.state.currentQ === 0) { - this.state.worldX = 0; - this.state.worldY = 0; - this.state.npcWorldX = Math.round(viewW * 3.5); - this.state.npcWorldY = 0; + this.state.pathDir = 'right'; + } else if (this._nextLegDir) { + this.state.pathDir = this._nextLegDir; + this._nextLegDir = null; + this._nextLegLen = null; } else { - /* Always reset camera to 0 each question — keeps trees in view. - NPC spawns off-screen right relative to fresh origin. */ - this.state.worldX = 0; - this.state.worldY = 0; - this.state.npcWorldX = Math.round(viewW * 3.0 + Math.random() * viewW * 0.8); - this.state.npcWorldY = 0; + this.state.pathDir = this._pickNextDir(this.state.pathDir); } + this.state.worldX = 0; + this.state.worldY = 0; + this.state.lastDir = this.state.pathDir; + + const {nx, ny} = this._npcOffsetForDir(this.state.pathDir, viewW, viewH); + this.state.npcWorldX = nx; + this.state.npcWorldY = ny; + this.state.npcSpawned = true; + this._generateTrees(); document.getElementById('hud-name').textContent = this.state.playerName||'ASH'; @@ -1058,7 +1136,9 @@ class Game { if (q && npcEl) npcEl.textContent = NPC[q.npc]||'🧑'; this._placeMapSprites(); + this._checkNPCProximity(); this._updateDirArrow(false); + Music.stopBattle(); Music.play(); saveGame(this.state); this.flashSaveDot(); @@ -1071,6 +1151,7 @@ class Game { }); } + updateHUD() { const tot=this.state.questions.length||100, done=this.state.currentQ, pct=(done/tot)*100; document.getElementById('hud-score').textContent = this.state.score.toLocaleString(); @@ -1096,7 +1177,7 @@ class Game { this.show('battle'); this._showController(true); - Music.pause(); + Music.playBattle(); this.state.answering=false; this.state.cursor=0; @@ -1117,53 +1198,78 @@ class Game { container.innerHTML=''; this.state.cursor=0; - /* Shuffle options so the correct answer lands in a random slot each time. - We track which shuffled index holds the correct answer. */ + const isMulti = Array.isArray(q.answers); + const indices = [0,1,2,3]; for (let i = indices.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [indices[i], indices[j]] = [indices[j], indices[i]]; } - const correctShuffledIdx = indices.indexOf(q.answer); - /* Build a map from original option letter (A/B/C/D) to shuffled slot number (1/2/3/4) - so any option text like "Both A and B" gets rewritten to e.g. "Both 2 and 4" */ - const letterToSlot = {}; - indices.forEach((origIdx, slotIdx) => { - const letter = ['A','B','C','D'][origIdx]; - letterToSlot[letter] = slotIdx + 1; - }); + const correctShuffledIdx = isMulti ? null : indices.indexOf(q.answer); + const correctShuffledSet = isMulti ? new Set(q.answers.map(a => indices.indexOf(a))) : null; - function rewriteLetterRefs(text) { - /* Only rewrite letters that appear in explicit option-reference patterns: - "Both A and B", "A and B", "Both B and C", etc. - Never replace standalone A/B/C/D in regular sentence text. */ - return text.replace(/\bBoth ([ABCD]) and ([ABCD])\b/gi, (_, l1, l2) => { - const n1 = letterToSlot[l1.toUpperCase()]; - const n2 = letterToSlot[l2.toUpperCase()]; - return `Both ${n1 ?? l1} and ${n2 ?? l2}`; - }).replace(/\b([ABCD]) and ([ABCD])\b/g, (_, l1, l2) => { - const n1 = letterToSlot[l1]; - const n2 = letterToSlot[l2]; - return `${n1 ?? l1} and ${n2 ?? l2}`; - }); + q._correctShuffledSet = correctShuffledSet; + q._indices = indices; + + if (isMulti) { + q._correctDisplayText = q.answers.map(a => q.options[a]).join(' AND '); + } else { + q._correctDisplayText = q.options[q.answer]; } - /* Store display text of correct answer with letter refs rewritten */ - q._correctDisplayText = rewriteLetterRefs(q.options[q.answer]); + const selectedSlots = new Set(); + let confirmBtn = null; + + if (isMulti) { + confirmBtn = document.createElement('button'); + confirmBtn.className='choice-btn confirm-btn confirm-inactive'; + confirmBtn.textContent='✔ CONFIRM'; + confirmBtn.addEventListener('click', () => { + if (this.state.answering || selectedSlots.size !== 2) return; + this.pickMulti(selectedSlots, correctShuffledSet, q, container); + }); + } indices.forEach((origIdx, slotIdx) => { const btn=document.createElement('button'); btn.className='choice-btn'; btn.setAttribute('data-letter', slotIdx + 1); - btn.textContent=rewriteLetterRefs(q.options[origIdx]); - btn.addEventListener('click',()=>this.pick(slotIdx, correctShuffledIdx, q, container)); + btn.textContent=q.options[origIdx]; + + if (isMulti) { + btn.addEventListener('click', () => { + if (this.state.answering) return; + if (btn.classList.contains('selected')) { + btn.classList.remove('selected'); + selectedSlots.delete(slotIdx); + } else { + if (selectedSlots.size >= 2) return; + btn.classList.add('selected'); + selectedSlots.add(slotIdx); + } + confirmBtn.classList.toggle('confirm-inactive', selectedSlots.size !== 2); + }); + } else { + btn.addEventListener('click',()=>this.pick(slotIdx, correctShuffledIdx, q, container)); + } btn.addEventListener('mouseenter', () => { this.state.cursor = slotIdx; this._renderCursor(); }); container.appendChild(btn); }); + + if (isMulti) { + confirmBtn.addEventListener('mouseenter', () => { + if (confirmBtn.classList.contains('confirm-inactive')) return; + this.state.cursor = 4; + this._renderCursor(); + }); + container.appendChild(confirmBtn); + container._confirmBtn = confirmBtn; + } + this._renderCursor(); } @@ -1186,7 +1292,36 @@ class Game { } else { this.state.streak=0; this.state.wrong++; SFX.wrong(); } setTimeout(()=>{ - /* Pre-generate trees for the next map leg while player reads result */ + this._preGenerateNextLeg(); + this.showResult(correct,q); + },120); + } + + pickMulti(selectedSlots, correctSet, q, container) { + if(this.state.answering)return; + this.state.answering=true; + + const btns=Array.from(container.querySelectorAll('.choice-btn:not(.confirm-btn)')); + const correct = selectedSlots.size === correctSet.size && + [...selectedSlots].every(s => correctSet.has(s)); + + btns.forEach((b,i)=>{ + b.classList.remove('cursor','selected'); + b.classList.add('disabled'); + if(correctSet.has(i)) b.classList.add('correct'); + else if(selectedSlots.has(i) && !correctSet.has(i)) b.classList.add('wrong'); + }); + const cb = container.querySelector('.confirm-btn'); + if(cb) cb.style.display='none'; + + if(correct){ + this.state.streak++; this.state.correct++; + this.state.maxStreak=Math.max(this.state.maxStreak,this.state.streak); + this.state.score+=this.state.streak>=5?200:this.state.streak>=3?150:100; + if(this.state.streak>=3) SFX.streak(); else SFX.correct(); + } else { this.state.streak=0; this.state.wrong++; SFX.wrong(); } + + setTimeout(()=>{ this._preGenerateNextLeg(); this.showResult(correct,q); },120); @@ -1196,6 +1331,8 @@ class Game { showResult(correct,q) { this.show('result'); this._showController(false); + Music.stopBattle(); + Music.pause(); document.getElementById('result-icon').textContent = correct?'✓':'✗'; const lbl=document.getElementById('result-label'); @@ -1210,18 +1347,23 @@ class Game { ptsEl.style.display='block'; ansEl.classList.remove('visible'); } else { ptsEl.style.display='none'; - ansEl.textContent='✓ Correct answer: '+(q._correctDisplayText||q.options[q.answer]); + const correctText = q._correctDisplayText || (q.answers ? q.answers.map(a=>q.options[a]).join(' AND ') : q.options[q.answer]); + ansEl.textContent='✓ Correct answer'+(q.answers?'s':'')+': '+correctText; ansEl.classList.add('visible'); } document.getElementById('result-explanation').textContent=q.explanation; - document.getElementById('btn-result-cont').onclick=()=>{ + const contBtn = document.getElementById('btn-result-cont'); + const doNext = () => { this.state.currentQ++; saveGame(this.state); if(this.state.currentQ>=this.state.questions.length){ clearSave(); this.showComplete(); } else if(MILESTONES[this.state.currentQ]) this.showLevelUp(); else this.showMap(); }; + contBtn.onclick = doNext; + const resultScreen = document.getElementById('screen-result'); + resultScreen.addEventListener('pointerdown', doNext, { once: true }); } /* ── LEVEL UP ─────────────────────────────────────────── */ diff --git a/index.html b/index.html index 8f0362d..11e9a2f 100644 --- a/index.html +++ b/index.html @@ -4,10 +4,11 @@ + -PokéSQL — Learn T-SQL - +PokéSQL — Lean SQL + @@ -30,10 +31,10 @@ -
★ SQL TRAINER EDITION ★
- +
★ SQL TRAINER ★
+ - + @@ -89,11 +90,13 @@
📍 - PALLET TOWN + SQL TOWN
-
+
0/100 +
+
🎵
@@ -102,8 +105,7 @@
-
- +
!
@@ -166,7 +168,7 @@
ASH gained SQL EXP!
-
🏅 SQL TRAINEE
+
🏅 CLOUD TRAINEE
25 / 100
@@ -182,48 +184,43 @@
🏆
CHAMPION!
ASH
-

You've mastered Microsoft T-SQL fundamentals! You're ready to write real queries, assist DBAs, and analyse data like a pro.

+

You've conquered SQL Town and mastered Microsoft T-SQL! Your SQL journey is just beginning — more towns and exams await!

-
🥉 SQL INITIATE — Q25
-
🥈 QUERY TRAINER — Q50
-
🥇 DATA WRANGLER — Q75
+
🥉 SQL EXPLORER — Q25
+
🥈 SQL APPRENTICE — Q50
+
🥇 SQL PRACTITIONER — Q75
🏆 SQL CHAMPION — Q100
+
🗺️ More towns & exams coming soon...
+ + + + - - - diff --git a/style.css b/style.css index 30b5383..27a1619 100644 --- a/style.css +++ b/style.css @@ -180,6 +180,7 @@ html, body { repeating-linear-gradient(0deg, transparent 0,transparent 31px, rgba(0,80,0,.12) 31px, rgba(0,80,0,.12) 32px), repeating-linear-gradient(90deg, transparent 0,transparent 31px, rgba(0,80,0,.12) 31px, rgba(0,80,0,.12) 32px), linear-gradient(180deg, #6cb84e 0%, #58a83e 100%); + background-position: 16px 16px, 16px 16px, 0 0; } /* ═══════════════════════════════════════════════════════════ @@ -188,19 +189,11 @@ html, body { Shown only when screen === 'map' or 'battle'. ═══════════════════════════════════════════════════════════ */ .gba-controller { - position: fixed; - bottom: 0; - left: 0; - right: 0; - height: clamp(110px, 22vw, 148px); display: flex; align-items: center; - justify-content: space-between; - padding: 0 clamp(8px,3vw,20px) max(8px, var(--safe-bottom)); background: linear-gradient(180deg, rgba(26,10,62,.82) 0%, rgba(14,6,36,.95) 100%); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); - border-top: 2px solid rgba(120,80,255,.35); z-index: 800; user-select: none; touch-action: none; @@ -208,9 +201,40 @@ html, body { } .gba-controller.hidden { display: none; } -/* Only show controller on touch / small screens by default. - On desktop we rely on keyboard — but controller still shows - if screen is narrow enough. JS toggles it per screen anyway. */ +/* ── PORTRAIT: two panels fixed at bottom ── */ +@media (orientation: portrait) { + #gba-left, #gba-right { + position: fixed; + bottom: 0; + height: clamp(110px, 22vw, 148px); + flex-direction: row; + border-top: 2px solid rgba(120,80,255,.35); + } + + #gba-left { + left: 0; + width: 65%; + justify-content: space-evenly; + align-items: center; + padding: 0 8px max(8px,var(--safe-bottom)) clamp(8px,3vw,20px); + } + + #gba-right { + right: 0; + width: 35%; + justify-content: center; + padding: 0 clamp(8px,3vw,20px) max(8px,var(--safe-bottom)) 8px; + } + + #gba-left .gba-mid-btns { + flex-direction: row; + gap: 6px; + margin-top: 0; + width: auto; + justify-content: center; + } +} + @media (min-width: 900px) { .gba-controller { display: none !important; } } @@ -257,6 +281,8 @@ html, body { gap: 8px; align-items: center; justify-content: center; + margin-top: 12px; + width: 100%; } .gba-mid-btn { background: #2a1a5e; @@ -271,6 +297,7 @@ html, body { -webkit-tap-highlight-color: transparent; transition: background .08s; min-width: 52px; + width: 52px; text-align: center; } .gba-mid-btn:active { background: #3d2f99; } @@ -348,8 +375,8 @@ html, body { } .title-main { text-align:center; z-index:1; padding:24px 16px; } .title-logo { margin-bottom:16px; line-height:1; } -.title-poke { font-size:clamp(30px,11vw,50px); color:var(--yellow); text-shadow:4px 4px 0 #7a5800,0 0 24px rgba(248,192,48,.5); animation:goldShimmer 3s ease-in-out infinite; display:inline-block; } -.title-sql { font-size:clamp(30px,11vw,50px); color:#60c8ff; text-shadow:4px 4px 0 #003d70,0 0 24px rgba(96,200,255,.5); display:inline-block; animation:blueShimmer 3s ease-in-out infinite; animation-delay:.4s; } +.title-poke { font-size:clamp(22px,7.5vw,38px); color:var(--yellow); text-shadow:4px 4px 0 #7a5800,0 0 24px rgba(248,192,48,.5); animation:goldShimmer 3s ease-in-out infinite; display:inline-block; } +.title-sql { font-size:clamp(22px,7.5vw,38px); color:#60c8ff; text-shadow:4px 4px 0 #003d70,0 0 24px rgba(96,200,255,.5); display:inline-block; animation:blueShimmer 3s ease-in-out infinite; } .title-sub { font-size:clamp(6px,1.8vw,8px); color:rgba(255,255,255,.75); letter-spacing:1px; margin:10px 0 6px; } .title-edition { font-size:clamp(5.5px,1.6vw,7px); color:var(--yellow); margin-bottom:24px; animation:goldShimmer 3s ease-in-out infinite; } .title-deco { font-size:clamp(22px,6vw,32px); margin-bottom:28px; display:flex; gap:16px; justify-content:center; } @@ -406,10 +433,11 @@ html, body { /* ─── MAP ────────────────────────────────────────────────── */ .map-header { background:var(--dark-bg); padding:8px 12px; display:flex; align-items:center; justify-content:space-between; gap:8px; border-bottom:3px solid var(--black); flex-shrink:0; } -.map-left { display:flex; align-items:center; gap:6px; } +.map-left { display:flex; align-items:center; gap:6px; flex:1; } +.map-center { display:flex; align-items:center; gap:6px; flex:1; justify-content:center; } +.map-right { display:flex; align-items:center; gap:6px; flex:1; justify-content:flex-end; } .map-pin { font-size:12px; } -.map-town-name { font-size:clamp(5.5px,1.6vw,7px); color:var(--yellow); letter-spacing:.5px; } -.map-right { display:flex; align-items:center; gap:6px; } +.map-town-name { font-size:clamp(5.5px,1.6vw,7px); color:var(--yellow); letter-spacing:.5px; word-spacing:-1px; } .prog-bar-outer { width:80px; height:9px; background:var(--black); border:2px solid var(--lightgray); overflow:hidden; } .prog-bar-inner { height:100%; width:0%; background:var(--hp-green); transition:width .5s ease; } .prog-text { font-size:6px; color:var(--white); } @@ -434,8 +462,9 @@ html, body { position: absolute; top: 0; bottom: 0; left: 0; width: 400%; + height: 400%; will-change: transform; - z-index: 5; + z-index: 25; } .map-trees { position: absolute; @@ -445,27 +474,11 @@ html, body { .tree { position: absolute; display: inline-block; - animation: treeSway 3s ease-in-out infinite; filter: drop-shadow(1px 2px 0 rgba(0,0,0,.3)); line-height: 1; + z-index: 60; } -.tree:nth-child(even) { animation-direction: reverse; } -.map-ground { - position: absolute; - bottom: 0; left: 0; right: 0; - height: 48%; - background: - linear-gradient(180deg, - transparent 0%, transparent 54%, - #3d8a28 54%, #4a9e30 58%, #3d8a28 62%, #4aaa32 66%, #3d8a28 100% - ), - linear-gradient(180deg, - #4a9e30 0%, #3d8a28 8%, #2d6e1a 10%, - #c8aa6a 10%, #b89858 30%, #c0a060 50%, #b89858 70%, #a88848 88%, - #2d6e1a 90%, #3d8a28 92%, #4a9e30 100% - ); - border-top: 3px solid #2d6e1a; -} +.tree:nth-child(even) { } /* Directional nudge arrow */ .map-dir-arrow { position: absolute; @@ -475,14 +488,13 @@ html, body { pointer-events: none; z-index: 50; filter: drop-shadow(0 0 4px rgba(248,192,48,.6)); - right: 12px; display: none; - bottom: -9999px; /* ensure off-screen when hidden */ + bottom: -9999px; } /* NPC lives inside .map-inner, positioned in world-px via JS */ -.map-npc-wrap { position:absolute; text-align:center; z-index: 10; } +.map-npc-wrap { position:absolute; text-align:center; z-index: 30; } .npc-bubble { font-size:14px; font-family:var(--font); color:var(--black); background:var(--yellow); border:2px solid var(--black); width:20px; height:20px; display:flex; align-items:center; justify-content:center; margin:0 auto 2px; animation:bounce .7s ease-in-out infinite; } -.map-npc { font-size:clamp(36px,10vw,52px); filter:drop-shadow(2px 3px 0 rgba(0,0,0,.35)); cursor:pointer; } +.map-npc { font-size:clamp(36px,10vw,52px); filter:drop-shadow(2px 3px 0 rgba(0,0,0,.35)); cursor:pointer; animation:npcFloat 2s ease-in-out infinite; } /* Player is fixed to the viewport centre horizontally; only Y moves */ /* "..." bubble above player when pressing talk away from NPC */ .player-bubble { @@ -501,6 +513,7 @@ html, body { position: absolute; z-index: 100; box-shadow: 1px 1px 0 rgba(0,0,0,.3); + left: -9999px; } .player-bubble::after { content: ''; @@ -523,18 +536,18 @@ html, body { background-image: url('./player.png'); background-repeat: no-repeat; background-size: 768px 64px; - background-position: 0 0; image-rendering: pixelated; filter: drop-shadow(2px 3px 0 rgba(0,0,0,.4)); - z-index: 10; + z-index: 20; font-size: 0; color: transparent; + left: -9999px; } .map-player.walk-down { animation: walkDown .32s steps(3) infinite; } .map-player.walk-up { animation: walkUp .32s steps(3) infinite; } .map-player.walk-left { animation: walkLeft .32s steps(3) infinite; } .map-player.walk-right { animation: walkRight .32s steps(3) infinite; } -.map-player.idle { animation: none; background-position-x: 0px; } +.map-player.idle { animation: none; } /* Talk hint that floats above player when near NPC */ .map-talk-hint { @@ -546,7 +559,7 @@ html, body { border: 2px solid var(--black); padding: 3px 6px; white-space: nowrap; - z-index: 11; + z-index: 50; animation: talkBubble .6s ease-in-out infinite; pointer-events: none; } @@ -618,17 +631,24 @@ html, body { .choice-btn:active { transform:translate(2px,2px); box-shadow:none; } /* D-pad / keyboard cursor highlight */ -.choice-btn.cursor { animation:choiceCursor .7s ease-in-out infinite; border-color:var(--yellow) !important; background:#fffae0 !important; } +.choice-btn.cursor:not(.confirm-btn) { animation:choiceCursor .7s ease-in-out infinite; border-color:var(--yellow) !important; background:#fffae0 !important; } +.choice-btn.confirm-btn.cursor { border-color:var(--yellow) !important; box-shadow:0 0 0 3px var(--yellow); } .choice-btn.correct { background:#c8ffc8; border-color:var(--green); animation:correctPulse .5s ease 3; pointer-events:none; } .choice-btn.wrong { background:#ffc8c8; border-color:var(--red); animation:wrongShake .35s ease; pointer-events:none; } .choice-btn.disabled{ opacity:.5; pointer-events:none; } +.choice-btn.selected{ background:#c0e0ff; border-color:var(--blue); border-width:4px; box-shadow:3px 3px 0 var(--blue); color:var(--black); } +.choice-btn.selected::after { content:' ✔'; color:var(--blue); font-size:1.6em; vertical-align:middle; } +.choice-btn.confirm-btn { background:var(--blue); color:#fff; border-color:#003080; text-align:center; grid-column: 1 / -1; } +.choice-btn.confirm-btn:hover:not(:disabled) { background:#0050d0; } +.choice-btn.confirm-inactive { opacity:.4; pointer-events:none; } +.choice-btn.confirm-btn::before { content:''; } .battle-footer { background:var(--dark-bg); border-top:3px solid var(--black); padding:clamp(4px,1.2vw,6px) 12px max(4px,var(--safe-bottom)); display:flex; justify-content:space-between; align-items:center; font-size:clamp(5.5px,1.5vw,7px); color:var(--yellow); flex-shrink:0; } .kbd-hint { font-size:5px; color:rgba(255,255,255,.3); } @media (max-width:480px) { .kbd-hint { display:none; } } /* ─── RESULT ─────────────────────────────────────────────── */ -#screen-result { background:rgba(0,0,0,.86); align-items:center; justify-content:center; } +#screen-result { background:var(--dark-bg); align-items:center; justify-content:center; } .result-box { background:var(--dialog-bg); border:4px solid var(--dialog-bd); padding:clamp(16px,4.5vw,26px) clamp(12px,3.5vw,20px); width:clamp(270px,88vw,390px); text-align:center; box-shadow:6px 6px 0 rgba(0,0,0,.5); animation:pop .4s cubic-bezier(.175,.885,.32,1.275); display:flex; flex-direction:column; gap:9px; } .result-icon { font-size:clamp(26px,8vw,40px); } .result-label { font-size:clamp(11px,3.5vw,15px); } @@ -678,3 +698,54 @@ html, body { /* ─── TOAST ──────────────────────────────────────────────── */ .toast { position:absolute; bottom:max(20px,var(--safe-bottom)); left:50%; transform:translateX(-50%); background:var(--dark-bg); border:3px solid var(--yellow); color:var(--yellow); font-family:var(--font); font-size:clamp(6.5px,1.8vw,8px); padding:10px 18px; z-index:950; animation:slideUp .3s ease; white-space:nowrap; max-width:90%; overflow:hidden; text-overflow:ellipsis; } .hidden { display:none !important; } + +/* ─── LANDSCAPE CONTROLLER LAYOUT ───────────────────────── */ +@media (orientation: landscape) and (max-height: 500px) { + html, body { align-items: flex-start; justify-content: flex-start; } + + #app { + width: 100vw; height: 100dvh; + border: none; border-radius: 0; box-shadow: none; + display: flex; flex-direction: row; align-items: stretch; + } + + .screen { position: relative; flex: 1; inset: auto; height: 100dvh; overflow: hidden; min-width: 0; } + + #gba-left, #gba-right { + position: relative !important; + bottom: auto !important; left: auto !important; right: auto !important; + height: 100dvh; width: clamp(150px, 22vw, 180px); + flex-direction: column; + justify-content: center; + align-items: center; + border-top: none; + gap: 8px; + flex-shrink: 0; + background: linear-gradient(180deg, rgba(26,10,62,.82) 0%, rgba(14,6,36,.95) 100%); + backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); + } + + #gba-left { + order: -1; + border-right: 2px solid rgba(120,80,255,.35); + border-left: none; border-top: none; + padding: 8px 8px 8px max(8px, env(safe-area-inset-left, 0px)); + } + #gba-right { + order: 1; + border-left: 2px solid rgba(120,80,255,.35); + border-right: none; border-top: none; + padding: 8px max(8px, env(safe-area-inset-right, 0px)) 8px 8px; + } + + .gba-controller.hidden { display: none; } + + .dpad { width: clamp(90px, 20vh, 120px); height: clamp(90px, 20vh, 120px); } + .gba-mid-btns { margin-top: 16px; } + .ab-cluster { position: relative; width: clamp(90px, 21vw, 120px); height: clamp(90px, 21vw, 120px); flex: none; } + #gba-right .ab-btn { position: absolute; } + #gba-right .ab-b { bottom: 14%; left: 5%; top: auto; transform: none; } + #gba-right .ab-a { top: 14%; right: 5%; transform: none; } + .gba-mid-btns { flex-direction: column; gap: 5px; } + .gba-mid-btn { min-width: 44px; width: 44px; font-size: 5px; padding: 6px 4px; } +}