diff --git a/agents/game-creator.md b/agents/game-creator.md index c416113..a7acb15 100644 --- a/agents/game-creator.md +++ b/agents/game-creator.md @@ -396,7 +396,7 @@ Launch a `Task` subagent: > 4. Add audio events to EventBus.js (including `AUDIO_TOGGLE_MUTE`) > 5. Wire audio into main.js and all scenes > 6. **Important**: Use explicit imports from `@strudel/web` (`import { stack, note, s } from '@strudel/web'`) — do NOT rely on global registration -> 7. **Mute toggle**: Wire `AUDIO_TOGGLE_MUTE` to `gameState.game.isMuted`. Both BGM and SFX must check `isMuted` before playing. Add M key shortcut and a speaker icon UI button. +> 7. **Mute toggle**: Wire `AUDIO_TOGGLE_MUTE` to `gameState.game.isMuted`. Both BGM and SFX must check `isMuted` before playing. Add M key shortcut and a speaker icon UI button drawn with the **Phaser Graphics API** (`fillRect`, `fillTriangle`, `arc`, `lineBetween`). Do NOT use `this.add.text()` for the mute button — see the game-audio skill "Mute Button" section for exact drawing code. > > **Iterate after each meaningful change**: The dev server is running on port ``. After wiring audio, run: > ``` diff --git a/examples/flappy-bird/src/audio/AudioBridge.js b/examples/flappy-bird/src/audio/AudioBridge.js index 3caa524..74e3298 100644 --- a/examples/flappy-bird/src/audio/AudioBridge.js +++ b/examples/flappy-bird/src/audio/AudioBridge.js @@ -1,4 +1,5 @@ import { eventBus, Events } from '../core/EventBus.js'; +import { gameState } from '../core/GameState.js'; import { audioManager } from './AudioManager.js'; import { menuTheme, gameplayBGM, gameOverTheme } from './music.js'; import { flapSfx, scoreSfx, deathSfx, clickSfx } from './sfx.js'; @@ -16,4 +17,11 @@ export function initAudioBridge() { eventBus.on(Events.BIRD_FLAP, () => flapSfx()); eventBus.on(Events.SCORE_CHANGED, () => scoreSfx()); eventBus.on(Events.BIRD_DIED, () => deathSfx()); + + // Mute toggle + eventBus.on(Events.AUDIO_TOGGLE_MUTE, () => { + gameState.isMuted = !gameState.isMuted; + try { localStorage.setItem('muted', gameState.isMuted); } catch (_) {} + if (gameState.isMuted) audioManager.stopMusic(); + }); } diff --git a/examples/flappy-bird/src/core/EventBus.js b/examples/flappy-bird/src/core/EventBus.js index 5b0aa48..4877a98 100644 --- a/examples/flappy-bird/src/core/EventBus.js +++ b/examples/flappy-bird/src/core/EventBus.js @@ -23,6 +23,7 @@ export const Events = { MUSIC_GAMEPLAY: 'music:gameplay', MUSIC_GAMEOVER: 'music:gameover', MUSIC_STOP: 'music:stop', + AUDIO_TOGGLE_MUTE: 'audio:toggleMute', }; class EventBus { diff --git a/examples/flappy-bird/src/core/GameState.js b/examples/flappy-bird/src/core/GameState.js index 4139cf1..f6dc6f3 100644 --- a/examples/flappy-bird/src/core/GameState.js +++ b/examples/flappy-bird/src/core/GameState.js @@ -1,6 +1,7 @@ class GameState { constructor() { this.bestScore = 0; + this.isMuted = localStorage.getItem('muted') === 'true'; this.reset(); } diff --git a/examples/flappy-bird/src/scenes/UIScene.js b/examples/flappy-bird/src/scenes/UIScene.js index da8ee68..e2f2b0a 100644 --- a/examples/flappy-bird/src/scenes/UIScene.js +++ b/examples/flappy-bird/src/scenes/UIScene.js @@ -1,6 +1,33 @@ import Phaser from 'phaser'; import { GAME, COLORS, TRANSITION, UI } from '../core/Constants.js'; import { eventBus, Events } from '../core/EventBus.js'; +import { gameState } from '../core/GameState.js'; + +const ICON_SIZE = 16; +const MUTE_MARGIN = 12; + +function drawMuteIcon(gfx, muted, size) { + gfx.clear(); + const s = size; + + gfx.fillStyle(0xffffff); + gfx.fillRect(-s * 0.15, -s * 0.15, s * 0.15, s * 0.3); + gfx.fillTriangle(-s * 0.15, -s * 0.3, -s * 0.15, s * 0.3, -s * 0.45, 0); + + if (!muted) { + gfx.lineStyle(2, 0xffffff); + gfx.beginPath(); + gfx.arc(0, 0, s * 0.2, -Math.PI / 4, Math.PI / 4); + gfx.strokePath(); + gfx.beginPath(); + gfx.arc(0, 0, s * 0.35, -Math.PI / 4, Math.PI / 4); + gfx.strokePath(); + } else { + gfx.lineStyle(3, 0xff4444); + gfx.lineBetween(s * 0.05, -s * 0.25, s * 0.35, s * 0.25); + gfx.lineBetween(s * 0.05, s * 0.25, s * 0.35, -s * 0.25); + } +} export class UIScene extends Phaser.Scene { constructor() { @@ -10,7 +37,6 @@ export class UIScene extends Phaser.Scene { create() { const cx = GAME.WIDTH / 2; - // Big centered score (Flappy Bird style) this.scoreText = this.add.text(cx, GAME.HEIGHT * UI.SCORE_Y_RATIO, '0', { fontSize: `${Math.round(GAME.HEIGHT * UI.SCORE_FONT_RATIO)}px`, fontFamily: UI.FONT, @@ -33,9 +59,31 @@ export class UIScene extends Phaser.Scene { }; eventBus.on(Events.SCORE_CHANGED, this.onScoreChanged); + this._createMuteButton(); this.events.on('shutdown', () => { eventBus.off(Events.SCORE_CHANGED, this.onScoreChanged); }); } + + _createMuteButton() { + const x = this.cameras.main.width - MUTE_MARGIN - ICON_SIZE; + const y = this.cameras.main.height - MUTE_MARGIN - ICON_SIZE; + + this.muteBg = this.add.circle(x, y, ICON_SIZE + 4, 0x000000, 0.3) + .setInteractive({ useHandCursor: true }) + .setDepth(100); + + this.muteIcon = this.add.graphics().setDepth(100); + this.muteIcon.setPosition(x, y); + drawMuteIcon(this.muteIcon, gameState.isMuted, ICON_SIZE); + + this.muteBg.on('pointerdown', () => this._toggleMute()); + this.input.keyboard.on('keydown-M', () => this._toggleMute()); + } + + _toggleMute() { + eventBus.emit(Events.AUDIO_TOGGLE_MUTE); + drawMuteIcon(this.muteIcon, gameState.isMuted, ICON_SIZE); + } } diff --git a/examples/flappy-bird/tests/e2e/visual.spec.js-snapshots/game-over-scene-chromium-linux.png b/examples/flappy-bird/tests/e2e/visual.spec.js-snapshots/game-over-scene-chromium-linux.png new file mode 100644 index 0000000..4a8660b Binary files /dev/null and b/examples/flappy-bird/tests/e2e/visual.spec.js-snapshots/game-over-scene-chromium-linux.png differ diff --git a/examples/flappy-bird/tests/e2e/visual.spec.js-snapshots/initial-gameplay-chromium-linux.png b/examples/flappy-bird/tests/e2e/visual.spec.js-snapshots/initial-gameplay-chromium-linux.png new file mode 100644 index 0000000..02c66c0 Binary files /dev/null and b/examples/flappy-bird/tests/e2e/visual.spec.js-snapshots/initial-gameplay-chromium-linux.png differ diff --git a/skills/game-audio/SKILL.md b/skills/game-audio/SKILL.md index 5a71761..cc6917f 100644 --- a/skills/game-audio/SKILL.md +++ b/skills/game-audio/SKILL.md @@ -329,10 +329,90 @@ eventBus.on(Events.AUDIO_TOGGLE_MUTE, () => { }); ``` -Wire the toggle to: -- A speaker icon button in the UI (visible on all scenes) -- The **M** key on keyboard -- Persist preference in `localStorage` if available +### Mute Button (Phaser Graphics API — do NOT use text) + +**Do NOT use `this.add.text()` for the mute button.** Draw a speaker icon with the Phaser Graphics API so it looks correct at any resolution. + +Helper that draws onto an existing Graphics object: + +```js +function drawMuteIcon(gfx, muted, size) { + gfx.clear(); + const s = size; + + // Speaker body — rectangle + triangle cone + gfx.fillStyle(0xffffff); + gfx.fillRect(-s * 0.15, -s * 0.15, s * 0.15, s * 0.3); + gfx.fillTriangle(-s * 0.15, -s * 0.3, -s * 0.15, s * 0.3, -s * 0.45, 0); + + if (!muted) { + // Sound waves — two arcs + gfx.lineStyle(2, 0xffffff); + gfx.beginPath(); + gfx.arc(0, 0, s * 0.2, -Math.PI / 4, Math.PI / 4); + gfx.strokePath(); + gfx.beginPath(); + gfx.arc(0, 0, s * 0.35, -Math.PI / 4, Math.PI / 4); + gfx.strokePath(); + } else { + // X mark + gfx.lineStyle(3, 0xff4444); + gfx.lineBetween(s * 0.05, -s * 0.25, s * 0.35, s * 0.25); + gfx.lineBetween(s * 0.05, s * 0.25, s * 0.35, -s * 0.25); + } +} +``` + +Create the button in UIScene (runs as a parallel scene, visible on all screens): + +```js +// In UIScene.create(): +_createMuteButton() { + const ICON_SIZE = 16; + const MARGIN = 12; + const x = this.cameras.main.width - MARGIN - ICON_SIZE; + const y = this.cameras.main.height - MARGIN - ICON_SIZE; + + // Hit zone — semi-transparent circle + this.muteBg = this.add.circle(x, y, ICON_SIZE + 4, 0x000000, 0.3) + .setInteractive({ useHandCursor: true }) + .setDepth(100); + + // Speaker icon drawn with Graphics API + this.muteIcon = this.add.graphics().setDepth(100); + this.muteIcon.setPosition(x, y); + drawMuteIcon(this.muteIcon, gameState.isMuted, ICON_SIZE); + + // Click toggles mute + this.muteBg.on('pointerdown', () => { + eventBus.emit(Events.AUDIO_TOGGLE_MUTE); + drawMuteIcon(this.muteIcon, gameState.isMuted, ICON_SIZE); + }); + + // M key shortcut + this.input.keyboard.on('keydown-M', () => { + eventBus.emit(Events.AUDIO_TOGGLE_MUTE); + drawMuteIcon(this.muteIcon, gameState.isMuted, ICON_SIZE); + }); +} +``` + +Persist preference via `localStorage`: + +```js +// GameState — read on construct +constructor() { + this.isMuted = localStorage.getItem('muted') === 'true'; + // ... +} + +// AudioBridge — write on toggle +eventBus.on(Events.AUDIO_TOGGLE_MUTE, () => { + gameState.isMuted = !gameState.isMuted; + try { localStorage.setItem('muted', gameState.isMuted); } catch (_) {} + if (gameState.isMuted) audioManager.stopMusic(); +}); +``` ## Integration Checklist diff --git a/skills/make-game/SKILL.md b/skills/make-game/SKILL.md index 28556d9..cdba7b8 100644 --- a/skills/make-game/SKILL.md +++ b/skills/make-game/SKILL.md @@ -514,7 +514,7 @@ Launch a `Task` subagent with these instructions: > 4. Add audio events to EventBus.js (including `AUDIO_TOGGLE_MUTE`) > 5. Wire audio into main.js and all scenes > 6. **Important**: Use explicit imports from `@strudel/web` (`import { stack, note, s } from '@strudel/web'`) — do NOT rely on global registration -> 7. **Mute toggle**: Wire `AUDIO_TOGGLE_MUTE` to `gameState.game.isMuted`. Both BGM and SFX must check `isMuted` before playing. Add M key shortcut and a speaker icon UI button. +> 7. **Mute toggle**: Wire `AUDIO_TOGGLE_MUTE` to `gameState.game.isMuted`. Both BGM and SFX must check `isMuted` before playing. Add M key shortcut and a speaker icon UI button drawn with the **Phaser Graphics API** (`fillRect`, `fillTriangle`, `arc`, `lineBetween`). Do NOT use `this.add.text()` for the mute button — see the game-audio skill "Mute Button" section for exact drawing code. > > **After completing your work**, append a `## Step 3: Audio` section to `progress.md` with: BGM patterns added, SFX event mappings, mute wiring confirmation. > diff --git a/templates/phaser-2d/src/core/EventBus.js b/templates/phaser-2d/src/core/EventBus.js index f3824a7..6c96804 100644 --- a/templates/phaser-2d/src/core/EventBus.js +++ b/templates/phaser-2d/src/core/EventBus.js @@ -29,6 +29,7 @@ export const Events = { MUSIC_GAMEPLAY: 'music:gameplay', MUSIC_GAMEOVER: 'music:gameover', MUSIC_STOP: 'music:stop', + AUDIO_TOGGLE_MUTE: 'audio:toggleMute', }; class EventBus {