dopaminejs 2.0.1 → 2.1.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,16 +3,23 @@
3
3
  * Handles synthesized sound effects using Web Audio API
4
4
  */
5
5
 
6
+ import { resolveStorage } from '../utils/storage.js';
7
+
6
8
  export class SoundManager {
7
9
  constructor(config = {}) {
8
10
  this.audioContext = null;
9
11
  this.storageKey = config.storageKey || 'dopamineSoundsMuted';
10
- this.muted = localStorage.getItem(this.storageKey) === 'true';
12
+ this.storage = config.storage || resolveStorage();
13
+ this.muted = this.storage.getItem(this.storageKey) === 'true';
11
14
 
12
15
  // Asset management
13
16
  this.assets = new Map(); // key -> AudioBuffer
14
17
  this.customSounds = config.customSounds || {}; // key -> url
15
18
 
19
+ // Master gain, created with the AudioContext on first use.
20
+ this.masterGain = null;
21
+ this._volume = config.volume ?? 1;
22
+
16
23
  // Preload custom sounds if provided
17
24
  if (Object.keys(this.customSounds).length > 0) {
18
25
  this.preloadSounds(this.customSounds);
@@ -24,12 +31,53 @@ export class SoundManager {
24
31
  */
25
32
  initAudio() {
26
33
  if (!this.audioContext) {
27
- const AudioContext = window.AudioContext || window.webkitAudioContext;
28
- this.audioContext = new AudioContext();
34
+ const Ctor = typeof window !== 'undefined'
35
+ ? (window.AudioContext || window.webkitAudioContext)
36
+ : undefined;
37
+
38
+ // No Web Audio API: SSR, a worker, or a very old browser. Stay
39
+ // silent rather than taking the whole game down.
40
+ if (!Ctor) return false;
41
+
42
+ this.audioContext = new Ctor();
43
+ this.masterGain = this.audioContext.createGain();
44
+ this.masterGain.gain.value = this._volume;
45
+ this.masterGain.connect(this.audioContext.destination);
29
46
  }
47
+
30
48
  if (this.audioContext.state === 'suspended') {
31
49
  this.audioContext.resume();
32
50
  }
51
+
52
+ return true;
53
+ }
54
+
55
+ /**
56
+ * Set master output volume.
57
+ * @param {number} value - 0..1
58
+ */
59
+ setVolume(value) {
60
+ this._volume = Math.max(0, Math.min(1, value));
61
+ if (this.masterGain) {
62
+ this.masterGain.gain.value = this._volume;
63
+ }
64
+ return this._volume;
65
+ }
66
+
67
+ /**
68
+ * Current master output volume.
69
+ * @returns {number}
70
+ */
71
+ getVolume() {
72
+ return this._volume;
73
+ }
74
+
75
+ /**
76
+ * Node every sound routes through, so volume and mute apply uniformly.
77
+ * @private
78
+ */
79
+ _output() {
80
+ return this.masterGain || this.audioContext.destination;
33
81
  }
34
82
 
35
83
  /**
@@ -38,7 +86,7 @@ export class SoundManager {
38
86
  */
39
87
  toggleMute() {
40
88
  this.muted = !this.muted;
41
- localStorage.setItem(this.storageKey, this.muted);
89
+ this.storage.setItem(this.storageKey, this.muted);
42
90
  return this.muted;
43
91
  }
44
92
 
@@ -92,7 +140,7 @@ export class SoundManager {
92
140
  */
93
141
  async play(key) {
94
142
  if (this.muted) return;
95
- this.initAudio();
143
+ if (!this.initAudio()) return;
96
144
 
97
145
  // 1. Try custom asset first
98
146
  if (this.assets.has(key)) {
@@ -127,7 +175,7 @@ export class SoundManager {
127
175
  _playBuffer(buffer) {
128
176
  const source = this.audioContext.createBufferSource();
129
177
  source.buffer = buffer;
130
- source.connect(this.audioContext.destination);
178
+ source.connect(this._output());
131
179
  source.start(0);
132
180
  }
133
181
 
@@ -136,18 +184,20 @@ export class SoundManager {
136
184
  */
137
185
  playTone(frequency, duration, type = 'sine', volume = 0.3) {
138
186
  if (this.muted) return;
139
- this.initAudio();
187
+ if (!this.initAudio()) return;
140
188
 
141
189
  const oscillator = this.audioContext.createOscillator();
142
190
  const gainNode = this.audioContext.createGain();
143
191
 
144
192
  oscillator.connect(gainNode);
145
- gainNode.connect(this.audioContext.destination);
193
+ gainNode.connect(this._output());
146
194
 
147
195
  oscillator.frequency.value = frequency;
148
196
  oscillator.type = type;
149
197
 
150
- gainNode.gain.setValueAtTime(volume, this.audioContext.currentTime);
198
+ // exponentialRampToValueAtTime cannot reach or start from zero.
199
+ const peak = Math.max(0.0001, volume);
200
+ gainNode.gain.setValueAtTime(peak, this.audioContext.currentTime);
151
201
  gainNode.gain.exponentialRampToValueAtTime(0.01, this.audioContext.currentTime + duration);
152
202
 
153
203
  oscillator.start(this.audioContext.currentTime);
@@ -3,9 +3,11 @@
3
3
  * Handles local storage persistence
4
4
  */
5
5
 
6
+ import { resolveStorage } from '../utils/storage.js';
7
+
6
8
  export class DataService {
7
9
  constructor(config = {}) {
8
- this.storage = config.storage || window.localStorage;
10
+ this.storage = config.storage || resolveStorage();
9
11
  this.prefix = config.prefix || 'dopamine_';
10
12
  }
11
13
 
@@ -18,7 +18,8 @@ export class RewardSystem extends EventEmitter {
18
18
  * @returns {Promise<Object>} Player data
19
19
  */
20
20
  async init() {
21
- this.player = await this.dataService.load('player', this._getDefaultPlayer());
21
+ const saved = await this.dataService.load('player', null);
22
+ this.player = this._migrate(saved);
22
23
 
23
24
  // Check daily streak
24
25
  this._updateDailyStreak();
@@ -52,6 +53,33 @@ export class RewardSystem extends EventEmitter {
52
53
  };
53
54
  }
54
55
 
56
+ /**
57
+ * Merge a persisted player record onto the current default shape.
58
+ *
59
+ * Saves written by older versions are missing whatever fields were added
60
+ * since. Without this the first property access on a new field throws
61
+ * before the game has drawn a frame.
62
+ *
63
+ * @param {Object|null} saved - Previously persisted player, if any
64
+ * @returns {Object} Player object guaranteed to have every field
65
+ * @private
66
+ */
67
+ _migrate(saved) {
68
+ const defaults = this._getDefaultPlayer();
69
+
70
+ if (!saved || typeof saved !== 'object') {
71
+ return defaults;
72
+ }
73
+
74
+ return {
75
+ ...defaults,
76
+ ...saved,
77
+ streak: { ...defaults.streak, ...(saved.streak || {}) },
78
+ achievements: saved.achievements || {},
79
+ stats: saved.stats || {}
80
+ };
81
+ }
82
+
55
83
  /**
56
84
  * Save player data
57
85
  */
@@ -69,6 +97,12 @@ export class RewardSystem extends EventEmitter {
69
97
  * @returns {Object} { leveledUp: boolean, newLevel: number, xpGained: number }
70
98
  */
71
99
  async addXP(amount, reason = '') {
100
+ if (!Number.isFinite(amount)) {
101
+ // Adding undefined/NaN here would poison player.xp permanently:
102
+ // NaN survives every subsequent arithmetic op and gets persisted.
103
+ throw new TypeError(`[DopamineJS] addXP expects a finite number, received ${amount}`);
104
+ }
105
+
72
106
  const oldLevel = this.player.level;
73
107
  this.player.xp += amount;
74
108
 
@@ -96,9 +130,9 @@ export class RewardSystem extends EventEmitter {
96
130
  * Calculate level from XP (exponential curve)
97
131
  */
98
132
  _calculateLevel(xp) {
99
- // Formula: XP = 50 * level * (level - 1)
100
- // Solving for level: level = (1 + sqrt(1 + 8*XP/50)) / 2
101
- const level = Math.floor((1 + Math.sqrt(1 + 8 * xp / 50)) / 2);
133
+ // Curve: XP = 50 * level * (level - 1)
134
+ // 50L² - 50L - xp = 0 => L = (1 + sqrt(1 + 4*xp/50)) / 2
135
+ const level = Math.floor((1 + Math.sqrt(1 + 4 * xp / 50)) / 2);
102
136
  return Math.max(1, level);
103
137
  }
104
138
 
@@ -109,10 +143,19 @@ export class RewardSystem extends EventEmitter {
109
143
  const nextLevel = this.player.level + 1;
110
144
  const xpNeeded = 50 * nextLevel * (nextLevel - 1);
111
145
  const currentLevelXP = 50 * this.player.level * (this.player.level - 1);
146
+ const band = xpNeeded - currentLevelXP;
147
+
148
+ // Clamped: a save written before the curve fix banked levels at half
149
+ // the XP, so player.xp can sit below its own level's floor. Those
150
+ // players keep the level they earned and start the band at 0.
151
+ const progress = band > 0
152
+ ? Math.max(0, Math.min(1, (this.player.xp - currentLevelXP) / band))
153
+ : 0;
154
+
112
155
  return {
113
156
  total: xpNeeded,
114
- needed: xpNeeded - this.player.xp,
115
- progress: (this.player.xp - currentLevelXP) / (xpNeeded - currentLevelXP)
157
+ needed: Math.max(0, xpNeeded - this.player.xp),
158
+ progress
116
159
  };
117
160
  }
118
161
 
@@ -175,8 +218,17 @@ export class RewardSystem extends EventEmitter {
175
218
  // Skip if already unlocked
176
219
  if (this.player.achievements[id]) continue;
177
220
 
178
- // Check if achievement condition is met
179
- if (achievement.check(this.player, gameName, result)) {
221
+ // Check conditions are user-supplied; one throwing must not stop
222
+ // the others from ever unlocking.
223
+ let met = false;
224
+ try {
225
+ met = achievement.check(this.player, gameName, result);
226
+ } catch (error) {
227
+ console.error(`[DopamineJS] Achievement "${id}" check threw:`, error);
228
+ continue;
229
+ }
230
+
231
+ if (met) {
180
232
  await this.unlockAchievement(id);
181
233
  unlockedAchievements.push(achievement);
182
234
  }
@@ -198,8 +250,9 @@ export class RewardSystem extends EventEmitter {
198
250
  seen: false
199
251
  };
200
252
 
201
- // Award XP
202
- await this.addXP(achievement.xp, `Achievement: ${achievement.name}`);
253
+ // Award XP. `xp` is optional in a user-defined achievement.
254
+ await this.addXP(Number.isFinite(achievement.xp) ? achievement.xp : 0,
255
+ `Achievement: ${achievement.name}`);
203
256
 
204
257
  this.emit('achievement_unlocked', achievement);
205
258
  await this.save();
@@ -278,19 +331,37 @@ export class RewardSystem extends EventEmitter {
278
331
  }
279
332
 
280
333
  /**
281
- * Get today's date as YYYY-MM-DD string
334
+ * Format a Date as a YYYY-MM-DD string in the player's local timezone.
335
+ *
336
+ * Deliberately not toISOString(), which formats in UTC. For anyone east or
337
+ * west of Greenwich that shifts the calendar day for part of every day and
338
+ * makes streaks reset or double-count.
339
+ *
340
+ * @param {Date} date
341
+ * @returns {string}
342
+ * @private
343
+ */
344
+ _toLocalDateString(date) {
345
+ const year = date.getFullYear();
346
+ const month = String(date.getMonth() + 1).padStart(2, '0');
347
+ const day = String(date.getDate()).padStart(2, '0');
348
+ return `${year}-${month}-${day}`;
349
+ }
350
+
351
+ /**
352
+ * Get today's local date as YYYY-MM-DD string
282
353
  */
283
354
  _getTodayDateString() {
284
- return new Date().toISOString().split('T')[0];
355
+ return this._toLocalDateString(new Date());
285
356
  }
286
357
 
287
358
  /**
288
- * Get yesterday's date as YYYY-MM-DD string
359
+ * Get yesterday's local date as YYYY-MM-DD string
289
360
  */
290
361
  _getYesterdayDateString() {
291
362
  const yesterday = new Date();
292
363
  yesterday.setDate(yesterday.getDate() - 1);
293
- return yesterday.toISOString().split('T')[0];
364
+ return this._toLocalDateString(yesterday);
294
365
  }
295
366
 
296
367
  /**
@@ -10,32 +10,34 @@ export class ParticleSystem {
10
10
  : document.body;
11
11
 
12
12
  this.canvasId = config.canvasId || 'dopamine-particle-canvas';
13
- this.canvas = document.getElementById(this.canvasId);
14
13
 
15
- if (!this.canvas) {
16
- this.canvas = document.createElement('canvas');
14
+ // Each instance owns its canvas. Sharing one by id meant two systems
15
+ // cleared each other's frames every tick.
16
+ this.canvas = document.createElement('canvas');
17
+ this.canvas.classList.add('dopamine-particle-canvas');
18
+ if (!document.getElementById(this.canvasId)) {
17
19
  this.canvas.id = this.canvasId;
18
- this.canvas.style.pointerEvents = 'none';
19
- this.canvas.style.zIndex = config.zIndex || '9999';
20
+ }
21
+ this.canvas.style.pointerEvents = 'none';
22
+ this.canvas.style.zIndex = config.zIndex || '9999';
20
23
 
21
- if (this.container === document.body) {
22
- this.canvas.style.position = 'fixed';
23
- this.canvas.style.top = '0';
24
- this.canvas.style.left = '0';
25
- } else {
26
- this.canvas.style.position = 'absolute';
27
- this.canvas.style.top = '0';
28
- this.canvas.style.left = '0';
29
- // Ensure container is positioned
30
- const style = window.getComputedStyle(this.container);
31
- if (style.position === 'static') {
32
- this.container.style.position = 'relative';
33
- }
24
+ if (this.container === document.body) {
25
+ this.canvas.style.position = 'fixed';
26
+ this.canvas.style.top = '0';
27
+ this.canvas.style.left = '0';
28
+ } else {
29
+ this.canvas.style.position = 'absolute';
30
+ this.canvas.style.top = '0';
31
+ this.canvas.style.left = '0';
32
+ // Ensure container is positioned
33
+ const style = window.getComputedStyle(this.container);
34
+ if (style.position === 'static') {
35
+ this.container.style.position = 'relative';
34
36
  }
35
-
36
- this.container.appendChild(this.canvas);
37
37
  }
38
38
 
39
+ this.container.appendChild(this.canvas);
40
+
39
41
  this.ctx = this.canvas.getContext('2d');
40
42
  this.particles = [];
41
43
  this.pool = []; // Object pool
@@ -43,23 +45,44 @@ export class ParticleSystem {
43
45
  this.customEffects = new Map(); // key -> callback
44
46
  this.isAnimating = false;
45
47
 
48
+ // Kept on the instance so destroy() can actually detach them. An
49
+ // inline arrow passed to addEventListener can never be removed.
50
+ this._onResize = () => this._resize();
51
+ this._resizeObserver = null;
52
+ this._frameHandle = null;
53
+
46
54
  this._resize();
47
55
  // Use ResizeObserver for container resizing if supported, fallback to window resize
48
56
  if (window.ResizeObserver && this.container !== document.body) {
49
- new ResizeObserver(() => this._resize()).observe(this.container);
57
+ this._resizeObserver = new ResizeObserver(this._onResize);
58
+ this._resizeObserver.observe(this.container);
50
59
  } else {
51
- window.addEventListener('resize', () => this._resize());
60
+ window.addEventListener('resize', this._onResize);
52
61
  }
53
62
  }
54
63
 
55
64
  _resize() {
56
- if (this.container === document.body) {
57
- this.canvas.width = window.innerWidth;
58
- this.canvas.height = window.innerHeight;
59
- } else {
60
- this.canvas.width = this.container.clientWidth;
61
- this.canvas.height = this.container.clientHeight;
62
- }
65
+ const cssWidth = this.container === document.body
66
+ ? window.innerWidth
67
+ : this.container.clientWidth;
68
+ const cssHeight = this.container === document.body
69
+ ? window.innerHeight
70
+ : this.container.clientHeight;
71
+
72
+ // Back the canvas with device pixels but keep the drawing coordinate
73
+ // system in CSS pixels, so particle positions stay in page units and
74
+ // nothing looks soft on a HiDPI display.
75
+ const dpr = window.devicePixelRatio || 1;
76
+
77
+ this.width = cssWidth;
78
+ this.height = cssHeight;
79
+
80
+ this.canvas.width = Math.round(cssWidth * dpr);
81
+ this.canvas.height = Math.round(cssHeight * dpr);
82
+ this.canvas.style.width = `${cssWidth}px`;
83
+ this.canvas.style.height = `${cssHeight}px`;
84
+
85
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
63
86
  }
64
87
 
65
88
  /**
@@ -237,7 +260,9 @@ export class ParticleSystem {
237
260
  }
238
261
 
239
262
  _animate() {
240
- this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
263
+ this._frameHandle = null;
264
+ // CSS pixels: the context is pre-scaled by devicePixelRatio.
265
+ this.ctx.clearRect(0, 0, this.width, this.height);
241
266
 
242
267
  for (let i = this.particles.length - 1; i >= 0; i--) {
243
268
  const p = this.particles[i];
@@ -288,7 +313,7 @@ export class ParticleSystem {
288
313
  }
289
314
 
290
315
  if (this.particles.length > 0) {
291
- requestAnimationFrame(() => this._animate());
316
+ this._frameHandle = requestAnimationFrame(() => this._animate());
292
317
  } else {
293
318
  this.isAnimating = false;
294
319
  }
@@ -322,6 +347,33 @@ export class ParticleSystem {
322
347
  // Recycle all
323
348
  this.particles.forEach(p => this._recycleParticle(p));
324
349
  this.particles = [];
325
- this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
350
+ this.ctx.clearRect(0, 0, this.width, this.height);
351
+ }
352
+
353
+ /**
354
+ * Stop animating, detach resize handling, and remove the canvas.
355
+ *
356
+ * Without this the resize listener and canvas outlive the instance, which
357
+ * matters in any single-page app that mounts and unmounts a game view.
358
+ */
359
+ destroy() {
360
+ this.clear();
361
+ this.isAnimating = false;
362
+
363
+ if (this._frameHandle !== null) {
364
+ cancelAnimationFrame(this._frameHandle);
365
+ this._frameHandle = null;
366
+ }
367
+
368
+ if (this._resizeObserver) {
369
+ this._resizeObserver.disconnect();
370
+ this._resizeObserver = null;
371
+ }
372
+ window.removeEventListener('resize', this._onResize);
373
+
374
+ this.canvas.remove();
375
+ this.sprites.clear();
376
+ this.customEffects.clear();
377
+ this.pool = [];
326
378
  }
327
379
  }
@@ -24,7 +24,6 @@ export default class Dopamine {
24
24
 
25
25
  async init() {
26
26
  await this.rewardSystem.init();
27
- console.log('DopamineJS Initialized 🚀');
28
27
  return {
29
28
  rewardSystem: this.rewardSystem,
30
29
  gameUI: this.gameUI,