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
package/dist/style.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
#game-ui-overlay,.game-ui-overlay{position:fixed;top:0;left:0;width:100%;pointer-events:none;z-index:1000;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}.game-ui-top-bar{display:flex;align-items:center;gap:12px;padding:12px 16px;background:#0000004d;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}.level-badge{display:flex;flex-direction:column;align-items:center;background:linear-gradient(135deg,#667eea,#764ba2);padding:8px 12px;border-radius:12px;box-shadow:0 4px 12px #667eea66;min-width:60px}.level-label{font-size:10px;font-weight:600;color:#fffc;letter-spacing:.5px}.level-number{font-size:24px;font-weight:800;color:#fff;line-height:1}.xp-container{flex:1;display:flex;flex-direction:column;gap:4px}.xp-bar-bg{height:12px;background:#fff3;border-radius:6px;overflow:hidden;box-shadow:inset 0 2px 4px #0003}.xp-bar-fill{height:100%;background:linear-gradient(90deg,#4facfe,#00f2fe);border-radius:6px;transition:width .5s ease-out;box-shadow:0 0 10px #4facfe99}.xp-text{font-size:11px;color:#fff;font-weight:600;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.5)}.streak-badge{display:flex;align-items:center;gap:6px;background:linear-gradient(135deg,#f80,#f40);padding:8px 12px;border-radius:12px;box-shadow:0 4px 12px #f806}.streak-icon{font-size:20px;animation:flicker 2s infinite}.streak-number{font-size:18px;font-weight:800;color:#fff}.achievement-popup{position:fixed;top:80px;right:20px;background:linear-gradient(135deg,#667eea,#764ba2);border-radius:16px;padding:16px;box-shadow:0 8px 32px #0006;animation:slideIn .5s cubic-bezier(.68,-.55,.265,1.55);z-index:10000;max-width:320px;overflow:hidden}.achievement-shine{position:absolute;top:0;left:-100%;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.3),transparent);animation:shine 2s infinite}.achievement-content{position:relative;display:flex;gap:12px;align-items:center}.achievement-icon{font-size:48px;animation:bounce .6s ease-out}.achievement-info{flex:1}.achievement-title{font-size:12px;font-weight:600;color:#ffffffe6;text-transform:uppercase;letter-spacing:.5px}.achievement-name{font-size:18px;font-weight:800;color:#fff;margin:2px 0}.achievement-desc{font-size:13px;color:#fffc;margin-bottom:4px}.achievement-xp{display:inline-block;background:#fff3;padding:4px 8px;border-radius:8px;font-size:12px;font-weight:700;color:gold}.level-up-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:#000000d9;display:flex;align-items:center;justify-content:center;z-index:10001;animation:fadeIn .3s ease-out}.level-up-content{text-align:center;animation:scaleIn .5s cubic-bezier(.68,-.55,.265,1.55)}.level-up-title{font-size:32px;font-weight:800;background:linear-gradient(135deg,gold,#ffed4e);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;text-transform:uppercase;letter-spacing:4px;margin-bottom:16px}.level-up-number{font-size:120px;font-weight:900;color:#fff;text-shadow:0 0 20px rgba(255,215,0,.8),0 0 40px rgba(255,215,0,.6),0 0 60px rgba(255,215,0,.4);animation:glow 1s ease-in-out infinite alternate}.level-up-subtitle{font-size:24px;color:#fffc;font-weight:600;margin-top:16px}.floating-text{position:fixed;font-weight:800;text-shadow:2px 2px 0 #000,-1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000;pointer-events:none;z-index:9998;animation:floatUp 2s ease-out forwards}@keyframes slideIn{0%{transform:translate(400px);opacity:0}to{transform:translate(0);opacity:1}}@keyframes slideOut{0%{transform:translate(0);opacity:1}to{transform:translate(400px);opacity:0}}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes scaleIn{0%{transform:scale(.5);opacity:0}to{transform:scale(1);opacity:1}}@keyframes bounce{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes glow{0%{text-shadow:0 0 20px rgba(255,215,0,.8),0 0 40px rgba(255,215,0,.6),0 0 60px rgba(255,215,0,.4)}to{text-shadow:0 0 30px rgba(255,215,0,1),0 0 60px rgba(255,215,0,.8),0 0 90px rgba(255,215,0,.6)}}@keyframes shine{0%{left:-100%}30%{left:100%}to{left:100%}}@keyframes flicker{0%,to{opacity:1}50%{opacity:.8}}@keyframes floatUp{0%{transform:translateY(0);opacity:1}to{transform:translateY(-100px);opacity:0}}@keyframes xp-pulse{0%{transform:scaleY(1)}50%{transform:scaleY(1.2)}to{transform:scaleY(1)}}@keyframes level-pulse{0%{transform:scale(1)}50%{transform:scale(1.1)}to{transform:scale(1)}}@media
|
|
1
|
+
#game-ui-overlay,.game-ui-overlay{position:fixed;top:0;left:0;width:100%;pointer-events:none;z-index:1000;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}.game-ui-top-bar{display:flex;align-items:center;gap:12px;padding:12px 16px;background:#0000004d;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}.level-badge{display:flex;flex-direction:column;align-items:center;background:linear-gradient(135deg,#667eea,#764ba2);padding:8px 12px;border-radius:12px;box-shadow:0 4px 12px #667eea66;min-width:60px}.level-label{font-size:10px;font-weight:600;color:#fffc;letter-spacing:.5px}.level-number{font-size:24px;font-weight:800;color:#fff;line-height:1}.xp-container{flex:1;display:flex;flex-direction:column;gap:4px}.xp-bar-bg{height:12px;background:#fff3;border-radius:6px;overflow:hidden;box-shadow:inset 0 2px 4px #0003}.xp-bar-fill{height:100%;background:linear-gradient(90deg,#4facfe,#00f2fe);border-radius:6px;transition:width .5s ease-out;box-shadow:0 0 10px #4facfe99}.xp-text{font-size:11px;color:#fff;font-weight:600;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.5)}.streak-badge{display:flex;align-items:center;gap:6px;background:linear-gradient(135deg,#f80,#f40);padding:8px 12px;border-radius:12px;box-shadow:0 4px 12px #f806}.streak-icon{font-size:20px;animation:flicker 2s infinite}.streak-number{font-size:18px;font-weight:800;color:#fff}.achievement-popup{position:fixed;top:80px;right:20px;background:linear-gradient(135deg,#667eea,#764ba2);border-radius:16px;padding:16px;box-shadow:0 8px 32px #0006;animation:slideIn .5s cubic-bezier(.68,-.55,.265,1.55);z-index:10000;max-width:320px;overflow:hidden}.achievement-shine{position:absolute;top:0;left:-100%;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.3),transparent);animation:shine 2s infinite}.achievement-content{position:relative;display:flex;gap:12px;align-items:center}.achievement-icon{font-size:48px;animation:bounce .6s ease-out}.achievement-info{flex:1}.achievement-title{font-size:12px;font-weight:600;color:#ffffffe6;text-transform:uppercase;letter-spacing:.5px}.achievement-name{font-size:18px;font-weight:800;color:#fff;margin:2px 0}.achievement-desc{font-size:13px;color:#fffc;margin-bottom:4px}.achievement-xp{display:inline-block;background:#fff3;padding:4px 8px;border-radius:8px;font-size:12px;font-weight:700;color:gold}.level-up-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:#000000d9;display:flex;align-items:center;justify-content:center;z-index:10001;animation:fadeIn .3s ease-out}.level-up-content{text-align:center;animation:scaleIn .5s cubic-bezier(.68,-.55,.265,1.55)}.level-up-title{font-size:32px;font-weight:800;background:linear-gradient(135deg,gold,#ffed4e);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;text-transform:uppercase;letter-spacing:4px;margin-bottom:16px}.level-up-number{font-size:120px;font-weight:900;color:#fff;text-shadow:0 0 20px rgba(255,215,0,.8),0 0 40px rgba(255,215,0,.6),0 0 60px rgba(255,215,0,.4);animation:glow 1s ease-in-out infinite alternate}.level-up-subtitle{font-size:24px;color:#fffc;font-weight:600;margin-top:16px}.floating-text{position:fixed;font-weight:800;text-shadow:2px 2px 0 #000,-1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000;pointer-events:none;z-index:9998;animation:floatUp 2s ease-out forwards}@keyframes slideIn{0%{transform:translate(400px);opacity:0}to{transform:translate(0);opacity:1}}@keyframes slideOut{0%{transform:translate(0);opacity:1}to{transform:translate(400px);opacity:0}}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes scaleIn{0%{transform:scale(.5);opacity:0}to{transform:scale(1);opacity:1}}@keyframes bounce{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes glow{0%{text-shadow:0 0 20px rgba(255,215,0,.8),0 0 40px rgba(255,215,0,.6),0 0 60px rgba(255,215,0,.4)}to{text-shadow:0 0 30px rgba(255,215,0,1),0 0 60px rgba(255,215,0,.8),0 0 90px rgba(255,215,0,.6)}}@keyframes shine{0%{left:-100%}30%{left:100%}to{left:100%}}@keyframes flicker{0%,to{opacity:1}50%{opacity:.8}}@keyframes floatUp{0%{transform:translateY(0);opacity:1}to{transform:translateY(-100px);opacity:0}}@keyframes xp-pulse{0%{transform:scaleY(1)}50%{transform:scaleY(1.2)}to{transform:scaleY(1)}}@keyframes level-pulse{0%{transform:scale(1)}50%{transform:scale(1.1)}to{transform:scale(1)}}@media(max-width:480px){.game-ui-top-bar{gap:8px;padding:10px 12px}.level-badge{padding:6px 10px;min-width:50px}.level-number{font-size:20px}.xp-bar-bg{height:10px}.xp-text{font-size:10px}.achievement-popup{right:12px;max-width:calc(100% - 24px)}.level-up-number{font-size:80px}}@media(prefers-reduced-motion:reduce){.game-ui-overlay *,.achievement-popup,.achievement-popup *,.level-up-overlay,.level-up-overlay *{animation:none!important;transition:none!important}.floating-text{animation:none!important}}
|
package/package.json
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dopaminejs",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.3.1",
|
|
4
|
+
"description": "Progression mechanics for web apps: XP, levels, achievements and timezone-correct daily streaks, with the particles and sound to make them land. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./dist/dopamine.
|
|
6
|
+
"main": "./dist/dopamine.cjs",
|
|
7
7
|
"module": "./dist/dopamine.js",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./types/index.d.ts",
|
|
11
11
|
"import": "./dist/dopamine.js",
|
|
12
|
-
"require": "./dist/dopamine.
|
|
12
|
+
"require": "./dist/dopamine.cjs"
|
|
13
|
+
},
|
|
14
|
+
"./engine": {
|
|
15
|
+
"types": "./types/index.d.ts",
|
|
16
|
+
"import": "./dist/engine.js",
|
|
17
|
+
"require": "./dist/engine.cjs"
|
|
13
18
|
},
|
|
14
19
|
"./style.css": "./dist/style.css"
|
|
15
20
|
},
|
|
21
|
+
"sideEffects": false,
|
|
16
22
|
"files": [
|
|
17
23
|
"dist",
|
|
18
24
|
"src",
|
|
@@ -21,20 +27,25 @@
|
|
|
21
27
|
"scripts": {
|
|
22
28
|
"dev": "vite",
|
|
23
29
|
"build": "vite build",
|
|
24
|
-
"test": "vitest",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:watch": "vitest",
|
|
25
32
|
"preview": "vite preview"
|
|
26
33
|
},
|
|
27
34
|
"keywords": [
|
|
28
|
-
"
|
|
29
|
-
"
|
|
35
|
+
"gamification",
|
|
36
|
+
"progression",
|
|
37
|
+
"streaks",
|
|
38
|
+
"daily-streak",
|
|
39
|
+
"xp",
|
|
40
|
+
"levels",
|
|
41
|
+
"achievements",
|
|
42
|
+
"engagement",
|
|
43
|
+
"rewards",
|
|
44
|
+
"user-retention",
|
|
30
45
|
"game-feel",
|
|
31
46
|
"juice",
|
|
32
|
-
"feedback",
|
|
33
|
-
"rewards",
|
|
34
47
|
"particles",
|
|
35
|
-
"
|
|
36
|
-
"xp",
|
|
37
|
-
"achievements"
|
|
48
|
+
"confetti"
|
|
38
49
|
],
|
|
39
50
|
"author": "BaryoDev",
|
|
40
51
|
"license": "MPL-2.0",
|
|
@@ -47,5 +58,8 @@
|
|
|
47
58
|
"url": "https://github.com/BaryoDev/dopaminejs/issues"
|
|
48
59
|
},
|
|
49
60
|
"homepage": "https://github.com/BaryoDev/dopaminejs#readme",
|
|
50
|
-
"types": "./types/index.d.ts"
|
|
61
|
+
"types": "./types/index.d.ts",
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=20.19"
|
|
64
|
+
}
|
|
51
65
|
}
|
package/src/core/Collider.js
CHANGED
|
@@ -14,19 +14,55 @@ export class Collider extends Component {
|
|
|
14
14
|
this.tag = null; // For collision filtering
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
get kernel() {
|
|
18
|
+
return this._kernel ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The kernel can arrive after onAttach(), when the collider was added to
|
|
23
|
+
* an object before that object joined a scene. Registering only in
|
|
24
|
+
* onAttach() left such colliders out of physics for good.
|
|
25
|
+
*/
|
|
26
|
+
set kernel(value) {
|
|
27
|
+
if (value === this._kernel) return;
|
|
28
|
+
|
|
29
|
+
this._unregister();
|
|
30
|
+
this._kernel = value;
|
|
31
|
+
this._register();
|
|
32
|
+
}
|
|
33
|
+
|
|
17
34
|
onAttach() {
|
|
18
35
|
super.onAttach();
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
this.physics.add(this);
|
|
22
|
-
}
|
|
36
|
+
this._attached = true;
|
|
37
|
+
this._register();
|
|
23
38
|
}
|
|
24
39
|
|
|
25
40
|
onDetach() {
|
|
26
41
|
super.onDetach();
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
42
|
+
this._attached = false;
|
|
43
|
+
this._unregister();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @private
|
|
48
|
+
*/
|
|
49
|
+
_register() {
|
|
50
|
+
if (this._physics || !this._attached) return;
|
|
51
|
+
|
|
52
|
+
const physics = this.physics;
|
|
53
|
+
if (physics) {
|
|
54
|
+
physics.add(this);
|
|
55
|
+
this._physics = physics;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @private
|
|
61
|
+
*/
|
|
62
|
+
_unregister() {
|
|
63
|
+
if (this._physics) {
|
|
64
|
+
this._physics.remove(this);
|
|
65
|
+
this._physics = null;
|
|
30
66
|
}
|
|
31
67
|
}
|
|
32
68
|
|
|
@@ -16,6 +16,62 @@ export class PluginRegistry {
|
|
|
16
16
|
|
|
17
17
|
// Track plugin load order for proper cleanup
|
|
18
18
|
this._loadOrder = [];
|
|
19
|
+
|
|
20
|
+
// Async inits in flight. Each notes the systems other plugins added
|
|
21
|
+
// meanwhile, so a failed one removes only its own.
|
|
22
|
+
this._inFlight = new Set();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @private
|
|
27
|
+
*/
|
|
28
|
+
_systemsSince(before) {
|
|
29
|
+
return this.kernel.systems.getSystemNames().filter((name) => !before.has(name));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Mark systems as belonging to a plugin that loaded.
|
|
34
|
+
* @private
|
|
35
|
+
*/
|
|
36
|
+
_claim(names) {
|
|
37
|
+
for (const pending of this._inFlight) {
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
pending.foreign.add(name);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Remove the systems a failed plugin registered. They would keep updating
|
|
46
|
+
* with nothing left to remove them.
|
|
47
|
+
* @private
|
|
48
|
+
*/
|
|
49
|
+
_rollback(names) {
|
|
50
|
+
for (const name of names) {
|
|
51
|
+
try {
|
|
52
|
+
this.kernel.systems.unregister(name);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
// The caller is about to rethrow the init error. This one
|
|
55
|
+
// must not replace it or stop the loop.
|
|
56
|
+
console.error(`[PluginRegistry] Error removing system "${name}":`, error);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Forget a plugin whose init failed.
|
|
63
|
+
*
|
|
64
|
+
* Removed by name: with two async inits in flight, the failed plugin is
|
|
65
|
+
* not necessarily the last entry.
|
|
66
|
+
* @private
|
|
67
|
+
*/
|
|
68
|
+
_forget(name) {
|
|
69
|
+
this._plugins.delete(name);
|
|
70
|
+
|
|
71
|
+
const index = this._loadOrder.indexOf(name);
|
|
72
|
+
if (index > -1) {
|
|
73
|
+
this._loadOrder.splice(index, 1);
|
|
74
|
+
}
|
|
19
75
|
}
|
|
20
76
|
|
|
21
77
|
/**
|
|
@@ -51,6 +107,8 @@ export class PluginRegistry {
|
|
|
51
107
|
this._loadOrder.push(plugin.name);
|
|
52
108
|
|
|
53
109
|
// Initialize plugin
|
|
110
|
+
const systemsBefore = new Set(this.kernel.systems.getSystemNames());
|
|
111
|
+
|
|
54
112
|
try {
|
|
55
113
|
plugin.init(this.kernel);
|
|
56
114
|
|
|
@@ -60,11 +118,12 @@ export class PluginRegistry {
|
|
|
60
118
|
version: plugin.version
|
|
61
119
|
});
|
|
62
120
|
|
|
63
|
-
|
|
121
|
+
this._claim(this._systemsSince(systemsBefore));
|
|
64
122
|
} catch (error) {
|
|
65
123
|
console.error(`[PluginRegistry] Failed to initialize plugin "${plugin.name}":`, error);
|
|
66
|
-
this.
|
|
67
|
-
this.
|
|
124
|
+
this._forget(plugin.name);
|
|
125
|
+
this._rollback(this._systemsSince(systemsBefore));
|
|
126
|
+
|
|
68
127
|
throw error;
|
|
69
128
|
}
|
|
70
129
|
|
|
@@ -96,21 +155,39 @@ export class PluginRegistry {
|
|
|
96
155
|
this._plugins.set(plugin.name, plugin);
|
|
97
156
|
this._loadOrder.push(plugin.name);
|
|
98
157
|
|
|
158
|
+
const pending = {
|
|
159
|
+
before: new Set(this.kernel.systems.getSystemNames()),
|
|
160
|
+
foreign: new Set()
|
|
161
|
+
};
|
|
162
|
+
const own = () => this._systemsSince(pending.before).filter((name) => !pending.foreign.has(name));
|
|
163
|
+
this._inFlight.add(pending);
|
|
164
|
+
|
|
99
165
|
// Initialize plugin (await if it returns a promise)
|
|
100
166
|
try {
|
|
101
167
|
await plugin.init(this.kernel);
|
|
102
168
|
|
|
169
|
+
this._inFlight.delete(pending);
|
|
170
|
+
this._claim(own());
|
|
171
|
+
|
|
103
172
|
// Emit event
|
|
104
173
|
this.kernel.events.emit('plugin_loaded', {
|
|
105
174
|
name: plugin.name,
|
|
106
175
|
version: plugin.version
|
|
107
176
|
});
|
|
108
|
-
|
|
109
|
-
// Plugin loaded successfully
|
|
110
177
|
} catch (error) {
|
|
111
178
|
console.error(`[PluginRegistry] Failed to initialize plugin "${plugin.name}":`, error);
|
|
112
|
-
this.
|
|
113
|
-
this.
|
|
179
|
+
this._inFlight.delete(pending);
|
|
180
|
+
this._forget(plugin.name);
|
|
181
|
+
|
|
182
|
+
const leftover = own();
|
|
183
|
+
if (this._inFlight.size === 0) {
|
|
184
|
+
this._rollback(leftover);
|
|
185
|
+
} else if (leftover.length > 0) {
|
|
186
|
+
// Another init is still running and may have registered some
|
|
187
|
+
// of these. Removing them could break a plugin that loads.
|
|
188
|
+
console.warn(`[PluginRegistry] Left systems in place after "${plugin.name}" failed: ${leftover.join(', ')}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
114
191
|
throw error;
|
|
115
192
|
}
|
|
116
193
|
|
package/src/core/Scene.js
CHANGED
|
@@ -4,7 +4,25 @@
|
|
|
4
4
|
export class Scene {
|
|
5
5
|
constructor() {
|
|
6
6
|
this.gameObjects = [];
|
|
7
|
-
this.
|
|
7
|
+
this._kernel = null; // Injected by Game
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
get kernel() {
|
|
11
|
+
return this._kernel;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Objects added before the scene had a kernel receive it here. Scenes are
|
|
16
|
+
* often built in a constructor, before Game.setScene() injects the kernel.
|
|
17
|
+
* The Director sets it to null on exit, which takes colliders out of
|
|
18
|
+
* physics until the scene runs again.
|
|
19
|
+
*/
|
|
20
|
+
set kernel(value) {
|
|
21
|
+
this._kernel = value;
|
|
22
|
+
|
|
23
|
+
for (const gameObject of this.gameObjects) {
|
|
24
|
+
gameObject.kernel = value;
|
|
25
|
+
}
|
|
8
26
|
}
|
|
9
27
|
|
|
10
28
|
/**
|
|
@@ -25,6 +43,13 @@ export class Scene {
|
|
|
25
43
|
if (this.kernel) {
|
|
26
44
|
gameObject.kernel = this.kernel; // Inject kernel
|
|
27
45
|
}
|
|
46
|
+
|
|
47
|
+
// Coming back after a remove(): components were detached then.
|
|
48
|
+
if (gameObject._detached) {
|
|
49
|
+
gameObject._detached = false;
|
|
50
|
+
this._eachComponent(gameObject, (component) => component.onAttach());
|
|
51
|
+
}
|
|
52
|
+
|
|
28
53
|
this.gameObjects.push(gameObject);
|
|
29
54
|
return gameObject;
|
|
30
55
|
}
|
|
@@ -37,6 +62,24 @@ export class Scene {
|
|
|
37
62
|
const index = this.gameObjects.indexOf(gameObject);
|
|
38
63
|
if (index > -1) {
|
|
39
64
|
this.gameObjects.splice(index, 1);
|
|
65
|
+
|
|
66
|
+
// Lets components release what they hold. A Collider left in
|
|
67
|
+
// physics keeps colliding for an object that is gone.
|
|
68
|
+
gameObject._detached = true;
|
|
69
|
+
this._eachComponent(gameObject, (component) => component.onDetach());
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Visit the components of an object and of all its children.
|
|
75
|
+
* @private
|
|
76
|
+
*/
|
|
77
|
+
_eachComponent(gameObject, fn) {
|
|
78
|
+
for (const component of [...gameObject.components]) {
|
|
79
|
+
fn(component);
|
|
80
|
+
}
|
|
81
|
+
for (const child of gameObject.children) {
|
|
82
|
+
this._eachComponent(child, fn);
|
|
40
83
|
}
|
|
41
84
|
}
|
|
42
85
|
|
|
@@ -45,8 +88,10 @@ export class Scene {
|
|
|
45
88
|
* @param {number} dt
|
|
46
89
|
*/
|
|
47
90
|
update(dt) {
|
|
48
|
-
|
|
49
|
-
|
|
91
|
+
// Snapshot: an object may add or remove objects mid-frame, and
|
|
92
|
+
// splicing the live array would skip the one after it.
|
|
93
|
+
for (const obj of [...this.gameObjects]) {
|
|
94
|
+
if (!obj._detached) obj.update(dt);
|
|
50
95
|
}
|
|
51
96
|
}
|
|
52
97
|
|
|
@@ -55,8 +100,8 @@ export class Scene {
|
|
|
55
100
|
* @param {CanvasRenderingContext2D} ctx
|
|
56
101
|
*/
|
|
57
102
|
render(ctx) {
|
|
58
|
-
for (const obj of this.gameObjects) {
|
|
59
|
-
obj.render(ctx);
|
|
103
|
+
for (const obj of [...this.gameObjects]) {
|
|
104
|
+
if (!obj._detached) obj.render(ctx);
|
|
60
105
|
}
|
|
61
106
|
}
|
|
62
107
|
}
|
|
@@ -17,6 +17,25 @@ export class SystemRegistry {
|
|
|
17
17
|
|
|
18
18
|
// Flag to track if order needs recomputation
|
|
19
19
|
this._needsRecompute = false;
|
|
20
|
+
|
|
21
|
+
// Systems that have thrown from an update, so each is logged once
|
|
22
|
+
// instead of sixty times a second.
|
|
23
|
+
this._failed = new WeakSet();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Run one system's update without letting a throw stop the others.
|
|
28
|
+
* @private
|
|
29
|
+
*/
|
|
30
|
+
_run(system, method, dt) {
|
|
31
|
+
try {
|
|
32
|
+
system[method](dt);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (!this._failed.has(system)) {
|
|
35
|
+
this._failed.add(system);
|
|
36
|
+
console.error(`[SystemRegistry] System ${method}() threw:`, error);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
20
39
|
}
|
|
21
40
|
|
|
22
41
|
/**
|
|
@@ -92,16 +111,18 @@ export class SystemRegistry {
|
|
|
92
111
|
return false;
|
|
93
112
|
}
|
|
94
113
|
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
entry.system.destroy
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
114
|
+
// A destroy that throws must not leave the system registered.
|
|
115
|
+
try {
|
|
116
|
+
if (entry.system.destroy) {
|
|
117
|
+
entry.system.destroy();
|
|
118
|
+
}
|
|
119
|
+
} finally {
|
|
120
|
+
this._systems.delete(name);
|
|
121
|
+
this._needsRecompute = true;
|
|
102
122
|
|
|
103
|
-
|
|
104
|
-
|
|
123
|
+
// Emit event
|
|
124
|
+
this.kernel.events.emit('system_unregistered', { name });
|
|
125
|
+
}
|
|
105
126
|
|
|
106
127
|
return true;
|
|
107
128
|
}
|
|
@@ -119,7 +140,7 @@ export class SystemRegistry {
|
|
|
119
140
|
for (let i = 0; i < this._updateOrder.length; i++) {
|
|
120
141
|
const system = this._updateOrder[i];
|
|
121
142
|
if (system.update) {
|
|
122
|
-
system
|
|
143
|
+
this._run(system, 'update', dt);
|
|
123
144
|
}
|
|
124
145
|
}
|
|
125
146
|
}
|
|
@@ -136,7 +157,7 @@ export class SystemRegistry {
|
|
|
136
157
|
for (let i = 0; i < this._fixedUpdateOrder.length; i++) {
|
|
137
158
|
const system = this._fixedUpdateOrder[i];
|
|
138
159
|
if (system.fixedUpdate) {
|
|
139
|
-
system
|
|
160
|
+
this._run(system, 'fixedUpdate', dt);
|
|
140
161
|
}
|
|
141
162
|
}
|
|
142
163
|
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import { resolveStorage } from '../utils/storage.js';
|
|
7
7
|
|
|
8
|
+
const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
9
|
+
|
|
8
10
|
export class SoundManager {
|
|
9
11
|
constructor(config = {}) {
|
|
10
12
|
this.audioContext = null;
|
|
@@ -18,12 +20,27 @@ export class SoundManager {
|
|
|
18
20
|
|
|
19
21
|
// Master gain, created with the AudioContext on first use.
|
|
20
22
|
this.masterGain = null;
|
|
21
|
-
this._volume =
|
|
23
|
+
this._volume = 1;
|
|
24
|
+
this.setVolume(config.volume ?? 1);
|
|
22
25
|
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
// In-flight loads by key, so a play() during preload does not fetch
|
|
27
|
+
// the same file twice.
|
|
28
|
+
this._loading = new Map();
|
|
29
|
+
|
|
30
|
+
// Queued follow-up tones, so destroy() can cancel them.
|
|
31
|
+
this._timers = new Set();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* setTimeout that destroy() can cancel.
|
|
36
|
+
* @private
|
|
37
|
+
*/
|
|
38
|
+
_later(fn, delay) {
|
|
39
|
+
const id = setTimeout(() => {
|
|
40
|
+
this._timers.delete(id);
|
|
41
|
+
fn();
|
|
42
|
+
}, delay);
|
|
43
|
+
this._timers.add(id);
|
|
27
44
|
}
|
|
28
45
|
|
|
29
46
|
/**
|
|
@@ -43,6 +60,10 @@ export class SoundManager {
|
|
|
43
60
|
this.masterGain = this.audioContext.createGain();
|
|
44
61
|
this.masterGain.gain.value = this._volume;
|
|
45
62
|
this.masterGain.connect(this.audioContext.destination);
|
|
63
|
+
|
|
64
|
+
// Decoding needs a context, so sounds registered before this
|
|
65
|
+
// point could not be loaded until now.
|
|
66
|
+
this.preloadSounds(this.customSounds);
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
if (this.audioContext.state === 'suspended') {
|
|
@@ -57,6 +78,9 @@ export class SoundManager {
|
|
|
57
78
|
* @param {number} value - 0..1
|
|
58
79
|
*/
|
|
59
80
|
setVolume(value) {
|
|
81
|
+
// NaN would pass through Math.min/max and silence the gain node.
|
|
82
|
+
if (!Number.isFinite(value)) return this._volume;
|
|
83
|
+
|
|
60
84
|
this._volume = Math.max(0, Math.min(1, value));
|
|
61
85
|
if (this.masterGain) {
|
|
62
86
|
this.masterGain.gain.value = this._volume;
|
|
@@ -116,19 +140,39 @@ export class SoundManager {
|
|
|
116
140
|
* Load and decode a sound file
|
|
117
141
|
*/
|
|
118
142
|
async loadSound(key, url) {
|
|
143
|
+
// We need an audio context to decode. Without one this waits for
|
|
144
|
+
// initAudio(), which loads everything registered so far.
|
|
145
|
+
if (!this.audioContext) return;
|
|
146
|
+
|
|
147
|
+
if (this._loading.has(key)) {
|
|
148
|
+
return this._loading.get(key);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const loading = this._load(key, url).finally(() => this._loading.delete(key));
|
|
152
|
+
this._loading.set(key, loading);
|
|
153
|
+
return loading;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @private
|
|
158
|
+
*/
|
|
159
|
+
async _load(key, url) {
|
|
119
160
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
161
|
+
const context = this.audioContext;
|
|
162
|
+
const response = await fetch(url);
|
|
163
|
+
|
|
164
|
+
// fetch resolves on a 404. Its body is an error page, not audio.
|
|
165
|
+
if (response.ok === false) {
|
|
166
|
+
throw new Error(`HTTP ${response.status}`);
|
|
126
167
|
}
|
|
127
168
|
|
|
128
|
-
const response = await fetch(url);
|
|
129
169
|
const arrayBuffer = await response.arrayBuffer();
|
|
130
|
-
const audioBuffer = await
|
|
131
|
-
|
|
170
|
+
const audioBuffer = await context.decodeAudioData(arrayBuffer);
|
|
171
|
+
|
|
172
|
+
// destroy() may have run while this was in flight.
|
|
173
|
+
if (this.audioContext === context) {
|
|
174
|
+
this.assets.set(key, audioBuffer);
|
|
175
|
+
}
|
|
132
176
|
} catch (error) {
|
|
133
177
|
console.warn(`[DopamineJS] Failed to load sound '${key}':`, error);
|
|
134
178
|
}
|
|
@@ -149,8 +193,14 @@ export class SoundManager {
|
|
|
149
193
|
}
|
|
150
194
|
|
|
151
195
|
// 2. Try to load if registered but not loaded
|
|
152
|
-
if (this.customSounds
|
|
196
|
+
if (hasOwn(this.customSounds, key) && !this.assets.has(key)) {
|
|
197
|
+
const context = this.audioContext;
|
|
153
198
|
await this.loadSound(key, this.customSounds[key]);
|
|
199
|
+
|
|
200
|
+
// destroy() ran while the file loaded. The synth fallback would
|
|
201
|
+
// open a new context.
|
|
202
|
+
if (this.audioContext !== context) return;
|
|
203
|
+
|
|
154
204
|
if (this.assets.has(key)) {
|
|
155
205
|
this._playBuffer(this.assets.get(key));
|
|
156
206
|
return;
|
|
@@ -167,7 +217,7 @@ export class SoundManager {
|
|
|
167
217
|
'gameover': () => this.playGameOver(true)
|
|
168
218
|
};
|
|
169
219
|
|
|
170
|
-
if (synthMap
|
|
220
|
+
if (hasOwn(synthMap, key)) {
|
|
171
221
|
synthMap[key]();
|
|
172
222
|
}
|
|
173
223
|
}
|
|
@@ -214,14 +264,14 @@ export class SoundManager {
|
|
|
214
264
|
playScore(force = false) {
|
|
215
265
|
if (!force && this.assets.has('score')) return this.play('score');
|
|
216
266
|
this.playTone(800, 0.15, 'sine', 0.25);
|
|
217
|
-
|
|
267
|
+
this._later(() => this.playTone(1000, 0.15, 'sine', 0.25), 50);
|
|
218
268
|
}
|
|
219
269
|
|
|
220
270
|
playGameOver(force = false) {
|
|
221
271
|
if (!force && this.assets.has('gameover')) return this.play('gameover');
|
|
222
272
|
this.playTone(300, 0.2, 'sawtooth', 0.3);
|
|
223
|
-
|
|
224
|
-
|
|
273
|
+
this._later(() => this.playTone(200, 0.2, 'sawtooth', 0.3), 100);
|
|
274
|
+
this._later(() => this.playTone(150, 0.3, 'sawtooth', 0.3), 200);
|
|
225
275
|
}
|
|
226
276
|
|
|
227
277
|
playClick(force = false) {
|
|
@@ -232,11 +282,32 @@ export class SoundManager {
|
|
|
232
282
|
playSuccess(force = false) {
|
|
233
283
|
if (!force && this.assets.has('success')) return this.play('success');
|
|
234
284
|
this.playTone(600, 0.1, 'sine', 0.2);
|
|
235
|
-
|
|
285
|
+
this._later(() => this.playTone(800, 0.2, 'sine', 0.2), 100);
|
|
236
286
|
}
|
|
237
287
|
|
|
238
288
|
playError(force = false) {
|
|
239
289
|
if (!force && this.assets.has('error')) return this.play('error');
|
|
240
290
|
this.playTone(200, 0.2, 'sawtooth', 0.3);
|
|
241
291
|
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Cancel queued tones, close the audio context and drop decoded buffers.
|
|
295
|
+
* Browsers cap the number of live AudioContexts per page.
|
|
296
|
+
*/
|
|
297
|
+
destroy() {
|
|
298
|
+
for (const id of this._timers) {
|
|
299
|
+
clearTimeout(id);
|
|
300
|
+
}
|
|
301
|
+
this._timers.clear();
|
|
302
|
+
|
|
303
|
+
if (this.audioContext) {
|
|
304
|
+
const closing = this.audioContext.close?.();
|
|
305
|
+
closing?.catch?.(() => {});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
this.audioContext = null;
|
|
309
|
+
this.masterGain = null;
|
|
310
|
+
this.assets.clear();
|
|
311
|
+
this._loading.clear();
|
|
312
|
+
}
|
|
242
313
|
}
|
|
@@ -15,16 +15,17 @@ export class ScreenShake extends Component {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
shake(intensity = 5, duration = 0.2) {
|
|
18
|
+
// Save original if not already shaking. Mid-shake the object sits at
|
|
19
|
+
// an offset, and saving that would move its resting position.
|
|
20
|
+
if (!this.isShaking) {
|
|
21
|
+
this.originalPos.x = this.gameObject.x;
|
|
22
|
+
this.originalPos.y = this.gameObject.y;
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
this.intensity = intensity;
|
|
19
26
|
this.duration = duration;
|
|
20
27
|
this.timer = 0;
|
|
21
28
|
this.isShaking = true;
|
|
22
|
-
|
|
23
|
-
// Save original if not already shaking
|
|
24
|
-
if (this.timer === 0) {
|
|
25
|
-
this.originalPos.x = this.gameObject.x;
|
|
26
|
-
this.originalPos.y = this.gameObject.y;
|
|
27
|
-
}
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
update(dt) {
|