From 862d2bc3ea951f24e454028924eb0d353973a02f Mon Sep 17 00:00:00 2001 From: YfengJ <166808804+YfengJ@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:51:14 +0800 Subject: [PATCH] Add Civ Lite audio controls and cues --- demo/civ-lite/audio-model.js | 307 +++++++++++++++++++++++++++++ demo/civ-lite/audio-model.test.mjs | 91 +++++++++ demo/civ-lite/game.js | 97 ++++++++- demo/civ-lite/index.html | 27 +++ demo/civ-lite/styles.css | 58 ++++++ 5 files changed, 577 insertions(+), 3 deletions(-) create mode 100644 demo/civ-lite/audio-model.js create mode 100644 demo/civ-lite/audio-model.test.mjs diff --git a/demo/civ-lite/audio-model.js b/demo/civ-lite/audio-model.js new file mode 100644 index 0000000..7e5744a --- /dev/null +++ b/demo/civ-lite/audio-model.js @@ -0,0 +1,307 @@ +export const AUDIO_STORAGE_KEY = 'civ-lite-audio-settings-v1'; + +export const DEFAULT_AUDIO_SETTINGS = Object.freeze({ + muted: false, + musicEnabled: true, + masterVolume: 0.75, + musicVolume: 0.55, + sfxVolume: 0.8, +}); + +export const AUDIO_EVENTS = Object.freeze({ + click: 'click', + select: 'select', + move: 'move', + attack: 'attack', + build: 'build', + endTurn: 'endTurn', + victory: 'victory', + defeat: 'defeat', +}); + +const SOUND_CUES = Object.freeze({ + click: { + waveform: 'square', + gain: 0.12, + notes: [{ frequency: 520, duration: 0.045 }], + }, + select: { + waveform: 'sine', + gain: 0.14, + notes: [ + { frequency: 420, duration: 0.05 }, + { frequency: 620, duration: 0.07, delay: 0.04 }, + ], + }, + move: { + waveform: 'triangle', + gain: 0.16, + notes: [ + { frequency: 180, duration: 0.07 }, + { frequency: 240, duration: 0.08, delay: 0.05 }, + ], + }, + attack: { + waveform: 'sawtooth', + gain: 0.18, + notes: [ + { frequency: 140, duration: 0.12 }, + { frequency: 90, duration: 0.11, delay: 0.05 }, + ], + }, + build: { + waveform: 'triangle', + gain: 0.15, + notes: [ + { frequency: 260, duration: 0.08 }, + { frequency: 330, duration: 0.08, delay: 0.08 }, + { frequency: 392, duration: 0.12, delay: 0.16 }, + ], + }, + endTurn: { + waveform: 'sine', + gain: 0.12, + notes: [ + { frequency: 392, duration: 0.08 }, + { frequency: 294, duration: 0.14, delay: 0.08 }, + ], + }, + victory: { + waveform: 'triangle', + gain: 0.16, + notes: [ + { frequency: 392, duration: 0.1 }, + { frequency: 494, duration: 0.1, delay: 0.1 }, + { frequency: 587, duration: 0.12, delay: 0.2 }, + { frequency: 784, duration: 0.18, delay: 0.32 }, + ], + }, + defeat: { + waveform: 'sawtooth', + gain: 0.14, + notes: [ + { frequency: 220, duration: 0.14 }, + { frequency: 165, duration: 0.16, delay: 0.12 }, + { frequency: 110, duration: 0.2, delay: 0.28 }, + ], + }, +}); + +export const MUSIC_THEMES = Object.freeze({ + menu: { + loop: true, + beatMs: 1400, + waveform: 'sine', + gain: 0.035, + progression: [ + [196, 246.94, 293.66], + [174.61, 220, 261.63], + [207.65, 261.63, 329.63], + [146.83, 196, 246.94], + ], + }, + game: { + loop: true, + beatMs: 1800, + waveform: 'triangle', + gain: 0.032, + progression: [ + [130.81, 196, 261.63], + [146.83, 220, 293.66], + [164.81, 246.94, 329.63], + [123.47, 185, 246.94], + ], + }, +}); + +function clampVolume(value) { + const number = Number(value); + if (!Number.isFinite(number)) return 0; + return Math.round(Math.max(0, Math.min(1, number)) * 100) / 100; +} + +export function normalizeAudioSettings(input = {}) { + return { + muted: Boolean(input.muted ?? DEFAULT_AUDIO_SETTINGS.muted), + musicEnabled: Boolean(input.musicEnabled ?? DEFAULT_AUDIO_SETTINGS.musicEnabled), + masterVolume: clampVolume(input.masterVolume ?? DEFAULT_AUDIO_SETTINGS.masterVolume), + musicVolume: clampVolume(input.musicVolume ?? DEFAULT_AUDIO_SETTINGS.musicVolume), + sfxVolume: clampVolume(input.sfxVolume ?? DEFAULT_AUDIO_SETTINGS.sfxVolume), + }; +} + +export function createAudioSettings(overrides = {}) { + return normalizeAudioSettings({ ...DEFAULT_AUDIO_SETTINGS, ...overrides }); +} + +export function serializeAudioSettings(settings) { + return JSON.stringify(normalizeAudioSettings(settings)); +} + +export function parseAudioSettings(value) { + if (!value) return createAudioSettings(); + try { + return normalizeAudioSettings(JSON.parse(value)); + } catch { + return createAudioSettings(); + } +} + +export function describeSoundCue(eventName) { + return SOUND_CUES[eventName] ?? SOUND_CUES.click; +} + +function resolveAudioContextCtor() { + return globalThis.AudioContext ?? globalThis.webkitAudioContext ?? null; +} + +function createGain(context, value = 1) { + const gain = context.createGain(); + gain.gain.value = value; + return gain; +} + +export function createAudioSystem({ + storage = globalThis.localStorage, + AudioContextCtor = resolveAudioContextCtor(), + onSettingsChange = () => {}, +} = {}) { + let settings = parseAudioSettings(storage?.getItem?.(AUDIO_STORAGE_KEY)); + let context = null; + let masterGain = null; + let musicGain = null; + let sfxGain = null; + let musicTimer = null; + let musicTheme = 'menu'; + let musicStep = 0; + let unlocked = false; + + function persist() { + storage?.setItem?.(AUDIO_STORAGE_KEY, serializeAudioSettings(settings)); + } + + function applyVolumes() { + if (!masterGain || !musicGain || !sfxGain) return; + masterGain.gain.value = settings.muted ? 0 : settings.masterVolume; + musicGain.gain.value = settings.musicVolume; + sfxGain.gain.value = settings.sfxVolume; + } + + function ensureContext() { + if (context || !AudioContextCtor) return context; + context = new AudioContextCtor(); + masterGain = createGain(context, settings.masterVolume); + musicGain = createGain(context, settings.musicVolume); + sfxGain = createGain(context, settings.sfxVolume); + musicGain.connect(masterGain); + sfxGain.connect(masterGain); + masterGain.connect(context.destination); + applyVolumes(); + return context; + } + + function stopMusic() { + if (musicTimer) { + clearTimeout(musicTimer); + musicTimer = null; + } + } + + function scheduleNote(destination, frequency, duration, delay, waveform, gainValue) { + if (!context || !destination) return; + const startAt = context.currentTime + delay; + const endAt = startAt + duration; + const oscillator = context.createOscillator(); + const gain = createGain(context, 0); + + oscillator.type = waveform; + oscillator.frequency.setValueAtTime(frequency, startAt); + gain.gain.setValueAtTime(0.0001, startAt); + gain.gain.exponentialRampToValueAtTime(gainValue, startAt + 0.015); + gain.gain.exponentialRampToValueAtTime(0.0001, endAt); + oscillator.connect(gain); + gain.connect(destination); + oscillator.start(startAt); + oscillator.stop(endAt + 0.03); + } + + function startMusic(themeName = musicTheme) { + musicTheme = MUSIC_THEMES[themeName] ? themeName : 'game'; + stopMusic(); + if (!context || !unlocked || settings.muted || !settings.musicEnabled) return false; + + const theme = MUSIC_THEMES[musicTheme]; + const playBar = () => { + if (!context || settings.muted || !settings.musicEnabled) return; + const chord = theme.progression[musicStep % theme.progression.length]; + for (const [index, frequency] of chord.entries()) { + scheduleNote(musicGain, frequency, 0.9, index * 0.035, theme.waveform, theme.gain); + } + musicStep += 1; + musicTimer = setTimeout(playBar, theme.beatMs); + }; + + playBar(); + return true; + } + + function updateSettings(patch) { + settings = normalizeAudioSettings({ ...settings, ...patch }); + persist(); + applyVolumes(); + if (!settings.musicEnabled || settings.muted) stopMusic(); + else startMusic(musicTheme); + onSettingsChange(settings); + return getSettings(); + } + + function getSettings() { + return { ...settings }; + } + + return { + getSettings, + getMusicTheme: () => musicTheme, + isUnlocked: () => unlocked, + async unlock(themeName = musicTheme) { + if (themeName) musicTheme = MUSIC_THEMES[themeName] ? themeName : musicTheme; + const ctx = ensureContext(); + if (!ctx) return false; + if (ctx.state === 'suspended') await ctx.resume(); + unlocked = true; + startMusic(musicTheme); + onSettingsChange(settings); + return true; + }, + setMusicTheme(themeName) { + musicTheme = MUSIC_THEMES[themeName] ? themeName : musicTheme; + if (unlocked) startMusic(musicTheme); + return musicTheme; + }, + updateSettings, + setMuted(muted) { + return updateSettings({ muted }); + }, + playEvent(eventName) { + if (!context || !unlocked || settings.muted) return false; + const cue = describeSoundCue(eventName); + for (const note of cue.notes) { + scheduleNote( + sfxGain, + note.frequency, + note.duration, + note.delay ?? 0, + cue.waveform, + cue.gain + ); + } + return true; + }, + destroy() { + stopMusic(); + context?.close?.(); + context = null; + unlocked = false; + }, + }; +} diff --git a/demo/civ-lite/audio-model.test.mjs b/demo/civ-lite/audio-model.test.mjs new file mode 100644 index 0000000..5f637d1 --- /dev/null +++ b/demo/civ-lite/audio-model.test.mjs @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +import { + AUDIO_EVENTS, + MUSIC_THEMES, + createAudioSettings, + describeSoundCue, + normalizeAudioSettings, + parseAudioSettings, + serializeAudioSettings, +} from './audio-model.js'; + +test('creates safe persisted audio defaults', () => { + const settings = createAudioSettings(); + + assert.equal(settings.muted, false); + assert.equal(settings.musicEnabled, true); + assert.equal(settings.masterVolume, 0.75); + assert.equal(settings.musicVolume, 0.55); + assert.equal(settings.sfxVolume, 0.8); +}); + +test('normalizes stored settings before applying them', () => { + const settings = normalizeAudioSettings({ + muted: 'yes', + musicEnabled: false, + masterVolume: 4, + musicVolume: -2, + sfxVolume: 0.333, + }); + + assert.equal(settings.muted, true); + assert.equal(settings.musicEnabled, false); + assert.equal(settings.masterVolume, 1); + assert.equal(settings.musicVolume, 0); + assert.equal(settings.sfxVolume, 0.33); +}); + +test('round trips settings through localStorage strings', () => { + const source = normalizeAudioSettings({ + muted: true, + masterVolume: 0.6, + musicVolume: 0.25, + sfxVolume: 0.5, + }); + + const stored = serializeAudioSettings(source); + assert.deepEqual(parseAudioSettings(stored), source); + assert.deepEqual(parseAudioSettings('{bad json'), createAudioSettings()); +}); + +test('maps gameplay events to distinct sound cues', () => { + assert.deepEqual(Object.keys(AUDIO_EVENTS).sort(), [ + 'attack', + 'build', + 'click', + 'defeat', + 'endTurn', + 'move', + 'select', + 'victory', + ]); + + assert.equal(describeSoundCue('move').waveform, 'triangle'); + assert.equal(describeSoundCue('attack').waveform, 'sawtooth'); + assert.ok(describeSoundCue('victory').notes.length > describeSoundCue('click').notes.length); +}); + +test('defines separate menu and gameplay music themes', () => { + assert.equal(MUSIC_THEMES.menu.loop, true); + assert.equal(MUSIC_THEMES.game.loop, true); + assert.notDeepEqual(MUSIC_THEMES.menu.progression, MUSIC_THEMES.game.progression); +}); + +test('renders audio controls in the sidebar', async () => { + const html = await readFile(new URL('./index.html', import.meta.url), 'utf8'); + + for (const id of [ + 'audioPanel', + 'btnMute', + 'audioTheme', + 'masterVolume', + 'musicVolume', + 'sfxVolume', + 'audioState', + ]) { + assert.match(html, new RegExp(`id="${id}"`)); + } +}); diff --git a/demo/civ-lite/game.js b/demo/civ-lite/game.js index 2428a1f..7a4909c 100644 --- a/demo/civ-lite/game.js +++ b/demo/civ-lite/game.js @@ -4,6 +4,7 @@ Inspired by Freeciv-web layers, C7 terrain system, and Unciv tile groups. ───────────────────────────────────────────────────── */ +import { AUDIO_EVENTS, createAudioSystem } from './audio-model.js'; import { buildSpriteAtlas } from './sprites.js'; // ─── Constants ────────────────────────────────────── @@ -37,8 +38,16 @@ const dom = { science: document.getElementById('science'), unitDet: document.getElementById('unitDetails'), logBox: document.getElementById('logContent'), + audioState: document.getElementById('audioState'), + audioTheme: document.getElementById('audioTheme'), + btnMute: document.getElementById('btnMute'), + masterVolume: document.getElementById('masterVolume'), + musicVolume: document.getElementById('musicVolume'), + sfxVolume: document.getElementById('sfxVolume'), }; +const audio = createAudioSystem({ onSettingsChange: renderAudioSettings }); + // ─── Build sprite atlas (async) ───────────────────── let ATLAS = null; @@ -51,6 +60,66 @@ function log(msg, cls = '') { while (dom.logBox.children.length > 60) dom.logBox.lastChild.remove(); } +// ─── Audio controls ───────────────────────────────── +function volumeToInput(value) { + return String(Math.round(value * 100)); +} + +function renderAudioSettings(settings = audio.getSettings()) { + if (!dom.btnMute) return; + dom.btnMute.textContent = settings.muted ? 'Unmute' : 'Mute'; + dom.btnMute.setAttribute('aria-pressed', String(settings.muted)); + dom.masterVolume.value = volumeToInput(settings.masterVolume); + dom.musicVolume.value = volumeToInput(settings.musicVolume); + dom.sfxVolume.value = volumeToInput(settings.sfxVolume); + dom.audioTheme.value = audio.getMusicTheme(); + + if (settings.muted) dom.audioState.textContent = 'Muted'; + else if (!settings.musicEnabled) dom.audioState.textContent = 'SFX only'; + else if (audio.isUnlocked()) dom.audioState.textContent = 'Playing'; + else dom.audioState.textContent = 'Ready'; +} + +function playAudioEvent(eventName) { + if (audio.playEvent(eventName)) return; + audio.unlock(audio.getMusicTheme()) + .then(ok => { + if (ok) audio.playEvent(eventName); + renderAudioSettings(); + }) + .catch(() => { + if (dom.audioState) dom.audioState.textContent = 'Blocked'; + }); +} + +function bindAudioControls() { + if (!dom.btnMute) return; + audio.setMusicTheme('game'); + renderAudioSettings(); + + dom.btnMute.addEventListener('click', () => { + audio.setMuted(!audio.getSettings().muted); + playAudioEvent(AUDIO_EVENTS.click); + }); + dom.audioTheme.addEventListener('change', () => { + audio.setMusicTheme(dom.audioTheme.value); + playAudioEvent(AUDIO_EVENTS.click); + renderAudioSettings(); + }); + + const bindVolume = (input, key) => { + input.addEventListener('input', () => { + audio.updateSettings({ [key]: Number(input.value) / 100 }); + playAudioEvent(AUDIO_EVENTS.click); + }); + }; + bindVolume(dom.masterVolume, 'masterVolume'); + bindVolume(dom.musicVolume, 'musicVolume'); + bindVolume(dom.sfxVolume, 'sfxVolume'); +} + +bindAudioControls(); + // ─── Seeded RNG for tile variants ─────────────────── function hashTile(x, y) { return ((x * 374761393 + y * 668265263) ^ 1274126177) >>> 0; } @@ -214,6 +283,7 @@ function calcReachable(unit) { // ─── Combat ───────────────────────────────────────── function combat(attacker, defender) { + playAudioEvent(AUDIO_EVENTS.attack); const defTerrain = S.map[defender.y][defender.x]; const defBonus = TERRAIN_DEFENSE[defTerrain] || 0; const dmg = Math.max(5, attacker.atk - defender.def * (1 + defBonus) * 0.5 + Math.random() * 10); @@ -248,11 +318,13 @@ function checkWin() { S.phase = 'gameover'; dom.status.textContent = '🎉 Victory!'; dom.status.className = 'status-win'; + playAudioEvent(AUDIO_EVENTS.victory); log('*** VICTORY! ***', 'build'); } else if (playerUnits.length === 0 && playerCities.length === 0) { S.phase = 'gameover'; dom.status.textContent = '💀 Defeat'; dom.status.className = 'status-lose'; + playAudioEvent(AUDIO_EVENTS.defeat); log('*** DEFEAT ***', 'combat'); } } @@ -306,6 +378,7 @@ function gatherResources(owner) { if (city.food >= 8 * city.pop) { city.pop++; city.food = 0; + if (owner === 'player') playAudioEvent(AUDIO_EVENTS.build); log(`${city.name} grows to pop ${city.pop}!`, 'build'); } // Auto-recruit @@ -323,6 +396,7 @@ function gatherResources(owner) { } } S.units.push({ id, owner, x: spawnX, y: spawnY, hp: 100, atk: 28, def: 15, mov: 2, movLeft: 2, type: 'warrior' }); + if (owner === 'player') playAudioEvent(AUDIO_EVENTS.build); log(`${city.name} trained a warrior!`, 'build'); if (owner === 'player') revealAround(spawnX, spawnY); } @@ -333,6 +407,7 @@ function gatherResources(owner) { // ─── Turn management ──────────────────────────────── function endPlayerTurn() { if (S.phase !== 'player') return; + playAudioEvent(AUDIO_EVENTS.endTurn); S.phase = 'bot'; S.selected = null; S.reachable.clear(); @@ -889,6 +964,7 @@ canvas.addEventListener('mouseleave', () => { // Minimap click miniC.addEventListener('click', e => { + playAudioEvent(AUDIO_EVENTS.click); const rect = miniC.getBoundingClientRect(); const mx = (e.clientX - rect.left) / rect.width; const my = (e.clientY - rect.top) / rect.height; @@ -903,6 +979,7 @@ function handleClick(tx, ty) { // Select own unit const myUnit = S.units.find(u => u.owner === 'player' && u.x === tx && u.y === ty); if (myUnit) { + playAudioEvent(AUDIO_EVENTS.select); S.selected = myUnit; S.reachable = calcReachable(myUnit); S.path = []; @@ -962,6 +1039,7 @@ function handleClick(tx, ty) { const walkPath = path.slice(0, stepsCanTake + 1); const finalMovLeft = movLeft; const sel = S.selected; + playAudioEvent(AUDIO_EVENTS.move); animateMove(sel, walkPath, () => { sel.movLeft = finalMovLeft; S.reachable = calcReachable(sel); @@ -969,7 +1047,12 @@ function handleClick(tx, ty) { refreshVision(); // City capture const cap = S.cities.find(c => c.owner === 'bot' && c.x === sel.x && c.y === sel.y); - if (cap) { cap.owner = 'player'; log(`Captured ${cap.name}!`, 'build'); checkWin(); } + if (cap) { + cap.owner = 'player'; + playAudioEvent(AUDIO_EVENTS.build); + log(`Captured ${cap.name}!`, 'build'); + checkWin(); + } }); } } @@ -983,9 +1066,13 @@ document.addEventListener('keydown', e => { if (e.key === 'e' || e.key === 'E') endPlayerTurn(); if (e.key === 'c' || e.key === 'C') { const u = S.selected || S.units.find(u => u.owner === 'player'); - if (u) centerOn(u.x, u.y); + if (u) { + playAudioEvent(AUDIO_EVENTS.click); + centerOn(u.x, u.y); + } } if (e.key === 'Escape') { + playAudioEvent(AUDIO_EVENTS.click); S.selected = null; S.reachable.clear(); S.path = []; @@ -999,6 +1086,7 @@ document.addEventListener('keydown', e => { if (idle.length === 0) return; const curIdx = S.selected ? idle.indexOf(S.selected) : -1; const next = idle[(curIdx + 1) % idle.length]; + playAudioEvent(AUDIO_EVENTS.select); S.selected = next; S.reachable = calcReachable(next); S.path = []; @@ -1015,7 +1103,10 @@ document.addEventListener('keyup', e => { document.getElementById('btnEndTurn').addEventListener('click', endPlayerTurn); document.getElementById('btnCenter').addEventListener('click', () => { const u = S.selected || S.units.find(u => u.owner === 'player'); - if (u) centerOn(u.x, u.y); + if (u) { + playAudioEvent(AUDIO_EVENTS.click); + centerOn(u.x, u.y); + } }); // ─── Main loop ────────────────────────────────────── diff --git a/demo/civ-lite/index.html b/demo/civ-lite/index.html index 163f984..a51ac19 100644 --- a/demo/civ-lite/index.html +++ b/demo/civ-lite/index.html @@ -36,6 +36,33 @@

