dopaminejs 2.2.0 → 2.3.1

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/dist/style.css CHANGED
@@ -1 +1 @@
1
- #game-ui-overlay,.game-ui-overlay{position:fixed;top:0;left:0;width:100%;pointer-events:none;z-index:1000;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}.game-ui-top-bar{display:flex;align-items:center;gap:12px;padding:12px 16px;background:#0000004d;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}.level-badge{display:flex;flex-direction:column;align-items:center;background:linear-gradient(135deg,#667eea,#764ba2);padding:8px 12px;border-radius:12px;box-shadow:0 4px 12px #667eea66;min-width:60px}.level-label{font-size:10px;font-weight:600;color:#fffc;letter-spacing:.5px}.level-number{font-size:24px;font-weight:800;color:#fff;line-height:1}.xp-container{flex:1;display:flex;flex-direction:column;gap:4px}.xp-bar-bg{height:12px;background:#fff3;border-radius:6px;overflow:hidden;box-shadow:inset 0 2px 4px #0003}.xp-bar-fill{height:100%;background:linear-gradient(90deg,#4facfe,#00f2fe);border-radius:6px;transition:width .5s ease-out;box-shadow:0 0 10px #4facfe99}.xp-text{font-size:11px;color:#fff;font-weight:600;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.5)}.streak-badge{display:flex;align-items:center;gap:6px;background:linear-gradient(135deg,#f80,#f40);padding:8px 12px;border-radius:12px;box-shadow:0 4px 12px #f806}.streak-icon{font-size:20px;animation:flicker 2s infinite}.streak-number{font-size:18px;font-weight:800;color:#fff}.achievement-popup{position:fixed;top:80px;right:20px;background:linear-gradient(135deg,#667eea,#764ba2);border-radius:16px;padding:16px;box-shadow:0 8px 32px #0006;animation:slideIn .5s cubic-bezier(.68,-.55,.265,1.55);z-index:10000;max-width:320px;overflow:hidden}.achievement-shine{position:absolute;top:0;left:-100%;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.3),transparent);animation:shine 2s infinite}.achievement-content{position:relative;display:flex;gap:12px;align-items:center}.achievement-icon{font-size:48px;animation:bounce .6s ease-out}.achievement-info{flex:1}.achievement-title{font-size:12px;font-weight:600;color:#ffffffe6;text-transform:uppercase;letter-spacing:.5px}.achievement-name{font-size:18px;font-weight:800;color:#fff;margin:2px 0}.achievement-desc{font-size:13px;color:#fffc;margin-bottom:4px}.achievement-xp{display:inline-block;background:#fff3;padding:4px 8px;border-radius:8px;font-size:12px;font-weight:700;color:gold}.level-up-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:#000000d9;display:flex;align-items:center;justify-content:center;z-index:10001;animation:fadeIn .3s ease-out}.level-up-content{text-align:center;animation:scaleIn .5s cubic-bezier(.68,-.55,.265,1.55)}.level-up-title{font-size:32px;font-weight:800;background:linear-gradient(135deg,gold,#ffed4e);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;text-transform:uppercase;letter-spacing:4px;margin-bottom:16px}.level-up-number{font-size:120px;font-weight:900;color:#fff;text-shadow:0 0 20px rgba(255,215,0,.8),0 0 40px rgba(255,215,0,.6),0 0 60px rgba(255,215,0,.4);animation:glow 1s ease-in-out infinite alternate}.level-up-subtitle{font-size:24px;color:#fffc;font-weight:600;margin-top:16px}.floating-text{position:fixed;font-weight:800;text-shadow:2px 2px 0 #000,-1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000;pointer-events:none;z-index:9998;animation:floatUp 2s ease-out forwards}@keyframes slideIn{0%{transform:translate(400px);opacity:0}to{transform:translate(0);opacity:1}}@keyframes slideOut{0%{transform:translate(0);opacity:1}to{transform:translate(400px);opacity:0}}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes scaleIn{0%{transform:scale(.5);opacity:0}to{transform:scale(1);opacity:1}}@keyframes bounce{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes glow{0%{text-shadow:0 0 20px rgba(255,215,0,.8),0 0 40px rgba(255,215,0,.6),0 0 60px rgba(255,215,0,.4)}to{text-shadow:0 0 30px rgba(255,215,0,1),0 0 60px rgba(255,215,0,.8),0 0 90px rgba(255,215,0,.6)}}@keyframes shine{0%{left:-100%}30%{left:100%}to{left:100%}}@keyframes flicker{0%,to{opacity:1}50%{opacity:.8}}@keyframes floatUp{0%{transform:translateY(0);opacity:1}to{transform:translateY(-100px);opacity:0}}@keyframes xp-pulse{0%{transform:scaleY(1)}50%{transform:scaleY(1.2)}to{transform:scaleY(1)}}@keyframes level-pulse{0%{transform:scale(1)}50%{transform:scale(1.1)}to{transform:scale(1)}}@media (max-width: 480px){.game-ui-top-bar{gap:8px;padding:10px 12px}.level-badge{padding:6px 10px;min-width:50px}.level-number{font-size:20px}.xp-bar-bg{height:10px}.xp-text{font-size:10px}.achievement-popup{right:12px;max-width:calc(100% - 24px)}.level-up-number{font-size:80px}}
1
+ #game-ui-overlay,.game-ui-overlay{position:fixed;top:0;left:0;width:100%;pointer-events:none;z-index:1000;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}.game-ui-top-bar{display:flex;align-items:center;gap:12px;padding:12px 16px;background:#0000004d;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}.level-badge{display:flex;flex-direction:column;align-items:center;background:linear-gradient(135deg,#667eea,#764ba2);padding:8px 12px;border-radius:12px;box-shadow:0 4px 12px #667eea66;min-width:60px}.level-label{font-size:10px;font-weight:600;color:#fffc;letter-spacing:.5px}.level-number{font-size:24px;font-weight:800;color:#fff;line-height:1}.xp-container{flex:1;display:flex;flex-direction:column;gap:4px}.xp-bar-bg{height:12px;background:#fff3;border-radius:6px;overflow:hidden;box-shadow:inset 0 2px 4px #0003}.xp-bar-fill{height:100%;background:linear-gradient(90deg,#4facfe,#00f2fe);border-radius:6px;transition:width .5s ease-out;box-shadow:0 0 10px #4facfe99}.xp-text{font-size:11px;color:#fff;font-weight:600;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.5)}.streak-badge{display:flex;align-items:center;gap:6px;background:linear-gradient(135deg,#f80,#f40);padding:8px 12px;border-radius:12px;box-shadow:0 4px 12px #f806}.streak-icon{font-size:20px;animation:flicker 2s infinite}.streak-number{font-size:18px;font-weight:800;color:#fff}.achievement-popup{position:fixed;top:80px;right:20px;background:linear-gradient(135deg,#667eea,#764ba2);border-radius:16px;padding:16px;box-shadow:0 8px 32px #0006;animation:slideIn .5s cubic-bezier(.68,-.55,.265,1.55);z-index:10000;max-width:320px;overflow:hidden}.achievement-shine{position:absolute;top:0;left:-100%;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.3),transparent);animation:shine 2s infinite}.achievement-content{position:relative;display:flex;gap:12px;align-items:center}.achievement-icon{font-size:48px;animation:bounce .6s ease-out}.achievement-info{flex:1}.achievement-title{font-size:12px;font-weight:600;color:#ffffffe6;text-transform:uppercase;letter-spacing:.5px}.achievement-name{font-size:18px;font-weight:800;color:#fff;margin:2px 0}.achievement-desc{font-size:13px;color:#fffc;margin-bottom:4px}.achievement-xp{display:inline-block;background:#fff3;padding:4px 8px;border-radius:8px;font-size:12px;font-weight:700;color:gold}.level-up-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:#000000d9;display:flex;align-items:center;justify-content:center;z-index:10001;animation:fadeIn .3s ease-out}.level-up-content{text-align:center;animation:scaleIn .5s cubic-bezier(.68,-.55,.265,1.55)}.level-up-title{font-size:32px;font-weight:800;background:linear-gradient(135deg,gold,#ffed4e);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;text-transform:uppercase;letter-spacing:4px;margin-bottom:16px}.level-up-number{font-size:120px;font-weight:900;color:#fff;text-shadow:0 0 20px rgba(255,215,0,.8),0 0 40px rgba(255,215,0,.6),0 0 60px rgba(255,215,0,.4);animation:glow 1s ease-in-out infinite alternate}.level-up-subtitle{font-size:24px;color:#fffc;font-weight:600;margin-top:16px}.floating-text{position:fixed;font-weight:800;text-shadow:2px 2px 0 #000,-1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000;pointer-events:none;z-index:9998;animation:floatUp 2s ease-out forwards}@keyframes slideIn{0%{transform:translate(400px);opacity:0}to{transform:translate(0);opacity:1}}@keyframes slideOut{0%{transform:translate(0);opacity:1}to{transform:translate(400px);opacity:0}}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes scaleIn{0%{transform:scale(.5);opacity:0}to{transform:scale(1);opacity:1}}@keyframes bounce{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes glow{0%{text-shadow:0 0 20px rgba(255,215,0,.8),0 0 40px rgba(255,215,0,.6),0 0 60px rgba(255,215,0,.4)}to{text-shadow:0 0 30px rgba(255,215,0,1),0 0 60px rgba(255,215,0,.8),0 0 90px rgba(255,215,0,.6)}}@keyframes shine{0%{left:-100%}30%{left:100%}to{left:100%}}@keyframes flicker{0%,to{opacity:1}50%{opacity:.8}}@keyframes floatUp{0%{transform:translateY(0);opacity:1}to{transform:translateY(-100px);opacity:0}}@keyframes xp-pulse{0%{transform:scaleY(1)}50%{transform:scaleY(1.2)}to{transform:scaleY(1)}}@keyframes level-pulse{0%{transform:scale(1)}50%{transform:scale(1.1)}to{transform:scale(1)}}@media(max-width:480px){.game-ui-top-bar{gap:8px;padding:10px 12px}.level-badge{padding:6px 10px;min-width:50px}.level-number{font-size:20px}.xp-bar-bg{height:10px}.xp-text{font-size:10px}.achievement-popup{right:12px;max-width:calc(100% - 24px)}.level-up-number{font-size:80px}}@media(prefers-reduced-motion:reduce){.game-ui-overlay *,.achievement-popup,.achievement-popup *,.level-up-overlay,.level-up-overlay *{animation:none!important;transition:none!important}.floating-text{animation:none!important}}
package/package.json CHANGED
@@ -1,18 +1,24 @@
1
1
  {
2
2
  "name": "dopaminejs",
3
- "version": "2.2.0",
4
- "description": "Lightweight Game Feel Engine - Add juice, rewards, and feedback to HTML5 games",
3
+ "version": "2.3.1",
4
+ "description": "Progression mechanics for web apps: XP, levels, achievements and timezone-correct daily streaks, with the particles and sound to make them land. Zero dependencies.",
5
5
  "type": "module",
6
- "main": "./dist/dopamine.umd.cjs",
6
+ "main": "./dist/dopamine.cjs",
7
7
  "module": "./dist/dopamine.js",
8
8
  "exports": {
9
9
  ".": {
10
10
  "types": "./types/index.d.ts",
11
11
  "import": "./dist/dopamine.js",
12
- "require": "./dist/dopamine.umd.cjs"
12
+ "require": "./dist/dopamine.cjs"
13
+ },
14
+ "./engine": {
15
+ "types": "./types/index.d.ts",
16
+ "import": "./dist/engine.js",
17
+ "require": "./dist/engine.cjs"
13
18
  },
14
19
  "./style.css": "./dist/style.css"
15
20
  },
21
+ "sideEffects": false,
16
22
  "files": [
17
23
  "dist",
18
24
  "src",
@@ -21,20 +27,25 @@
21
27
  "scripts": {
22
28
  "dev": "vite",
23
29
  "build": "vite build",
24
- "test": "vitest",
30
+ "test": "vitest run",
31
+ "test:watch": "vitest",
25
32
  "preview": "vite preview"
26
33
  },
27
34
  "keywords": [
28
- "game",
29
- "gamedev",
35
+ "gamification",
36
+ "progression",
37
+ "streaks",
38
+ "daily-streak",
39
+ "xp",
40
+ "levels",
41
+ "achievements",
42
+ "engagement",
43
+ "rewards",
44
+ "user-retention",
30
45
  "game-feel",
31
46
  "juice",
32
- "feedback",
33
- "rewards",
34
47
  "particles",
35
- "sound",
36
- "xp",
37
- "achievements"
48
+ "confetti"
38
49
  ],
39
50
  "author": "BaryoDev",
40
51
  "license": "MPL-2.0",
@@ -47,5 +58,8 @@
47
58
  "url": "https://github.com/BaryoDev/dopaminejs/issues"
48
59
  },
49
60
  "homepage": "https://github.com/BaryoDev/dopaminejs#readme",
50
- "types": "./types/index.d.ts"
61
+ "types": "./types/index.d.ts",
62
+ "engines": {
63
+ "node": ">=20.19"
64
+ }
51
65
  }
