dopaminejs 2.0.2 → 2.1.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 +843 -656
- package/dist/dopamine.umd.cjs +17 -25
- package/dist/style.css +1 -1
- package/package.json +2 -2
- 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 +85 -14
- 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/systems/Ticker.js +22 -3
- package/src/dopamine/effects/WebGLParticleSystem.js +0 -345
|
@@ -18,7 +18,8 @@ export class RewardSystem extends EventEmitter {
|
|
|
18
18
|
* @returns {Promise<Object>} Player data
|
|
19
19
|
*/
|
|
20
20
|
async init() {
|
|
21
|
-
|
|
21
|
+
const saved = await this.dataService.load('player', null);
|
|
22
|
+
this.player = this._migrate(saved);
|
|
22
23
|
|
|
23
24
|
// Check daily streak
|
|
24
25
|
this._updateDailyStreak();
|
|
@@ -52,6 +53,33 @@ export class RewardSystem extends EventEmitter {
|
|
|
52
53
|
};
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Merge a persisted player record onto the current default shape.
|
|
58
|
+
*
|
|
59
|
+
* Saves written by older versions are missing whatever fields were added
|
|
60
|
+
* since. Without this the first property access on a new field throws
|
|
61
|
+
* before the game has drawn a frame.
|
|
62
|
+
*
|
|
63
|
+
* @param {Object|null} saved - Previously persisted player, if any
|
|
64
|
+
* @returns {Object} Player object guaranteed to have every field
|
|
65
|
+
* @private
|
|
66
|
+
*/
|
|
67
|
+
_migrate(saved) {
|
|
68
|
+
const defaults = this._getDefaultPlayer();
|
|
69
|
+
|
|
70
|
+
if (!saved || typeof saved !== 'object') {
|
|
71
|
+
return defaults;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
...defaults,
|
|
76
|
+
...saved,
|
|
77
|
+
streak: { ...defaults.streak, ...(saved.streak || {}) },
|
|
78
|
+
achievements: saved.achievements || {},
|
|
79
|
+
stats: saved.stats || {}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
55
83
|
/**
|
|
56
84
|
* Save player data
|
|
57
85
|
*/
|
|
@@ -69,6 +97,12 @@ export class RewardSystem extends EventEmitter {
|
|
|
69
97
|
* @returns {Object} { leveledUp: boolean, newLevel: number, xpGained: number }
|
|
70
98
|
*/
|
|
71
99
|
async addXP(amount, reason = '') {
|
|
100
|
+
if (!Number.isFinite(amount)) {
|
|
101
|
+
// Adding undefined/NaN here would poison player.xp permanently:
|
|
102
|
+
// NaN survives every subsequent arithmetic op and gets persisted.
|
|
103
|
+
throw new TypeError(`[DopamineJS] addXP expects a finite number, received ${amount}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
72
106
|
const oldLevel = this.player.level;
|
|
73
107
|
this.player.xp += amount;
|
|
74
108
|
|
|
@@ -96,9 +130,9 @@ export class RewardSystem extends EventEmitter {
|
|
|
96
130
|
* Calculate level from XP (exponential curve)
|
|
97
131
|
*/
|
|
98
132
|
_calculateLevel(xp) {
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
const level = Math.floor((1 + Math.sqrt(1 +
|
|
133
|
+
// Curve: XP = 50 * level * (level - 1)
|
|
134
|
+
// 50L² - 50L - xp = 0 => L = (1 + sqrt(1 + 4*xp/50)) / 2
|
|
135
|
+
const level = Math.floor((1 + Math.sqrt(1 + 4 * xp / 50)) / 2);
|
|
102
136
|
return Math.max(1, level);
|
|
103
137
|
}
|
|
104
138
|
|
|
@@ -109,10 +143,19 @@ export class RewardSystem extends EventEmitter {
|
|
|
109
143
|
const nextLevel = this.player.level + 1;
|
|
110
144
|
const xpNeeded = 50 * nextLevel * (nextLevel - 1);
|
|
111
145
|
const currentLevelXP = 50 * this.player.level * (this.player.level - 1);
|
|
146
|
+
const band = xpNeeded - currentLevelXP;
|
|
147
|
+
|
|
148
|
+
// Clamped: a save written before the curve fix banked levels at half
|
|
149
|
+
// the XP, so player.xp can sit below its own level's floor. Those
|
|
150
|
+
// players keep the level they earned and start the band at 0.
|
|
151
|
+
const progress = band > 0
|
|
152
|
+
? Math.max(0, Math.min(1, (this.player.xp - currentLevelXP) / band))
|
|
153
|
+
: 0;
|
|
154
|
+
|
|
112
155
|
return {
|
|
113
156
|
total: xpNeeded,
|
|
114
|
-
needed: xpNeeded - this.player.xp,
|
|
115
|
-
progress
|
|
157
|
+
needed: Math.max(0, xpNeeded - this.player.xp),
|
|
158
|
+
progress
|
|
116
159
|
};
|
|
117
160
|
}
|
|
118
161
|
|
|
@@ -175,8 +218,17 @@ export class RewardSystem extends EventEmitter {
|
|
|
175
218
|
// Skip if already unlocked
|
|
176
219
|
if (this.player.achievements[id]) continue;
|
|
177
220
|
|
|
178
|
-
// Check
|
|
179
|
-
|
|
221
|
+
// Check conditions are user-supplied; one throwing must not stop
|
|
222
|
+
// the others from ever unlocking.
|
|
223
|
+
let met = false;
|
|
224
|
+
try {
|
|
225
|
+
met = achievement.check(this.player, gameName, result);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
console.error(`[DopamineJS] Achievement "${id}" check threw:`, error);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (met) {
|
|
180
232
|
await this.unlockAchievement(id);
|
|
181
233
|
unlockedAchievements.push(achievement);
|
|
182
234
|
}
|
|
@@ -198,8 +250,9 @@ export class RewardSystem extends EventEmitter {
|
|
|
198
250
|
seen: false
|
|
199
251
|
};
|
|
200
252
|
|
|
201
|
-
// Award XP
|
|
202
|
-
await this.addXP(achievement.xp
|
|
253
|
+
// Award XP. `xp` is optional in a user-defined achievement.
|
|
254
|
+
await this.addXP(Number.isFinite(achievement.xp) ? achievement.xp : 0,
|
|
255
|
+
`Achievement: ${achievement.name}`);
|
|
203
256
|
|
|
204
257
|
this.emit('achievement_unlocked', achievement);
|
|
205
258
|
await this.save();
|
|
@@ -278,19 +331,37 @@ export class RewardSystem extends EventEmitter {
|
|
|
278
331
|
}
|
|
279
332
|
|
|
280
333
|
/**
|
|
281
|
-
*
|
|
334
|
+
* Format a Date as a YYYY-MM-DD string in the player's local timezone.
|
|
335
|
+
*
|
|
336
|
+
* Deliberately not toISOString(), which formats in UTC. For anyone east or
|
|
337
|
+
* west of Greenwich that shifts the calendar day for part of every day and
|
|
338
|
+
* makes streaks reset or double-count.
|
|
339
|
+
*
|
|
340
|
+
* @param {Date} date
|
|
341
|
+
* @returns {string}
|
|
342
|
+
* @private
|
|
343
|
+
*/
|
|
344
|
+
_toLocalDateString(date) {
|
|
345
|
+
const year = date.getFullYear();
|
|
346
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
347
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
348
|
+
return `${year}-${month}-${day}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Get today's local date as YYYY-MM-DD string
|
|
282
353
|
*/
|
|
283
354
|
_getTodayDateString() {
|
|
284
|
-
return new Date()
|
|
355
|
+
return this._toLocalDateString(new Date());
|
|
285
356
|
}
|
|
286
357
|
|
|
287
358
|
/**
|
|
288
|
-
* Get yesterday's date as YYYY-MM-DD string
|
|
359
|
+
* Get yesterday's local date as YYYY-MM-DD string
|
|
289
360
|
*/
|
|
290
361
|
_getYesterdayDateString() {
|
|
291
362
|
const yesterday = new Date();
|
|
292
363
|
yesterday.setDate(yesterday.getDate() - 1);
|
|
293
|
-
return
|
|
364
|
+
return this._toLocalDateString(yesterday);
|
|
294
365
|
}
|
|
295
366
|
|
|
296
367
|
/**
|
|
@@ -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
|
}
|