Selected Unit

+
+

Audio

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Ready +
+
+

Map

diff --git a/demo/civ-lite/styles.css b/demo/civ-lite/styles.css index 249777a..f188093 100644 --- a/demo/civ-lite/styles.css +++ b/demo/civ-lite/styles.css @@ -227,6 +227,64 @@ button:active { box-shadow: 0 0 8px rgba(63,185,80,0.2); } +/* ───── Audio controls ───────────────────────────── */ +#audioPanel { + display: flex; + flex-direction: column; + gap: 8px; +} + +.audio-row { + display: grid; + grid-template-columns: 58px 1fr; + align-items: center; + gap: 8px; + font-size: 0.76rem; + color: var(--dim); +} + +.audio-row label { + min-width: 0; +} + +.audio-row select { + width: 100%; + background: var(--surface2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 6px; + font: inherit; +} + +.audio-row input[type="range"] { + width: 100%; + accent-color: var(--accent); +} + +.audio-actions { + display: grid; + grid-template-columns: 82px 1fr; + align-items: center; + gap: 8px; +} + +#btnMute { + padding: 6px 10px; +} + +#btnMute[aria-pressed="true"] { + border-color: var(--gold); + color: var(--gold); +} + +#audioState { + min-width: 0; + color: var(--dim); + font-size: 0.72rem; + text-align: right; +} + /* ───── Minimap ──────────────────────────────────── */ #minimapBox { padding: 10px 14px; }