sindicate 0.5.0 → 0.7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sindicate",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Sindicate — open-world game engine for three.js WebGPU. Streaming worlds, characters, mounts & vehicles, missions, crowds.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -22,7 +22,8 @@
22
22
  ],
23
23
  "exports": {
24
24
  ".": "./src/index.js",
25
- "./*": "./src/*"
25
+ "./*": "./src/*",
26
+ "./tools/*": "./tools/*"
26
27
  },
27
28
  "files": [
28
29
  "src",
@@ -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,244 @@
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 || Object.keys(this.areas)[0]); 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
+ // Pre-decode the catalogue's `resident` list (file names) so combat stings and
130
+ // exploration cuts start INSTANTLY — everything else decodes on first crossfade.
131
+ if (this._cat.resident?.length) {
132
+ const defs = [this.menu, ...this.attack, ...this.venue,
133
+ ...Object.values(this.areas).flat(), this.restSleep, this.restWake].filter(Boolean);
134
+ for (const name of this._cat.resident) {
135
+ const t = defs.find((d) => d.url.endsWith(`/${name}`));
136
+ if (t) this._decode(t).catch(() => { /* pre-decode is best-effort */ });
137
+ }
138
+ }
139
+ if (this.ctx.state === 'suspended') this.ctx.resume().catch(() => {});
140
+ }
141
+
142
+ playMenu() { this._state = 'menu'; this._crossfade(this.menu, true); }
143
+ startExploration() { this._state = 'explore'; this._area = this._half; this._playArea(this._half); }
144
+
145
+ playSting(poolName, fallback) {
146
+ const pool = this.areas[poolName]; if (!pool || !pool.length) return;
147
+ this._state = 'sting'; this._stingFallback = fallback;
148
+ this._crossfade(pool[Math.floor(Math.random() * pool.length)], false);
149
+ }
150
+ stingActive() { return this._state === 'sting'; }
151
+
152
+ _playArea(name) {
153
+ const pool = this.areas[name]; if (!pool || !pool.length) return;
154
+ const loop = pool.length === 1;
155
+ let i = Math.floor(Math.random() * pool.length);
156
+ if (pool[i] === this._cur) i = (i + 1) % pool.length; // don't repeat the same track back-to-back
157
+ this._crossfade(pool[i], loop);
158
+ }
159
+
160
+ setArea(name) {
161
+ name = name || null;
162
+ if (name === this._area) return;
163
+ this._area = name;
164
+ if (this._state === 'combat') return;
165
+ if (name) { this._state = 'area'; this._playArea(name); }
166
+ else { this.startExploration(); }
167
+ }
168
+ setNearbyAreas() { /* Web Audio decodes on demand + frees on stop — no manual streaming to manage */ }
169
+
170
+ // THE BAR, mixed by distance. gain 0..1 = how loud the saloon is from where the player stands.
171
+ // The band plays on its own source under the world music, which we duck by the same amount.
172
+ async setVenue(gain) {
173
+ gain = Math.max(0, Math.min(1, gain || 0));
174
+ this._venueGain = gain;
175
+ this._duck = 1 - gain;
176
+ this._ensureCtx();
177
+ if (gain > 0.01) {
178
+ if (!this._venueTrack) await this._nextVenue();
179
+ else this._rampGain(this._venueTrack, gain, 180);
180
+ } else if (this._venueTrack) {
181
+ this._stop(this._venueTrack, 400); this._venueTrack = null;
182
+ }
183
+ if (this._cur) this._rampGain(this._cur, this._duck, 180);
184
+ }
185
+
186
+ async _nextVenue() {
187
+ const pool = this.venue.filter((a) => a !== this._venueTrack);
188
+ const t = pool[Math.floor(Math.random() * pool.length)] ?? this.venue[0];
189
+ if (!t) return;
190
+ if (this._venueTrack && this._venueTrack !== t) this._stop(this._venueTrack, 300);
191
+ this._venueTrack = t;
192
+ let buffer;
193
+ try { buffer = await this._decode(t); } catch { if (this._venueTrack === t) this._venueTrack = null; return; }
194
+ if (this._venueTrack !== t) return;
195
+ const src = this.ctx.createBufferSource(); src.buffer = buffer; src.loop = false;
196
+ const g = this.ctx.createGain(); g.gain.value = 0;
197
+ src.connect(g).connect(this.master);
198
+ const now = this.ctx.currentTime;
199
+ g.gain.setValueAtTime(0, now); g.gain.linearRampToValueAtTime(this._venueGain, now + 0.3);
200
+ 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(); };
201
+ src.start();
202
+ t.node = src; t.gain = g;
203
+ }
204
+
205
+ _rampGain(t, target, ms) {
206
+ if (!t || !t.gain) return;
207
+ const now = this.ctx.currentTime;
208
+ try { t.gain.gain.cancelScheduledValues(now); t.gain.gain.setValueAtTime(t.gain.gain.value, now); t.gain.gain.linearRampToValueAtTime(target, now + ms / 1000); } catch { /* */ }
209
+ }
210
+
211
+ enterCombat() {
212
+ if (!this.attack.length) return;
213
+ this._state = 'combat';
214
+ this._crossfade(this.attack[Math.floor(Math.random() * this.attack.length)], true);
215
+ }
216
+ exitCombat() {
217
+ if (this._state !== 'combat') return;
218
+ if (this._area) { this._state = 'area'; this._playArea(this._area); }
219
+ else this.startExploration();
220
+ }
221
+
222
+ // campfire rest: fall-asleep cut on E, wake cut on waking — both fully decoded, so no garbled start.
223
+ // The wake cut is decoded now (at E) so it's ready by the time we wake ~15s later.
224
+ playRestSleep() { this._state = 'sting'; this._ensureCtx(); this._decode(this.restWake).catch(() => {}); this._crossfade(this.restSleep, false); }
225
+ playRestWake() { this._state = 'sting'; this._crossfade(this.restWake, false); }
226
+
227
+ setMuted(on) { this._muted = on; this._applyMaster(); }
228
+ setEnabled(on) { this.enabled = on; if (this.ctx && on) this.ctx.resume?.().catch(() => {}); this._applyMaster(); }
229
+ setMusicVolume(v) {
230
+ this.volume = Math.max(0, Math.min(1, +v || 0));
231
+ try { localStorage.setItem(this._storageKey, this.volume); } catch { /* private mode */ }
232
+ this._applyMaster();
233
+ }
234
+ setVolume(v) { this.setMusicVolume(v); }
235
+
236
+ _applyMaster() {
237
+ if (!this.master) return;
238
+ const target = (this.enabled && !this._muted) ? this.volume : 0;
239
+ const now = this.ctx.currentTime;
240
+ 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; }
241
+ }
242
+
243
+ unloadMenu() { /* buffers free themselves on stop */ }
244
+ }
@@ -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
+ }
@@ -8,6 +8,11 @@ import { texture, uv } from 'three/tsl';
8
8
  // Register BEFORE the first applyAtlas/instantiate call (materials are cached).
