2026-05-20 09:16:02 -07:00
/* ─── NPC ROSTER ─────────────────────────────────────────── */
const NPC = {
'Professor Oak' : '👴' ,
'Rival Gary' : '😤' ,
'Old Man Bob' : '🧓' ,
'Nurse Joy' : '👩⚕️' ,
'Officer Jenny' : '👮♀️' ,
'Brock' : '🧑🍳' ,
'Misty' : '🧜♀️' ,
'Giovanni' : '😈' ,
'Team Rocket Grunt' : '🚀' ,
'Scientist Bill' : '🔬' ,
'Fisherman Ralph' : '🎣' ,
'Hiker Taro' : '🧗' ,
'Gym Leader Surge' : '⚡' ,
'Erika' : '🌸' ,
'Koga' : '🥷' ,
'Sabrina' : '🔮' ,
'Blaine' : '🌋' ,
'Lorelei' : '❄️' ,
'Bruno' : '💪' ,
'Agatha' : '👻' ,
'Lance' : '🐉' ,
'Champion' : '🏆' ,
'Gary' : '😤' ,
};
const INTRO_MSGS = [
{ speaker : 'PROFESSOR OAK' , npc : 'Professor Oak' , text : "Hello {name}! Welcome to PokéAzure — the world of Azure certification training! My name is Professor Oak, the Azure Pokémon Professor!" },
{ speaker : 'PROFESSOR OAK' , npc : 'Professor Oak' , text : "This world is inhabited by powerful cloud services — Compute, Storage, Networking, and Security — mastered with knowledge!" },
{ speaker : 'PROFESSOR OAK' , npc : 'Professor Oak' , text : "Your mission? Journey through each town, challenge Azure experts, and conquer every certification exam!" },
2026-05-20 10:26:42 -07:00
{ speaker : 'PROFESSOR OAK' , npc : 'Professor Oak' , text : "Start in AZ-900 Town with AZ-900 — then travel the Azure world to tackle Administrator, Developer, Security, and beyond!" },
2026-05-20 09:16:02 -07:00
{ speaker : 'PROFESSOR OAK' , npc : 'Professor Oak' , text : "100 questions await you. Each correct answer earns you Azure 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 WRONG_FB = [ "Not quite..." , "Hmm, that's not it." , "Try again next time!" , "Almost..." , "Not this time!" ];
const MILESTONES = {
25 : { badge : '🥉 CLOUD EXPLORER' , stars : '★ ★ ☆ ☆' },
50 : { badge : '🥈 AZURE APPRENTICE' , stars : '★ ★ ★ ☆' },
75 : { badge : '🥇 CLOUD PRACTITIONER' , stars : '★ ★ ★ ★' },
};
const SAVE_KEY = 'pokeazure_save_v1' ;
/* ─── 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 , savedAt : Date . now () })); } 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 muted = false ;
let wantPlay = false ; /* true if we tried to play but were blocked */
function getAudio () {
if ( ! audio ) {
audio = new Audio ( './music.mp3' );
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 (() => {});
});
}
return audio ;
}
function updateBtn () {
const btn = document . getElementById ( 'music-toggle' );
if ( ! btn ) return ;
btn . textContent = muted ? '🔇' : '🎵' ;
btn . classList . toggle ( 'muted' , muted );
}
return {
play () {
if ( muted ) return ;
wantPlay = true ;
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 ();
},
toggle () {
muted = ! muted ;
updateBtn ();
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 ) {
a . play (). catch (() => {});
}
},
};
})();
/* ─── CHIPTUNE SOUND ENGINE ──────────────────────────────── */
const SFX = (() => {
let ctx = null ;
function getCtx () {
if ( ! ctx ) ctx = new ( window . AudioContext || window . webkitAudioContext )();
if ( ctx . state === 'suspended' ) ctx . resume ();
return ctx ;
}
2026-05-20 11:48:34 -07:00
function unlock () {
2026-05-20 11:46:21 -07:00
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 });
2026-05-20 09:16:02 -07:00
/* Core: play a tone with envelope */
function tone ( freq , type , vol , attack , sustain , release , when ) {
const c = getCtx ();
const t = when ?? c . currentTime ;
const osc = c . createOscillator ();
const gain = c . createGain ();
osc . connect ( gain );
gain . connect ( c . destination );
osc . type = type ;
osc . frequency . setValueAtTime ( freq , t );
gain . gain . setValueAtTime ( 0 , t );
gain . gain . linearRampToValueAtTime ( vol , t + attack );
gain . gain . setValueAtTime ( vol , t + attack + sustain );
gain . gain . exponentialRampToValueAtTime ( 0.0001 , t + attack + sustain + release );
osc . start ( t );
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 ;
notes . forEach ( n => {
if ( n . f ) tone ( n . f , type , vol , 0.01 , n . d * 0.6 , n . d * 0.4 , t );
t += n . d ;
});
}
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 () {
2026-05-20 11:46:21 -07:00
const c = getCtx ();
tone ( 440 , 'square' , 0.12 , 0.005 , 0.03 , 0.04 , c . currentTime + 0.05 );
2026-05-20 09:16:02 -07:00
},
/* 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 );
},
};
})();
/* ─── GAME ───────────────────────────────────────────────── */
class Game {
constructor () {
this . state = {
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 ,
lastDir : 'right' ,
/* battle cursor */
cursor : 0 ,
};
this . screens = {};
document . querySelectorAll ( '.screen' ). forEach ( s => {
this . screens [ s . id . replace ( 'screen-' , '' )] = s ;
});
this . _heldKeys = new Set ();
this . _walkLoop = null ;
this . _introClick = null ;
this . _introKey = null ;
this . _gpLoop = null ;
this . _bindController ();
this . _bindGlobalKeys ();
this . _bindGamepad ();
this . boot ();
}
/* ══════════════════════════════════════════════════════════
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 ]) => {
const btn = document . getElementById ( id );
if ( ! btn ) return ;
const start = ( e ) => { e . preventDefault (); this . _dpadStart ( dir ); btn . classList . add ( 'pressed' ); };
const end = ( e ) => { e . preventDefault (); this . _dpadEnd ( dir ); btn . classList . remove ( 'pressed' ); };
btn . addEventListener ( 'pointerdown' , start , { passive : false });
btn . addEventListener ( 'pointerup' , end , { passive : false });
btn . addEventListener ( 'pointerleave' , end , { passive : false });
btn . addEventListener ( 'pointercancel' , end , { passive : false });
});
const aBtn = document . getElementById ( 'btn-a' );
const bBtn = document . getElementById ( 'btn-b' );
aBtn . addEventListener ( 'pointerdown' , ( e )=>{ e . preventDefault (); aBtn . classList . add ( 'pressed' ); this . _pressA (); });
aBtn . addEventListener ( 'pointerup' , ( e )=>{ e . preventDefault (); aBtn . classList . remove ( 'pressed' ); });
aBtn . addEventListener ( 'pointercancel' ,( e )=>{ aBtn . classList . remove ( 'pressed' ); });
bBtn . addEventListener ( 'pointerdown' , ( e )=>{ e . preventDefault (); bBtn . classList . add ( 'pressed' ); this . _pressB (); });
bBtn . addEventListener ( 'pointerup' , ( e )=>{ e . preventDefault (); bBtn . classList . remove ( 'pressed' ); });
bBtn . addEventListener ( 'pointercancel' ,( e )=>{ bBtn . classList . remove ( 'pressed' ); });
document . getElementById ( 'btn-start' ). addEventListener ( 'pointerdown' , ( e )=>{ e . preventDefault (); });
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 === '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 ; }
if ( s === 'battle' && this . state . answering ) { return ; }
if ( s === 'intro' ) { this . advanceIntro (); return ; }
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 ); }
}
_dpadEnd ( dir ) {
this . _heldKeys . delete ( dir );
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 );
}
_stopWalkLoop () {
clearInterval ( this . _walkLoop );
this . _walkLoop = null ;
if ( this . state . screen === 'map' ) {
const p = document . getElementById ( 'map-player' );
if ( p ) {
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' ;
}
}
}
_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 */
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 . lastDir = dir ;
this . _applyCamera ();
/* Player sprite stays horizontally centred; only Y shifts */
const p = document . getElementById ( 'map-player' );
2026-05-20 09:42:02 -07:00
const bub = document . getElementById ( 'player-bubble' );
if ( p ) {
2026-05-20 09:16:02 -07:00
const centreX = viewW / 2 - 16 ;
const centreY = viewH * 0.65 + this . state . worldY ;
2026-05-20 09:42:02 -07:00
const bottomPx = viewH - centreY - 40 ;
p . style . left = centreX + 'px' ;
p . style . bottom = bottomPx + 'px' ;
2026-05-20 09:16:02 -07:00
p . className = `map-player walk- ${ dir } ` ;
2026-05-20 09:42:02 -07:00
if ( bub ) { bub . style . left = ( centreX - 4 ) + 'px' ; bub . style . bottom = ( bottomPx + 64 ) + 'px' ; }
2026-05-20 09:16:02 -07:00
}
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)` ;
this . _positionNPCOnScreen ();
}
_positionNPCOnScreen () {
const world = document . getElementById ( 'map-inner' );
const viewH = world ? world . parentElement . offsetHeight : 300 ;
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' ;
}
}
_checkNPCProximity () {
2026-05-20 09:42:02 -07:00
const world = document . getElementById ( 'map-world' );
const viewW = ( world && world . offsetWidth ) ? world . offsetWidth : 420 ;
const viewH = ( world && world . offsetHeight ) ? world . offsetHeight : 300 ;
2026-05-20 09:16:02 -07:00
const playerWorldX = this . state . worldX + viewW / 2 ;
const dx = Math . abs ( playerWorldX - this . state . npcWorldX );
2026-05-20 09:42:02 -07:00
const near = this . state . npcWorldX > 0 && dx < 60 ;
2026-05-20 09:16:02 -07:00
const hint = document . getElementById ( 'map-talk-hint' );
const bub = document . getElementById ( 'npc-bubble' );
if ( hint ) {
2026-05-20 13:12:54 -07:00
hint . style . display = near ? 'block' : 'none' ;
2026-05-20 09:16:02 -07:00
hint . style . left = ( viewW / 2 - 28 ) + 'px' ;
hint . style . bottom = ( viewH * 0.50 ) + 'px' ;
}
if ( bub ) bub . style . opacity = near ? '0' : '1' ;
this . _updateDirArrow ( near );
}
_updateDirArrow ( nearNPC ) {
const arrow = document . getElementById ( 'map-dir-arrow' );
if ( ! arrow ) return ;
2026-05-20 12:45:02 -07:00
if ( nearNPC ) { arrow . style . display = 'none' ; arrow . style . animation = 'none' ; return ; }
2026-05-20 12:31:33 -07:00
const world = document . getElementById ( 'map-world' );
const viewH = ( world && world . offsetHeight ) ? world . offsetHeight : 300 ;
2026-05-20 12:45:02 -07:00
arrow . textContent = '▶' ;
arrow . style . display = 'block' ;
arrow . style . animation = 'arrowPulse .7s ease-in-out infinite' ;
arrow . style . removeProperty ( 'left' );
arrow . style . bottom = ( viewH * 0.28 ) + 'px' ;
2026-05-20 09:16:02 -07:00
}
_nearNPC () {
2026-05-20 09:42:02 -07:00
if ( ! this . state . npcWorldX ) return false ;
const world = document . getElementById ( 'map-world' );
const viewW = ( world && world . offsetWidth ) ? world . offsetWidth : 420 ;
2026-05-20 09:16:02 -07:00
const playerWorldX = this . state . worldX + viewW / 2 ;
return Math . abs ( playerWorldX - this . state . npcWorldX ) < 60 ;
}
_talkToNPC () {
2026-05-20 09:28:58 -07:00
if ( ! this . _nearNPC ()) {
2026-05-20 09:33:06 -07:00
const bub = document . getElementById ( 'player-bubble' );
if ( bub ) {
bub . classList . remove ( 'visible' );
void bub . offsetWidth ;
bub . classList . add ( 'visible' );
clearTimeout ( this . _bubbleTimer );
this . _bubbleTimer = setTimeout (() => bub . classList . remove ( 'visible' ), 1500 );
}
2026-05-20 09:28:58 -07:00
return ;
}
2026-05-20 09:16:02 -07:00
const arrow = document . getElementById ( 'map-dir-arrow' );
2026-05-20 12:45:02 -07:00
if ( arrow ) { arrow . style . display = 'none' ; arrow . style . animation = 'none' ; }
2026-05-20 09:33:06 -07:00
const bub = document . getElementById ( 'player-bubble' );
if ( bub ) bub . classList . remove ( 'visible' );
2026-05-20 09:16:02 -07:00
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' );
2026-05-20 09:42:02 -07:00
if ( p ) {
const left = viewW / 2 - 16 ;
const bottom = viewH * 0.28 ;
2026-05-20 13:12:54 -07:00
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' ; }
2026-05-20 09:16:02 -07:00
}
this . _applyCamera ();
const hint = document . getElementById ( 'map-talk-hint' );
2026-05-20 13:12:54 -07:00
if ( hint ) hint . style . display = 'none' ;
2026-05-20 09:16:02 -07:00
}
/* ══════════════════════════════════════════════════════════
BATTLE CURSOR — navigate 2× 2 grid with D-pad / WASD
══════════════════════════════════════════════════════════ */
_moveCursor ( dir ) {
2026-05-20 10:17:06 -07:00
const btns = document . querySelectorAll ( '.choice-btn:not(.disabled):not(.correct):not(.wrong):not(.confirm-inactive)' );
2026-05-20 09:16:02 -07:00
if ( ! btns . length ) return ;
2026-05-20 09:55:51 -07:00
const total = btns . length ;
2026-05-20 09:16:02 -07:00
let c = this . state . cursor ;
2026-05-20 09:55:51 -07:00
const hasConfirm = total === 5 ;
2026-05-20 09:16:02 -07:00
if ( dir === 'right' ) c = ( c === 0 ) ? 1 : ( c === 2 ) ? 3 : c ;
2026-05-20 09:55:51 -07:00
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 );
2026-05-20 09:16:02 -07:00
this . state . cursor = c ;
this . _renderCursor ();
SFX . select ();
}
_renderCursor () {
document . querySelectorAll ( '.choice-btn' ). forEach (( b , i ) => {
b . classList . toggle ( 'cursor' , i === this . state . cursor );
});
}
_confirmCursor () {
if ( this . state . answering ) return ;
2026-05-20 10:17:06 -07:00
const btns = document . querySelectorAll ( '.choice-btn:not(.disabled):not(.correct):not(.wrong):not(.confirm-inactive)' );
2026-05-20 09:16:02 -07:00
const target = btns [ this . state . cursor ];
if ( target ) target . click ();
}
/* ══════════════════════════════════════════════════════════
GLOBAL KEYBOARD
══════════════════════════════════════════════════════════ */
_bindGlobalKeys () {
const DPAD_KEYS = {
'ArrowUp' : 'up' , 'ArrowDown' : 'down' , 'ArrowLeft' : 'left' , 'ArrowRight' : 'right' ,
'w' : 'up' , 's' : 'down' , 'a' : 'left' , 'd' : 'right' ,
'W' : 'up' , 'S' : 'down' , 'A' : 'left' , 'D' : 'right' ,
};
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 ();
else if ( e . key . length === 1 && /[A-Z0-9.\-!?]/i . test ( e . key )) this . addChar ( e . key . toUpperCase ());
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 ( DPAD_KEYS [ e . key ] && ! e . repeat ) {
const dir = DPAD_KEYS [ e . key ];
if (( s === 'map' || s === 'battle' ) && ! this . _heldKeys . has ( dir )) {
e . preventDefault ();
this . _heldKeys . add ( dir );
if ( s === 'map' && ! this . _walkLoop ) this . _startWalkLoop ();
if ( s === 'battle' ) this . _moveCursor ( dir );
}
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 ) {
this . state . cursor = numMap [ e . key ];
this . _renderCursor ();
setTimeout (()=> this . _confirmCursor (), 80 );
}
}
});
document . addEventListener ( 'keyup' , ( e ) => {
const DPAD_KEYS = {
'ArrowUp' : 'up' , 'ArrowDown' : 'down' , 'ArrowLeft' : 'left' , 'ArrowRight' : 'right' ,
'w' : 'up' , 's' : 'down' , 'a' : 'left' , 'd' : 'right' ,
'W' : 'up' , 'S' : 'down' , 'A' : 'left' , 'D' : 'right' ,
};
const dir = DPAD_KEYS [ e . key ];
if ( dir ) {
this . _heldKeys . delete ( dir );
if ( this . _heldKeys . size === 0 ) this . _stopWalkLoop ();
}
});
}
/* ══════════════════════════════════════════════════════════
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 ;
window . addEventListener ( 'gamepadconnected' , ( e ) => {
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 ();
}
_startGpLoop () {
const poll = () => {
this . _gpLoop = requestAnimationFrame ( poll );
this . _pollGamepad ();
};
this . _gpLoop = requestAnimationFrame ( poll );
}
_pollGamepad () {
const gamepads = navigator . getGamepads ? navigator . getGamepads () : [];
const gp = [... gamepads ]. find ( g => g );
if ( ! gp ) return ;
const DEAD = 0.25 ;
const axes = gp . axes ;
const btns = gp . buttons ;
const pressedNow = ( i ) => btns [ i ] ? . pressed ;
const wasPressed = ( i ) => !! this . _gpPrev [ `btn_ ${ i } ` ];
const justPressed = ( i ) => pressedNow ( i ) && ! wasPressed ( i );
const gpDirs = {
up : ( axes [ 1 ] < - DEAD ) || !! btns [ 12 ] ? . pressed ,
down : ( axes [ 1 ] > DEAD ) || !! btns [ 13 ] ? . pressed ,
left : ( axes [ 0 ] < - DEAD ) || !! btns [ 14 ] ? . pressed ,
right : ( axes [ 0 ] > DEAD ) || !! btns [ 15 ] ? . pressed ,
};
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' );
const okBtn = document . getElementById ( 'btn-confirm-name' );
const keys = [... letterKeys , delBtn , okBtn ]. filter ( Boolean );
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 ;
let c = this . _gpKeyCursor ;
if ( dir === 'right' ) c = Math . min ( c + 1 , total - 1 );
if ( dir === 'left' ) c = Math . max ( c - 1 , 0 );
if ( dir === 'down' ) c = Math . min ( c + COLS , total - 1 );
if ( dir === 'up' ) c = Math . max ( c - COLS , 0 );
this . _gpKeyCursor = c ;
keys . forEach (( k , i ) => {
k . style . outline = i === c ? '3px solid #f8c030' : '' ;
k . style . outlineOffset = i === c ? '-2px' : '' ;
});
}
if ( justPressed ( 0 ) || justPressed ( 1 )) { /* A or B — activate highlighted key */
const key = keys [ this . _gpKeyCursor ];
if ( ! key ) return ;
const isLetterKey = key . classList . contains ( 'key-btn' );
if ( isLetterKey ) {
key . dispatchEvent ( new PointerEvent ( 'pointerdown' , { bubbles : true , isPrimary : true }));
} else if ( key . id === 'btn-backspace' ) {
this . delChar ();
} else if ( key . id === 'btn-confirm-name' ) {
this . confirmName ();
}
}
if ( justPressed ( 2 )) { /* X — delete */
this . delChar ();
}
if ( justPressed ( 9 ) || justPressed ( 8 )) { /* Start or Select — confirm name */
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' );
const newBtn = document . getElementById ( 'btn-new-game' );
const movedUp = gpDirs . up && ! this . _gpPrev [ 'axis_up' ];
const movedDown = gpDirs . down && ! this . _gpPrev [ 'axis_down' ];
if ( movedUp || movedDown ) {
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 ]) {
if ( s === 'map' ) {
this . _heldKeys . add ( dir );
if ( ! this . _walkLoop ) this . _startWalkLoop ();
}
if ( s === 'battle' ) this . _moveCursor ( dir );
} 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 */ }
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 ();
}
else if ( s === 'intro' ) { this . advanceIntro (); }
else if ( s === 'battle' && ! this . state . answering ) { this . _confirmCursor (); }
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 { 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 (); }
else if ( s === 'result' ) { document . getElementById ( 'btn-result-cont' ) ? . click (); }
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 ]) => {
if ( justPressed ( + btn )) {
this . state . cursor = idx ;
this . _renderCursor ();
setTimeout (() => this . _confirmCursor (), 80 );
}
});
}
}
/* 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 ) {
const ctrl = document . getElementById ( 'gba-controller' );
if ( ! ctrl ) return ;
if ( visible ) ctrl . classList . remove ( 'hidden' );
else ctrl . classList . add ( 'hidden' );
}
/* ══════════════════════════════════════════════════════════
SCREENS
══════════════════════════════════════════════════════════ */
boot () {
this . show ( 'boot' );
this . _showController ( false );
setTimeout (() => this . showTitle (), 1800 );
}
showTitle () {
this . show ( 'title' );
SFX . boot ();
this . _showController ( false );
const go = () => {
document . getElementById ( 'screen-title' ). removeEventListener ( 'click' , go );
Music . unblock ();
this . showContinueOrName ();
};
document . getElementById ( 'screen-title' ). addEventListener ( 'click' , go );
}
showContinueOrName () {
const save = loadGame ();
if ( save && save . playerName && save . currentQ > 0 && save . currentQ < 100 ) {
this . show ( 'continue' );
this . _showController ( false );
const pct = Math . round (( save . currentQ / 100 ) * 100 );
document . getElementById ( 'save-name-disp' ). textContent = save . playerName ;
document . getElementById ( 'save-prog-disp' ). textContent = `Q ${ save . currentQ } /100 ( ${ pct } %)` ;
document . getElementById ( 'save-score-disp' ). textContent = save . score . toLocaleString ();
document . getElementById ( 'btn-continue-save' ). onclick = () => { Music . unblock (); this . restoreFromSave ( save ); };
document . getElementById ( 'btn-new-game' ). onclick = () => {
document . getElementById ( 'continue-warn' ). textContent = 'Starting a new game will erase your saved progress!' ;
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 {
this . startFresh ();
}
}
restoreFromSave ( save ) {
Object . assign ( this . state , {
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 ,
});
this . loadQuestions (() => this . showMap ());
}
startFresh () {
Object . assign ( this . state , { playerName : '' , currentQ : 0 , score : 0 , streak : 0 , maxStreak : 0 , correct : 0 , wrong : 0 });
this . showNameEntry ();
}
/* ── NAME ─────────────────────────────────────────────── */
showNameEntry () {
this . show ( 'name' );
this . _showController ( false );
this . _gpKeyCursor = 0 ;
this . buildKeyboard ();
this . typeText ( 'name-prompt-text' , "Hello there! Welcome to PokéAzure! My name is Professor Oak — the Azure Pokémon Professor. Now tell me, what is your name?" );
document . getElementById ( 'btn-backspace' ). onclick = () => this . delChar ();
document . getElementById ( 'btn-confirm-name' ). onclick = () => this . confirmName ();
}
buildKeyboard () {
const grid = document . getElementById ( 'keyboard-grid' );
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-!?' . split ( '' );
grid . innerHTML = '' ;
chars . forEach ( ch => {
const b = document . createElement ( 'button' );
b . className = 'key-btn' ; b . textContent = ch ;
b . addEventListener ( 'pointerdown' , ( e )=>{ e . preventDefault (); this . addChar ( ch ); });
grid . appendChild ( b );
});
}
addChar ( ch ) { if ( this . state . playerName . length < 10 ){ this . state . playerName += ch ; this . refreshNameDisplay (); SFX . select (); } }
delChar () { this . state . playerName = this . state . playerName . slice ( 0 , - 1 ); this . refreshNameDisplay (); }
refreshNameDisplay () {
const el = document . getElementById ( 'name-display' );
if ( el ) el . textContent = this . state . playerName + ( this . state . playerName . length < 10 ? '_' : '' );
}
confirmName () { if ( ! this . state . playerName . trim ()) this . state . playerName = 'ASH' ; SFX . confirm (); this . showIntro (); }
/* ── INTRO ────────────────────────────────────────────── */
showIntro () {
this . show ( 'intro' );
this . _showController ( false );
this . state . introStep = 0 ;
this . renderIntroMsg ();
const advance = () => this . advanceIntro ();
const introEl = document . getElementById ( 'screen-intro' );
if ( this . _introClick ) introEl . removeEventListener ( 'click' , this . _introClick );
this . _introClick = advance ;
introEl . addEventListener ( 'click' , advance );
}
renderIntroMsg () {
const msg = INTRO_MSGS [ this . state . introStep ];
if ( ! msg ) return ;
const npcEl = document . getElementById ( 'intro-npc' );
if ( npcEl ) npcEl . textContent = NPC [ msg . npc ] || '👴' ;
document . getElementById ( 'intro-speaker' ). textContent = msg . speaker ;
document . getElementById ( 'intro-arrow' ). style . display = 'none' ;
this . typeText ( 'intro-text' ,
msg . text . replace ( /{name}/g , this . state . playerName || 'ASH' ),
()=>{ document . getElementById ( 'intro-arrow' ). style . display = 'block' ; }
);
}
advanceIntro () {
if ( this . state . twTimer ) {
clearInterval ( this . state . twTimer ); this . state . twTimer = null ;
const msg = INTRO_MSGS [ this . state . introStep ];
document . getElementById ( 'intro-text' ). textContent = msg . text . replace ( /{name}/g , this . state . playerName || 'ASH' );
document . getElementById ( 'intro-arrow' ). style . display = 'block' ;
return ;
}
this . state . introStep ++ ;
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 ());
return ;
}
this . renderIntroMsg ();
}
/* ── LOAD QUESTIONS ───────────────────────────────────── */
loadQuestions ( cb ) {
if ( this . state . questions . length > 0 ) { cb (); return ; }
fetch ( './questions.json' )
. then ( r => r . json ())
2026-05-20 11:46:21 -07:00
. then ( data =>{
const qs = data . levels [ 0 ]. questions ;
for ( let i = qs . length - 1 ; i > 0 ; i -- ) {
const j = Math . floor ( Math . random () * ( i + 1 ));
[ qs [ i ], qs [ j ]] = [ qs [ j ], qs [ i ]];
}
this . state . questions = qs ;
cb ();
})
2026-05-20 09:16:02 -07:00
. catch (()=> this . toast ( '❌ Could not load questions.json' ));
}
/* 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. */
_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 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 );
span . textContent = '🌳' ;
span . style . fontSize = size + 'px' ;
span . style . left = x + 'px' ;
span . style . top = y + 'px' ;
span . style . animationDelay = ( Math . random () * 3 ). toFixed ( 2 ) + 's' ;
span . style . animationDuration = ( 2.5 + Math . random () * 2 ). toFixed ( 1 ) + 's' ;
container . appendChild ( span );
}
}
/* 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 ;
}
showMap () {
this . show ( 'map' );
this . _showController ( true );
/* We need the viewport size — defer a tick so the DOM is laid out */
requestAnimationFrame (() => {
this . _generateTrees ();
const world = document . getElementById ( 'map-inner' );
const viewW = world ? world . parentElement . offsetWidth : 420 ;
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 ;
} 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 ;
}
document . getElementById ( 'hud-name' ). textContent = this . state . playerName || 'ASH' ;
this . updateHUD ();
const q = this . state . questions [ this . state . currentQ ];
const npcEl = document . getElementById ( 'map-npc' );
if ( q && npcEl ) npcEl . textContent = NPC [ q . npc ] || '🧑' ;
this . _placeMapSprites ();
this . _updateDirArrow ( false );
Music . play ();
saveGame ( this . state );
this . flashSaveDot ();
const npcEl2 = document . getElementById ( 'map-npc' );
if ( npcEl2 ) npcEl2 . onclick = ()=> this . _talkToNPC ();
const musicBtn = document . getElementById ( 'music-toggle' );
if ( musicBtn ) musicBtn . onclick = () => Music . toggle ();
});
}
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 ();
document . getElementById ( 'hud-streak' ). textContent = this . state . streak + ( this . state . streak >= 3 ? '🔥' : '' );
document . getElementById ( 'hud-correct' ). textContent = ` ${ this . state . correct } / ${ done } ` ;
document . getElementById ( 'map-prog-text' ). textContent = ` ${ done } / ${ tot } ` ;
const bar = document . getElementById ( 'map-prog-bar' );
if ( bar ){ bar . style . width = pct + '%' ; bar . style . background = pct < 40 ? 'var(--hp-green)' : pct < 75 ? 'var(--hp-yellow)' : '#60c8ff' ; }
}
flashSaveDot () {
const dot = document . getElementById ( 'save-dot' );
if ( ! dot ) return ; dot . style . opacity = '1' ;
setTimeout (()=>{ dot . style . opacity = '.4' ; }, 1500 );
}
/* ── BATTLE ───────────────────────────────────────────── */
startQuestion () {
this . _stopWalkLoop ();
this . _heldKeys . clear ();
const q = this . state . questions [ this . state . currentQ ];
if ( ! q ){ this . showComplete (); return ; }
this . show ( 'battle' );
this . _showController ( true );
Music . pause ();
this . state . answering = false ;
this . state . cursor = 0 ;
document . getElementById ( 'battle-npc-sprite' ). textContent = NPC [ q . npc ] || '🧑' ;
document . getElementById ( 'battle-npc-name' ). textContent = q . npc . toUpperCase ();
document . getElementById ( 'battle-speaker' ). textContent = q . npc . toUpperCase ();
document . getElementById ( 'battle-player-name' ). textContent = ( this . state . playerName || 'ASH' ). toUpperCase ();
document . getElementById ( 'battle-q-num' ). textContent = this . state . currentQ + 1 ;
document . getElementById ( 'battle-pts' ). textContent = this . state . score . toLocaleString ();
document . getElementById ( 'battle-arrow' ). style . display = 'none' ;
document . getElementById ( 'battle-choices' ). innerHTML = '' ;
this . typeText ( 'battle-text' , q . text , ()=> this . renderChoices ( q ));
}
renderChoices ( q ) {
const container = document . getElementById ( 'battle-choices' );
container . innerHTML = '' ;
this . state . cursor = 0 ;
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 = isMulti ? null : indices . indexOf ( q . answer );
const correctShuffledSet = isMulti ? new Set ( q . answers . map ( a => indices . indexOf ( a ))) : null ;
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 ];
}
const selectedSlots = new Set ();
2026-05-20 10:21:26 -07:00
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 );
});
}
2026-05-20 09:16:02 -07:00
indices . forEach (( origIdx , slotIdx ) => {
const btn = document . createElement ( 'button' );
btn . className = 'choice-btn' ;
btn . setAttribute ( 'data-letter' , slotIdx + 1 );
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 );
}
2026-05-20 10:17:06 -07:00
confirmBtn . classList . toggle ( 'confirm-inactive' , selectedSlots . size !== 2 );
2026-05-20 09:16:02 -07:00
});
} else {
btn . addEventListener ( 'click' ,()=> this . pick ( slotIdx , correctShuffledIdx , q , container ));
}
2026-05-20 11:43:00 -07:00
btn . addEventListener ( 'mouseenter' , () => {
this . state . cursor = slotIdx ;
this . _renderCursor ();
});
2026-05-20 09:16:02 -07:00
container . appendChild ( btn );
});
if ( isMulti ) {
2026-05-20 11:43:00 -07:00
confirmBtn . addEventListener ( 'mouseenter' , () => {
if ( confirmBtn . classList . contains ( 'confirm-inactive' )) return ;
this . state . cursor = 4 ;
this . _renderCursor ();
});
2026-05-20 09:16:02 -07:00
container . appendChild ( confirmBtn );
container . _confirmBtn = confirmBtn ;
}
this . _renderCursor ();
}
pick ( chosen , correctIdx , q , container ) {
if ( this . state . answering ) return ;
this . state . answering = true ;
const btns = container . querySelectorAll ( '.choice-btn' );
const correct = chosen === correctIdx ;
btns . forEach ( b =>{ b . classList . remove ( 'cursor' ); b . classList . add ( 'disabled' ); });
btns [ correctIdx ]. classList . remove ( 'disabled' ); btns [ correctIdx ]. classList . add ( 'correct' );
if ( ! correct ) btns [ chosen ]. classList . add ( 'wrong' );
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 );
}
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 );
}
/* ── RESULT ───────────────────────────────────────────── */
showResult ( correct , q ) {
this . show ( 'result' );
this . _showController ( false );
document . getElementById ( 'result-icon' ). textContent = correct ? '✓' : '✗' ;
const lbl = document . getElementById ( 'result-label' );
lbl . textContent = correct ? CORRECT_FB [ Math . floor ( Math . random () * CORRECT_FB . length )]
: WRONG_FB [ Math . floor ( Math . random () * WRONG_FB . length )];
lbl . className = 'result-label ' + ( correct ? 'correct' : 'wrong' );
const ansEl = document . getElementById ( 'result-correct-ans' );
const ptsEl = document . getElementById ( 'result-pts' );
if ( correct ){
ptsEl . textContent = this . state . streak >= 5 ? '+200 PTS 🔥 STREAK!' : this . state . streak >= 3 ? '+150 PTS 🔥 HOT!' : '+100 PTS' ;
ptsEl . style . display = 'block' ; ansEl . classList . remove ( 'visible' );
} else {
ptsEl . style . display = 'none' ;
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 = ()=>{
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 ();
};
}
/* ── LEVEL UP ─────────────────────────────────────────── */
showLevelUp () {
this . show ( 'levelup' );
SFX . levelUp ();
this . _showController ( false );
const ms = MILESTONES [ this . state . currentQ ];
const pct = ( this . state . currentQ / ( this . state . questions . length || 100 )) * 100 ;
document . getElementById ( 'lu-name' ). textContent = this . state . playerName || 'ASH' ;
document . getElementById ( 'lu-stars' ). textContent = ` ${ ms . stars } BADGE EARNED! ${ ms . stars } ` ;
document . getElementById ( 'lu-badge' ). textContent = ms . badge ;
document . getElementById ( 'lu-prog-label' ). textContent = ` ${ this . state . currentQ } / 100` ;
const bar = document . getElementById ( 'lu-prog-inner' );
if ( bar ) bar . style . width = pct + '%' ;
document . getElementById ( 'btn-lu-cont' ). onclick = ()=> this . showMap ();
}
/* ── COMPLETE ─────────────────────────────────────────── */
showComplete () {
this . show ( 'complete' );
SFX . complete ();
this . _showController ( false );
const name = this . state . playerName || 'ASH' , tot = this . state . questions . length || 100 ;
const acc = tot > 0 ? Math . round (( this . state . correct / tot ) * 100 ) : 0 ;
document . getElementById ( 'complete-name' ). textContent = name ;
document . getElementById ( 'complete-stats' ). textContent =
`FINAL SCORE: ${ this . state . score . toLocaleString () } \n` +
`CORRECT: ${ this . state . correct } / ${ tot } \n` +
`ACCURACY: ${ acc } %\n` +
`BEST STREAK: ${ this . state . maxStreak } 🔥` ;
document . getElementById ( 'btn-play-again' ). onclick = ()=>{ clearSave (); this . startFresh (); };
}
/* ── SCREEN SWITCH ────────────────────────────────────── */
show ( name ) {
Object . values ( this . screens ). forEach ( s => s . classList . remove ( 'active' ));
const t = this . screens [ name ]; if ( t ) t . classList . add ( 'active' );
this . state . screen = name ;
}
/* ── TYPEWRITER ───────────────────────────────────────── */
typeText ( id , text , onDone ) {
if ( this . state . twTimer ){ clearInterval ( this . state . twTimer ); this . state . twTimer = null ;}
const el = document . getElementById ( id ); if ( ! el ) return ; el . textContent = '' ;
let i = 0 ;
this . state . twTimer = setInterval (()=>{
if ( i < text . length ){ el . textContent += text [ i ++ ]; if ( i % 2 === 0 ) SFX . type ();}
else { clearInterval ( this . state . twTimer ); this . state . twTimer = null ; if ( onDone ) onDone ();}
}, 26 );
}
/* ── TOAST ────────────────────────────────────────────── */
toast ( msg , ms = 2600 ){
const el = document . getElementById ( 'toast' );
el . textContent = msg ; el . classList . remove ( 'hidden' );
setTimeout (()=> el . classList . add ( 'hidden' ), ms );
}
}
window . addEventListener ( 'DOMContentLoaded' ,()=>{ new Game (); });