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.
- package/README.md +10 -10
- package/dist/dopamine.cjs +37 -0
- package/dist/dopamine.js +632 -1378
- package/dist/engine.cjs +1 -0
- package/dist/engine.js +1158 -0
- package/dist/style.css +1 -1
- package/package.json +31 -14
- 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 +255 -25
- 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/index.js +6 -0
- package/src/systems/Director.js +7 -0
- package/src/systems/Input.js +8 -4
- package/src/systems/Loader.js +7 -3
- package/src/systems/Physics.js +7 -3
- package/src/systems/Ticker.js +14 -3
- package/src/systems/deprecate.js +38 -0
- package/types/index.d.ts +617 -0
- 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,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
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
304
|
+
const total = this.player.xp;
|
|
305
|
+
this._publish('xp_gained', { amount, reason, leveledUp, newLevel, total });
|
|
121
306
|
|
|
122
307
|
if (leveledUp) {
|
|
123
|
-
this.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
}
|