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
+130
-66
@@ -55,6 +55,9 @@ function clearSave() { try { localStorage.removeItem(SAVE_KEY); } catch(_){} }
|
|||||||
/* ─── CHIPTUNE SOUND ENGINE ──────────────────────────────── */
|
/* ─── CHIPTUNE SOUND ENGINE ──────────────────────────────── */
|
||||||
const SFX = (() => {
|
const SFX = (() => {
|
||||||
let ctx = null;
|
let ctx = null;
|
||||||
|
let bgmNodes = []; /* currently playing BGM oscillators/gains */
|
||||||
|
let bgmTrack = null; /* 'overworld' | 'battle' | null */
|
||||||
|
let bgmTimer = null;
|
||||||
|
|
||||||
function getCtx() {
|
function getCtx() {
|
||||||
if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)();
|
if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
@@ -62,12 +65,11 @@ const SFX = (() => {
|
|||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Core: play a tone with envelope */
|
|
||||||
function tone(freq, type, vol, attack, sustain, release, when) {
|
function tone(freq, type, vol, attack, sustain, release, when) {
|
||||||
const c = getCtx();
|
const c = getCtx();
|
||||||
const t = when ?? c.currentTime;
|
const t = when ?? c.currentTime;
|
||||||
const osc = c.createOscillator();
|
const osc = c.createOscillator();
|
||||||
const gain= c.createGain();
|
const gain = c.createGain();
|
||||||
osc.connect(gain);
|
osc.connect(gain);
|
||||||
gain.connect(c.destination);
|
gain.connect(c.destination);
|
||||||
osc.type = type;
|
osc.type = type;
|
||||||
@@ -77,98 +79,158 @@ const SFX = (() => {
|
|||||||
gain.gain.setValueAtTime(vol, t + attack + sustain);
|
gain.gain.setValueAtTime(vol, t + attack + sustain);
|
||||||
gain.gain.exponentialRampToValueAtTime(0.0001, t + attack + sustain + release);
|
gain.gain.exponentialRampToValueAtTime(0.0001, t + attack + sustain + release);
|
||||||
osc.start(t);
|
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, startAt) {
|
||||||
function seq(notes, type='square', vol=0.18) {
|
|
||||||
const c = getCtx();
|
const c = getCtx();
|
||||||
let t = c.currentTime + 0.02;
|
let t = startAt ?? (c.currentTime + 0.02);
|
||||||
notes.forEach(n => {
|
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;
|
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 {
|
return {
|
||||||
/* Boot jingle — classic rising fanfare */
|
stopBGM,
|
||||||
|
overworldBGM() { startBGM('overworld'); },
|
||||||
|
battleBGM() { startBGM('battle'); },
|
||||||
|
|
||||||
boot() {
|
boot() {
|
||||||
seq([
|
seq([{f:262,d:.1},{f:330,d:.1},{f:392,d:.1},{f:523,d:.25}],'square',0.2);
|
||||||
{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() {
|
encounter() {
|
||||||
seq([{f:523,d:.1},{f:659,d:.18}], 'square', 0.22);
|
stopBGM();
|
||||||
|
seq([{f:523,d:.1},{f:659,d:.18}],'square',0.22);
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Typewriter blip per character */
|
|
||||||
type() {
|
type() {
|
||||||
tone(880 + Math.random()*200, 'square', 0.03, 0.005, 0.01, 0.02);
|
tone(880+Math.random()*200,'square',0.03,0.005,0.01,0.02);
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Select / menu move */
|
|
||||||
select() {
|
select() {
|
||||||
tone(440, 'square', 0.12, 0.005, 0.03, 0.04);
|
tone(440,'square',0.12,0.005,0.03,0.04);
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Correct answer — happy ascending chord */
|
|
||||||
correct() {
|
correct() {
|
||||||
seq([
|
seq([{f:523,d:.08},{f:659,d:.08},{f:784,d:.08},{f:1047,d:.2}],'square',0.18);
|
||||||
{f:523,d:.08},{f:659,d:.08},{f:784,d:.08},{f:1047,d:.2}
|
|
||||||
], 'square', 0.18);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Streak bonus — extra flourish */
|
|
||||||
streak() {
|
streak() {
|
||||||
seq([
|
seq([{f:523,d:.06},{f:659,d:.06},{f:784,d:.06},{f:1047,d:.06},{f:1319,d:.2}],'square',0.18);
|
||||||
{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() {
|
wrong() {
|
||||||
seq([
|
seq([{f:330,d:.1},{f:277,d:.1},{f:233,d:.18}],'sawtooth',0.15);
|
||||||
{f:330,d:.1},{f:277,d:.1},{f:233,d:.18}
|
|
||||||
], 'sawtooth', 0.15);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Badge / level up fanfare */
|
|
||||||
levelUp() {
|
levelUp() {
|
||||||
seq([
|
stopBGM();
|
||||||
{f:523,d:.1},{f:659,d:.1},{f:784,d:.1},{f:659,d:.1},
|
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);
|
||||||
{f:784,d:.1},{f:1047,d:.3}
|
|
||||||
], 'square', 0.2);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Champion / completion — full fanfare */
|
|
||||||
complete() {
|
complete() {
|
||||||
seq([
|
stopBGM();
|
||||||
{f:523,d:.1},{f:659,d:.1},{f:784,d:.1},{f:1047,d:.1},
|
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}
|
{f:784,d:.08},{f:880,d:.08},{f:1047,d:.08},{f:1319,d:.4}],'square',0.2);
|
||||||
], 'square', 0.2);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Menu confirm (name entry OK) */
|
|
||||||
confirm() {
|
confirm() {
|
||||||
seq([{f:523,d:.08},{f:784,d:.15}], 'square', 0.18);
|
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._placeMapSprites();
|
||||||
this._updateDirArrow(false);
|
this._updateDirArrow(false);
|
||||||
SFX.overworldStart();
|
SFX.overworldBGM();
|
||||||
saveGame(this.state);
|
saveGame(this.state);
|
||||||
this.flashSaveDot();
|
this.flashSaveDot();
|
||||||
|
|
||||||
@@ -774,6 +836,7 @@ class Game {
|
|||||||
|
|
||||||
this.show('battle');
|
this.show('battle');
|
||||||
this._showController(true);
|
this._showController(true);
|
||||||
|
SFX.battleBGM();
|
||||||
this.state.answering=false;
|
this.state.answering=false;
|
||||||
this.state.cursor=0;
|
this.state.cursor=0;
|
||||||
|
|
||||||
@@ -838,6 +901,7 @@ class Game {
|
|||||||
/* ── RESULT ───────────────────────────────────────────── */
|
/* ── RESULT ───────────────────────────────────────────── */
|
||||||
showResult(correct,q) {
|
showResult(correct,q) {
|
||||||
this.show('result');
|
this.show('result');
|
||||||
|
SFX.stopBGM();
|
||||||
this._showController(false);
|
this._showController(false);
|
||||||
|
|
||||||
document.getElementById('result-icon').textContent = correct?'✓':'✗';
|
document.getElementById('result-icon').textContent = correct?'✓':'✗';
|
||||||
|
|||||||
Reference in new issue
Block a user