dopaminejs 2.0.2 → 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.
@@ -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
  }