dopaminejs 2.1.0 → 2.3.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.
@@ -24,6 +24,7 @@ export default class Dopamine {
24
24
 
25
25
  async init() {
26
26
  await this.rewardSystem.init();
27
+ this._syncUI();
27
28
  return {
28
29
  rewardSystem: this.rewardSystem,
29
30
  gameUI: this.gameUI,
@@ -32,30 +33,61 @@ export default class Dopamine {
32
33
  };
33
34
  }
34
35
 
36
+ /**
37
+ * Draw the loaded player. Without this the overlay keeps its placeholder
38
+ * (level 1, 0 XP, streak 1) until the first event arrives.
39
+ * @private
40
+ */
41
+ _syncUI() {
42
+ const { player } = this.rewardSystem;
43
+ const { total, needed, progress } = this.rewardSystem.getXPForNextLevel();
44
+
45
+ this.gameUI.updateXP(player.xp, needed, total, progress);
46
+ this.gameUI.updateLevel(player.level);
47
+ this.gameUI.updateStreak(player.streak.current);
48
+ }
49
+
35
50
  _bindEvents() {
36
- this.rewardSystem.on('xp_gained', (data) => {
37
- const { total, needed } = this.rewardSystem.getXPForNextLevel();
38
- this.gameUI.updateXP(this.rewardSystem.player.xp, needed, total);
39
-
40
- if (data.xpGained > 0) {
41
- // Show floating text for XP gain
42
- // We might need a way to know WHERE to show this, or just show it in a standard place
43
- // For now, let's just update the bar
44
- }
45
- });
46
-
47
- this.rewardSystem.on('level_up', (data) => {
48
- this.gameUI.updateLevel(data.newLevel);
49
- this.gameUI.showLevelUp(data.oldLevel, data.newLevel);
50
- });
51
-
52
- this.rewardSystem.on('achievement_unlocked', (achievement) => {
53
- this.gameUI.showAchievement(achievement);
54
- });
55
-
56
- this.rewardSystem.on('new_high_score', (data) => {
57
- this.gameUI.showNotification(`New High Score: ${data.score}!`, 'legendary');
58
- this.soundManager.playSuccess();
59
- });
51
+ const rewards = this.rewardSystem;
52
+
53
+ this._unsubscribe = [
54
+ rewards.on('xp_gained', () => {
55
+ const { total, needed, progress } = rewards.getXPForNextLevel();
56
+ this.gameUI.updateXP(rewards.player.xp, needed, total, progress);
57
+ }),
58
+
59
+ rewards.on('level_up', (data) => {
60
+ this.gameUI.updateLevel(data.newLevel);
61
+ this.gameUI.showLevelUp(data.oldLevel, data.newLevel);
62
+ }),
63
+
64
+ rewards.on('achievement_unlocked', (achievement) => {
65
+ this.gameUI.showAchievement(achievement);
66
+ }),
67
+
68
+ rewards.on('streak_updated', (data) => {
69
+ this.gameUI.updateStreak(data.current);
70
+ }),
71
+
72
+ rewards.on('new_high_score', (data) => {
73
+ this.gameUI.showNotification(`New High Score: ${data.score}!`, 'legendary');
74
+ this.soundManager.playSuccess();
75
+ })
76
+ ];
77
+ }
78
+
79
+ /**
80
+ * Remove the overlay and canvas, stop audio, and detach from the reward
81
+ * system. The reward system itself stays usable.
82
+ */
83
+ destroy() {
84
+ for (const unsubscribe of this._unsubscribe) {
85
+ unsubscribe();
86
+ }
87
+ this._unsubscribe = [];
88
+
89
+ this.gameUI.destroy();
90
+ this.particleSystem.destroy();
91
+ this.soundManager.destroy();
60
92
  }
61
93
  }
@@ -56,6 +56,22 @@ export class GameUI {
56
56
  </div>
57
57
  `;
58
58
 
59
+ // Popups are decorative and short-lived, so a screen reader would
60
+ // miss them. This region repeats them as plain text.
61
+ this.liveRegion = document.createElement('div');
62
+ this.liveRegion.className = 'dopamine-live-region';
63
+ this.liveRegion.setAttribute('aria-live', 'polite');
64
+ this.liveRegion.setAttribute('role', 'status');
65
+ Object.assign(this.liveRegion.style, {
66
+ position: 'absolute',
67
+ width: '1px',
68
+ height: '1px',
69
+ overflow: 'hidden',
70
+ clipPath: 'inset(50%)',
71
+ whiteSpace: 'nowrap'
72
+ });
73
+ this.container.appendChild(this.liveRegion);
74
+
59
75
  document.body.appendChild(this.container);
60
76
 
61
77
  // Scoped to this instance. Document-wide getElementById would make a
@@ -91,6 +107,16 @@ export class GameUI {
91
107
  return el;
92
108
  }
93
109
 
110
+ /**
111
+ * Repeat a popup as text for assistive technology.
112
+ * @private
113
+ */
114
+ _announce(message) {
115
+ if (this.liveRegion) {
116
+ this.liveRegion.textContent = message;
117
+ }
118
+ }
119
+
94
120
  /**
95
121
  * Replay a CSS animation by clearing it for one tick.
96
122
  * @private
@@ -106,10 +132,15 @@ export class GameUI {
106
132
  * @param {number} current - XP held right now
107
133
  * @param {number} needed - XP still required for the next level
108
134
  * @param {number} total - XP total that marks the next level
135
+ * @param {number} [progress] - Position within the current level, 0 to 1.
136
+ * Without it the bar falls back to current / total, which is lifetime
137
+ * XP and starts every level after the first partly full.
109
138
  */
110
- updateXP(current, needed, total) {
111
- const progress = total > 0 ? (current / total) * 100 : 0;
112
- this.xpBar.style.width = `${Math.max(0, Math.min(100, progress))}%`;
139
+ updateXP(current, needed, total, progress) {
140
+ const fraction = Number.isFinite(progress)
141
+ ? progress
142
+ : (total > 0 ? current / total : 0);
143
+ this.xpBar.style.width = `${Math.max(0, Math.min(100, fraction * 100))}%`;
113
144
  this.xpText.textContent = `${current} / ${total} XP`;
114
145
 
115
146
  this._replayAnimation(this.xpBar, 'xp-pulse 0.3s ease-out');
@@ -134,6 +165,8 @@ export class GameUI {
134
165
  this.streakBadge.style.background = 'linear-gradient(135deg, #ff6b00 0%, #ff4400 100%)';
135
166
  } else if (days >= 3) {
136
167
  this.streakBadge.style.background = 'linear-gradient(135deg, #ff8800 0%, #ff6b00 100%)';
168
+ } else {
169
+ this.streakBadge.style.background = '';
137
170
  }
138
171
  }
139
172
 
@@ -167,6 +200,7 @@ export class GameUI {
167
200
  popup.querySelector('.achievement-xp').textContent = `+${achievement.xp ?? 0} XP`;
168
201
 
169
202
  this._mount(popup);
203
+ this._announce(`Achievement unlocked: ${achievement.name ?? ''}`);
170
204
 
171
205
  // Trigger confetti at popup location
172
206
  this._defer(() => {
@@ -197,6 +231,7 @@ export class GameUI {
197
231
  overlay.querySelector('.level-up-number').textContent = newLevel;
198
232
 
199
233
  this._mount(overlay);
234
+ this._announce(`Level up: ${newLevel}`);
200
235
 
201
236
  // Fireworks effect
202
237
  this._defer(() => {
@@ -265,13 +300,20 @@ export class GameUI {
265
300
  const x = window.innerWidth / 2;
266
301
  const y = window.innerHeight / 3;
267
302
 
303
+ // The message is shown as written. showLuckyMoment and showCombo
304
+ // expect a multiplier and would wrap it in "LUCKY ...×!".
268
305
  if (type === 'legendary') {
269
- this.showLuckyMoment(message, x, y);
306
+ this.showFloatingText(message, x, y, '#ffd700', '36px');
307
+ this.particleSystem.starBurst(x, y, 12);
308
+ this.particleSystem.confetti(x, y, 25);
270
309
  } else if (type === 'rare') {
271
- this.showCombo(message, x, y);
310
+ this.showFloatingText(message, x, y, '#ff6b6b', '32px');
311
+ this.particleSystem.fire(x, y, 20);
272
312
  } else {
273
313
  this.showFloatingText(message, x, y, '#fff', '24px');
274
314
  }
315
+
316
+ this._announce(message);
275
317
  }
276
318
 
277
319
  /**
@@ -367,6 +409,7 @@ export class GameUI {
367
409
 
368
410
  this.container?.remove();
369
411
  this.container = null;
412
+ this.liveRegion = null;
370
413
  this.xpBar = null;
371
414
  this.xpText = null;
372
415
  this.levelBadge = null;
@@ -375,3 +375,21 @@
375
375
  font-size: 80px;
376
376
  }
377
377
  }
378
+
379
+ /* Users who asked the OS for less motion get the same information without
380
+ the movement. Floating text keeps its place until the script removes it:
381
+ its animation ends at opacity 0, so cutting the duration would hide it. */
382
+ @media (prefers-reduced-motion: reduce) {
383
+ .game-ui-overlay *,
384
+ .achievement-popup,
385
+ .achievement-popup *,
386
+ .level-up-overlay,
387
+ .level-up-overlay * {
388
+ animation: none !important;
389
+ transition: none !important;
390
+ }
391
+
392
+ .floating-text {
393
+ animation: none !important;
394
+ }
395
+ }
package/src/engine.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Engine subpath export — `import { Game, Scene } from 'dopaminejs/engine'`
3
+ *
4
+ * The game engine that DopamineJS is built on. Use this subpath if your project
5
+ * only needs the entity/component system, renderer, or physics, and not the
6
+ * progression mechanics (RewardSystem, GameUI, DataService).
7
+ *
8
+ * The root `dopaminejs` export continues to re-export everything, so nothing breaks.
9
+ * This subpath makes the README's distinction between the engine and the progression
10
+ * layer real in code and gives bundlers a clear surface to tree-shake against.
11
+ */
12
+
13
+ // Entity / component system
14
+ export { Game } from './core/Game.js';
15
+ export { Scene } from './core/Scene.js';
16
+ export { GameObject } from './core/GameObject.js';
17
+ export { Component } from './core/Component.js';
18
+ export { Vector2 } from './core/Vector2.js';
19
+ export { Collider } from './core/Collider.js';
20
+ export { Sprite } from './core/Sprite.js';
21
+ export { Animator } from './core/Animator.js';
22
+
23
+ // Kernel and plugin infrastructure
24
+ export { DopamineKernel } from './core/DopamineKernel.js';
25
+ export { EventBus } from './core/EventBus.js';
26
+ export { SystemRegistry } from './core/SystemRegistry.js';
27
+ export { PluginRegistry } from './core/PluginRegistry.js';
28
+
29
+ // Built-in systems
30
+ export { Renderer } from './renderer/Renderer.js';
31
+ export { Ticker } from './systems/Ticker.js';
32
+ export { Loader, GlobalLoader } from './systems/Loader.js';
33
+ export { Input, GlobalInput } from './systems/Input.js';
34
+ export { Director } from './systems/Director.js';
35
+ export { Physics, GlobalPhysics } from './systems/Physics.js';
36
+
37
+ // Engine-level components
38
+ export { ParticleEmitter, ScreenShake } from './dopamine/components/index.js';
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';
@@ -14,11 +14,18 @@ export class Director {
14
14
  run(scene) {
15
15
  if (this.currentScene) {
16
16
  this.currentScene.onExit();
17
+
18
+ // Its colliders would keep colliding with the next scene.
19
+ this.currentScene.kernel = null;
17
20
  }
18
21
 
19
22
  this.currentScene = scene;
20
23
  this.currentScene.game = this.game;
21
24
 
25
+ if (this.game?.kernel) {
26
+ scene.kernel = this.game.kernel;
27
+ }
28
+
22
29
  // Let the game know (if game engine needs to reference it directly)
23
30
  this.game.scene = scene;
24
31
 
@@ -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
+ );
@@ -9,6 +9,7 @@ export class Ticker {
9
9
  this.callbacks = new Set();
10
10
  this._frameHandle = null;
11
11
  this._tick = this._tick.bind(this);
12
+ this._failed = new WeakSet();
12
13
  }
13
14
 
14
15
  /**
@@ -64,12 +65,22 @@ export class Ticker {
64
65
  const dt = (time - this.lastTime) / 1000; // Delta time in seconds
65
66
  this.lastTime = time;
66
67
 
67
- // Cap dt to prevent huge jumps if tab was inactive
68
- const safeDt = Math.min(dt, 0.1);
68
+ // Cap dt to prevent huge jumps if tab was inactive. Floored at 0: the
69
+ // first frame's timestamp can predate the performance.now() in start().
70
+ const safeDt = Math.max(0, Math.min(dt, 0.1));
69
71
 
70
72
  // Snapshot: a callback may add or remove callbacks mid-frame.
71
73
  for (const callback of [...this.callbacks]) {
72
- callback(safeDt);
74
+ try {
75
+ callback(safeDt);
76
+ } catch (error) {
77
+ // A throw here used to skip the re-queue below with `running`
78
+ // still true, so the loop died and start() refused to revive it.
79
+ if (!this._failed.has(callback)) {
80
+ this._failed.add(callback);
81
+ console.error('[Ticker] Callback threw:', error);
82
+ }
83
+ }
73
84
  }
74
85
 
75
86
  // A callback may have called stop(); don't queue another frame if so.
@@ -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
+ }