sindicate 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/audio/ambience.js +65 -0
- package/src/audio/music.js +234 -0
- package/src/audio/synth.js +228 -0
- package/src/systems/casino/blackjack.js +187 -0
- package/src/systems/casino/cards3d.js +55 -0
- package/src/systems/casino/coins3d.js +75 -0
- package/src/systems/casino/table3d.js +130 -0
- package/src/systems/coach.js +1034 -0
- package/src/systems/combat.js +586 -0
- package/src/systems/crime.js +582 -0
- package/src/systems/encounters.js +1 -1
- package/src/systems/enemy.js +442 -0
- package/src/systems/loot.js +303 -0
- package/src/systems/missions.js +418 -0
- package/src/systems/npc.js +1 -1
- package/src/systems/player.js +1619 -0
- package/src/systems/roadGraph.js +264 -0
- package/src/systems/satchel.js +500 -0
- package/src/systems/scenes.js +105 -0
- package/src/systems/shop.js +515 -0
- package/src/systems/train.js +1957 -0
- package/src/ui/cartograph.js +456 -0
- package/src/ui/deathScreen.js +130 -0
- package/src/ui/dialogue.js +434 -0
- package/src/ui/iconBaker.js +180 -0
- package/src/ui/intro.js +137 -0
- package/src/ui/menuHints.js +79 -0
- package/src/ui/pauseMenu.js +194 -0
- package/src/ui/saveLoadScreen.js +156 -0
- package/src/ui/titleScreen.js +359 -0
- package/src/ui/worldmap.js +837 -0
package/package.json
CHANGED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Layered ambient beds — looping environment loops streamed as HTMLAudio, each with a
|
|
2
|
+
// target volume set by context (time of day). Volumes ramp toward their targets every
|
|
3
|
+
// frame so beds fade in/out naturally. Desert v1: a constant wind bed (stronger at night)
|
|
4
|
+
// plus dawn birdsong. Kept separate from the SFX synth (one-shots) and the streamed music.
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
8
|
+
export function smoothstep(edge0, edge1, x) {
|
|
9
|
+
const t = clamp01((x - edge0) / (edge1 - edge0));
|
|
10
|
+
return t * t * (3 - 2 * t);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class Ambience {
|
|
14
|
+
// beds/paths/targets are GAME data:
|
|
15
|
+
// new Ambience(game, { beds: ['wind'], base, computeTargets: (game, beds) => {...}, storageKey })
|
|
16
|
+
constructor(game, { beds = [], base = '/assets/audio/ambience', computeTargets = null, storageKey = 'av_volAmb' } = {}) {
|
|
17
|
+
this._bedIds = beds;
|
|
18
|
+
this._base = base;
|
|
19
|
+
this._computeFn = computeTargets;
|
|
20
|
+
this._storageKey = storageKey;
|
|
21
|
+
this.game = game;
|
|
22
|
+
this.enabled = true;
|
|
23
|
+
this.master = +(localStorage.getItem(this._storageKey) ?? 0.6); // Ambience slider (0..1); persists
|
|
24
|
+
this.beds = {}; // id -> { el, vol, target }
|
|
25
|
+
this._built = false;
|
|
26
|
+
this._t = 999; // force an immediate target compute on first update
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_build() {
|
|
30
|
+
this._built = true;
|
|
31
|
+
const A = this._base;
|
|
32
|
+
for (const id of this._bedIds) {
|
|
33
|
+
const el = new Audio(`${A}/${id}.wav`);
|
|
34
|
+
el.loop = true; el.preload = 'none'; el.volume = 0;
|
|
35
|
+
this.beds[id] = { el, vol: 0, target: 0 };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
setEnabled(on) {
|
|
40
|
+
this.enabled = on;
|
|
41
|
+
if (!on) for (const b of Object.values(this.beds)) { b.el.pause(); b.vol = 0; b.el.volume = 0; }
|
|
42
|
+
}
|
|
43
|
+
setMuted(on) { for (const b of Object.values(this.beds)) b.el.muted = on; }
|
|
44
|
+
setVolume(v) { this.master = clamp01(v); }
|
|
45
|
+
|
|
46
|
+
update(dt) {
|
|
47
|
+
if (!this.game.audio?.ctx || !this.enabled) return; // wait for the unlock gesture
|
|
48
|
+
if (!this._built) this._build();
|
|
49
|
+
this._t += dt;
|
|
50
|
+
if (this._t >= 0.5) { this._computeTargets(); this._t = 0; }
|
|
51
|
+
for (const b of Object.values(this.beds)) {
|
|
52
|
+
const step = dt * 0.5; // ~2s fade in/out
|
|
53
|
+
b.vol += Math.sign(b.target - b.vol) * Math.min(Math.abs(b.target - b.vol), step);
|
|
54
|
+
if (b.target > 0 && b.el.paused) { try { b.el.currentTime = 0; } catch { /* not seekable yet */ } b.el.play().catch(() => {}); }
|
|
55
|
+
b.el.volume = clamp01(b.vol * this.master);
|
|
56
|
+
if (b.vol < 0.004 && b.target === 0 && !b.el.paused) b.el.pause(); // free the stream once silent
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_computeTargets() {
|
|
61
|
+
for (const id of this._bedIds) this.beds[id].target = 0;
|
|
62
|
+
this._computeFn?.(this.game, this.beds);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Music — WEB AUDIO, decode-ahead. Every track's mp3 is FULLY decoded to a PCM AudioBuffer BEFORE it
|
|
2
|
+
// plays (ctx.decodeAudioData), so nothing decodes during playback and no track garbles its opening.
|
|
3
|
+
// (The old HTML-<audio> path decoded WHILE streaming → a stuttered/garbled first ~half-second on the
|
|
4
|
+
// initial play of every cut — Nick: "decode before playing not while playing".)
|
|
5
|
+
//
|
|
6
|
+
// A shared master GainNode carries the music volume + mute. Each playing track gets its own GainNode
|
|
7
|
+
// for crossfades and the saloon's distance mix. Buffers are decoded on demand and freed when a track
|
|
8
|
+
// stops, so memory stays bounded — the mp3s live in the browser's HTTP cache, so a re-decode is cheap.
|
|
9
|
+
//
|
|
10
|
+
// Catalogue: a title cut (menu), a wander pool under the open world (areas.desert), the saloon's own
|
|
11
|
+
// band (venue, mixed by distance via setVenue), and the campfire rest cuts. Every path no-ops on an
|
|
12
|
+
// empty pool, and the whole thing stays silent until unlock() resumes the context on a user gesture.
|
|
13
|
+
|
|
14
|
+
export class Music {
|
|
15
|
+
constructor({ base = '/assets/audio/music', catalogue = {}, storageKey = 'av_volMusic' } = {}) {
|
|
16
|
+
this._base = base;
|
|
17
|
+
this._cat = catalogue;
|
|
18
|
+
this._storageKey = storageKey;
|
|
19
|
+
this.enabled = true;
|
|
20
|
+
this.volume = +(localStorage.getItem(this._storageKey) ?? 0.55);
|
|
21
|
+
this.fadeMs = 1600;
|
|
22
|
+
this._muted = false;
|
|
23
|
+
this._state = 'idle'; // idle | menu | explore | area | sting | combat
|
|
24
|
+
this._cur = null; // foreground track def
|
|
25
|
+
this._venueTrack = null; // the saloon track currently carrying the bar
|
|
26
|
+
this._venueGain = 0;
|
|
27
|
+
this._duck = 1; // 1 - venueGain (how loud the world music is UNDER the bar)
|
|
28
|
+
this._half = 'south';
|
|
29
|
+
this._area = null;
|
|
30
|
+
this._stingFallback = null;
|
|
31
|
+
this._unlocked = false;
|
|
32
|
+
this.ctx = null; this.master = null;
|
|
33
|
+
|
|
34
|
+
// the catalogue is GAME data: { menu, attack: [], venue: [], areas: {id: []}, restSleep, restWake } (file names)
|
|
35
|
+
const cat = this._cat;
|
|
36
|
+
this.menu = cat.menu ? this._def(cat.menu) : null;
|
|
37
|
+
this.attack = (cat.attack ?? []).map((n) => this._def(n));
|
|
38
|
+
this.venue = (cat.venue ?? []).map((n) => this._def(n));
|
|
39
|
+
this.areas = Object.fromEntries(Object.entries(cat.areas ?? {}).map(([k, v]) => [k, v.map((n) => this._def(n))]));
|
|
40
|
+
this.restSleep = cat.restSleep ? this._def(cat.restSleep) : null;
|
|
41
|
+
this.restWake = cat.restWake ? this._def(cat.restWake) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_def(file) { return { url: `${this._base}/${file}`, buffer: null, _decoding: null, node: null, gain: null }; }
|
|
45
|
+
|
|
46
|
+
_ensureCtx() {
|
|
47
|
+
if (this.ctx) return;
|
|
48
|
+
const Ctx = window.AudioContext || window.webkitAudioContext;
|
|
49
|
+
this.ctx = new Ctx();
|
|
50
|
+
this.master = this.ctx.createGain();
|
|
51
|
+
this.master.gain.value = (this.enabled && !this._muted) ? this.volume : 0;
|
|
52
|
+
this.master.connect(this.ctx.destination);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// fetch + FULLY decode to PCM before it can be played. cached on the track; re-fetch hits HTTP cache.
|
|
56
|
+
async _decode(t) {
|
|
57
|
+
if (t.buffer) return t.buffer;
|
|
58
|
+
if (!t._decoding) {
|
|
59
|
+
t._decoding = fetch(t.url)
|
|
60
|
+
.then((r) => r.arrayBuffer())
|
|
61
|
+
.then((ab) => this.ctx.decodeAudioData(ab))
|
|
62
|
+
.then((buf) => { t.buffer = buf; t._decoding = null; return buf; })
|
|
63
|
+
.catch((e) => { t._decoding = null; throw e; });
|
|
64
|
+
}
|
|
65
|
+
return t._decoding;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_play(t, loop, target, buffer) {
|
|
69
|
+
const src = this.ctx.createBufferSource();
|
|
70
|
+
src.buffer = buffer; src.loop = loop;
|
|
71
|
+
const g = this.ctx.createGain(); g.gain.value = 0;
|
|
72
|
+
src.connect(g).connect(this.master);
|
|
73
|
+
const now = this.ctx.currentTime;
|
|
74
|
+
g.gain.setValueAtTime(0, now);
|
|
75
|
+
g.gain.linearRampToValueAtTime(target, now + this.fadeMs / 1000);
|
|
76
|
+
src.onended = () => { if (!src._manual) this._onEnded(t); this._free(t); };
|
|
77
|
+
src.start();
|
|
78
|
+
t.node = src; t.gain = g;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_stop(t, fade = this.fadeMs) {
|
|
82
|
+
if (!t || !t.node) return;
|
|
83
|
+
const src = t.node, g = t.gain;
|
|
84
|
+
src._manual = true;
|
|
85
|
+
const now = this.ctx.currentTime;
|
|
86
|
+
try { g.gain.cancelScheduledValues(now); g.gain.setValueAtTime(g.gain.value, now); g.gain.linearRampToValueAtTime(0, now + fade / 1000); } catch { /* */ }
|
|
87
|
+
try { src.stop(now + fade / 1000 + 0.05); } catch { /* */ }
|
|
88
|
+
t.node = null; t.gain = null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// release a track's decoded PCM once nothing is using it (keeps memory bounded)
|
|
92
|
+
_free(t) {
|
|
93
|
+
if (t.node || t === this._cur || t === this._venueTrack) return;
|
|
94
|
+
t.buffer = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async _crossfade(t, loop) {
|
|
98
|
+
if (!t) return;
|
|
99
|
+
if (t === this._cur) { if (t.node) t.node.loop = loop; return; }
|
|
100
|
+
const prev = this._cur;
|
|
101
|
+
this._cur = t;
|
|
102
|
+
this._ensureCtx();
|
|
103
|
+
let buffer;
|
|
104
|
+
try { buffer = await this._decode(t); } catch { if (this._cur === t) this._cur = prev; return; }
|
|
105
|
+
if (this._cur !== t) return; // superseded by a newer crossfade during the decode
|
|
106
|
+
if (prev && prev !== t) this._stop(prev, this.fadeMs);
|
|
107
|
+
this._play(t, loop, this._duck, buffer);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// natural end of a non-loop foreground track → carry on with the state's next cut
|
|
111
|
+
_onEnded(t) {
|
|
112
|
+
if (this._cur !== t) return;
|
|
113
|
+
if (t === this.restSleep) return; // asleep under the black; the wake cut takes over
|
|
114
|
+
if (t === this.restWake) { this._state = 'area'; this._playArea(this._area || 'desert'); return; }
|
|
115
|
+
if (this._state === 'sting') {
|
|
116
|
+
const fb = this._stingFallback; this._stingFallback = null;
|
|
117
|
+
this._state = 'area'; this._playArea(fb || this._area || this._half);
|
|
118
|
+
} else if (this._state === 'area' || this._state === 'explore') {
|
|
119
|
+
this._playArea(this._area || this._half);
|
|
120
|
+
} else if (this._state === 'combat') {
|
|
121
|
+
this.enterCombat();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
unlock() {
|
|
126
|
+
if (this._unlocked) return;
|
|
127
|
+
this._unlocked = true;
|
|
128
|
+
this._ensureCtx();
|
|
129
|
+
if (this.ctx.state === 'suspended') this.ctx.resume().catch(() => {});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
playMenu() { this._state = 'menu'; this._crossfade(this.menu, true); }
|
|
133
|
+
startExploration() { this._state = 'explore'; this._area = this._half; this._playArea(this._half); }
|
|
134
|
+
|
|
135
|
+
playSting(poolName, fallback) {
|
|
136
|
+
const pool = this.areas[poolName]; if (!pool || !pool.length) return;
|
|
137
|
+
this._state = 'sting'; this._stingFallback = fallback;
|
|
138
|
+
this._crossfade(pool[Math.floor(Math.random() * pool.length)], false);
|
|
139
|
+
}
|
|
140
|
+
stingActive() { return this._state === 'sting'; }
|
|
141
|
+
|
|
142
|
+
_playArea(name) {
|
|
143
|
+
const pool = this.areas[name]; if (!pool || !pool.length) return;
|
|
144
|
+
const loop = pool.length === 1;
|
|
145
|
+
let i = Math.floor(Math.random() * pool.length);
|
|
146
|
+
if (pool[i] === this._cur) i = (i + 1) % pool.length; // don't repeat the same track back-to-back
|
|
147
|
+
this._crossfade(pool[i], loop);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
setArea(name) {
|
|
151
|
+
name = name || null;
|
|
152
|
+
if (name === this._area) return;
|
|
153
|
+
this._area = name;
|
|
154
|
+
if (this._state === 'combat') return;
|
|
155
|
+
if (name) { this._state = 'area'; this._playArea(name); }
|
|
156
|
+
else { this.startExploration(); }
|
|
157
|
+
}
|
|
158
|
+
setNearbyAreas() { /* Web Audio decodes on demand + frees on stop — no manual streaming to manage */ }
|
|
159
|
+
|
|
160
|
+
// THE BAR, mixed by distance. gain 0..1 = how loud the saloon is from where the player stands.
|
|
161
|
+
// The band plays on its own source under the world music, which we duck by the same amount.
|
|
162
|
+
async setVenue(gain) {
|
|
163
|
+
gain = Math.max(0, Math.min(1, gain || 0));
|
|
164
|
+
this._venueGain = gain;
|
|
165
|
+
this._duck = 1 - gain;
|
|
166
|
+
this._ensureCtx();
|
|
167
|
+
if (gain > 0.01) {
|
|
168
|
+
if (!this._venueTrack) await this._nextVenue();
|
|
169
|
+
else this._rampGain(this._venueTrack, gain, 180);
|
|
170
|
+
} else if (this._venueTrack) {
|
|
171
|
+
this._stop(this._venueTrack, 400); this._venueTrack = null;
|
|
172
|
+
}
|
|
173
|
+
if (this._cur) this._rampGain(this._cur, this._duck, 180);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async _nextVenue() {
|
|
177
|
+
const pool = this.venue.filter((a) => a !== this._venueTrack);
|
|
178
|
+
const t = pool[Math.floor(Math.random() * pool.length)] ?? this.venue[0];
|
|
179
|
+
if (!t) return;
|
|
180
|
+
if (this._venueTrack && this._venueTrack !== t) this._stop(this._venueTrack, 300);
|
|
181
|
+
this._venueTrack = t;
|
|
182
|
+
let buffer;
|
|
183
|
+
try { buffer = await this._decode(t); } catch { if (this._venueTrack === t) this._venueTrack = null; return; }
|
|
184
|
+
if (this._venueTrack !== t) return;
|
|
185
|
+
const src = this.ctx.createBufferSource(); src.buffer = buffer; src.loop = false;
|
|
186
|
+
const g = this.ctx.createGain(); g.gain.value = 0;
|
|
187
|
+
src.connect(g).connect(this.master);
|
|
188
|
+
const now = this.ctx.currentTime;
|
|
189
|
+
g.gain.setValueAtTime(0, now); g.gain.linearRampToValueAtTime(this._venueGain, now + 0.3);
|
|
190
|
+
src.onended = () => { const wasBar = this._venueTrack === t; t.node = null; t.gain = null; this._free(t); if (!src._manual && wasBar && this._venueGain > 0.01) this._nextVenue(); };
|
|
191
|
+
src.start();
|
|
192
|
+
t.node = src; t.gain = g;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
_rampGain(t, target, ms) {
|
|
196
|
+
if (!t || !t.gain) return;
|
|
197
|
+
const now = this.ctx.currentTime;
|
|
198
|
+
try { t.gain.gain.cancelScheduledValues(now); t.gain.gain.setValueAtTime(t.gain.gain.value, now); t.gain.gain.linearRampToValueAtTime(target, now + ms / 1000); } catch { /* */ }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
enterCombat() {
|
|
202
|
+
if (!this.attack.length) return;
|
|
203
|
+
this._state = 'combat';
|
|
204
|
+
this._crossfade(this.attack[Math.floor(Math.random() * this.attack.length)], true);
|
|
205
|
+
}
|
|
206
|
+
exitCombat() {
|
|
207
|
+
if (this._state !== 'combat') return;
|
|
208
|
+
if (this._area) { this._state = 'area'; this._playArea(this._area); }
|
|
209
|
+
else this.startExploration();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// campfire rest: fall-asleep cut on E, wake cut on waking — both fully decoded, so no garbled start.
|
|
213
|
+
// The wake cut is decoded now (at E) so it's ready by the time we wake ~15s later.
|
|
214
|
+
playRestSleep() { this._state = 'sting'; this._ensureCtx(); this._decode(this.restWake).catch(() => {}); this._crossfade(this.restSleep, false); }
|
|
215
|
+
playRestWake() { this._state = 'sting'; this._crossfade(this.restWake, false); }
|
|
216
|
+
|
|
217
|
+
setMuted(on) { this._muted = on; this._applyMaster(); }
|
|
218
|
+
setEnabled(on) { this.enabled = on; if (this.ctx && on) this.ctx.resume?.().catch(() => {}); this._applyMaster(); }
|
|
219
|
+
setMusicVolume(v) {
|
|
220
|
+
this.volume = Math.max(0, Math.min(1, +v || 0));
|
|
221
|
+
try { localStorage.setItem(this._storageKey, this.volume); } catch { /* private mode */ }
|
|
222
|
+
this._applyMaster();
|
|
223
|
+
}
|
|
224
|
+
setVolume(v) { this.setMusicVolume(v); }
|
|
225
|
+
|
|
226
|
+
_applyMaster() {
|
|
227
|
+
if (!this.master) return;
|
|
228
|
+
const target = (this.enabled && !this._muted) ? this.volume : 0;
|
|
229
|
+
const now = this.ctx.currentTime;
|
|
230
|
+
try { this.master.gain.cancelScheduledValues(now); this.master.gain.setValueAtTime(this.master.gain.value, now); this.master.gain.linearRampToValueAtTime(target, now + 0.08); } catch { this.master.gain.value = target; }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
unloadMenu() { /* buffers free themselves on stop */ }
|
|
234
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// SFX engine: real recordings where we have them, WebAudio synthesis where we don't.
|
|
2
|
+
// Gun sounds are synthesized (no recordings in the packs) — they live in the play()
|
|
3
|
+
// switch so a future recorded swap is a this._sfx data entry, zero call-site churn.
|
|
4
|
+
|
|
5
|
+
// SFX that have REAL recorded variants (loaded by loadSfx). play() prefers these over the synth.
|
|
6
|
+
// { b: base sample name, n: variant count, g: gain, j: pitch-jitter (0 keeps musical clips in tune) }.
|
|
7
|
+
// Sources: Fighting Sounds (swing/hit), Deadly Kombat (block), Explosion pack (spell booms),
|
|
8
|
+
// Casual Game SFX (ui/coin/orb/heal/quest/level-up) — all real recordings, no synth beeps.
|
|
9
|
+
// The recorded-SFX table and sample dirs are GAME data — pass at construction:
|
|
10
|
+
// new AudioSystem({ sfx, muted, sfxDirs, storageKey })
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
export class AudioSystem {
|
|
14
|
+
constructor({ sfx = {}, muted = [], sfxDirs = { combat: '/assets/audio/combat', ui: '/assets/audio/ui' }, storageKey = 'av_volSfx' } = {}) {
|
|
15
|
+
this._sfx = sfx;
|
|
16
|
+
this._muted = new Set(muted);
|
|
17
|
+
this._sfxDirs = sfxDirs;
|
|
18
|
+
this._storageKey = storageKey;
|
|
19
|
+
this.ctx = null;
|
|
20
|
+
this.master = null;
|
|
21
|
+
this.musicGain = null;
|
|
22
|
+
this.sfxGain = null;
|
|
23
|
+
this.enabled = true;
|
|
24
|
+
this.mode = 'day'; // day | night | danger | dungeon
|
|
25
|
+
this.muted = false;
|
|
26
|
+
this.samples = {}; // decoded AudioBuffers for the few real recorded SFX (horse etc.)
|
|
27
|
+
this.deadEyeLP = null; // master lowpass — Dead Eye ducks the world underwater (phase 2)
|
|
28
|
+
this.sampleRateScale = 1; // global sample pitch scale — Dead Eye slow-mo hook; 1 = neutral
|
|
29
|
+
this.sfxVolume = +(localStorage.getItem(this._storageKey) ?? 0.85); // Sound FX slider (0..1) → sfxGain
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Sound-FX volume (System-menu slider). Applied to sfxGain; persists.
|
|
33
|
+
setSfxVolume(v) {
|
|
34
|
+
this.sfxVolume = Math.max(0, Math.min(1, v));
|
|
35
|
+
if (this.sfxGain) this.sfxGain.gain.value = this.sfxVolume;
|
|
36
|
+
localStorage.setItem(this._storageKey, this.sfxVolume);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Load & cache a set of {name: url} audio files as AudioBuffers. Idempotent per name.
|
|
40
|
+
// Everything else in this system is synthesized; these are the exceptions where a real
|
|
41
|
+
// recording reads far better than an oscillator (horse neighs/hoofsteps/breathing).
|
|
42
|
+
async loadSamples(map) {
|
|
43
|
+
this.ensure();
|
|
44
|
+
if (!this.ctx) return;
|
|
45
|
+
await Promise.all(Object.entries(map).map(async ([name, url]) => {
|
|
46
|
+
if (this.samples[name]) return;
|
|
47
|
+
try {
|
|
48
|
+
const arr = await (await fetch(url)).arrayBuffer();
|
|
49
|
+
this.samples[name] = await this.ctx.decodeAudioData(arr);
|
|
50
|
+
} catch (e) { console.warn('[audio] sample load failed', name, e); }
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Fire a one-shot (or looping) sample. Returns { src, g } so a loop can be stopped/faded.
|
|
55
|
+
sample(name, { gain = 1, rate = 1, loop = false, dest = this.sfxGain } = {}) {
|
|
56
|
+
if (!this.enabled || !this.ctx) return null;
|
|
57
|
+
const buf = this.samples[name];
|
|
58
|
+
if (!buf) return null;
|
|
59
|
+
const src = this.ctx.createBufferSource();
|
|
60
|
+
src.buffer = buf; src.loop = loop; src.playbackRate.value = rate * this.sampleRateScale;
|
|
61
|
+
const g = this.ctx.createGain(); g.gain.value = gain;
|
|
62
|
+
src.connect(g).connect(dest || this.sfxGain);
|
|
63
|
+
src.start();
|
|
64
|
+
return { src, g };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setMuted(on) { this.muted = on; if (this.master) this.master.gain.value = on ? 0 : 0.5; }
|
|
68
|
+
|
|
69
|
+
ensure() {
|
|
70
|
+
if (this.ctx) return;
|
|
71
|
+
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
72
|
+
this.master = this.ctx.createGain();
|
|
73
|
+
this.master.gain.value = 0.5;
|
|
74
|
+
// Dead Eye lowpass sits between master and the speakers — wide open (neutral) until
|
|
75
|
+
// setDeadEye() ramps it down, so it costs nothing when unused.
|
|
76
|
+
this.deadEyeLP = this.ctx.createBiquadFilter();
|
|
77
|
+
this.deadEyeLP.type = 'lowpass';
|
|
78
|
+
this.deadEyeLP.frequency.value = 20000;
|
|
79
|
+
this.deadEyeLP.Q.value = 0.0001;
|
|
80
|
+
this.master.connect(this.deadEyeLP).connect(this.ctx.destination);
|
|
81
|
+
this.musicGain = this.ctx.createGain();
|
|
82
|
+
this.musicGain.gain.value = 0.32;
|
|
83
|
+
this.musicGain.connect(this.master);
|
|
84
|
+
this.sfxGain = this.ctx.createGain();
|
|
85
|
+
this.sfxGain.gain.value = this.sfxVolume;
|
|
86
|
+
this.sfxGain.connect(this.master);
|
|
87
|
+
this.loadSfx(); // decode all the recorded SFX (combat + ui/pickup/spell) now the context exists
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Dead Eye (phase 2): 0 = normal, 1 = full slow-mo. Muffles the world (20kHz -> 700Hz,
|
|
91
|
+
// exponential so the sweep sounds even) and slows sample pitch a touch. Safe pre-ensure().
|
|
92
|
+
setDeadEye(amount) {
|
|
93
|
+
const a = Math.max(0, Math.min(1, amount));
|
|
94
|
+
this.sampleRateScale = 1 - 0.25 * a;
|
|
95
|
+
if (!this.deadEyeLP) return;
|
|
96
|
+
const f = 20000 * Math.pow(700 / 20000, a);
|
|
97
|
+
this.deadEyeLP.frequency.setTargetAtTime(f, this.ctx.currentTime, 0.08);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setVolume(v) { if (this.master) this.master.gain.value = v; }
|
|
101
|
+
|
|
102
|
+
// ----- tiny synth helpers -----
|
|
103
|
+
osc(type, freq, t0, dur, gainEnv, dest = this.sfxGain, detune = 0) {
|
|
104
|
+
const o = this.ctx.createOscillator();
|
|
105
|
+
o.type = type; o.frequency.value = freq; o.detune.value = detune;
|
|
106
|
+
const g = this.ctx.createGain();
|
|
107
|
+
g.gain.setValueAtTime(0, t0);
|
|
108
|
+
for (const [dt, v] of gainEnv) g.gain.linearRampToValueAtTime(v, t0 + dt);
|
|
109
|
+
o.connect(g).connect(dest);
|
|
110
|
+
o.start(t0); o.stop(t0 + dur + 0.05);
|
|
111
|
+
return o;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
noise(t0, dur, { freq = 1200, q = 1, env = [[0, 0.6], [0.15, 0]] } = {}, dest = this.sfxGain) {
|
|
115
|
+
const len = Math.ceil(this.ctx.sampleRate * dur);
|
|
116
|
+
const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
|
|
117
|
+
const d = buf.getChannelData(0);
|
|
118
|
+
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
|
|
119
|
+
const src = this.ctx.createBufferSource();
|
|
120
|
+
src.buffer = buf;
|
|
121
|
+
const f = this.ctx.createBiquadFilter();
|
|
122
|
+
f.type = 'bandpass'; f.frequency.value = freq; f.Q.value = q;
|
|
123
|
+
const g = this.ctx.createGain();
|
|
124
|
+
g.gain.setValueAtTime(0, t0);
|
|
125
|
+
for (const [dt, v] of env) g.gain.linearRampToValueAtTime(v, t0 + dt);
|
|
126
|
+
src.connect(f).connect(g).connect(dest);
|
|
127
|
+
src.start(t0); src.stop(t0 + dur);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Lazily load all recorded SFX (combat swing/hit/block/boom + ui/coin/orb/heal/quest/level-up).
|
|
131
|
+
// Called from ensure() once the context exists; sample() no-ops until they finish decoding.
|
|
132
|
+
loadSfx() {
|
|
133
|
+
const C = this._sfxDirs.combat, U = this._sfxDirs.ui;
|
|
134
|
+
const map = {};
|
|
135
|
+
for (let i = 1; i <= 6; i++) { map[`swing${i}`] = `${C}/swing${i}.wav`; map[`hit${i}`] = `${C}/hit${i}.wav`; }
|
|
136
|
+
for (let i = 1; i <= 3; i++) map[`block${i}`] = `${C}/block${i}.wav`;
|
|
137
|
+
for (let i = 1; i <= 4; i++) map[`boom${i}`] = `${C}/boom${i}.wav`;
|
|
138
|
+
map.uiclick1 = `${U}/uiclick1.wav`;
|
|
139
|
+
for (let i = 1; i <= 2; i++) map[`coin${i}`] = `${U}/coin${i}.wav`;
|
|
140
|
+
for (let i = 1; i <= 3; i++) map[`orb${i}`] = `${U}/orb${i}.wav`;
|
|
141
|
+
for (const e of ['heal', 'quest', 'questDone', 'renown', 'levelup']) map[`${e}1`] = `${U}/${e}1.wav`;
|
|
142
|
+
this.loadSamples(map);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// gainMul (default 1) scales the sound's volume — the hook for distance-attenuated one-shots: a
|
|
146
|
+
// caller with a world position computes (1 − d/range) and passes it, exactly as _idleVoice does with
|
|
147
|
+
// sample(). It applies on the recorded path (where the fight SFX live); the synth fallbacks are UI/gun
|
|
148
|
+
// cues played at fixed spots, so they ignore it.
|
|
149
|
+
play(name, gainMul = 1) {
|
|
150
|
+
if (!this.enabled || !this.ctx || gainMul <= 0) return;
|
|
151
|
+
// real recorded variants take priority over the synth fallback (once decoded)
|
|
152
|
+
const r = this._sfx[name];
|
|
153
|
+
if (r && this.samples[`${r.b}1`]) {
|
|
154
|
+
const k = 1 + ((Math.random() * r.n) | 0);
|
|
155
|
+
this.sample(`${r.b}${k}`, { gain: r.g * gainMul, rate: (r.r ?? 1) * (r.j ? 0.94 + Math.random() * 0.12 : 1) });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (this._muted.has(name)) return; // no recorded source yet — silent, never a synth beep
|
|
159
|
+
const t = this.ctx.currentTime + 0.001;
|
|
160
|
+
switch (name) {
|
|
161
|
+
// ---- guns (synthesized — no recordings; a this._sfx entry later swaps these out) ----
|
|
162
|
+
case 'gunshot': {
|
|
163
|
+
const j = 0.92 + Math.random() * 0.16; // slight per-shot drift so volleys don't machine-gun
|
|
164
|
+
this.noise(t, 0.08, { freq: 3200 * j, q: 0.4, env: [[0, 1.0], [0.08, 0]] }); // crack
|
|
165
|
+
this.noise(t, 0.25, { freq: 170 * j, q: 0.7, env: [[0, 0], [0.02, 0.85], [0.25, 0]] }); // lowpassed boom tail
|
|
166
|
+
this.osc('sine', 62 * j, t, 0.2, [[0, 0.5], [0.2, 0]]); // chest thump
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case 'gunshot_far': { // distant/muffled report — enemy fire across the street
|
|
170
|
+
const j = 0.9 + Math.random() * 0.2;
|
|
171
|
+
this.noise(t, 0.45, { freq: 130 * j, q: 0.8, env: [[0, 0], [0.04, 0.4], [0.45, 0]] });
|
|
172
|
+
this.osc('sine', 55 * j, t, 0.35, [[0, 0.25], [0.35, 0]]);
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case 'ricochet': { // descending whine off rock/wood; randomized start pitch
|
|
176
|
+
const f0 = 1900 + Math.random() * 1400;
|
|
177
|
+
const o = this.ctx.createOscillator();
|
|
178
|
+
o.type = 'sine';
|
|
179
|
+
o.frequency.setValueAtTime(f0, t);
|
|
180
|
+
o.frequency.exponentialRampToValueAtTime(f0 * 0.28, t + 0.2);
|
|
181
|
+
const g = this.ctx.createGain();
|
|
182
|
+
g.gain.setValueAtTime(0, t);
|
|
183
|
+
g.gain.linearRampToValueAtTime(0.22, t + 0.015);
|
|
184
|
+
g.gain.linearRampToValueAtTime(0, t + 0.2);
|
|
185
|
+
o.connect(g).connect(this.sfxGain);
|
|
186
|
+
o.start(t); o.stop(t + 0.25);
|
|
187
|
+
this.noise(t, 0.05, { freq: 2600, q: 1.5, env: [[0, 0.3], [0.05, 0]] }); // impact ping
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
case 'dryfire': this.noise(t, 0.03, { freq: 2400, q: 2, env: [[0, 0.3], [0.03, 0]] }); break;
|
|
191
|
+
case 'reload': // two hammer/cylinder clicks
|
|
192
|
+
this.noise(t, 0.035, { freq: 1700, q: 2.5, env: [[0, 0.35], [0.035, 0]] });
|
|
193
|
+
this.noise(t + 0.12, 0.04, { freq: 1300, q: 2.5, env: [[0, 0.4], [0.04, 0]] });
|
|
194
|
+
break;
|
|
195
|
+
case 'swing': this.noise(t, 0.18, { freq: 900, q: 0.8, env: [[0, 0], [0.04, 0.5], [0.18, 0]] }); break;
|
|
196
|
+
case 'swingHeavy': this.noise(t, 0.3, { freq: 500, q: 0.8, env: [[0, 0], [0.06, 0.7], [0.3, 0]] }); break;
|
|
197
|
+
case 'hit': this.noise(t, 0.12, { freq: 350, q: 1.5, env: [[0, 0.9], [0.12, 0]] }); this.osc('square', 90, t, 0.1, [[0, 0.5], [0.1, 0]]); break;
|
|
198
|
+
case 'hurt': this.osc('sawtooth', 160, t, 0.18, [[0, 0.4], [0.18, 0]]); break;
|
|
199
|
+
case 'block': this.osc('triangle', 1200, t, 0.1, [[0, 0.5], [0.1, 0]]); this.noise(t, 0.08, { freq: 2400, q: 3, env: [[0, 0.4], [0.08, 0]] }); break;
|
|
200
|
+
case 'whoosh': this.noise(t, 0.25, { freq: 600, q: 0.6, env: [[0, 0], [0.1, 0.4], [0.25, 0]] }); break;
|
|
201
|
+
case 'bow': this.osc('triangle', 220, t, 0.12, [[0, 0.5], [0.12, 0]]); this.noise(t, 0.15, { freq: 1500, q: 2, env: [[0, 0.3], [0.15, 0]] }); break;
|
|
202
|
+
case 'fireball': this.noise(t, 0.5, { freq: 300, q: 0.5, env: [[0, 0], [0.15, 0.6], [0.5, 0]] }); break;
|
|
203
|
+
case 'boom': this.noise(t, 0.6, { freq: 120, q: 0.4, env: [[0, 1], [0.6, 0]] }); this.osc('sine', 55, t, 0.5, [[0, 0.8], [0.5, 0]]); break;
|
|
204
|
+
case 'lightning': this.noise(t, 0.3, { freq: 3500, q: 0.5, env: [[0, 0.8], [0.3, 0]] }); this.osc('sawtooth', 80, t, 0.25, [[0, 0.5], [0.25, 0]]); break;
|
|
205
|
+
case 'push': this.osc('sine', 70, t, 0.4, [[0, 0.9], [0.4, 0]]); this.noise(t, 0.3, { freq: 250, q: 0.7, env: [[0, 0.5], [0.3, 0]] }); break;
|
|
206
|
+
case 'heal': this.arp(t, [523, 659, 784], 0.09, 'sine', 0.25); break;
|
|
207
|
+
case 'orb': this.osc('sine', 880 + Math.random() * 220, t, 0.18, [[0, 0.25], [0.18, 0]]); break;
|
|
208
|
+
case 'quest': this.arp(t, [392, 523, 659, 784], 0.13, 'triangle', 0.3); break;
|
|
209
|
+
case 'questDone': this.arp(t, [523, 659, 784, 1046, 784, 1046], 0.12, 'triangle', 0.32); break;
|
|
210
|
+
case 'levelup': this.arp(t, [392, 494, 587, 784, 988], 0.1, 'square', 0.18); break;
|
|
211
|
+
case 'renown': this.arp(t, [330, 392, 494, 659], 0.15, 'triangle', 0.28); break;
|
|
212
|
+
case 'aggro': this.osc('sawtooth', 110, t, 0.3, [[0, 0.25], [0.3, 0]]); break;
|
|
213
|
+
case 'ui': this.osc('sine', 660, t, 0.06, [[0, 0.2], [0.06, 0]]); break;
|
|
214
|
+
case 'coin': this.osc('square', 1320, t, 0.08, [[0, 0.15], [0.08, 0]]); this.osc('square', 1760, t + 0.06, 0.08, [[0, 0.12], [0.08, 0]]); break;
|
|
215
|
+
case 'door': this.noise(t, 0.4, { freq: 180, q: 1, env: [[0, 0.5], [0.4, 0]] }); break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
arp(t0, freqs, step, type = 'sine', vol = 0.25) {
|
|
220
|
+
freqs.forEach((f, i) => {
|
|
221
|
+
this.osc(type, f, t0 + i * step, step * 1.8, [[0, vol], [step * 1.8, 0]]);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
setMode(mode) {
|
|
226
|
+
if (this.mode !== mode) this.mode = mode;
|
|
227
|
+
}
|
|
228
|
+
}
|