Codex CLI
feat: Implement dynamic fire-rate powerups and enemy behavior updates
5cf582c
// Lightweight Web Audio synth for SFX
import { CFG } from './config.js';
let ctx = null;
let master = null;
let musicBus = null;
let musicState = null;
function ensureContext() {
if (!ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
ctx = new AC();
master = ctx.createGain();
master.gain.value = (CFG.audio && CFG.audio.master != null) ? CFG.audio.master : 0.6;
master.connect(ctx.destination);
// Dedicated bus for background music so we can control it separately
musicBus = ctx.createGain();
musicBus.gain.value = (CFG.audio && CFG.audio.musicVol != null) ? CFG.audio.musicVol : 0.18;
musicBus.connect(master);
}
if (ctx.state === 'suspended') ctx.resume();
}
export function initAudio() {
ensureContext();
}
export function resumeAudio() {
ensureContext();
}
// -------------------------------
// Background music (8-bit style)
// -------------------------------
function midiToFreq(m) {
return 440 * Math.pow(2, (m - 69) / 12);
}
function envGain(at, g, a = 0.004, d = 0.18, level = 1.0) {
g.gain.cancelScheduledValues(at);
g.gain.setValueAtTime(0.0001, at);
g.gain.linearRampToValueAtTime(level, at + a);
g.gain.exponentialRampToValueAtTime(0.0001, at + d);
}
function note(at, midi, dur = 0.15, type = 'square', gainMul = 0.25, dest = musicBus) {
const o = ctx.createOscillator();
o.type = type;
o.frequency.setValueAtTime(midiToFreq(midi), at);
const g = ctx.createGain();
envGain(at, g, 0.003, Math.max(0.06, dur), gainMul);
o.connect(g).connect(dest);
o.start(at);
o.stop(at + dur + 0.02);
}
function kick(at, vol = 0.5) {
const o = ctx.createOscillator(); o.type = 'sine';
const g = ctx.createGain();
// Pitch decay for punchy kick
o.frequency.setValueAtTime(130, at);
o.frequency.exponentialRampToValueAtTime(48, at + 0.12);
envGain(at, g, 0.002, 0.16, vol * 0.9);
o.connect(g).connect(musicBus);
o.start(at); o.stop(at + 0.2);
}
function snare(at, vol = 0.35) {
const len = Math.floor(ctx.sampleRate * 0.12);
const b = ctx.createBuffer(1, len, ctx.sampleRate);
const ch = b.getChannelData(0);
for (let i = 0; i < len; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / len);
const src = ctx.createBufferSource(); src.buffer = b;
const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1800; bp.Q.value = 0.9;
const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 600;
const g = ctx.createGain(); envGain(at, g, 0.001, 0.13, vol);
src.connect(hp).connect(bp).connect(g).connect(musicBus);
src.start(at); src.stop(at + 0.14);
}
function hat(at, vol = 0.15) {
const len = Math.floor(ctx.sampleRate * 0.04);
const b = ctx.createBuffer(1, len, ctx.sampleRate);
const ch = b.getChannelData(0);
for (let i = 0; i < len; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / len);
const src = ctx.createBufferSource(); src.buffer = b;
const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 4000;
const g = ctx.createGain(); envGain(at, g, 0.0006, 0.06, vol);
src.connect(hp).connect(g).connect(musicBus);
src.start(at); src.stop(at + 0.06);
}
// Scale: natural minor (Aeolian)
const SCALE = [0, 2, 3, 5, 7, 8, 10];
function degreeToMidi(rootMidi, deg, octaveOffset = 0) {
const idx = ((deg % 7) + 7) % 7;
const oct = Math.floor(deg / 7) + octaveOffset;
return rootMidi + SCALE[idx] + 12 * oct;
}
function makeMusicState() {
const tempo = (CFG.audio && CFG.audio.musicTempo) || 138;
const spb = 60 / tempo; // seconds per quarter note
return {
running: false,
tempo,
spb,
stepDur: spb / 4, // 16th notes
nextTime: 0,
step: 0,
timer: null,
lookaheadMs: 25,
scheduleHorizon: 0.2,
// musical state
rootMidi: 45, // A2 base root
barLen: 16, // 16th steps per bar
// choose a fresh progression occasionally
progression: pickProgression(),
barInProg: 0
};
}
function pickProgression() {
// Common minor progressions (degrees in natural minor)
const progs = [
[0, 5, 2, 6], // i - VI - III - VII
[0, 3, 6, 2], // i - iv - VII - III
[0, 5, 6, 4], // i - VI - VII - v
[0, 2, 5, 6] // i - III - VI - VII
];
return progs[(Math.random() * progs.length) | 0];
}
function currentChordDegrees(ms) {
const deg = ms.progression[ms.barInProg % ms.progression.length];
return [deg, deg + 2, deg + 4];
}
function scheduleMusic(ms) {
const now = ctx.currentTime;
while (ms.nextTime < now + ms.scheduleHorizon) {
const t = ms.nextTime;
const stepInBar = ms.step % ms.barLen;
const barIdx = Math.floor(ms.step / ms.barLen);
if (stepInBar === 0 && barIdx % 4 === 0 && Math.random() < 0.5) {
// Occasionally change progression for variety
ms.progression = pickProgression();
}
if (stepInBar === 0) {
ms.barInProg = (ms.barInProg + 1) % ms.progression.length;
}
const triad = currentChordDegrees(ms);
// Drums
if (stepInBar === 0 || stepInBar === 8) kick(t, 0.5);
if (stepInBar === 4 || stepInBar === 12) snare(t, 0.32);
if (stepInBar % 2 === 0) hat(t, (stepInBar % 4 === 0) ? 0.12 : 0.10);
// Bass (triangle) on 8ths
if (stepInBar % 2 === 0) {
const bassDeg = (stepInBar < 8) ? triad[0] : (Math.random() < 0.5 ? triad[0] : triad[2]);
const bassMidi = degreeToMidi(ms.rootMidi, bassDeg - 7, 0); // keep it low
note(t, bassMidi, ms.stepDur * 1.6, 'triangle', 0.22);
}
// Arpeggio (square) on 16ths
{
const arpIdx = (stepInBar % 8);
const choice = [triad[0], triad[1], triad[2], triad[1], triad[0], triad[1], triad[2], triad[1]][arpIdx];
const octaveLift = (stepInBar >= 8) ? 1 : 0;
const arpMidi = degreeToMidi(ms.rootMidi + 12, choice, octaveLift);
note(t, arpMidi, ms.stepDur * 0.9, 'square', 0.14);
}
// Occasional lead blip
if (stepInBar === 7 && Math.random() < 0.5) {
const leadDeg = triad[2] + 2; // a step above the fifth
const leadMidi = degreeToMidi(ms.rootMidi + 24, leadDeg, 0);
note(t, leadMidi, ms.stepDur * 2.5, 'square', 0.12);
}
ms.nextTime += ms.stepDur;
ms.step++;
}
}
function tickScheduler() {
if (!musicState || !musicState.running) return;
scheduleMusic(musicState);
}
export function startMusic() {
if (CFG.audio && CFG.audio.musicEnabled === false) return;
ensureContext();
if (musicState && musicState.running) return; // already running
if (!musicState) musicState = makeMusicState();
// Recreate in case tempo changed
const tempo = (CFG.audio && CFG.audio.musicTempo) || musicState.tempo || 138;
musicState.tempo = tempo;
musicState.spb = 60 / tempo;
musicState.stepDur = musicState.spb / 4;
musicState.nextTime = ctx.currentTime + 0.05;
musicState.step = 0;
musicState.running = true;
// Smoothly set bus gain to configured level
const target = (CFG.audio && CFG.audio.musicVol != null) ? CFG.audio.musicVol : 0.18;
musicBus.gain.cancelScheduledValues(ctx.currentTime);
musicBus.gain.setValueAtTime(musicBus.gain.value, ctx.currentTime);
musicBus.gain.linearRampToValueAtTime(target, ctx.currentTime + 0.25);
if (musicState.timer) clearInterval(musicState.timer);
musicState.timer = setInterval(tickScheduler, musicState.lookaheadMs);
}
export function stopMusic(fade = 0.4) {
if (!musicState || !musicState.running) return;
musicState.running = false;
if (musicState.timer) { clearInterval(musicState.timer); musicState.timer = null; }
// Fade out the bus quickly
const t = ctx.currentTime;
musicBus.gain.cancelScheduledValues(t);
musicBus.gain.setValueAtTime(musicBus.gain.value, t);
musicBus.gain.linearRampToValueAtTime(0.0001, t + fade);
}
// Simple gunshot: noise burst + filtered click + low thump
export function playGunshot() {
ensureContext();
const t0 = ctx.currentTime;
// White noise burst
const noiseBuf = ctx.createBuffer(1, Math.floor(ctx.sampleRate * 0.12), ctx.sampleRate);
const data = noiseBuf.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1) * 0.9;
const noise = ctx.createBufferSource();
noise.buffer = noiseBuf;
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 800;
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.setValueAtTime(6000, t0);
lp.frequency.exponentialRampToValueAtTime(1200, t0 + 0.12);
const nGain = ctx.createGain();
nGain.gain.setValueAtTime(0.0, t0);
nGain.gain.linearRampToValueAtTime((CFG.audio?.gunshotVol ?? 0.9) * 0.7, t0 + 0.002);
nGain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.12);
noise.connect(hp).connect(lp).connect(nGain).connect(master);
noise.start(t0);
noise.stop(t0 + 0.13);
// Low thump
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(160, t0);
osc.frequency.exponentialRampToValueAtTime(60, t0 + 0.12);
const oGain = ctx.createGain();
oGain.gain.setValueAtTime(0.0, t0);
oGain.gain.linearRampToValueAtTime((CFG.audio?.gunshotVol ?? 0.9) * 0.3, t0 + 0.005);
oGain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.14);
osc.connect(oGain).connect(master);
osc.start(t0);
osc.stop(t0 + 0.16);
}
// Headshot: bright, short bell with slight pitch down
export function playHeadshot() {
ensureContext();
const t0 = ctx.currentTime;
const baseVol = (CFG.audio?.headshotVol ?? 0.8);
// Two detuned triangles
const makeVoice = (freq, detune) => {
const o = ctx.createOscillator();
o.type = 'triangle';
o.frequency.setValueAtTime(freq, t0);
o.detune.setValueAtTime(detune, t0);
o.frequency.exponentialRampToValueAtTime(freq * 0.75, t0 + 0.18);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0, t0);
g.gain.linearRampToValueAtTime(baseVol * 0.45, t0 + 0.005);
g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.22);
o.connect(g);
return { o, g };
};
const v1 = makeVoice(1100, 0);
const v2 = makeVoice(1100 * 1.5, 8);
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.setValueAtTime(1500, t0);
bp.Q.value = 3;
v1.g.connect(bp);
v2.g.connect(bp);
bp.connect(master);
v1.o.start(t0); v1.o.stop(t0 + 0.24);
v2.o.start(t0); v2.o.stop(t0 + 0.24);
// Tiny click to emphasize
const click = ctx.createBufferSource();
const buf = ctx.createBuffer(1, Math.floor(ctx.sampleRate * 0.01), ctx.sampleRate);
const ch = buf.getChannelData(0);
for (let i = 0; i < ch.length; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / ch.length);
click.buffer = buf;
const cGain = ctx.createGain();
cGain.gain.value = baseVol * 0.25;
click.connect(cGain).connect(master);
click.start(t0);
}
// Explosion: bass thump + noise burst with decay
export function playExplosion() {
ensureContext();
const t0 = ctx.currentTime;
const masterVol = (CFG.audio?.master ?? 0.6);
const base = 0.9 * masterVol;
// Low boom
const boom = ctx.createOscillator();
boom.type = 'sine';
boom.frequency.setValueAtTime(120, t0);
boom.frequency.exponentialRampToValueAtTime(45, t0 + 0.5);
const bGain = ctx.createGain();
bGain.gain.setValueAtTime(0.0001, t0);
bGain.gain.linearRampToValueAtTime(base * 0.8, t0 + 0.02);
bGain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.8);
boom.connect(bGain).connect(master);
boom.start(t0); boom.stop(t0 + 0.9);
// Noise blast (band-limited)
const len = Math.floor(ctx.sampleRate * 0.4);
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const ch = buf.getChannelData(0);
for (let i = 0; i < len; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / len);
const noise = ctx.createBufferSource();
noise.buffer = buf;
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2600;
const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 80;
const nGain = ctx.createGain();
nGain.gain.setValueAtTime(0.0001, t0);
nGain.gain.linearRampToValueAtTime(base * 0.7, t0 + 0.01);
nGain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.45);
noise.connect(hp).connect(lp).connect(nGain).connect(master);
noise.start(t0); noise.stop(t0 + 0.5);
}
// Shimmery pickup chime for powerups
export function playPowerupPickup() {
ensureContext();
const t0 = ctx.currentTime;
const vol = (CFG.audio?.pickupVol ?? 1.0);
// Bell-like "gling": bright metallic ping with tiny upward gliss and sparkle
const makeGling = (at, freq, dur, level) => {
const o = ctx.createOscillator(); o.type = 'sine';
const m = ctx.createOscillator(); m.type = 'sine';
const mg = ctx.createGain(); mg.gain.value = freq * 1.15; // FM index
m.connect(mg).connect(o.frequency);
// Slight upward glide for a cheerful attack
o.frequency.setValueAtTime(freq * 0.94, at);
o.frequency.exponentialRampToValueAtTime(freq, at + 0.04);
m.frequency.setValueAtTime(freq * 1.6, at);
m.frequency.exponentialRampToValueAtTime(freq * 1.9, at + 0.04);
const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 12; bp.frequency.value = freq * 1.15;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, at);
g.gain.linearRampToValueAtTime(level * vol, at + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
o.connect(bp).connect(g).connect(master);
o.start(at); m.start(at);
o.stop(at + dur + 0.02); m.stop(at + dur + 0.02);
};
makeGling(t0 + 0.00, 1800, 0.28, 0.55);
makeGling(t0 + 0.02, 2400, 0.22, 0.40);
// Sparkle tail
const len = Math.floor(ctx.sampleRate * 0.06);
const b = ctx.createBuffer(1, len, ctx.sampleRate);
const ch = b.getChannelData(0);
for (let i = 0; i < len; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / len);
const src = ctx.createBufferSource(); src.buffer = b;
const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 3200;
const g2 = ctx.createGain();
const t1 = t0 + 0.015;
g2.gain.setValueAtTime(0.0001, t1);
g2.gain.linearRampToValueAtTime(0.18 * vol, t1 + 0.01);
g2.gain.exponentialRampToValueAtTime(0.0001, t1 + 0.08);
src.connect(hp).connect(g2).connect(master);
src.start(t1); src.stop(t1 + 0.10);
}
// FM metal ping helper
function fmPing(t, { freq = 650, mod = 1200, index = 1.2, dur = 0.14, gain = 0.28, bpFreq = 1800, q = 8 }) {
const o = ctx.createOscillator(); o.type = 'sine';
const m = ctx.createOscillator(); m.type = 'sine';
const mg = ctx.createGain(); mg.gain.value = freq * index;
m.connect(mg).connect(o.frequency);
o.frequency.setValueAtTime(freq, t);
m.frequency.setValueAtTime(mod, t);
const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = q; bp.frequency.value = bpFreq;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(gain * (CFG.audio?.reloadVol ?? 0.7), t + 0.006);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(bp).connect(g).connect(master);
o.start(t); m.start(t);
o.stop(t + dur + 0.02); m.stop(t + dur + 0.02);
}
function tick(t, len = 0.012, gain = 0.18) {
const src = ctx.createBufferSource();
const b = ctx.createBuffer(1, Math.floor(ctx.sampleRate * len), ctx.sampleRate);
const ch = b.getChannelData(0);
for (let i = 0; i < ch.length; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / ch.length);
src.buffer = b;
const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 2000;
const g = ctx.createGain(); g.gain.value = gain * (CFG.audio?.reloadVol ?? 0.7);
src.connect(hp).connect(g).connect(master);
src.start(t); src.stop(t + len + 0.005);
}
// Start-of-reload metallic cue (short, crisp)
export function playReloadStart() {
ensureContext();
const t0 = ctx.currentTime;
fmPing(t0 + 0.0, { freq: 900, mod: 1800, index: 1.4, dur: 0.10, gain: 0.22, bpFreq: 2200, q: 10 });
tick(t0 + 0.01, 0.01, 0.12);
}
// End-of-reload latch (deeper metallic clack)
export function playReloadEnd() {
ensureContext();
const t0 = ctx.currentTime;
fmPing(t0, { freq: 520, mod: 900, index: 1.0, dur: 0.16, gain: 0.32, bpFreq: 1500, q: 7 });
fmPing(t0 + 0.02, { freq: 780, mod: 1400, index: 0.9, dur: 0.12, gain: 0.18, bpFreq: 1900, q: 9 });
tick(t0 + 0.005, 0.012, 0.2);
}