dopaminejs 1.0.2 → 1.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/ARCHITECTURE.md +71 -0
- package/CHANGELOG.md +27 -0
- package/README.md +22 -0
- package/dist/dopamine.mjs +161 -97
- package/dist/dopamine.umd.js +6 -6
- package/package.json +4 -2
- package/src/audio/SoundManager.js +118 -12
package/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# DopamineJS Architecture & Roadmap 🏗️
|
|
2
|
+
|
|
3
|
+
**Vision**: To become the standard "Game Feel Engine" for the web—a plug-and-play layer that handles the "juice" (feedback, rewards, satisfaction) so developers can focus on core mechanics.
|
|
4
|
+
|
|
5
|
+
## 🧠 Design Philosophy
|
|
6
|
+
1. **Zero-Config Start**: It must work beautifully out of the box.
|
|
7
|
+
2. **Infinite Extensibility**: Every default (sound, particle, UI style) must be replaceable.
|
|
8
|
+
3. **Asset Agnostic**: Support synthesized sounds/shapes (no assets) AND custom assets (mp3/png).
|
|
9
|
+
4. **AI-First**: APIs should be self-documenting and predictable for AI agents to use effectively.
|
|
10
|
+
|
|
11
|
+
## 🗺️ Roadmap
|
|
12
|
+
|
|
13
|
+
### Phase 1: Audio Extensibility (v1.1.0)
|
|
14
|
+
**Goal**: Allow developers to bring their own soundscapes.
|
|
15
|
+
|
|
16
|
+
- **[NEW] Asset Loader**: A lightweight preloader for audio files.
|
|
17
|
+
- **[UPDATE] SoundManager**:
|
|
18
|
+
- Add `registerSound(key, url)` method.
|
|
19
|
+
- Support `Howler.js` integration (optional) or robust Web Audio buffer caching.
|
|
20
|
+
- **Feature**: "Sound Packs" - Allow switching between 'Retro', 'Modern', 'Cute' preset packs.
|
|
21
|
+
|
|
22
|
+
### Phase 2: Visual Customization (v1.2.0)
|
|
23
|
+
**Goal**: Break free from geometric primitives.
|
|
24
|
+
|
|
25
|
+
- **[UPDATE] ParticleSystem**:
|
|
26
|
+
- **Sprite Support**: Allow `image` or `sprite` properties in particle config.
|
|
27
|
+
- **Custom Emitters**: Define custom particle behaviors (gravity, velocity, life, decay) via JSON config.
|
|
28
|
+
- **Editor**: (Long term) A web-based particle editor that exports JSON for DopamineJS.
|
|
29
|
+
|
|
30
|
+
### Phase 3: UI Theming & Templates (v1.3.0)
|
|
31
|
+
**Goal**: Make the UI fit any game art style.
|
|
32
|
+
|
|
33
|
+
- **[NEW] Theme Engine**:
|
|
34
|
+
- Use CSS Variables for all colors, fonts, and spacing.
|
|
35
|
+
- `Dopamine.setTheme('dark-cyberpunk')`
|
|
36
|
+
- **[UPDATE] GameUI**:
|
|
37
|
+
- **Icon Sets**: Allow passing an icon map (SVG strings or URLs) to replace default emojis.
|
|
38
|
+
- **Slots**: Allow developers to inject custom HTML into notifications or level-up screens.
|
|
39
|
+
|
|
40
|
+
### Phase 4: The "Dopamine Ecosystem" (v2.0.0)
|
|
41
|
+
**Goal**: Community-driven content.
|
|
42
|
+
|
|
43
|
+
- **Plugin System**: Middleware for the `RewardSystem` (e.g., "BattlePass Plugin", "Leaderboard Plugin").
|
|
44
|
+
- **Backend Integration**: Webhooks for `onLevelUp` or `onAchievement` to validate rewards on a server.
|
|
45
|
+
|
|
46
|
+
## 📝 Developer Guide: How to Extend (Current Best Practices)
|
|
47
|
+
|
|
48
|
+
While we build these features, here is how you can extend DopamineJS today:
|
|
49
|
+
|
|
50
|
+
### Custom Achievements
|
|
51
|
+
You can already define any achievement logic you want:
|
|
52
|
+
|
|
53
|
+
```javascript
|
|
54
|
+
achievements: {
|
|
55
|
+
'die_100_times': {
|
|
56
|
+
name: 'You Tried',
|
|
57
|
+
icon: '<img src="skull.png" width="20">', // HTML is supported in icons!
|
|
58
|
+
check: (p) => p.stats.deaths >= 100
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Custom CSS
|
|
64
|
+
Override the default styles by adding your own CSS *after* importing DopamineJS:
|
|
65
|
+
|
|
66
|
+
```css
|
|
67
|
+
/* Override the XP bar color */
|
|
68
|
+
.xp-bar-fill {
|
|
69
|
+
background: linear-gradient(90deg, #ff00cc, #3333ff) !important;
|
|
70
|
+
}
|
|
71
|
+
```
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [1.1.0] - 2025-12-04
|
|
6
|
+
### Added
|
|
7
|
+
- **Audio Extensibility**: Support for custom audio files (`.mp3`, `.wav`).
|
|
8
|
+
- `SoundManager.registerSound(key, url)`: Register custom assets.
|
|
9
|
+
- `SoundManager.play(key)`: Unified API to play custom sounds or fallback to synth.
|
|
10
|
+
- Support for `customSounds` in the initial configuration.
|
|
11
|
+
|
|
12
|
+
## [1.0.2] - 2025-12-04
|
|
13
|
+
### Fixed
|
|
14
|
+
- Fixed `npm run release` script failing due to missing `vitest` dependency.
|
|
15
|
+
- Uncommented `npm publish` in deployment script to ensure package is actually published to npm.
|
|
16
|
+
|
|
17
|
+
## [1.0.1] - 2025-12-04
|
|
18
|
+
### Added
|
|
19
|
+
- Initial release of DopamineJS.
|
|
20
|
+
- Core systems: `RewardSystem`, `ParticleSystem`, `SoundManager`, `GameUI`.
|
|
21
|
+
- Basic gamification features: XP, Levels, Streaks, Achievements.
|
|
22
|
+
- Built-in visual effects: Confetti, Coin Shower, Sparkles, Fire, Star Burst.
|
|
23
|
+
- Synthesized sound effects.
|
|
24
|
+
- Comprehensive README with "Vibe Coding" instructions for AI agents.
|
|
25
|
+
|
|
26
|
+
## [1.0.0] - 2025-12-04
|
|
27
|
+
- Initial scaffold.
|
package/README.md
CHANGED
|
@@ -121,6 +121,28 @@ Pre-loaded satisfying sound effects (generated synthetically or minimal assets).
|
|
|
121
121
|
soundManager.playSuccess();
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
+
### 🎧 Audio Customization (New in v1.1.0)
|
|
125
|
+
You can replace the default synthesized sounds with your own audio files (`.mp3`, `.wav`, etc.).
|
|
126
|
+
|
|
127
|
+
```javascript
|
|
128
|
+
// 1. Register custom sounds
|
|
129
|
+
soundManager.registerSound('jump', '/assets/audio/jump_v2.mp3');
|
|
130
|
+
soundManager.registerSound('win', '/assets/audio/epic_win.wav');
|
|
131
|
+
|
|
132
|
+
// 2. Play them (auto-loads on first play if not preloaded)
|
|
133
|
+
soundManager.play('jump');
|
|
134
|
+
|
|
135
|
+
// 3. Or pass them in initial config
|
|
136
|
+
const app = new Dopamine({
|
|
137
|
+
sound: {
|
|
138
|
+
customSounds: {
|
|
139
|
+
'click': '/assets/ui_click.mp3',
|
|
140
|
+
'level_up': '/assets/level_up.mp3'
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
124
146
|
### 🎨 Game UI
|
|
125
147
|
Built-in UI components to show progress without coding HTML/CSS.
|
|
126
148
|
- **Notifications**: "Achievement Unlocked", "Level Up", etc.
|
package/dist/dopamine.mjs
CHANGED
|
@@ -25,11 +25,11 @@ class p {
|
|
|
25
25
|
* @param {*} data - Event data
|
|
26
26
|
*/
|
|
27
27
|
emit(t, e) {
|
|
28
|
-
this.listeners.has(t) && this.listeners.get(t).forEach((
|
|
28
|
+
this.listeners.has(t) && this.listeners.get(t).forEach((s) => {
|
|
29
29
|
try {
|
|
30
|
-
|
|
31
|
-
} catch (
|
|
32
|
-
console.error(`Error in listener for event '${t}':`,
|
|
30
|
+
s(e);
|
|
31
|
+
} catch (i) {
|
|
32
|
+
console.error(`Error in listener for event '${t}':`, i);
|
|
33
33
|
}
|
|
34
34
|
});
|
|
35
35
|
}
|
|
@@ -87,10 +87,10 @@ class m extends p {
|
|
|
87
87
|
* @returns {Object} { leveledUp: boolean, newLevel: number, xpGained: number }
|
|
88
88
|
*/
|
|
89
89
|
async addXP(t, e = "") {
|
|
90
|
-
const
|
|
90
|
+
const s = this.player.level;
|
|
91
91
|
this.player.xp += t;
|
|
92
|
-
const
|
|
93
|
-
return a && (this.player.level =
|
|
92
|
+
const i = this._calculateLevel(this.player.xp), a = i > s;
|
|
93
|
+
return a && (this.player.level = i), await this.save(), this.emit("xp_gained", { amount: t, reason: e, leveledUp: a, newLevel: i }), a && this.emit("level_up", { oldLevel: s, newLevel: i }), { leveledUp: a, newLevel: i, xpGained: t };
|
|
94
94
|
}
|
|
95
95
|
/**
|
|
96
96
|
* Calculate level from XP (exponential curve)
|
|
@@ -103,11 +103,11 @@ class m extends p {
|
|
|
103
103
|
* Get XP needed for next level
|
|
104
104
|
*/
|
|
105
105
|
getXPForNextLevel() {
|
|
106
|
-
const t = this.player.level + 1, e = 50 * t * (t - 1),
|
|
106
|
+
const t = this.player.level + 1, e = 50 * t * (t - 1), s = 50 * this.player.level * (this.player.level - 1);
|
|
107
107
|
return {
|
|
108
108
|
total: e,
|
|
109
109
|
needed: e - this.player.xp,
|
|
110
|
-
progress: (this.player.xp -
|
|
110
|
+
progress: (this.player.xp - s) / (e - s)
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
113
|
/**
|
|
@@ -117,21 +117,21 @@ class m extends p {
|
|
|
117
117
|
*/
|
|
118
118
|
async recordGame(t, e) {
|
|
119
119
|
this.player.stats[t] || (this.player.stats[t] = { totalPlays: 0, highScore: 0 });
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
a !== "score" && (
|
|
120
|
+
const s = this.player.stats[t];
|
|
121
|
+
s.totalPlays++, this.player.totalGamesPlayed++, e.score !== void 0 && e.score > s.highScore && (s.highScore = e.score, this.emit("new_high_score", { gameName: t, score: e.score })), Object.keys(e).forEach((a) => {
|
|
122
|
+
a !== "score" && (s[a] = (s[a] || 0) + (typeof e[a] == "number" ? e[a] : 0));
|
|
123
123
|
});
|
|
124
|
-
let
|
|
125
|
-
e.score && (
|
|
124
|
+
let i = 10;
|
|
125
|
+
e.score && (i += Math.floor(e.score / 5)), i = Math.floor(i * this.getStreakMultiplier()), await this.addXP(i, `Played ${t}`), await this.checkAchievements(t, e), await this.save();
|
|
126
126
|
}
|
|
127
127
|
/**
|
|
128
128
|
* Check and unlock achievements
|
|
129
129
|
*/
|
|
130
130
|
async checkAchievements(t, e) {
|
|
131
|
-
const
|
|
132
|
-
for (const [
|
|
133
|
-
this.player.achievements[
|
|
134
|
-
return
|
|
131
|
+
const s = [];
|
|
132
|
+
for (const [i, a] of Object.entries(this.achievements))
|
|
133
|
+
this.player.achievements[i] || a.check(this.player, t, e) && (await this.unlockAchievement(i), s.push(a));
|
|
134
|
+
return s;
|
|
135
135
|
}
|
|
136
136
|
/**
|
|
137
137
|
* Unlock an achievement
|
|
@@ -174,8 +174,8 @@ class m extends p {
|
|
|
174
174
|
const t = this._getTodayDateString(), e = this.player.streak.lastPlayDate;
|
|
175
175
|
if (e === t)
|
|
176
176
|
return;
|
|
177
|
-
const
|
|
178
|
-
e ===
|
|
177
|
+
const s = this._getYesterdayDateString();
|
|
178
|
+
e === s ? (this.player.streak.current++, this.player.streak.current > this.player.streak.longest && (this.player.streak.longest = this.player.streak.current)) : this.player.streak.current = 1, this.player.streak.lastPlayDate = t;
|
|
179
179
|
}
|
|
180
180
|
/**
|
|
181
181
|
* Get streak multiplier for XP
|
|
@@ -242,7 +242,7 @@ class m extends p {
|
|
|
242
242
|
};
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
|
-
class
|
|
245
|
+
class u {
|
|
246
246
|
constructor(t = {}) {
|
|
247
247
|
this.storage = t.storage || window.localStorage, this.prefix = t.prefix || "dopamine_";
|
|
248
248
|
}
|
|
@@ -253,10 +253,10 @@ class y {
|
|
|
253
253
|
*/
|
|
254
254
|
async save(t, e) {
|
|
255
255
|
try {
|
|
256
|
-
const
|
|
257
|
-
return this.storage.setItem(this.prefix + t,
|
|
258
|
-
} catch (
|
|
259
|
-
return console.error("Error saving data:",
|
|
256
|
+
const s = JSON.stringify(e);
|
|
257
|
+
return this.storage.setItem(this.prefix + t, s), !0;
|
|
258
|
+
} catch (s) {
|
|
259
|
+
return console.error("Error saving data:", s), !1;
|
|
260
260
|
}
|
|
261
261
|
}
|
|
262
262
|
/**
|
|
@@ -266,10 +266,10 @@ class y {
|
|
|
266
266
|
*/
|
|
267
267
|
async load(t, e = null) {
|
|
268
268
|
try {
|
|
269
|
-
const
|
|
270
|
-
return
|
|
271
|
-
} catch (
|
|
272
|
-
return console.error("Error loading data:",
|
|
269
|
+
const s = this.storage.getItem(this.prefix + t);
|
|
270
|
+
return s ? JSON.parse(s) : e;
|
|
271
|
+
} catch (s) {
|
|
272
|
+
return console.error("Error loading data:", s), e;
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
275
|
/**
|
|
@@ -280,7 +280,7 @@ class y {
|
|
|
280
280
|
this.storage.removeItem(this.prefix + t);
|
|
281
281
|
}
|
|
282
282
|
}
|
|
283
|
-
class
|
|
283
|
+
class y {
|
|
284
284
|
constructor(t) {
|
|
285
285
|
this.particleSystem = t, this.container = null, this.xpBar = null, this.levelDisplay = null, this.streakDisplay = null, this.init();
|
|
286
286
|
}
|
|
@@ -312,9 +312,9 @@ class v {
|
|
|
312
312
|
/**
|
|
313
313
|
* Update XP bar
|
|
314
314
|
*/
|
|
315
|
-
updateXP(t, e,
|
|
316
|
-
const
|
|
317
|
-
this.xpBar.style.width = `${Math.min(100,
|
|
315
|
+
updateXP(t, e, s) {
|
|
316
|
+
const i = t / s * 100;
|
|
317
|
+
this.xpBar.style.width = `${Math.min(100, i)}%`, this.xpText.textContent = `${t} / ${s} XP`, this.xpBar.style.animation = "none", setTimeout(() => {
|
|
318
318
|
this.xpBar.style.animation = "xp-pulse 0.3s ease-out";
|
|
319
319
|
}, 10);
|
|
320
320
|
}
|
|
@@ -353,8 +353,8 @@ class v {
|
|
|
353
353
|
</div>
|
|
354
354
|
</div>
|
|
355
355
|
`, document.body.appendChild(e), setTimeout(() => {
|
|
356
|
-
const
|
|
357
|
-
this.particleSystem.confetti(
|
|
356
|
+
const s = e.getBoundingClientRect();
|
|
357
|
+
this.particleSystem.confetti(s.left + s.width / 2, s.top + s.height / 2, 40);
|
|
358
358
|
}, 300), setTimeout(() => {
|
|
359
359
|
e.style.animation = "slideOut 0.3s ease-in forwards", setTimeout(() => e.remove(), 300);
|
|
360
360
|
}, 4e3);
|
|
@@ -363,35 +363,35 @@ class v {
|
|
|
363
363
|
* Show level up screen
|
|
364
364
|
*/
|
|
365
365
|
showLevelUp(t, e) {
|
|
366
|
-
const
|
|
367
|
-
|
|
366
|
+
const s = document.createElement("div");
|
|
367
|
+
s.className = "level-up-overlay", s.innerHTML = `
|
|
368
368
|
<div class="level-up-content">
|
|
369
369
|
<div class="level-up-title">LEVEL UP!</div>
|
|
370
370
|
<div class="level-up-number">${e}</div>
|
|
371
371
|
<div class="level-up-subtitle">Amazing progress!</div>
|
|
372
372
|
</div>
|
|
373
|
-
`, document.body.appendChild(
|
|
374
|
-
for (let
|
|
373
|
+
`, document.body.appendChild(s), setTimeout(() => {
|
|
374
|
+
for (let i = 0; i < 5; i++)
|
|
375
375
|
setTimeout(() => {
|
|
376
376
|
const a = Math.random() * window.innerWidth, n = Math.random() * window.innerHeight * 0.6;
|
|
377
377
|
this.particleSystem.confetti(a, n, 30);
|
|
378
|
-
},
|
|
378
|
+
}, i * 200);
|
|
379
379
|
}, 300), setTimeout(() => {
|
|
380
|
-
|
|
380
|
+
s.style.opacity = "0", setTimeout(() => s.remove(), 500);
|
|
381
381
|
}, 2500);
|
|
382
382
|
}
|
|
383
383
|
/**
|
|
384
384
|
* Show floating text (e.g., "+50 XP", "COMBO!")
|
|
385
385
|
*/
|
|
386
|
-
showFloatingText(t, e,
|
|
386
|
+
showFloatingText(t, e, s, i = "#ffd700", a = "24px") {
|
|
387
387
|
const n = document.createElement("div");
|
|
388
|
-
n.className = "floating-text", n.textContent = t, n.style.left = e + "px", n.style.top =
|
|
388
|
+
n.className = "floating-text", n.textContent = t, n.style.left = e + "px", n.style.top = s + "px", n.style.color = i, n.style.fontSize = a, document.body.appendChild(n), setTimeout(() => n.remove(), 2e3);
|
|
389
389
|
}
|
|
390
390
|
/**
|
|
391
391
|
* Show combo multiplier
|
|
392
392
|
*/
|
|
393
|
-
showCombo(t, e,
|
|
394
|
-
this.showFloatingText(`${t}× COMBO!`, e,
|
|
393
|
+
showCombo(t, e, s) {
|
|
394
|
+
this.showFloatingText(`${t}× COMBO!`, e, s, "#ff6b6b", "32px"), this.particleSystem.fire(e, s, 20);
|
|
395
395
|
}
|
|
396
396
|
/**
|
|
397
397
|
* Show "near miss"indicator
|
|
@@ -402,15 +402,15 @@ class v {
|
|
|
402
402
|
/**
|
|
403
403
|
* Show lucky moment (random multiplier)
|
|
404
404
|
*/
|
|
405
|
-
showLuckyMoment(t, e,
|
|
406
|
-
this.showFloatingText(`LUCKY ${t}×!`, e,
|
|
405
|
+
showLuckyMoment(t, e, s) {
|
|
406
|
+
this.showFloatingText(`LUCKY ${t}×!`, e, s, "#ffd700", "36px"), this.particleSystem.starBurst(e, s, 12), this.particleSystem.confetti(e, s, 25);
|
|
407
407
|
}
|
|
408
408
|
/**
|
|
409
409
|
* Show generic notification
|
|
410
410
|
*/
|
|
411
411
|
showNotification(t, e = "normal") {
|
|
412
|
-
const
|
|
413
|
-
e === "legendary" ? this.showLuckyMoment(t,
|
|
412
|
+
const s = window.innerWidth / 2, i = window.innerHeight / 3;
|
|
413
|
+
e === "legendary" ? this.showLuckyMoment(t, s, i) : e === "rare" ? this.showCombo(t, s, i) : this.showFloatingText(t, s, i, "#fff", "24px");
|
|
414
414
|
}
|
|
415
415
|
/**
|
|
416
416
|
* Show Game Over Summary
|
|
@@ -418,19 +418,19 @@ class v {
|
|
|
418
418
|
showSummary(t) {
|
|
419
419
|
const e = document.createElement("div");
|
|
420
420
|
e.className = "message-box", e.style.display = "flex", e.style.position = "absolute", e.style.top = "50%", e.style.left = "50%", e.style.transform = "translate(-50%, -50%)", e.style.zIndex = "1000";
|
|
421
|
-
let
|
|
422
|
-
t.metrics && (
|
|
421
|
+
let s = "";
|
|
422
|
+
t.metrics && (s = Object.entries(t.metrics).map(([i, a]) => `<div style="display:flex;justify-content:space-between;width:100%"><span>${i}:</span> <strong>${a}</strong></div>`).join("")), e.innerHTML = `
|
|
423
423
|
<h1>Game Over</h1>
|
|
424
424
|
<div style="font-size: 3rem; font-weight: bold; color: #e06020; margin: 10px 0;">${t.score}</div>
|
|
425
425
|
<div style="width: 100%; margin-bottom: 20px;">
|
|
426
|
-
${
|
|
426
|
+
${s}
|
|
427
427
|
</div>
|
|
428
428
|
<button class="btn" onclick="location.reload()">Play Again</button>
|
|
429
429
|
<button class="btn" onclick="window.location.href='index.html'" style="margin-top: 10px; background: #543847;">Exit</button>
|
|
430
430
|
`, document.body.appendChild(e);
|
|
431
431
|
}
|
|
432
432
|
}
|
|
433
|
-
class
|
|
433
|
+
class v {
|
|
434
434
|
constructor(t = {}) {
|
|
435
435
|
this.canvasId = t.canvasId || "dopamine-particle-canvas", this.canvas = document.getElementById(this.canvasId), this.canvas || (this.canvas = document.createElement("canvas"), this.canvas.id = this.canvasId, this.canvas.style.position = "fixed", this.canvas.style.top = "0", this.canvas.style.left = "0", this.canvas.style.width = "100%", this.canvas.style.height = "100%", this.canvas.style.pointerEvents = "none", this.canvas.style.zIndex = t.zIndex || "9999", document.body.appendChild(this.canvas)), this.ctx = this.canvas.getContext("2d"), this.particles = [], this.isAnimating = !1, this._resize(), window.addEventListener("resize", () => this._resize());
|
|
436
436
|
}
|
|
@@ -440,9 +440,9 @@ class u {
|
|
|
440
440
|
/**
|
|
441
441
|
* Confetti explosion (for achievements, level ups)
|
|
442
442
|
*/
|
|
443
|
-
confetti(t, e,
|
|
444
|
-
const
|
|
445
|
-
for (let a = 0; a <
|
|
443
|
+
confetti(t, e, s = 50) {
|
|
444
|
+
const i = ["#ff6b6b", "#4ecdc4", "#45b7d1", "#f7dc6f", "#c06c84", "#6c5ce7"];
|
|
445
|
+
for (let a = 0; a < s; a++)
|
|
446
446
|
this.particles.push({
|
|
447
447
|
x: t,
|
|
448
448
|
y: e,
|
|
@@ -450,7 +450,7 @@ class u {
|
|
|
450
450
|
vy: (Math.random() - 0.5) * 10 - 5,
|
|
451
451
|
rotation: Math.random() * Math.PI * 2,
|
|
452
452
|
rotationSpeed: (Math.random() - 0.5) * 0.2,
|
|
453
|
-
color:
|
|
453
|
+
color: i[Math.floor(Math.random() * i.length)],
|
|
454
454
|
size: Math.random() * 8 + 4,
|
|
455
455
|
life: 1,
|
|
456
456
|
decay: 0.015 + Math.random() * 0.01,
|
|
@@ -462,15 +462,15 @@ class u {
|
|
|
462
462
|
/**
|
|
463
463
|
* Sparkle effect (for score milestones, combos)
|
|
464
464
|
*/
|
|
465
|
-
sparkle(t, e,
|
|
466
|
-
for (let a = 0; a <
|
|
467
|
-
const n = Math.PI * 2 /
|
|
465
|
+
sparkle(t, e, s = 20, i = "#ffd700") {
|
|
466
|
+
for (let a = 0; a < s; a++) {
|
|
467
|
+
const n = Math.PI * 2 / s * a, r = 2 + Math.random() * 3;
|
|
468
468
|
this.particles.push({
|
|
469
469
|
x: t,
|
|
470
470
|
y: e,
|
|
471
471
|
vx: Math.cos(n) * r,
|
|
472
472
|
vy: Math.sin(n) * r,
|
|
473
|
-
color:
|
|
473
|
+
color: i,
|
|
474
474
|
size: Math.random() * 4 + 2,
|
|
475
475
|
life: 1,
|
|
476
476
|
decay: 0.02,
|
|
@@ -484,8 +484,8 @@ class u {
|
|
|
484
484
|
/**
|
|
485
485
|
* Coin shower (for collecting coins)
|
|
486
486
|
*/
|
|
487
|
-
coinShower(t, e,
|
|
488
|
-
for (let
|
|
487
|
+
coinShower(t, e, s = 10) {
|
|
488
|
+
for (let i = 0; i < s; i++)
|
|
489
489
|
this.particles.push({
|
|
490
490
|
x: t + (Math.random() - 0.5) * 100,
|
|
491
491
|
y: e - Math.random() * 50,
|
|
@@ -503,15 +503,15 @@ class u {
|
|
|
503
503
|
/**
|
|
504
504
|
* Fire particles (for streaks, combos)
|
|
505
505
|
*/
|
|
506
|
-
fire(t, e,
|
|
507
|
-
const
|
|
508
|
-
for (let a = 0; a <
|
|
506
|
+
fire(t, e, s = 15) {
|
|
507
|
+
const i = ["#ff6b00", "#ff8800", "#ffaa00", "#ff4400"];
|
|
508
|
+
for (let a = 0; a < s; a++)
|
|
509
509
|
this.particles.push({
|
|
510
510
|
x: t + (Math.random() - 0.5) * 20,
|
|
511
511
|
y: e,
|
|
512
512
|
vx: (Math.random() - 0.5) * 2,
|
|
513
513
|
vy: -Math.random() * 3 - 2,
|
|
514
|
-
color:
|
|
514
|
+
color: i[Math.floor(Math.random() * i.length)],
|
|
515
515
|
size: Math.random() * 6 + 3,
|
|
516
516
|
life: 1,
|
|
517
517
|
decay: 0.025,
|
|
@@ -524,9 +524,9 @@ class u {
|
|
|
524
524
|
/**
|
|
525
525
|
* Star burst (for "perfect" moments)
|
|
526
526
|
*/
|
|
527
|
-
starBurst(t, e,
|
|
528
|
-
for (let
|
|
529
|
-
const a = Math.PI * 2 /
|
|
527
|
+
starBurst(t, e, s = 8) {
|
|
528
|
+
for (let i = 0; i < s; i++) {
|
|
529
|
+
const a = Math.PI * 2 / s * i, n = 4;
|
|
530
530
|
this.particles.push({
|
|
531
531
|
x: t,
|
|
532
532
|
y: e,
|
|
@@ -558,15 +558,15 @@ class u {
|
|
|
558
558
|
}
|
|
559
559
|
this.particles.length > 0 ? requestAnimationFrame(() => this._animate()) : this.isAnimating = !1;
|
|
560
560
|
}
|
|
561
|
-
_drawStar(t, e,
|
|
561
|
+
_drawStar(t, e, s, i, a, n) {
|
|
562
562
|
let r = Math.PI / 2 * 3 + n;
|
|
563
|
-
const c = Math.PI /
|
|
564
|
-
this.ctx.beginPath(), this.ctx.moveTo(t, e -
|
|
565
|
-
for (let d = 0; d <
|
|
566
|
-
let l = t + Math.cos(r) *
|
|
563
|
+
const c = Math.PI / s;
|
|
564
|
+
this.ctx.beginPath(), this.ctx.moveTo(t, e - i);
|
|
565
|
+
for (let d = 0; d < s; d++) {
|
|
566
|
+
let l = t + Math.cos(r) * i, h = e + Math.sin(r) * i;
|
|
567
567
|
this.ctx.lineTo(l, h), r += c, l = t + Math.cos(r) * a, h = e + Math.sin(r) * a, this.ctx.lineTo(l, h), r += c;
|
|
568
568
|
}
|
|
569
|
-
this.ctx.lineTo(t, e -
|
|
569
|
+
this.ctx.lineTo(t, e - i), this.ctx.closePath(), this.ctx.fill();
|
|
570
570
|
}
|
|
571
571
|
/**
|
|
572
572
|
* Clear all particles
|
|
@@ -577,13 +577,17 @@ class u {
|
|
|
577
577
|
}
|
|
578
578
|
class f {
|
|
579
579
|
constructor(t = {}) {
|
|
580
|
-
this.audioContext = null, this.storageKey = t.storageKey || "dopamineSoundsMuted", this.muted = localStorage.getItem(this.storageKey) === "true";
|
|
580
|
+
this.audioContext = null, this.storageKey = t.storageKey || "dopamineSoundsMuted", this.muted = localStorage.getItem(this.storageKey) === "true", this.assets = /* @__PURE__ */ new Map(), this.soundPack = t.soundPack || "synth", this.customSounds = t.customSounds || {}, Object.keys(this.customSounds).length > 0 && this.preloadSounds(this.customSounds);
|
|
581
581
|
}
|
|
582
582
|
/**
|
|
583
583
|
* Initialize Audio Context (must be called after user interaction)
|
|
584
584
|
*/
|
|
585
585
|
initAudio() {
|
|
586
|
-
|
|
586
|
+
if (!this.audioContext) {
|
|
587
|
+
const t = window.AudioContext || window.webkitAudioContext;
|
|
588
|
+
this.audioContext = new t();
|
|
589
|
+
}
|
|
590
|
+
this.audioContext.state === "suspended" && this.audioContext.resume();
|
|
587
591
|
}
|
|
588
592
|
/**
|
|
589
593
|
* Toggle mute state
|
|
@@ -592,42 +596,102 @@ class f {
|
|
|
592
596
|
toggleMute() {
|
|
593
597
|
return this.muted = !this.muted, localStorage.setItem(this.storageKey, this.muted), this.muted;
|
|
594
598
|
}
|
|
599
|
+
/**
|
|
600
|
+
* Register a custom sound URL
|
|
601
|
+
* @param {string} key - Unique identifier (e.g., 'jump', 'win')
|
|
602
|
+
* @param {string} url - Path to audio file
|
|
603
|
+
*/
|
|
604
|
+
registerSound(t, e) {
|
|
605
|
+
this.customSounds[t] = e, this.audioContext && this.loadSound(t, e);
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Preload multiple sounds
|
|
609
|
+
* @param {Object} soundMap - { key: url }
|
|
610
|
+
*/
|
|
611
|
+
async preloadSounds(t) {
|
|
612
|
+
const e = Object.entries(t).map(([s, i]) => this.loadSound(s, i));
|
|
613
|
+
await Promise.allSettled(e);
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Load and decode a sound file
|
|
617
|
+
*/
|
|
618
|
+
async loadSound(t, e) {
|
|
619
|
+
try {
|
|
620
|
+
if (!this.audioContext)
|
|
621
|
+
return;
|
|
622
|
+
const i = await (await fetch(e)).arrayBuffer(), a = await this.audioContext.decodeAudioData(i);
|
|
623
|
+
this.assets.set(t, a);
|
|
624
|
+
} catch (s) {
|
|
625
|
+
console.warn(`[DopamineJS] Failed to load sound '${t}':`, s);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Play a sound by key (custom) or fallback to synth
|
|
630
|
+
* @param {string} key - 'jump', 'score', 'success', etc.
|
|
631
|
+
*/
|
|
632
|
+
async play(t) {
|
|
633
|
+
if (this.muted) return;
|
|
634
|
+
if (this.initAudio(), this.assets.has(t)) {
|
|
635
|
+
this._playBuffer(this.assets.get(t));
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (this.customSounds[t] && !this.assets.has(t) && (await this.loadSound(t, this.customSounds[t]), this.assets.has(t))) {
|
|
639
|
+
this._playBuffer(this.assets.get(t));
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const e = {
|
|
643
|
+
jump: () => this.playJump(!0),
|
|
644
|
+
// true = force synth
|
|
645
|
+
score: () => this.playScore(!0),
|
|
646
|
+
success: () => this.playSuccess(!0),
|
|
647
|
+
click: () => this.playClick(!0),
|
|
648
|
+
error: () => this.playError(!0),
|
|
649
|
+
gameover: () => this.playGameOver(!0)
|
|
650
|
+
};
|
|
651
|
+
e[t] && e[t]();
|
|
652
|
+
}
|
|
653
|
+
_playBuffer(t) {
|
|
654
|
+
const e = this.audioContext.createBufferSource();
|
|
655
|
+
e.buffer = t, e.connect(this.audioContext.destination), e.start(0);
|
|
656
|
+
}
|
|
595
657
|
/**
|
|
596
658
|
* Play a synthesized tone
|
|
597
|
-
* @param {number} frequency
|
|
598
|
-
* @param {number} duration
|
|
599
|
-
* @param {string} type - 'sine', 'square', 'sawtooth', 'triangle'
|
|
600
|
-
* @param {number} volume
|
|
601
659
|
*/
|
|
602
|
-
playTone(t, e,
|
|
660
|
+
playTone(t, e, s = "sine", i = 0.3) {
|
|
603
661
|
if (this.muted) return;
|
|
604
662
|
this.initAudio();
|
|
605
663
|
const a = this.audioContext.createOscillator(), n = this.audioContext.createGain();
|
|
606
|
-
a.connect(n), n.connect(this.audioContext.destination), a.frequency.value = t, a.type =
|
|
664
|
+
a.connect(n), n.connect(this.audioContext.destination), a.frequency.value = t, a.type = s, n.gain.setValueAtTime(i, this.audioContext.currentTime), n.gain.exponentialRampToValueAtTime(0.01, this.audioContext.currentTime + e), a.start(this.audioContext.currentTime), a.stop(this.audioContext.currentTime + e);
|
|
607
665
|
}
|
|
608
|
-
// --- Presets ---
|
|
609
|
-
playJump() {
|
|
666
|
+
// --- Presets (Synth) ---
|
|
667
|
+
playJump(t = !1) {
|
|
668
|
+
if (!t && this.assets.has("jump")) return this.play("jump");
|
|
610
669
|
this.playTone(400, 0.1, "square", 0.2);
|
|
611
670
|
}
|
|
612
|
-
playScore() {
|
|
671
|
+
playScore(t = !1) {
|
|
672
|
+
if (!t && this.assets.has("score")) return this.play("score");
|
|
613
673
|
this.playTone(800, 0.15, "sine", 0.25), setTimeout(() => this.playTone(1e3, 0.15, "sine", 0.25), 50);
|
|
614
674
|
}
|
|
615
|
-
playGameOver() {
|
|
675
|
+
playGameOver(t = !1) {
|
|
676
|
+
if (!t && this.assets.has("gameover")) return this.play("gameover");
|
|
616
677
|
this.playTone(300, 0.2, "sawtooth", 0.3), setTimeout(() => this.playTone(200, 0.2, "sawtooth", 0.3), 100), setTimeout(() => this.playTone(150, 0.3, "sawtooth", 0.3), 200);
|
|
617
678
|
}
|
|
618
|
-
playClick() {
|
|
679
|
+
playClick(t = !1) {
|
|
680
|
+
if (!t && this.assets.has("click")) return this.play("click");
|
|
619
681
|
this.playTone(600, 0.05, "sine", 0.1);
|
|
620
682
|
}
|
|
621
|
-
playSuccess() {
|
|
683
|
+
playSuccess(t = !1) {
|
|
684
|
+
if (!t && this.assets.has("success")) return this.play("success");
|
|
622
685
|
this.playTone(600, 0.1, "sine", 0.2), setTimeout(() => this.playTone(800, 0.2, "sine", 0.2), 100);
|
|
623
686
|
}
|
|
624
|
-
playError() {
|
|
687
|
+
playError(t = !1) {
|
|
688
|
+
if (!t && this.assets.has("error")) return this.play("error");
|
|
625
689
|
this.playTone(200, 0.2, "sawtooth", 0.3);
|
|
626
690
|
}
|
|
627
691
|
}
|
|
628
692
|
class g {
|
|
629
693
|
constructor(t = {}) {
|
|
630
|
-
this.config = t, this.dataService = new
|
|
694
|
+
this.config = t, this.dataService = new u(t.data || {}), this.rewardSystem = new m(this.dataService, t.rewards || {}), this.soundManager = new f(t.sound || {}), this.particleSystem = new v(t.particles || {}), this.gameUI = new y(this.particleSystem), this._bindEvents();
|
|
631
695
|
}
|
|
632
696
|
async init() {
|
|
633
697
|
return await this.rewardSystem.init(), console.log("DopamineJS Initialized 🚀"), {
|
|
@@ -639,8 +703,8 @@ class g {
|
|
|
639
703
|
}
|
|
640
704
|
_bindEvents() {
|
|
641
705
|
this.rewardSystem.on("xp_gained", (t) => {
|
|
642
|
-
const { total: e, needed:
|
|
643
|
-
this.gameUI.updateXP(this.rewardSystem.player.xp,
|
|
706
|
+
const { total: e, needed: s } = this.rewardSystem.getXPForNextLevel();
|
|
707
|
+
this.gameUI.updateXP(this.rewardSystem.player.xp, s, e), t.xpGained > 0;
|
|
644
708
|
}), this.rewardSystem.on("level_up", (t) => {
|
|
645
709
|
this.gameUI.updateLevel(t.newLevel), this.gameUI.showLevelUp(t.oldLevel, t.newLevel);
|
|
646
710
|
}), this.rewardSystem.on("achievement_unlocked", (t) => {
|
|
@@ -651,9 +715,9 @@ class g {
|
|
|
651
715
|
}
|
|
652
716
|
}
|
|
653
717
|
export {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
718
|
+
u as DataService,
|
|
719
|
+
y as GameUI,
|
|
720
|
+
v as ParticleSystem,
|
|
657
721
|
m as RewardSystem,
|
|
658
722
|
f as SoundManager,
|
|
659
723
|
g as default
|
package/dist/dopamine.umd.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
(function(r,h){typeof exports=="object"&&typeof module<"u"?h(exports):typeof define=="function"&&define.amd?define(["exports"],h):(r=typeof globalThis<"u"?globalThis:r||self,h(r.Dopamine={}))})(this,function(r){"use strict";class h{constructor(){this.listeners=new Map}on(t,e){return this.listeners.has(t)||this.listeners.set(t,new Set),this.listeners.get(t).add(e),()=>this.off(t,e)}off(t,e){this.listeners.has(t)&&this.listeners.get(t).delete(e)}emit(t,e){this.listeners.has(t)&&this.listeners.get(t).forEach(
|
|
1
|
+
(function(r,h){typeof exports=="object"&&typeof module<"u"?h(exports):typeof define=="function"&&define.amd?define(["exports"],h):(r=typeof globalThis<"u"?globalThis:r||self,h(r.Dopamine={}))})(this,function(r){"use strict";class h{constructor(){this.listeners=new Map}on(t,e){return this.listeners.has(t)||this.listeners.set(t,new Set),this.listeners.get(t).add(e),()=>this.off(t,e)}off(t,e){this.listeners.has(t)&&this.listeners.get(t).delete(e)}emit(t,e){this.listeners.has(t)&&this.listeners.get(t).forEach(s=>{try{s(e)}catch(i){console.error(`Error in listener for event '${t}':`,i)}})}clear(){this.listeners.clear()}}class p extends h{constructor(t,e={}){super(),this.dataService=t,this.player=null,this.achievements={...this._initAchievements(),...e.achievements||{}}}async init(){return this.player=await this.dataService.load("player",this._getDefaultPlayer()),this._updateDailyStreak(),await this.save(),this.player}_getDefaultPlayer(){return{name:"Player",xp:0,level:1,totalGamesPlayed:0,createdAt:Date.now(),lastPlayedAt:Date.now(),streak:{current:1,longest:1,lastPlayDate:this._getTodayDateString()},achievements:{},stats:{}}}async save(){this.player&&(this.player.lastPlayedAt=Date.now(),await this.dataService.save("player",this.player))}async addXP(t,e=""){const s=this.player.level;this.player.xp+=t;const i=this._calculateLevel(this.player.xp),a=i>s;return a&&(this.player.level=i),await this.save(),this.emit("xp_gained",{amount:t,reason:e,leveledUp:a,newLevel:i}),a&&this.emit("level_up",{oldLevel:s,newLevel:i}),{leveledUp:a,newLevel:i,xpGained:t}}_calculateLevel(t){const e=Math.floor((1+Math.sqrt(1+8*t/50))/2);return Math.max(1,e)}getXPForNextLevel(){const t=this.player.level+1,e=50*t*(t-1),s=50*this.player.level*(this.player.level-1);return{total:e,needed:e-this.player.xp,progress:(this.player.xp-s)/(e-s)}}async recordGame(t,e){this.player.stats[t]||(this.player.stats[t]={totalPlays:0,highScore:0});const s=this.player.stats[t];s.totalPlays++,this.player.totalGamesPlayed++,e.score!==void 0&&e.score>s.highScore&&(s.highScore=e.score,this.emit("new_high_score",{gameName:t,score:e.score})),Object.keys(e).forEach(a=>{a!=="score"&&(s[a]=(s[a]||0)+(typeof e[a]=="number"?e[a]:0))});let i=10;e.score&&(i+=Math.floor(e.score/5)),i=Math.floor(i*this.getStreakMultiplier()),await this.addXP(i,`Played ${t}`),await this.checkAchievements(t,e),await this.save()}async checkAchievements(t,e){const s=[];for(const[i,a]of Object.entries(this.achievements))this.player.achievements[i]||a.check(this.player,t,e)&&(await this.unlockAchievement(i),s.push(a));return s}async unlockAchievement(t){const e=this.achievements[t];return e?(this.player.achievements[t]={unlockedAt:Date.now(),seen:!1},await this.addXP(e.xp,`Achievement: ${e.name}`),this.emit("achievement_unlocked",e),await this.save(),!0):!1}getUnlockedAchievements(){return Object.entries(this.player.achievements).map(([t,e])=>({...this.achievements[t],id:t,...e}))}getUnseenAchievements(){return this.getUnlockedAchievements().filter(t=>!t.seen)}async markAchievementsSeen(t){for(const e of t)this.player.achievements[e]&&(this.player.achievements[e].seen=!0);await this.save()}_updateDailyStreak(){const t=this._getTodayDateString(),e=this.player.streak.lastPlayDate;if(e===t)return;const s=this._getYesterdayDateString();e===s?(this.player.streak.current++,this.player.streak.current>this.player.streak.longest&&(this.player.streak.longest=this.player.streak.current)):this.player.streak.current=1,this.player.streak.lastPlayDate=t}getStreakMultiplier(){const t=this.player.streak.current;return t>=7?2:t>=5?1.5:t>=3?1.25:1}_getTodayDateString(){return new Date().toISOString().split("T")[0]}_getYesterdayDateString(){const t=new Date;return t.setDate(t.getDate()-1),t.toISOString().split("T")[0]}_initAchievements(){return{first_game:{name:"First Steps",description:"Play your first game",icon:"🎮",xp:50,check:t=>t.totalGamesPlayed>=1},level_5:{name:"Rising Star",description:"Reach level 5",icon:"⭐",xp:100,check:t=>t.level>=5},level_10:{name:"Expert Player",description:"Reach level 10",icon:"🏆",xp:250,check:t=>t.level>=10},streak_3:{name:"Dedicated",description:"Play 3 days in a row",icon:"🔥",xp:100,check:t=>t.streak.current>=3},streak_7:{name:"Unstoppable",description:"Play 7 days in a row",icon:"💥",xp:300,check:t=>t.streak.current>=7}}}}class u{constructor(t={}){this.storage=t.storage||window.localStorage,this.prefix=t.prefix||"dopamine_"}async save(t,e){try{const s=JSON.stringify(e);return this.storage.setItem(this.prefix+t,s),!0}catch(s){return console.error("Error saving data:",s),!1}}async load(t,e=null){try{const s=this.storage.getItem(this.prefix+t);return s?JSON.parse(s):e}catch(s){return console.error("Error loading data:",s),e}}async clear(t){this.storage.removeItem(this.prefix+t)}}class m{constructor(t){this.particleSystem=t,this.container=null,this.xpBar=null,this.levelDisplay=null,this.streakDisplay=null,this.init()}init(){this.container=document.createElement("div"),this.container.id="game-ui-overlay",this.container.innerHTML=`
|
|
2
2
|
<div class="game-ui-top-bar">
|
|
3
3
|
<div class="level-badge" id="level-badge">
|
|
4
4
|
<span class="level-label">LVL</span>
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
<span class="streak-number" id="streak-number">1</span>
|
|
18
18
|
</div>
|
|
19
19
|
</div>
|
|
20
|
-
`,document.body.appendChild(this.container),this.xpBar=document.getElementById("xp-bar-fill"),this.xpText=document.getElementById("xp-text"),this.levelDisplay=document.getElementById("level-number"),this.streakDisplay=document.getElementById("streak-number")}updateXP(t,e,
|
|
20
|
+
`,document.body.appendChild(this.container),this.xpBar=document.getElementById("xp-bar-fill"),this.xpText=document.getElementById("xp-text"),this.levelDisplay=document.getElementById("level-number"),this.streakDisplay=document.getElementById("streak-number")}updateXP(t,e,s){const i=t/s*100;this.xpBar.style.width=`${Math.min(100,i)}%`,this.xpText.textContent=`${t} / ${s} XP`,this.xpBar.style.animation="none",setTimeout(()=>{this.xpBar.style.animation="xp-pulse 0.3s ease-out"},10)}updateLevel(t){this.levelDisplay.textContent=t;const e=document.getElementById("level-badge");e.style.animation="none",setTimeout(()=>{e.style.animation="level-pulse 0.5s ease-out"},10)}updateStreak(t){this.streakDisplay.textContent=t;const e=document.getElementById("streak-badge");t>=7?e.style.background="linear-gradient(135deg, #ff6b00 0%, #ff4400 100%)":t>=3&&(e.style.background="linear-gradient(135deg, #ff8800 0%, #ff6b00 100%)")}showAchievement(t){const e=document.createElement("div");e.className="achievement-popup",e.innerHTML=`
|
|
21
21
|
<div class="achievement-shine"></div>
|
|
22
22
|
<div class="achievement-content">
|
|
23
23
|
<div class="achievement-icon">${t.icon}</div>
|
|
@@ -28,18 +28,18 @@
|
|
|
28
28
|
<div class="achievement-xp">+${t.xp} XP</div>
|
|
29
29
|
</div>
|
|
30
30
|
</div>
|
|
31
|
-
`,document.body.appendChild(e),setTimeout(()=>{const
|
|
31
|
+
`,document.body.appendChild(e),setTimeout(()=>{const s=e.getBoundingClientRect();this.particleSystem.confetti(s.left+s.width/2,s.top+s.height/2,40)},300),setTimeout(()=>{e.style.animation="slideOut 0.3s ease-in forwards",setTimeout(()=>e.remove(),300)},4e3)}showLevelUp(t,e){const s=document.createElement("div");s.className="level-up-overlay",s.innerHTML=`
|
|
32
32
|
<div class="level-up-content">
|
|
33
33
|
<div class="level-up-title">LEVEL UP!</div>
|
|
34
34
|
<div class="level-up-number">${e}</div>
|
|
35
35
|
<div class="level-up-subtitle">Amazing progress!</div>
|
|
36
36
|
</div>
|
|
37
|
-
`,document.body.appendChild(
|
|
37
|
+
`,document.body.appendChild(s),setTimeout(()=>{for(let i=0;i<5;i++)setTimeout(()=>{const a=Math.random()*window.innerWidth,n=Math.random()*window.innerHeight*.6;this.particleSystem.confetti(a,n,30)},i*200)},300),setTimeout(()=>{s.style.opacity="0",setTimeout(()=>s.remove(),500)},2500)}showFloatingText(t,e,s,i="#ffd700",a="24px"){const n=document.createElement("div");n.className="floating-text",n.textContent=t,n.style.left=e+"px",n.style.top=s+"px",n.style.color=i,n.style.fontSize=a,document.body.appendChild(n),setTimeout(()=>n.remove(),2e3)}showCombo(t,e,s){this.showFloatingText(`${t}× COMBO!`,e,s,"#ff6b6b","32px"),this.particleSystem.fire(e,s,20)}showNearMiss(t,e){this.showFloatingText("CLOSE CALL!",t,e,"#45b7d1","20px"),this.particleSystem.sparkle(t,e,15,"#45b7d1")}showLuckyMoment(t,e,s){this.showFloatingText(`LUCKY ${t}×!`,e,s,"#ffd700","36px"),this.particleSystem.starBurst(e,s,12),this.particleSystem.confetti(e,s,25)}showNotification(t,e="normal"){const s=window.innerWidth/2,i=window.innerHeight/3;e==="legendary"?this.showLuckyMoment(t,s,i):e==="rare"?this.showCombo(t,s,i):this.showFloatingText(t,s,i,"#fff","24px")}showSummary(t){const e=document.createElement("div");e.className="message-box",e.style.display="flex",e.style.position="absolute",e.style.top="50%",e.style.left="50%",e.style.transform="translate(-50%, -50%)",e.style.zIndex="1000";let s="";t.metrics&&(s=Object.entries(t.metrics).map(([i,a])=>`<div style="display:flex;justify-content:space-between;width:100%"><span>${i}:</span> <strong>${a}</strong></div>`).join("")),e.innerHTML=`
|
|
38
38
|
<h1>Game Over</h1>
|
|
39
39
|
<div style="font-size: 3rem; font-weight: bold; color: #e06020; margin: 10px 0;">${t.score}</div>
|
|
40
40
|
<div style="width: 100%; margin-bottom: 20px;">
|
|
41
|
-
${
|
|
41
|
+
${s}
|
|
42
42
|
</div>
|
|
43
43
|
<button class="btn" onclick="location.reload()">Play Again</button>
|
|
44
44
|
<button class="btn" onclick="window.location.href='index.html'" style="margin-top: 10px; background: #543847;">Exit</button>
|
|
45
|
-
`,document.body.appendChild(e)}}class
|
|
45
|
+
`,document.body.appendChild(e)}}class y{constructor(t={}){this.canvasId=t.canvasId||"dopamine-particle-canvas",this.canvas=document.getElementById(this.canvasId),this.canvas||(this.canvas=document.createElement("canvas"),this.canvas.id=this.canvasId,this.canvas.style.position="fixed",this.canvas.style.top="0",this.canvas.style.left="0",this.canvas.style.width="100%",this.canvas.style.height="100%",this.canvas.style.pointerEvents="none",this.canvas.style.zIndex=t.zIndex||"9999",document.body.appendChild(this.canvas)),this.ctx=this.canvas.getContext("2d"),this.particles=[],this.isAnimating=!1,this._resize(),window.addEventListener("resize",()=>this._resize())}_resize(){this.canvas.width=window.innerWidth,this.canvas.height=window.innerHeight}confetti(t,e,s=50){const i=["#ff6b6b","#4ecdc4","#45b7d1","#f7dc6f","#c06c84","#6c5ce7"];for(let a=0;a<s;a++)this.particles.push({x:t,y:e,vx:(Math.random()-.5)*10,vy:(Math.random()-.5)*10-5,rotation:Math.random()*Math.PI*2,rotationSpeed:(Math.random()-.5)*.2,color:i[Math.floor(Math.random()*i.length)],size:Math.random()*8+4,life:1,decay:.015+Math.random()*.01,gravity:.3,type:"confetti"});this._startAnimation()}sparkle(t,e,s=20,i="#ffd700"){for(let a=0;a<s;a++){const n=Math.PI*2/s*a,o=2+Math.random()*3;this.particles.push({x:t,y:e,vx:Math.cos(n)*o,vy:Math.sin(n)*o,color:i,size:Math.random()*4+2,life:1,decay:.02,gravity:-.1,type:"spark"})}this._startAnimation()}coinShower(t,e,s=10){for(let i=0;i<s;i++)this.particles.push({x:t+(Math.random()-.5)*100,y:e-Math.random()*50,vx:(Math.random()-.5)*3,vy:Math.random()*-2-3,color:"#ffd700",size:12,life:1,decay:.012,gravity:.4,type:"coin"});this._startAnimation()}fire(t,e,s=15){const i=["#ff6b00","#ff8800","#ffaa00","#ff4400"];for(let a=0;a<s;a++)this.particles.push({x:t+(Math.random()-.5)*20,y:e,vx:(Math.random()-.5)*2,vy:-Math.random()*3-2,color:i[Math.floor(Math.random()*i.length)],size:Math.random()*6+3,life:1,decay:.025,gravity:-.2,type:"fire"});this._startAnimation()}starBurst(t,e,s=8){for(let i=0;i<s;i++){const a=Math.PI*2/s*i,n=4;this.particles.push({x:t,y:e,vx:Math.cos(a)*n,vy:Math.sin(a)*n,rotation:a,color:"#ffffff",size:15,life:1,decay:.03,gravity:0,type:"star"})}this._startAnimation()}_startAnimation(){this.isAnimating||(this.isAnimating=!0,this._animate())}_animate(){this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height);for(let t=this.particles.length-1;t>=0;t--){const e=this.particles[t];if(e.vy+=e.gravity,e.x+=e.vx,e.y+=e.vy,e.life-=e.decay,e.rotationSpeed&&(e.rotation+=e.rotationSpeed),e.life<=0){this.particles.splice(t,1);continue}this.ctx.save(),this.ctx.globalAlpha=e.life,this.ctx.fillStyle=e.color,e.type==="confetti"?(this.ctx.translate(e.x,e.y),this.ctx.rotate(e.rotation),this.ctx.fillRect(-e.size/2,-e.size/2,e.size,e.size/2)):e.type==="coin"?(this.ctx.beginPath(),this.ctx.arc(e.x,e.y,e.size,0,Math.PI*2),this.ctx.fill(),this.ctx.fillStyle="#ffee88",this.ctx.beginPath(),this.ctx.arc(e.x-e.size/3,e.y-e.size/3,e.size/3,0,Math.PI*2),this.ctx.fill()):e.type==="star"?this._drawStar(e.x,e.y,5,e.size,e.size/2,e.rotation):(this.ctx.beginPath(),this.ctx.arc(e.x,e.y,e.size,0,Math.PI*2),this.ctx.fill()),this.ctx.restore()}this.particles.length>0?requestAnimationFrame(()=>this._animate()):this.isAnimating=!1}_drawStar(t,e,s,i,a,n){let o=Math.PI/2*3+n;const f=Math.PI/s;this.ctx.beginPath(),this.ctx.moveTo(t,e-i);for(let g=0;g<s;g++){let c=t+Math.cos(o)*i,d=e+Math.sin(o)*i;this.ctx.lineTo(c,d),o+=f,c=t+Math.cos(o)*a,d=e+Math.sin(o)*a,this.ctx.lineTo(c,d),o+=f}this.ctx.lineTo(t,e-i),this.ctx.closePath(),this.ctx.fill()}clear(){this.particles=[],this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height)}}class v{constructor(t={}){this.audioContext=null,this.storageKey=t.storageKey||"dopamineSoundsMuted",this.muted=localStorage.getItem(this.storageKey)==="true",this.assets=new Map,this.soundPack=t.soundPack||"synth",this.customSounds=t.customSounds||{},Object.keys(this.customSounds).length>0&&this.preloadSounds(this.customSounds)}initAudio(){if(!this.audioContext){const t=window.AudioContext||window.webkitAudioContext;this.audioContext=new t}this.audioContext.state==="suspended"&&this.audioContext.resume()}toggleMute(){return this.muted=!this.muted,localStorage.setItem(this.storageKey,this.muted),this.muted}registerSound(t,e){this.customSounds[t]=e,this.audioContext&&this.loadSound(t,e)}async preloadSounds(t){const e=Object.entries(t).map(([s,i])=>this.loadSound(s,i));await Promise.allSettled(e)}async loadSound(t,e){try{if(!this.audioContext)return;const i=await(await fetch(e)).arrayBuffer(),a=await this.audioContext.decodeAudioData(i);this.assets.set(t,a)}catch(s){console.warn(`[DopamineJS] Failed to load sound '${t}':`,s)}}async play(t){if(this.muted)return;if(this.initAudio(),this.assets.has(t)){this._playBuffer(this.assets.get(t));return}if(this.customSounds[t]&&!this.assets.has(t)&&(await this.loadSound(t,this.customSounds[t]),this.assets.has(t))){this._playBuffer(this.assets.get(t));return}const e={jump:()=>this.playJump(!0),score:()=>this.playScore(!0),success:()=>this.playSuccess(!0),click:()=>this.playClick(!0),error:()=>this.playError(!0),gameover:()=>this.playGameOver(!0)};e[t]&&e[t]()}_playBuffer(t){const e=this.audioContext.createBufferSource();e.buffer=t,e.connect(this.audioContext.destination),e.start(0)}playTone(t,e,s="sine",i=.3){if(this.muted)return;this.initAudio();const a=this.audioContext.createOscillator(),n=this.audioContext.createGain();a.connect(n),n.connect(this.audioContext.destination),a.frequency.value=t,a.type=s,n.gain.setValueAtTime(i,this.audioContext.currentTime),n.gain.exponentialRampToValueAtTime(.01,this.audioContext.currentTime+e),a.start(this.audioContext.currentTime),a.stop(this.audioContext.currentTime+e)}playJump(t=!1){if(!t&&this.assets.has("jump"))return this.play("jump");this.playTone(400,.1,"square",.2)}playScore(t=!1){if(!t&&this.assets.has("score"))return this.play("score");this.playTone(800,.15,"sine",.25),setTimeout(()=>this.playTone(1e3,.15,"sine",.25),50)}playGameOver(t=!1){if(!t&&this.assets.has("gameover"))return this.play("gameover");this.playTone(300,.2,"sawtooth",.3),setTimeout(()=>this.playTone(200,.2,"sawtooth",.3),100),setTimeout(()=>this.playTone(150,.3,"sawtooth",.3),200)}playClick(t=!1){if(!t&&this.assets.has("click"))return this.play("click");this.playTone(600,.05,"sine",.1)}playSuccess(t=!1){if(!t&&this.assets.has("success"))return this.play("success");this.playTone(600,.1,"sine",.2),setTimeout(()=>this.playTone(800,.2,"sine",.2),100)}playError(t=!1){if(!t&&this.assets.has("error"))return this.play("error");this.playTone(200,.2,"sawtooth",.3)}}class x{constructor(t={}){this.config=t,this.dataService=new u(t.data||{}),this.rewardSystem=new p(this.dataService,t.rewards||{}),this.soundManager=new v(t.sound||{}),this.particleSystem=new y(t.particles||{}),this.gameUI=new m(this.particleSystem),this._bindEvents()}async init(){return await this.rewardSystem.init(),console.log("DopamineJS Initialized 🚀"),{rewardSystem:this.rewardSystem,gameUI:this.gameUI,particleSystem:this.particleSystem,soundManager:this.soundManager}}_bindEvents(){this.rewardSystem.on("xp_gained",t=>{const{total:e,needed:s}=this.rewardSystem.getXPForNextLevel();this.gameUI.updateXP(this.rewardSystem.player.xp,s,e),t.xpGained>0}),this.rewardSystem.on("level_up",t=>{this.gameUI.updateLevel(t.newLevel),this.gameUI.showLevelUp(t.oldLevel,t.newLevel)}),this.rewardSystem.on("achievement_unlocked",t=>{this.gameUI.showAchievement(t)}),this.rewardSystem.on("new_high_score",t=>{this.gameUI.showNotification(`New High Score: ${t.score}!`,"legendary"),this.soundManager.playSuccess()})}}r.DataService=u,r.GameUI=m,r.ParticleSystem=y,r.RewardSystem=p,r.SoundManager=v,r.default=x,Object.defineProperties(r,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dopaminejs",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A lightweight library for adding juice, rewards, and feedback to HTML5 games.",
|
|
5
5
|
"main": "dist/dopamine.umd.js",
|
|
6
6
|
"module": "dist/dopamine.mjs",
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
"dist",
|
|
10
10
|
"src",
|
|
11
11
|
"README.md",
|
|
12
|
-
"LICENSE"
|
|
12
|
+
"LICENSE",
|
|
13
|
+
"CHANGELOG.md",
|
|
14
|
+
"ARCHITECTURE.md"
|
|
13
15
|
],
|
|
14
16
|
"exports": {
|
|
15
17
|
".": {
|
|
@@ -8,6 +8,16 @@ export class SoundManager {
|
|
|
8
8
|
this.audioContext = null;
|
|
9
9
|
this.storageKey = config.storageKey || 'dopamineSoundsMuted';
|
|
10
10
|
this.muted = localStorage.getItem(this.storageKey) === 'true';
|
|
11
|
+
|
|
12
|
+
// Asset management
|
|
13
|
+
this.assets = new Map(); // key -> AudioBuffer
|
|
14
|
+
this.soundPack = config.soundPack || 'synth'; // 'synth' or custom
|
|
15
|
+
this.customSounds = config.customSounds || {}; // key -> url
|
|
16
|
+
|
|
17
|
+
// Preload custom sounds if provided
|
|
18
|
+
if (Object.keys(this.customSounds).length > 0) {
|
|
19
|
+
this.preloadSounds(this.customSounds);
|
|
20
|
+
}
|
|
11
21
|
}
|
|
12
22
|
|
|
13
23
|
/**
|
|
@@ -15,7 +25,11 @@ export class SoundManager {
|
|
|
15
25
|
*/
|
|
16
26
|
initAudio() {
|
|
17
27
|
if (!this.audioContext) {
|
|
18
|
-
|
|
28
|
+
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
29
|
+
this.audioContext = new AudioContext();
|
|
30
|
+
}
|
|
31
|
+
if (this.audioContext.state === 'suspended') {
|
|
32
|
+
this.audioContext.resume();
|
|
19
33
|
}
|
|
20
34
|
}
|
|
21
35
|
|
|
@@ -29,12 +43,98 @@ export class SoundManager {
|
|
|
29
43
|
return this.muted;
|
|
30
44
|
}
|
|
31
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Register a custom sound URL
|
|
48
|
+
* @param {string} key - Unique identifier (e.g., 'jump', 'win')
|
|
49
|
+
* @param {string} url - Path to audio file
|
|
50
|
+
*/
|
|
51
|
+
registerSound(key, url) {
|
|
52
|
+
this.customSounds[key] = url;
|
|
53
|
+
// Auto-load if context exists, otherwise wait for init
|
|
54
|
+
if (this.audioContext) {
|
|
55
|
+
this.loadSound(key, url);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Preload multiple sounds
|
|
61
|
+
* @param {Object} soundMap - { key: url }
|
|
62
|
+
*/
|
|
63
|
+
async preloadSounds(soundMap) {
|
|
64
|
+
const promises = Object.entries(soundMap).map(([key, url]) => this.loadSound(key, url));
|
|
65
|
+
await Promise.allSettled(promises);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Load and decode a sound file
|
|
70
|
+
*/
|
|
71
|
+
async loadSound(key, url) {
|
|
72
|
+
try {
|
|
73
|
+
// We need audio context to decode
|
|
74
|
+
if (!this.audioContext) {
|
|
75
|
+
// If no context yet, we can't decode.
|
|
76
|
+
// In a real app, we might fetch buffer first, decode later.
|
|
77
|
+
// For simplicity, we'll wait for initAudio() or lazy load.
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const response = await fetch(url);
|
|
82
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
83
|
+
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
|
|
84
|
+
this.assets.set(key, audioBuffer);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.warn(`[DopamineJS] Failed to load sound '${key}':`, error);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Play a sound by key (custom) or fallback to synth
|
|
92
|
+
* @param {string} key - 'jump', 'score', 'success', etc.
|
|
93
|
+
*/
|
|
94
|
+
async play(key) {
|
|
95
|
+
if (this.muted) return;
|
|
96
|
+
this.initAudio();
|
|
97
|
+
|
|
98
|
+
// 1. Try custom asset first
|
|
99
|
+
if (this.assets.has(key)) {
|
|
100
|
+
this._playBuffer(this.assets.get(key));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. Try to load if registered but not loaded
|
|
105
|
+
if (this.customSounds[key] && !this.assets.has(key)) {
|
|
106
|
+
await this.loadSound(key, this.customSounds[key]);
|
|
107
|
+
if (this.assets.has(key)) {
|
|
108
|
+
this._playBuffer(this.assets.get(key));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 3. Fallback to synth based on key name
|
|
114
|
+
// Map common keys to synth methods
|
|
115
|
+
const synthMap = {
|
|
116
|
+
'jump': () => this.playJump(true), // true = force synth
|
|
117
|
+
'score': () => this.playScore(true),
|
|
118
|
+
'success': () => this.playSuccess(true),
|
|
119
|
+
'click': () => this.playClick(true),
|
|
120
|
+
'error': () => this.playError(true),
|
|
121
|
+
'gameover': () => this.playGameOver(true)
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (synthMap[key]) {
|
|
125
|
+
synthMap[key]();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
_playBuffer(buffer) {
|
|
130
|
+
const source = this.audioContext.createBufferSource();
|
|
131
|
+
source.buffer = buffer;
|
|
132
|
+
source.connect(this.audioContext.destination);
|
|
133
|
+
source.start(0);
|
|
134
|
+
}
|
|
135
|
+
|
|
32
136
|
/**
|
|
33
137
|
* Play a synthesized tone
|
|
34
|
-
* @param {number} frequency
|
|
35
|
-
* @param {number} duration
|
|
36
|
-
* @param {string} type - 'sine', 'square', 'sawtooth', 'triangle'
|
|
37
|
-
* @param {number} volume
|
|
38
138
|
*/
|
|
39
139
|
playTone(frequency, duration, type = 'sine', volume = 0.3) {
|
|
40
140
|
if (this.muted) return;
|
|
@@ -56,33 +156,39 @@ export class SoundManager {
|
|
|
56
156
|
oscillator.stop(this.audioContext.currentTime + duration);
|
|
57
157
|
}
|
|
58
158
|
|
|
59
|
-
// --- Presets ---
|
|
159
|
+
// --- Presets (Synth) ---
|
|
60
160
|
|
|
61
|
-
playJump() {
|
|
161
|
+
playJump(force = false) {
|
|
162
|
+
if (!force && this.assets.has('jump')) return this.play('jump');
|
|
62
163
|
this.playTone(400, 0.1, 'square', 0.2);
|
|
63
164
|
}
|
|
64
165
|
|
|
65
|
-
playScore() {
|
|
166
|
+
playScore(force = false) {
|
|
167
|
+
if (!force && this.assets.has('score')) return this.play('score');
|
|
66
168
|
this.playTone(800, 0.15, 'sine', 0.25);
|
|
67
169
|
setTimeout(() => this.playTone(1000, 0.15, 'sine', 0.25), 50);
|
|
68
170
|
}
|
|
69
171
|
|
|
70
|
-
playGameOver() {
|
|
172
|
+
playGameOver(force = false) {
|
|
173
|
+
if (!force && this.assets.has('gameover')) return this.play('gameover');
|
|
71
174
|
this.playTone(300, 0.2, 'sawtooth', 0.3);
|
|
72
175
|
setTimeout(() => this.playTone(200, 0.2, 'sawtooth', 0.3), 100);
|
|
73
176
|
setTimeout(() => this.playTone(150, 0.3, 'sawtooth', 0.3), 200);
|
|
74
177
|
}
|
|
75
178
|
|
|
76
|
-
playClick() {
|
|
179
|
+
playClick(force = false) {
|
|
180
|
+
if (!force && this.assets.has('click')) return this.play('click');
|
|
77
181
|
this.playTone(600, 0.05, 'sine', 0.1);
|
|
78
182
|
}
|
|
79
183
|
|
|
80
|
-
playSuccess() {
|
|
184
|
+
playSuccess(force = false) {
|
|
185
|
+
if (!force && this.assets.has('success')) return this.play('success');
|
|
81
186
|
this.playTone(600, 0.1, 'sine', 0.2);
|
|
82
187
|
setTimeout(() => this.playTone(800, 0.2, 'sine', 0.2), 100);
|
|
83
188
|
}
|
|
84
189
|
|
|
85
|
-
playError() {
|
|
190
|
+
playError(force = false) {
|
|
191
|
+
if (!force && this.assets.has('error')) return this.play('error');
|
|
86
192
|
this.playTone(200, 0.2, 'sawtooth', 0.3);
|
|
87
193
|
}
|
|
88
194
|
}
|