@@ -14,19 +14,55 @@ export class Collider extends Component {
14
14
  this.tag = null; // For collision filtering
15
15
  }
16
16
 
17
+ get kernel() {
18
+ return this._kernel ?? null;
19
+ }
20
+
21
+ /**
22
+ * The kernel can arrive after onAttach(), when the collider was added to
23
+ * an object before that object joined a scene. Registering only in
24
+ * onAttach() left such colliders out of physics for good.
25
+ */
26
+ set kernel(value) {
27
+ if (value === this._kernel) return;
28
+
29
+ this._unregister();
30
+ this._kernel = value;
31
+ this._register();
32
+ }
33
+
17
34
  onAttach() {
18
35
  super.onAttach();
19
- // Register with physics system through kernel
20
- if (this.physics) {
21
- this.physics.add(this);
22
- }
36
+ this._attached = true;
37
+ this._register();
23
38
  }
24
39
 
25
40
  onDetach() {
26
41
  super.onDetach();
27
- // Unregister from physics
28
- if (this.physics) {
29
- this.physics.remove(this);
42
+ this._attached = false;
43
+ this._unregister();
44
+ }
45
+
46
+ /**
47
+ * @private
48
+ */
49
+ _register() {
50
+ if (this._physics || !this._attached) return;
51
+
52
+ const physics = this.physics;
53
+ if (physics) {
54
+ physics.add(this);
55
+ this._physics = physics;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * @private
61
+ */
62
+ _unregister() {
63
+ if (this._physics) {
64
+ this._physics.remove(this);
65
+ this._physics = null;
30
66
  }
31
67
  }
32
68
 
@@ -16,6 +16,62 @@ export class PluginRegistry {
16
16
 
17
17
  // Track plugin load order for proper cleanup
18
18
  this._loadOrder = [];
19
+
20
+ // Async inits in flight. Each notes the systems other plugins added
21
+ // meanwhile, so a failed one removes only its own.
22
+ this._inFlight = new Set();
23
+ }
24
+
25
+ /**
26
+ * @private
27
+ */
28
+ _systemsSince(before) {
29
+ return this.kernel.systems.getSystemNames().filter((name) => !before.has(name));
30
+ }
31
+
32
+ /**
33
+ * Mark systems as belonging to a plugin that loaded.
34
+ * @private
35
+ */
36
+ _claim(names) {
37
+ for (const pending of this._inFlight) {
38
+ for (const name of names) {
39
+ pending.foreign.add(name);
40
+ }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Remove the systems a failed plugin registered. They would keep updating
46
+ * with nothing left to remove them.
47
+ * @private
48
+ */
49
+ _rollback(names) {
50
+ for (const name of names) {
51
+ try {
52
+ this.kernel.systems.unregister(name);
53
+ } catch (error) {
54
+ // The caller is about to rethrow the init error. This one
55
+ // must not replace it or stop the loop.
56
+ console.error(`[PluginRegistry] Error removing system "${name}":`, error);
57
+ }
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Forget a plugin whose init failed.
63
+ *
64
+ * Removed by name: with two async inits in flight, the failed plugin is
65
+ * not necessarily the last entry.
66
+ * @private
67
+ */
68
+ _forget(name) {
69
+ this._plugins.delete(name);
70
+
71
+ const index = this._loadOrder.indexOf(name);
72
+ if (index > -1) {
73
+ this._loadOrder.splice(index, 1);
74
+ }
19
75
  }
20
76
 
21
77
  /**
@@ -51,6 +107,8 @@ export class PluginRegistry {
51
107
  this._loadOrder.push(plugin.name);
52
108
 
53
109
  // Initialize plugin
110
+ const systemsBefore = new Set(this.kernel.systems.getSystemNames());
111
+
54
112
  try {
55
113
  plugin.init(this.kernel);
56
114
 
@@ -60,11 +118,12 @@ export class PluginRegistry {
60
118
  version: plugin.version
61
119
  });
62
120
 
63
- // Plugin loaded successfully
121
+ this._claim(this._systemsSince(systemsBefore));
64
122
  } catch (error) {
65
123
  console.error(`[PluginRegistry] Failed to initialize plugin "${plugin.name}":`, error);
66
- this._plugins.delete(plugin.name);
67
- this._loadOrder.pop();
124
+ this._forget(plugin.name);
125
+ this._rollback(this._systemsSince(systemsBefore));
126
+
68
127
  throw error;
69
128
  }
70
129
 
@@ -96,21 +155,39 @@ export class PluginRegistry {
96
155
  this._plugins.set(plugin.name, plugin);
97
156
  this._loadOrder.push(plugin.name);
98
157
 
158
+ const pending = {
159
+ before: new Set(this.kernel.systems.getSystemNames()),
160
+ foreign: new Set()
161
+ };
162
+ const own = () => this._systemsSince(pending.before).filter((name) => !pending.foreign.has(name));
163
+ this._inFlight.add(pending);
164
+
99
165
  // Initialize plugin (await if it returns a promise)
100
166
  try {
101
167
  await plugin.init(this.kernel);
102
168
 
169
+ this._inFlight.delete(pending);
170
+ this._claim(own());
171
+
103
172
  // Emit event
104
173
  this.kernel.events.emit('plugin_loaded', {
105
174
  name: plugin.name,
106
175
  version: plugin.version
107
176
  });
108
-
109
- // Plugin loaded successfully
110
177
  } catch (error) {
111
178
  console.error(`[PluginRegistry] Failed to initialize plugin "${plugin.name}":`, error);
112
- this._plugins.delete(plugin.name);
113
- this._loadOrder.pop();
179
+ this._inFlight.delete(pending);
180
+ this._forget(plugin.name);
181
+
182
+ const leftover = own();
183
+ if (this._inFlight.size === 0) {
184
+ this._rollback(leftover);
185
+ } else if (leftover.length > 0) {
186
+ // Another init is still running and may have registered some
187
+ // of these. Removing them could break a plugin that loads.
188
+ console.warn(`[PluginRegistry] Left systems in place after "${plugin.name}" failed: ${leftover.join(', ')}`);
189
+ }
190
+
114
191
  throw error;
115
192
  }
116
193
 
package/src/core/Scene.js CHANGED
@@ -4,7 +4,25 @@
4
4
  export class Scene {
5
5
  constructor() {
6
6
  this.gameObjects = [];
7
- this.kernel = null; // Injected by Game
7
+ this._kernel = null; // Injected by Game
8
+ }
9
+
10
+ get kernel() {
11
+ return this._kernel;
12
+ }
13
+
14
+ /**
15
+ * Objects added before the scene had a kernel receive it here. Scenes are
16
+ * often built in a constructor, before Game.setScene() injects the kernel.
17
+ * The Director sets it to null on exit, which takes colliders out of
18
+ * physics until the scene runs again.
19
+ */
20
+ set kernel(value) {
21
+ this._kernel = value;
22
+
23
+ for (const gameObject of this.gameObjects) {
24
+ gameObject.kernel = value;
25
+ }
8
26
  }
9
27
 
10
28
  /**
@@ -25,6 +43,13 @@ export class Scene {
25
43
  if (this.kernel) {
26
44
  gameObject.kernel = this.kernel; // Inject kernel
27
45
  }
46
+
47
+ // Coming back after a remove(): components were detached then.
48
+ if (gameObject._detached) {
49
+ gameObject._detached = false;
50
+ this._eachComponent(gameObject, (component) => component.onAttach());
51
+ }
52
+
28
53
  this.gameObjects.push(gameObject);
29
54
  return gameObject;
30
55
  }
@@ -37,6 +62,24 @@ export class Scene {
37
62
  const index = this.gameObjects.indexOf(gameObject);
38
63
  if (index > -1) {
39
64
  this.gameObjects.splice(index, 1);
65
+
66
+ // Lets components release what they hold. A Collider left in
67
+ // physics keeps colliding for an object that is gone.
68
+ gameObject._detached = true;
69
+ this._eachComponent(gameObject, (component) => component.onDetach());
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Visit the components of an object and of all its children.
75
+ * @private
76
+ */
77
+ _eachComponent(gameObject, fn) {
78
+ for (const component of [...gameObject.components]) {
79
+ fn(component);
80
+ }
81
+ for (const child of gameObject.children) {
82
+ this._eachComponent(child, fn);
40
83
  }
41
84
  }
42
85
 
@@ -45,8 +88,10 @@ export class Scene {
45
88
  * @param {number} dt
46
89
  */
47
90
  update(dt) {
48
- for (const obj of this.gameObjects) {
49
- obj.update(dt);
91
+ // Snapshot: an object may add or remove objects mid-frame, and
92
+ // splicing the live array would skip the one after it.
93
+ for (const obj of [...this.gameObjects]) {
94
+ if (!obj._detached) obj.update(dt);
50
95
  }
51
96
  }
52
97
 
@@ -55,8 +100,8 @@ export class Scene {
55
100
  * @param {CanvasRenderingContext2D} ctx
56
101
  */
57
102
  render(ctx) {
58
- for (const obj of this.gameObjects) {
59
- obj.render(ctx);
103
+ for (const obj of [...this.gameObjects]) {
104
+ if (!obj._detached) obj.render(ctx);
60
105
  }
61
106
  }
62
107
  }
@@ -17,6 +17,25 @@ export class SystemRegistry {
17
17
 
18
18
  // Flag to track if order needs recomputation
19
19
  this._needsRecompute = false;
20
+
21
+ // Systems that have thrown from an update, so each is logged once
22
+ // instead of sixty times a second.
23
+ this._failed = new WeakSet();
24
+ }
25
+
26
+ /**
27
+ * Run one system's update without letting a throw stop the others.
28
+ * @private
29
+ */
30
+ _run(system, method, dt) {
31
+ try {
32
+ system[method](dt);
33
+ } catch (error) {
34
+ if (!this._failed.has(system)) {
35
+ this._failed.add(system);
36
+ console.error(`[SystemRegistry] System ${method}() threw:`, error);
37
+ }
38
+ }
20
39
  }
21
40
 
22
41
  /**
@@ -92,16 +111,18 @@ export class SystemRegistry {
92
111
  return false;
93
112
  }
94
113
 
95
- // Call destroy if available
96
- if (entry.system.destroy) {
97
- entry.system.destroy();
98
- }
99
-
100
- this._systems.delete(name);
101
- this._needsRecompute = true;
114
+ // A destroy that throws must not leave the system registered.
115
+ try {
116
+ if (entry.system.destroy) {
117
+ entry.system.destroy();
118
+ }
119
+ } finally {
120
+ this._systems.delete(name);
121
+ this._needsRecompute = true;
102
122
 
103
- // Emit event
104
- this.kernel.events.emit('system_unregistered', { name });
123
+ // Emit event
124
+ this.kernel.events.emit('system_unregistered', { name });
125
+ }
105
126
 
106
127
  return true;
107
128
  }
@@ -119,7 +140,7 @@ export class SystemRegistry {
119
140
  for (let i = 0; i < this._updateOrder.length; i++) {
120
141
  const system = this._updateOrder[i];
121
142
  if (system.update) {
122
- system.update(dt);
143
+ this._run(system, 'update', dt);
123
144
  }
124
145
  }
125
146
  }
@@ -136,7 +157,7 @@ export class SystemRegistry {
136
157
  for (let i = 0; i < this._fixedUpdateOrder.length; i++) {
137
158
  const system = this._fixedUpdateOrder[i];
138
159
  if (system.fixedUpdate) {
139
- system.fixedUpdate(dt);
160
+ this._run(system, 'fixedUpdate', dt);
140
161
  }
141
162
  }
142
163
  }
@@ -5,6 +5,8 @@
5
5
 
6
6
  import { resolveStorage } from '../utils/storage.js';
7
7
 
8
+ const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
9
+
8
10
  export class SoundManager {
9
11
  constructor(config = {}) {
10
12
  this.audioContext = null;
@@ -18,12 +20,27 @@ export class SoundManager {
18
20
 
19
21
  // Master gain, created with the AudioContext on first use.
20
22
  this.masterGain = null;
21
- this._volume = config.volume ?? 1;
23
+ this._volume = 1;
24
+ this.setVolume(config.volume ?? 1);
22
25
 
23
- // Preload custom sounds if provided
24
- if (Object.keys(this.customSounds).length > 0) {
25
- this.preloadSounds(this.customSounds);
26
- }
26
+ // In-flight loads by key, so a play() during preload does not fetch
27
+ // the same file twice.
28
+ this._loading = new Map();
29
+
30
+ // Queued follow-up tones, so destroy() can cancel them.
31
+ this._timers = new Set();
32
+ }
33
+
34
+ /**
35
+ * setTimeout that destroy() can cancel.
36
+ * @private
37
+ */
38
+ _later(fn, delay) {
39
+ const id = setTimeout(() => {
40
+ this._timers.delete(id);
41
+ fn();
42
+ }, delay);
43
+ this._timers.add(id);
27
44
  }
28
45
 
29
46
  /**
@@ -43,6 +60,10 @@ export class SoundManager {
43
60
  this.masterGain = this.audioContext.createGain();
44
61
  this.masterGain.gain.value = this._volume;
45
62
  this.masterGain.connect(this.audioContext.destination);
63
+
64
+ // Decoding needs a context, so sounds registered before this
65
+ // point could not be loaded until now.
66
+ this.preloadSounds(this.customSounds);
46
67
  }
47
68
 
48
69
  if (this.audioContext.state === 'suspended') {
@@ -57,6 +78,9 @@ export class SoundManager {
57
78
  * @param {number} value - 0..1
58
79
  */
59
80
  setVolume(value) {
81
+ // NaN would pass through Math.min/max and silence the gain node.
82
+ if (!Number.isFinite(value)) return this._volume;
83
+
60
84
  this._volume = Math.max(0, Math.min(1, value));
61
85
  if (this.masterGain) {
62
86
  this.masterGain.gain.value = this._volume;
@@ -116,19 +140,39 @@ export class SoundManager {
116
140
  * Load and decode a sound file
117
141
  */
118
142
  async loadSound(key, url) {
143
+ // We need an audio context to decode. Without one this waits for
144
+ // initAudio(), which loads everything registered so far.
145
+ if (!this.audioContext) return;
146
+
147
+ if (this._loading.has(key)) {
148
+ return this._loading.get(key);
149
+ }
150
+
151
+ const loading = this._load(key, url).finally(() => this._loading.delete(key));
152
+ this._loading.set(key, loading);
153
+ return loading;
154
+ }
155
+
156
+ /**
157
+ * @private
158
+ */
159
+ async _load(key, url) {
119
160
  try {
120
- // We need audio context to decode
121
- if (!this.audioContext) {
122
- // If no context yet, we can't decode.
123
- // In a real app, we might fetch buffer first, decode later.
124
- // For simplicity, we'll wait for initAudio() or lazy load.
125
- return;
161
+ const context = this.audioContext;
162
+ const response = await fetch(url);
163
+
164
+ // fetch resolves on a 404. Its body is an error page, not audio.
165
+ if (response.ok === false) {
166
+ throw new Error(`HTTP ${response.status}`);
126
167
  }
127
168
 
128
- const response = await fetch(url);
129
169
  const arrayBuffer = await response.arrayBuffer();
130
- const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
131
- this.assets.set(key, audioBuffer);
170
+ const audioBuffer = await context.decodeAudioData(arrayBuffer);
171
+
172
+ // destroy() may have run while this was in flight.
173
+ if (this.audioContext === context) {
174
+ this.assets.set(key, audioBuffer);
175
+ }
132
176
  } catch (error) {
133
177
  console.warn(`[DopamineJS] Failed to load sound '${key}':`, error);
134
178
  }
@@ -149,8 +193,14 @@ export class SoundManager {
149
193
  }
150
194
 
151
195
  // 2. Try to load if registered but not loaded
152
- if (this.customSounds[key] && !this.assets.has(key)) {
196
+ if (hasOwn(this.customSounds, key) && !this.assets.has(key)) {
197
+ const context = this.audioContext;
153
198
  await this.loadSound(key, this.customSounds[key]);
199
+
200
+ // destroy() ran while the file loaded. The synth fallback would
201
+ // open a new context.
202
+ if (this.audioContext !== context) return;
203
+
154
204
  if (this.assets.has(key)) {
155
205
  this._playBuffer(this.assets.get(key));
156
206
  return;
@@ -167,7 +217,7 @@ export class SoundManager {
167
217
  'gameover': () => this.playGameOver(true)
168
218
  };
169
219
 
170
- if (synthMap[key]) {
220
+ if (hasOwn(synthMap, key)) {
171
221
  synthMap[key]();
172
222
  }
173
223
  }
@@ -214,14 +264,14 @@ export class SoundManager {
214
264
  playScore(force = false) {
215
265
  if (!force && this.assets.has('score')) return this.play('score');
216
266
  this.playTone(800, 0.15, 'sine', 0.25);
217
- setTimeout(() => this.playTone(1000, 0.15, 'sine', 0.25), 50);
267
+ this._later(() => this.playTone(1000, 0.15, 'sine', 0.25), 50);
218
268
  }
219
269
 
220
270
  playGameOver(force = false) {
221
271
  if (!force && this.assets.has('gameover')) return this.play('gameover');
222
272
  this.playTone(300, 0.2, 'sawtooth', 0.3);
223
- setTimeout(() => this.playTone(200, 0.2, 'sawtooth', 0.3), 100);
224
- setTimeout(() => this.playTone(150, 0.3, 'sawtooth', 0.3), 200);
273
+ this._later(() => this.playTone(200, 0.2, 'sawtooth', 0.3), 100);
274
+ this._later(() => this.playTone(150, 0.3, 'sawtooth', 0.3), 200);
225
275
  }
226
276
 
227
277
  playClick(force = false) {
@@ -232,11 +282,32 @@ export class SoundManager {
232
282
  playSuccess(force = false) {
233
283
  if (!force && this.assets.has('success')) return this.play('success');
234
284
  this.playTone(600, 0.1, 'sine', 0.2);
235
- setTimeout(() => this.playTone(800, 0.2, 'sine', 0.2), 100);
285
+ this._later(() => this.playTone(800, 0.2, 'sine', 0.2), 100);
236
286
  }
237
287
 
238
288
  playError(force = false) {
239
289
  if (!force && this.assets.has('error')) return this.play('error');
240
290
  this.playTone(200, 0.2, 'sawtooth', 0.3);
241
291
  }
292
+
293
+ /**
294
+ * Cancel queued tones, close the audio context and drop decoded buffers.
295
+ * Browsers cap the number of live AudioContexts per page.
296
+ */
297
+ destroy() {
298
+ for (const id of this._timers) {
299
+ clearTimeout(id);
300
+ }
301
+ this._timers.clear();
302
+
303
+ if (this.audioContext) {
304
+ const closing = this.audioContext.close?.();
305
+ closing?.catch?.(() => {});
306
+ }
307
+
308
+ this.audioContext = null;
309
+ this.masterGain = null;
310
+ this.assets.clear();
311
+ this._loading.clear();
312
+ }
242
313
  }
@@ -15,16 +15,17 @@ export class ScreenShake extends Component {
15
15
  }
16
16
 
17
17
  shake(intensity = 5, duration = 0.2) {
18
+ // Save original if not already shaking. Mid-shake the object sits at
19
+ // an offset, and saving that would move its resting position.
20
+ if (!this.isShaking) {
21
+ this.originalPos.x = this.gameObject.x;
22
+ this.originalPos.y = this.gameObject.y;
23
+ }
24
+
18
25
  this.intensity = intensity;
19
26
  this.duration = duration;
20
27
  this.timer = 0;
21
28
  this.isShaking = true;
22
-
23
- // Save original if not already shaking
24
- if (this.timer === 0) {
25
- this.originalPos.x = this.gameObject.x;
26
- this.originalPos.y = this.gameObject.y;
27
- }
28
29
  }
29
30
 
30
31
  update(dt) {
@@ -1,4 +1,2 @@
1
- import Dopamine from '../index.js';
2
-
3
1
  export { ParticleEmitter } from './ParticleEmitter.js';
4
2
  export { ScreenShake } from './ScreenShake.js';