docs(03-complete-experience): create phase 3 plans
This commit is contained in:
@@ -86,7 +86,11 @@ Plans:
|
|||||||
6. Audio initializes only after first user interaction (respects browser autoplay policy)
|
6. Audio initializes only after first user interaction (respects browser autoplay policy)
|
||||||
7. Sound can be toggled on/off from settings without breaking game state
|
7. Sound can be toggled on/off from settings without breaking game state
|
||||||
|
|
||||||
**Plans**: TBD
|
**Plans:** 2 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] 03-01-PLAN.md — Palette system, neon screens (title/settings/modeSelect/diffSelect/gameover), arrow-key navigation
|
||||||
|
- [ ] 03-02-PLAN.md — Audio module (WebAudio synthesis), paddle/wall/score sounds, soundEnabled gating
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -143,7 +147,7 @@ Plans:
|
|||||||
|-------|----------------|--------|-----------|
|
|-------|----------------|--------|-----------|
|
||||||
| 1. Foundation | 1/2 | In Progress | — |
|
| 1. Foundation | 1/2 | In Progress | — |
|
||||||
| 2. Core Gameplay | 2/2 | Complete | 2026-03-10 |
|
| 2. Core Gameplay | 2/2 | Complete | 2026-03-10 |
|
||||||
| 3. Complete Experience | 0/? | Not started | — |
|
| 3. Complete Experience | 0/2 | Planned | — |
|
||||||
| 4. Polish & Depth | 0/? | Not started | — |
|
| 4. Polish & Depth | 0/? | Not started | — |
|
||||||
| 5. Release | 0/? | Not started | — |
|
| 5. Release | 0/? | Not started | — |
|
||||||
|
|
||||||
@@ -152,3 +156,4 @@ Plans:
|
|||||||
*Roadmap created: 2026-03-10*
|
*Roadmap created: 2026-03-10*
|
||||||
*Phase 1 planned: 2026-03-10*
|
*Phase 1 planned: 2026-03-10*
|
||||||
*Phase 2 planned: 2026-03-10*
|
*Phase 2 planned: 2026-03-10*
|
||||||
|
*Phase 3 planned: 2026-03-10*
|
||||||
|
|||||||
307
.planning/phases/03-complete-experience/03-01-PLAN.md
Normal file
307
.planning/phases/03-complete-experience/03-01-PLAN.md
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
---
|
||||||
|
phase: 03-complete-experience
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- index.html
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- SCRN-01
|
||||||
|
- SCRN-02
|
||||||
|
- SCRN-03
|
||||||
|
- SCRN-04
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Opening the game shows a title screen with glowing 'SUPER PONG NEXT GEN' title, neon accent color, and a menu with Play and Settings"
|
||||||
|
- "Arrow keys navigate menu items; Enter/Space confirms; Escape/Backspace goes back"
|
||||||
|
- "Play navigates to mode select (Solo vs AI / 2-Player), then difficulty select for AI mode"
|
||||||
|
- "Settings screen allows changing AI difficulty, sound on/off, and color scheme — changes take effect immediately"
|
||||||
|
- "Completing a match shows the game over screen with winner + full score (e.g. PLAYER 1 WINS 7 – 4) and Play Again / Main Menu options"
|
||||||
|
- "All screens use the same neon palette — ball, paddles, text accents, and glow all update when palette changes"
|
||||||
|
artifacts:
|
||||||
|
- path: "index.html"
|
||||||
|
provides: "Palette system, new gameStates (title/settings), neon rendering for all screens"
|
||||||
|
contains: "GameConfig.PALETTES, GameState.activePalette, GameState.soundEnabled, GameState.selectedMenu"
|
||||||
|
key_links:
|
||||||
|
- from: "Input._handleKeyDown"
|
||||||
|
to: "GameState.gameState"
|
||||||
|
via: "ArrowUp/ArrowDown/Enter/Escape modify selectedMenu and gameState"
|
||||||
|
pattern: "ArrowUp|ArrowDown|Enter|Escape|Backspace"
|
||||||
|
- from: "GameLoop.main() render switch"
|
||||||
|
to: "GameState.activePalette"
|
||||||
|
via: "All ctx.fillStyle calls reference activePalette.accent"
|
||||||
|
pattern: "activePalette\\.accent"
|
||||||
|
- from: "GameState.activePalette"
|
||||||
|
to: "GameConfig.PALETTES"
|
||||||
|
via: "Settings screen sets activePalette = GameConfig.PALETTES[name]"
|
||||||
|
pattern: "PALETTES\\."
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Transform all screens to the neon arcade aesthetic and build proper title/settings navigation.
|
||||||
|
|
||||||
|
Purpose: Replace Phase 2's plain white temporary screens with a coherent visual identity. Add the title screen (first thing player sees) and settings screen (palette, difficulty, sound). Wire arrow-key menu navigation. Overhaul game over to show full score format.
|
||||||
|
|
||||||
|
Output: index.html with palette system, 5 neon-styled game states (title, settings, modeSelect, diffSelect, gameover), and complete keyboard navigation flow.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/dabit/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/dabit/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/03-complete-experience/03-CONTEXT.md
|
||||||
|
@.planning/phases/03-complete-experience/03-RESEARCH.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key contracts from existing index.html the executor will extend. -->
|
||||||
|
|
||||||
|
GameConfig (current structure):
|
||||||
|
```javascript
|
||||||
|
const GameConfig = {
|
||||||
|
initialBallSpeed: 220,
|
||||||
|
speedIncrement: 18,
|
||||||
|
paddleSpeed: 400,
|
||||||
|
WIN_SCORE: 7,
|
||||||
|
AI_EASY: { speed: 200, reactionDelay: 0.3, errorMargin: 20 },
|
||||||
|
AI_MEDIUM: { speed: 320, reactionDelay: 0.1, errorMargin: 5 },
|
||||||
|
AI_HARD: { speed: 400, reactionDelay: 0.05, errorMargin: 2 }
|
||||||
|
};
|
||||||
|
// ADD: PALETTES object with MAGENTA, CYAN, LIME, WHITE presets
|
||||||
|
// ADD: PALETTE_KEYS array for cycling ['MAGENTA','CYAN','LIME','WHITE']
|
||||||
|
```
|
||||||
|
|
||||||
|
GameState (current structure):
|
||||||
|
```javascript
|
||||||
|
const GameState = {
|
||||||
|
paddle1: { x,y,width:12,height:80,speed:400,color:'#fff' },
|
||||||
|
ball: { x,y,radius:8,vx,vy,speed,color:'#fff' },
|
||||||
|
paddle2: { x,y,width:12,height:80,speed:400,color:'#fff' },
|
||||||
|
score1: 0, score2: 0,
|
||||||
|
mode: null,
|
||||||
|
difficulty: 'medium',
|
||||||
|
gameState: 'modeSelect', // Change initial value to 'title'
|
||||||
|
winner: null
|
||||||
|
};
|
||||||
|
// ADD: activePalette (reference to GameConfig.PALETTES.MAGENTA)
|
||||||
|
// ADD: soundEnabled: true
|
||||||
|
// ADD: selectedMenu: 0
|
||||||
|
// ADD: previousState: null
|
||||||
|
```
|
||||||
|
|
||||||
|
Input._handleKeyDown (current):
|
||||||
|
- Handles W/S/ArrowUp/ArrowDown for paddle movement
|
||||||
|
- Digit1/2/3 for mode and difficulty selection
|
||||||
|
- KeyR restart in gameover state
|
||||||
|
// EXTEND: Add ArrowUp/Down for menu navigation in title/settings/modeSelect/diffSelect
|
||||||
|
// EXTEND: Enter/Space to confirm selection
|
||||||
|
// EXTEND: Escape/Backspace to go back (GameState.previousState)
|
||||||
|
// ADD: Audio.init() call on first keydown (for Plan 02 — add the call even though Audio
|
||||||
|
// module doesn't exist yet; guard with: if (typeof Audio !== 'undefined' && !Audio.isInitialized) Audio.init())
|
||||||
|
|
||||||
|
GameLoop.main() render switch (current states):
|
||||||
|
- 'modeSelect' → plain white text prompt (Press 1/2)
|
||||||
|
- 'diffSelect' → plain white text prompt (Press 1/2/3)
|
||||||
|
- 'playing'/'scored'/'gameover' → gameplay with white colors
|
||||||
|
// ADD: 'title' render case — arcade title screen
|
||||||
|
// ADD: 'settings' render case — settings screen
|
||||||
|
// RESTYLE: 'modeSelect' → neon arrow-key navigation
|
||||||
|
// RESTYLE: 'diffSelect' → neon arrow-key navigation
|
||||||
|
// RESTYLE: 'gameover' overlay — "PLAYER 1 WINS 7 – 4" format + Play Again / Main Menu
|
||||||
|
// UPDATE: all hardcoded '#fff' colors → GameState.activePalette.accent
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Add palette system to GameConfig and extend GameState</name>
|
||||||
|
<files>index.html</files>
|
||||||
|
<action>
|
||||||
|
In GameConfig, add after the AI_HARD entry:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
PALETTES: {
|
||||||
|
MAGENTA: { accent: '#ff00cc', glow: 'rgba(255,0,204,0.6)', name: 'Magenta' },
|
||||||
|
CYAN: { accent: '#00ffff', glow: 'rgba(0,255,255,0.6)', name: 'Cyan' },
|
||||||
|
LIME: { accent: '#00ff00', glow: 'rgba(0,255,0,0.6)', name: 'Lime' },
|
||||||
|
WHITE: { accent: '#ffffff', glow: 'rgba(255,255,255,0.6)', name: 'White' }
|
||||||
|
},
|
||||||
|
PALETTE_KEYS: ['MAGENTA', 'CYAN', 'LIME', 'WHITE']
|
||||||
|
```
|
||||||
|
|
||||||
|
In GameState, change `gameState: 'modeSelect'` to `gameState: 'title'` and add:
|
||||||
|
```javascript
|
||||||
|
activePalette: null, // Set after GameConfig defined — see init sequence
|
||||||
|
soundEnabled: true,
|
||||||
|
selectedMenu: 0,
|
||||||
|
previousState: null
|
||||||
|
```
|
||||||
|
|
||||||
|
After the GameState object definition (before Physics), add:
|
||||||
|
```javascript
|
||||||
|
GameState.activePalette = GameConfig.PALETTES.MAGENTA;
|
||||||
|
// Also update paddle/ball color references to use palette at draw time (not stored on objects)
|
||||||
|
```
|
||||||
|
|
||||||
|
Do NOT store palette color on `paddle1.color`, `paddle2.color`, or `ball.color` — those properties stay as fallback `'#fff'`. The palette is applied at draw-call time by reading `GameState.activePalette.accent` in the renderer. This keeps the physics/AI logic untouched.
|
||||||
|
</action>
|
||||||
|
<verify>Open index.html in a browser. The game starts at a blank black screen (title state). Open the browser console and run: `console.log(GameState.activePalette.accent)` — should log `'#ff00cc'`. `console.log(GameConfig.PALETTE_KEYS)` — should log `['MAGENTA','CYAN','LIME','WHITE']`.</verify>
|
||||||
|
<done>GameConfig has PALETTES and PALETTE_KEYS. GameState has activePalette pointing to MAGENTA preset, soundEnabled:true, selectedMenu:0, previousState:null. gameState starts at 'title'.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Rewrite Input keydown, add all render cases with neon styling</name>
|
||||||
|
<files>index.html</files>
|
||||||
|
<action>
|
||||||
|
**Input._handleKeyDown rewrite:**
|
||||||
|
|
||||||
|
Replace the current keydown handler body with:
|
||||||
|
|
||||||
|
1. **Audio init guard** (for Plan 02 integration — harmless no-op now):
|
||||||
|
```javascript
|
||||||
|
if (typeof Audio !== 'undefined' && !Audio.isInitialized) Audio.init();
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Continuous-hold keys** (keep as-is):
|
||||||
|
```javascript
|
||||||
|
if (e.code === 'KeyW') { this.keys.w = true; e.preventDefault(); }
|
||||||
|
// ... etc
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Title screen navigation** (gameState === 'title'):
|
||||||
|
- ArrowUp → selectedMenu = Math.max(0, selectedMenu - 1)
|
||||||
|
- ArrowDown → selectedMenu = Math.min(1, selectedMenu + 1) (two items: Play=0, Settings=1)
|
||||||
|
- Enter OR Space → if selectedMenu===0: previousState='title', gameState='modeSelect', selectedMenu=0
|
||||||
|
if selectedMenu===1: previousState='title', gameState='settings', selectedMenu=0
|
||||||
|
|
||||||
|
4. **Mode select navigation** (gameState === 'modeSelect'):
|
||||||
|
- ArrowUp/Down → selectedMenu cycles between 0 (Solo vs AI) and 1 (2-Player)
|
||||||
|
- Enter OR Space → if selectedMenu===0: mode='ai', previousState='modeSelect', gameState='diffSelect', selectedMenu=1 (default Medium)
|
||||||
|
if selectedMenu===1: mode='2p', gameState='playing', Physics.serveBall()
|
||||||
|
- Escape OR Backspace → gameState='title', selectedMenu=0
|
||||||
|
- KEEP existing Digit1/Digit2 for backward compat (just in case)
|
||||||
|
|
||||||
|
5. **Difficulty select navigation** (gameState === 'diffSelect'):
|
||||||
|
- ArrowUp/Down → selectedMenu cycles 0 (Easy), 1 (Medium), 2 (Hard)
|
||||||
|
- Enter OR Space → difficulty=['easy','medium','hard'][selectedMenu]; AI.init(); gameState='playing'; Physics.serveBall()
|
||||||
|
- Escape OR Backspace → gameState='modeSelect', selectedMenu=0
|
||||||
|
- KEEP existing Digit1/2/3 for backward compat
|
||||||
|
|
||||||
|
6. **Settings navigation** (gameState === 'settings'):
|
||||||
|
Items: 0=AI Difficulty, 1=Sound, 2=Color Scheme
|
||||||
|
- ArrowUp/Down → selectedMenu cycles 0-2
|
||||||
|
- ArrowLeft OR ArrowRight OR Enter OR Space → modify the selected setting:
|
||||||
|
- If selectedMenu===0: cycle difficulty: easy→medium→hard→easy (ArrowLeft goes reverse)
|
||||||
|
- If selectedMenu===1: toggle soundEnabled
|
||||||
|
- If selectedMenu===2: cycle activePalette through PALETTE_KEYS array
|
||||||
|
```javascript
|
||||||
|
const idx = GameConfig.PALETTE_KEYS.indexOf(
|
||||||
|
Object.keys(GameConfig.PALETTES).find(k => GameConfig.PALETTES[k] === GameState.activePalette)
|
||||||
|
);
|
||||||
|
const nextIdx = (idx + 1) % GameConfig.PALETTE_KEYS.length;
|
||||||
|
GameState.activePalette = GameConfig.PALETTES[GameConfig.PALETTE_KEYS[nextIdx]];
|
||||||
|
```
|
||||||
|
ArrowLeft goes (idx - 1 + length) % length
|
||||||
|
- Escape OR Backspace → gameState = previousState (which is 'title'), selectedMenu=0
|
||||||
|
|
||||||
|
7. **Game over navigation** (gameState === 'gameover'):
|
||||||
|
Items: 0=Play Again, 1=Main Menu
|
||||||
|
- ArrowUp/Down → selectedMenu cycles 0-1
|
||||||
|
- Enter OR Space →
|
||||||
|
- if selectedMenu===0 (Play Again): reset scores, winner, mode, difficulty; gameState='modeSelect'; selectedMenu=0; AI.init(); Physics.init(...)
|
||||||
|
- if selectedMenu===1 (Main Menu): reset scores, winner, mode, difficulty; gameState='title'; selectedMenu=0; AI.init(); Physics.init(...)
|
||||||
|
- KEEP KeyR restart (goes to modeSelect for backward compat)
|
||||||
|
|
||||||
|
**GameLoop.main() render overhaul:**
|
||||||
|
|
||||||
|
Helper function (add before GameLoop object): `function drawNeonText(ctx, text, x, y, font, palette, blurRadius) { ctx.save(); ctx.shadowColor = palette.glow; ctx.shadowBlur = blurRadius || 15; ctx.fillStyle = palette.accent; ctx.font = font; ctx.fillText(text, x, y); ctx.restore(); }`
|
||||||
|
|
||||||
|
Replace ALL occurrences of hardcoded `'#fff'` and `'rgba(255,255,255,0.8)'` in render cases with `GameState.activePalette.accent` where appropriate. Score display, divider line (keep dim white), paddles, ball — all use palette.
|
||||||
|
|
||||||
|
**Render cases to add/replace:**
|
||||||
|
|
||||||
|
*'title' case (add before modeSelect):*
|
||||||
|
- Renderer.clear()
|
||||||
|
- Large glowing title: "SUPER PONG NEXT GEN" — bold 52px monospace, centered at logicalHeight/2 - 80, shadowBlur=20
|
||||||
|
- Subtitle: "ARCADE EDITION" — 18px, shadowBlur=8, half-alpha glow, centered at logicalHeight/2 - 40
|
||||||
|
- Menu items (Play at index 0, Settings at index 1) centered at logicalHeight/2 + 20 and +70
|
||||||
|
- Selected item: bold 24px + shadowBlur=12 + leading `> ` marker
|
||||||
|
- Unselected: 20px + shadowBlur=0 + leading ` ` spaces
|
||||||
|
- Footer hint: "ARROW KEYS to navigate, ENTER to select" — dim white, 14px, bottom of screen
|
||||||
|
- early return
|
||||||
|
|
||||||
|
*'settings' case (add after title):*
|
||||||
|
- Renderer.clear()
|
||||||
|
- "SETTINGS" title — bold 36px, centered at y=80, shadowBlur=15
|
||||||
|
- Items at y=170, 240, 310 (3 items with 70px spacing):
|
||||||
|
- AI Difficulty: display current GameState.difficulty.toUpperCase() on right
|
||||||
|
- Sound: display GameState.soundEnabled ? 'ON' : 'OFF' on right
|
||||||
|
- Color Scheme: display GameState.activePalette.name on right
|
||||||
|
- Selected item highlighted with shadowBlur=12, bold font, `> ` marker
|
||||||
|
- Unselected: normal weight, no glow
|
||||||
|
- Footer: "ARROW KEYS to select, LEFT/RIGHT or ENTER to change, ESC to return" — dim white, 14px
|
||||||
|
- early return
|
||||||
|
|
||||||
|
*'modeSelect' case (restyle existing):*
|
||||||
|
- Renderer.clear()
|
||||||
|
- "SELECT MODE" neon title — bold 36px, shadowBlur=15, centered at logicalHeight/2 - 90
|
||||||
|
- Two items: "Solo vs AI" (index 0) and "2-Player Local" (index 1) — same highlight pattern as title menu
|
||||||
|
- Footer hint: "ARROW KEYS + ENTER to select, ESC to go back"
|
||||||
|
- early return (remove old Digit1/2 instructions from display, but keep handler)
|
||||||
|
|
||||||
|
*'diffSelect' case (restyle existing):*
|
||||||
|
- Renderer.clear()
|
||||||
|
- "SELECT DIFFICULTY" neon title
|
||||||
|
- Three items: Easy / Medium / Hard — selectedMenu starts at 1 (Medium) when entering from modeSelect
|
||||||
|
- Footer hint
|
||||||
|
- early return
|
||||||
|
|
||||||
|
*'gameover' overlay (restyle existing):*
|
||||||
|
- Build winner label: `gs.winner === 'player1' ? 'PLAYER 1' : gs.winner === 'ai' ? 'AI' : 'PLAYER 2'`
|
||||||
|
- Score line: `winnerLabel + ' WINS ' + gs.score1 + ' – ' + gs.score2`
|
||||||
|
- Large neon text centered, shadowBlur=20
|
||||||
|
- Two menu items below: "Play Again" (index 0), "Main Menu" (index 1) — with selection highlight
|
||||||
|
- Remove old "Press R to play again" text
|
||||||
|
|
||||||
|
*Gameplay rendering (playing/scored):*
|
||||||
|
- Change all `p1.color`, `p2.color`, `b.color` in drawRect/drawCircle calls to `GameState.activePalette.accent`
|
||||||
|
- Score display: change `'rgba(255,255,255,0.8)'` to `GameState.activePalette.accent`
|
||||||
|
- Center divider: keep dim `rgba(255,255,255,0.2)` (neutral, not palette-colored)
|
||||||
|
</action>
|
||||||
|
<verify>Open index.html. Verify: (1) Title screen renders with neon magenta glow on black background, menu shows Play and Settings. (2) Arrow keys change which item is highlighted. (3) Enter on Play goes to mode select with neon style. (4) Enter on Settings goes to settings screen showing difficulty/sound/palette. (5) Changing Color Scheme updates the palette (ball/paddle/text all change on next gameplay screen). (6) Completing a game shows "PLAYER X WINS N – M" format with Play Again and Main Menu options. (7) Escape from settings/modeSelect returns to previous screen correctly.</verify>
|
||||||
|
<done>All 5 game states (title, settings, modeSelect, diffSelect, gameover) render with neon palette. Arrow-key navigation works. Palette switching affects all game colors. Game over shows winner + full score in the specified format.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Full manual playthrough checklist:
|
||||||
|
1. Fresh page load → title screen with neon magenta glow (SCRN-01)
|
||||||
|
2. Arrow keys highlight menu items; Enter on Play → mode select (SCRN-02)
|
||||||
|
3. Enter on Settings → settings screen; change palette to Cyan → start game → verify ball/paddles/scores are cyan (SCRN-03)
|
||||||
|
4. Complete a match → game over shows "PLAYER X WINS N – M" with Play Again + Main Menu (SCRN-04)
|
||||||
|
5. Play Again returns to mode select with fresh scores; Main Menu returns to title
|
||||||
|
6. Escape from modeSelect returns to title; Escape from diffSelect returns to modeSelect
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Title screen renders with neon branding on first load
|
||||||
|
- All screens use consistent neon palette from GameState.activePalette
|
||||||
|
- Arrow-key + Enter navigation works across all menu screens
|
||||||
|
- Settings palette change propagates to ball/paddle/UI colors in gameplay
|
||||||
|
- Game over displays "PLAYER X WINS N – M" with functional navigation options
|
||||||
|
- No hardcoded '#fff' in the actively-rendered gameplay/menu paths (all reference activePalette.accent)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/03-complete-experience/03-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
277
.planning/phases/03-complete-experience/03-02-PLAN.md
Normal file
277
.planning/phases/03-complete-experience/03-02-PLAN.md
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
---
|
||||||
|
phase: 03-complete-experience
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on:
|
||||||
|
- 03-01
|
||||||
|
files_modified:
|
||||||
|
- index.html
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- AUD-01
|
||||||
|
- AUD-02
|
||||||
|
- AUD-03
|
||||||
|
- AUD-04
|
||||||
|
- AUD-05
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "A mid-range sine tone plays on every paddle hit (both paddles)"
|
||||||
|
- "A higher-pitch snappy tone plays on every wall bounce"
|
||||||
|
- "A low-frequency deep thud plays every time a player scores"
|
||||||
|
- "Page load produces no audio — first keypress initializes the AudioContext"
|
||||||
|
- "Sound Off in settings silences all subsequent audio; Sound On restores it — no game state disruption"
|
||||||
|
artifacts:
|
||||||
|
- path: "index.html"
|
||||||
|
provides: "Audio module with WebAudio synthesis, event-triggered playback"
|
||||||
|
contains: "const Audio = {"
|
||||||
|
key_links:
|
||||||
|
- from: "Audio.init()"
|
||||||
|
to: "Input._handleKeyDown"
|
||||||
|
via: "Called on first keydown before processing input"
|
||||||
|
pattern: "Audio\\.init"
|
||||||
|
- from: "Audio.play('paddleHit')"
|
||||||
|
to: "Physics._checkPaddleCollision / _checkPaddle2Collision"
|
||||||
|
via: "Called after collision is confirmed — add at end of each collision method"
|
||||||
|
pattern: "Audio\\.play.*paddleHit"
|
||||||
|
- from: "Audio.play('wallHit')"
|
||||||
|
to: "Physics.update() wall bounce block"
|
||||||
|
via: "Called when ball.vy is reversed by wall — add inside both wall bounce if-blocks"
|
||||||
|
pattern: "Audio\\.play.*wallHit"
|
||||||
|
- from: "Audio.play('score')"
|
||||||
|
to: "GameLoop.main() score detection block"
|
||||||
|
via: "Called immediately after gs.score1++ or gs.score2++"
|
||||||
|
pattern: "Audio\\.play.*score"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add the Audio module with WebAudio synthesized sound effects and wire it to game events.
|
||||||
|
|
||||||
|
Purpose: Sound is a core part of the game feel. Three distinct tones (paddle, wall, score) provide satisfying feedback. The WebAudio approach satisfies the browser autoplay policy automatically since Audio.init() is called from within the Input keydown handler established in Plan 01.
|
||||||
|
|
||||||
|
Output: index.html with an Audio module and sound effects firing at paddle hits, wall bounces, and score events — all gated by GameState.soundEnabled.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/dabit/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/dabit/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/phases/03-complete-experience/03-CONTEXT.md
|
||||||
|
@.planning/phases/03-complete-experience/03-RESEARCH.md
|
||||||
|
@.planning/phases/03-complete-experience/03-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Plan 01 added these to index.html — Audio module integrates against them. -->
|
||||||
|
|
||||||
|
GameState additions from Plan 01:
|
||||||
|
```javascript
|
||||||
|
GameState.soundEnabled // boolean — true by default; toggled via settings
|
||||||
|
```
|
||||||
|
|
||||||
|
Input._handleKeyDown additions from Plan 01:
|
||||||
|
```javascript
|
||||||
|
// Already contains this guard at the top of the handler:
|
||||||
|
if (typeof Audio !== 'undefined' && !Audio.isInitialized) Audio.init();
|
||||||
|
// Audio.init() will be called on the FIRST keydown event
|
||||||
|
```
|
||||||
|
|
||||||
|
Physics collision methods (unchanged from Phase 2):
|
||||||
|
```javascript
|
||||||
|
Physics._checkPaddleCollision(ball, paddle) // called when ball.vx < 0 (toward paddle1)
|
||||||
|
Physics._checkPaddle2Collision(ball, paddle) // called when ball.vx > 0 (toward paddle2)
|
||||||
|
// Both return early if no collision; if collision confirmed, code runs after the push-out line
|
||||||
|
```
|
||||||
|
|
||||||
|
Physics.update() wall bounce (unchanged from Phase 2):
|
||||||
|
```javascript
|
||||||
|
if (ball.y - ball.radius < 0) {
|
||||||
|
ball.y = ball.radius;
|
||||||
|
ball.vy = Math.abs(ball.vy);
|
||||||
|
// ADD: Audio.play('wallHit'); here
|
||||||
|
}
|
||||||
|
if (ball.y + ball.radius > this.height) {
|
||||||
|
ball.y = this.height - ball.radius;
|
||||||
|
ball.vy = -Math.abs(ball.vy);
|
||||||
|
// ADD: Audio.play('wallHit'); here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
GameLoop.main() score detection (unchanged from Phase 2):
|
||||||
|
```javascript
|
||||||
|
if (ball.x + ball.radius < 0) {
|
||||||
|
gs.score2++;
|
||||||
|
// ADD: Audio.play('score'); here
|
||||||
|
gs.gameState = 'scored';
|
||||||
|
...
|
||||||
|
} else if (ball.x - ball.radius > Physics.width) {
|
||||||
|
gs.score1++;
|
||||||
|
// ADD: Audio.play('score'); here
|
||||||
|
gs.gameState = 'scored';
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Add Audio module with WebAudio synthesis</name>
|
||||||
|
<files>index.html</files>
|
||||||
|
<action>
|
||||||
|
Add the Audio module object to index.html between the AI object and the GameLoop object (before `const GameLoop = {`).
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const Audio = {
|
||||||
|
audioContext: null,
|
||||||
|
isInitialized: false,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (this.isInitialized) return;
|
||||||
|
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
if (this.audioContext.state === 'suspended') {
|
||||||
|
this.audioContext.resume();
|
||||||
|
}
|
||||||
|
this.isInitialized = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
play(eventType) {
|
||||||
|
if (!GameState.soundEnabled || !this.isInitialized || !this.audioContext) return;
|
||||||
|
|
||||||
|
const ctx = this.audioContext;
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
if (eventType === 'paddleHit') {
|
||||||
|
// Mid-range tone — recognizable rally sound
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(400, now);
|
||||||
|
gain.gain.setValueAtTime(0.1, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.15);
|
||||||
|
|
||||||
|
} else if (eventType === 'wallHit') {
|
||||||
|
// Higher-pitch quick snap — distinct from paddle
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(700, now);
|
||||||
|
gain.gain.setValueAtTime(0.07, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.08);
|
||||||
|
|
||||||
|
} else if (eventType === 'score') {
|
||||||
|
// Deep low-frequency thud — weightier than the other two
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(80, now);
|
||||||
|
// Pitch drop adds weight to the thud
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(40, now + 0.2);
|
||||||
|
gain.gain.setValueAtTime(0.15, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical design notes:**
|
||||||
|
- `exponentialRampToValueAtTime` must NOT ramp to exactly 0 (use 0.001 minimum) — exponential ramp to 0 is mathematically invalid and causes audio clicking artifacts (pitfall #5 from RESEARCH.md)
|
||||||
|
- Always call `osc.stop()` to free WebAudio node resources — omitting this causes a memory leak
|
||||||
|
- The `play()` guard `if (!GameState.soundEnabled || !this.isInitialized || !this.audioContext)` covers all three failure modes: settings muted, not yet init'd, context creation failed
|
||||||
|
</action>
|
||||||
|
<verify>Open browser DevTools console. Load index.html. Run `console.log(typeof Audio, Audio.isInitialized)` — should print `"object" false`. Press any key. Run `console.log(Audio.isInitialized, Audio.audioContext.state)` — should print `true "running"`.</verify>
|
||||||
|
<done>Audio module exists in index.html. isInitialized is false on page load. After first keypress, isInitialized is true and audioContext.state is 'running'.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Wire Audio.play() calls to game events</name>
|
||||||
|
<files>index.html</files>
|
||||||
|
<action>
|
||||||
|
Add Audio.play() calls at exactly four locations in index.html:
|
||||||
|
|
||||||
|
**Location 1 — Physics._checkPaddleCollision, after the push-out line:**
|
||||||
|
Find: `ball.x = paddle.x + paddle.width + ball.radius + 1;`
|
||||||
|
After that line, add: `Audio.play('paddleHit');`
|
||||||
|
|
||||||
|
**Location 2 — Physics._checkPaddle2Collision, after the push-out line:**
|
||||||
|
Find: `ball.x = paddle.x - ball.radius - 1;`
|
||||||
|
After that line, add: `Audio.play('paddleHit');`
|
||||||
|
|
||||||
|
**Location 3 — Physics.update(), both wall bounce blocks:**
|
||||||
|
Find the two wall bounce if-blocks. In each block, after the `ball.vy` assignment, add:
|
||||||
|
```javascript
|
||||||
|
Audio.play('wallHit');
|
||||||
|
```
|
||||||
|
The top-wall block becomes:
|
||||||
|
```javascript
|
||||||
|
if (ball.y - ball.radius < 0) {
|
||||||
|
ball.y = ball.radius;
|
||||||
|
ball.vy = Math.abs(ball.vy);
|
||||||
|
Audio.play('wallHit');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The bottom-wall block becomes:
|
||||||
|
```javascript
|
||||||
|
if (ball.y + ball.radius > this.height) {
|
||||||
|
ball.y = this.height - ball.radius;
|
||||||
|
ball.vy = -Math.abs(ball.vy);
|
||||||
|
Audio.play('wallHit');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Location 4 — GameLoop.main() score detection block:**
|
||||||
|
Find the two scoring branches (ball.x + ball.radius < 0 and ball.x - ball.radius > Physics.width).
|
||||||
|
In each branch, immediately after the `gs.score2++` or `gs.score1++` line, add:
|
||||||
|
```javascript
|
||||||
|
Audio.play('score');
|
||||||
|
```
|
||||||
|
|
||||||
|
No other changes. Do NOT add Audio.play() calls anywhere else. The Input handler's Audio.init() guard was added by Plan 01 — verify it is present (`if (typeof Audio !== 'undefined' && !Audio.isInitialized) Audio.init();` at the top of _handleKeyDown).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
Open index.html. Start a game (title → play → mode select → AI mode → playing). Verify:
|
||||||
|
1. Paddle hit: rally the ball against the AI paddle — hear a mid-tone ping each time it hits either paddle
|
||||||
|
2. Wall hit: let ball bounce off top/bottom wall — hear a distinct higher-pitch snap (different from paddle)
|
||||||
|
3. Score: let a point be scored — hear a low deep thud (clearly different weight from other sounds)
|
||||||
|
4. Sound off: go to Settings, set Sound to OFF, play a game — no sounds. Set Sound to ON — sounds return.
|
||||||
|
5. No audio on page load (before first keypress): DevTools > Console > `Audio.audioContext` should be null before pressing any key.
|
||||||
|
</verify>
|
||||||
|
<done>Audio.play('paddleHit') fires on paddle1 and paddle2 collisions. Audio.play('wallHit') fires on top and bottom wall bounces. Audio.play('score') fires on each point scored. Toggling soundEnabled via settings correctly mutes/unmutes. AudioContext is null until first keydown.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Full audio verification checklist:
|
||||||
|
1. Page load: DevTools console, `Audio.audioContext === null` — confirms AUD-04 (no audio init at load)
|
||||||
|
2. First keydown: `Audio.audioContext.state === 'running'` — confirms autoplay policy satisfied
|
||||||
|
3. Start a rally: distinct sounds on paddle hit vs wall bounce (AUD-01, AUD-02)
|
||||||
|
4. Score a point: deep thud plays (AUD-03)
|
||||||
|
5. Settings → Sound OFF → play game → silence; Settings → Sound ON → sounds return (AUD-05)
|
||||||
|
6. No clicking or popping artifacts in any sound
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Three synthesized sounds are clearly distinct (pitch and duration differ between paddle, wall, score)
|
||||||
|
- AudioContext initializes only after first user input (AUD-04 satisfied)
|
||||||
|
- soundEnabled flag in settings reliably gates all audio (AUD-05 satisfied)
|
||||||
|
- No audio-related memory leaks (osc.stop() called on all oscillators)
|
||||||
|
- Sound toggle works without affecting game state (score, paddles, ball position unchanged after toggle)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/03-complete-experience/03-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user