Add looping chiptune BGM — cheerful overworld track and tense battle track, phrase-based seamless loop
This commit is contained in:
1 parent
1b62cbbc14
commit
48a35d381b
1 file changed
+125
-61
@@ -55,6 +55,9 @@ function clearSave() { try { localStorage.removeItem(SAVE_KEY); } catch(_){} }
|
||||
/* ─── CHIPTUNE SOUND ENGINE ──────────────────────────────── */
|
||||
const SFX = (() => {
|
||||
let ctx = null;
|
||||
let bgmNodes = []; /* currently playing BGM oscillators/gains */
|
||||
let bgmTrack = null; /* 'overworld' | 'battle' | null */
|
||||
let bgmTimer = null;
|
||||
|
||||
function getCtx() {
|
||||
if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
@@ -62,7 +65,6 @@ const SFX = (() => {
|
||||
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;
|
||||
@@ -77,98 +79,158 @@ const SFX = (() => {
|
||||
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);
|
||||
osc.stop(t + attack + sustain + release + 0.02);
|
||||
return { osc, gain };
|
||||
}
|
||||
|
||||
/* Sequence of notes: [{f, d}...] */
|
||||
function seq(notes, type='square', vol=0.18) {
|
||||
function seq(notes, type='square', vol=0.18, startAt) {
|
||||
const c = getCtx();
|
||||
let t = c.currentTime + 0.02;
|
||||
let t = startAt ?? (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);
|
||||
if (n.f) tone(n.f, type, vol, 0.01, n.d * 0.55, n.d * 0.35, t);
|
||||
t += n.d;
|
||||
});
|
||||
return t; /* returns time when sequence ends */
|
||||
}
|
||||
|
||||
/* ── BGM TRACKS ───────────────────────────────────────────
|
||||
Each track is an array of "phrases" (arrays of notes).
|
||||
The engine plays them in sequence, then loops.
|
||||
Two channels: melody (square) + bass (triangle).
|
||||
─────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Pallet Town-inspired overworld — cheerful, major key */
|
||||
const OVERWORLD = {
|
||||
melody: [
|
||||
/* phrase A */
|
||||
[{f:392,d:.15},{f:440,d:.15},{f:494,d:.15},{f:523,d:.2},
|
||||
{f:494,d:.12},{f:440,d:.12},{f:392,d:.22},
|
||||
{f:330,d:.15},{f:370,d:.15},{f:392,d:.3}],
|
||||
/* phrase B */
|
||||
[{f:523,d:.15},{f:494,d:.1},{f:440,d:.1},{f:494,d:.15},{f:523,d:.2},
|
||||
{f:587,d:.15},{f:659,d:.15},{f:587,d:.12},{f:523,d:.28}],
|
||||
/* phrase C — bridge */
|
||||
[{f:659,d:.12},{f:587,d:.12},{f:523,d:.12},{f:494,d:.12},{f:440,d:.2},
|
||||
{f:392,d:.12},{f:440,d:.12},{f:494,d:.12},{f:523,d:.24}],
|
||||
/* phrase A repeat */
|
||||
[{f:392,d:.15},{f:440,d:.15},{f:494,d:.15},{f:523,d:.2},
|
||||
{f:494,d:.12},{f:440,d:.12},{f:392,d:.22},
|
||||
{f:330,d:.15},{f:392,d:.15},{f:523,d:.35}],
|
||||
],
|
||||
bass: [
|
||||
[{f:131,d:.3},{f:0,d:.1},{f:147,d:.3},{f:0,d:.1},{f:131,d:.3},{f:0,d:.1},{f:110,d:.3},{f:0,d:.1}],
|
||||
[{f:131,d:.3},{f:0,d:.1},{f:147,d:.3},{f:0,d:.1},{f:165,d:.3},{f:0,d:.1},{f:147,d:.3},{f:0,d:.1}],
|
||||
[{f:165,d:.3},{f:0,d:.1},{f:147,d:.3},{f:0,d:.1},{f:131,d:.3},{f:0,d:.1},{f:110,d:.4}],
|
||||
[{f:131,d:.3},{f:0,d:.1},{f:147,d:.3},{f:0,d:.1},{f:131,d:.3},{f:0,d:.1},{f:98,d:.5}],
|
||||
],
|
||||
};
|
||||
|
||||
/* Battle theme — tense, minor key, faster */
|
||||
const BATTLE = {
|
||||
melody: [
|
||||
/* phrase A — urgent opening */
|
||||
[{f:494,d:.1},{f:523,d:.1},{f:494,d:.1},{f:0,d:.05},
|
||||
{f:440,d:.1},{f:466,d:.1},{f:440,d:.1},{f:0,d:.05},
|
||||
{f:392,d:.1},{f:415,d:.1},{f:392,d:.15},{f:0,d:.08},
|
||||
{f:370,d:.1},{f:392,d:.22}],
|
||||
/* phrase B */
|
||||
[{f:523,d:.1},{f:587,d:.1},{f:523,d:.1},{f:494,d:.1},{f:0,d:.05},
|
||||
{f:466,d:.1},{f:494,d:.1},{f:466,d:.1},{f:440,d:.15},
|
||||
{f:0,d:.08},{f:392,d:.1},{f:440,d:.25}],
|
||||
/* phrase C — climax */
|
||||
[{f:659,d:.1},{f:587,d:.08},{f:523,d:.08},{f:494,d:.08},{f:466,d:.1},
|
||||
{f:440,d:.08},{f:415,d:.08},{f:392,d:.08},{f:370,d:.1},
|
||||
{f:349,d:.08},{f:330,d:.25}],
|
||||
/* phrase D — resolve back */
|
||||
[{f:392,d:.1},{f:440,d:.1},{f:494,d:.1},{f:523,d:.1},{f:494,d:.1},
|
||||
{f:440,d:.1},{f:415,d:.1},{f:392,d:.28}],
|
||||
],
|
||||
bass: [
|
||||
[{f:147,d:.2},{f:0,d:.05},{f:147,d:.2},{f:0,d:.05},{f:139,d:.2},{f:0,d:.05},{f:131,d:.25}],
|
||||
[{f:147,d:.2},{f:0,d:.05},{f:155,d:.2},{f:0,d:.05},{f:147,d:.2},{f:0,d:.05},{f:131,d:.25}],
|
||||
[{f:165,d:.2},{f:0,d:.05},{f:155,d:.2},{f:0,d:.05},{f:147,d:.2},{f:0,d:.05},{f:131,d:.3}],
|
||||
[{f:131,d:.2},{f:0,d:.05},{f:139,d:.2},{f:0,d:.05},{f:147,d:.2},{f:0,d:.05},{f:131,d:.3}],
|
||||
],
|
||||
};
|
||||
|
||||
function stopBGM() {
|
||||
if (bgmTimer) { clearTimeout(bgmTimer); bgmTimer = null; }
|
||||
bgmTrack = null;
|
||||
}
|
||||
|
||||
function playBGMTrack(track, name, phraseIdx) {
|
||||
if (bgmTrack !== name) return; /* stopped or switched */
|
||||
const c = getCtx();
|
||||
const now = c.currentTime;
|
||||
const melody = track.melody[phraseIdx % track.melody.length];
|
||||
const bass = track.bass[phraseIdx % track.bass.length];
|
||||
|
||||
const melVol = name === 'battle' ? 0.13 : 0.11;
|
||||
const basVol = name === 'battle' ? 0.07 : 0.06;
|
||||
|
||||
let end = now + 0.02;
|
||||
/* play melody and bass simultaneously, get the longer duration */
|
||||
let mEnd = now + 0.02;
|
||||
melody.forEach(n => { if(n.f) tone(n.f,'square',melVol,0.01,n.d*.55,n.d*.3,mEnd); mEnd+=n.d; });
|
||||
let bEnd = now + 0.02;
|
||||
bass.forEach(n => { if(n.f) tone(n.f,'triangle',basVol,0.01,n.d*.7,n.d*.2,bEnd); bEnd+=n.d; });
|
||||
end = Math.max(mEnd, bEnd);
|
||||
|
||||
const delay = Math.max(0, (end - c.currentTime) * 1000 - 30);
|
||||
bgmTimer = setTimeout(() => {
|
||||
playBGMTrack(track, name, phraseIdx + 1);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function startBGM(name) {
|
||||
if (bgmTrack === name) return;
|
||||
stopBGM();
|
||||
bgmTrack = name;
|
||||
const track = name === 'battle' ? BATTLE : OVERWORLD;
|
||||
playBGMTrack(track, name, 0);
|
||||
}
|
||||
|
||||
/* ── SFX ──────────────────────────────────────────────── */
|
||||
return {
|
||||
/* Boot jingle — classic rising fanfare */
|
||||
stopBGM,
|
||||
overworldBGM() { startBGM('overworld'); },
|
||||
battleBGM() { startBGM('battle'); },
|
||||
|
||||
boot() {
|
||||
seq([
|
||||
{f:262,d:.1},{f:330,d:.1},{f:392,d:.1},{f:523,d:.25}
|
||||
], 'square', 0.2);
|
||||
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() {
|
||||
stopBGM();
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
stopBGM();
|
||||
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);
|
||||
stopBGM();
|
||||
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);
|
||||
},
|
||||
addChar(ch) { /* kept for compat */ },
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -740,7 +802,7 @@ class Game {
|
||||
|
||||
this._placeMapSprites();
|
||||
this._updateDirArrow(false);
|
||||
SFX.overworldStart();
|
||||
SFX.overworldBGM();
|
||||
saveGame(this.state);
|
||||
this.flashSaveDot();
|
||||
|
||||
@@ -774,6 +836,7 @@ class Game {
|
||||
|
||||
this.show('battle');
|
||||
this._showController(true);
|
||||
SFX.battleBGM();
|
||||
this.state.answering=false;
|
||||
this.state.cursor=0;
|
||||
|
||||
@@ -838,6 +901,7 @@ class Game {
|
||||
/* ── RESULT ───────────────────────────────────────────── */
|
||||
showResult(correct,q) {
|
||||
this.show('result');
|
||||
SFX.stopBGM();
|
||||
this._showController(false);
|
||||
|
||||
document.getElementById('result-icon').textContent = correct?'✓':'✗';
|
||||
|
||||
Reference in new issue
Block a user