Compare commits
1 Commits
sprint/iss
...
burn/20260
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bf9ac4a6a |
@@ -190,6 +190,7 @@ Events Resolved: <span id="st-resolved">0</span>
|
|||||||
<div id="log-entries"></div>
|
<div id="log-entries"></div>
|
||||||
</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>
|
<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-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 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%">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<script src="js/sound.js"></script>
|
||||||
<script src="js/data.js"></script>
|
<script src="js/data.js"></script>
|
||||||
<script src="js/utils.js"></script>
|
<script src="js/utils.js"></script>
|
||||||
<script src="js/strategy.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