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.
@@ -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,12 +5,76 @@
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 {
22
+ /**
23
+ * @param {Object} dataService - Persistence, see DataService
24
+ * @param {Object} [config]
25
+ * @param {Object} [config.achievements] - Extra achievement definitions
26
+ * @param {Object} [config.events] - Kernel EventBus to mirror events onto
27
+ * @param {Object} [config.kernel] - Kernel; its `events` bus is used if present
28
+ */
9
29
  constructor(dataService, config = {}) {
10
30
  super();
11
31
  this.dataService = dataService;
12
32
  this.player = null;
13
33
  this.achievements = { ...this._initAchievements(), ...(config.achievements || {}) };
34
+
35
+ // Optional bridge to the kernel bus. EventBus.Events already declared
36
+ // XP_GAINED, LEVEL_UP and ACHIEVEMENT_UNLOCKED with nothing emitting
37
+ // them; this connects the two event systems without changing the
38
+ // existing rewardSystem.on(...) surface that games already use.
39
+ this.events = config.events || config.kernel?.events || null;
40
+
41
+ // Write batching. Every mutation used to hit storage directly, so one
42
+ // recordGame produced four full serializations of the player object.
43
+ this._batchDepth = 0;
44
+ this._pendingSave = false;
45
+ }
46
+
47
+ /**
48
+ * Emit locally, and mirror onto the kernel bus when one is attached.
49
+ * @private
50
+ */
51
+ _publish(event, data) {
52
+ this.emit(event, data);
53
+ if (this.events) {
54
+ this.events.emit(event, data);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Run `fn` with saves collapsed into a single write at the end.
60
+ *
61
+ * Nested batches are counted, so an inner operation that batches on its
62
+ * own still results in exactly one write for the outermost call.
63
+ *
64
+ * @param {Function} fn
65
+ * @private
66
+ */
67
+ async _batch(fn) {
68
+ this._batchDepth++;
69
+ try {
70
+ return await fn();
71
+ } finally {
72
+ this._batchDepth--;
73
+ if (this._batchDepth === 0 && this._pendingSave) {
74
+ this._pendingSave = false;
75
+ await this._write();
76
+ }
77
+ }
14
78
  }
15
79
 
16
80
  /**
@@ -22,14 +86,35 @@ export class RewardSystem extends EventEmitter {
22
86
  this.player = this._migrate(saved);
23
87
 
24
88
  // Check daily streak
25
- this._updateDailyStreak();
89
+ const streakChanged = this._updateDailyStreak();
26
90
 
27
91
  // Save updated player data
28
92
  await this.save();
29
93
 
94
+ if (streakChanged) {
95
+ this._publishStreak();
96
+ }
97
+
30
98
  return this.player;
31
99
  }
32
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
+
33
118
  /**
34
119
  * Get default player data structure
35
120
  */
@@ -67,26 +152,118 @@ export class RewardSystem extends EventEmitter {
67
152
  _migrate(saved) {
68
153
  const defaults = this._getDefaultPlayer();
69
154
 
70
- if (!saved || typeof saved !== 'object') {
155
+ if (!isRecord(saved)) {
71
156
  return defaults;
72
157
  }
73
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
+
74
173
  return {
75
174
  ...defaults,
76
175
  ...saved,
77
- streak: { ...defaults.streak, ...(saved.streak || {}) },
78
- achievements: saved.achievements || {},
79
- 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)
80
191
  };
81
192
  }
82
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
+
83
240
  /**
84
241
  * Save player data
85
242
  */
86
243
  async save() {
87
- if (this.player) {
88
- this.player.lastPlayedAt = Date.now();
89
- await this.dataService.save('player', this.player);
244
+ if (!this.player) return;
245
+
246
+ // Inside a batch, mark dirty and let the outermost call do the write.
247
+ if (this._batchDepth > 0) {
248
+ this._pendingSave = true;
249
+ return;
250
+ }
251
+
252
+ await this._write();
253
+ }
254
+
255
+ /**
256
+ * The actual persistence call. Bypasses batching.
257
+ * @private
258
+ */
259
+ async _write() {
260
+ this.player.lastPlayedAt = Date.now();
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' });
90
267
  }
91
268
  }
92
269
 
@@ -103,11 +280,18 @@ export class RewardSystem extends EventEmitter {
103
280
  throw new TypeError(`[DopamineJS] addXP expects a finite number, received ${amount}`);
104
281
  }
105
282
 
283
+ this._requirePlayer();
284
+
106
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);
107
290
  this.player.xp += amount;
108
291
 
109
- // Check for level up
110
- 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));
111
295
  const leveledUp = newLevel > oldLevel;
112
296
 
113
297
  if (leveledUp) {
@@ -117,10 +301,11 @@ export class RewardSystem extends EventEmitter {
117
301
  await this.save();
118
302
 
119
303
  // Notify listeners
120
- this.emit('xp_gained', { amount, reason, leveledUp, newLevel });
304
+ const total = this.player.xp;
305
+ this._publish('xp_gained', { amount, reason, leveledUp, newLevel, total });
121
306
 
122
307
  if (leveledUp) {
123
- this.emit('level_up', { oldLevel, newLevel });
308
+ this._publish('level_up', { oldLevel, newLevel, totalXP: total });
124
309
  }
125
310
 
126
311
  return { leveledUp, newLevel, xpGained: amount };
@@ -132,6 +317,8 @@ export class RewardSystem extends EventEmitter {
132
317
  _calculateLevel(xp) {
133
318
  // Curve: XP = 50 * level * (level - 1)
134
319
  // 50L² - 50L - xp = 0 => L = (1 + sqrt(1 + 4*xp/50)) / 2
320
+ if (!(xp > 0)) return 1;
321
+
135
322
  const level = Math.floor((1 + Math.sqrt(1 + 4 * xp / 50)) / 2);
136
323
  return Math.max(1, level);
137
324
  }
@@ -140,6 +327,8 @@ export class RewardSystem extends EventEmitter {
140
327
  * Get XP needed for next level
141
328
  */
142
329
  getXPForNextLevel() {
330
+ this._requirePlayer();
331
+
143
332
  const nextLevel = this.player.level + 1;
144
333
  const xpNeeded = 50 * nextLevel * (nextLevel - 1);
145
334
  const currentLevelXP = 50 * this.player.level * (this.player.level - 1);
@@ -164,9 +353,40 @@ export class RewardSystem extends EventEmitter {
164
353
  * @param {string} gameName - Unique ID for the game
165
354
  * @param {Object} result - Game-specific result data
166
355
  */
167
- 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
+
373
+ // Batched: addXP and each unlockAchievement below also save, which
374
+ // meant four full serializations of the player object per game.
375
+ return this._batch(() => this._recordGame(gameName, result));
376
+ }
377
+
378
+ /**
379
+ * @private
380
+ */
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
+
168
388
  // Initialize stats for this game if not exists
169
- if (!this.player.stats[gameName]) {
389
+ if (!hasOwn(this.player.stats, gameName)) {
170
390
  this.player.stats[gameName] = { totalPlays: 0, highScore: 0 };
171
391
  }
172
392
 
@@ -179,22 +399,24 @@ export class RewardSystem extends EventEmitter {
179
399
  // Update high score if applicable
180
400
  if (result.score !== undefined && result.score > stats.highScore) {
181
401
  stats.highScore = result.score;
182
- this.emit('new_high_score', { gameName, score: result.score });
402
+ this._publish('new_high_score', { gameName, score: result.score });
183
403
  }
184
404
 
185
405
  // Merge other result data into stats
186
406
  Object.keys(result).forEach(key => {
187
- if (key !== 'score') {
188
- 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);
189
410
  }
190
411
  });
191
412
 
192
413
  // Base XP for playing
193
414
  let xp = 10;
194
415
 
195
- // Score-based bonus
416
+ // Score-based bonus. A negative score earns nothing extra; it does
417
+ // not take XP away.
196
418
  if (result.score) {
197
- xp += Math.floor(result.score / 5);
419
+ xp += Math.max(0, Math.floor(result.score / 5));
198
420
  }
199
421
 
200
422
  // Streak multiplier
@@ -216,7 +438,7 @@ export class RewardSystem extends EventEmitter {
216
438
 
217
439
  for (const [id, achievement] of Object.entries(this.achievements)) {
218
440
  // Skip if already unlocked
219
- if (this.player.achievements[id]) continue;
441
+ if (hasOwn(this.player.achievements, id)) continue;
220
442
 
221
443
  // Check conditions are user-supplied; one throwing must not stop
222
444
  // the others from ever unlocking.
@@ -228,8 +450,7 @@ export class RewardSystem extends EventEmitter {
228
450
  continue;
229
451
  }
230
452
 
231
- if (met) {
232
- await this.unlockAchievement(id);
453
+ if (met && await this.unlockAchievement(id)) {
233
454
  unlockedAchievements.push(achievement);
234
455
  }
235
456
  }
@@ -241,6 +462,13 @@ export class RewardSystem extends EventEmitter {
241
462
  * Unlock an achievement
242
463
  */
243
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
+
244
472
  const achievement = this.achievements[achievementId];
245
473
  if (!achievement) return false;
246
474
 
@@ -254,7 +482,7 @@ export class RewardSystem extends EventEmitter {
254
482
  await this.addXP(Number.isFinite(achievement.xp) ? achievement.xp : 0,
255
483
  `Achievement: ${achievement.name}`);
256
484
 
257
- this.emit('achievement_unlocked', achievement);
485
+ this._publish('achievement_unlocked', { ...achievement, id: achievementId });
258
486
  await this.save();
259
487
 
260
488
  return true;
@@ -284,7 +512,7 @@ export class RewardSystem extends EventEmitter {
284
512
  */
285
513
  async markAchievementsSeen(achievementIds) {
286
514
  for (const id of achievementIds) {
287
- if (this.player.achievements[id]) {
515
+ if (hasOwn(this.player.achievements, id)) {
288
516
  this.player.achievements[id].seen = true;
289
517
  }
290
518
  }
@@ -293,6 +521,7 @@ export class RewardSystem extends EventEmitter {
293
521
 
294
522
  /**
295
523
  * Update daily streak
524
+ * @returns {boolean} True when the calendar day changed
296
525
  */
297
526
  _updateDailyStreak() {
298
527
  const today = this._getTodayDateString();
@@ -300,7 +529,7 @@ export class RewardSystem extends EventEmitter {
300
529
 
301
530
  if (lastPlayed === today) {
302
531
  // Already played today
303
- return;
532
+ return false;
304
533
  }
305
534
 
306
535
  const yesterday = this._getYesterdayDateString();
@@ -317,6 +546,7 @@ export class RewardSystem extends EventEmitter {
317
546
  }
318
547
 
319
548
  this.player.streak.lastPlayDate = today;
549
+ return true;
320
550
  }
321
551
 
322
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
  }