dopaminejs 2.0.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +373 -0
- package/README.md +115 -0
- package/dist/dopamine.js +933 -673
- package/dist/dopamine.umd.cjs +17 -25
- package/dist/style.css +1 -1
- package/package.json +7 -4
- package/src/core/DopamineKernel.js +11 -1
- package/src/core/EventBus.js +32 -9
- package/src/dopamine/audio/SoundManager.js +59 -9
- package/src/dopamine/core/DataService.js +3 -1
- package/src/dopamine/core/RewardSystem.js +166 -21
- package/src/dopamine/effects/ParticleSystem.js +84 -32
- package/src/dopamine/ui/GameUI.js +185 -63
- package/src/dopamine/ui/dopamine.css +4 -1
- package/src/dopamine/utils/storage.js +49 -0
- package/src/index.js +6 -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 +22 -3
- package/src/systems/deprecate.js +38 -0
- package/types/index.d.ts +588 -0
- package/src/dopamine/effects/WebGLParticleSystem.js +0 -345
|
@@ -6,11 +6,62 @@
|
|
|
6
6
|
import { EventEmitter } from './EventEmitter.js';
|
|
7
7
|
|
|
8
8
|
export class RewardSystem extends EventEmitter {
|
|
9
|
+
/**
|
|
10
|
+
* @param {Object} dataService - Persistence, see DataService
|
|
11
|
+
* @param {Object} [config]
|
|
12
|
+
* @param {Object} [config.achievements] - Extra achievement definitions
|
|
13
|
+
* @param {Object} [config.events] - Kernel EventBus to mirror events onto
|
|
14
|
+
* @param {Object} [config.kernel] - Kernel; its `events` bus is used if present
|
|
15
|
+
*/
|
|
9
16
|
constructor(dataService, config = {}) {
|
|
10
17
|
super();
|
|
11
18
|
this.dataService = dataService;
|
|
12
19
|
this.player = null;
|
|
13
20
|
this.achievements = { ...this._initAchievements(), ...(config.achievements || {}) };
|
|
21
|
+
|
|
22
|
+
// Optional bridge to the kernel bus. EventBus.Events already declared
|
|
23
|
+
// XP_GAINED, LEVEL_UP and ACHIEVEMENT_UNLOCKED with nothing emitting
|
|
24
|
+
// them; this connects the two event systems without changing the
|
|
25
|
+
// existing rewardSystem.on(...) surface that games already use.
|
|
26
|
+
this.events = config.events || config.kernel?.events || null;
|
|
27
|
+
|
|
28
|
+
// Write batching. Every mutation used to hit storage directly, so one
|
|
29
|
+
// recordGame produced four full serializations of the player object.
|
|
30
|
+
this._batchDepth = 0;
|
|
31
|
+
this._pendingSave = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Emit locally, and mirror onto the kernel bus when one is attached.
|
|
36
|
+
* @private
|
|
37
|
+
*/
|
|
38
|
+
_publish(event, data) {
|
|
39
|
+
this.emit(event, data);
|
|
40
|
+
if (this.events) {
|
|
41
|
+
this.events.emit(event, data);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run `fn` with saves collapsed into a single write at the end.
|
|
47
|
+
*
|
|
48
|
+
* Nested batches are counted, so an inner operation that batches on its
|
|
49
|
+
* own still results in exactly one write for the outermost call.
|
|
50
|
+
*
|
|
51
|
+
* @param {Function} fn
|
|
52
|
+
* @private
|
|
53
|
+
*/
|
|
54
|
+
async _batch(fn) {
|
|
55
|
+
this._batchDepth++;
|
|
56
|
+
try {
|
|
57
|
+
return await fn();
|
|
58
|
+
} finally {
|
|
59
|
+
this._batchDepth--;
|
|
60
|
+
if (this._batchDepth === 0 && this._pendingSave) {
|
|
61
|
+
this._pendingSave = false;
|
|
62
|
+
await this._write();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
14
65
|
}
|
|
15
66
|
|
|
16
67
|
/**
|
|
@@ -18,7 +69,8 @@ export class RewardSystem extends EventEmitter {
|
|
|
18
69
|
* @returns {Promise<Object>} Player data
|
|
19
70
|
*/
|
|
20
71
|
async init() {
|
|
21
|
-
|
|
72
|
+
const saved = await this.dataService.load('player', null);
|
|
73
|
+
this.player = this._migrate(saved);
|
|
22
74
|
|
|
23
75
|
// Check daily streak
|
|
24
76
|
this._updateDailyStreak();
|
|
@@ -52,14 +104,55 @@ export class RewardSystem extends EventEmitter {
|
|
|
52
104
|
};
|
|
53
105
|
}
|
|
54
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Merge a persisted player record onto the current default shape.
|
|
109
|
+
*
|
|
110
|
+
* Saves written by older versions are missing whatever fields were added
|
|
111
|
+
* since. Without this the first property access on a new field throws
|
|
112
|
+
* before the game has drawn a frame.
|
|
113
|
+
*
|
|
114
|
+
* @param {Object|null} saved - Previously persisted player, if any
|
|
115
|
+
* @returns {Object} Player object guaranteed to have every field
|
|
116
|
+
* @private
|
|
117
|
+
*/
|
|
118
|
+
_migrate(saved) {
|
|
119
|
+
const defaults = this._getDefaultPlayer();
|
|
120
|
+
|
|
121
|
+
if (!saved || typeof saved !== 'object') {
|
|
122
|
+
return defaults;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
...defaults,
|
|
127
|
+
...saved,
|
|
128
|
+
streak: { ...defaults.streak, ...(saved.streak || {}) },
|
|
129
|
+
achievements: saved.achievements || {},
|
|
130
|
+
stats: saved.stats || {}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
55
134
|
/**
|
|
56
135
|
* Save player data
|
|
57
136
|
*/
|
|
58
137
|
async save() {
|
|
59
|
-
if (this.player)
|
|
60
|
-
|
|
61
|
-
|
|
138
|
+
if (!this.player) return;
|
|
139
|
+
|
|
140
|
+
// Inside a batch, mark dirty and let the outermost call do the write.
|
|
141
|
+
if (this._batchDepth > 0) {
|
|
142
|
+
this._pendingSave = true;
|
|
143
|
+
return;
|
|
62
144
|
}
|
|
145
|
+
|
|
146
|
+
await this._write();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The actual persistence call. Bypasses batching.
|
|
151
|
+
* @private
|
|
152
|
+
*/
|
|
153
|
+
async _write() {
|
|
154
|
+
this.player.lastPlayedAt = Date.now();
|
|
155
|
+
await this.dataService.save('player', this.player);
|
|
63
156
|
}
|
|
64
157
|
|
|
65
158
|
/**
|
|
@@ -69,6 +162,12 @@ export class RewardSystem extends EventEmitter {
|
|
|
69
162
|
* @returns {Object} { leveledUp: boolean, newLevel: number, xpGained: number }
|
|
70
163
|
*/
|
|
71
164
|
async addXP(amount, reason = '') {
|
|
165
|
+
if (!Number.isFinite(amount)) {
|
|
166
|
+
// Adding undefined/NaN here would poison player.xp permanently:
|
|
167
|
+
// NaN survives every subsequent arithmetic op and gets persisted.
|
|
168
|
+
throw new TypeError(`[DopamineJS] addXP expects a finite number, received ${amount}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
72
171
|
const oldLevel = this.player.level;
|
|
73
172
|
this.player.xp += amount;
|
|
74
173
|
|
|
@@ -83,10 +182,10 @@ export class RewardSystem extends EventEmitter {
|
|
|
83
182
|
await this.save();
|
|
84
183
|
|
|
85
184
|
// Notify listeners
|
|
86
|
-
this.
|
|
185
|
+
this._publish('xp_gained', { amount, reason, leveledUp, newLevel });
|
|
87
186
|
|
|
88
187
|
if (leveledUp) {
|
|
89
|
-
this.
|
|
188
|
+
this._publish('level_up', { oldLevel, newLevel });
|
|
90
189
|
}
|
|
91
190
|
|
|
92
191
|
return { leveledUp, newLevel, xpGained: amount };
|
|
@@ -96,9 +195,9 @@ export class RewardSystem extends EventEmitter {
|
|
|
96
195
|
* Calculate level from XP (exponential curve)
|
|
97
196
|
*/
|
|
98
197
|
_calculateLevel(xp) {
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
const level = Math.floor((1 + Math.sqrt(1 +
|
|
198
|
+
// Curve: XP = 50 * level * (level - 1)
|
|
199
|
+
// 50L² - 50L - xp = 0 => L = (1 + sqrt(1 + 4*xp/50)) / 2
|
|
200
|
+
const level = Math.floor((1 + Math.sqrt(1 + 4 * xp / 50)) / 2);
|
|
102
201
|
return Math.max(1, level);
|
|
103
202
|
}
|
|
104
203
|
|
|
@@ -109,10 +208,19 @@ export class RewardSystem extends EventEmitter {
|
|
|
109
208
|
const nextLevel = this.player.level + 1;
|
|
110
209
|
const xpNeeded = 50 * nextLevel * (nextLevel - 1);
|
|
111
210
|
const currentLevelXP = 50 * this.player.level * (this.player.level - 1);
|
|
211
|
+
const band = xpNeeded - currentLevelXP;
|
|
212
|
+
|
|
213
|
+
// Clamped: a save written before the curve fix banked levels at half
|
|
214
|
+
// the XP, so player.xp can sit below its own level's floor. Those
|
|
215
|
+
// players keep the level they earned and start the band at 0.
|
|
216
|
+
const progress = band > 0
|
|
217
|
+
? Math.max(0, Math.min(1, (this.player.xp - currentLevelXP) / band))
|
|
218
|
+
: 0;
|
|
219
|
+
|
|
112
220
|
return {
|
|
113
221
|
total: xpNeeded,
|
|
114
|
-
needed: xpNeeded - this.player.xp,
|
|
115
|
-
progress
|
|
222
|
+
needed: Math.max(0, xpNeeded - this.player.xp),
|
|
223
|
+
progress
|
|
116
224
|
};
|
|
117
225
|
}
|
|
118
226
|
|
|
@@ -122,6 +230,15 @@ export class RewardSystem extends EventEmitter {
|
|
|
122
230
|
* @param {Object} result - Game-specific result data
|
|
123
231
|
*/
|
|
124
232
|
async recordGame(gameName, result) {
|
|
233
|
+
// Batched: addXP and each unlockAchievement below also save, which
|
|
234
|
+
// meant four full serializations of the player object per game.
|
|
235
|
+
return this._batch(() => this._recordGame(gameName, result));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* @private
|
|
240
|
+
*/
|
|
241
|
+
async _recordGame(gameName, result) {
|
|
125
242
|
// Initialize stats for this game if not exists
|
|
126
243
|
if (!this.player.stats[gameName]) {
|
|
127
244
|
this.player.stats[gameName] = { totalPlays: 0, highScore: 0 };
|
|
@@ -136,7 +253,7 @@ export class RewardSystem extends EventEmitter {
|
|
|
136
253
|
// Update high score if applicable
|
|
137
254
|
if (result.score !== undefined && result.score > stats.highScore) {
|
|
138
255
|
stats.highScore = result.score;
|
|
139
|
-
this.
|
|
256
|
+
this._publish('new_high_score', { gameName, score: result.score });
|
|
140
257
|
}
|
|
141
258
|
|
|
142
259
|
// Merge other result data into stats
|
|
@@ -175,8 +292,17 @@ export class RewardSystem extends EventEmitter {
|
|
|
175
292
|
// Skip if already unlocked
|
|
176
293
|
if (this.player.achievements[id]) continue;
|
|
177
294
|
|
|
178
|
-
// Check
|
|
179
|
-
|
|
295
|
+
// Check conditions are user-supplied; one throwing must not stop
|
|
296
|
+
// the others from ever unlocking.
|
|
297
|
+
let met = false;
|
|
298
|
+
try {
|
|
299
|
+
met = achievement.check(this.player, gameName, result);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
console.error(`[DopamineJS] Achievement "${id}" check threw:`, error);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (met) {
|
|
180
306
|
await this.unlockAchievement(id);
|
|
181
307
|
unlockedAchievements.push(achievement);
|
|
182
308
|
}
|
|
@@ -198,10 +324,11 @@ export class RewardSystem extends EventEmitter {
|
|
|
198
324
|
seen: false
|
|
199
325
|
};
|
|
200
326
|
|
|
201
|
-
// Award XP
|
|
202
|
-
await this.addXP(achievement.xp
|
|
327
|
+
// Award XP. `xp` is optional in a user-defined achievement.
|
|
328
|
+
await this.addXP(Number.isFinite(achievement.xp) ? achievement.xp : 0,
|
|
329
|
+
`Achievement: ${achievement.name}`);
|
|
203
330
|
|
|
204
|
-
this.
|
|
331
|
+
this._publish('achievement_unlocked', achievement);
|
|
205
332
|
await this.save();
|
|
206
333
|
|
|
207
334
|
return true;
|
|
@@ -278,19 +405,37 @@ export class RewardSystem extends EventEmitter {
|
|
|
278
405
|
}
|
|
279
406
|
|
|
280
407
|
/**
|
|
281
|
-
*
|
|
408
|
+
* Format a Date as a YYYY-MM-DD string in the player's local timezone.
|
|
409
|
+
*
|
|
410
|
+
* Deliberately not toISOString(), which formats in UTC. For anyone east or
|
|
411
|
+
* west of Greenwich that shifts the calendar day for part of every day and
|
|
412
|
+
* makes streaks reset or double-count.
|
|
413
|
+
*
|
|
414
|
+
* @param {Date} date
|
|
415
|
+
* @returns {string}
|
|
416
|
+
* @private
|
|
417
|
+
*/
|
|
418
|
+
_toLocalDateString(date) {
|
|
419
|
+
const year = date.getFullYear();
|
|
420
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
421
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
422
|
+
return `${year}-${month}-${day}`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Get today's local date as YYYY-MM-DD string
|
|
282
427
|
*/
|
|
283
428
|
_getTodayDateString() {
|
|
284
|
-
return new Date()
|
|
429
|
+
return this._toLocalDateString(new Date());
|
|
285
430
|
}
|
|
286
431
|
|
|
287
432
|
/**
|
|
288
|
-
* Get yesterday's date as YYYY-MM-DD string
|
|
433
|
+
* Get yesterday's local date as YYYY-MM-DD string
|
|
289
434
|
*/
|
|
290
435
|
_getYesterdayDateString() {
|
|
291
436
|
const yesterday = new Date();
|
|
292
437
|
yesterday.setDate(yesterday.getDate() - 1);
|
|
293
|
-
return
|
|
438
|
+
return this._toLocalDateString(yesterday);
|
|
294
439
|
}
|
|
295
440
|
|
|
296
441
|
/**
|
|
@@ -10,32 +10,34 @@ export class ParticleSystem {
|
|
|
10
10
|
: document.body;
|
|
11
11
|
|
|
12
12
|
this.canvasId = config.canvasId || 'dopamine-particle-canvas';
|
|
13
|
-
this.canvas = document.getElementById(this.canvasId);
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
// Each instance owns its canvas. Sharing one by id meant two systems
|
|
15
|
+
// cleared each other's frames every tick.
|
|
16
|
+
this.canvas = document.createElement('canvas');
|
|
17
|
+
this.canvas.classList.add('dopamine-particle-canvas');
|
|
18
|
+
if (!document.getElementById(this.canvasId)) {
|
|
17
19
|
this.canvas.id = this.canvasId;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
}
|
|
21
|
+
this.canvas.style.pointerEvents = 'none';
|
|
22
|
+
this.canvas.style.zIndex = config.zIndex || '9999';
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
24
|
+
if (this.container === document.body) {
|
|
25
|
+
this.canvas.style.position = 'fixed';
|
|
26
|
+
this.canvas.style.top = '0';
|
|
27
|
+
this.canvas.style.left = '0';
|
|
28
|
+
} else {
|
|
29
|
+
this.canvas.style.position = 'absolute';
|
|
30
|
+
this.canvas.style.top = '0';
|
|
31
|
+
this.canvas.style.left = '0';
|
|
32
|
+
// Ensure container is positioned
|
|
33
|
+
const style = window.getComputedStyle(this.container);
|
|
34
|
+
if (style.position === 'static') {
|
|
35
|
+
this.container.style.position = 'relative';
|
|
34
36
|
}
|
|
35
|
-
|
|
36
|
-
this.container.appendChild(this.canvas);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
this.container.appendChild(this.canvas);
|
|
40
|
+
|
|
39
41
|
this.ctx = this.canvas.getContext('2d');
|
|
40
42
|
this.particles = [];
|
|
41
43
|
this.pool = []; // Object pool
|
|
@@ -43,23 +45,44 @@ export class ParticleSystem {
|
|
|
43
45
|
this.customEffects = new Map(); // key -> callback
|
|
44
46
|
this.isAnimating = false;
|
|
45
47
|
|
|
48
|
+
// Kept on the instance so destroy() can actually detach them. An
|
|
49
|
+
// inline arrow passed to addEventListener can never be removed.
|
|
50
|
+
this._onResize = () => this._resize();
|
|
51
|
+
this._resizeObserver = null;
|
|
52
|
+
this._frameHandle = null;
|
|
53
|
+
|
|
46
54
|
this._resize();
|
|
47
55
|
// Use ResizeObserver for container resizing if supported, fallback to window resize
|
|
48
56
|
if (window.ResizeObserver && this.container !== document.body) {
|
|
49
|
-
new ResizeObserver(
|
|
57
|
+
this._resizeObserver = new ResizeObserver(this._onResize);
|
|
58
|
+
this._resizeObserver.observe(this.container);
|
|
50
59
|
} else {
|
|
51
|
-
window.addEventListener('resize',
|
|
60
|
+
window.addEventListener('resize', this._onResize);
|
|
52
61
|
}
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
_resize() {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
this.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
const cssWidth = this.container === document.body
|
|
66
|
+
? window.innerWidth
|
|
67
|
+
: this.container.clientWidth;
|
|
68
|
+
const cssHeight = this.container === document.body
|
|
69
|
+
? window.innerHeight
|
|
70
|
+
: this.container.clientHeight;
|
|
71
|
+
|
|
72
|
+
// Back the canvas with device pixels but keep the drawing coordinate
|
|
73
|
+
// system in CSS pixels, so particle positions stay in page units and
|
|
74
|
+
// nothing looks soft on a HiDPI display.
|
|
75
|
+
const dpr = window.devicePixelRatio || 1;
|
|
76
|
+
|
|
77
|
+
this.width = cssWidth;
|
|
78
|
+
this.height = cssHeight;
|
|
79
|
+
|
|
80
|
+
this.canvas.width = Math.round(cssWidth * dpr);
|
|
81
|
+
this.canvas.height = Math.round(cssHeight * dpr);
|
|
82
|
+
this.canvas.style.width = `${cssWidth}px`;
|
|
83
|
+
this.canvas.style.height = `${cssHeight}px`;
|
|
84
|
+
|
|
85
|
+
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
63
86
|
}
|
|
64
87
|
|
|
65
88
|
/**
|
|
@@ -237,7 +260,9 @@ export class ParticleSystem {
|
|
|
237
260
|
}
|
|
238
261
|
|
|
239
262
|
_animate() {
|
|
240
|
-
this.
|
|
263
|
+
this._frameHandle = null;
|
|
264
|
+
// CSS pixels: the context is pre-scaled by devicePixelRatio.
|
|
265
|
+
this.ctx.clearRect(0, 0, this.width, this.height);
|
|
241
266
|
|
|
242
267
|
for (let i = this.particles.length - 1; i >= 0; i--) {
|
|
243
268
|
const p = this.particles[i];
|
|
@@ -288,7 +313,7 @@ export class ParticleSystem {
|
|
|
288
313
|
}
|
|
289
314
|
|
|
290
315
|
if (this.particles.length > 0) {
|
|
291
|
-
requestAnimationFrame(() => this._animate());
|
|
316
|
+
this._frameHandle = requestAnimationFrame(() => this._animate());
|
|
292
317
|
} else {
|
|
293
318
|
this.isAnimating = false;
|
|
294
319
|
}
|
|
@@ -322,6 +347,33 @@ export class ParticleSystem {
|
|
|
322
347
|
// Recycle all
|
|
323
348
|
this.particles.forEach(p => this._recycleParticle(p));
|
|
324
349
|
this.particles = [];
|
|
325
|
-
this.ctx.clearRect(0, 0, this.
|
|
350
|
+
this.ctx.clearRect(0, 0, this.width, this.height);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Stop animating, detach resize handling, and remove the canvas.
|
|
355
|
+
*
|
|
356
|
+
* Without this the resize listener and canvas outlive the instance, which
|
|
357
|
+
* matters in any single-page app that mounts and unmounts a game view.
|
|
358
|
+
*/
|
|
359
|
+
destroy() {
|
|
360
|
+
this.clear();
|
|
361
|
+
this.isAnimating = false;
|
|
362
|
+
|
|
363
|
+
if (this._frameHandle !== null) {
|
|
364
|
+
cancelAnimationFrame(this._frameHandle);
|
|
365
|
+
this._frameHandle = null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (this._resizeObserver) {
|
|
369
|
+
this._resizeObserver.disconnect();
|
|
370
|
+
this._resizeObserver = null;
|
|
371
|
+
}
|
|
372
|
+
window.removeEventListener('resize', this._onResize);
|
|
373
|
+
|
|
374
|
+
this.canvas.remove();
|
|
375
|
+
this.sprites.clear();
|
|
376
|
+
this.customEffects.clear();
|
|
377
|
+
this.pool = [];
|
|
326
378
|
}
|
|
327
379
|
}
|