diff --git a/game.js b/game.js index 485c62b..6436bc6 100644 --- a/game.js +++ b/game.js @@ -1,21 +1,71 @@ -/* ─── NPC ROSTER ─────────────────────────────────────────── */ const CONFIG = { gameName: 'PokéSQL', subject: 'SQL', examName: 'SQL', townName: 'SQL Town', - saveKey: 'pokesql_save_v1', + saveKey: 'pokesql_save_v2', 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!", + "But first... every trainer needs a partner Pokémon! I have three special Pokémon waiting for you in my lab!", "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!", + "Your progress is saved automatically in your browser so you can pick up right where you left off. Now, {name}... let's choose your partner!", ], namePrompt: "Hello there! Welcome to PokéSQL! My name is Professor Oak — the SQL Professor. Now tell me, what is your name?", }; +const ALL_POKEMON = [ + { name:'Charmander', emoji:'🔥', type:'Fire' }, + { name:'Squirtle', emoji:'💧', type:'Water' }, + { name:'Bulbasaur', emoji:'🌿', type:'Grass' }, + { name:'Pikachu', emoji:'⚡', type:'Electric' }, + { name:'Eevee', emoji:'🦊', type:'Normal' }, + { name:'Jigglypuff', emoji:'🎀', type:'Fairy' }, + { name:'Gengar', emoji:'👻', type:'Ghost' }, + { name:'Snorlax', emoji:'😴', type:'Normal' }, + { name:'Machamp', emoji:'💪', type:'Fighting' }, + { name:'Alakazam', emoji:'🔮', type:'Psychic' }, + { name:'Gyarados', emoji:'🐉', type:'Dragon' }, + { name:'Lapras', emoji:'🐋', type:'Water' }, + { name:'Dragonite', emoji:'🐲', type:'Dragon' }, + { name:'Mewtwo', emoji:'🧬', type:'Psychic' }, + { name:'Arcanine', emoji:'🐕', type:'Fire' }, + { name:'Rapidash', emoji:'🦄', type:'Fire' }, + { name:'Geodude', emoji:'🪨', type:'Rock' }, + { name:'Onix', emoji:'🐍', type:'Rock' }, + { name:'Haunter', emoji:'😈', type:'Ghost' }, + { name:'Abra', emoji:'🧘', type:'Psychic' }, + { name:'Pidgeot', emoji:'🦅', type:'Flying' }, + { name:'Butterfree', emoji:'🦋', type:'Bug' }, + { name:'Sandslash', emoji:'🦔', type:'Ground' }, + { name:'Ninetales', emoji:'🦊', type:'Fire' }, + { name:'Tentacruel', emoji:'🐙', type:'Water' }, + { name:'Magnemite', emoji:'🧲', type:'Electric' }, + { name:'Cloyster', emoji:'🐚', type:'Ice' }, + { name:'Hitmonlee', emoji:'🦵', type:'Fighting' }, + { name:'Starmie', emoji:'⭐', type:'Water' }, + { name:'Scyther', emoji:'🦗', type:'Bug' }, + { name:'Magikarp', emoji:'🐟', type:'Water' }, + { name:'Ditto', emoji:'🟣', type:'Normal' }, + { name:'Voltorb', emoji:'🔴', type:'Electric' }, + { name:'Cubone', emoji:'🦴', type:'Ground' }, + { name:'Chansey', emoji:'🥚', type:'Normal' }, + { name:'Kangaskhan', emoji:'🦘', type:'Normal' }, + { name:'Pinsir', emoji:'🪲', type:'Bug' }, + { name:'Tauros', emoji:'🐂', type:'Normal' }, + { name:'Vaporeon', emoji:'💎', type:'Water' }, + { name:'Flareon', emoji:'🌋', type:'Fire' }, + { name:'Jolteon', emoji:'⚡', type:'Electric' }, +]; + +const STARTER_POOL = [ + { name:'Charmander', emoji:'🔥', type:'Fire' }, + { name:'Squirtle', emoji:'💧', type:'Water' }, + { name:'Bulbasaur', emoji:'🌿', type:'Grass' }, +]; + function setNPC(el, val) { if (!el) return; if (val && val.endsWith('.png')) { @@ -68,14 +118,18 @@ const MILESTONES = { const SAVE_KEY = CONFIG.saveKey; -/* ─── SAVE / LOAD ────────────────────────────────────────── */ function saveGame(s) { - try { localStorage.setItem(SAVE_KEY, JSON.stringify({ playerName:s.playerName, currentQ:s.currentQ, score:s.score, streak:s.streak, maxStreak:s.maxStreak, correct:s.correct, wrong:s.wrong, questionOrder:s.questions.map(q=>q.id), savedAt:Date.now() })); } catch(_){} + try { localStorage.setItem(SAVE_KEY, JSON.stringify({ + playerName:s.playerName, currentQ:s.currentQ, score:s.score, + streak:s.streak, maxStreak:s.maxStreak, correct:s.correct, wrong:s.wrong, + questionOrder:s.questions.map(q=>q.id), savedAt:Date.now(), + party:s.party, pokeballs:s.pokeballs, playerLevel:s.playerLevel, + caughtCount:s.caughtCount, + })); } catch(_){} } function loadGame() { try { const r=localStorage.getItem(SAVE_KEY); return r?JSON.parse(r):null; } catch(_){return null;} } function clearSave() { try { localStorage.removeItem(SAVE_KEY); } catch(_){} } -/* ─── BACKGROUND MUSIC ───────────────────────────────────── */ const Music = (() => { let audio = null; let rival = null; @@ -151,7 +205,6 @@ const Music = (() => { }; })(); -/* ─── CHIPTUNE SOUND ENGINE ──────────────────────────────── */ const SFX = (() => { let ctx = null; @@ -173,7 +226,6 @@ const SFX = (() => { } }); - /* Core: play a tone with envelope */ function tone(freq, type, vol, attack, sustain, release, when) { const c = getCtx(); const t = when ?? c.currentTime; @@ -191,7 +243,6 @@ const SFX = (() => { osc.stop(t + attack + sustain + release + 0.01); } - /* Sequence of notes: [{f, d}...] */ function seq(notes, type='square', vol=0.18) { const c = getCtx(); let t = c.currentTime + 0.02; @@ -202,89 +253,43 @@ const SFX = (() => { } return { - /* Boot jingle — classic rising fanfare */ - boot() { - seq([ - {f:262,d:.1},{f:330,d:.1},{f:392,d:.1},{f:523,d:.25} - ], 'square', 0.2); - }, - - /* Overworld bgm loop — simple cheerful melody */ - overworldStart() { - seq([ - {f:392,d:.12},{f:440,d:.12},{f:494,d:.12},{f:523,d:.18}, - {f:494,d:.1}, {f:440,d:.1}, {f:392,d:.18}, - {f:330,d:.12},{f:392,d:.12},{f:440,d:.12},{f:392,d:.24}, - ], 'square', 0.12); - }, - - /* Footstep click — tiny blip each walk tick */ - step() { - tone(180, 'square', 0.04, 0.005, 0.02, 0.03); - }, - - /* NPC encounter — ascending two-note ding */ - encounter() { - seq([{f:523,d:.1},{f:659,d:.18}], 'square', 0.22); - }, - - /* Typewriter blip per character */ - type() { - tone(880 + Math.random()*200, 'square', 0.03, 0.005, 0.01, 0.02); - }, - - /* Select / menu move */ - select() { - const c = getCtx(); - tone(440, 'square', 0.12, 0.005, 0.03, 0.04, c.currentTime + 0.05); - }, - - /* Correct answer — happy ascending chord */ - correct() { - seq([ - {f:523,d:.08},{f:659,d:.08},{f:784,d:.08},{f:1047,d:.2} - ], 'square', 0.18); - }, - - /* Streak bonus — extra flourish */ - streak() { - seq([ - {f:523,d:.06},{f:659,d:.06},{f:784,d:.06}, - {f:1047,d:.06},{f:1319,d:.2} - ], 'square', 0.18); - }, - - /* Wrong answer — descending buzz */ - wrong() { - seq([ - {f:330,d:.1},{f:277,d:.1},{f:233,d:.18} - ], 'sawtooth', 0.15); - }, - - /* Badge / level up fanfare */ - levelUp() { - seq([ - {f:523,d:.1},{f:659,d:.1},{f:784,d:.1},{f:659,d:.1}, - {f:784,d:.1},{f:1047,d:.3} - ], 'square', 0.2); - }, - - /* Champion / completion — full fanfare */ - complete() { - seq([ - {f:523,d:.1},{f:659,d:.1},{f:784,d:.1},{f:1047,d:.1}, - {f:784,d:.08},{f:880,d:.08},{f:1047,d:.08},{f:1319,d:.4} - ], 'square', 0.2); - }, - - /* Menu confirm (name entry OK) */ - confirm() { - seq([{f:523,d:.08},{f:784,d:.15}], 'square', 0.18); - }, + boot() { seq([{f:262,d:.1},{f:330,d:.1},{f:392,d:.1},{f:523,d:.25}], 'square', 0.2); }, + overworldStart() { seq([{f:392,d:.12},{f:440,d:.12},{f:494,d:.12},{f:523,d:.18},{f:494,d:.1},{f:440,d:.1},{f:392,d:.18},{f:330,d:.12},{f:392,d:.12},{f:440,d:.12},{f:392,d:.24}], 'square', 0.12); }, + step() { tone(180, 'square', 0.04, 0.005, 0.02, 0.03); }, + encounter() { seq([{f:523,d:.1},{f:659,d:.18}], 'square', 0.22); }, + type() { tone(880 + Math.random()*200, 'square', 0.03, 0.005, 0.01, 0.02); }, + select() { const c = getCtx(); tone(440, 'square', 0.12, 0.005, 0.03, 0.04, c.currentTime + 0.05); }, + correct() { seq([{f:523,d:.08},{f:659,d:.08},{f:784,d:.08},{f:1047,d:.2}], 'square', 0.18); }, + streak() { seq([{f:523,d:.06},{f:659,d:.06},{f:784,d:.06},{f:1047,d:.06},{f:1319,d:.2}], 'square', 0.18); }, + wrong() { seq([{f:330,d:.1},{f:277,d:.1},{f:233,d:.18}], 'sawtooth', 0.15); }, + levelUp() { seq([{f:523,d:.1},{f:659,d:.1},{f:784,d:.1},{f:659,d:.1},{f:784,d:.1},{f:1047,d:.3}], 'square', 0.2); }, + complete() { seq([{f:523,d:.1},{f:659,d:.1},{f:784,d:.1},{f:1047,d:.1},{f:784,d:.08},{f:880,d:.08},{f:1047,d:.08},{f:1319,d:.4}], 'square', 0.2); }, + confirm() { seq([{f:523,d:.08},{f:784,d:.15}], 'square', 0.18); }, + catch() { seq([{f:330,d:.08},{f:440,d:.08},{f:523,d:.08},{f:659,d:.15},{f:784,d:.25}], 'square', 0.2); }, + pokeball() { seq([{f:600,d:.06},{f:500,d:.06},{f:400,d:.06},{f:300,d:.1}], 'triangle', 0.15); }, + escape() { seq([{f:400,d:.08},{f:350,d:.08},{f:250,d:.15}], 'sawtooth', 0.12); }, }; })(); -/* ─── GAME ───────────────────────────────────────────────── */ +function getPlayerLevel(state) { + return Math.max(1, Math.floor(state.correct * 1.2) + 1); +} + +function randomPokemonForNPC() { + const pool = ALL_POKEMON.filter(p => !STARTER_POOL.find(s => s.name === p.name)); + const pick = pool[Math.floor(Math.random() * pool.length)]; + const level = Math.floor(Math.random() * 15) + 1; + return { ...pick, level }; +} + +function randomWildPokemon(playerLevel) { + const pick = ALL_POKEMON[Math.floor(Math.random() * ALL_POKEMON.length)]; + const minLvl = Math.max(1, playerLevel - 3); + const maxLvl = playerLevel + 5; + const level = Math.floor(Math.random() * (maxLvl - minLvl + 1)) + minLvl; + return { ...pick, level }; +} + class Game { constructor() { this.state = { @@ -297,6 +302,15 @@ class Game { pathDir: 'right', lastDir: 'right', cursor:0, + party: [], + pokeballs: 0, + playerLevel: 1, + caughtCount: 0, + activePokemon: null, + wildWorldX: 0, wildWorldY: 0, + wildSpawned: false, + currentWild: null, + currentNPCPokemon: null, }; this.screens = {}; @@ -316,9 +330,6 @@ class Game { this.boot(); } - /* ══════════════════════════════════════════════════════════ - CONTROLLER OVERLAY — wires D-pad, A, B, SELECT, START - ══════════════════════════════════════════════════════════ */ _bindController() { const dpadMap = { 'dp-up':'up', 'dp-down':'down', 'dp-left':'left', 'dp-right':'right' }; @@ -350,17 +361,17 @@ class Game { document.getElementById('btn-select').addEventListener('pointerdown', (e)=>{ e.preventDefault(); }); } - /* A — map: talk to NPC | battle: confirm cursor | elsewhere: advance */ _pressA() { const s = this.state.screen; if (s === 'map') { this._talkToNPC(); return; } if (s === 'battle' && !this.state.answering) { this._confirmCursor(); return; } if (s === 'intro') { this.advanceIntro(); return; } + if (s === 'starter-confirm') { document.getElementById('btn-starter-continue')?.click(); return; } + if (s === 'catch-result') { document.getElementById('btn-catch-continue')?.click(); return; } if (s === 'result') { document.getElementById('btn-result-cont')?.click(); return; } if (s === 'levelup') { document.getElementById('btn-lu-cont')?.click(); return; } } - /* B — map: talk to NPC (same as A) | battle: no-op while answering | intro/result: advance */ _pressB() { const s = this.state.screen; if (s === 'map') { this._talkToNPC(); return; } @@ -369,11 +380,9 @@ class Game { if (s === 'result') { document.getElementById('btn-result-cont')?.click(); return; } } - /* D-pad start hold */ _dpadStart(dir) { this._heldKeys.add(dir); if (!this._walkLoop) this._startWalkLoop(); - const s = this.state.screen; if (s === 'battle') { this._moveCursor(dir); } } @@ -383,9 +392,6 @@ class Game { if (this._heldKeys.size === 0) this._stopWalkLoop(); } - /* ══════════════════════════════════════════════════════════ - MAP WALKING — 16-step loop, collision, NPC proximity - ══════════════════════════════════════════════════════════ */ _startWalkLoop() { if (this._walkLoop) return; this._walkLoop = setInterval(() => this._walkTick(), 30); @@ -429,6 +435,31 @@ class Game { this.state.worldX += dx; this.state.worldY += dy; + + if (this._rocks && this._rocks.length > 0) { + for (const rock of this._rocks) { + const rdx = this.state.worldX - rock.wx; + const rdy = this.state.worldY - rock.wy; + if (Math.abs(rdx) < rock.r && Math.abs(rdy) < rock.r) { + this.state.worldX -= dx; + this.state.worldY -= dy; + return; + } + } + } + + if (this._trees && this._trees.length > 0) { + for (const tree of this._trees) { + const tdx = this.state.worldX - tree.wx; + const tdy = this.state.worldY - tree.wy; + if (Math.abs(tdx) < tree.r && Math.abs(tdy) < tree.r) { + this.state.worldX -= dx; + this.state.worldY -= dy; + return; + } + } + } + this.state.lastDir = dir; this._applyCamera(); @@ -444,6 +475,7 @@ class Game { if (bub) { bub.style.left = (cx - 4) + 'px'; bub.style.bottom = (bot + 66) + 'px'; } } this._checkNPCProximity(); + this._checkWildProximity(); } _applyCamera() { @@ -454,6 +486,7 @@ class Game { const ty = -(spread + this.state.worldY - viewH / 2); if (inner) inner.style.transform = `translate(${tx}px,${ty}px)`; this._positionNPCOnScreen(); + this._positionWildOnScreen(); } _positionNPCOnScreen() { @@ -467,6 +500,18 @@ class Game { } } + _positionWildOnScreen() { + const {viewW, viewH} = this._getViewport(); + const wildWrap = document.getElementById('map-wild-wrap'); + if (wildWrap && this.state.wildSpawned) { + const screenX = viewW / 2 + (this.state.wildWorldX - this.state.worldX) - 24; + const screenY = viewH / 2 + (this.state.wildWorldY - this.state.worldY) - 24; + wildWrap.style.left = screenX + 'px'; + wildWrap.style.bottom = (viewH - screenY - 48) + 'px'; + wildWrap.style.display = 'block'; + } + } + _playerScreenOffset() { const {viewW, viewH} = this._getViewport(); return { px: viewW / 2, py: viewH - (viewH * 0.45) - 32 }; @@ -480,6 +525,15 @@ class Game { return Math.sqrt((npcSX - px) ** 2 + (npcSY - py) ** 2); } + _wildScreenDist() { + if (!this.state.wildSpawned) return Infinity; + const {viewW, viewH} = this._getViewport(); + const {px, py} = this._playerScreenOffset(); + const wSX = viewW / 2 + (this.state.wildWorldX - this.state.worldX); + const wSY = viewH / 2 + (this.state.wildWorldY - this.state.worldY); + return Math.sqrt((wSX - px) ** 2 + (wSY - py) ** 2); + } + _checkNPCProximity() { const {viewW, viewH} = this._getViewport(); const dist = this._npcScreenDist(); @@ -498,10 +552,21 @@ class Game { this._updateDirArrow(near); } + _checkWildProximity() { + if (!this.state.wildSpawned || !this.state.currentWild) return; + const dist = this._wildScreenDist(); + if (dist < 80) { + this._stopWalkLoop(); + this._heldKeys.clear(); + SFX.encounter(); + this.showCatchScreen(); + } + } + _updateDirArrow(nearNPC) { const arrow = document.getElementById('map-dir-arrow'); if (!arrow) return; - if (nearNPC) { arrow.style.display = 'none'; arrow.style.animation = 'none'; return; } + if (nearNPC) { arrow.style.display = 'none'; arrow.style.animation = 'none'; this._stopArrowCycle(); return; } const {viewW, viewH} = this._getViewport(); const dx = this.state.npcWorldX - this.state.worldX; const dy = this.state.npcWorldY - this.state.worldY; @@ -519,8 +584,12 @@ class Game { const glyphs = { right:'▶', left:'◀', up:'▲', down:'▼' }; arrow.textContent = glyphs[pd]; - arrow.style.display = 'block'; - arrow.style.animation = 'arrowPulse .7s ease-in-out infinite'; + if (this._arrowDir !== pd) { + this._arrowDir = pd; + arrow.style.display = 'block'; + arrow.style.animation = 'arrowPulse .7s ease-in-out infinite'; + this._startArrowCycle(arrow); + } if (pd === 'right') { arrow.style.removeProperty('left'); arrow.style.right = '12px'; @@ -540,6 +609,28 @@ class Game { } } + _startArrowCycle(arrow) { + this._stopArrowCycle(); + arrow.style.opacity = '0'; + const cycle = () => { + if (!arrow || arrow.style.display === 'none') return; + arrow.style.opacity = '1'; + this._arrowHideTimer = setTimeout(() => { + arrow.style.opacity = '0'; + }, 3000); + }; + cycle(); + this._arrowInterval = setInterval(cycle, 15000); + } + + _stopArrowCycle() { + clearInterval(this._arrowInterval); + clearTimeout(this._arrowHideTimer); + this._arrowInterval = null; + this._arrowHideTimer = null; + this._arrowDir = null; + } + _nearNPC() { if (!this.state.npcSpawned) return false; return this._npcScreenDist() < 100; @@ -558,7 +649,7 @@ class Game { return; } const arrow = document.getElementById('map-dir-arrow'); - if (arrow) { arrow.style.display = 'none'; arrow.style.animation = 'none'; } + if (arrow) { arrow.style.display = 'none'; arrow.style.animation = 'none'; this._stopArrowCycle(); } const bub = document.getElementById('player-bubble'); if (bub) bub.classList.remove('visible'); SFX.encounter(); @@ -584,9 +675,6 @@ class Game { if (hint) hint.style.display = 'none'; } - /* ══════════════════════════════════════════════════════════ - BATTLE CURSOR — navigate 2×2 grid with D-pad / WASD - ══════════════════════════════════════════════════════════ */ _moveCursor(dir) { const btns = document.querySelectorAll('.choice-btn:not(.disabled):not(.correct):not(.wrong):not(.confirm-inactive)'); if (!btns.length) return; @@ -616,9 +704,6 @@ class Game { if (target) target.click(); } - /* ══════════════════════════════════════════════════════════ - GLOBAL KEYBOARD - ══════════════════════════════════════════════════════════ */ _bindGlobalKeys() { const DPAD_KEYS = { 'ArrowUp':'up','ArrowDown':'down','ArrowLeft':'left','ArrowRight':'right', @@ -629,7 +714,6 @@ class Game { document.addEventListener('keydown', (e) => { const s = this.state.screen; - /* Name screen — character input */ if (s === 'name') { if (e.key==='Backspace') { e.preventDefault(); this.delChar(); } else if (e.key==='Enter') this.confirmName(); @@ -637,16 +721,69 @@ class Game { return; } - /* Title */ if (s === 'title' && (e.key==='Enter'||e.key===' ')) { document.getElementById('screen-title').click(); return; } - - /* Continue screen */ if (s === 'continue' && (e.key==='Enter'||e.key===' ')) { document.getElementById('btn-continue-save')?.click(); return; } - - /* Intro */ if (s === 'intro' && (e.key==='Enter'||e.key===' '||e.key==='e'||e.key==='E')) { this.advanceIntro(); return; } - /* D-pad direction keys */ + if (s === 'starter') { + if (e.key==='1'||e.key==='2'||e.key==='3') { + this._starterCursor = parseInt(e.key) - 1; + this._renderStarterCursor(); + setTimeout(() => this.pickStarter(this._starterCursor), 150); + return; + } + if (e.key==='ArrowLeft'||e.key==='a'||e.key==='A') { + this._starterCursor = Math.max(0, (this._starterCursor||0) - 1); + this._renderStarterCursor(); + return; + } + if (e.key==='ArrowRight'||e.key==='d'||e.key==='D') { + this._starterCursor = Math.min(2, (this._starterCursor||0) + 1); + this._renderStarterCursor(); + return; + } + if (e.key==='Enter'||e.key===' '||e.key==='e'||e.key==='E') { + this.pickStarter(this._starterCursor||0); + return; + } + return; + } + + if (s === 'starter-confirm' && (e.key==='Enter'||e.key===' ')) { + document.getElementById('btn-starter-continue')?.click(); + return; + } + + if (s === 'catch') { + if (e.key==='1'||e.key==='2') { + const btns = document.querySelectorAll('.catch-action-btn'); + if (e.key==='1' && btns[0]) btns[0].click(); + if (e.key==='2' && btns[1]) btns[1].click(); + return; + } + if (e.key==='ArrowLeft'||e.key==='a'||e.key==='A') { + this._catchCursor = 0; + this._renderCatchCursor(); + return; + } + if (e.key==='ArrowRight'||e.key==='d'||e.key==='D') { + this._catchCursor = 1; + this._renderCatchCursor(); + return; + } + if (e.key==='Enter'||e.key===' '||e.key==='e'||e.key==='E') { + const btns = document.querySelectorAll('.catch-action-btn'); + if (btns[this._catchCursor||0]) btns[this._catchCursor||0].click(); + return; + } + return; + } + + if (s === 'catch-result' && (e.key==='Enter'||e.key===' ')) { + document.getElementById('btn-catch-continue')?.click(); + return; + } + if (DPAD_KEYS[e.key] && !e.repeat) { const dir = DPAD_KEYS[e.key]; if ((s==='map' || s==='battle') && !this._heldKeys.has(dir)) { @@ -658,17 +795,14 @@ class Game { return; } - /* A-button equivalent on keyboard */ if ((e.key==='Enter'||e.key==='e'||e.key==='E') && !e.repeat) { e.preventDefault(); this._pressA(); return; } - /* B-button */ if ((e.key==='x'||e.key==='X') && !e.repeat) { this._pressB(); return; } - /* Quick-pick numbers on battle */ if (s==='battle' && !this.state.answering) { const numMap={'1':0,'2':1,'3':2,'4':3}; if (numMap[e.key]!==undefined) { @@ -693,21 +827,6 @@ class Game { }); } - /* ══════════════════════════════════════════════════════════ - XBOX / GAMEPAD SUPPORT (Gamepad API) - Polls every animation frame. Works with Xbox, PS, and most - USB/Bluetooth gamepads that the browser recognises. - - Xbox mapping: - Axes 0/1 = left stick X/Y - Button 0 = A (confirm / talk) - Button 1 = B (talk / back) - Button 12 = D-pad up - Button 13 = D-pad down - Button 14 = D-pad left - Button 15 = D-pad right - Button 9 = Start (confirm) - ══════════════════════════════════════════════════════════ */ _bindGamepad() { this._gpPrev = {}; this._gpActive = false; @@ -716,8 +835,6 @@ class Game { console.log('Gamepad connected:', e.gamepad.id); }); - /* Start polling immediately — don't wait for the event. - The poll function checks if any gamepad is present each frame. */ this._startGpLoop(); } @@ -751,7 +868,6 @@ class Game { const s = this.state.screen; - /* ── NAME SCREEN — D-pad navigates keyboard grid, A selects, B deletes, Start confirms ── */ if (s === 'name') { const letterKeys = Array.from(document.querySelectorAll('.key-btn')); const delBtn = document.getElementById('btn-backspace'); @@ -760,14 +876,11 @@ class Game { const total = keys.length; const COLS = 10; - /* Highlight current key — works for letter keys, DEL and OK */ keys.forEach((k, i) => { k.style.outline = i === this._gpKeyCursor ? '3px solid #f8c030' : ''; k.style.outlineOffset = i === this._gpKeyCursor ? '-2px' : ''; }); - /* D-pad navigation with repeat delay */ - const now = Date.now(); const moved = Object.entries(gpDirs).find(([d, active]) => active && !this._gpPrev[`axis_${d}`]); if (moved) { const [dir] = moved; @@ -783,7 +896,7 @@ class Game { }); } - if (justPressed(0) || justPressed(1)) { /* A or B — activate highlighted key */ + if (justPressed(0) || justPressed(1)) { const key = keys[this._gpKeyCursor]; if (!key) return; const isLetterKey = key.classList.contains('key-btn'); @@ -795,17 +908,15 @@ class Game { this.confirmName(); } } - if (justPressed(2)) { /* X — delete */ + if (justPressed(2)) { this.delChar(); } - if (justPressed(9) || justPressed(8)) { /* Start or Select — confirm name */ + if (justPressed(9) || justPressed(8)) { this.confirmName(); } - /* ── ALL OTHER SCREENS — directional movement + button actions ── */ } else { - /* ── CONTINUE SCREEN — D-pad up/down switches between Continue and New Game ── */ if (s === 'continue') { if (this._gpContCursor === undefined) this._gpContCursor = 0; const contBtn = document.getElementById('btn-continue-save'); @@ -817,12 +928,10 @@ class Game { this._gpContCursor = this._gpContCursor === 0 ? 1 : 0; } - /* Highlight selected button */ if (contBtn) contBtn.style.outline = this._gpContCursor === 0 ? '3px solid #f8c030' : ''; if (newBtn) newBtn.style.outline = this._gpContCursor === 1 ? '3px solid #f8c030' : ''; } - /* Walk / battle cursor movement */ Object.entries(gpDirs).forEach(([dir, active]) => { const key = `axis_${dir}`; if (active && !this._gpPrev[key]) { @@ -831,18 +940,24 @@ class Game { if (!this._walkLoop) this._startWalkLoop(); } if (s === 'battle') this._moveCursor(dir); + if (s === 'starter' && (dir === 'left' || dir === 'right')) { + this._starterCursor = dir === 'left' ? Math.max(0, (this._starterCursor||0) - 1) : Math.min(2, (this._starterCursor||0) + 1); + this._renderStarterCursor(); + } + if (s === 'catch' && (dir === 'left' || dir === 'right')) { + this._catchCursor = dir === 'left' ? 0 : 1; + this._renderCatchCursor(); + } } else if (!active && this._gpPrev[key]) { this._heldKeys.delete(dir); if (this._heldKeys.size === 0) this._stopWalkLoop(); } }); - /* A / Start — context action on every screen */ if (justPressed(0) || justPressed(9)) { - if (s === 'boot') { /* wait for title */ } + if (s === 'boot') { } else if (s === 'title') { document.getElementById('screen-title')?.click(); } else if (s === 'continue') { - /* 0 = CONTINUE highlighted, 1 = NEW GAME highlighted */ if (this._gpContCursor === 0) document.getElementById('btn-continue-save')?.click(); else document.getElementById('btn-new-game')?.click(); } @@ -851,10 +966,13 @@ class Game { else if (s === 'result') { document.getElementById('btn-result-cont')?.click(); } else if (s === 'levelup') { document.getElementById('btn-lu-cont')?.click(); } else if (s === 'complete') { document.getElementById('btn-play-again')?.click(); } + else if (s === 'starter-confirm') { document.getElementById('btn-starter-continue')?.click(); } + else if (s === 'starter') { this.pickStarter(this._starterCursor||0); } + else if (s === 'catch') { const cbtns = document.querySelectorAll('.catch-action-btn'); if (cbtns[this._catchCursor||0]) cbtns[this._catchCursor||0].click(); } + else if (s === 'catch-result') { document.getElementById('btn-catch-continue')?.click(); } else { this._pressA(); } } - /* B — also confirms answer in battle */ if (justPressed(1)) { if (s === 'battle' && !this.state.answering) { this._confirmCursor(); } else if (s === 'intro') { this.advanceIntro(); } @@ -862,7 +980,6 @@ class Game { else { this._pressB(); } } - /* Quick-pick answers X=2, Y=3, LB=4, RB=5 */ if (s === 'battle' && !this.state.answering) { const quickMap = { 2:0, 3:1, 4:2, 5:3 }; Object.entries(quickMap).forEach(([btn, idx]) => { @@ -875,12 +992,12 @@ class Game { } } - /* Store all states for next frame */ Object.entries(gpDirs).forEach(([d, v]) => this._gpPrev[`axis_${d}`] = v); for (let i = 0; i < btns.length; i++) { this._gpPrev[`btn_${i}`] = btns[i]?.pressed; } } + _showController(visible) { ['gba-left', 'gba-right'].forEach(id => { const el = document.getElementById(id); @@ -890,9 +1007,6 @@ class Game { }); } - /* ══════════════════════════════════════════════════════════ - SCREENS - ══════════════════════════════════════════════════════════ */ boot() { this.show('boot'); this._showController(false); @@ -913,7 +1027,7 @@ class Game { showContinueOrName() { const save = loadGame(); - if (save && save.playerName && save.currentQ > 0 && save.currentQ < 100) { + if (save && save.playerName && save.currentQ > 0 && save.currentQ < 100 && save.party && save.party.length > 0) { this.show('continue'); this._showController(false); const pct = Math.round((save.currentQ/100)*100); @@ -927,13 +1041,13 @@ class Game { document.getElementById('btn-new-game').textContent = '✦ CONFIRM NEW GAME'; document.getElementById('btn-new-game').onclick = () => { clearSave(); this.startFresh(); }; }; - /* Reset controller cursor to CONTINUE button */ this._gpContCursor = 0; setTimeout(() => { const c = document.getElementById('btn-continue-save'); if (c) c.style.outline = '3px solid #f8c030'; }, 50); } else { + clearSave(); this.startFresh(); } } @@ -944,6 +1058,11 @@ class Game { score:save.score, streak:save.streak, maxStreak:save.maxStreak, correct:save.correct, wrong:save.wrong, worldX:0, worldY:0, npcWorldX:0, npcWorldY:0, npcSpawned:false, + party: save.party || [], + pokeballs: save.pokeballs || 0, + playerLevel: save.playerLevel || 1, + caughtCount: save.caughtCount || 0, + activePokemon: save.party && save.party.length > 0 ? save.party[0] : null, }); this.loadQuestions(() => { if (save.questionOrder && save.questionOrder.length === this.state.questions.length) { @@ -958,11 +1077,13 @@ class Game { } startFresh() { - Object.assign(this.state, { playerName:'', currentQ:0, score:0, streak:0, maxStreak:0, correct:0, wrong:0 }); + Object.assign(this.state, { + playerName:'', currentQ:0, score:0, streak:0, maxStreak:0, correct:0, wrong:0, + party:[], pokeballs:0, playerLevel:1, caughtCount:0, activePokemon:null, + }); this.showNameEntry(); } - /* ── NAME ─────────────────────────────────────────────── */ showNameEntry() { this.show('name'); this._showController(false); @@ -993,7 +1114,6 @@ class Game { } confirmName() { if(!this.state.playerName.trim()) this.state.playerName='ASH'; SFX.confirm(); this.showIntro(); } - /* ── INTRO ────────────────────────────────────────────── */ showIntro() { this.show('intro'); this._showController(false); @@ -1032,13 +1152,197 @@ class Game { if (this.state.introStep >= INTRO_MSGS.length) { const el = document.getElementById('screen-intro'); if (this._introClick) el.removeEventListener('click', this._introClick); - this.loadQuestions(() => this.showMap()); + this.showStarterSelection(); return; } this.renderIntroMsg(); } - /* ── LOAD QUESTIONS ───────────────────────────────────── */ + showStarterSelection() { + this.show('starter'); + this._showController(true); + this._starterCursor = 0; + const oakEl = document.getElementById('starter-oak'); + if (oakEl) setNPC(oakEl, NPC['Professor Oak']); + + this.typeText('starter-text', `Now, ${this.state.playerName||'ASH'}! Choose your partner Pokémon! Each one is special in its own way!`); + + const container = document.getElementById('starter-choices'); + container.innerHTML = ''; + + STARTER_POOL.forEach((poke, i) => { + const btn = document.createElement('button'); + btn.className = 'starter-btn'; + btn.innerHTML = ` + ${poke.emoji} + ${poke.name} + ${poke.type} · Lv5 + `; + btn.addEventListener('click', () => this.pickStarter(i)); + container.appendChild(btn); + }); + this._renderStarterCursor(); + } + + _renderStarterCursor() { + const btns = document.querySelectorAll('.starter-btn'); + btns.forEach((btn, i) => { + btn.classList.toggle('starter-btn-selected', i === (this._starterCursor||0)); + }); + } + + _renderCatchCursor() { + const btns = document.querySelectorAll('.catch-action-btn'); + btns.forEach((btn, i) => { + btn.classList.toggle('catch-btn-selected', i === (this._catchCursor||0)); + }); + } + + pickStarter(index) { + const chosen = { ...STARTER_POOL[index], level: 5 }; + this.state.party = [chosen]; + this.state.activePokemon = chosen; + this.state.pokeballs = 5; + + SFX.catch(); + + this.show('starter-confirm'); + this._showController(false); + + document.getElementById('starter-chosen-emoji').textContent = chosen.emoji; + document.getElementById('starter-chosen-name').textContent = chosen.name.toUpperCase(); + document.getElementById('starter-chosen-level').textContent = `Lv ${chosen.level} · ${chosen.type}`; + + const others = STARTER_POOL.filter((_, i) => i !== index); + const fleeText = `${others[0].emoji} ${others[0].name} and ${others[1].emoji} ${others[1].name} ran away!`; + document.getElementById('starter-flee-text').textContent = fleeText; + + this.typeText('starter-confirm-text', + `Great choice! ${chosen.name} looks excited to join you! The other Pokémon scurried away into the wild. You also received 5 Pokéballs!` + ); + + document.getElementById('btn-starter-continue').onclick = () => { + this.loadQuestions(() => this.showMap()); + }; + } + + _spawnWildPokemon() { + if (this.state.pokeballs <= 0) { this.state.wildSpawned = false; return; } + if (Math.random() > 0.90) { this.state.wildSpawned = false; return; } + + const pLevel = getPlayerLevel(this.state); + const wild = randomWildPokemon(pLevel); + this.state.currentWild = wild; + + const {viewW, viewH} = this._getViewport(); + const npcDx = this.state.npcWorldX; + const npcDy = this.state.npcWorldY; + + const t = 0.2 + Math.random() * 0.5; + let wx = npcDx * t + (Math.random() - 0.5) * viewW * 1.5; + let wy = npcDy * t + (Math.random() - 0.5) * viewH * 1.5; + + const minDist = 150; + if (Math.sqrt(wx*wx + wy*wy) < minDist) { + wx += (wx >= 0 ? 1 : -1) * minDist; + wy += (wy >= 0 ? 1 : -1) * minDist; + } + + this.state.wildWorldX = wx; + this.state.wildWorldY = wy; + this.state.wildSpawned = true; + + const wildEl = document.getElementById('map-wild'); + if (wildEl) wildEl.textContent = wild.emoji; + const wildWrap = document.getElementById('map-wild-wrap'); + if (wildWrap) wildWrap.style.display = 'block'; + + this._positionWildOnScreen(); + } + + showCatchScreen() { + const wild = this.state.currentWild; + if (!wild) return; + + this.show('catch'); + this._showController(true); + this._catchCursor = 0; + Music.pause(); + + document.getElementById('catch-wild-pokemon').textContent = wild.emoji; + document.getElementById('catch-wild-name').textContent = wild.name.toUpperCase(); + document.getElementById('catch-wild-level').textContent = `Lv ${wild.level}`; + + const pLevel = getPlayerLevel(this.state); + const canCatch = pLevel >= wild.level; + + this.typeText('catch-text', `A wild ${wild.name} appeared!`); + + const actions = document.getElementById('catch-actions'); + actions.innerHTML = ''; + + const throwBtn = document.createElement('button'); + throwBtn.className = 'catch-action-btn btn-pixel btn-red'; + throwBtn.textContent = `⚪ THROW BALL (${this.state.pokeballs})`; + throwBtn.disabled = this.state.pokeballs <= 0; + if (this.state.pokeballs <= 0) throwBtn.style.opacity = '0.4'; + throwBtn.addEventListener('click', () => this.throwPokeball(wild, canCatch)); + actions.appendChild(throwBtn); + + const runBtn = document.createElement('button'); + runBtn.className = 'catch-action-btn btn-pixel btn-blue'; + runBtn.textContent = '🏃 RUN AWAY'; + runBtn.addEventListener('click', () => { + this.state.wildSpawned = false; + this.state.currentWild = null; + const wildWrap = document.getElementById('map-wild-wrap'); + if (wildWrap) wildWrap.style.display = 'none'; + this.showMap(); + }); + actions.appendChild(runBtn); + } + + throwPokeball(wild, canCatch) { + this.state.pokeballs--; + SFX.pokeball(); + + this.show('catch-result'); + this._showController(false); + + const resultEmoji = document.getElementById('catch-result-emoji'); + const resultMsg = document.getElementById('catch-result-msg'); + + if (canCatch) { + setTimeout(() => SFX.catch(), 500); + resultEmoji.textContent = wild.emoji; + resultMsg.innerHTML = ` +
You've conquered SQL Town and mastered Microsoft T-SQL! Your SQL journey is just beginning — more towns and exams await!
+