dopaminejs 1.0.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +71 -0
- package/CHANGELOG.md +36 -0
- package/README.md +73 -0
- package/dist/dopamine.mjs +360 -283
- package/dist/dopamine.umd.js +12 -12
- package/package.json +4 -2
- package/src/audio/SoundManager.js +118 -12
- package/src/effects/ParticleSystem.js +201 -131
package/dist/dopamine.umd.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
(function(
|
|
1
|
+
(function(o,p){typeof exports=="object"&&typeof module<"u"?p(exports):typeof define=="function"&&define.amd?define(["exports"],p):(o=typeof globalThis<"u"?globalThis:o||self,p(o.Dopamine={}))})(this,function(o){"use strict";class p{constructor(){this.listeners=new Map}on(e,t){return this.listeners.has(e)||this.listeners.set(e,new Set),this.listeners.get(e).add(t),()=>this.off(e,t)}off(e,t){this.listeners.has(e)&&this.listeners.get(e).delete(t)}emit(e,t){this.listeners.has(e)&&this.listeners.get(e).forEach(s=>{try{s(t)}catch(i){console.error(`Error in listener for event '${e}':`,i)}})}clear(){this.listeners.clear()}}class m extends p{constructor(e,t={}){super(),this.dataService=e,this.player=null,this.achievements={...this._initAchievements(),...t.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(e,t=""){const s=this.player.level;this.player.xp+=e;const i=this._calculateLevel(this.player.xp),a=i>s;return a&&(this.player.level=i),await this.save(),this.emit("xp_gained",{amount:e,reason:t,leveledUp:a,newLevel:i}),a&&this.emit("level_up",{oldLevel:s,newLevel:i}),{leveledUp:a,newLevel:i,xpGained:e}}_calculateLevel(e){const t=Math.floor((1+Math.sqrt(1+8*e/50))/2);return Math.max(1,t)}getXPForNextLevel(){const e=this.player.level+1,t=50*e*(e-1),s=50*this.player.level*(this.player.level-1);return{total:t,needed:t-this.player.xp,progress:(this.player.xp-s)/(t-s)}}async recordGame(e,t){this.player.stats[e]||(this.player.stats[e]={totalPlays:0,highScore:0});const s=this.player.stats[e];s.totalPlays++,this.player.totalGamesPlayed++,t.score!==void 0&&t.score>s.highScore&&(s.highScore=t.score,this.emit("new_high_score",{gameName:e,score:t.score})),Object.keys(t).forEach(a=>{a!=="score"&&(s[a]=(s[a]||0)+(typeof t[a]=="number"?t[a]:0))});let i=10;t.score&&(i+=Math.floor(t.score/5)),i=Math.floor(i*this.getStreakMultiplier()),await this.addXP(i,`Played ${e}`),await this.checkAchievements(e,t),await this.save()}async checkAchievements(e,t){const s=[];for(const[i,a]of Object.entries(this.achievements))this.player.achievements[i]||a.check(this.player,e,t)&&(await this.unlockAchievement(i),s.push(a));return s}async unlockAchievement(e){const t=this.achievements[e];return t?(this.player.achievements[e]={unlockedAt:Date.now(),seen:!1},await this.addXP(t.xp,`Achievement: ${t.name}`),this.emit("achievement_unlocked",t),await this.save(),!0):!1}getUnlockedAchievements(){return Object.entries(this.player.achievements).map(([e,t])=>({...this.achievements[e],id:e,...t}))}getUnseenAchievements(){return this.getUnlockedAchievements().filter(e=>!e.seen)}async markAchievementsSeen(e){for(const t of e)this.player.achievements[t]&&(this.player.achievements[t].seen=!0);await this.save()}_updateDailyStreak(){const e=this._getTodayDateString(),t=this.player.streak.lastPlayDate;if(t===e)return;const s=this._getYesterdayDateString();t===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=e}getStreakMultiplier(){const e=this.player.streak.current;return e>=7?2:e>=5?1.5:e>=3?1.25:1}_getTodayDateString(){return new Date().toISOString().split("T")[0]}_getYesterdayDateString(){const e=new Date;return e.setDate(e.getDate()-1),e.toISOString().split("T")[0]}_initAchievements(){return{first_game:{name:"First Steps",description:"Play your first game",icon:"🎮",xp:50,check:e=>e.totalGamesPlayed>=1},level_5:{name:"Rising Star",description:"Reach level 5",icon:"⭐",xp:100,check:e=>e.level>=5},level_10:{name:"Expert Player",description:"Reach level 10",icon:"🏆",xp:250,check:e=>e.level>=10},streak_3:{name:"Dedicated",description:"Play 3 days in a row",icon:"🔥",xp:100,check:e=>e.streak.current>=3},streak_7:{name:"Unstoppable",description:"Play 7 days in a row",icon:"💥",xp:300,check:e=>e.streak.current>=7}}}}class y{constructor(e={}){this.storage=e.storage||window.localStorage,this.prefix=e.prefix||"dopamine_"}async save(e,t){try{const s=JSON.stringify(t);return this.storage.setItem(this.prefix+e,s),!0}catch(s){return console.error("Error saving data:",s),!1}}async load(e,t=null){try{const s=this.storage.getItem(this.prefix+e);return s?JSON.parse(s):t}catch(s){return console.error("Error loading data:",s),t}}async clear(e){this.storage.removeItem(this.prefix+e)}}class f{constructor(e){this.particleSystem=e,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,29 +17,29 @@
|
|
|
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,
|
|
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(e,t,s){const i=e/s*100;this.xpBar.style.width=`${Math.min(100,i)}%`,this.xpText.textContent=`${e} / ${s} XP`,this.xpBar.style.animation="none",setTimeout(()=>{this.xpBar.style.animation="xp-pulse 0.3s ease-out"},10)}updateLevel(e){this.levelDisplay.textContent=e;const t=document.getElementById("level-badge");t.style.animation="none",setTimeout(()=>{t.style.animation="level-pulse 0.5s ease-out"},10)}updateStreak(e){this.streakDisplay.textContent=e;const t=document.getElementById("streak-badge");e>=7?t.style.background="linear-gradient(135deg, #ff6b00 0%, #ff4400 100%)":e>=3&&(t.style.background="linear-gradient(135deg, #ff8800 0%, #ff6b00 100%)")}showAchievement(e){const t=document.createElement("div");t.className="achievement-popup",t.innerHTML=`
|
|
21
21
|
<div class="achievement-shine"></div>
|
|
22
22
|
<div class="achievement-content">
|
|
23
|
-
<div class="achievement-icon">${
|
|
23
|
+
<div class="achievement-icon">${e.icon}</div>
|
|
24
24
|
<div class="achievement-info">
|
|
25
25
|
<div class="achievement-title">Achievement Unlocked!</div>
|
|
26
|
-
<div class="achievement-name">${
|
|
27
|
-
<div class="achievement-desc">${
|
|
28
|
-
<div class="achievement-xp">+${
|
|
26
|
+
<div class="achievement-name">${e.name}</div>
|
|
27
|
+
<div class="achievement-desc">${e.description}</div>
|
|
28
|
+
<div class="achievement-xp">+${e.xp} XP</div>
|
|
29
29
|
</div>
|
|
30
30
|
</div>
|
|
31
|
-
`,document.body.appendChild(
|
|
31
|
+
`,document.body.appendChild(t),setTimeout(()=>{const s=t.getBoundingClientRect();this.particleSystem.confetti(s.left+s.width/2,s.top+s.height/2,40)},300),setTimeout(()=>{t.style.animation="slideOut 0.3s ease-in forwards",setTimeout(()=>t.remove(),300)},4e3)}showLevelUp(e,t){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
|
-
<div class="level-up-number">${
|
|
34
|
+
<div class="level-up-number">${t}</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(e,t,s,i="#ffd700",a="24px"){const n=document.createElement("div");n.className="floating-text",n.textContent=e,n.style.left=t+"px",n.style.top=s+"px",n.style.color=i,n.style.fontSize=a,document.body.appendChild(n),setTimeout(()=>n.remove(),2e3)}showCombo(e,t,s){this.showFloatingText(`${e}× COMBO!`,t,s,"#ff6b6b","32px"),this.particleSystem.fire(t,s,20)}showNearMiss(e,t){this.showFloatingText("CLOSE CALL!",e,t,"#45b7d1","20px"),this.particleSystem.sparkle(e,t,15,"#45b7d1")}showLuckyMoment(e,t,s){this.showFloatingText(`LUCKY ${e}×!`,t,s,"#ffd700","36px"),this.particleSystem.starBurst(t,s,12),this.particleSystem.confetti(t,s,25)}showNotification(e,t="normal"){const s=window.innerWidth/2,i=window.innerHeight/3;t==="legendary"?this.showLuckyMoment(e,s,i):t==="rare"?this.showCombo(e,s,i):this.showFloatingText(e,s,i,"#fff","24px")}showSummary(e){const t=document.createElement("div");t.className="message-box",t.style.display="flex",t.style.position="absolute",t.style.top="50%",t.style.left="50%",t.style.transform="translate(-50%, -50%)",t.style.zIndex="1000";let s="";e.metrics&&(s=Object.entries(e.metrics).map(([i,a])=>`<div style="display:flex;justify-content:space-between;width:100%"><span>${i}:</span> <strong>${a}</strong></div>`).join("")),t.innerHTML=`
|
|
38
38
|
<h1>Game Over</h1>
|
|
39
|
-
<div style="font-size: 3rem; font-weight: bold; color: #e06020; margin: 10px 0;">${
|
|
39
|
+
<div style="font-size: 3rem; font-weight: bold; color: #e06020; margin: 10px 0;">${e.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(
|
|
45
|
+
`,document.body.appendChild(t)}}class v{constructor(e={}){this.container=e.container?typeof e.container=="string"?document.querySelector(e.container):e.container:document.body,this.canvasId=e.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.pointerEvents="none",this.canvas.style.zIndex=e.zIndex||"9999",this.container===document.body?(this.canvas.style.position="fixed",this.canvas.style.top="0",this.canvas.style.left="0"):(this.canvas.style.position="absolute",this.canvas.style.top="0",this.canvas.style.left="0",window.getComputedStyle(this.container).position==="static"&&(this.container.style.position="relative")),this.container.appendChild(this.canvas)),this.ctx=this.canvas.getContext("2d"),this.particles=[],this.pool=[],this.sprites=new Map,this.customEffects=new Map,this.isAnimating=!1,this._resize(),window.ResizeObserver&&this.container!==document.body?new ResizeObserver(()=>this._resize()).observe(this.container):window.addEventListener("resize",()=>this._resize())}_resize(){this.container===document.body?(this.canvas.width=window.innerWidth,this.canvas.height=window.innerHeight):(this.canvas.width=this.container.clientWidth,this.canvas.height=this.container.clientHeight)}registerSprite(e,t){const s=new Image;s.src=t,this.sprites.set(e,s)}registerEffect(e,t){this.customEffects.set(e,t)}play(e,t,s,...i){const a=this.customEffects.get(e);a?a(t,s,...i):console.warn(`[DopamineJS] Effect '${e}' not found.`)}emit(e){const{x:t,y:s,count:i=10,speed:a=5,gravity:n=.1,decay:l=.02,life:d=1,color:c="#ffd700",sprite:u=null,spread:M=Math.PI*2,angle:P=0,size:T=5}=e;for(let x=0;x<i;x++){const r=this._getParticle(),w=P+(Math.random()-.5)*M,S=Math.random()*a;r.x=t,r.y=s,r.vx=Math.cos(w)*S,r.vy=Math.sin(w)*S,r.life=d,r.decay=l*(.8+Math.random()*.4),r.gravity=n,r.color=Array.isArray(c)?c[Math.floor(Math.random()*c.length)]:c,r.size=T*(.8+Math.random()*.4),r.sprite=u,r.rotation=Math.random()*Math.PI*2,r.rotationSpeed=(Math.random()-.5)*.2,this.particles.push(r)}this._startAnimation()}_getParticle(){return this.pool.length>0?this.pool.pop():{}}_recycleParticle(e){this.pool.push(e)}confetti(e,t,s=50){const i=["#ff6b6b","#4ecdc4","#45b7d1","#f7dc6f","#c06c84","#6c5ce7"];this.emit({x:e,y:t,count:s,color:i,speed:10,gravity:.3,decay:.015,size:8,spread:Math.PI*2})}coinShower(e,t,s=10){this.emit({x:e,y:t,count:s,color:"#ffd700",speed:4,gravity:.4,decay:.012,size:12,angle:-Math.PI/2,spread:Math.PI/2})}sparkle(e,t,s=20,i="#ffd700"){this.emit({x:e,y:t,count:s,color:i,speed:3,gravity:-.05,decay:.03,size:3})}fire(e,t,s=15){const i=["#ff6b00","#ff8800","#ffaa00","#ff4400"];this.emit({x:e,y:t,count:s,color:i,speed:3,gravity:-.2,decay:.04,size:5,angle:-Math.PI/2,spread:Math.PI/4})}starBurst(e,t,s=8){for(let i=0;i<s;i++){const a=this._getParticle(),n=Math.PI*2/s*i,l=4;a.x=e,a.y=t,a.vx=Math.cos(n)*l,a.vy=Math.sin(n)*l,a.life=1,a.decay=.03,a.gravity=0,a.color="#ffffff",a.size=15,a.sprite=null,a.type="star",a.rotation=n,a.rotationSpeed=0,this.particles.push(a)}this._startAnimation()}_startAnimation(){this.isAnimating||(this.isAnimating=!0,this._animate())}_animate(){this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height);for(let e=this.particles.length-1;e>=0;e--){const t=this.particles[e];if(t.vy+=t.gravity,t.x+=t.vx,t.y+=t.vy,t.life-=t.decay,t.rotationSpeed&&(t.rotation+=t.rotationSpeed),t.life<=0){this._recycleParticle(t),this.particles.splice(e,1);continue}if(this.ctx.save(),this.ctx.globalAlpha=t.life,this.ctx.translate(t.x,t.y),this.ctx.rotate(t.rotation),t.sprite&&this.sprites.has(t.sprite)){const s=this.sprites.get(t.sprite);s.complete&&this.ctx.drawImage(s,-t.size/2,-t.size/2,t.size,t.size)}else t.type==="star"?(this.ctx.fillStyle=t.color,this._drawStarPath(0,0,5,t.size,t.size/2),this.ctx.fill()):(this.ctx.fillStyle=t.color,this.ctx.beginPath(),this.ctx.arc(0,0,t.size,0,Math.PI*2),this.ctx.fill());this.ctx.restore()}this.particles.length>0?requestAnimationFrame(()=>this._animate()):this.isAnimating=!1}_drawStarPath(e,t,s,i,a){let n=Math.PI/2*3,l=e,d=t;const c=Math.PI/s;this.ctx.beginPath(),this.ctx.moveTo(e,t-i);for(let u=0;u<s;u++)l=e+Math.cos(n)*i,d=t+Math.sin(n)*i,this.ctx.lineTo(l,d),n+=c,l=e+Math.cos(n)*a,d=t+Math.sin(n)*a,this.ctx.lineTo(l,d),n+=c;this.ctx.lineTo(e,t-i),this.ctx.closePath()}clear(){this.particles.forEach(e=>this._recycleParticle(e)),this.particles=[],this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height)}}class g{constructor(e={}){this.audioContext=null,this.storageKey=e.storageKey||"dopamineSoundsMuted",this.muted=localStorage.getItem(this.storageKey)==="true",this.assets=new Map,this.soundPack=e.soundPack||"synth",this.customSounds=e.customSounds||{},Object.keys(this.customSounds).length>0&&this.preloadSounds(this.customSounds)}initAudio(){if(!this.audioContext){const e=window.AudioContext||window.webkitAudioContext;this.audioContext=new e}this.audioContext.state==="suspended"&&this.audioContext.resume()}toggleMute(){return this.muted=!this.muted,localStorage.setItem(this.storageKey,this.muted),this.muted}registerSound(e,t){this.customSounds[e]=t,this.audioContext&&this.loadSound(e,t)}async preloadSounds(e){const t=Object.entries(e).map(([s,i])=>this.loadSound(s,i));await Promise.allSettled(t)}async loadSound(e,t){try{if(!this.audioContext)return;const i=await(await fetch(t)).arrayBuffer(),a=await this.audioContext.decodeAudioData(i);this.assets.set(e,a)}catch(s){console.warn(`[DopamineJS] Failed to load sound '${e}':`,s)}}async play(e){if(this.muted)return;if(this.initAudio(),this.assets.has(e)){this._playBuffer(this.assets.get(e));return}if(this.customSounds[e]&&!this.assets.has(e)&&(await this.loadSound(e,this.customSounds[e]),this.assets.has(e))){this._playBuffer(this.assets.get(e));return}const t={jump:()=>this.playJump(!0),score:()=>this.playScore(!0),success:()=>this.playSuccess(!0),click:()=>this.playClick(!0),error:()=>this.playError(!0),gameover:()=>this.playGameOver(!0)};t[e]&&t[e]()}_playBuffer(e){const t=this.audioContext.createBufferSource();t.buffer=e,t.connect(this.audioContext.destination),t.start(0)}playTone(e,t,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=e,a.type=s,n.gain.setValueAtTime(i,this.audioContext.currentTime),n.gain.exponentialRampToValueAtTime(.01,this.audioContext.currentTime+t),a.start(this.audioContext.currentTime),a.stop(this.audioContext.currentTime+t)}playJump(e=!1){if(!e&&this.assets.has("jump"))return this.play("jump");this.playTone(400,.1,"square",.2)}playScore(e=!1){if(!e&&this.assets.has("score"))return this.play("score");this.playTone(800,.15,"sine",.25),setTimeout(()=>this.playTone(1e3,.15,"sine",.25),50)}playGameOver(e=!1){if(!e&&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(e=!1){if(!e&&this.assets.has("click"))return this.play("click");this.playTone(600,.05,"sine",.1)}playSuccess(e=!1){if(!e&&this.assets.has("success"))return this.play("success");this.playTone(600,.1,"sine",.2),setTimeout(()=>this.playTone(800,.2,"sine",.2),100)}playError(e=!1){if(!e&&this.assets.has("error"))return this.play("error");this.playTone(200,.2,"sawtooth",.3)}}class b{constructor(e={}){this.config=e,this.dataService=new y(e.data||{}),this.rewardSystem=new m(this.dataService,e.rewards||{}),this.soundManager=new g(e.sound||{}),this.particleSystem=new v(e.particles||{}),this.gameUI=new f(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",e=>{const{total:t,needed:s}=this.rewardSystem.getXPForNextLevel();this.gameUI.updateXP(this.rewardSystem.player.xp,s,t),e.xpGained>0}),this.rewardSystem.on("level_up",e=>{this.gameUI.updateLevel(e.newLevel),this.gameUI.showLevelUp(e.oldLevel,e.newLevel)}),this.rewardSystem.on("achievement_unlocked",e=>{this.gameUI.showAchievement(e)}),this.rewardSystem.on("new_high_score",e=>{this.gameUI.showNotification(`New High Score: ${e.score}!`,"legendary"),this.soundManager.playSuccess()})}}o.DataService=y,o.GameUI=f,o.ParticleSystem=v,o.RewardSystem=m,o.SoundManager=g,o.default=b,Object.defineProperties(o,{__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.2.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
|
}
|