Add Web Audio chiptune sound engine — boot jingle, overworld melody, typewriter blips, correct/wrong/streak/levelup/complete fanfares

This commit is contained in:
Justin Oros
2026-05-14 11:19:16 -07:00
parent 6d71f9ee8b
commit 1b62cbbc14
+131 -4
View File
@@ -52,6 +52,126 @@ function saveGame(s) {
function loadGame() { try { const r=localStorage.getItem(SAVE_KEY); return r?JSON.parse(r):null; } catch(_){return null;} } function loadGame() { try { const r=localStorage.getItem(SAVE_KEY); return r?JSON.parse(r):null; } catch(_){return null;} }
function clearSave() { try { localStorage.removeItem(SAVE_KEY); } catch(_){} } function clearSave() { try { localStorage.removeItem(SAVE_KEY); } catch(_){} }
/* ─── CHIPTUNE SOUND ENGINE ──────────────────────────────── */
const SFX = (() => {
let ctx = null;
function getCtx() {
if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)();
if (ctx.state === 'suspended') ctx.resume();
return ctx;
}
/* Core: play a tone with envelope */
function tone(freq, type, vol, attack, sustain, release, when) {
const c = getCtx();
const t = when ?? c.currentTime;
const osc = c.createOscillator();
const gain= c.createGain();
osc.connect(gain);
gain.connect(c.destination);
osc.type = type;
osc.frequency.setValueAtTime(freq, t);
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(vol, t + attack);
gain.gain.setValueAtTime(vol, t + attack + sustain);
gain.gain.exponentialRampToValueAtTime(0.0001, t + attack + sustain + release);
osc.start(t);
osc.stop(t + attack + sustain + release + 0.01);
}
/* Sequence of notes: [{f, d}...] */
function seq(notes, type='square', vol=0.18) {
const c = getCtx();
let t = c.currentTime + 0.02;
notes.forEach(n => {
if (n.f) tone(n.f, type, vol, 0.01, n.d * 0.6, n.d * 0.4, t);
t += n.d;
});
}
return {
/* Boot jingle — classic rising fanfare */
boot() {
seq([
{f:262,d:.1},{f:330,d:.1},{f:392,d:.1},{f:523,d:.25}
], 'square', 0.2);
},
/* Overworld bgm loop — simple cheerful melody */
overworldStart() {
seq([
{f:392,d:.12},{f:440,d:.12},{f:494,d:.12},{f:523,d:.18},
{f:494,d:.1}, {f:440,d:.1}, {f:392,d:.18},
{f:330,d:.12},{f:392,d:.12},{f:440,d:.12},{f:392,d:.24},
], 'square', 0.12);
},
/* Footstep click — tiny blip each walk tick */
step() {
tone(180, 'square', 0.04, 0.005, 0.02, 0.03);
},
/* NPC encounter — ascending two-note ding */
encounter() {
seq([{f:523,d:.1},{f:659,d:.18}], 'square', 0.22);
},
/* Typewriter blip per character */
type() {
tone(880 + Math.random()*200, 'square', 0.03, 0.005, 0.01, 0.02);
},
/* Select / menu move */
select() {
tone(440, 'square', 0.12, 0.005, 0.03, 0.04);
},
/* Correct answer — happy ascending chord */
correct() {
seq([
{f:523,d:.08},{f:659,d:.08},{f:784,d:.08},{f:1047,d:.2}
], 'square', 0.18);
},
/* Streak bonus — extra flourish */
streak() {
seq([
{f:523,d:.06},{f:659,d:.06},{f:784,d:.06},
{f:1047,d:.06},{f:1319,d:.2}
], 'square', 0.18);
},
/* Wrong answer — descending buzz */
wrong() {
seq([
{f:330,d:.1},{f:277,d:.1},{f:233,d:.18}
], 'sawtooth', 0.15);
},
/* Badge / level up fanfare */
levelUp() {
seq([
{f:523,d:.1},{f:659,d:.1},{f:784,d:.1},{f:659,d:.1},
{f:784,d:.1},{f:1047,d:.3}
], 'square', 0.2);
},
/* Champion / completion — full fanfare */
complete() {
seq([
{f:523,d:.1},{f:659,d:.1},{f:784,d:.1},{f:1047,d:.1},
{f:784,d:.08},{f:880,d:.08},{f:1047,d:.08},{f:1319,d:.4}
], 'square', 0.2);
},
/* Menu confirm (name entry OK) */
confirm() {
seq([{f:523,d:.08},{f:784,d:.15}], 'square', 0.18);
},
};
})();
/* ─── GAME ───────────────────────────────────────────────── */ /* ─── GAME ───────────────────────────────────────────────── */
class Game { class Game {
constructor() { constructor() {
@@ -273,6 +393,7 @@ class Game {
_talkToNPC() { _talkToNPC() {
const arrow = document.getElementById('map-dir-arrow'); const arrow = document.getElementById('map-dir-arrow');
if (arrow) arrow.style.display = 'none'; if (arrow) arrow.style.display = 'none';
SFX.encounter();
this.startQuestion(); this.startQuestion();
} }
@@ -308,6 +429,7 @@ class Game {
c = Math.min(c, btns.length - 1); c = Math.min(c, btns.length - 1);
this.state.cursor = c; this.state.cursor = c;
this._renderCursor(); this._renderCursor();
SFX.select();
} }
_renderCursor() { _renderCursor() {
@@ -421,6 +543,7 @@ class Game {
showTitle() { showTitle() {
this.show('title'); this.show('title');
SFX.boot();
this._showController(false); this._showController(false);
const go = () => { document.getElementById('screen-title').removeEventListener('click',go); this.showContinueOrName(); }; const go = () => { document.getElementById('screen-title').removeEventListener('click',go); this.showContinueOrName(); };
document.getElementById('screen-title').addEventListener('click', go); document.getElementById('screen-title').addEventListener('click', go);
@@ -484,13 +607,13 @@ class Game {
}); });
} }
addChar(ch) { if (this.state.playerName.length<10){ this.state.playerName+=ch; this.refreshNameDisplay(); } } addChar(ch) { if (this.state.playerName.length<10){ this.state.playerName+=ch; this.refreshNameDisplay(); SFX.select(); } }
delChar() { this.state.playerName=this.state.playerName.slice(0,-1); this.refreshNameDisplay(); } delChar() { this.state.playerName=this.state.playerName.slice(0,-1); this.refreshNameDisplay(); }
refreshNameDisplay() { refreshNameDisplay() {
const el=document.getElementById('name-display'); const el=document.getElementById('name-display');
if(el) el.textContent=this.state.playerName+(this.state.playerName.length<10?'_':''); if(el) el.textContent=this.state.playerName+(this.state.playerName.length<10?'_':'');
} }
confirmName() { if(!this.state.playerName.trim()) this.state.playerName='ASH'; this.showIntro(); } confirmName() { if(!this.state.playerName.trim()) this.state.playerName='ASH'; SFX.confirm(); this.showIntro(); }
/* ── INTRO ────────────────────────────────────────────── */ /* ── INTRO ────────────────────────────────────────────── */
showIntro() { showIntro() {
@@ -617,6 +740,7 @@ class Game {
this._placeMapSprites(); this._placeMapSprites();
this._updateDirArrow(false); this._updateDirArrow(false);
SFX.overworldStart();
saveGame(this.state); saveGame(this.state);
this.flashSaveDot(); this.flashSaveDot();
@@ -705,7 +829,8 @@ class Game {
this.state.streak++; this.state.correct++; this.state.streak++; this.state.correct++;
this.state.maxStreak=Math.max(this.state.maxStreak,this.state.streak); this.state.maxStreak=Math.max(this.state.maxStreak,this.state.streak);
this.state.score+=this.state.streak>=5?200:this.state.streak>=3?150:100; this.state.score+=this.state.streak>=5?200:this.state.streak>=3?150:100;
} else { this.state.streak=0; this.state.wrong++; } if(this.state.streak>=3) SFX.streak(); else SFX.correct();
} else { this.state.streak=0; this.state.wrong++; SFX.wrong(); }
setTimeout(()=>this.showResult(correct,q),120); setTimeout(()=>this.showResult(correct,q),120);
} }
@@ -745,6 +870,7 @@ class Game {
/* ── LEVEL UP ─────────────────────────────────────────── */ /* ── LEVEL UP ─────────────────────────────────────────── */
showLevelUp() { showLevelUp() {
this.show('levelup'); this.show('levelup');
SFX.levelUp();
this._showController(false); this._showController(false);
const ms=MILESTONES[this.state.currentQ]; const ms=MILESTONES[this.state.currentQ];
const pct=(this.state.currentQ/(this.state.questions.length||100))*100; const pct=(this.state.currentQ/(this.state.questions.length||100))*100;
@@ -760,6 +886,7 @@ class Game {
/* ── COMPLETE ─────────────────────────────────────────── */ /* ── COMPLETE ─────────────────────────────────────────── */
showComplete() { showComplete() {
this.show('complete'); this.show('complete');
SFX.complete();
this._showController(false); this._showController(false);
const name=this.state.playerName||'ASH', tot=this.state.questions.length||100; const name=this.state.playerName||'ASH', tot=this.state.questions.length||100;
const acc=tot>0?Math.round((this.state.correct/tot)*100):0; const acc=tot>0?Math.round((this.state.correct/tot)*100):0;
@@ -785,7 +912,7 @@ class Game {
const el=document.getElementById(id); if(!el)return; el.textContent=''; const el=document.getElementById(id); if(!el)return; el.textContent='';
let i=0; let i=0;
this.state.twTimer=setInterval(()=>{ this.state.twTimer=setInterval(()=>{
if(i<text.length){el.textContent+=text[i++];} if(i<text.length){el.textContent+=text[i++]; if(i%2===0) SFX.type();}
else{clearInterval(this.state.twTimer);this.state.twTimer=null;if(onDone)onDone();} else{clearInterval(this.state.twTimer);this.state.twTimer=null;if(onDone)onDone();}
},26); },26);
} }