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.
- package/README.md +10 -10
- package/dist/dopamine.cjs +37 -0
- package/dist/dopamine.js +584 -1403
- package/dist/engine.cjs +1 -0
- package/dist/engine.js +1158 -0
- package/dist/style.css +1 -1
- package/package.json +27 -13
- package/src/core/Collider.js +43 -7
- package/src/core/PluginRegistry.js +84 -7
- package/src/core/Scene.js +50 -5
- package/src/core/SystemRegistry.js +32 -11
- package/src/dopamine/audio/SoundManager.js +91 -20
- package/src/dopamine/components/ScreenShake.js +7 -6
- package/src/dopamine/components/index.js +0 -2
- package/src/dopamine/core/DataService.js +31 -3
- package/src/dopamine/core/RewardSystem.js +178 -22
- package/src/dopamine/effects/ParticleSystem.js +72 -15
- package/src/dopamine/index.js +56 -24
- package/src/dopamine/ui/GameUI.js +48 -5
- package/src/dopamine/ui/dopamine.css +18 -0
- package/src/engine.js +38 -0
- package/src/systems/Director.js +7 -0
- package/src/systems/Ticker.js +14 -3
- package/types/index.d.ts +59 -14
- package/dist/dopamine.umd.cjs +0 -37
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 /
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/src/dopamine/index.js
CHANGED
|
@@ -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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
}
|