dopaminejs 2.0.2 → 2.2.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.
@@ -3,6 +3,8 @@
3
3
  * Reusable UI elements for XP bars, achievement popups, level-up screens, etc.
4
4
  */
5
5
 
6
+ const OVERLAY_ID = 'game-ui-overlay';
7
+
6
8
  export class GameUI {
7
9
  constructor(particleSystem) {
8
10
  this.particleSystem = particleSystem;
@@ -10,6 +12,12 @@ export class GameUI {
10
12
  this.xpBar = null;
11
13
  this.levelDisplay = null;
12
14
  this.streakDisplay = null;
15
+
16
+ // Everything this instance put in the DOM or scheduled, so destroy()
17
+ // can take it all back out again.
18
+ this._timers = new Set();
19
+ this._detached = [];
20
+
13
21
  this.init();
14
22
  }
15
23
 
@@ -19,50 +27,92 @@ export class GameUI {
19
27
  init() {
20
28
  // Create main container
21
29
  this.container = document.createElement('div');
22
- this.container.id = 'game-ui-overlay';
30
+ this.container.className = 'game-ui-overlay';
31
+
32
+ // The stylesheet targets #game-ui-overlay. Claim the id only if it is
33
+ // free, so a second instance cannot produce a duplicate id.
34
+ if (!document.getElementById(OVERLAY_ID)) {
35
+ this.container.id = OVERLAY_ID;
36
+ }
37
+
23
38
  this.container.innerHTML = `
24
39
  <div class="game-ui-top-bar">
25
- <div class="level-badge" id="level-badge">
40
+ <div class="level-badge">
26
41
  <span class="level-label">LVL</span>
27
- <span class="level-number" id="level-number">1</span>
42
+ <span class="level-number">1</span>
28
43
  </div>
29
-
44
+
30
45
  <div class="xp-container">
31
46
  <div class="xp-bar-bg">
32
- <div class="xp-bar-fill" id="xp-bar-fill" style="width: 0%"></div>
47
+ <div class="xp-bar-fill" style="width: 0%"></div>
33
48
  </div>
34
- <div class="xp-text" id="xp-text">0 /100 XP</div>
49
+ <div class="xp-text">0 / 100 XP</div>
35
50
  </div>
36
-
37
- <div class="streak-badge" id="streak-badge">
51
+
52
+ <div class="streak-badge">
38
53
  <span class="streak-icon">🔥</span>
39
- <span class="streak-number" id="streak-number">1</span>
54
+ <span class="streak-number">1</span>
40
55
  </div>
41
56
  </div>
42
57
  `;
43
58
 
44
59
  document.body.appendChild(this.container);
45
60
 
46
- // Cache elements
47
- this.xpBar = document.getElementById('xp-bar-fill');
48
- this.xpText = document.getElementById('xp-text');
49
- this.levelDisplay = document.getElementById('level-number');
50
- this.streakDisplay = document.getElementById('streak-number');
61
+ // Scoped to this instance. Document-wide getElementById would make a
62
+ // second GameUI silently rebind every element to the first one's DOM.
63
+ this.levelBadge = this.container.querySelector('.level-badge');
64
+ this.streakBadge = this.container.querySelector('.streak-badge');
65
+ this.xpBar = this.container.querySelector('.xp-bar-fill');
66
+ this.xpText = this.container.querySelector('.xp-text');
67
+ this.levelDisplay = this.container.querySelector('.level-number');
68
+ this.streakDisplay = this.container.querySelector('.streak-number');
69
+ }
70
+
71
+ /**
72
+ * setTimeout that destroy() can cancel.
73
+ * @private
74
+ */
75
+ _defer(fn, delay) {
76
+ const id = setTimeout(() => {
77
+ this._timers.delete(id);
78
+ fn();
79
+ }, delay);
80
+ this._timers.add(id);
81
+ return id;
82
+ }
83
+
84
+ /**
85
+ * Append a transient element and remember it for cleanup.
86
+ * @private
87
+ */
88
+ _mount(el) {
89
+ document.body.appendChild(el);
90
+ this._detached.push(el);
91
+ return el;
92
+ }
93
+
94
+ /**
95
+ * Replay a CSS animation by clearing it for one tick.
96
+ * @private
97
+ */
98
+ _replayAnimation(el, animation) {
99
+ if (!el) return;
100
+ el.style.animation = 'none';
101
+ this._defer(() => { el.style.animation = animation; }, 10);
51
102
  }
52
103
 
53
104
  /**
54
105
  * Update XP bar
106
+ * @param {number} current - XP held right now
107
+ * @param {number} needed - XP still required for the next level
108
+ * @param {number} total - XP total that marks the next level
55
109
  */
56
110
  updateXP(current, needed, total) {
57
- const progress = (current / total) * 100;
58
- this.xpBar.style.width = `${Math.min(100, progress)}%`;
111
+ const progress = total > 0 ? (current / total) * 100 : 0;
112
+ this.xpBar.style.width = `${Math.max(0, Math.min(100, progress))}%`;
59
113
  this.xpText.textContent = `${current} / ${total} XP`;
60
114
 
61
- // Add pulse animation when gaining XP
62
- this.xpBar.style.animation = 'none';
63
- setTimeout(() => {
64
- this.xpBar.style.animation = 'xp-pulse 0.3s ease-out';
65
- }, 10);
115
+ this._replayAnimation(this.xpBar, 'xp-pulse 0.3s ease-out');
66
116
  }
67
117
 
68
118
  /**
@@ -70,13 +120,7 @@ export class GameUI {
70
120
  */
71
121
  updateLevel(level) {
72
122
  this.levelDisplay.textContent = level;
73
-
74
- // Pulse animation
75
- const badge = document.getElementById('level-badge');
76
- badge.style.animation = 'none';
77
- setTimeout(() => {
78
- badge.style.animation = 'level-pulse 0.5s ease-out';
79
- }, 10);
123
+ this._replayAnimation(this.levelBadge, 'level-pulse 0.5s ease-out');
80
124
  }
81
125
 
82
126
  /**
@@ -86,16 +130,20 @@ export class GameUI {
86
130
  this.streakDisplay.textContent = days;
87
131
 
88
132
  // Change color based on streak
89
- const badge = document.getElementById('streak-badge');
90
133
  if (days >= 7) {
91
- badge.style.background = 'linear-gradient(135deg, #ff6b00 0%, #ff4400 100%)';
134
+ this.streakBadge.style.background = 'linear-gradient(135deg, #ff6b00 0%, #ff4400 100%)';
92
135
  } else if (days >= 3) {
93
- badge.style.background = 'linear-gradient(135deg, #ff8800 0%, #ff6b00 100%)';
136
+ this.streakBadge.style.background = 'linear-gradient(135deg, #ff8800 0%, #ff6b00 100%)';
94
137
  }
95
138
  }
96
139
 
97
140
  /**
98
141
  * Show achievement unlocked popup
142
+ *
143
+ * `icon` is rendered as HTML by design (documented in ARCHITECTURE.md, so
144
+ * games can pass an <img> or inline SVG). Every other field is game state
145
+ * and could carry a player-supplied or server-supplied string, so it goes
146
+ * in as text.
99
147
  */
100
148
  showAchievement(achievement) {
101
149
  const popup = document.createElement('div');
@@ -103,28 +151,33 @@ export class GameUI {
103
151
  popup.innerHTML = `
104
152
  <div class="achievement-shine"></div>
105
153
  <div class="achievement-content">
106
- <div class="achievement-icon">${achievement.icon}</div>
154
+ <div class="achievement-icon"></div>
107
155
  <div class="achievement-info">
108
156
  <div class="achievement-title">Achievement Unlocked!</div>
109
- <div class="achievement-name">${achievement.name}</div>
110
- <div class="achievement-desc">${achievement.description}</div>
111
- <div class="achievement-xp">+${achievement.xp} XP</div>
157
+ <div class="achievement-name"></div>
158
+ <div class="achievement-desc"></div>
159
+ <div class="achievement-xp"></div>
112
160
  </div>
113
161
  </div>
114
162
  `;
115
163
 
116
- document.body.appendChild(popup);
164
+ popup.querySelector('.achievement-icon').innerHTML = achievement.icon ?? '';
165
+ popup.querySelector('.achievement-name').textContent = achievement.name ?? '';
166
+ popup.querySelector('.achievement-desc').textContent = achievement.description ?? '';
167
+ popup.querySelector('.achievement-xp').textContent = `+${achievement.xp ?? 0} XP`;
168
+
169
+ this._mount(popup);
117
170
 
118
171
  // Trigger confetti at popup location
119
- setTimeout(() => {
172
+ this._defer(() => {
120
173
  const rect = popup.getBoundingClientRect();
121
174
  this.particleSystem.confetti(rect.left + rect.width / 2, rect.top + rect.height / 2, 40);
122
175
  }, 300);
123
176
 
124
177
  // Remove after animation
125
- setTimeout(() => {
178
+ this._defer(() => {
126
179
  popup.style.animation = 'slideOut 0.3s ease-in forwards';
127
- setTimeout(() => popup.remove(), 300);
180
+ this._defer(() => this._remove(popup), 300);
128
181
  }, 4000);
129
182
  }
130
183
 
@@ -137,17 +190,18 @@ export class GameUI {
137
190
  overlay.innerHTML = `
138
191
  <div class="level-up-content">
139
192
  <div class="level-up-title">LEVEL UP!</div>
140
- <div class="level-up-number">${newLevel}</div>
193
+ <div class="level-up-number"></div>
141
194
  <div class="level-up-subtitle">Amazing progress!</div>
142
195
  </div>
143
196
  `;
197
+ overlay.querySelector('.level-up-number').textContent = newLevel;
144
198
 
145
- document.body.appendChild(overlay);
199
+ this._mount(overlay);
146
200
 
147
201
  // Fireworks effect
148
- setTimeout(() => {
202
+ this._defer(() => {
149
203
  for (let i = 0; i < 5; i++) {
150
- setTimeout(() => {
204
+ this._defer(() => {
151
205
  const x = Math.random() * window.innerWidth;
152
206
  const y = Math.random() * window.innerHeight * 0.6;
153
207
  this.particleSystem.confetti(x, y, 30);
@@ -156,9 +210,9 @@ export class GameUI {
156
210
  }, 300);
157
211
 
158
212
  // Remove after 3 seconds
159
- setTimeout(() => {
213
+ this._defer(() => {
160
214
  overlay.style.opacity = '0';
161
- setTimeout(() => overlay.remove(), 500);
215
+ this._defer(() => this._remove(overlay), 500);
162
216
  }, 2500);
163
217
  }
164
218
 
@@ -174,9 +228,9 @@ export class GameUI {
174
228
  floater.style.color = color;
175
229
  floater.style.fontSize = size;
176
230
 
177
- document.body.appendChild(floater);
231
+ this._mount(floater);
178
232
 
179
- setTimeout(() => floater.remove(), 2000);
233
+ this._defer(() => this._remove(floater), 2000);
180
234
  }
181
235
 
182
236
  /**
@@ -188,7 +242,7 @@ export class GameUI {
188
242
  }
189
243
 
190
244
  /**
191
- * Show "near miss"indicator
245
+ * Show "near miss" indicator
192
246
  */
193
247
  showNearMiss(x, y) {
194
248
  this.showFloatingText('CLOSE CALL!', x, y, '#45b7d1', '20px');
@@ -222,6 +276,12 @@ export class GameUI {
222
276
 
223
277
  /**
224
278
  * Show Game Over Summary
279
+ *
280
+ * @param {Object} data
281
+ * @param {number|string} data.score - Final score
282
+ * @param {Object} [data.metrics] - Label/value pairs listed under the score
283
+ * @param {Function} [data.onReplay] - Replay button handler (default: reload)
284
+ * @param {Function} [data.onExit] - Exit button handler (button hidden if omitted)
225
285
  */
226
286
  showSummary(data) {
227
287
  const overlay = document.createElement('div');
@@ -233,23 +293,85 @@ export class GameUI {
233
293
  overlay.style.transform = 'translate(-50%, -50%)';
234
294
  overlay.style.zIndex = '1000';
235
295
 
236
- let metricsHtml = '';
237
- if (data.metrics) {
238
- metricsHtml = Object.entries(data.metrics)
239
- .map(([key, value]) => `<div style="display:flex;justify-content:space-between;width:100%"><span>${key}:</span> <strong>${value}</strong></div>`)
240
- .join('');
296
+ const heading = document.createElement('h1');
297
+ heading.textContent = 'Game Over';
298
+
299
+ const score = document.createElement('div');
300
+ score.className = 'summary-score';
301
+ score.style.cssText = 'font-size: 3rem; font-weight: bold; color: #e06020; margin: 10px 0;';
302
+ score.textContent = data.score;
303
+
304
+ const metrics = document.createElement('div');
305
+ metrics.className = 'summary-metrics';
306
+ metrics.style.cssText = 'width: 100%; margin-bottom: 20px;';
307
+
308
+ for (const [key, value] of Object.entries(data.metrics || {})) {
309
+ const row = document.createElement('div');
310
+ row.style.cssText = 'display:flex;justify-content:space-between;width:100%';
311
+
312
+ const label = document.createElement('span');
313
+ label.textContent = `${key}:`;
314
+
315
+ const amount = document.createElement('strong');
316
+ amount.textContent = value;
317
+
318
+ row.append(label, amount);
319
+ metrics.appendChild(row);
241
320
  }
242
321
 
243
- overlay.innerHTML = `
244
- <h1>Game Over</h1>
245
- <div style="font-size: 3rem; font-weight: bold; color: #e06020; margin: 10px 0;">${data.score}</div>
246
- <div style="width: 100%; margin-bottom: 20px;">
247
- ${metricsHtml}
248
- </div>
249
- <button class="btn" onclick="location.reload()">Play Again</button>
250
- <button class="btn" onclick="window.location.href='index.html'" style="margin-top: 10px; background: #543847;">Exit</button>
251
- `;
322
+ overlay.append(heading, score, metrics);
323
+
324
+ // addEventListener rather than an onclick attribute: inline handlers
325
+ // are blocked by any script-src CSP without 'unsafe-inline'.
326
+ const replay = document.createElement('button');
327
+ replay.className = 'btn';
328
+ replay.textContent = 'Play Again';
329
+ replay.addEventListener('click', data.onReplay || (() => window.location.reload()));
330
+ overlay.appendChild(replay);
252
331
 
253
- document.body.appendChild(overlay);
332
+ if (data.onExit) {
333
+ const exit = document.createElement('button');
334
+ exit.className = 'btn';
335
+ exit.textContent = 'Exit';
336
+ exit.style.cssText = 'margin-top: 10px; background: #543847;';
337
+ exit.addEventListener('click', data.onExit);
338
+ overlay.appendChild(exit);
339
+ }
340
+
341
+ this._mount(overlay);
342
+ return overlay;
343
+ }
344
+
345
+ /**
346
+ * @private
347
+ */
348
+ _remove(el) {
349
+ el.remove();
350
+ const i = this._detached.indexOf(el);
351
+ if (i > -1) this._detached.splice(i, 1);
352
+ }
353
+
354
+ /**
355
+ * Tear down the overlay, any transient popups, and all pending timers.
356
+ */
357
+ destroy() {
358
+ for (const id of this._timers) {
359
+ clearTimeout(id);
360
+ }
361
+ this._timers.clear();
362
+
363
+ for (const el of this._detached) {
364
+ el.remove();
365
+ }
366
+ this._detached = [];
367
+
368
+ this.container?.remove();
369
+ this.container = null;
370
+ this.xpBar = null;
371
+ this.xpText = null;
372
+ this.levelBadge = null;
373
+ this.streakBadge = null;
374
+ this.levelDisplay = null;
375
+ this.streakDisplay = null;
254
376
  }
255
377
  }
@@ -6,7 +6,10 @@
6
6
 
7
7
  /* ==================== TOP BAR (XP, Level, Streak) ==================== */
8
8
 
9
- #game-ui-overlay {
9
+ /* The id is kept so existing `#game-ui-overlay` overrides still win on
10
+ specificity; the class is what every instance actually carries. */
11
+ #game-ui-overlay,
12
+ .game-ui-overlay {
10
13
  position: fixed;
11
14
  top: 0;
12
15
  left: 0;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Storage resolution for environments that may not have localStorage.
3
+ *
4
+ * Three cases need to work:
5
+ * - Browser: localStorage.
6
+ * - SSR / worker / test runner: no `window` at all, so importing the package
7
+ * must not throw at module or constructor time.
8
+ * - Safari private browsing and storage-blocked iframes: localStorage exists
9
+ * but every write throws QuotaExceededError.
10
+ */
11
+
12
+ /**
13
+ * A Storage-shaped object backed by a Map. Values do not survive a reload,
14
+ * which is the correct degradation: the game runs, progress just is not kept.
15
+ *
16
+ * @returns {{getItem: Function, setItem: Function, removeItem: Function, clear: Function}}
17
+ */
18
+ export function createMemoryStorage() {
19
+ const map = new Map();
20
+
21
+ return {
22
+ getItem: (key) => (map.has(key) ? map.get(key) : null),
23
+ setItem: (key, value) => { map.set(key, String(value)); },
24
+ removeItem: (key) => { map.delete(key); },
25
+ clear: () => { map.clear(); }
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Return localStorage if it is present and writable, otherwise a memory store.
31
+ *
32
+ * @returns {Object} Storage-shaped object
33
+ */
34
+ export function resolveStorage() {
35
+ try {
36
+ if (typeof localStorage === 'undefined') {
37
+ return createMemoryStorage();
38
+ }
39
+
40
+ // Presence is not enough: blocked contexts throw only on write.
41
+ const probe = '__dopamine_probe__';
42
+ localStorage.setItem(probe, '1');
43
+ localStorage.removeItem(probe);
44
+
45
+ return localStorage;
46
+ } catch {
47
+ return createMemoryStorage();
48
+ }
49
+ }
package/src/index.js CHANGED
@@ -12,6 +12,12 @@ export { PluginRegistry } from './core/PluginRegistry.js';
12
12
  // Export System Interfaces
13
13
  export * from './interfaces/index.js';
14
14
 
15
+ // The emitter RewardSystem extends, so consumers can type their listeners.
16
+ export { EventEmitter } from './dopamine/core/EventEmitter.js';
17
+
18
+ // Storage resolution, useful for tests and for SSR where localStorage is absent.
19
+ export { createMemoryStorage, resolveStorage } from './dopamine/utils/storage.js';
20
+
15
21
  // Export New Engine Core
16
22
  export { Game } from './core/Game.js';
17
23
  export { Scene } from './core/Scene.js';
@@ -1,3 +1,5 @@
1
+ import { deprecatedGlobal } from './deprecate.js';
2
+
1
3
  /**
2
4
  * Input System
3
5
  * Tracks keyboard and mouse input.
@@ -71,7 +73,9 @@ export class Input {
71
73
  }
72
74
  }
73
75
 
74
- // DEPRECATED: Keep for backward compatibility
75
- export const GlobalInput = new Input();
76
- // Note: GlobalInput won't have listeners attached unless init() is called
77
- console.warn('[DopamineJS] GlobalInput is deprecated. Use kernel.input instead.');
76
+ // DEPRECATED: kept for v1 compatibility. Warns on first use, not on import.
77
+ // Note: listeners are not attached unless init() is called.
78
+ export const GlobalInput = deprecatedGlobal(
79
+ new Input(),
80
+ '[DopamineJS] GlobalInput is deprecated. Use kernel.input instead.'
81
+ );
@@ -1,3 +1,5 @@
1
+ import { deprecatedGlobal } from './deprecate.js';
2
+
1
3
  /**
2
4
  * Asset Loader
3
5
  * Loads and caches images, audio, etc.
@@ -39,6 +41,8 @@ export class Loader {
39
41
  }
40
42
  }
41
43
 
42
- // DEPRECATED: Keep for backward compatibility
43
- export const GlobalLoader = new Loader();
44
- console.warn('[DopamineJS] GlobalLoader is deprecated. Use kernel.loader instead.');
44
+ // DEPRECATED: kept for v1 compatibility. Warns on first use, not on import.
45
+ export const GlobalLoader = deprecatedGlobal(
46
+ new Loader(),
47
+ '[DopamineJS] GlobalLoader is deprecated. Use kernel.loader instead.'
48
+ );
@@ -1,3 +1,5 @@
1
+ import { deprecatedGlobal } from './deprecate.js';
2
+
1
3
  import { Collider } from '../core/Collider.js';
2
4
 
3
5
  export class Physics {
@@ -112,6 +114,8 @@ export class Physics {
112
114
  setGravity(x, y) { /* TODO: Add gravity support */ }
113
115
  }
114
116
 
115
- // DEPRECATED: Keep for backward compatibility, but log warning
116
- export const GlobalPhysics = new Physics();
117
- console.warn('[DopamineJS] GlobalPhysics is deprecated. Use kernel.physics instead.');
117
+ // DEPRECATED: kept for v1 compatibility. Warns on first use, not on import.
118
+ export const GlobalPhysics = deprecatedGlobal(
119
+ new Physics(),
120
+ '[DopamineJS] GlobalPhysics is deprecated. Use kernel.physics instead.'
121
+ );
@@ -7,6 +7,7 @@ export class Ticker {
7
7
  this.running = false;
8
8
  this.lastTime = 0;
9
9
  this.callbacks = new Set();
10
+ this._frameHandle = null;
10
11
  this._tick = this._tick.bind(this);
11
12
  }
12
13
 
@@ -33,7 +34,14 @@ export class Ticker {
33
34
  if (this.running) return;
34
35
  this.running = true;
35
36
  this.lastTime = performance.now();
36
- requestAnimationFrame(this._tick);
37
+
38
+ // Cancel first: a frame queued before the last stop() may still be
39
+ // pending, and letting it through would leave two live loops running
40
+ // the game at double speed.
41
+ if (this._frameHandle !== null) {
42
+ cancelAnimationFrame(this._frameHandle);
43
+ }
44
+ this._frameHandle = requestAnimationFrame(this._tick);
37
45
  }
38
46
 
39
47
  /**
@@ -41,9 +49,16 @@ export class Ticker {
41
49
  */
42
50
  stop() {
43
51
  this.running = false;
52
+
53
+ if (this._frameHandle !== null) {
54
+ cancelAnimationFrame(this._frameHandle);
55
+ this._frameHandle = null;
56
+ }
44
57
  }
45
58
 
46
59
  _tick(time) {
60
+ this._frameHandle = null;
61
+
47
62
  if (!this.running) return;
48
63
 
49
64
  const dt = (time - this.lastTime) / 1000; // Delta time in seconds
@@ -52,10 +67,14 @@ export class Ticker {
52
67
  // Cap dt to prevent huge jumps if tab was inactive
53
68
  const safeDt = Math.min(dt, 0.1);
54
69
 
55
- for (const callback of this.callbacks) {
70
+ // Snapshot: a callback may add or remove callbacks mid-frame.
71
+ for (const callback of [...this.callbacks]) {
56
72
  callback(safeDt);
57
73
  }
58
74
 
59
- requestAnimationFrame(this._tick);
75
+ // A callback may have called stop(); don't queue another frame if so.
76
+ if (this.running) {
77
+ this._frameHandle = requestAnimationFrame(this._tick);
78
+ }
60
79
  }
61
80
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Deprecation helper for the v1 global singletons.
3
+ *
4
+ * These used to warn at module scope, which meant importing the package
5
+ * printed a warning for every legacy global whether or not the consumer
6
+ * touched one. A deprecation notice should cost you something only when you
7
+ * use the deprecated thing, otherwise it is just noise that trains people to
8
+ * ignore warnings.
9
+ *
10
+ * @param {Object} instance - The real system to delegate to
11
+ * @param {string} message - Warning text, emitted once on first use
12
+ * @returns {Object} A proxy that warns on first property access
13
+ */
14
+ export function deprecatedGlobal(instance, message) {
15
+ let warned = false;
16
+
17
+ return new Proxy(instance, {
18
+ get(target, prop, receiver) {
19
+ if (!warned) {
20
+ warned = true;
21
+ console.warn(message);
22
+ }
23
+
24
+ const value = Reflect.get(target, prop, receiver);
25
+ // Methods must stay bound to the real instance, not the proxy,
26
+ // or internal `this` access re-enters the trap on every field.
27
+ return typeof value === 'function' ? value.bind(target) : value;
28
+ },
29
+
30
+ set(target, prop, value) {
31
+ if (!warned) {
32
+ warned = true;
33
+ console.warn(message);
34
+ }
35
+ return Reflect.set(target, prop, value);
36
+ }
37
+ });
38
+ }