diff --git a/README.md b/README.md
index c0e8478..9b2da01 100644
--- a/README.md
+++ b/README.md
@@ -1,19 +1,7 @@
-# ๐ฎ PokรฉAzure โ Azure you ready?
+# ๐ฎ PokรฉAzure โ Learn Azure Cloud
-A Pokรฉmon-style Game Boy Advance browser game for **studying Microsoft Azure certification exams**!
-Journey through towns, each covering a different Azure exam. Play on your phone, tablet, or PC โ progress saves automatically.
-
-> *"Gotta cert 'em all!"*
-
-**Current towns:**
-| Town | Exam | Status |
-|---|---|---|
-| ๐๏ธ AZ-900 Town | AZ-900: Azure Fundamentals | โ
Available |
-| ๐๏ธ Administrator City | AZ-104: Azure Administrator | ๐ Coming soon |
-| ๐๏ธ Developer District | AZ-204: Azure Developer | ๐ Coming soon |
-| ๐ Security Stronghold | AZ-500: Azure Security | ๐ Coming soon |
-
----
+A Pokรฉmon-style Game Boy Advance browser game for learning **Microsoft Azure** and preparing for the **AZ-900** exam!
+Play it on your phone, tablet, or PC โ progress is saved automatically to your browser.
## ๐ Play It Live
๐ [Launch PokรฉAzure](https://justinoros.github.io/pokeazure)
@@ -27,15 +15,20 @@ pokeazure/
โโโ index.html โ Game screens & structure
โโโ style.css โ GBA Pokรฉmon aesthetic, fully responsive
โโโ game.js โ Game engine, controller, walking, save system
-โโโ questions.json โ Questions & answers (organised by town/exam)
-โโโ music.mp3 โ Overworld background music (looping)
-โโโ music-rival.mp3 โ Battle/question screen music (looping)
-โโโ player.png โ Player sprite sheet
+โโโ config.js โ Game-specific config (name, text, badges, milestones)
+โโโ questions.json โ 100 AZ-900 questions & answers
+โโโ player.png โ Player sprite sheet (24 frames, 16ร32px)
โโโ oak.png โ Professor Oak NPC sprite
โโโ gary.png โ Rival Gary NPC sprite
โโโ joy.png โ Nurse Joy NPC sprite
โโโ jenny.png โ Officer Jenny NPC sprite
-โโโ favicon.ico โ Browser tab icon
+โโโ brock.png โ Brock NPC sprite
+โโโ misty.png โ Misty NPC sprite
+โโโ taro.png โ Hiker Taro NPC sprite
+โโโ ralph.png โ Fisherman Ralph NPC sprite
+โโโ music.mp3 โ Background music (overworld)
+โโโ music-rival.mp3 โ Background music (rival/battle)
+โโโ favicon.ico โ Browser tab icon / Pokรฉball
โโโ README.md
```
@@ -44,7 +37,7 @@ pokeazure/
## ๐น๏ธ Host on GitHub Pages (free)
1. Create a GitHub repo named **`pokeazure`**
-2. Upload all files to the root of the repo
+2. Upload all files listed above
3. Go to **Settings โ Pages โ Source: `main` branch, `/ (root)` folder**
4. Hit **Save** โ live in ~60 seconds at `https://justinoros.github.io/pokeazure`
@@ -52,55 +45,76 @@ No server, no database, no cost.
---
-## ๐ฎ Controls
+## ๐ฎ GBA Controller Overlay
-### Mobile Portrait
-A semi-transparent GBA-style controller overlays the bottom of the screen, split into two panels:
-- **Left panel**: D-pad + SELECT/START
-- **Right panel**: A and B buttons
+On mobile, a semi-transparent Game Boy Advance-style controller overlays the bottom of the screen:
-### Mobile Landscape
-The controller moves to side panels flanking the game screen:
-- **Left panel**: D-pad + SELECT/START (stacked vertically)
-- **Right panel**: A and B buttons (diagonal layout)
+| Control | Action |
+|---|---|
+| **D-pad** | Walk the player around the map |
+| **A button** (red) | Talk to NPC ยท Confirm answer ยท Advance dialog |
+| **B button** (blue) | Talk to NPC ยท Advance dialog |
+| **SELECT / START** | Decorative |
+
+The controller is hidden on desktop (โฅ900px wide) โ use the keyboard instead.
+
+---
+
+## ๐น๏ธ Xbox / Gamepad Support
+
+Plug in any Xbox, PlayStation, or USB gamepad and it works automatically:
+
+| Input | Action |
+|---|---|
+| **Left stick / D-pad** | Walk on map |
+| **A button** | Talk to NPC ยท Confirm answer ยท Advance dialog |
+| **B button** | Talk to NPC ยท Advance dialog |
+| **Start** | Confirm / advance on all screens |
+| **X / Y / LB / RB** | Quick-pick answers 1/2/3/4 in battle |
+
+---
+
+## โจ๏ธ PC Keyboard Controls
-### PC Keyboard
| Key | Action |
|---|---|
| **WASD / Arrow keys** | Walk player on map |
| **E / Enter** | Talk to NPC ยท Confirm answer ยท Advance dialog |
| **X** | Talk to NPC ยท Advance dialog |
-| **1 / 2 / 3 / 4** | Quick-pick answer |
+| **1 / 2 / 3 / 4** | Quick-pick answer directly |
| **Space** | Advance dialog |
---
-## ๐บ๏ธ Map & Gameplay
+## ๐บ๏ธ Map & Walking
-- Player walks freely in all 4 directions across a large 2D world
-- A directional arrow indicates where the next NPC is โ hides when the NPC is on screen
-- Walk up to an NPC and press E/A to start a question
-- After answering, the next NPC spawns in a random direction (never the opposite of the last)
-- Trees render above the player for depth
-- NPCs float with a gentle animation
-
----
-
-## ๐ต Music
-
-- **Overworld** (`music.mp3`): plays while walking the map
-- **Battle** (`music-rival.mp3`): plays during question/answer screen
-- **Result screen**: silent
-- Toggle mute with the ๐ต button in the HUD
+- The player sprite walks freely around the overworld map using the D-pad, WASD, arrow keys, or Xbox controller
+- Walking animations use the `player.png` sprite sheet with directional frames
+- A blinking `โถ` arrow on the right edge points toward the next NPC
+- A `...` speech bubble appears above the player if you press talk before reaching the NPC
+- Press **A**, **B**, **E**, or **Enter** when near the NPC to start the question
+- Tapping the NPC sprite directly also triggers the conversation
---
## ๐พ Save System
-- **Auto-saves** after every answered question
-- **Continue screen** on return visits showing trainer name, progress, and score
-- **Shuffled question order** is saved so continuing always resumes at the correct question
-- **New Game** option prompts confirmation before erasing
+Progress is stored in the player's browser (`localStorage`) automatically:
+
+- **Auto-saves** after every answered question and on map return
+- **Shuffled question order** is saved so continuing a game resumes the exact same sequence
+- **Continue screen** appears on next visit showing trainer name, progress %, and score
+- **New Game** option always available โ prompts confirmation before erasing save
+- Save clears automatically on completion so the next run starts fresh
+
+---
+
+## ๐ต Music
+
+- Background music plays automatically on the map (`music.mp3`)
+- Music pauses during questions and resumes on return to the map
+- **๐ต / ๐ toggle** in the map header to mute/unmute
+- Music resumes correctly after switching browser tabs on mobile
---
@@ -108,62 +122,48 @@ The controller moves to side panels flanking the game screen:
| Feature | Detail |
|---|---|
-| Free 2D movement | Walk in any direction, camera follows player |
-| Random NPC directions | Next NPC spawns in a random direction each leg (no U-turns) |
-| PNG NPC sprites | NPCs use pixel art PNG files; emoji fallback for unassigned NPCs |
-| Mobile portrait & landscape | Fully optimised controller layout for both orientations |
-| Single & multi-select questions | "Choose two" shows CONFIRM button; single select is immediate |
-| Shuffled question order | Different order every new game |
+| GBA controller overlay | D-pad + A/B buttons, semi-transparent, mobile only |
+| Xbox / gamepad support | Full navigation on all screens including name entry |
+| Walking player | Pixel sprite sheet, directional walk animations |
+| Mobile-first design | Full-screen on phones with safe-area support, framed on desktop |
+| localStorage save | Auto-save with shuffled order preserved on continue |
+| Starter Pokรฉmon selection | Choose Charmander, Squirtle, or Bulbasaur at the start |
+| Wild Pokรฉmon encounters | Wild Pokรฉmon spawn on the map โ walk into them to catch |
+| Pokรฉball & catching system | Throw Pokรฉballs to catch wild Pokรฉmon and build your party |
+| Pokรฉmon party | Manage a party of caught Pokรฉmon, switch your active battler |
+| 100 AZ-900 questions | Randomised order every new game, shuffled answer slots |
+| Multi-select questions | Some questions require choosing two correct answers |
+| Numbered answers | Options labelled 1/2/3/4 โ no A/B/C/D pattern to exploit |
+| PNG NPC sprites | NPCs can use image files or emoji |
+| Name entry | On-screen pixel keyboard navigable by gamepad |
+| Typewriter text | Authentic dialog effect, tap/press/gamepad to skip |
+| `...` proximity bubble | Shows above player when pressing talk too far from NPC |
+| Background music | Loops on map, pauses during questions, mute toggle |
| Streak bonuses | 3ร correct = +150 pts ๐ฅ, 5ร+ = +200 pts ๐ฅ |
-| Milestone badges | Cloud Explorer (Q25), Azure Apprentice (Q50), Cloud Practitioner (Q75), AZ-900 Champion (Q100) |
+| Milestone badges | Earned at Q25, Q50, Q75, Q100 |
| Correct answer reveal | Wrong answers show the right answer + full explanation |
-| Tap anywhere to continue | Result screen advances on any tap/click, not just CONTINUE button |
-| Rival battle music | Separate music track plays during questions |
-| Player faces last direction | Idle sprite faces the last direction walked |
+| Scanline overlay | Authentic CRT/GBA screen effect |
---
-## ๐ AZ-900 Topics Covered (AZ-900 Town)
+## ๐ Topics Covered
-| Domain | Topics |
+| Questions | Topics |
|---|---|
-| Cloud Concepts | IaaS / PaaS / SaaS, public / private / hybrid cloud, CapEx vs OpEx, elasticity, scalability, high availability, disaster recovery |
-| Core Azure Services | Regions, availability zones, resource groups, subscriptions, management groups, VMs, App Service, Azure Functions, Blob/File storage tiers, VNet, VPN Gateway, ExpressRoute, Load Balancer, Cosmos DB, Azure SQL |
-| Security & Identity | Microsoft Entra ID, RBAC, MFA, Conditional Access, SSO, Key Vault, NSGs, Azure Firewall, DDoS Protection, Microsoft Defender for Cloud, defence in depth, shared responsibility model |
-| Management & Governance | Azure Policy, Blueprints, Resource locks, Tags, ARM templates, Azure CLI, Azure PowerShell, Cloud Shell, Azure Portal, Azure Arc, Azure Migrate, Microsoft Purview |
-| Monitoring & Health | Azure Monitor, Azure Advisor, Azure Service Health, Application Insights, health advisories, RCA reports |
-| Pricing & Cost Management | Pricing Calculator, TCO Calculator, Azure Reservations, Spot VMs, Cost Management + Billing, budgets, pay-as-you-go |
-
----
-
-## โ Adding a New Town (Exam)
-
-Add a new level object to the `levels` array in `questions.json`:
-
-```json
-{
- "levels": [
- {
- "id": 1,
- "name": "AZ-900 Town",
- "exam": "AZ-900",
- "questions": [ ... ]
- },
- {
- "id": 2,
- "name": "Administrator City",
- "exam": "AZ-104",
- "questions": [ ... ]
- }
- ]
-}
-```
+| 1โ15 | Regions, availability zones, VPNs, virtual networks, resource tags, reservations, locks |
+| 16โ25 | Azure Cloud Shell, CLI tools, Advisor, Monitor, Service Health, Application Insights |
+| 26โ35 | Subscriptions, management groups, VMs, serverless, Blob storage tiers |
+| 36โ50 | Defense in depth, SSO, Conditional Access, RBAC, MFA, cloud models (public/private/hybrid) |
+| 51โ65 | IaaS / PaaS / SaaS, cloud pricing, Cost Management, regions, resource groups, Entra ID |
+| 66โ80 | NSGs, ExpressRoute, Load Balancer, App Service, Cosmos DB, CapEx vs OpEx, Well-Architected Framework |
+| 81โ100 | ARM Templates, Azure Portal, CLI, least privilege, Defender, shared responsibility, Blueprints, GRS, CAF, Private Link, Arc, DevOps, Sentinel, MFA, elasticity |
---
## โ Adding Questions
-**Single-answer:**
+Edit `questions.json`, add to the `questions` array inside `levels[0]`:
+
```json
{
"id": 101,
@@ -175,58 +175,47 @@ Add a new level object to the `levels` array in `questions.json`:
}
```
-**Multi-select (choose two):**
+For a multi-select question (choose two), use `answers` instead of `answer`:
+
```json
{
"id": 102,
- "npc": "Nurse Joy",
- "text": "Which two options are correct? (Choose two)",
- "options": ["Option A", "Option B", "Option C", "Option D"],
+ "npc": "Misty",
+ "text": "Which two of these are Azure compute services? (Choose two)",
+ "options": ["Azure VMs", "Azure Blob", "Azure Functions", "Azure DNS"],
"answers": [0, 2],
- "explanation": "Options A and C are correct because..."
+ "explanation": "Azure VMs and Azure Functions are both compute services."
}
```
+`answer` / `answers` use the **0-based index** of the correct option(s). The game shuffles display order randomly at runtime.
+
---
-## ๐ง Adding NPC Sprites
+## ๐ง Adding NPCs
-In `game.js`, update the `NPC` object at the top:
+In `game.js`, add to the `NPC` object:
```js
const NPC = {
- 'Professor Oak': 'oak.png', // PNG file in repo root
- 'Nurse Joy': 'joy.png',
- 'Brock': '๐ง', // emoji fallback still works
+ 'Your NPC Name': 'npc.png', // PNG sprite file in repo root
+ 'Another NPC': '๐ง', // or an emoji
};
```
-- PNG filenames are relative to the repo root (no path needed)
-- Any NPC not in the object falls back to `๐ง`
+Then reference `"npc": "Your NPC Name"` in `questions.json`. PNG files are auto-prefixed with `./` so just use the filename.
---
-## ๐ง Rebranding (CONFIG object)
+## โ๏ธ Customising the Game
-To create a version for a different exam set, update the `CONFIG` object at the top of `game.js`:
-
-```js
-const CONFIG = {
- gameName: 'PokรฉAzure',
- subject: 'Azure',
- examName: 'AZ-900',
- townName: 'AZ-900 Town',
- saveKey: 'pokeazure_save_v1',
- introLines: [ ... ],
- namePrompt: '...',
-};
-```
+All game-specific text lives in **`config.js`** โ the only file that differs between PokรฉAzure and its sister game PokรฉSQL. Edit it to change the game name, town name, intro dialog, badge names, battle move names, and more without touching `game.js` or `index.html`.
---
## ๐ฑ Mobile Tips
- Add to Home Screen on iOS/Android for a full-screen app-like experience
-- The game uses `100dvh` and `env(safe-area-inset-*)` for notched phones
-- Tap anywhere on the result screen to continue (no need to find the CONTINUE button)
-- Rotate to landscape for a wider game view with side-panel controls
+- The game uses `100dvh` and `env(safe-area-inset-bottom)` for notched phones (iPhone X+)
+- Tap anywhere on the dialog box during the intro to skip the typewriter and advance
+- Tap the NPC sprite directly on the map as an alternative to pressing A/B
diff --git a/config.js b/config.js
new file mode 100644
index 0000000..cfb188d
--- /dev/null
+++ b/config.js
@@ -0,0 +1,33 @@
+const CONFIG = {
+ gameName: 'PokรฉAzure',
+ subject: 'Azure',
+ examName: 'AZ-900',
+ townName: 'Azure Town',
+ saveKey: 'pokeazure_save_v2',
+ titleText: 'AZURE',
+ titleEdition: 'โ
AZURE TRAINER โ
',
+ titleFooter: 'PokรฉAZURE v1.0 ยท AZ-900 ยท github.com/JustinOros',
+ townNameUpper:'AZURE TOWN',
+ continueLogoSuffix: 'AZURE',
+ expLabel: 'AZURE EXP',
+ battleMove: 'AZURE KNOWLEDGE',
+ battleMiss: 'Azure Cloud',
+ completeMsg: "You've conquered Azure Town and mastered AZ-900! Your Azure journey is just beginning โ more towns and exams await!",
+ badges: [
+ '๐ฅ AZURE EXPLORER โ Q25',
+ '๐ฅ AZURE APPRENTICE โ Q50',
+ '๐ฅ AZURE PRACTITIONER โ Q75',
+ '๐ AZURE CHAMPION โ Q100',
+ ],
+ milestones: {
+ 25: { badge: '๐ฅ AZURE EXPLORER', stars: 'โ
โ
โ โ' },
+ 50: { badge: '๐ฅ AZURE APPRENTICE', stars: 'โ
โ
โ
โ' },
+ 75: { badge: '๐ฅ AZURE PRACTITIONER', stars: 'โ
โ
โ
โ
' },
+ },
+ introLines: [
+ "Hello {name}! Welcome to PokรฉAzure โ the world of Azure certification training! My name is Professor Oak, the Azure Pokรฉmon Professor!",
+ "Your mission? Journey through AZ-900 Town, challenge Azure experts, and conquer 100 certification questions to become the ultimate Azure Trainer!",
+ "But first... every trainer needs a partner Pokรฉmon! Let's choose yours, {name}!",
+ ],
+ namePrompt: "Hello there! Welcome to PokรฉAzure! My name is Professor Oak โ the Azure Pokรฉmon Professor. Now tell me, what is your name?",
+};
diff --git a/game.js b/game.js
index f69778a..4c7af7d 100644
--- a/game.js
+++ b/game.js
@@ -1,21 +1,53 @@
-/* โโโ NPC ROSTER โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
-const CONFIG = {
- gameName: 'PokรฉAzure',
- subject: 'Azure',
- examName: 'AZ-900',
- townName: 'AZ-900 Town',
- saveKey: 'pokeazure_save_v1',
- introLines: [
- "Hello {name}! Welcome to PokรฉAzure โ the world of Azure certification training! My name is Professor Oak, the Azure Pokรฉmon Professor!",
- "This world is inhabited by powerful cloud services โ Compute, Storage, Networking, and Security โ mastered with knowledge!",
- "Your mission? Journey through each town, challenge Azure experts, and conquer every certification exam!",
- "Start in AZ-900 Town with AZ-900 โ then travel the Azure world to tackle Administrator, Developer, Security, and beyond!",
- "100 questions await you. Each correct answer earns you Azure 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รฉAzure! My name is Professor Oak โ the Azure Pokรฉmon 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;
@@ -61,22 +93,22 @@ const INTRO_MSGS = CONFIG.introLines.map(text => ({ speaker:'PROFESSOR OAK', npc
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 = CONFIG.saveKey;
+const MILESTONES = CONFIG.milestones;
-/* โโโ 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;
@@ -87,7 +119,7 @@ const Music = (() => {
if (!audio) {
audio = new Audio('./music.mp3');
audio.loop = true;
- audio.volume = 0.5;
+ audio.volume = 0.375;
audio.preload = 'auto';
audio.addEventListener('canplaythrough', () => {
if (wantPlay && !muted) audio.play().catch(() => {});
@@ -100,7 +132,7 @@ const Music = (() => {
if (!rival) {
rival = new Audio('./music-rival.mp3');
rival.loop = true;
- rival.volume = 0.5;
+ rival.volume = 0.375;
rival.preload = 'auto';
}
return rival;
@@ -152,7 +184,6 @@ const Music = (() => {
};
})();
-/* โโโ CHIPTUNE SOUND ENGINE โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
const SFX = (() => {
let ctx = null;
@@ -174,7 +205,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;
@@ -192,7 +222,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;
@@ -203,89 +232,48 @@ 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); },
+ backspace() { seq([{f:350,d:.05},{f:250,d:.08}], 'triangle', 0.12); },
+ 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); },
+ pokeballBounce() { seq([{f:300,d:.04},{f:500,d:.06},{f:200,d:.08}], 'triangle', 0.12); },
+ escape() { seq([{f:400,d:.08},{f:350,d:.08},{f:250,d:.15}], 'sawtooth', 0.12); },
+ attack() { seq([{f:200,d:.04},{f:400,d:.04},{f:600,d:.04},{f:800,d:.03},{f:1000,d:.03},{f:600,d:.06}], 'sawtooth', 0.2); },
+ battleWin() { seq([{f:523,d:.1},{f:659,d:.1},{f:784,d:.12},{f:1047,d:.15},{f:1319,d:.25}], 'square', 0.22); },
+ battleLose() { seq([{f:400,d:.12},{f:350,d:.12},{f:300,d:.14},{f:200,d:.25}], 'sawtooth', 0.18); },
};
})();
-/* โโโ 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 = {
@@ -298,6 +286,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 = {};
@@ -317,9 +314,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' };
@@ -347,36 +341,97 @@ class Game {
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-start').addEventListener('pointerdown', (e)=>{ e.preventDefault(); if (this.state.screen === 'name') { this.confirmName(); } else { this._pressA(); } });
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 === 'title') { document.getElementById('screen-title')?.click(); return; }
+ if (s === 'name') { this._nameKeyConfirm(); return; }
+ if (s === 'continue') {
+ if (this._contCursor === 1) document.getElementById('btn-new-game')?.click();
+ else document.getElementById('btn-continue-save')?.click();
+ return;
+ }
if (s === 'map') { this._talkToNPC(); return; }
if (s === 'battle' && !this.state.answering) { this._confirmCursor(); return; }
if (s === 'intro') { this.advanceIntro(); return; }
+ if (s === 'party-select') { this._partySelectConfirm(); return; }
+ if (s === 'starter') { this.pickStarter(this._starterCursor||0); return; }
+ if (s === 'starter-confirm') { document.getElementById('btn-starter-continue')?.click(); return; }
+ if (s === 'catch') { const btns = document.querySelectorAll('.catch-action-btn'); if (btns[this._catchCursor||0]) btns[this._catchCursor||0].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 === 'name') { this.delChar(); return; }
if (s === 'map') { this._talkToNPC(); return; }
if (s === 'battle' && this.state.answering) { return; }
+ if (s === 'battle' && !this.state.answering) { this._confirmCursor(); return; }
if (s === 'intro') { this.advanceIntro(); return; }
+ if (s === 'catch') { const btns = document.querySelectorAll('.catch-action-btn'); if (btns[this._catchCursor||0]) btns[this._catchCursor||0].click(); return; }
if (s === 'result') { document.getElementById('btn-result-cont')?.click(); 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 === 'levelup') { document.getElementById('btn-lu-cont')?.click(); return; }
}
- /* D-pad start hold */
_dpadStart(dir) {
+ const s = this.state.screen;
+ if (s === 'name') {
+ const COLS = 10;
+ const keys = this._getNameKeys();
+ const total = keys.length;
+ let c = this._gpKeyCursor;
+ const row = Math.floor(c / COLS);
+ const col = c % COLS;
+ const totalRows = Math.ceil(total / COLS);
+ if (dir === 'right') {
+ c = (col + 1 >= COLS || c + 1 >= total) ? row * COLS : c + 1;
+ }
+ if (dir === 'left') {
+ c = (col - 1 < 0) ? Math.min(row * COLS + COLS - 1, total - 1) : c - 1;
+ }
+ if (dir === 'down') {
+ c = c + COLS >= total ? col : c + COLS;
+ }
+ if (dir === 'up') {
+ c = c - COLS < 0 ? Math.min((totalRows - 1) * COLS + col, total - 1) : c - COLS;
+ }
+ this._gpKeyCursor = c;
+ this._renderNameCursor();
+ SFX.select();
+ return;
+ }
+ if (s === 'party-select' && (dir === 'up' || dir === 'down')) {
+ const max = (this._partySelectOrdered || []).length - 1;
+ this._partySelectCursor = dir === 'up' ? Math.max(0, this._partySelectCursor - 1) : Math.min(max, this._partySelectCursor + 1);
+ this._renderPartySelectCursor();
+ SFX.select();
+ return;
+ }
+ if (s === 'continue' && (dir === 'up' || dir === 'down')) {
+ this._contCursor = this._contCursor === 0 ? 1 : 0;
+ this._renderContCursor();
+ return;
+ }
+ 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();
+ return;
+ }
+ if (s === 'catch' && (dir === 'left' || dir === 'right')) {
+ this._catchCursor = dir === 'left' ? 0 : 1;
+ this._renderCatchCursor();
+ return;
+ }
+ if (s === 'battle') { this._moveCursor(dir); }
this._heldKeys.add(dir);
if (!this._walkLoop) this._startWalkLoop();
-
- const s = this.state.screen;
- if (s === 'battle') { this._moveCursor(dir); }
}
_dpadEnd(dir) {
@@ -384,9 +439,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);
@@ -430,6 +482,7 @@ class Game {
this.state.worldX += dx;
this.state.worldY += dy;
+
this.state.lastDir = dir;
this._applyCamera();
@@ -445,6 +498,7 @@ class Game {
if (bub) { bub.style.left = (cx - 4) + 'px'; bub.style.bottom = (bot + 66) + 'px'; }
}
this._checkNPCProximity();
+ this._checkWildProximity();
}
_applyCamera() {
@@ -455,6 +509,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() {
@@ -468,6 +523,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 };
@@ -481,6 +548,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();
@@ -499,10 +575,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;
@@ -520,8 +607,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';
@@ -541,6 +632,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;
@@ -559,11 +672,90 @@ 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();
- this.startQuestion();
+ const conscious = this.state.party.filter(p => !p.fainted);
+ if (conscious.length > 1) {
+ this.showPartySelect();
+ } else {
+ this.state.activePokemon = conscious[0] || this.state.party[0];
+ this.startQuestion();
+ }
+ }
+
+ showPartySelect() {
+ this.show('party-select');
+ this._showController(true);
+ this._partySelectCursor = 0;
+
+ const list = document.getElementById('party-select-list');
+ list.innerHTML = '';
+
+ const ordered = [...this.state.party].reverse().sort((a, b) => (a.fainted ? 1 : 0) - (b.fainted ? 1 : 0));
+
+ ordered.forEach((poke, i) => {
+ const entry = document.createElement('div');
+ entry.className = 'party-select-entry' + (poke.fainted ? ' fainted' : '');
+ if (i === 0 && !poke.fainted) entry.classList.add('party-selected');
+
+ const emojiWrap = document.createElement('div');
+ emojiWrap.className = 'party-select-emoji';
+ emojiWrap.textContent = poke.emoji;
+ if (poke.fainted) {
+ const zzz = document.createElement('span');
+ zzz.className = 'party-select-zzz';
+ zzz.textContent = 'zZz';
+ emojiWrap.appendChild(zzz);
+ }
+
+ const info = document.createElement('div');
+ info.className = 'party-select-info';
+ info.innerHTML = `
${poke.name.toUpperCase()}
${poke.type} ยท Lv ${poke.level}
`;
+
+ const badge = document.createElement('div');
+ badge.className = 'party-select-badge' + (poke.fainted ? ' badge-fainted' : '');
+ badge.textContent = poke.fainted ? 'FAINTED' : 'READY';
+
+ entry.appendChild(emojiWrap);
+ entry.appendChild(info);
+ entry.appendChild(badge);
+
+ if (!poke.fainted) {
+ entry.addEventListener('click', () => {
+ this.state.activePokemon = poke;
+ SFX.confirm();
+ this.startQuestion();
+ });
+ }
+
+ list.appendChild(entry);
+ });
+
+ this._partySelectOrdered = ordered;
+ this._renderPartySelectCursor();
+ }
+
+ _renderPartySelectCursor() {
+ const entries = document.querySelectorAll('.party-select-entry');
+ entries.forEach((e, i) => {
+ e.classList.toggle('party-selected', i === this._partySelectCursor && !e.classList.contains('fainted'));
+ if (i === this._partySelectCursor) {
+ e.scrollIntoView({ block:'nearest', behavior:'smooth' });
+ }
+ });
+ }
+
+ _partySelectConfirm() {
+ const ordered = this._partySelectOrdered;
+ if (!ordered) return;
+ const poke = ordered[this._partySelectCursor];
+ if (poke && !poke.fainted) {
+ this.state.activePokemon = poke;
+ SFX.confirm();
+ this.startQuestion();
+ }
}
_placeMapSprites() {
@@ -585,9 +777,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;
@@ -617,9 +806,6 @@ class Game {
if (target) target.click();
}
- /* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- GLOBAL KEYBOARD
- โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
_bindGlobalKeys() {
const DPAD_KEYS = {
'ArrowUp':'up','ArrowDown':'down','ArrowLeft':'left','ArrowRight':'right',
@@ -630,7 +816,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();
@@ -638,16 +823,101 @@ 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 === 'party-select') {
+ if (e.key==='ArrowUp'||e.key==='w'||e.key==='W') {
+ this._partySelectCursor = Math.max(0, this._partySelectCursor - 1);
+ this._renderPartySelectCursor();
+ SFX.select();
+ return;
+ }
+ if (e.key==='ArrowDown'||e.key==='s'||e.key==='S') {
+ const max = (this._partySelectOrdered || []).length - 1;
+ this._partySelectCursor = Math.min(max, this._partySelectCursor + 1);
+ this._renderPartySelectCursor();
+ SFX.select();
+ return;
+ }
+ if (e.key==='Enter'||e.key===' '||e.key==='e'||e.key==='E') {
+ this._partySelectConfirm();
+ return;
+ }
+ return;
+ }
+ if (s === 'continue') {
+ if (e.key==='ArrowUp'||e.key==='w'||e.key==='W'||e.key==='ArrowDown'||e.key==='s'||e.key==='S') {
+ this._contCursor = this._contCursor === 0 ? 1 : 0;
+ this._renderContCursor();
+ return;
+ }
+ if (e.key==='Enter'||e.key===' '||e.key==='e'||e.key==='E') {
+ if (this._contCursor === 1) document.getElementById('btn-new-game')?.click();
+ else document.getElementById('btn-continue-save')?.click();
+ return;
+ }
+ return;
+ }
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)) {
@@ -659,17 +929,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) {
@@ -694,21 +961,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;
@@ -717,8 +969,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();
}
@@ -752,7 +1002,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');
@@ -761,14 +1010,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;
@@ -784,7 +1030,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');
@@ -796,34 +1042,24 @@ 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');
- 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;
+ this._contCursor = this._contCursor === 0 ? 1 : 0;
+ this._renderContCursor();
}
-
- /* 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]) {
@@ -832,19 +1068,31 @@ class Game {
if (!this._walkLoop) this._startWalkLoop();
}
if (s === 'battle') this._moveCursor(dir);
+ if (s === 'party-select' && (dir === 'up' || dir === 'down')) {
+ const max = (this._partySelectOrdered || []).length - 1;
+ this._partySelectCursor = dir === 'up' ? Math.max(0, this._partySelectCursor - 1) : Math.min(max, this._partySelectCursor + 1);
+ this._renderPartySelectCursor();
+ SFX.select();
+ }
+ 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();
+ if (this._contCursor === 0) document.getElementById('btn-continue-save')?.click();
else document.getElementById('btn-new-game')?.click();
}
else if (s === 'intro') { this.advanceIntro(); }
@@ -852,10 +1100,14 @@ 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 === 'party-select') { this._partySelectConfirm(); }
+ 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(); }
@@ -863,7 +1115,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]) => {
@@ -876,12 +1127,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);
@@ -891,19 +1142,16 @@ class Game {
});
}
- /* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- SCREENS
- โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
boot() {
this.show('boot');
- this._showController(false);
+ this._showController(true);
setTimeout(() => this.showTitle(), 1800);
}
showTitle() {
this.show('title');
SFX.boot();
- this._showController(false);
+ this._showController(true);
const go = () => {
document.getElementById('screen-title').removeEventListener('click', go);
Music.unblock();
@@ -914,9 +1162,10 @@ 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);
+ this._showController(true);
+ this._contCursor = 0;
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}%)`;
@@ -928,13 +1177,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;
+ this._contCursor = 0;
setTimeout(() => {
const c = document.getElementById('btn-continue-save');
if (c) c.style.outline = '3px solid #f8c030';
}, 50);
} else {
+ clearSave();
this.startFresh();
}
}
@@ -945,6 +1194,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) {
@@ -959,14 +1213,16 @@ 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);
+ this._showController(true);
this._gpKeyCursor = 0;
this.buildKeyboard();
this.typeText('name-prompt-text', CONFIG.namePrompt);
@@ -984,20 +1240,49 @@ class Game {
b.addEventListener('pointerdown', (e)=>{ e.preventDefault(); this.addChar(ch); });
grid.appendChild(b);
});
+ this._gpKeyCursor = 0;
+ this._renderNameCursor();
+ }
+
+ _getNameKeys() {
+ const letterKeys = Array.from(document.querySelectorAll('.key-btn'));
+ const delBtn = document.getElementById('btn-backspace');
+ const okBtn = document.getElementById('btn-confirm-name');
+ return [...letterKeys, delBtn, okBtn].filter(Boolean);
+ }
+
+ _renderNameCursor() {
+ const keys = this._getNameKeys();
+ keys.forEach((k, i) => {
+ k.style.outline = i === this._gpKeyCursor ? '3px solid #f8c030' : '';
+ k.style.outlineOffset = i === this._gpKeyCursor ? '-2px' : '';
+ });
+ }
+
+ _nameKeyConfirm() {
+ const keys = this._getNameKeys();
+ const key = keys[this._gpKeyCursor];
+ if (!key) return;
+ if (key.classList.contains('key-btn')) {
+ this.addChar(key.textContent);
+ } else if (key.id === 'btn-backspace') {
+ this.delChar();
+ } else if (key.id === 'btn-confirm-name') {
+ this.confirmName();
+ }
}
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(); }
+ delChar() { this.state.playerName=this.state.playerName.slice(0,-1); this.refreshNameDisplay(); SFX.backspace(); }
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._showController(true);
this.state.introStep = 0;
this.renderIntroMsg();
@@ -1033,13 +1318,280 @@ 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() {
+ SFX.select();
+ 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));
+ });
+ }
+
+ _renderContCursor() {
+ const contBtn = document.getElementById('btn-continue-save');
+ const newBtn = document.getElementById('btn-new-game');
+ if (contBtn) contBtn.style.outline = this._contCursor === 0 ? '3px solid #f8c030' : '';
+ if (newBtn) newBtn.style.outline = this._contCursor === 1 ? '3px solid #f8c030' : '';
+ }
+
+ 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(true);
+
+ 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 = () => {
+ if (this.state.twTimer) { clearInterval(this.state.twTimer); this.state.twTimer = null; }
+ 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._throwing = false;
+
+ 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.innerHTML = '
THROW BALL';
+ 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);
+ this._renderCatchCursor();
+ }
+
+ throwPokeball(wild, canCatch) {
+ if (this._throwing) return;
+ if (this.state.pokeballs <= 0) return;
+ this._throwing = true;
+ this.state.pokeballs--;
+ SFX.pokeball();
+
+ const catchScreen = document.getElementById('screen-catch');
+ const pokemonEl = document.getElementById('catch-wild-pokemon');
+ const nameEl = document.getElementById('catch-wild-name');
+ const levelEl = document.getElementById('catch-wild-level');
+
+ const ball = document.createElement('img');
+ ball.src = './favicon.ico';
+ ball.className = 'pokeball-throw';
+ catchScreen.appendChild(ball);
+
+ setTimeout(() => {
+ const pokeRect = pokemonEl.getBoundingClientRect();
+ const screenRect = catchScreen.getBoundingClientRect();
+ const burstX = pokeRect.left - screenRect.left + pokeRect.width / 2;
+ const burstY = pokeRect.top - screenRect.top + pokeRect.height * 0.25;
+
+ const burst = document.createElement('div');
+ burst.className = 'pokeball-burst';
+ burst.style.left = burstX + 'px';
+ burst.style.top = burstY + 'px';
+ catchScreen.appendChild(burst);
+
+ ball.remove();
+ pokemonEl.style.opacity = '0';
+ pokemonEl.style.transform = 'scale(0)';
+ pokemonEl.style.transition = 'opacity .2s, transform .2s';
+ if (nameEl) nameEl.style.display = 'none';
+ if (levelEl) levelEl.style.display = 'none';
+
+ const restBall = document.createElement('img');
+ restBall.src = './favicon.ico';
+ restBall.className = 'pokeball-rest';
+ restBall.style.left = (burstX - 20) + 'px';
+ restBall.style.top = (burstY + 20) + 'px';
+ catchScreen.appendChild(restBall);
+
+ setTimeout(() => burst.remove(), 500);
+
+ let bounceCount = 0;
+ const doBounce = () => {
+ const maxBounces = canCatch ? 3 : 2;
+ if (bounceCount >= maxBounces) {
+ restBall.remove();
+ pokemonEl.style.opacity = '1';
+ pokemonEl.style.transform = '';
+ pokemonEl.style.transition = '';
+ if (nameEl) nameEl.style.display = '';
+ if (levelEl) levelEl.style.display = '';
+ this._showCatchResult(wild, canCatch);
+ return;
+ }
+ bounceCount++;
+ SFX.pokeballBounce();
+ restBall.classList.remove('pokeball-bounce-anim');
+ void restBall.offsetWidth;
+ restBall.classList.add('pokeball-bounce-anim');
+ setTimeout(doBounce, 600);
+ };
+
+ setTimeout(doBounce, 600);
+ }, 700);
+ }
+
+ _showCatchResult(wild, canCatch) {
+ this._throwing = false;
+ this.show('catch-result');
+ this._showController(true);
+
+ const resultEmoji = document.getElementById('catch-result-emoji');
+ const resultMsg = document.getElementById('catch-result-msg');
+ const continueBtn = document.getElementById('btn-catch-continue');
+ if (continueBtn) continueBtn.style.display = 'none';
+
+ if (canCatch) {
+ setTimeout(() => SFX.catch(), 500);
+ resultEmoji.textContent = wild.emoji;
+ resultMsg.innerHTML = `
+ GOTCHA!
+ ${wild.name} (Lv ${wild.level}) was caught!
+ Added to your party!
+ `;
+ this.state.party.push({ ...wild });
+ this.state.caughtCount++;
+ } else {
+ setTimeout(() => SFX.escape(), 300);
+ resultEmoji.textContent = '๐จ';
+ resultMsg.innerHTML = `
+ OH NO!
+ ${wild.name} (Lv ${wild.level}) broke free and escaped!
+ The Pokรฉmon was too strong! Train more to catch stronger Pokรฉmon.
+ `;
+ }
+
+ this.state.wildSpawned = false;
+ this.state.currentWild = null;
+ const wildWrap = document.getElementById('map-wild-wrap');
+ if (wildWrap) wildWrap.style.display = 'none';
+
+ setTimeout(() => {
+ if (this.state.screen === 'catch-result') {
+ saveGame(this.state);
+ this.showMap();
+ }
+ }, 3000);
+ }
+
loadQuestions(cb) {
if (this.state.questions.length>0) { cb(); return; }
fetch('./questions.json')
@@ -1056,9 +1608,6 @@ class Game {
.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. */
_pickNextDir(currentDir) {
const opposite = { right:'left', left:'right', up:'down', down:'up' };
const opp = opposite[currentDir];
@@ -1078,6 +1627,8 @@ class Game {
const cy = spread;
const npcX = cx + this.state.npcWorldX;
const npcY = cy + this.state.npcWorldY;
+ const occupied = [];
+ this._trees = [];
for (let i = 0; i < 300; i++) {
const span = document.createElement('span');
span.className = 'tree';
@@ -1088,6 +1639,8 @@ class Game {
y = cy + (Math.random() - 0.5) * spread * 1.8;
tries++;
} while (tries < 10 && Math.abs(x - npcX) < 80 && Math.abs(y - npcY) < 80);
+ occupied.push({ x, y, r: size * 0.6 });
+ this._trees.push({ wx: x - cx, wy: y - cy, r: size * 0.35 });
span.textContent = '๐ณ';
span.style.fontSize = size + 'px';
span.style.left = x + 'px';
@@ -1096,6 +1649,51 @@ class Game {
span.style.animationDuration = (2.5 + Math.random() * 2).toFixed(1) + 's';
container.appendChild(span);
}
+ const grassEmojis = ['๐พ'];
+ for (let i = 0; i < 2400; i++) {
+ let x, y, tries = 0, tooClose;
+ do {
+ x = cx + (Math.random() - 0.5) * spread * 1.8;
+ y = cy + (Math.random() - 0.5) * spread * 1.8;
+ tries++;
+ tooClose = occupied.some(t => Math.abs(x - t.x) < t.r && Math.abs(y - t.y) < t.r);
+ } while (tries < 12 && (tooClose || (Math.abs(x - npcX) < 80 && Math.abs(y - npcY) < 80)));
+ if (tooClose) continue;
+ const span = document.createElement('span');
+ span.className = 'tall-grass';
+ const size = 5 + Math.random() * 4;
+ span.textContent = grassEmojis[Math.floor(Math.random() * grassEmojis.length)];
+ span.style.fontSize = size + 'px';
+ span.style.left = x + 'px';
+ span.style.top = y + 'px';
+ span.style.animationDelay = (Math.random() * 4).toFixed(2) + 's';
+ span.style.animationDuration = (2 + Math.random() * 2).toFixed(1) + 's';
+ if (Math.random() > 0.5) span.classList.add('tall-grass-flip');
+ container.appendChild(span);
+ }
+ this._rocks = [];
+ for (let i = 0; i < 90; i++) {
+ let x, y, tries = 0, tooClose;
+ do {
+ x = cx + (Math.random() - 0.5) * spread * 1.8;
+ y = cy + (Math.random() - 0.5) * spread * 1.8;
+ tries++;
+ tooClose = occupied.some(t => Math.abs(x - t.x) < t.r && Math.abs(y - t.y) < t.r);
+ } while (tries < 12 && (tooClose || (Math.abs(x - npcX) < 80 && Math.abs(y - npcY) < 80)));
+ if (tooClose) continue;
+ occupied.push({ x, y, r: 20 });
+ const wx = x - cx;
+ const wy = y - cy;
+ this._rocks.push({ wx, wy, r: 22 });
+ const span = document.createElement('span');
+ span.className = 'map-rock';
+ const size = 11 + Math.random() * 6;
+ span.textContent = '๐ชจ';
+ span.style.fontSize = size + 'px';
+ span.style.left = x + 'px';
+ span.style.top = y + 'px';
+ container.appendChild(span);
+ }
}
_preGenerateNextLeg() {
@@ -1141,6 +1739,7 @@ class Game {
this.state.npcSpawned = true;
this._generateTrees();
+ this._spawnWildPokemon();
document.getElementById('hud-name').textContent = this.state.playerName||'ASH';
this.updateHUD();
@@ -1165,15 +1764,23 @@ 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();
document.getElementById('hud-streak').textContent = this.state.streak+(this.state.streak>=3?'๐ฅ':'');
- document.getElementById('hud-correct').textContent = `${this.state.correct}/${done}`;
+ const partyEl = document.getElementById('hud-party');
+ if (partyEl) {
+ partyEl.textContent = this.state.party.map(p => p.emoji).join('');
+ if (this.state.party.length === 0) partyEl.textContent = 'โ';
+ }
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'; }
+
+ const pbEl = document.getElementById('pokeball-count');
+ if (pbEl) pbEl.innerHTML = `
${this.state.pokeballs}`;
+
+ this.state.playerLevel = getPlayerLevel(this.state);
}
flashSaveDot() {
@@ -1182,23 +1789,43 @@ class Game {
setTimeout(()=>{ dot.style.opacity='.4'; },1500);
}
- /* โโ BATTLE โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+ _getActivePokemon() {
+ if (this.state.activePokemon && !this.state.activePokemon.fainted) return this.state.activePokemon;
+ const conscious = this.state.party.filter(p => !p.fainted);
+ if (conscious.length > 0) return conscious[0];
+ if (this.state.party.length === 0) return { name:'MissingNo', emoji:'โ', type:'???', level:1 };
+ return this.state.party[0];
+ }
+
startQuestion() {
this._stopWalkLoop();
this._heldKeys.clear();
const q=this.state.questions[this.state.currentQ];
if(!q){ this.showComplete(); return; }
+ const playerPoke = this._getActivePokemon();
+ const npcPoke = randomPokemonForNPC();
+ this.state.currentNPCPokemon = npcPoke;
+
this.show('battle');
this._showController(true);
Music.playBattle();
this.state.answering=false;
this.state.cursor=0;
- setNPC(document.getElementById('battle-npc-sprite'), NPC[q.npc]||'๐ง');
+ document.getElementById('battle-npc-pokemon').textContent = npcPoke.emoji;
+ document.getElementById('battle-npc-pokemon-name').textContent = npcPoke.name.toUpperCase();
+ document.getElementById('battle-npc-pokemon-level').textContent = `Lv ${npcPoke.level}`;
document.getElementById('battle-npc-name').textContent = q.npc.toUpperCase();
- document.getElementById('battle-speaker').textContent = q.npc.toUpperCase();
+ const npcTrainer = document.getElementById('battle-npc-trainer');
+ if (npcTrainer) setNPC(npcTrainer, NPC[q.npc] || '๐ง');
+
+ document.getElementById('battle-player-pokemon').textContent = playerPoke.emoji;
+ document.getElementById('battle-player-pokemon-name').textContent = playerPoke.name.toUpperCase();
+ document.getElementById('battle-player-pokemon-level').textContent = `Lv ${playerPoke.level}`;
document.getElementById('battle-player-name').textContent= (this.state.playerName||'ASH').toUpperCase();
+
+ document.getElementById('battle-speaker').textContent = q.npc.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';
@@ -1302,12 +1929,15 @@ class Game {
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;
+ this.state.pokeballs += 3;
if(this.state.streak>=3) SFX.streak(); else SFX.correct();
} else { this.state.streak=0; this.state.wrong++; SFX.wrong(); }
+ this.state.playerLevel = getPlayerLevel(this.state);
+
setTimeout(()=>{
this._preGenerateNextLeg();
- this.showResult(correct,q);
+ this.showBattleAnim(correct, q);
},120);
}
@@ -1332,19 +1962,101 @@ class Game {
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;
+ this.state.pokeballs += 3;
if(this.state.streak>=3) SFX.streak(); else SFX.correct();
} else { this.state.streak=0; this.state.wrong++; SFX.wrong(); }
+ this.state.playerLevel = getPlayerLevel(this.state);
+
setTimeout(()=>{
this._preGenerateNextLeg();
- this.showResult(correct,q);
+ this.showBattleAnim(correct, q);
},120);
}
- /* โโ RESULT โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+ showBattleAnim(correct, q) {
+ this.show('battle-anim');
+ this._showController(false);
+
+ const playerPoke = this._getActivePokemon();
+ const npcPoke = this.state.currentNPCPokemon || { name:'???', emoji:'โ', level:1 };
+
+ const npcTrainer = document.getElementById('anim-npc-trainer');
+ if (npcTrainer) {
+ const npcQ = this.state.questions[this.state.currentQ];
+ setNPC(npcTrainer, NPC[npcQ?.npc] || '๐ง');
+ }
+ const playerTrainer = document.getElementById('anim-player-trainer');
+ if (playerTrainer) playerTrainer.innerHTML = '';
+
+ document.getElementById('anim-player-pokemon-name').textContent = playerPoke.name.toUpperCase();
+ document.getElementById('anim-player-pokemon').textContent = playerPoke.emoji;
+ document.getElementById('anim-npc-pokemon-name').textContent = npcPoke.name.toUpperCase();
+ document.getElementById('anim-npc-pokemon').textContent = npcPoke.emoji;
+
+ const playerHp = document.getElementById('anim-player-hp');
+ const npcHp = document.getElementById('anim-npc-hp');
+ playerHp.style.width = '100%';
+ npcHp.style.width = '100%';
+ playerHp.style.background = 'var(--hp-green)';
+ npcHp.style.background = 'var(--hp-green)';
+
+ const playerSprite = document.getElementById('anim-player-pokemon');
+ const npcSprite = document.getElementById('anim-npc-pokemon');
+ playerSprite.style.opacity = '1';
+ playerSprite.style.transform = '';
+ npcSprite.style.opacity = '1';
+ npcSprite.style.transform = '';
+
+ const msg = document.getElementById('battle-anim-msg');
+
+ if (correct) {
+ msg.textContent = `${playerPoke.name} used ${CONFIG.battleMove}!`;
+ SFX.attack();
+ setTimeout(() => {
+ npcHp.style.width = '0%';
+ npcHp.style.background = 'var(--red)';
+ msg.textContent = `It's super effective! ${npcPoke.name} fainted!`;
+ npcSprite.style.opacity = '0.3';
+ npcSprite.style.transform = 'translateY(20px) scale(0.5)';
+ SFX.battleWin();
+ }, 800);
+ setTimeout(() => {
+ npcSprite.style.opacity = '1';
+ npcSprite.style.transform = '';
+ this.showResult(true, q);
+ }, 2200);
+ } else {
+ msg.textContent = `${playerPoke.name} used ${CONFIG.battleMiss}... but it missed!`;
+ SFX.attack();
+ setTimeout(() => {
+ msg.textContent = `${npcPoke.name} used COUNTER ATTACK!`;
+ SFX.attack();
+ }, 800);
+ setTimeout(() => {
+ playerHp.style.width = '0%';
+ playerHp.style.background = 'var(--red)';
+ msg.textContent = `${playerPoke.name} fainted!`;
+ playerSprite.style.opacity = '0.3';
+ playerSprite.style.transform = 'translateY(20px) scale(0.5)';
+ SFX.battleLose();
+ playerPoke.fainted = true;
+ const conscious = this.state.party.filter(p => !p.fainted);
+ if (conscious.length === 0) {
+ this.state.party[0].fainted = false;
+ }
+ }, 1400);
+ setTimeout(() => {
+ playerSprite.style.opacity = '1';
+ playerSprite.style.transform = '';
+ this.showResult(false, q);
+ }, 2800);
+ }
+ }
+
showResult(correct,q) {
this.show('result');
- this._showController(false);
+ this._showController(true);
Music.stopBattle();
Music.pause();
@@ -1356,11 +2068,16 @@ class Game {
const ansEl=document.getElementById('result-correct-ans');
const ptsEl=document.getElementById('result-pts');
+ const pbEl=document.getElementById('result-pokeballs');
+
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');
+ pbEl.textContent = `โช +3 Pokรฉballs! (Total: ${this.state.pokeballs})`;
+ pbEl.style.display = 'block';
} else {
ptsEl.style.display='none';
+ pbEl.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');
@@ -1376,11 +2093,8 @@ class Game {
else this.showMap();
};
contBtn.onclick = doNext;
- const resultScreen = document.getElementById('screen-result');
- resultScreen.addEventListener('pointerdown', doNext, { once: true });
}
- /* โโ LEVEL UP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
showLevelUp() {
this.show('levelup');
SFX.levelUp();
@@ -1396,7 +2110,6 @@ class Game {
document.getElementById('btn-lu-cont').onclick=()=>this.showMap();
}
- /* โโ COMPLETE โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
showComplete() {
this.show('complete');
SFX.complete();
@@ -1408,18 +2121,24 @@ class Game {
`FINAL SCORE: ${this.state.score.toLocaleString()}\n`+
`CORRECT: ${this.state.correct} / ${tot}\n`+
`ACCURACY: ${acc}%\n`+
- `BEST STREAK: ${this.state.maxStreak} ๐ฅ`;
+ `BEST STREAK: ${this.state.maxStreak} ๐ฅ\n`+
+ `POKรMON CAUGHT: ${this.state.caughtCount}`;
+
+ const pokeEl = document.getElementById('complete-pokemon');
+ if (pokeEl && this.state.party.length > 0) {
+ pokeEl.innerHTML = 'YOUR PARTY
' +
+ this.state.party.map(p => `${p.emoji} ${p.name} Lv${p.level}`).join('');
+ }
+
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='';
@@ -1430,7 +2149,6 @@ class Game {
},26);
}
- /* โโ TOAST โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
toast(msg,ms=2600){
const el=document.getElementById('toast');
el.textContent=msg; el.classList.remove('hidden');
diff --git a/index.html b/index.html
index 8fd3a2a..690806f 100644
--- a/index.html
+++ b/index.html
@@ -2,12 +2,12 @@
-
+
-PokรฉAzure โ Study for Azure Certifications
+PokรฉAzure โ Learn Azure Cloud
@@ -17,9 +17,8 @@
-
GAME BOY ADVANCE
+
GAME BOYโข
โช
-
ยฉ 2026 PokรฉAzure
@@ -29,19 +28,19 @@
- POKรAZURE
+ POKรAZURE
-
โ
AZURE CERT TRAINER โ
+
โ
AZURE TRAINER โ
PRESS START
-
+
-
POKรAZURE
+
POKรAZURE
TRAINERโ
@@ -73,9 +72,8 @@
-
-
-
+
PROFESSOR OAK
@@ -85,18 +83,73 @@
PRESS E TO CONTINUETAP / PRESS A TO CONTINUE
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -104,16 +157,17 @@
-
-
-
+
+
...
-
Press EPress A
โถ
@@ -121,7 +175,7 @@
-
+
@@ -129,17 +183,30 @@
+
+
+
@@ -159,6 +250,7 @@
+100 PTS
+
@@ -168,7 +260,7 @@
โ
BADGE EARNED! โ
-
ASH gained Azure EXP!
+
ASH gained AZURE EXP!
๐
CLOUD TRAINEE
@@ -185,13 +277,14 @@
๐
CHAMPION!
ASH
-
You've conquered AZ-900 Town and mastered AZ-900! Your Azure journey is just beginning โ more towns and exams await!
+
You've conquered Azure Town and mastered AZ-900! Your Azure journey is just beginning โ more towns and exams await!
+
-
๐ฅ CLOUD EXPLORER โ Q25
-
๐ฅ AZURE APPRENTICE โ Q50
-
๐ฅ CLOUD PRACTITIONER โ Q75
-
๐ AZ-900 CHAMPION โ Q100
+
๐ฅ AZURE EXPLORER โ Q25
+
๐ฅ AZURE APPRENTICE โ Q50
+
๐ฅ AZURE PRACTITIONER โ Q75
+
๐ AZURE CHAMPION โ Q100
๐บ๏ธ More towns & exams coming soon...
@@ -220,9 +313,28 @@
-
+
+
+
+