kiwiengine 0.5.15 → 0.5.17

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.
@@ -9,7 +9,9 @@ async function getAvailableContext() {
9
9
  return audioContext;
10
10
  }
11
11
  let isPageVisible = !document.hidden;
12
- window.addEventListener('visibilitychange', () => isPageVisible = !document.hidden);
12
+ window.addEventListener('visibilitychange', () => {
13
+ isPageVisible = !document.hidden;
14
+ });
13
15
  function createSafeStorage() {
14
16
  const memory = (() => {
15
17
  const m = new Map();
@@ -41,7 +43,19 @@ function createSafeStorage() {
41
43
  }
42
44
  }
43
45
  const safeStorage = createSafeStorage();
44
- class Audio {
46
+ function clamp01(n) {
47
+ return Math.max(0, Math.min(1, n));
48
+ }
49
+ function readBool(key, defaultValue) {
50
+ const v = safeStorage.getItem(key);
51
+ if (v == null)
52
+ return defaultValue;
53
+ return v === '1' || v === 'true';
54
+ }
55
+ function writeBool(key, value) {
56
+ safeStorage.setItem(key, value ? '1' : '0');
57
+ }
58
+ export class Audio {
45
59
  src;
46
60
  #volume;
47
61
  #loop;
@@ -56,17 +70,18 @@ class Audio {
56
70
  #offset = 0;
57
71
  constructor(src, volume, loop) {
58
72
  this.src = src;
59
- this.#volume = volume;
73
+ this.#volume = clamp01(volume);
60
74
  this.#loop = loop;
61
75
  this.play();
62
76
  }
63
77
  get volume() { return this.#volume; }
64
78
  set volume(volume) {
65
- this.#volume = volume;
79
+ this.#volume = clamp01(volume);
66
80
  if (this.#gainNode)
67
- this.#gainNode.gain.value = Math.max(0, Math.min(1, volume));
81
+ this.#gainNode.gain.value = this.#volume;
68
82
  }
69
83
  async play() {
84
+ // On mobile, avoid starting audio while the page is hidden (often blocked / unreliable)
70
85
  if (isMobile && !isPageVisible)
71
86
  return;
72
87
  if (!this.#audioBuffer) {
@@ -80,14 +95,17 @@ class Audio {
80
95
  }
81
96
  if (!this.#audioBuffer)
82
97
  return;
98
+ // If already playing, restart cleanly
83
99
  if (this.#isPlaying)
84
100
  this.stop();
101
+ // If this is not a resume, reset offset to the beginning
85
102
  if (!this.#isPaused)
86
103
  this.#offset = 0;
87
104
  this.#isPlaying = true;
88
105
  this.#isPaused = false;
89
106
  if (!this.#audioContext)
90
107
  this.#audioContext = await getAvailableContext();
108
+ // If state changed while awaiting, bail out
91
109
  if (!this.#isPlaying)
92
110
  return;
93
111
  if (!this.#gainNode) {
@@ -95,25 +113,39 @@ class Audio {
95
113
  this.#gainNode.gain.value = this.#volume;
96
114
  this.#gainNode.connect(this.#audioContext.destination);
97
115
  }
116
+ else {
117
+ this.#gainNode.gain.value = this.#volume;
118
+ }
98
119
  this.#source = this.#audioContext.createBufferSource();
99
120
  this.#source.buffer = this.#audioBuffer;
100
121
  this.#source.loop = this.#loop;
101
122
  this.#source.connect(this.#gainNode);
102
123
  this.#source.start(0, this.#offset);
103
124
  this.#startTime = this.#audioContext.currentTime;
104
- this.#source.onended = () => { if (!this.#isPaused && !this.#loop)
105
- this.stop(); };
125
+ this.#source.onended = () => {
126
+ // Only auto-stop for one-shot sounds that were not paused and are not looping
127
+ if (!this.#isPaused && !this.#loop)
128
+ this.stop();
129
+ };
106
130
  }
107
131
  #clear() {
108
132
  if (this.#source) {
109
- this.#source.stop();
110
- this.#source.disconnect();
133
+ // stop() can throw if already stopped; ignore safely
134
+ try {
135
+ this.#source.stop();
136
+ }
137
+ catch { /* noop */ }
138
+ try {
139
+ this.#source.disconnect();
140
+ }
141
+ catch { /* noop */ }
111
142
  this.#source = undefined;
112
143
  }
113
144
  }
114
145
  pause() {
115
146
  if (this.#isPlaying && !this.#isPaused) {
116
147
  if (this.#audioContext) {
148
+ // Track elapsed time so we can resume from the correct offset
117
149
  this.#pauseTime = this.#audioContext.currentTime;
118
150
  this.#offset += this.#pauseTime - this.#startTime;
119
151
  }
@@ -123,6 +155,7 @@ class Audio {
123
155
  }
124
156
  }
125
157
  stop() {
158
+ // Full stop resets the offset; next play starts from the beginning
126
159
  this.#isPlaying = false;
127
160
  this.#isPaused = false;
128
161
  this.#offset = 0;
@@ -131,33 +164,68 @@ class Audio {
131
164
  }
132
165
  class MusicPlayer {
133
166
  #volume = 0.7;
167
+ #enabled = true;
134
168
  #currentAudio;
169
+ #pendingSrc;
135
170
  constructor() {
136
- const stored = parseFloat(safeStorage.getItem('musicVolume') || '');
137
- this.#volume = Number.isNaN(stored) ? this.#volume : stored;
171
+ const storedVol = parseFloat(safeStorage.getItem('musicVolume') || '');
172
+ this.#volume = Number.isNaN(storedVol) ? this.#volume : clamp01(storedVol);
173
+ // Separate from volume: true/false music enable state
174
+ this.#enabled = readBool('musicEnabled', true);
138
175
  if (isMobile) {
139
176
  document.addEventListener('visibilitychange', () => {
140
- if (document.hidden)
177
+ if (document.hidden) {
178
+ // When hidden, pause to avoid mobile audio issues
141
179
  this.pause();
180
+ }
142
181
  else {
143
182
  isPageVisible = true;
144
- this.#currentAudio?.play();
183
+ // Only resume if enabled
184
+ if (this.#enabled)
185
+ this.#currentAudio?.play();
145
186
  }
146
187
  });
147
188
  }
148
189
  }
149
- get volume() {
150
- return this.#volume;
190
+ get enabled() { return this.#enabled; }
191
+ set enabled(v) {
192
+ this.#enabled = v;
193
+ writeBool('musicEnabled', v);
194
+ if (!v) {
195
+ // "Off" means: do not output music. Keep the state by pausing (resume later).
196
+ this.pause();
197
+ return;
198
+ }
199
+ // When turning on, play the last requested track if any; otherwise just resume
200
+ if (this.#pendingSrc) {
201
+ const src = this.#pendingSrc;
202
+ this.#pendingSrc = undefined;
203
+ this.play(src);
204
+ }
205
+ else {
206
+ this.#currentAudio?.play();
207
+ }
151
208
  }
209
+ enable() { this.enabled = true; }
210
+ disable() { this.enabled = false; }
211
+ toggle() { this.enabled = !this.enabled; }
212
+ get volume() { return this.#volume; }
152
213
  set volume(volume) {
153
- this.#volume = volume;
154
- safeStorage.setItem('musicVolume', volume.toString());
214
+ this.#volume = clamp01(volume);
215
+ safeStorage.setItem('musicVolume', this.#volume.toString());
155
216
  if (this.#currentAudio)
156
- this.#currentAudio.volume = volume;
217
+ this.#currentAudio.volume = this.#volume;
157
218
  }
158
219
  play(src) {
159
- if (this.#currentAudio?.src === src)
220
+ // Remember the user's intent even if music is currently disabled
221
+ this.#pendingSrc = src;
222
+ if (!this.#enabled)
160
223
  return;
224
+ // If it's the same track, resume instead of recreating the Audio
225
+ if (this.#currentAudio?.src === src) {
226
+ this.#currentAudio.play();
227
+ return;
228
+ }
161
229
  this.#currentAudio?.stop();
162
230
  this.#currentAudio = new Audio(src, this.#volume, true);
163
231
  }
@@ -165,27 +233,43 @@ class MusicPlayer {
165
233
  this.#currentAudio?.pause();
166
234
  }
167
235
  stop() {
236
+ // stop() is a hard reset (unlike pause)
168
237
  this.#currentAudio?.stop();
169
238
  this.#currentAudio = undefined;
239
+ this.#pendingSrc = undefined;
170
240
  }
171
241
  }
172
242
  class SfxPlayer {
173
243
  #volume = 1;
244
+ #enabled = true;
174
245
  constructor() {
175
- const stored = parseFloat(safeStorage.getItem('sfxVolume') || '');
176
- this.#volume = Number.isNaN(stored) ? this.#volume : stored;
246
+ const storedVol = parseFloat(safeStorage.getItem('sfxVolume') || '');
247
+ this.#volume = Number.isNaN(storedVol) ? this.#volume : clamp01(storedVol);
248
+ // Separate from volume: true/false SFX enable state
249
+ this.#enabled = readBool('sfxEnabled', true);
177
250
  }
178
- get volume() {
179
- return this.#volume;
251
+ get enabled() { return this.#enabled; }
252
+ set enabled(v) {
253
+ this.#enabled = v;
254
+ writeBool('sfxEnabled', v);
180
255
  }
256
+ enable() { this.enabled = true; }
257
+ disable() { this.enabled = false; }
258
+ toggle() { this.enabled = !this.enabled; }
259
+ get volume() { return this.#volume; }
181
260
  set volume(volume) {
182
- this.#volume = volume;
183
- safeStorage.setItem('sfxVolume', volume.toString());
261
+ this.#volume = clamp01(volume);
262
+ safeStorage.setItem('sfxVolume', this.#volume.toString());
184
263
  }
185
264
  play(src) {
265
+ // If disabled, do not play any one-shot sounds
266
+ if (!this.#enabled)
267
+ return;
186
268
  new Audio(src, this.#volume, false);
187
269
  }
188
270
  playRandom(...srcs) {
271
+ if (!this.#enabled)
272
+ return;
189
273
  this.play(srcs[Math.floor(Math.random() * srcs.length)]);
190
274
  }
191
275
  }
@@ -1 +1 @@
1
- {"version":3,"file":"audio.js","sourceRoot":"","sources":["../../src/asset/audio.ts"],"names":[],"mappings":"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","sourcesContent":["import { isMobile } from '../utils/device'\nimport { audioLoader } from './loaders/audio'\n\nexport const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()\nwindow.addEventListener('mousedown', () => audioContext.resume())\nwindow.addEventListener('touchend', () => audioContext.resume())\n\nasync function getAvailableContext(): Promise<AudioContext> {\n if (audioContext.state === 'suspended') await audioContext.resume()\n return audioContext\n}\n\nlet isPageVisible = !document.hidden\nwindow.addEventListener('visibilitychange', () => isPageVisible = !document.hidden)\n\ntype BasicStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>\n\ntype SafeStorage = BasicStorage & {\n persistent: boolean\n}\n\nfunction createSafeStorage(): SafeStorage {\n const memory = (() => {\n const m = new Map<string, string>()\n const api: SafeStorage = {\n persistent: false,\n getItem: (k) => (m.has(k) ? m.get(k)! : null),\n setItem: (k, v) => { m.set(k, String(v)) },\n removeItem: (k) => { m.delete(k) },\n }\n return api\n })()\n\n if (typeof window === 'undefined') return memory\n\n try {\n const ls = window.localStorage\n const probeKey = '__safe_ls_probe__' + Math.random().toString(36).slice(2)\n ls.setItem(probeKey, '1')\n ls.removeItem(probeKey)\n\n const safe: SafeStorage = {\n persistent: true,\n getItem: (k) => ls.getItem(k),\n setItem: (k, v) => ls.setItem(k, v),\n removeItem: (k) => ls.removeItem(k),\n }\n return safe\n } catch {\n return memory\n }\n}\n\nconst safeStorage = createSafeStorage()\n\nclass Audio {\n src: string\n #volume: number\n #loop: boolean\n\n #audioBuffer?: AudioBuffer\n #audioContext?: AudioContext\n #gainNode?: GainNode\n #source?: AudioBufferSourceNode\n\n #isPlaying = false\n #isPaused = false\n #startTime = 0\n #pauseTime = 0\n #offset = 0\n\n constructor(src: string, volume: number, loop: boolean) {\n this.src = src\n this.#volume = volume\n this.#loop = loop\n this.play()\n }\n\n get volume() { return this.#volume }\n set volume(volume: number) {\n this.#volume = volume\n if (this.#gainNode) this.#gainNode.gain.value = Math.max(0, Math.min(1, volume))\n }\n\n async play() {\n if (isMobile && !isPageVisible) return\n\n if (!this.#audioBuffer) {\n if (audioLoader.checkCached(this.src)) {\n this.#audioBuffer = audioLoader.getCached(this.src)\n } else {\n console.info(`Audio not preloaded. Loading now: ${this.src}`)\n this.#audioBuffer = await audioLoader.load(this.src)\n }\n }\n if (!this.#audioBuffer) return\n\n if (this.#isPlaying) this.stop()\n if (!this.#isPaused) this.#offset = 0\n this.#isPlaying = true\n this.#isPaused = false\n if (!this.#audioContext) this.#audioContext = await getAvailableContext()\n if (!this.#isPlaying) return\n\n if (!this.#gainNode) {\n this.#gainNode = this.#audioContext.createGain()\n this.#gainNode.gain.value = this.#volume\n this.#gainNode.connect(this.#audioContext.destination)\n }\n\n this.#source = this.#audioContext.createBufferSource()\n this.#source.buffer = this.#audioBuffer\n this.#source.loop = this.#loop\n this.#source.connect(this.#gainNode)\n this.#source.start(0, this.#offset)\n this.#startTime = this.#audioContext.currentTime\n this.#source.onended = () => { if (!this.#isPaused && !this.#loop) this.stop() }\n }\n\n #clear(): void {\n if (this.#source) {\n this.#source.stop()\n this.#source.disconnect()\n this.#source = undefined\n }\n }\n\n pause() {\n if (this.#isPlaying && !this.#isPaused) {\n if (this.#audioContext) {\n this.#pauseTime = this.#audioContext.currentTime\n this.#offset += this.#pauseTime - this.#startTime\n }\n this.#isPaused = true\n this.#isPlaying = false\n this.#clear()\n }\n }\n\n stop() {\n this.#isPlaying = false\n this.#isPaused = false\n this.#offset = 0\n this.#clear()\n }\n}\n\nclass MusicPlayer {\n #volume = 0.7\n #currentAudio?: Audio\n\n constructor() {\n const stored = parseFloat(safeStorage.getItem('musicVolume') || '')\n this.#volume = Number.isNaN(stored) ? this.#volume : stored\n\n if (isMobile) {\n document.addEventListener('visibilitychange', () => {\n if (document.hidden) this.pause()\n else {\n isPageVisible = true\n this.#currentAudio?.play()\n }\n })\n }\n }\n\n get volume() {\n return this.#volume\n }\n\n set volume(volume: number) {\n this.#volume = volume\n safeStorage.setItem('musicVolume', volume.toString())\n if (this.#currentAudio) this.#currentAudio.volume = volume\n }\n\n play(src: string) {\n if (this.#currentAudio?.src === src) return\n this.#currentAudio?.stop()\n this.#currentAudio = new Audio(src, this.#volume, true)\n }\n\n pause() {\n this.#currentAudio?.pause()\n }\n\n stop() {\n this.#currentAudio?.stop()\n this.#currentAudio = undefined\n }\n}\n\nclass SfxPlayer {\n #volume = 1\n\n constructor() {\n const stored = parseFloat(safeStorage.getItem('sfxVolume') || '')\n this.#volume = Number.isNaN(stored) ? this.#volume : stored\n }\n\n get volume() {\n return this.#volume\n }\n\n set volume(volume: number) {\n this.#volume = volume\n safeStorage.setItem('sfxVolume', volume.toString())\n }\n\n play(src: string) {\n new Audio(src, this.#volume, false)\n }\n\n playRandom(...srcs: string[]) {\n this.play(srcs[Math.floor(Math.random() * srcs.length)])\n }\n}\n\nexport const musicPlayer = new MusicPlayer()\nexport const sfxPlayer = new SfxPlayer()\n"]}
1
+ {"version":3,"file":"audio.js","sourceRoot":"","sources":["../../src/asset/audio.ts"],"names":[],"mappings":"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","sourcesContent":["import { isMobile } from '../utils/device'\nimport { audioLoader } from './loaders/audio'\n\nexport const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()\nwindow.addEventListener('mousedown', () => audioContext.resume())\nwindow.addEventListener('touchend', () => audioContext.resume())\n\nasync function getAvailableContext(): Promise<AudioContext> {\n if (audioContext.state === 'suspended') await audioContext.resume()\n return audioContext\n}\n\nlet isPageVisible = !document.hidden\nwindow.addEventListener('visibilitychange', () => {\n isPageVisible = !document.hidden\n})\n\ntype BasicStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>\n\ntype SafeStorage = BasicStorage & {\n persistent: boolean\n}\n\nfunction createSafeStorage(): SafeStorage {\n const memory = (() => {\n const m = new Map<string, string>()\n const api: SafeStorage = {\n persistent: false,\n getItem: (k) => (m.has(k) ? m.get(k)! : null),\n setItem: (k, v) => { m.set(k, String(v)) },\n removeItem: (k) => { m.delete(k) },\n }\n return api\n })()\n\n if (typeof window === 'undefined') return memory\n\n try {\n const ls = window.localStorage\n const probeKey = '__safe_ls_probe__' + Math.random().toString(36).slice(2)\n ls.setItem(probeKey, '1')\n ls.removeItem(probeKey)\n\n const safe: SafeStorage = {\n persistent: true,\n getItem: (k) => ls.getItem(k),\n setItem: (k, v) => ls.setItem(k, v),\n removeItem: (k) => ls.removeItem(k),\n }\n return safe\n } catch {\n return memory\n }\n}\n\nconst safeStorage = createSafeStorage()\n\nfunction clamp01(n: number) {\n return Math.max(0, Math.min(1, n))\n}\n\nfunction readBool(key: string, defaultValue: boolean) {\n const v = safeStorage.getItem(key)\n if (v == null) return defaultValue\n return v === '1' || v === 'true'\n}\n\nfunction writeBool(key: string, value: boolean) {\n safeStorage.setItem(key, value ? '1' : '0')\n}\n\nexport class Audio {\n src: string\n #volume: number\n #loop: boolean\n\n #audioBuffer?: AudioBuffer\n #audioContext?: AudioContext\n #gainNode?: GainNode\n #source?: AudioBufferSourceNode\n\n #isPlaying = false\n #isPaused = false\n #startTime = 0\n #pauseTime = 0\n #offset = 0\n\n constructor(src: string, volume: number, loop: boolean) {\n this.src = src\n this.#volume = clamp01(volume)\n this.#loop = loop\n this.play()\n }\n\n get volume() { return this.#volume }\n set volume(volume: number) {\n this.#volume = clamp01(volume)\n if (this.#gainNode) this.#gainNode.gain.value = this.#volume\n }\n\n async play() {\n // On mobile, avoid starting audio while the page is hidden (often blocked / unreliable)\n if (isMobile && !isPageVisible) return\n\n if (!this.#audioBuffer) {\n if (audioLoader.checkCached(this.src)) {\n this.#audioBuffer = audioLoader.getCached(this.src)\n } else {\n console.info(`Audio not preloaded. Loading now: ${this.src}`)\n this.#audioBuffer = await audioLoader.load(this.src)\n }\n }\n if (!this.#audioBuffer) return\n\n // If already playing, restart cleanly\n if (this.#isPlaying) this.stop()\n\n // If this is not a resume, reset offset to the beginning\n if (!this.#isPaused) this.#offset = 0\n\n this.#isPlaying = true\n this.#isPaused = false\n\n if (!this.#audioContext) this.#audioContext = await getAvailableContext()\n\n // If state changed while awaiting, bail out\n if (!this.#isPlaying) return\n\n if (!this.#gainNode) {\n this.#gainNode = this.#audioContext.createGain()\n this.#gainNode.gain.value = this.#volume\n this.#gainNode.connect(this.#audioContext.destination)\n } else {\n this.#gainNode.gain.value = this.#volume\n }\n\n this.#source = this.#audioContext.createBufferSource()\n this.#source.buffer = this.#audioBuffer\n this.#source.loop = this.#loop\n this.#source.connect(this.#gainNode)\n this.#source.start(0, this.#offset)\n this.#startTime = this.#audioContext.currentTime\n\n this.#source.onended = () => {\n // Only auto-stop for one-shot sounds that were not paused and are not looping\n if (!this.#isPaused && !this.#loop) this.stop()\n }\n }\n\n #clear(): void {\n if (this.#source) {\n // stop() can throw if already stopped; ignore safely\n try { this.#source.stop() } catch { /* noop */ }\n try { this.#source.disconnect() } catch { /* noop */ }\n this.#source = undefined\n }\n }\n\n pause() {\n if (this.#isPlaying && !this.#isPaused) {\n if (this.#audioContext) {\n // Track elapsed time so we can resume from the correct offset\n this.#pauseTime = this.#audioContext.currentTime\n this.#offset += this.#pauseTime - this.#startTime\n }\n this.#isPaused = true\n this.#isPlaying = false\n this.#clear()\n }\n }\n\n stop() {\n // Full stop resets the offset; next play starts from the beginning\n this.#isPlaying = false\n this.#isPaused = false\n this.#offset = 0\n this.#clear()\n }\n}\n\nclass MusicPlayer {\n #volume = 0.7\n #enabled = true\n #currentAudio?: Audio\n #pendingSrc?: string\n\n constructor() {\n const storedVol = parseFloat(safeStorage.getItem('musicVolume') || '')\n this.#volume = Number.isNaN(storedVol) ? this.#volume : clamp01(storedVol)\n\n // Separate from volume: true/false music enable state\n this.#enabled = readBool('musicEnabled', true)\n\n if (isMobile) {\n document.addEventListener('visibilitychange', () => {\n if (document.hidden) {\n // When hidden, pause to avoid mobile audio issues\n this.pause()\n } else {\n isPageVisible = true\n // Only resume if enabled\n if (this.#enabled) this.#currentAudio?.play()\n }\n })\n }\n }\n\n get enabled() { return this.#enabled }\n set enabled(v: boolean) {\n this.#enabled = v\n writeBool('musicEnabled', v)\n\n if (!v) {\n // \"Off\" means: do not output music. Keep the state by pausing (resume later).\n this.pause()\n return\n }\n\n // When turning on, play the last requested track if any; otherwise just resume\n if (this.#pendingSrc) {\n const src = this.#pendingSrc\n this.#pendingSrc = undefined\n this.play(src)\n } else {\n this.#currentAudio?.play()\n }\n }\n\n enable() { this.enabled = true }\n disable() { this.enabled = false }\n toggle() { this.enabled = !this.enabled }\n\n get volume() { return this.#volume }\n set volume(volume: number) {\n this.#volume = clamp01(volume)\n safeStorage.setItem('musicVolume', this.#volume.toString())\n if (this.#currentAudio) this.#currentAudio.volume = this.#volume\n }\n\n play(src: string) {\n // Remember the user's intent even if music is currently disabled\n this.#pendingSrc = src\n if (!this.#enabled) return\n\n // If it's the same track, resume instead of recreating the Audio\n if (this.#currentAudio?.src === src) {\n this.#currentAudio.play()\n return\n }\n\n this.#currentAudio?.stop()\n this.#currentAudio = new Audio(src, this.#volume, true)\n }\n\n pause() {\n this.#currentAudio?.pause()\n }\n\n stop() {\n // stop() is a hard reset (unlike pause)\n this.#currentAudio?.stop()\n this.#currentAudio = undefined\n this.#pendingSrc = undefined\n }\n}\n\nclass SfxPlayer {\n #volume = 1\n #enabled = true\n\n constructor() {\n const storedVol = parseFloat(safeStorage.getItem('sfxVolume') || '')\n this.#volume = Number.isNaN(storedVol) ? this.#volume : clamp01(storedVol)\n\n // Separate from volume: true/false SFX enable state\n this.#enabled = readBool('sfxEnabled', true)\n }\n\n get enabled() { return this.#enabled }\n set enabled(v: boolean) {\n this.#enabled = v\n writeBool('sfxEnabled', v)\n }\n\n enable() { this.enabled = true }\n disable() { this.enabled = false }\n toggle() { this.enabled = !this.enabled }\n\n get volume() { return this.#volume }\n set volume(volume: number) {\n this.#volume = clamp01(volume)\n safeStorage.setItem('sfxVolume', this.#volume.toString())\n }\n\n play(src: string) {\n // If disabled, do not play any one-shot sounds\n if (!this.#enabled) return\n new Audio(src, this.#volume, false)\n }\n\n playRandom(...srcs: string[]) {\n if (!this.#enabled) return\n this.play(srcs[Math.floor(Math.random() * srcs.length)])\n }\n}\n\nexport const musicPlayer = new MusicPlayer()\nexport const sfxPlayer = new SfxPlayer()\n"]}
@@ -1,7 +1,22 @@
1
1
  export declare const audioContext: AudioContext;
2
+ export declare class Audio {
3
+ #private;
4
+ src: string;
5
+ constructor(src: string, volume: number, loop: boolean);
6
+ get volume(): number;
7
+ set volume(volume: number);
8
+ play(): Promise<void>;
9
+ pause(): void;
10
+ stop(): void;
11
+ }
2
12
  declare class MusicPlayer {
3
13
  #private;
4
14
  constructor();
15
+ get enabled(): boolean;
16
+ set enabled(v: boolean);
17
+ enable(): void;
18
+ disable(): void;
19
+ toggle(): void;
5
20
  get volume(): number;
6
21
  set volume(volume: number);
7
22
  play(src: string): void;
@@ -11,6 +26,11 @@ declare class MusicPlayer {
11
26
  declare class SfxPlayer {
12
27
  #private;
13
28
  constructor();
29
+ get enabled(): boolean;
30
+ set enabled(v: boolean);
31
+ enable(): void;
32
+ disable(): void;
33
+ toggle(): void;
14
34
  get volume(): number;
15
35
  set volume(volume: number);
16
36
  play(src: string): void;
@@ -1 +1 @@
1
- {"version":3,"file":"audio.d.ts","sourceRoot":"","sources":["../../../src/asset/audio.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,YAAY,cAAoE,CAAA;AAgJ7F,cAAM,WAAW;;;IAmBf,IAAI,MAAM,IAIS,MAAM,CAFxB;IAED,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,EAIxB;IAED,IAAI,CAAC,GAAG,EAAE,MAAM;IAMhB,KAAK;IAIL,IAAI;CAIL;AAED,cAAM,SAAS;;;IAQb,IAAI,MAAM,IAIS,MAAM,CAFxB;IAED,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,EAGxB;IAED,IAAI,CAAC,GAAG,EAAE,MAAM;IAIhB,UAAU,CAAC,GAAG,IAAI,EAAE,MAAM,EAAE;CAG7B;AAED,eAAO,MAAM,WAAW,aAAoB,CAAA;AAC5C,eAAO,MAAM,SAAS,WAAkB,CAAA"}
1
+ {"version":3,"file":"audio.d.ts","sourceRoot":"","sources":["../../../src/asset/audio.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,YAAY,cAAoE,CAAA;AAoE7F,qBAAa,KAAK;;IAChB,GAAG,EAAE,MAAM,CAAA;gBAeC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO;IAOtD,IAAI,MAAM,IACS,MAAM,CADW;IACpC,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,EAGxB;IAEK,IAAI;IA0DV,KAAK;IAaL,IAAI;CAOL;AAED,cAAM,WAAW;;;IA2Bf,IAAI,OAAO,IACI,OAAO,CADgB;IACtC,IAAI,OAAO,CAAC,CAAC,EAAE,OAAO,EAkBrB;IAED,MAAM;IACN,OAAO;IACP,MAAM;IAEN,IAAI,MAAM,IACS,MAAM,CADW;IACpC,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,EAIxB;IAED,IAAI,CAAC,GAAG,EAAE,MAAM;IAehB,KAAK;IAIL,IAAI;CAML;AAED,cAAM,SAAS;;;IAYb,IAAI,OAAO,IACI,OAAO,CADgB;IACtC,IAAI,OAAO,CAAC,CAAC,EAAE,OAAO,EAGrB;IAED,MAAM;IACN,OAAO;IACP,MAAM;IAEN,IAAI,MAAM,IACS,MAAM,CADW;IACpC,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,EAGxB;IAED,IAAI,CAAC,GAAG,EAAE,MAAM;IAMhB,UAAU,CAAC,GAAG,IAAI,EAAE,MAAM,EAAE;CAI7B;AAED,eAAO,MAAM,WAAW,aAAoB,CAAA;AAC5C,eAAO,MAAM,SAAS,WAAkB,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kiwiengine",
3
- "version": "0.5.15",
3
+ "version": "0.5.17",
4
4
  "types": "./lib/types/index.d.ts",
5
5
  "main": "./lib/index.js",
6
6
  "dependencies": {
@@ -11,7 +11,9 @@ async function getAvailableContext(): Promise<AudioContext> {
11
11
  }
12
12
 
13
13
  let isPageVisible = !document.hidden
14
- window.addEventListener('visibilitychange', () => isPageVisible = !document.hidden)
14
+ window.addEventListener('visibilitychange', () => {
15
+ isPageVisible = !document.hidden
16
+ })
15
17
 
16
18
  type BasicStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
17
19
 
@@ -53,7 +55,21 @@ function createSafeStorage(): SafeStorage {
53
55
 
54
56
  const safeStorage = createSafeStorage()
55
57
 
56
- class Audio {
58
+ function clamp01(n: number) {
59
+ return Math.max(0, Math.min(1, n))
60
+ }
61
+
62
+ function readBool(key: string, defaultValue: boolean) {
63
+ const v = safeStorage.getItem(key)
64
+ if (v == null) return defaultValue
65
+ return v === '1' || v === 'true'
66
+ }
67
+
68
+ function writeBool(key: string, value: boolean) {
69
+ safeStorage.setItem(key, value ? '1' : '0')
70
+ }
71
+
72
+ export class Audio {
57
73
  src: string
58
74
  #volume: number
59
75
  #loop: boolean
@@ -71,18 +87,19 @@ class Audio {
71
87
 
72
88
  constructor(src: string, volume: number, loop: boolean) {
73
89
  this.src = src
74
- this.#volume = volume
90
+ this.#volume = clamp01(volume)
75
91
  this.#loop = loop
76
92
  this.play()
77
93
  }
78
94
 
79
95
  get volume() { return this.#volume }
80
96
  set volume(volume: number) {
81
- this.#volume = volume
82
- if (this.#gainNode) this.#gainNode.gain.value = Math.max(0, Math.min(1, volume))
97
+ this.#volume = clamp01(volume)
98
+ if (this.#gainNode) this.#gainNode.gain.value = this.#volume
83
99
  }
84
100
 
85
101
  async play() {
102
+ // On mobile, avoid starting audio while the page is hidden (often blocked / unreliable)
86
103
  if (isMobile && !isPageVisible) return
87
104
 
88
105
  if (!this.#audioBuffer) {
@@ -95,17 +112,26 @@ class Audio {
95
112
  }
96
113
  if (!this.#audioBuffer) return
97
114
 
115
+ // If already playing, restart cleanly
98
116
  if (this.#isPlaying) this.stop()
117
+
118
+ // If this is not a resume, reset offset to the beginning
99
119
  if (!this.#isPaused) this.#offset = 0
120
+
100
121
  this.#isPlaying = true
101
122
  this.#isPaused = false
123
+
102
124
  if (!this.#audioContext) this.#audioContext = await getAvailableContext()
125
+
126
+ // If state changed while awaiting, bail out
103
127
  if (!this.#isPlaying) return
104
128
 
105
129
  if (!this.#gainNode) {
106
130
  this.#gainNode = this.#audioContext.createGain()
107
131
  this.#gainNode.gain.value = this.#volume
108
132
  this.#gainNode.connect(this.#audioContext.destination)
133
+ } else {
134
+ this.#gainNode.gain.value = this.#volume
109
135
  }
110
136
 
111
137
  this.#source = this.#audioContext.createBufferSource()
@@ -114,13 +140,18 @@ class Audio {
114
140
  this.#source.connect(this.#gainNode)
115
141
  this.#source.start(0, this.#offset)
116
142
  this.#startTime = this.#audioContext.currentTime
117
- this.#source.onended = () => { if (!this.#isPaused && !this.#loop) this.stop() }
143
+
144
+ this.#source.onended = () => {
145
+ // Only auto-stop for one-shot sounds that were not paused and are not looping
146
+ if (!this.#isPaused && !this.#loop) this.stop()
147
+ }
118
148
  }
119
149
 
120
150
  #clear(): void {
121
151
  if (this.#source) {
122
- this.#source.stop()
123
- this.#source.disconnect()
152
+ // stop() can throw if already stopped; ignore safely
153
+ try { this.#source.stop() } catch { /* noop */ }
154
+ try { this.#source.disconnect() } catch { /* noop */ }
124
155
  this.#source = undefined
125
156
  }
126
157
  }
@@ -128,6 +159,7 @@ class Audio {
128
159
  pause() {
129
160
  if (this.#isPlaying && !this.#isPaused) {
130
161
  if (this.#audioContext) {
162
+ // Track elapsed time so we can resume from the correct offset
131
163
  this.#pauseTime = this.#audioContext.currentTime
132
164
  this.#offset += this.#pauseTime - this.#startTime
133
165
  }
@@ -138,6 +170,7 @@ class Audio {
138
170
  }
139
171
 
140
172
  stop() {
173
+ // Full stop resets the offset; next play starts from the beginning
141
174
  this.#isPlaying = false
142
175
  this.#isPaused = false
143
176
  this.#offset = 0
@@ -147,35 +180,74 @@ class Audio {
147
180
 
148
181
  class MusicPlayer {
149
182
  #volume = 0.7
183
+ #enabled = true
150
184
  #currentAudio?: Audio
185
+ #pendingSrc?: string
151
186
 
152
187
  constructor() {
153
- const stored = parseFloat(safeStorage.getItem('musicVolume') || '')
154
- this.#volume = Number.isNaN(stored) ? this.#volume : stored
188
+ const storedVol = parseFloat(safeStorage.getItem('musicVolume') || '')
189
+ this.#volume = Number.isNaN(storedVol) ? this.#volume : clamp01(storedVol)
190
+
191
+ // Separate from volume: true/false music enable state
192
+ this.#enabled = readBool('musicEnabled', true)
155
193
 
156
194
  if (isMobile) {
157
195
  document.addEventListener('visibilitychange', () => {
158
- if (document.hidden) this.pause()
159
- else {
196
+ if (document.hidden) {
197
+ // When hidden, pause to avoid mobile audio issues
198
+ this.pause()
199
+ } else {
160
200
  isPageVisible = true
161
- this.#currentAudio?.play()
201
+ // Only resume if enabled
202
+ if (this.#enabled) this.#currentAudio?.play()
162
203
  }
163
204
  })
164
205
  }
165
206
  }
166
207
 
167
- get volume() {
168
- return this.#volume
208
+ get enabled() { return this.#enabled }
209
+ set enabled(v: boolean) {
210
+ this.#enabled = v
211
+ writeBool('musicEnabled', v)
212
+
213
+ if (!v) {
214
+ // "Off" means: do not output music. Keep the state by pausing (resume later).
215
+ this.pause()
216
+ return
217
+ }
218
+
219
+ // When turning on, play the last requested track if any; otherwise just resume
220
+ if (this.#pendingSrc) {
221
+ const src = this.#pendingSrc
222
+ this.#pendingSrc = undefined
223
+ this.play(src)
224
+ } else {
225
+ this.#currentAudio?.play()
226
+ }
169
227
  }
170
228
 
229
+ enable() { this.enabled = true }
230
+ disable() { this.enabled = false }
231
+ toggle() { this.enabled = !this.enabled }
232
+
233
+ get volume() { return this.#volume }
171
234
  set volume(volume: number) {
172
- this.#volume = volume
173
- safeStorage.setItem('musicVolume', volume.toString())
174
- if (this.#currentAudio) this.#currentAudio.volume = volume
235
+ this.#volume = clamp01(volume)
236
+ safeStorage.setItem('musicVolume', this.#volume.toString())
237
+ if (this.#currentAudio) this.#currentAudio.volume = this.#volume
175
238
  }
176
239
 
177
240
  play(src: string) {
178
- if (this.#currentAudio?.src === src) return
241
+ // Remember the user's intent even if music is currently disabled
242
+ this.#pendingSrc = src
243
+ if (!this.#enabled) return
244
+
245
+ // If it's the same track, resume instead of recreating the Audio
246
+ if (this.#currentAudio?.src === src) {
247
+ this.#currentAudio.play()
248
+ return
249
+ }
250
+
179
251
  this.#currentAudio?.stop()
180
252
  this.#currentAudio = new Audio(src, this.#volume, true)
181
253
  }
@@ -185,33 +257,49 @@ class MusicPlayer {
185
257
  }
186
258
 
187
259
  stop() {
260
+ // stop() is a hard reset (unlike pause)
188
261
  this.#currentAudio?.stop()
189
262
  this.#currentAudio = undefined
263
+ this.#pendingSrc = undefined
190
264
  }
191
265
  }
192
266
 
193
267
  class SfxPlayer {
194
268
  #volume = 1
269
+ #enabled = true
195
270
 
196
271
  constructor() {
197
- const stored = parseFloat(safeStorage.getItem('sfxVolume') || '')
198
- this.#volume = Number.isNaN(stored) ? this.#volume : stored
272
+ const storedVol = parseFloat(safeStorage.getItem('sfxVolume') || '')
273
+ this.#volume = Number.isNaN(storedVol) ? this.#volume : clamp01(storedVol)
274
+
275
+ // Separate from volume: true/false SFX enable state
276
+ this.#enabled = readBool('sfxEnabled', true)
199
277
  }
200
278
 
201
- get volume() {
202
- return this.#volume
279
+ get enabled() { return this.#enabled }
280
+ set enabled(v: boolean) {
281
+ this.#enabled = v
282
+ writeBool('sfxEnabled', v)
203
283
  }
204
284
 
285
+ enable() { this.enabled = true }
286
+ disable() { this.enabled = false }
287
+ toggle() { this.enabled = !this.enabled }
288
+
289
+ get volume() { return this.#volume }
205
290
  set volume(volume: number) {
206
- this.#volume = volume
207
- safeStorage.setItem('sfxVolume', volume.toString())
291
+ this.#volume = clamp01(volume)
292
+ safeStorage.setItem('sfxVolume', this.#volume.toString())
208
293
  }
209
294
 
210
295
  play(src: string) {
296
+ // If disabled, do not play any one-shot sounds
297
+ if (!this.#enabled) return
211
298
  new Audio(src, this.#volume, false)
212
299
  }
213
300
 
214
301
  playRandom(...srcs: string[]) {
302
+ if (!this.#enabled) return
215
303
  this.play(srcs[Math.floor(Math.random() * srcs.length)])
216
304
  }
217
305
  }