9
9
  let atlasEmissiveHook = null;
10
10
  export function setAtlasEmissiveHook(fn) { atlasEmissiveHook = fn; }
11
+
12
+ // The cooked-clip cache version tracks each GAME's animation sources (a rebake bumps
13
+ // it), not the engine — register per game: setClipCacheVersion(2). Defaults to 2
14
+ // (western's current rebake generation).
15
+ export function setClipCacheVersion(v) { CLIP_JSON_VER = v; }
11
16
  // Patched loader from vendor/ — merges ALL FBX animation layers per stack. The
12
17
  // engine does NOT depend on a node_modules postinstall patch.
13
18
  import { FBXLoader } from '../vendor/FBXLoader.js';
@@ -532,7 +537,7 @@ export function applyAtlas(root, tex, { emissive = null } = {}) {
532
537
  // public/assets/clips/<name>.json on first parse (self-written via /__save-clipjson)
533
538
  // and read the JSON on every later boot. Bump CLIP_JSON_VER after changing any source
534
539
  // animation (the name alone can't see content changes), or delete the folder.
535
- const CLIP_JSON_VER = 2; // v2: full-pack gsbake rebake (97 clips, 2026-07-12)
540
+ let CLIP_JSON_VER = 2; // v2: full-pack gsbake rebake (97 clips, 2026-07-12)
536
541
  function clipJsonName(url) {
537
542
  let h = 5381;
538
543
  for (let i = 0; i < url.length; i++) h = ((h << 5) + h + url.charCodeAt(i)) >>> 0;