dopaminejs 2.2.0 → 2.3.1

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.
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Data Service Module
3
3
  * Handles local storage persistence
4
+ *
5
+ * The storage may be synchronous (localStorage) or return promises
6
+ * (AsyncStorage, an IndexedDB wrapper, a server call). Every call is awaited.
4
7
  */
5
8
 
6
9
  import { resolveStorage } from '../utils/storage.js';
@@ -9,6 +12,30 @@ export class DataService {
9
12
  constructor(config = {}) {
10
13
  this.storage = config.storage || resolveStorage();
11
14
  this.prefix = config.prefix || 'dopamine_';
15
+
16
+ // Last write still in flight on promise storage, or null.
17
+ this._tail = null;
18
+ }
19
+
20
+ /**
21
+ * Run a write after any write still in flight.
22
+ *
23
+ * Promise storage can finish two overlapping writes in either order, and
24
+ * the older value would win. Synchronous storage never has one in flight,
25
+ * so it still writes before this returns.
26
+ * @private
27
+ */
28
+ _write(op) {
29
+ const result = this._tail ? this._tail.then(op) : op();
30
+
31
+ if (typeof result?.then === 'function') {
32
+ const tail = Promise.resolve(result).catch(() => {}).then(() => {
33
+ if (this._tail === tail) this._tail = null;
34
+ });
35
+ this._tail = tail;
36
+ }
37
+
38
+ return result;
12
39
  }
13
40
 
14
41
  /**
@@ -19,7 +46,7 @@ export class DataService {
19
46
  async save(key, data) {
20
47
  try {
21
48
  const serialized = JSON.stringify(data);
22
- this.storage.setItem(this.prefix + key, serialized);
49
+ await this._write(() => this.storage.setItem(this.prefix + key, serialized));
23
50
  return true;
24
51
  } catch (e) {
25
52
  console.error('Error saving data:', e);
@@ -34,7 +61,8 @@ export class DataService {
34
61
  */
35
62
  async load(key, defaultValue = null) {
36
63
  try {
37
- const data = this.storage.getItem(this.prefix + key);
64
+ await this._tail;
65
+ const data = await this.storage.getItem(this.prefix + key);
38
66
  return data ? JSON.parse(data) : defaultValue;
39
67
  } catch (e) {
40
68
  console.error('Error loading data:', e);
@@ -47,6 +75,6 @@ export class DataService {
47
75
  * @param {string} key
48
76
  */
49
77
  async clear(key) {
50
- this.storage.removeItem(this.prefix + key);
78
+ await this._write(() => this.storage.removeItem(this.prefix + key));
51
79
  }
52
80
  }
@@ -5,6 +5,19 @@
5
5
 
6
6
  import { EventEmitter } from './EventEmitter.js';
7
7
 
8
+ // Keys that resolve to something on Object.prototype. Game names and
9
+ // achievement ids index plain objects, so these must never be used as one.
10
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
11
+
12
+ const isSafeKey = (key) => typeof key === 'string' && key.length > 0 && !UNSAFE_KEYS.has(key);
13
+ const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
14
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
15
+
16
+ const toCount = (value, fallback, min = 0) => {
17
+ const number = Number(value);
18
+ return Number.isInteger(number) && number >= min ? number : fallback;
19
+ };
20
+
8
21
  export class RewardSystem extends EventEmitter {
9
22
  /**
10
23
  * @param {Object} dataService - Persistence, see DataService
@@ -73,14 +86,35 @@ export class RewardSystem extends EventEmitter {
73
86
  this.player = this._migrate(saved);
74
87
 
75
88
  // Check daily streak
76
- this._updateDailyStreak();
89
+ const streakChanged = this._updateDailyStreak();
77
90
 
78
91
  // Save updated player data
79
92
  await this.save();
80
93
 
94
+ if (streakChanged) {
95
+ this._publishStreak();
96
+ }
97
+
81
98
  return this.player;
82
99
  }
83
100
 
101
+ /**
102
+ * @private
103
+ */
104
+ _requirePlayer() {
105
+ if (!this.player) {
106
+ throw new Error('[DopamineJS] RewardSystem.init() must be awaited before use');
107
+ }
108
+ }
109
+
110
+ /**
111
+ * @private
112
+ */
113
+ _publishStreak() {
114
+ const { current, longest } = this.player.streak;
115
+ this._publish('streak_updated', { current, longest });
116
+ }
117
+
84
118
  /**
85
119
  * Get default player data structure
86
120
  */
@@ -118,19 +152,91 @@ export class RewardSystem extends EventEmitter {
118
152
  _migrate(saved) {
119
153
  const defaults = this._getDefaultPlayer();
120
154
 
121
- if (!saved || typeof saved !== 'object') {
155
+ if (!isRecord(saved)) {
122
156
  return defaults;
123
157
  }
124
158
 
159
+ // Storage is writable by the player and by anything else on the
160
+ // origin, so every field is checked. A string xp would concatenate on
161
+ // the next addXP, and a non-object stats would throw on first use.
162
+ const xpValue = Number(saved.xp);
163
+ const xp = Number.isFinite(xpValue) && xpValue >= 0 ? xpValue : 0;
164
+ const earned = this._calculateLevel(xp);
165
+
166
+ const savedStreak = isRecord(saved.streak) ? saved.streak : {};
167
+ const current = toCount(savedStreak.current, 1, 1);
168
+ const lastPlayDate = typeof savedStreak.lastPlayDate === 'string'
169
+ && /^\d{4}-\d{2}-\d{2}$/.test(savedStreak.lastPlayDate)
170
+ ? savedStreak.lastPlayDate
171
+ : defaults.streak.lastPlayDate;
172
+
125
173
  return {
126
174
  ...defaults,
127
175
  ...saved,
128
- streak: { ...defaults.streak, ...(saved.streak || {}) },
129
- achievements: saved.achievements || {},
130
- stats: saved.stats || {}
176
+ name: typeof saved.name === 'string' ? saved.name : defaults.name,
177
+ xp,
178
+ // A level above the curve is kept: older versions banked levels
179
+ // at half the XP and those players keep what they earned.
180
+ level: Math.max(earned, toCount(saved.level, earned, 1)),
181
+ totalGamesPlayed: toCount(saved.totalGamesPlayed, 0),
182
+ createdAt: Number.isFinite(saved.createdAt) ? saved.createdAt : defaults.createdAt,
183
+ lastPlayedAt: Number.isFinite(saved.lastPlayedAt) ? saved.lastPlayedAt : defaults.lastPlayedAt,
184
+ streak: {
185
+ current,
186
+ longest: Math.max(current, toCount(savedStreak.longest, current, 1)),
187
+ lastPlayDate
188
+ },
189
+ achievements: this._migrateAchievements(saved.achievements),
190
+ stats: this._migrateStats(saved.stats)
131
191
  };
132
192
  }
133
193
 
194
+ /**
195
+ * @private
196
+ */
197
+ _migrateAchievements(saved) {
198
+ const achievements = {};
199
+ if (!isRecord(saved)) return achievements;
200
+
201
+ for (const id of Object.keys(saved)) {
202
+ const entry = saved[id];
203
+ if (!isSafeKey(id) || !isRecord(entry)) continue;
204
+
205
+ achievements[id] = {
206
+ unlockedAt: Number.isFinite(entry.unlockedAt) ? entry.unlockedAt : Date.now(),
207
+ seen: entry.seen === true
208
+ };
209
+ }
210
+
211
+ return achievements;
212
+ }
213
+
214
+ /**
215
+ * @private
216
+ */
217
+ _migrateStats(saved) {
218
+ const stats = {};
219
+ if (!isRecord(saved)) return stats;
220
+
221
+ for (const gameName of Object.keys(saved)) {
222
+ const entry = saved[gameName];
223
+ if (!isSafeKey(gameName) || !isRecord(entry)) continue;
224
+
225
+ const game = {};
226
+ for (const key of Object.keys(entry)) {
227
+ if (isSafeKey(key) && Number.isFinite(entry[key])) {
228
+ game[key] = entry[key];
229
+ }
230
+ }
231
+ game.totalPlays = toCount(entry.totalPlays, 0);
232
+ game.highScore = Number.isFinite(entry.highScore) ? entry.highScore : 0;
233
+
234
+ stats[gameName] = game;
235
+ }
236
+
237
+ return stats;
238
+ }
239
+
134
240
  /**
135
241
  * Save player data
136
242
  */
@@ -152,7 +258,13 @@ export class RewardSystem extends EventEmitter {
152
258
  */
153
259
  async _write() {
154
260
  this.player.lastPlayedAt = Date.now();
155
- await this.dataService.save('player', this.player);
261
+ const saved = await this.dataService.save('player', this.player);
262
+
263
+ // DataService logs and returns false rather than throwing, so a full
264
+ // or blocked storage would otherwise lose progress without a sign.
265
+ if (saved === false) {
266
+ this._publish('save_failed', { key: 'player' });
267
+ }
156
268
  }
157
269
 
158
270
  /**
@@ -168,11 +280,18 @@ export class RewardSystem extends EventEmitter {
168
280
  throw new TypeError(`[DopamineJS] addXP expects a finite number, received ${amount}`);
169
281
  }
170
282
 
283
+ this._requirePlayer();
284
+
171
285
  const oldLevel = this.player.level;
286
+
287
+ // XP stops at zero, so a large negative amount removes less than
288
+ // asked. Report what changed.
289
+ amount = Math.max(amount, -this.player.xp);
172
290
  this.player.xp += amount;
173
291
 
174
- // Check for level up
175
- const newLevel = this._calculateLevel(this.player.xp);
292
+ // Check for level up. Levels are never taken back, so a negative
293
+ // amount or a level banked under an older curve reports the stored one.
294
+ const newLevel = Math.max(oldLevel, this._calculateLevel(this.player.xp));
176
295
  const leveledUp = newLevel > oldLevel;
177
296
 
178
297
  if (leveledUp) {
@@ -182,10 +301,11 @@ export class RewardSystem extends EventEmitter {
182
301
  await this.save();
183
302
 
184
303
  // Notify listeners
185
- this._publish('xp_gained', { amount, reason, leveledUp, newLevel });
304
+ const total = this.player.xp;
305
+ this._publish('xp_gained', { amount, reason, leveledUp, newLevel, total });
186
306
 
187
307
  if (leveledUp) {
188
- this._publish('level_up', { oldLevel, newLevel });
308
+ this._publish('level_up', { oldLevel, newLevel, totalXP: total });
189
309
  }
190
310
 
191
311
  return { leveledUp, newLevel, xpGained: amount };
@@ -197,6 +317,8 @@ export class RewardSystem extends EventEmitter {
197
317
  _calculateLevel(xp) {
198
318
  // Curve: XP = 50 * level * (level - 1)
199
319
  // 50L² - 50L - xp = 0 => L = (1 + sqrt(1 + 4*xp/50)) / 2
320
+ if (!(xp > 0)) return 1;
321
+
200
322
  const level = Math.floor((1 + Math.sqrt(1 + 4 * xp / 50)) / 2);
201
323
  return Math.max(1, level);
202
324
  }
@@ -205,6 +327,8 @@ export class RewardSystem extends EventEmitter {
205
327
  * Get XP needed for next level
206
328
  */
207
329
  getXPForNextLevel() {
330
+ this._requirePlayer();
331
+
208
332
  const nextLevel = this.player.level + 1;
209
333
  const xpNeeded = 50 * nextLevel * (nextLevel - 1);
210
334
  const currentLevelXP = 50 * this.player.level * (this.player.level - 1);
@@ -229,7 +353,23 @@ export class RewardSystem extends EventEmitter {
229
353
  * @param {string} gameName - Unique ID for the game
230
354
  * @param {Object} result - Game-specific result data
231
355
  */
232
- async recordGame(gameName, result) {
356
+ async recordGame(gameName, result = {}) {
357
+ this._requirePlayer();
358
+
359
+ if (!isSafeKey(gameName)) {
360
+ throw new TypeError(`[DopamineJS] recordGame expects a game name, received ${String(gameName)}`);
361
+ }
362
+
363
+ if (!isRecord(result)) {
364
+ throw new TypeError('[DopamineJS] recordGame expects a result object');
365
+ }
366
+
367
+ // Checked before anything is touched, so a bad score cannot leave the
368
+ // play counted and the XP missing.
369
+ if (result.score !== undefined && !Number.isFinite(result.score)) {
370
+ throw new TypeError(`[DopamineJS] recordGame expects a finite score, received ${String(result.score)}`);
371
+ }
372
+
233
373
  // Batched: addXP and each unlockAchievement below also save, which
234
374
  // meant four full serializations of the player object per game.
235
375
  return this._batch(() => this._recordGame(gameName, result));
@@ -239,8 +379,14 @@ export class RewardSystem extends EventEmitter {
239
379
  * @private
240
380
  */
241
381
  async _recordGame(gameName, result) {
382
+ // A game played on a new day counts for the streak, not only a
383
+ // page load. A tab left open past midnight used to miss the day.
384
+ if (this._updateDailyStreak()) {
385
+ this._publishStreak();
386
+ }
387
+
242
388
  // Initialize stats for this game if not exists
243
- if (!this.player.stats[gameName]) {
389
+ if (!hasOwn(this.player.stats, gameName)) {
244
390
  this.player.stats[gameName] = { totalPlays: 0, highScore: 0 };
245
391
  }
246
392
 
@@ -258,17 +404,19 @@ export class RewardSystem extends EventEmitter {
258
404
 
259
405
  // Merge other result data into stats
260
406
  Object.keys(result).forEach(key => {
261
- if (key !== 'score') {
262
- stats[key] = (stats[key] || 0) + (typeof result[key] === 'number' ? result[key] : 0);
407
+ if (key !== 'score' && isSafeKey(key)) {
408
+ const current = hasOwn(stats, key) && Number.isFinite(stats[key]) ? stats[key] : 0;
409
+ stats[key] = current + (Number.isFinite(result[key]) ? result[key] : 0);
263
410
  }
264
411
  });
265
412
 
266
413
  // Base XP for playing
267
414
  let xp = 10;
268
415
 
269
- // Score-based bonus
416
+ // Score-based bonus. A negative score earns nothing extra; it does
417
+ // not take XP away.
270
418
  if (result.score) {
271
- xp += Math.floor(result.score / 5);
419
+ xp += Math.max(0, Math.floor(result.score / 5));
272
420
  }
273
421
 
274
422
  // Streak multiplier
@@ -290,7 +438,7 @@ export class RewardSystem extends EventEmitter {
290
438
 
291
439
  for (const [id, achievement] of Object.entries(this.achievements)) {
292
440
  // Skip if already unlocked
293
- if (this.player.achievements[id]) continue;
441
+ if (hasOwn(this.player.achievements, id)) continue;
294
442
 
295
443
  // Check conditions are user-supplied; one throwing must not stop
296
444
  // the others from ever unlocking.
@@ -302,8 +450,7 @@ export class RewardSystem extends EventEmitter {
302
450
  continue;
303
451
  }
304
452
 
305
- if (met) {
306
- await this.unlockAchievement(id);
453
+ if (met && await this.unlockAchievement(id)) {
307
454
  unlockedAchievements.push(achievement);
308
455
  }
309
456
  }
@@ -315,6 +462,13 @@ export class RewardSystem extends EventEmitter {
315
462
  * Unlock an achievement
316
463
  */
317
464
  async unlockAchievement(achievementId) {
465
+ this._requirePlayer();
466
+
467
+ if (!isSafeKey(achievementId) || !hasOwn(this.achievements, achievementId)) return false;
468
+
469
+ // Already unlocked: paying the XP again would let one call farm levels.
470
+ if (hasOwn(this.player.achievements, achievementId)) return false;
471
+
318
472
  const achievement = this.achievements[achievementId];
319
473
  if (!achievement) return false;
320
474
 
@@ -328,7 +482,7 @@ export class RewardSystem extends EventEmitter {
328
482
  await this.addXP(Number.isFinite(achievement.xp) ? achievement.xp : 0,
329
483
  `Achievement: ${achievement.name}`);
330
484
 
331
- this._publish('achievement_unlocked', achievement);
485
+ this._publish('achievement_unlocked', { ...achievement, id: achievementId });
332
486
  await this.save();
333
487
 
334
488
  return true;
@@ -358,7 +512,7 @@ export class RewardSystem extends EventEmitter {
358
512
  */
359
513
  async markAchievementsSeen(achievementIds) {
360
514
  for (const id of achievementIds) {
361
- if (this.player.achievements[id]) {
515
+ if (hasOwn(this.player.achievements, id)) {
362
516
  this.player.achievements[id].seen = true;
363
517
  }
364
518
  }
@@ -367,6 +521,7 @@ export class RewardSystem extends EventEmitter {
367
521
 
368
522
  /**
369
523
  * Update daily streak
524
+ * @returns {boolean} True when the calendar day changed
370
525
  */
371
526
  _updateDailyStreak() {
372
527
  const today = this._getTodayDateString();
@@ -374,7 +529,7 @@ export class RewardSystem extends EventEmitter {
374
529
 
375
530
  if (lastPlayed === today) {
376
531
  // Already played today
377
- return;
532
+ return false;
378
533
  }
379
534
 
380
535
  const yesterday = this._getYesterdayDateString();
@@ -391,6 +546,7 @@ export class RewardSystem extends EventEmitter {
391
546
  }
392
547
 
393
548
  this.player.streak.lastPlayDate = today;
549
+ return true;
394
550
  }
395
551
 
396
552
  /**
@@ -9,6 +9,20 @@ export class ParticleSystem {
9
9
  (typeof config.container === 'string' ? document.querySelector(config.container) : config.container)
10
10
  : document.body;
11
11
 
12
+ if (!this.container) {
13
+ console.warn(`[DopamineJS] Particle container '${config.container}' not found. Using document.body.`);
14
+ this.container = document.body;
15
+ }
16
+
17
+ // Upper bound on live particles. Each one is drawn every frame, so an
18
+ // oversized count would freeze the tab.
19
+ this.maxParticles = Number.isFinite(config.maxParticles) ? Math.max(0, Math.floor(config.maxParticles)) : 5000;
20
+
21
+ // Effects are skipped for users who asked the OS for less motion.
22
+ this._reducedMotion = config.respectReducedMotion !== false && typeof window.matchMedia === 'function'
23
+ ? window.matchMedia('(prefers-reduced-motion: reduce)')
24
+ : null;
25
+
12
26
  this.canvasId = config.canvasId || 'dopamine-particle-canvas';
13
27
 
14
28
  // Each instance owns its canvas. Sharing one by id meant two systems
@@ -50,6 +64,7 @@ export class ParticleSystem {
50
64
  this._onResize = () => this._resize();
51
65
  this._resizeObserver = null;
52
66
  this._frameHandle = null;
67
+ this._lastFrameTime = null;
53
68
 
54
69
  this._resize();
55
70
  // Use ResizeObserver for container resizing if supported, fallback to window resize
@@ -133,10 +148,13 @@ export class ParticleSystem {
133
148
  sprite = null,
134
149
  spread = Math.PI * 2,
135
150
  angle = 0,
136
- size = 5
151
+ size = 5,
152
+ type = null
137
153
  } = config;
138
154
 
139
- for (let i = 0; i < count; i++) {
155
+ const total = this._allowed(count);
156
+
157
+ for (let i = 0; i < total; i++) {
140
158
  const p = this._getParticle();
141
159
  const pAngle = angle + (Math.random() - 0.5) * spread;
142
160
  const pSpeed = Math.random() * speed;
@@ -151,13 +169,29 @@ export class ParticleSystem {
151
169
  p.color = Array.isArray(color) ? color[Math.floor(Math.random() * color.length)] : color;
152
170
  p.size = size * (0.8 + Math.random() * 0.4);
153
171
  p.sprite = sprite;
172
+ // Always set: pooled particles keep the fields of their last use.
173
+ p.type = type;
154
174
  p.rotation = Math.random() * Math.PI * 2;
155
175
  p.rotationSpeed = (Math.random() - 0.5) * 0.2;
156
176
 
157
177
  this.particles.push(p);
158
178
  }
159
179
 
160
- this._startAnimation();
180
+ if (total > 0) {
181
+ this._startAnimation();
182
+ }
183
+ }
184
+
185
+ /**
186
+ * How many of `count` particles may be created right now.
187
+ * @private
188
+ */
189
+ _allowed(count) {
190
+ if (this._reducedMotion?.matches) return 0;
191
+ if (!Number.isFinite(count)) return 0;
192
+
193
+ const room = this.maxParticles - this.particles.length;
194
+ return Math.max(0, Math.min(Math.floor(count), room));
161
195
  }
162
196
 
163
197
  _getParticle() {
@@ -228,9 +262,11 @@ export class ParticleSystem {
228
262
  // Star burst is unique because of fixed angles, so we keep manual loop or use emit carefully
229
263
  // For simplicity, let's use emit but we lose the perfect star shape distribution
230
264
  // To keep it perfect, we'll manually push particles but use the pool
231
- for (let i = 0; i < count; i++) {
265
+ const total = this._allowed(count);
266
+
267
+ for (let i = 0; i < total; i++) {
232
268
  const p = this._getParticle();
233
- const angle = (Math.PI * 2 / count) * i;
269
+ const angle = (Math.PI * 2 / total) * i;
234
270
  const speed = 4;
235
271
 
236
272
  p.x = x;
@@ -249,38 +285,59 @@ export class ParticleSystem {
249
285
 
250
286
  this.particles.push(p);
251
287
  }
252
- this._startAnimation();
288
+
289
+ if (total > 0) {
290
+ this._startAnimation();
291
+ }
253
292
  }
254
293
 
255
294
  _startAnimation() {
256
295
  if (!this.isAnimating) {
257
296
  this.isAnimating = true;
297
+ this._lastFrameTime = null;
258
298
  this._animate();
259
299
  }
260
300
  }
261
301
 
262
- _animate() {
302
+ /**
303
+ * @param {number} [time] - requestAnimationFrame timestamp
304
+ */
305
+ _animate(time) {
263
306
  this._frameHandle = null;
264
307
  // CSS pixels: the context is pre-scaled by devicePixelRatio.
265
308
  this.ctx.clearRect(0, 0, this.width, this.height);
266
309
 
310
+ // Velocities are tuned per 60 Hz frame. Scaling by elapsed time keeps
311
+ // the same speed on a 120 Hz display instead of running twice as fast.
312
+ let step = 1;
313
+ if (Number.isFinite(time)) {
314
+ if (this._lastFrameTime !== null) {
315
+ step = Math.max(0, Math.min(3, (time - this._lastFrameTime) / (1000 / 60)));
316
+ }
317
+ this._lastFrameTime = time;
318
+ }
319
+
267
320
  for (let i = this.particles.length - 1; i >= 0; i--) {
268
321
  const p = this.particles[i];
269
322
 
270
323
  // Physics
271
- p.vy += p.gravity;
272
- p.x += p.vx;
273
- p.y += p.vy;
274
- p.life -= p.decay;
324
+ p.vy += p.gravity * step;
325
+ p.x += p.vx * step;
326
+ p.y += p.vy * step;
327
+ p.life -= p.decay * step;
275
328
 
276
329
  if (p.rotationSpeed) {
277
- p.rotation += p.rotationSpeed;
330
+ p.rotation += p.rotationSpeed * step;
278
331
  }
279
332
 
280
- // Death
333
+ // Death. Swap with the last particle rather than splice, which
334
+ // shifts the rest of the array for every particle that dies.
281
335
  if (p.life <= 0) {
282
336
  this._recycleParticle(p);
283
- this.particles.splice(i, 1);
337
+ const last = this.particles.pop();
338
+ if (i < this.particles.length) {
339
+ this.particles[i] = last;
340
+ }
284
341
  continue;
285
342
  }
286
343
 
@@ -313,7 +370,7 @@ export class ParticleSystem {
313
370
  }
314
371
 
315
372
  if (this.particles.length > 0) {
316
- this._frameHandle = requestAnimationFrame(() => this._animate());
373
+ this._frameHandle = requestAnimationFrame((next) => this._animate(next));
317
374
  } else {
318
375
  this.isAnimating = false;
319
376
  }
@@ -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
  }