Compare commits
1 Commits
docs/disma
...
burn/20260
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bf9ac4a6a |
@@ -190,6 +190,7 @@ Events Resolved: <span id="st-resolved">0</span>
|
||||
<div id="log-entries"></div>
|
||||
</div>
|
||||
<div id="save-toast" role="status" aria-live="polite" style="display:none;position:fixed;top:16px;right:16px;background:#0e1420;border:1px solid #2a3a4a;color:#4a9eff;font-size:10px;padding:6px 12px;border-radius:4px;z-index:50;opacity:0;transition:opacity 0.4s;pointer-events:none">Save</div>
|
||||
<button id="mute-btn" onclick="toggleMute()" aria-label="Sound on, click to mute" title="Toggle sound (M)" style="position:fixed;bottom:16px;right:52px;width:28px;height:28px;background:#0e0e1a;border:1px solid #333;color:#555;font-size:13px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:50;font-family:inherit;transition:all 0.2s">🔊</button>
|
||||
<div id="help-btn" onclick="toggleHelp()" style="position:fixed;bottom:16px;right:16px;width:28px;height:28px;background:#0e0e1a;border:1px solid #333;color:#555;font-size:14px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:50;font-family:inherit;transition:all 0.2s" title="Keyboard shortcuts (?)">?</div>
|
||||
<div id="help-overlay" onclick="if(event.target===this)toggleHelp()" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.92);z-index:80;justify-content:center;align-items:center;flex-direction:column;padding:40px">
|
||||
<div style="background:#0e0e1a;border:1px solid #1a3a5a;border-radius:8px;padding:24px 32px;max-width:420px;width:100%">
|
||||
@@ -224,6 +225,7 @@ The light is on. The room is empty."
|
||||
<button aria-label="Start over, reset all progress" onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}">START OVER</button>
|
||||
</div>
|
||||
|
||||
<script src="js/sound.js"></script>
|
||||
<script src="js/data.js"></script>
|
||||
<script src="js/utils.js"></script>
|
||||
<script src="js/strategy.js"></script>
|
||||
|
||||
311
js/sound.js
Normal file
311
js/sound.js
Normal file
@@ -0,0 +1,311 @@
|
||||
// === SOUND DESIGN ENGINE (#57) ===
|
||||
// Procedural audio via Web Audio API — no external files needed.
|
||||
// All sounds generated at runtime using oscillators, noise, and filters.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
let ctx = null;
|
||||
let masterGain = null;
|
||||
let ambientNode = null;
|
||||
let ambientGain = null;
|
||||
let _initialized = false;
|
||||
let _currentAmbientPhase = 0;
|
||||
|
||||
// Lazily init AudioContext on first user interaction (browser policy)
|
||||
function ensureCtx() {
|
||||
if (_initialized) return;
|
||||
if (typeof window.AudioContext === 'undefined' && typeof window.webkitAudioContext === 'undefined') return;
|
||||
try {
|
||||
ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
masterGain = ctx.createGain();
|
||||
masterGain.gain.value = 0.3;
|
||||
masterGain.connect(ctx.destination);
|
||||
_initialized = true;
|
||||
} catch (e) {
|
||||
// Audio not available — fail silently
|
||||
}
|
||||
}
|
||||
|
||||
function isMuted() {
|
||||
return typeof _muted !== 'undefined' && _muted;
|
||||
}
|
||||
|
||||
function playNote(freq, duration, type, vol, detune) {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = type || 'sine';
|
||||
osc.frequency.value = freq;
|
||||
if (detune) osc.detune.value = detune;
|
||||
gain.gain.setValueAtTime((vol || 0.15), ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + duration);
|
||||
}
|
||||
|
||||
function playNoise(duration, vol, filterFreq) {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
const bufferSize = 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 gain = ctx.createGain();
|
||||
gain.gain.setValueAtTime(vol || 0.05, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.value = filterFreq || 2000;
|
||||
filter.Q.value = 1;
|
||||
source.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
source.start(ctx.currentTime);
|
||||
source.stop(ctx.currentTime + duration);
|
||||
}
|
||||
|
||||
// === PUBLIC SOUND API ===
|
||||
|
||||
// Click / type sound — short mechanical keypress
|
||||
function playClick() {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
// Two quick notes to simulate a key press
|
||||
const base = 600 + Math.random() * 400;
|
||||
playNote(base, 0.04, 'square', 0.08);
|
||||
playNote(base * 1.5, 0.03, 'sine', 0.04);
|
||||
// Tiny noise burst for the mechanical feel
|
||||
playNoise(0.02, 0.03, 3000 + Math.random() * 2000);
|
||||
}
|
||||
|
||||
// Auto-tick sound — softer than manual click
|
||||
function playAutoTick() {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
playNote(800 + Math.random() * 200, 0.02, 'sine', 0.03);
|
||||
}
|
||||
|
||||
// Building purchase — satisfying thud + chime
|
||||
function playBuild() {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
// Low thud
|
||||
playNote(120, 0.12, 'sine', 0.12);
|
||||
// High chime
|
||||
playNote(880, 0.2, 'sine', 0.06);
|
||||
setTimeout(() => playNote(1100, 0.15, 'sine', 0.04), 50);
|
||||
}
|
||||
|
||||
// Project complete — ascending 3-note chime
|
||||
function playProject() {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
const notes = [523, 659, 784]; // C5, E5, G5
|
||||
notes.forEach((f, i) => {
|
||||
setTimeout(() => playNote(f, 0.25, 'sine', 0.08), i * 80);
|
||||
});
|
||||
}
|
||||
|
||||
// Milestone chime — bright ascending arpeggio
|
||||
function playMilestone() {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
const notes = [523, 659, 784, 1047]; // C5 E5 G5 C6
|
||||
notes.forEach((f, i) => {
|
||||
setTimeout(() => {
|
||||
playNote(f, 0.3, 'sine', 0.1);
|
||||
playNote(f * 1.005, 0.3, 'sine', 0.04); // slight detune shimmer
|
||||
}, i * 70);
|
||||
});
|
||||
}
|
||||
|
||||
// Phase transition fanfare — grand ascending sweep
|
||||
function playFanfare() {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
const notes = [262, 330, 392, 523, 660, 784, 1047, 1318]; // C major scale up
|
||||
notes.forEach((f, i) => {
|
||||
setTimeout(() => {
|
||||
playNote(f, 0.5, 'sine', 0.07);
|
||||
playNote(f * 2, 0.3, 'triangle', 0.03);
|
||||
}, i * 50);
|
||||
});
|
||||
// Add noise burst
|
||||
setTimeout(() => playNoise(0.3, 0.04, 1500), 200);
|
||||
}
|
||||
|
||||
// Sprint activate — power-up whoosh
|
||||
function playSprint() {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
// Rising sweep
|
||||
ensureCtx();
|
||||
if (!ctx) return;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(200, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(1200, ctx.currentTime + 0.3);
|
||||
gain.gain.setValueAtTime(0.06, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + 0.4);
|
||||
}
|
||||
|
||||
// Event / error — dissonant warning
|
||||
function playEvent() {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
playNote(220, 0.3, 'square', 0.08);
|
||||
playNote(233, 0.3, 'square', 0.06); // Minor 2nd — dissonance
|
||||
}
|
||||
|
||||
// Resolve event — resolved chord
|
||||
function playResolve() {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
playNote(440, 0.2, 'sine', 0.06);
|
||||
playNote(554, 0.2, 'sine', 0.05); // Major third
|
||||
setTimeout(() => playNote(660, 0.3, 'sine', 0.06), 100);
|
||||
}
|
||||
|
||||
// Drift ending — descending dissonant tones
|
||||
function playDriftEnding() {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
const notes = [440, 370, 311, 261, 220];
|
||||
notes.forEach((f, i) => {
|
||||
setTimeout(() => {
|
||||
playNote(f, 0.8, 'sawtooth', 0.05);
|
||||
playNote(f * 0.99, 0.8, 'sawtooth', 0.03); // beat frequency = dread
|
||||
}, i * 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Beacon ending — warm major chord swell
|
||||
function playBeaconEnding() {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
const chord = [262, 330, 392, 523, 660]; // C major with extra warmth
|
||||
chord.forEach((f, i) => {
|
||||
setTimeout(() => {
|
||||
playNote(f, 2.0, 'sine', 0.06);
|
||||
playNote(f * 2, 1.5, 'triangle', 0.03);
|
||||
}, i * 150);
|
||||
});
|
||||
}
|
||||
|
||||
// === BACKGROUND AMBIENT ===
|
||||
// Continuous drone that shifts with game phase
|
||||
|
||||
function startAmbient() {
|
||||
ensureCtx();
|
||||
if (!ctx || ambientNode) return;
|
||||
try {
|
||||
// Create a low-frequency drone
|
||||
const osc1 = ctx.createOscillator();
|
||||
const osc2 = ctx.createOscillator();
|
||||
const lfo = ctx.createOscillator();
|
||||
const lfoGain = ctx.createGain();
|
||||
ambientGain = ctx.createGain();
|
||||
|
||||
osc1.type = 'sine';
|
||||
osc1.frequency.value = 55; // A1 — deep hum
|
||||
osc2.type = 'sine';
|
||||
osc2.frequency.value = 82.4; // E2 — fifth above
|
||||
|
||||
// LFO for subtle movement
|
||||
lfo.type = 'sine';
|
||||
lfo.frequency.value = 0.15;
|
||||
lfoGain.gain.value = 3;
|
||||
lfo.connect(lfoGain);
|
||||
lfoGain.connect(osc1.frequency);
|
||||
|
||||
ambientGain.gain.value = 0.02; // Very quiet background
|
||||
|
||||
osc1.connect(ambientGain);
|
||||
osc2.connect(ambientGain);
|
||||
ambientGain.connect(masterGain);
|
||||
|
||||
osc1.start();
|
||||
osc2.start();
|
||||
lfo.start();
|
||||
|
||||
ambientNode = { osc1, osc2, lfo, lfoGain };
|
||||
_currentAmbientPhase = 0;
|
||||
} catch (e) {
|
||||
// Ambient failed — ok to skip
|
||||
}
|
||||
}
|
||||
|
||||
function updateAmbientPhase(phase) {
|
||||
if (!ambientNode || phase === _currentAmbientPhase) return;
|
||||
_currentAmbientPhase = phase;
|
||||
try {
|
||||
// Shift ambient frequencies based on phase — higher phases = richer harmonics
|
||||
const baseFreqs = [
|
||||
[55, 82.4], // Phase 1: simple fifth
|
||||
[55, 82.4, 110], // Phase 2: add octave
|
||||
[65, 98, 131], // Phase 3: shift up, richer
|
||||
[55, 82.4, 110, 165], // Phase 4: full chord
|
||||
[65, 82.4, 110, 131, 165], // Phase 5: lush
|
||||
];
|
||||
const freqs = baseFreqs[Math.min(phase - 1, baseFreqs.length - 1)];
|
||||
if (ambientNode.osc1) {
|
||||
ambientNode.osc1.frequency.setTargetAtTime(freqs[0], ctx.currentTime, 1);
|
||||
}
|
||||
if (ambientNode.osc2) {
|
||||
ambientNode.osc2.frequency.setTargetAtTime(freqs[1], ctx.currentTime, 1);
|
||||
}
|
||||
// Slightly increase volume with phase
|
||||
if (ambientGain) {
|
||||
ambientGain.gain.setTargetAtTime(Math.min(0.04, 0.02 + phase * 0.003), ctx.currentTime, 0.5);
|
||||
}
|
||||
} catch (e) { /* ok */ }
|
||||
}
|
||||
|
||||
function stopAmbient() {
|
||||
if (!ambientNode) return;
|
||||
try {
|
||||
ambientNode.osc1.stop();
|
||||
ambientNode.osc2.stop();
|
||||
ambientNode.lfo.stop();
|
||||
} catch (e) { /* already stopped */ }
|
||||
ambientNode = null;
|
||||
}
|
||||
|
||||
// === EXPORT TO WINDOW ===
|
||||
window.SoundEngine = {
|
||||
playClick: playClick,
|
||||
playAutoTick: playAutoTick,
|
||||
playBuild: playBuild,
|
||||
playProject: playProject,
|
||||
playMilestone: playMilestone,
|
||||
playFanfare: playFanfare,
|
||||
playSprint: playSprint,
|
||||
playEvent: playEvent,
|
||||
playResolve: playResolve,
|
||||
playDriftEnding: playDriftEnding,
|
||||
playBeaconEnding: playBeaconEnding,
|
||||
startAmbient: startAmbient,
|
||||
updateAmbientPhase: updateAmbientPhase,
|
||||
stopAmbient: stopAmbient
|
||||
};
|
||||
|
||||
// Auto-init ambient on first interaction
|
||||
document.addEventListener('click', function initAmbientOnce() {
|
||||
startAmbient();
|
||||
document.removeEventListener('click', initAmbientOnce);
|
||||
}, { once: true });
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user