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.
@@ -6,11 +6,62 @@
6
6
  import { EventEmitter } from './EventEmitter.js';
7
7
 
8
8
  export class RewardSystem extends EventEmitter {
9
+ /**
10
+ * @param {Object} dataService - Persistence, see DataService
11
+ * @param {Object} [config]
12
+ * @param {Object} [config.achievements] - Extra achievement definitions
13
+ * @param {Object} [config.events] - Kernel EventBus to mirror events onto
14
+ * @param {Object} [config.kernel] - Kernel; its `events` bus is used if present
15
+ */
9
16
  constructor(dataService, config = {}) {
10
17
  super();
11
18
  this.dataService = dataService;
12
19
  this.player = null;
13
20
  this.achievements = { ...this._initAchievements(), ...(config.achievements || {}) };
21
+
22
+ // Optional bridge to the kernel bus. EventBus.Events already declared
23
+ // XP_GAINED, LEVEL_UP and ACHIEVEMENT_UNLOCKED with nothing emitting
24
+ // them; this connects the two event systems without changing the
25
+ // existing rewardSystem.on(...) surface that games already use.
26
+ this.events = config.events || config.kernel?.events || null;
27
+
28
+ // Write batching. Every mutation used to hit storage directly, so one
29
+ // recordGame produced four full serializations of the player object.
30
+ this._batchDepth = 0;
31
+ this._pendingSave = false;
32
+ }
33
+
34
+ /**
35
+ * Emit locally, and mirror onto the kernel bus when one is attached.
36
+ * @private
37
+ */
38
+ _publish(event, data) {
39
+ this.emit(event, data);
40
+ if (this.events) {
41
+ this.events.emit(event, data);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Run `fn` with saves collapsed into a single write at the end.
47
+ *
48
+ * Nested batches are counted, so an inner operation that batches on its
49
+ * own still results in exactly one write for the outermost call.
50
+ *
51
+ * @param {Function} fn
52
+ * @private
53
+ */
54
+ async _batch(fn) {
55
+ this._batchDepth++;
56
+ try {
57
+ return await fn();
58
+ } finally {
59
+ this._batchDepth--;
60
+ if (this._batchDepth === 0 && this._pendingSave) {
61
+ this._pendingSave = false;
62
+ await this._write();
63
+ }
64
+ }
14
65
  }
15
66
 
16
67
  /**
@@ -18,7 +69,8 @@ export class RewardSystem extends EventEmitter {
18
69
  * @returns {Promise<Object>} Player data
19
70
  */
20
71
  async init() {
21
- this.player = await this.dataService.load('player', this._getDefaultPlayer());
72
+ const saved = await this.dataService.load('player', null);
73
+ this.player = this._migrate(saved);
22
74
 
23
75
  // Check daily streak
24
76
  this._updateDailyStreak();
@@ -52,14 +104,55 @@ export class RewardSystem extends EventEmitter {
52
104
  };
53
105
  }
54
106
 
107
+ /**
108
+ * Merge a persisted player record onto the current default shape.
109
+ *
110
+ * Saves written by older versions are missing whatever fields were added
111
+ * since. Without this the first property access on a new field throws
112
+ * before the game has drawn a frame.
113
+ *
114
+ * @param {Object|null} saved - Previously persisted player, if any
115
+ * @returns {Object} Player object guaranteed to have every field
116
+ * @private
117
+ */
118
+ _migrate(saved) {
119
+ const defaults = this._getDefaultPlayer();
120
+
121
+ if (!saved || typeof saved !== 'object') {
122
+ return defaults;
123
+ }
124
+
125
+ return {
126
+ ...defaults,
127
+ ...saved,
128
+ streak: { ...defaults.streak, ...(saved.streak || {}) },
129
+ achievements: saved.achievements || {},
130
+ stats: saved.stats || {}
131
+ };
132
+ }
133
+
55
134
  /**
56
135
  * Save player data
57
136
  */
58
137
  async save() {
59
- if (this.player) {
60
- this.player.lastPlayedAt = Date.now();
61
- await this.dataService.save('player', this.player);
138
+ if (!this.player) return;
139
+
140
+ // Inside a batch, mark dirty and let the outermost call do the write.
141
+ if (this._batchDepth > 0) {
142
+ this._pendingSave = true;
143
+ return;
62
144
  }
145
+
146
+ await this._write();
147
+ }
148
+
149
+ /**
150
+ * The actual persistence call. Bypasses batching.
151
+ * @private
152
+ */
153
+ async _write() {
154
+ this.player.lastPlayedAt = Date.now();
155
+ await this.dataService.save('player', this.player);
63
156
  }
64
157
 
65
158
  /**
@@ -69,6 +162,12 @@ export class RewardSystem extends EventEmitter {
69
162
  * @returns {Object} { leveledUp: boolean, newLevel: number, xpGained: number }
70
163
  */
71
164
  async addXP(amount, reason = '') {
165
+ if (!Number.isFinite(amount)) {
166
+ // Adding undefined/NaN here would poison player.xp permanently:
167
+ // NaN survives every subsequent arithmetic op and gets persisted.
168
+ throw new TypeError(`[DopamineJS] addXP expects a finite number, received ${amount}`);
169
+ }
170
+
72
171
  const oldLevel = this.player.level;
73
172
  this.player.xp += amount;
74
173
 
@@ -83,10 +182,10 @@ export class RewardSystem extends EventEmitter {
83
182
  await this.save();
84
183
 
85
184
  // Notify listeners
86
- this.emit('xp_gained', { amount, reason, leveledUp, newLevel });
185
+ this._publish('xp_gained', { amount, reason, leveledUp, newLevel });
87
186
 
88
187
  if (leveledUp) {
89
- this.emit('level_up', { oldLevel, newLevel });
188
+ this._publish('level_up', { oldLevel, newLevel });
90
189
  }
91
190
 
92
191
  return { leveledUp, newLevel, xpGained: amount };
@@ -96,9 +195,9 @@ export class RewardSystem extends EventEmitter {
96
195
  * Calculate level from XP (exponential curve)
97
196
  */
98
197
  _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);
198
+ // Curve: XP = 50 * level * (level - 1)
199
+ // 50L² - 50L - xp = 0 => L = (1 + sqrt(1 + 4*xp/50)) / 2
200
+ const level = Math.floor((1 + Math.sqrt(1 + 4 * xp / 50)) / 2);
102
201
  return Math.max(1, level);
103
202
  }
104
203
 
@@ -109,10 +208,19 @@ export class RewardSystem extends EventEmitter {
109
208
  const nextLevel = this.player.level + 1;
110
209
  const xpNeeded = 50 * nextLevel * (nextLevel - 1);
111
210
  const currentLevelXP = 50 * this.player.level * (this.player.level - 1);
211
+ const band = xpNeeded - currentLevelXP;
212
+
213
+ // Clamped: a save written before the curve fix banked levels at half
214
+ // the XP, so player.xp can sit below its own level's floor. Those
215
+ // players keep the level they earned and start the band at 0.
216
+ const progress = band > 0
217
+ ? Math.max(0, Math.min(1, (this.player.xp - currentLevelXP) / band))
218
+ : 0;
219
+
112
220
  return {
113
221
  total: xpNeeded,
114
- needed: xpNeeded - this.player.xp,
115
- progress: (this.player.xp - currentLevelXP) / (xpNeeded - currentLevelXP)
222
+ needed: Math.max(0, xpNeeded - this.player.xp),
223
+ progress
116
224
  };
117
225
  }
118
226
 
@@ -122,6 +230,15 @@ export class RewardSystem extends EventEmitter {
122
230
  * @param {Object} result - Game-specific result data
123
231
  */
124
232
  async recordGame(gameName, result) {
233
+ // Batched: addXP and each unlockAchievement below also save, which
234
+ // meant four full serializations of the player object per game.
235
+ return this._batch(() => this._recordGame(gameName, result));
236
+ }
237
+
238
+ /**
239
+ * @private
240
+ */
241
+ async _recordGame(gameName, result) {
125
242
  // Initialize stats for this game if not exists
126
243
  if (!this.player.stats[gameName]) {
127
244
  this.player.stats[gameName] = { totalPlays: 0, highScore: 0 };
@@ -136,7 +253,7 @@ export class RewardSystem extends EventEmitter {
136
253
  // Update high score if applicable
137
254
  if (result.score !== undefined && result.score > stats.highScore) {
138
255
  stats.highScore = result.score;
139
- this.emit('new_high_score', { gameName, score: result.score });
256
+ this._publish('new_high_score', { gameName, score: result.score });
140
257
  }
141
258
 
142
259
  // Merge other result data into stats
@@ -175,8 +292,17 @@ export class RewardSystem extends EventEmitter {
175
292
  // Skip if already unlocked
176
293
  if (this.player.achievements[id]) continue;
177
294
 
178
- // Check if achievement condition is met
179
- if (achievement.check(this.player, gameName, result)) {
295
+ // Check conditions are user-supplied; one throwing must not stop
296
+ // the others from ever unlocking.
297
+ let met = false;
298
+ try {
299
+ met = achievement.check(this.player, gameName, result);
300
+ } catch (error) {
301
+ console.error(`[DopamineJS] Achievement "${id}" check threw:`, error);
302
+ continue;
303
+ }
304
+
305
+ if (met) {
180
306
  await this.unlockAchievement(id);
181
307
  unlockedAchievements.push(achievement);
182
308
  }
@@ -198,10 +324,11 @@ export class RewardSystem extends EventEmitter {
198
324
  seen: false
199
325
  };
200
326
 
201
- // Award XP
202
- await this.addXP(achievement.xp, `Achievement: ${achievement.name}`);
327
+ // Award XP. `xp` is optional in a user-defined achievement.
328
+ await this.addXP(Number.isFinite(achievement.xp) ? achievement.xp : 0,
329
+ `Achievement: ${achievement.name}`);
203
330
 
204
- this.emit('achievement_unlocked', achievement);
331
+ this._publish('achievement_unlocked', achievement);
205
332
  await this.save();
206
333
 
207
334
  return true;
@@ -278,19 +405,37 @@ export class RewardSystem extends EventEmitter {
278
405
  }
279
406
 
280
407
  /**
281
- * Get today's date as YYYY-MM-DD string
408
+ * Format a Date as a YYYY-MM-DD string in the player's local timezone.
409
+ *
410
+ * Deliberately not toISOString(), which formats in UTC. For anyone east or
411
+ * west of Greenwich that shifts the calendar day for part of every day and
412
+ * makes streaks reset or double-count.
413
+ *
414
+ * @param {Date} date
415
+ * @returns {string}
416
+ * @private
417
+ */
418
+ _toLocalDateString(date) {
419
+ const year = date.getFullYear();
420
+ const month = String(date.getMonth() + 1).padStart(2, '0');
421
+ const day = String(date.getDate()).padStart(2, '0');
422
+ return `${year}-${month}-${day}`;
423
+ }
424
+
425
+ /**
426
+ * Get today's local date as YYYY-MM-DD string
282
427
  */
283
428
  _getTodayDateString() {
284
- return new Date().toISOString().split('T')[0];
429
+ return this._toLocalDateString(new Date());
285
430
  }
286
431
 
287
432
  /**
288
- * Get yesterday's date as YYYY-MM-DD string
433
+ * Get yesterday's local date as YYYY-MM-DD string
289
434
  */
290
435
  _getYesterdayDateString() {
291
436
  const yesterday = new Date();
292
437
  yesterday.setDate(yesterday.getDate() - 1);
293
- return yesterday.toISOString().split('T')[0];
438
+ return this._toLocalDateString(yesterday);
294
439
  }
295
440
 
296
441
  /**
@@ -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
  }