11 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 03-complete-experience | 02 | execute | 2 |
|
|
true |
|
|
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.
<execution_context> @/home/dabit/.claude/get-shit-done/workflows/execute-plan.md @/home/dabit/.claude/get-shit-done/templates/summary.md </execution_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.mdGameState additions from Plan 01:
GameState.soundEnabled // boolean — true by default; toggled via settings
Input._handleKeyDown additions from Plan 01:
// 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):
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):
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):
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';
...
}
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:
exponentialRampToValueAtTimemust 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()guardif (!GameState.soundEnabled || !this.isInitialized || !this.audioContext)covers all three failure modes: settings muted, not yet init'd, context creation failed Open browser DevTools console. Load index.html. Runconsole.log(typeof Audio, Audio.isInitialized)— should print"object" false. Press any key. Runconsole.log(Audio.isInitialized, Audio.audioContext.state)— should printtrue "running". Audio module exists in index.html. isInitialized is false on page load. After first keypress, isInitialized is true and audioContext.state is 'running'.
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:
Audio.play('wallHit');
The top-wall block becomes:
if (ball.y - ball.radius < 0) {
ball.y = ball.radius;
ball.vy = Math.abs(ball.vy);
Audio.play('wallHit');
}
The bottom-wall block becomes:
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:
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).
Open index.html. Start a game (title → play → mode select → AI mode → playing). Verify:
- Paddle hit: rally the ball against the AI paddle — hear a mid-tone ping each time it hits either paddle
- Wall hit: let ball bounce off top/bottom wall — hear a distinct higher-pitch snap (different from paddle)
- Score: let a point be scored — hear a low deep thud (clearly different weight from other sounds)
- Sound off: go to Settings, set Sound to OFF, play a game — no sounds. Set Sound to ON — sounds return.
- No audio on page load (before first keypress): DevTools > Console >
Audio.audioContextshould be null before pressing any key. 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.
<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>