mirror of
https://github.com/PlayableIntelligence/game-creator.git
synced 2026-09-19 07:34:10 +08:00
feat(lowball-blitz): add Strudel BGM + Web Audio SFX with mute toggle
Integrate procedural audio system using @strudel/web for looping BGM and Web Audio API for one-shot SFX. Gameplay BGM is an energetic mischievous chiptune at 130 cpm with anti-repetition techniques (cycle alternation, layer phasing, probabilistic notes). Game over theme is somber at 60 cpm. Six SFX mapped to game events: throw, hit, combo, damage, collect, near-miss. Mute button (bottom-left) with M keyboard shortcut, preference persisted to localStorage. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -160,6 +160,32 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Mute button */
|
||||
#mute-btn {
|
||||
position: fixed;
|
||||
bottom: max(20px, 3vh);
|
||||
left: 16px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 2px solid rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 16;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition: background 0.2s;
|
||||
line-height: 1;
|
||||
}
|
||||
#mute-btn:hover { background: rgba(0, 0, 0, 0.55); }
|
||||
#mute-btn:active { background: rgba(0, 0, 0, 0.7); }
|
||||
|
||||
/* Mobile hint text */
|
||||
#mobile-hints {
|
||||
position: fixed;
|
||||
@@ -203,6 +229,9 @@
|
||||
<!-- Mobile hints -->
|
||||
<div id="mobile-hints">Tap left/right to dodge | Tap right side to throw</div>
|
||||
|
||||
<!-- Mute button -->
|
||||
<button id="mute-btn" aria-label="Mute audio">🔊</button>
|
||||
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { eventBus, Events } from '../core/EventBus.js';
|
||||
import { gameState } from '../core/GameState.js';
|
||||
import { audioManager } from './AudioManager.js';
|
||||
import { gameplayBGM, gameOverTheme } from './music.js';
|
||||
import { throwSfx, hitSfx, comboSfx, damageSfx, collectSfx, nearMissSfx } from './sfx.js';
|
||||
|
||||
export function initAudioBridge() {
|
||||
// Init Strudel on first user interaction (browser autoplay policy)
|
||||
eventBus.on(Events.AUDIO_INIT, () => audioManager.init());
|
||||
|
||||
// --- BGM transitions (Strudel) ---
|
||||
eventBus.on(Events.GAME_START, () => audioManager.playMusic(gameplayBGM));
|
||||
eventBus.on(Events.GAME_RESTART, () => audioManager.playMusic(gameplayBGM));
|
||||
eventBus.on(Events.GAME_OVER, () => audioManager.playMusic(gameOverTheme));
|
||||
eventBus.on(Events.MUSIC_GAMEPLAY, () => audioManager.playMusic(gameplayBGM));
|
||||
eventBus.on(Events.MUSIC_GAMEOVER, () => audioManager.playMusic(gameOverTheme));
|
||||
eventBus.on(Events.MUSIC_STOP, () => audioManager.stopMusic());
|
||||
|
||||
// --- SFX (Web Audio API -- direct one-shot calls) ---
|
||||
eventBus.on(Events.ENVELOPE_THROWN, () => throwSfx());
|
||||
eventBus.on(Events.HOUSE_HIT, () => hitSfx());
|
||||
eventBus.on(Events.COMBO_CHANGED, (data) => {
|
||||
if (data && data.combo >= 3) {
|
||||
comboSfx(data.combo);
|
||||
}
|
||||
});
|
||||
eventBus.on(Events.PLAYER_HIT, () => damageSfx());
|
||||
eventBus.on(Events.PANIC_COLLECTED, () => collectSfx());
|
||||
eventBus.on(Events.SPECTACLE_NEAR_MISS, () => nearMissSfx());
|
||||
|
||||
// --- Mute toggle ---
|
||||
eventBus.on(Events.AUDIO_TOGGLE_MUTE, () => {
|
||||
gameState.isMuted = !gameState.isMuted;
|
||||
try { localStorage.setItem('lowball-blitz-muted', gameState.isMuted); } catch (_) { /* noop */ }
|
||||
if (gameState.isMuted) {
|
||||
audioManager.stopMusic();
|
||||
} else if (gameState.started && !gameState.gameOver) {
|
||||
// Resume gameplay music when unmuting during active gameplay
|
||||
audioManager.playMusic(gameplayBGM);
|
||||
}
|
||||
// Update mute button icon
|
||||
_updateMuteButton();
|
||||
});
|
||||
}
|
||||
|
||||
/** Update the mute button UI (called from AudioBridge to keep audio concerns together) */
|
||||
function _updateMuteButton() {
|
||||
const btn = document.getElementById('mute-btn');
|
||||
if (btn) {
|
||||
btn.textContent = gameState.isMuted ? '\u{1F507}' : '\u{1F50A}';
|
||||
btn.setAttribute('aria-label', gameState.isMuted ? 'Unmute audio' : 'Mute audio');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { initStrudel, hush } from '@strudel/web';
|
||||
import { gameState } from '../core/GameState.js';
|
||||
|
||||
class AudioManager {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
this.currentMusic = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.initialized) return;
|
||||
try {
|
||||
initStrudel();
|
||||
this.initialized = true;
|
||||
console.log('[Audio] Strudel initialized');
|
||||
} catch (e) {
|
||||
console.warn('[Audio] Strudel init failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
playMusic(patternFn) {
|
||||
if (!this.initialized || gameState.isMuted) return;
|
||||
this.stopMusic();
|
||||
// hush() needs a scheduler tick to process before new pattern starts
|
||||
setTimeout(() => {
|
||||
try {
|
||||
this.currentMusic = patternFn();
|
||||
} catch (e) {
|
||||
console.warn('[Audio] BGM error:', e);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
stopMusic() {
|
||||
if (!this.initialized) return;
|
||||
try { hush(); } catch (e) { /* noop */ }
|
||||
this.currentMusic = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const audioManager = new AudioManager();
|
||||
@@ -0,0 +1,96 @@
|
||||
import { stack, note } from '@strudel/web';
|
||||
|
||||
/**
|
||||
* Gameplay BGM (~130 cpm) -- energetic, mischievous suburban runner vibe.
|
||||
* Layers: bass + lead melody + counter melody + synth kick + arp texture.
|
||||
* Uses cycle alternation, layer phasing, probabilistic notes, and filter cycling
|
||||
* for 30+ second effective loop before exact repetition.
|
||||
*/
|
||||
export function gameplayBGM() {
|
||||
return stack(
|
||||
// Lead melody -- 4 alternating mischievous phrases (square for chiptune energy)
|
||||
note('<[e4 g4 a4 ~ e4 g4 b4 ~ a4 g4 e4 ~ d4 e4 ~ ~] [g4 a4 b4 ~ g4 a4 c5 ~ b4 a4 g4 ~ e4 g4 ~ ~] [a4 ~ g4 e4 g4 ~ e4 d4 e4 ~ g4 a4 ~ ~ b4 ~] [b4 a4 g4 ~ e4 g4 a4 ~ g4 e4 d4 ~ e4 ~ ~ ~]>')
|
||||
.s('square')
|
||||
.gain(0.15)
|
||||
.lpf(2500)
|
||||
.decay(0.12)
|
||||
.sustain(0.2)
|
||||
.release(0.3),
|
||||
// Counter melody -- sparse, offset phasing (1.5x cycle)
|
||||
note('<[~ ~ b4 ~ ~ ~ e5? ~ ~ ~ ~ ~ g4? ~ ~ ~] [~ ~ ~ ~ ~ e5 ~ ~ ~ b4? ~ ~ ~ ~ g4 ~]>')
|
||||
.s('square')
|
||||
.gain(0.07)
|
||||
.lpf('<3000 2200 2800 2000>')
|
||||
.decay(0.15)
|
||||
.sustain(0)
|
||||
.slow(1.5),
|
||||
// Bass -- 3 alternating root progressions (triangle for warmth)
|
||||
note('<[e2 ~ e2 ~ a2 ~ a2 ~ d2 ~ d2 ~ g2 ~ g2 ~] [a2 ~ a2 ~ d2 ~ d2 ~ g2 ~ g2 ~ c2 ~ c2 ~] [e2 ~ g2 ~ a2 ~ e2 ~ d2 ~ g2 ~ c2 ~ e2 ~]>')
|
||||
.s('triangle')
|
||||
.gain(0.2)
|
||||
.lpf(500),
|
||||
// Synth kick drum -- 2 alternating patterns (sine for thump)
|
||||
note('<[c1 ~ c1 ~ c1 c1 ~ ~ c1 ~ c1 ~ c1 ~ c1 ~] [c1 c1 ~ ~ c1 ~ c1 ~ ~ c1 ~ c1 c1 ~ ~ c1]>')
|
||||
.s('sine')
|
||||
.gain(0.25)
|
||||
.decay(0.12)
|
||||
.sustain(0)
|
||||
.lpf(200),
|
||||
// Hi-hat texture -- probabilistic for organic feel
|
||||
note('c6 c6? c6 c6? c6 c6? c6 c6?')
|
||||
.s('square')
|
||||
.gain(0.04)
|
||||
.decay(0.03)
|
||||
.sustain(0)
|
||||
.lpf('<6000 5000 7000 5500>')
|
||||
.fast(2),
|
||||
// Arp texture -- filter cycling, phased against other layers
|
||||
note('e3 g3 b3 e4')
|
||||
.s('square')
|
||||
.fast(4)
|
||||
.gain(0.04)
|
||||
.lpf('<1000 700 1400 900>')
|
||||
.decay(0.06)
|
||||
.sustain(0)
|
||||
.slow(3)
|
||||
).cpm(130).play();
|
||||
}
|
||||
|
||||
/**
|
||||
* Game over theme (~60 cpm) -- somber, descending, short looping phrase.
|
||||
* 3 alternating descending melodies + dark pad + ghostly texture.
|
||||
*/
|
||||
export function gameOverTheme() {
|
||||
return stack(
|
||||
// Descending melody -- 3 variations (triangle for softness)
|
||||
note('<[b4 ~ a4 ~ g4 ~ e4 ~ d4 ~ c4 ~ ~ ~ ~ ~] [e4 ~ d4 ~ c4 ~ b3 ~ a3 ~ g3 ~ ~ ~ ~ ~] [g4 ~ e4 ~ d4 ~ c4 ~ e4 ~ d4 ~ b3 ~ ~ ~]>')
|
||||
.s('triangle')
|
||||
.gain(0.16)
|
||||
.decay(0.6)
|
||||
.sustain(0.1)
|
||||
.release(1.0)
|
||||
.room(0.6)
|
||||
.roomsize(5)
|
||||
.lpf(1800),
|
||||
// Dark pad -- alternating minor chords on slow cycle
|
||||
note('<[a2,c3,e3] [d2,f2,a2] [e2,g2,b2]>')
|
||||
.s('sine')
|
||||
.attack(0.5)
|
||||
.release(2.5)
|
||||
.gain(0.1)
|
||||
.room(0.7)
|
||||
.roomsize(6)
|
||||
.lpf(1200)
|
||||
.slow(2),
|
||||
// Ghostly high texture -- probabilistic, phased
|
||||
note('~ ~ ~ ~ ~ e5? ~ ~ ~ ~ ~ ~ ~ b4? ~ ~')
|
||||
.s('sine')
|
||||
.gain(0.03)
|
||||
.delay(0.5)
|
||||
.delaytime(0.6)
|
||||
.delayfeedback(0.5)
|
||||
.room(0.7)
|
||||
.lpf(2000)
|
||||
.slow(3)
|
||||
).slow(3).cpm(60).play();
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* SFX Engine -- Web Audio API one-shot sounds.
|
||||
* NEVER use Strudel for SFX (it loops). All sounds here fire once and stop.
|
||||
*/
|
||||
|
||||
import { gameState } from '../core/GameState.js';
|
||||
|
||||
let audioCtx = null;
|
||||
|
||||
function getCtx() {
|
||||
if (!audioCtx) {
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
return audioCtx;
|
||||
}
|
||||
|
||||
/** Play a single tone that stops after duration */
|
||||
function playTone(freq, type, duration, gain = 0.3, filterFreq = 4000) {
|
||||
const ctx = getCtx();
|
||||
const now = ctx.currentTime;
|
||||
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = type;
|
||||
osc.frequency.setValueAtTime(freq, now);
|
||||
|
||||
const gainNode = ctx.createGain();
|
||||
gainNode.gain.setValueAtTime(gain, now);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||||
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(filterFreq, now);
|
||||
|
||||
osc.connect(filter).connect(gainNode).connect(ctx.destination);
|
||||
osc.start(now);
|
||||
osc.stop(now + duration);
|
||||
}
|
||||
|
||||
/** Play a sequence of tones (each fires once and stops) */
|
||||
function playNotes(notes, type, noteDuration, gap, gain = 0.3, filterFreq = 4000) {
|
||||
const ctx = getCtx();
|
||||
const now = ctx.currentTime;
|
||||
|
||||
notes.forEach((freq, i) => {
|
||||
const start = now + i * gap;
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = type;
|
||||
osc.frequency.setValueAtTime(freq, start);
|
||||
|
||||
const gainNode = ctx.createGain();
|
||||
gainNode.gain.setValueAtTime(gain, start);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.001, start + noteDuration);
|
||||
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(filterFreq, start);
|
||||
|
||||
osc.connect(filter).connect(gainNode).connect(ctx.destination);
|
||||
osc.start(start);
|
||||
osc.stop(start + noteDuration);
|
||||
});
|
||||
}
|
||||
|
||||
/** Play noise burst (for whooshes, swooshes) */
|
||||
function playNoise(duration, gain = 0.2, lpfFreq = 4000, hpfFreq = 0) {
|
||||
const ctx = getCtx();
|
||||
const now = ctx.currentTime;
|
||||
const bufferSize = Math.floor(ctx.sampleRate * duration);
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
|
||||
const gainNode = ctx.createGain();
|
||||
gainNode.gain.setValueAtTime(gain, now);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||||
|
||||
const lpf = ctx.createBiquadFilter();
|
||||
lpf.type = 'lowpass';
|
||||
lpf.frequency.setValueAtTime(lpfFreq, now);
|
||||
|
||||
let chain = source.connect(lpf).connect(gainNode);
|
||||
|
||||
if (hpfFreq > 0) {
|
||||
const hpf = ctx.createBiquadFilter();
|
||||
hpf.type = 'highpass';
|
||||
hpf.frequency.setValueAtTime(hpfFreq, now);
|
||||
source.disconnect();
|
||||
chain = source.connect(hpf).connect(lpf).connect(gainNode);
|
||||
}
|
||||
|
||||
chain.connect(ctx.destination);
|
||||
source.start(now);
|
||||
source.stop(now + duration);
|
||||
}
|
||||
|
||||
// Note frequencies for reference:
|
||||
// C4=261.63 D4=293.66 E4=329.63 F4=349.23 G4=392.00
|
||||
// A4=440.00 B4=493.88 C5=523.25 E5=659.25 B5=987.77
|
||||
|
||||
/**
|
||||
* Throw SFX -- quick whoosh (short noise burst with pitch sweep).
|
||||
* Feels like an envelope sailing through the air.
|
||||
*/
|
||||
export function throwSfx() {
|
||||
if (gameState.isMuted) return;
|
||||
playNoise(0.15, 0.18, 5000, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hit SFX -- satisfying impact (low thump + high sparkle).
|
||||
* Envelope smacking into a house.
|
||||
*/
|
||||
export function hitSfx() {
|
||||
if (gameState.isMuted) return;
|
||||
// Low thump
|
||||
playTone(65.41, 'sine', 0.15, 0.28, 800);
|
||||
// High sparkle
|
||||
playTone(987.77, 'square', 0.1, 0.12, 6000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Combo SFX -- ascending arpeggio.
|
||||
* Scales with combo count (higher combos = more notes).
|
||||
*/
|
||||
export function comboSfx(comboCount) {
|
||||
if (gameState.isMuted) return;
|
||||
const baseNotes = [329.63, 440.00, 523.25, 659.25, 987.77];
|
||||
// Use more notes for higher combos (3-5 notes)
|
||||
const count = Math.min(Math.max(3, comboCount), baseNotes.length);
|
||||
const notes = baseNotes.slice(0, count);
|
||||
playNotes(notes, 'square', 0.08, 0.05, 0.22, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Damage SFX -- harsh buzz/dissonance.
|
||||
* Getting hit by a real estate agent.
|
||||
*/
|
||||
export function damageSfx() {
|
||||
if (gameState.isMuted) return;
|
||||
// Descending harsh tones
|
||||
playNotes([392, 329.63, 261.63, 220, 174.61], 'sawtooth', 0.12, 0.07, 0.25, 1500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect SFX -- bright pickup chime (ascending two-note).
|
||||
* Collecting panic points.
|
||||
*/
|
||||
export function collectSfx() {
|
||||
if (gameState.isMuted) return;
|
||||
playNotes([659.25, 987.77], 'square', 0.12, 0.07, 0.25, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Near miss SFX -- quick swoosh.
|
||||
* Dodging an agent at the last moment.
|
||||
*/
|
||||
export function nearMissSfx() {
|
||||
if (gameState.isMuted) return;
|
||||
playNoise(0.12, 0.12, 3000, 600);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export const Events = {
|
||||
|
||||
// Audio (used by /add-audio)
|
||||
AUDIO_INIT: 'audio:init',
|
||||
AUDIO_TOGGLE_MUTE: 'audio:toggle_mute',
|
||||
MUSIC_MENU: 'music:menu',
|
||||
MUSIC_GAMEPLAY: 'music:gameplay',
|
||||
MUSIC_GAMEOVER: 'music:gameover',
|
||||
|
||||
@@ -25,8 +25,10 @@ class GameState {
|
||||
this.housesHit = 0;
|
||||
this.totalThrown = 0;
|
||||
|
||||
// Audio
|
||||
this.isMuted = false;
|
||||
// Audio -- persist mute preference across sessions
|
||||
if (this.isMuted === undefined) {
|
||||
try { this.isMuted = localStorage.getItem('lowball-blitz-muted') === 'true'; } catch (_) { this.isMuted = false; }
|
||||
}
|
||||
|
||||
// Combo timer
|
||||
this._comboTimer = 0;
|
||||
|
||||
@@ -2,9 +2,56 @@ import { Game } from './core/Game.js';
|
||||
import { eventBus, Events } from './core/EventBus.js';
|
||||
import { gameState } from './core/GameState.js';
|
||||
import { IS_MOBILE } from './core/Constants.js';
|
||||
import { initAudioBridge } from './audio/AudioBridge.js';
|
||||
|
||||
const game = new Game();
|
||||
|
||||
// --- Audio ---
|
||||
initAudioBridge();
|
||||
|
||||
// Init audio on first user interaction (browser autoplay policy)
|
||||
let audioInitDone = false;
|
||||
function initAudioOnce() {
|
||||
if (audioInitDone) return;
|
||||
audioInitDone = true;
|
||||
eventBus.emit(Events.AUDIO_INIT);
|
||||
// Start gameplay music after init (game auto-starts, no title screen)
|
||||
if (gameState.started && !gameState.gameOver) {
|
||||
eventBus.emit(Events.MUSIC_GAMEPLAY);
|
||||
}
|
||||
}
|
||||
window.addEventListener('click', initAudioOnce, { once: false });
|
||||
window.addEventListener('touchstart', initAudioOnce, { once: false });
|
||||
window.addEventListener('keydown', initAudioOnce, { once: false });
|
||||
|
||||
// M key toggles mute
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'm' || e.key === 'M') {
|
||||
eventBus.emit(Events.AUDIO_TOGGLE_MUTE);
|
||||
}
|
||||
});
|
||||
|
||||
// Mute button click handler
|
||||
const muteBtn = document.getElementById('mute-btn');
|
||||
if (muteBtn) {
|
||||
muteBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
eventBus.emit(Events.AUDIO_TOGGLE_MUTE);
|
||||
});
|
||||
// Set initial icon based on persisted mute state
|
||||
if (gameState.isMuted) {
|
||||
muteBtn.textContent = '\u{1F507}';
|
||||
muteBtn.setAttribute('aria-label', 'Unmute audio');
|
||||
}
|
||||
}
|
||||
|
||||
// On mobile, shift mute button up above joystick zone
|
||||
if (IS_MOBILE) {
|
||||
if (muteBtn) {
|
||||
muteBtn.style.bottom = 'max(140px, calc(3vh + 120px))';
|
||||
}
|
||||
}
|
||||
|
||||
// Expose for Playwright testing
|
||||
window.__GAME__ = game;
|
||||
window.__GAME_STATE__ = gameState;
|
||||
|
||||
Reference in New Issue
Block a user