dopaminejs 1.1.0 → 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.
@@ -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(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=`
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,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=`
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">${t.icon}</div>
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">${t.name}</div>
27
- <div class="achievement-desc">${t.description}</div>
28
- <div class="achievement-xp">+${t.xp} XP</div>
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(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=`
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">${e}</div>
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(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=`
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;">${t.score}</div>
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(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"}})});
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.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",
@@ -5,153 +5,227 @@
5
5
 
6
6
  export class ParticleSystem {
7
7
  constructor(config = {}) {
8
+ this.container = config.container ?
9
+ (typeof config.container === 'string' ? document.querySelector(config.container) : config.container)
10
+ : document.body;
11
+
8
12
  this.canvasId = config.canvasId || 'dopamine-particle-canvas';
9
13
  this.canvas = document.getElementById(this.canvasId);
10
14
 
11
15
  if (!this.canvas) {
12
- // Create overlay canvas if it doesn't exist
13
16
  this.canvas = document.createElement('canvas');
14
17
  this.canvas.id = this.canvasId;
15
- this.canvas.style.position = 'fixed';
16
- this.canvas.style.top = '0';
17
- this.canvas.style.left = '0';
18
- this.canvas.style.width = '100%';
19
- this.canvas.style.height = '100%';
20
18
  this.canvas.style.pointerEvents = 'none';
21
19
  this.canvas.style.zIndex = config.zIndex || '9999';
22
- document.body.appendChild(this.canvas);
20
+
21
+ if (this.container === document.body) {
22
+ this.canvas.style.position = 'fixed';
23
+ this.canvas.style.top = '0';
24
+ this.canvas.style.left = '0';
25
+ } else {
26
+ this.canvas.style.position = 'absolute';
27
+ this.canvas.style.top = '0';
28
+ this.canvas.style.left = '0';
29
+ // Ensure container is positioned
30
+ const style = window.getComputedStyle(this.container);
31
+ if (style.position === 'static') {
32
+ this.container.style.position = 'relative';
33
+ }
34
+ }
35
+
36
+ this.container.appendChild(this.canvas);
23
37
  }
24
38
 
25
39
  this.ctx = this.canvas.getContext('2d');
26
40
  this.particles = [];
41
+ this.pool = []; // Object pool
42
+ this.sprites = new Map(); // key -> Image
43
+ this.customEffects = new Map(); // key -> callback
27
44
  this.isAnimating = false;
28
45
 
29
46
  this._resize();
30
- window.addEventListener('resize', () => this._resize());
47
+ // Use ResizeObserver for container resizing if supported, fallback to window resize
48
+ if (window.ResizeObserver && this.container !== document.body) {
49
+ new ResizeObserver(() => this._resize()).observe(this.container);
50
+ } else {
51
+ window.addEventListener('resize', () => this._resize());
52
+ }
31
53
  }
32
54
 
33
55
  _resize() {
34
- this.canvas.width = window.innerWidth;
35
- this.canvas.height = window.innerHeight;
56
+ if (this.container === document.body) {
57
+ this.canvas.width = window.innerWidth;
58
+ this.canvas.height = window.innerHeight;
59
+ } else {
60
+ this.canvas.width = this.container.clientWidth;
61
+ this.canvas.height = this.container.clientHeight;
62
+ }
36
63
  }
37
64
 
38
65
  /**
39
- * Confetti explosion (for achievements, level ups)
66
+ * Register a custom sprite image
67
+ * @param {string} key
68
+ * @param {string} url
40
69
  */
41
- confetti(x, y, count = 50) {
42
- const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#f7dc6f', '#c06c84', '#6c5ce7'];
43
-
44
- for (let i = 0; i < count; i++) {
45
- this.particles.push({
46
- x, y,
47
- vx: (Math.random() - 0.5) * 10,
48
- vy: (Math.random() - 0.5) * 10 - 5,
49
- rotation: Math.random() * Math.PI * 2,
50
- rotationSpeed: (Math.random() - 0.5) * 0.2,
51
- color: colors[Math.floor(Math.random() * colors.length)],
52
- size: Math.random() * 8 + 4,
53
- life: 1.0,
54
- decay: 0.015 + Math.random() * 0.01,
55
- gravity: 0.3,
56
- type: 'confetti'
57
- });
58
- }
59
-
60
- this._startAnimation();
70
+ registerSprite(key, url) {
71
+ const img = new Image();
72
+ img.src = url;
73
+ this.sprites.set(key, img);
61
74
  }
62
75
 
63
76
  /**
64
- * Sparkle effect (for score milestones, combos)
77
+ * Register a custom effect
78
+ * @param {string} name
79
+ * @param {Function} callback - (x, y, ...args) => void
65
80
  */
66
- sparkle(x, y, count = 20, color = '#ffd700') {
67
- for (let i = 0; i < count; i++) {
68
- const angle = (Math.PI * 2 / count) * i;
69
- const speed = 2 + Math.random() * 3;
70
-
71
- this.particles.push({
72
- x, y,
73
- vx: Math.cos(angle) * speed,
74
- vy: Math.sin(angle) * speed,
75
- color,
76
- size: Math.random() * 4 + 2,
77
- life: 1.0,
78
- decay: 0.02,
79
- gravity: -0.1, // Slight upward drift
80
- type: 'spark'
81
- });
82
- }
83
-
84
- this._startAnimation();
81
+ registerEffect(name, callback) {
82
+ this.customEffects.set(name, callback);
85
83
  }
86
84
 
87
85
  /**
88
- * Coin shower (for collecting coins)
86
+ * Play a registered effect
89
87
  */
90
- coinShower(x, y, count = 10) {
91
- for (let i = 0; i < count; i++) {
92
- this.particles.push({
93
- x: x + (Math.random() - 0.5) * 100,
94
- y: y - Math.random() * 50,
95
- vx: (Math.random() - 0.5) * 3,
96
- vy: Math.random() * -2 - 3,
97
- color: '#ffd700',
98
- size: 12,
99
- life: 1.0,
100
- decay: 0.012,
101
- gravity: 0.4,
102
- type: 'coin'
103
- });
88
+ play(name, x, y, ...args) {
89
+ const effect = this.customEffects.get(name);
90
+ if (effect) {
91
+ effect(x, y, ...args);
92
+ } else {
93
+ console.warn(`[DopamineJS] Effect '${name}' not found.`);
104
94
  }
105
-
106
- this._startAnimation();
107
95
  }
108
96
 
109
97
  /**
110
- * Fire particles (for streaks, combos)
98
+ * Emit particles with custom config
99
+ * @param {Object} config
111
100
  */
112
- fire(x, y, count = 15) {
113
- const colors = ['#ff6b00', '#ff8800', '#ffaa00', '#ff4400'];
101
+ emit(config) {
102
+ const {
103
+ x, y,
104
+ count = 10,
105
+ speed = 5,
106
+ gravity = 0.1,
107
+ decay = 0.02,
108
+ life = 1.0,
109
+ color = '#ffd700',
110
+ sprite = null,
111
+ spread = Math.PI * 2,
112
+ angle = 0,
113
+ size = 5
114
+ } = config;
114
115
 
115
116
  for (let i = 0; i < count; i++) {
116
- this.particles.push({
117
- x: x + (Math.random() - 0.5) * 20,
118
- y,
119
- vx: (Math.random() - 0.5) * 2,
120
- vy: -Math.random() * 3 - 2,
121
- color: colors[Math.floor(Math.random() * colors.length)],
122
- size: Math.random() * 6 + 3,
123
- life: 1.0,
124
- decay: 0.025,
125
- gravity: -0.2, // Upward
126
- type: 'fire'
127
- });
117
+ const p = this._getParticle();
118
+ const pAngle = angle + (Math.random() - 0.5) * spread;
119
+ const pSpeed = Math.random() * speed;
120
+
121
+ p.x = x;
122
+ p.y = y;
123
+ p.vx = Math.cos(pAngle) * pSpeed;
124
+ p.vy = Math.sin(pAngle) * pSpeed;
125
+ p.life = life;
126
+ p.decay = decay * (0.8 + Math.random() * 0.4); // Randomize decay slightly
127
+ p.gravity = gravity;
128
+ p.color = Array.isArray(color) ? color[Math.floor(Math.random() * color.length)] : color;
129
+ p.size = size * (0.8 + Math.random() * 0.4);
130
+ p.sprite = sprite;
131
+ p.rotation = Math.random() * Math.PI * 2;
132
+ p.rotationSpeed = (Math.random() - 0.5) * 0.2;
133
+
134
+ this.particles.push(p);
128
135
  }
129
136
 
130
137
  this._startAnimation();
131
138
  }
132
139
 
133
- /**
134
- * Star burst (for "perfect" moments)
135
- */
140
+ _getParticle() {
141
+ if (this.pool.length > 0) {
142
+ return this.pool.pop();
143
+ }
144
+ return {}; // New particle object
145
+ }
146
+
147
+ _recycleParticle(p) {
148
+ this.pool.push(p);
149
+ }
150
+
151
+ // --- Built-in Effects ---
152
+
153
+ confetti(x, y, count = 50) {
154
+ const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#f7dc6f', '#c06c84', '#6c5ce7'];
155
+ this.emit({
156
+ x, y, count,
157
+ color: colors,
158
+ speed: 10,
159
+ gravity: 0.3,
160
+ decay: 0.015,
161
+ size: 8,
162
+ spread: Math.PI * 2
163
+ });
164
+ }
165
+
166
+ coinShower(x, y, count = 10) {
167
+ this.emit({
168
+ x, y, count,
169
+ color: '#ffd700',
170
+ speed: 4,
171
+ gravity: 0.4,
172
+ decay: 0.012,
173
+ size: 12,
174
+ angle: -Math.PI / 2, // Upwards initially
175
+ spread: Math.PI / 2
176
+ });
177
+ }
178
+
179
+ sparkle(x, y, count = 20, color = '#ffd700') {
180
+ this.emit({
181
+ x, y, count,
182
+ color,
183
+ speed: 3,
184
+ gravity: -0.05, // Float up
185
+ decay: 0.03,
186
+ size: 3
187
+ });
188
+ }
189
+
190
+ fire(x, y, count = 15) {
191
+ const colors = ['#ff6b00', '#ff8800', '#ffaa00', '#ff4400'];
192
+ this.emit({
193
+ x, y, count,
194
+ color: colors,
195
+ speed: 3,
196
+ gravity: -0.2,
197
+ decay: 0.04,
198
+ size: 5,
199
+ angle: -Math.PI / 2,
200
+ spread: Math.PI / 4
201
+ });
202
+ }
203
+
136
204
  starBurst(x, y, count = 8) {
205
+ // Star burst is unique because of fixed angles, so we keep manual loop or use emit carefully
206
+ // For simplicity, let's use emit but we lose the perfect star shape distribution
207
+ // To keep it perfect, we'll manually push particles but use the pool
137
208
  for (let i = 0; i < count; i++) {
209
+ const p = this._getParticle();
138
210
  const angle = (Math.PI * 2 / count) * i;
139
211
  const speed = 4;
140
212
 
141
- this.particles.push({
142
- x, y,
143
- vx: Math.cos(angle) * speed,
144
- vy: Math.sin(angle) * speed,
145
- rotation: angle,
146
- color: '#ffffff',
147
- size: 15,
148
- life: 1.0,
149
- decay: 0.03,
150
- gravity: 0,
151
- type: 'star'
152
- });
213
+ p.x = x;
214
+ p.y = y;
215
+ p.vx = Math.cos(angle) * speed;
216
+ p.vy = Math.sin(angle) * speed;
217
+ p.life = 1.0;
218
+ p.decay = 0.03;
219
+ p.gravity = 0;
220
+ p.color = '#ffffff';
221
+ p.size = 15;
222
+ p.sprite = null;
223
+ p.type = 'star'; // Special type for drawing logic
224
+ p.rotation = angle;
225
+ p.rotationSpeed = 0;
226
+
227
+ this.particles.push(p);
153
228
  }
154
-
155
229
  this._startAnimation();
156
230
  }
157
231
 
@@ -165,11 +239,10 @@ export class ParticleSystem {
165
239
  _animate() {
166
240
  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
167
241
 
168
- // Update and draw particles
169
242
  for (let i = this.particles.length - 1; i >= 0; i--) {
170
243
  const p = this.particles[i];
171
244
 
172
- // Update physics
245
+ // Physics
173
246
  p.vy += p.gravity;
174
247
  p.x += p.vx;
175
248
  p.y += p.vy;
@@ -179,43 +252,41 @@ export class ParticleSystem {
179
252
  p.rotation += p.rotationSpeed;
180
253
  }
181
254
 
182
- // Remove dead particles
255
+ // Death
183
256
  if (p.life <= 0) {
257
+ this._recycleParticle(p);
184
258
  this.particles.splice(i, 1);
185
259
  continue;
186
260
  }
187
261
 
188
- // Draw particle
262
+ // Draw
189
263
  this.ctx.save();
190
264
  this.ctx.globalAlpha = p.life;
191
- this.ctx.fillStyle = p.color;
192
-
193
- if (p.type === 'confetti') {
194
- this.ctx.translate(p.x, p.y);
195
- this.ctx.rotate(p.rotation);
196
- this.ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size / 2);
197
- } else if (p.type === 'coin') {
198
- this.ctx.beginPath();
199
- this.ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
200
- this.ctx.fill();
201
- // Add shine
202
- this.ctx.fillStyle = '#ffee88';
203
- this.ctx.beginPath();
204
- this.ctx.arc(p.x - p.size / 3, p.y - p.size / 3, p.size / 3, 0, Math.PI * 2);
205
- this.ctx.fill();
265
+ this.ctx.translate(p.x, p.y);
266
+ this.ctx.rotate(p.rotation);
267
+
268
+ if (p.sprite && this.sprites.has(p.sprite)) {
269
+ const img = this.sprites.get(p.sprite);
270
+ if (img.complete) {
271
+ this.ctx.drawImage(img, -p.size / 2, -p.size / 2, p.size, p.size);
272
+ }
206
273
  } else if (p.type === 'star') {
207
- this._drawStar(p.x, p.y, 5, p.size, p.size / 2, p.rotation);
274
+ this.ctx.fillStyle = p.color;
275
+ this._drawStarPath(0, 0, 5, p.size, p.size / 2);
276
+ this.ctx.fill();
208
277
  } else {
209
- // Default: circle
278
+ this.ctx.fillStyle = p.color;
279
+ // Default shape is square for performance/confetti, or circle
280
+ // Let's usefillRect for speed unless it's a coin/sparkle which usually implies circle
281
+ // For generic emit, let's assume circle if no sprite
210
282
  this.ctx.beginPath();
211
- this.ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
283
+ this.ctx.arc(0, 0, p.size, 0, Math.PI * 2);
212
284
  this.ctx.fill();
213
285
  }
214
286
 
215
287
  this.ctx.restore();
216
288
  }
217
289
 
218
- // Continue animation if particles remain
219
290
  if (this.particles.length > 0) {
220
291
  requestAnimationFrame(() => this._animate());
221
292
  } else {
@@ -223,16 +294,18 @@ export class ParticleSystem {
223
294
  }
224
295
  }
225
296
 
226
- _drawStar(cx, cy, spikes, outerRadius, innerRadius, rotation) {
227
- let rot = Math.PI / 2 * 3 + rotation;
297
+ _drawStarPath(cx, cy, spikes, outerRadius, innerRadius) {
298
+ let rot = Math.PI / 2 * 3;
299
+ let x = cx;
300
+ let y = cy;
228
301
  const step = Math.PI / spikes;
229
302
 
230
303
  this.ctx.beginPath();
231
304
  this.ctx.moveTo(cx, cy - outerRadius);
232
305
 
233
306
  for (let i = 0; i < spikes; i++) {
234
- let x = cx + Math.cos(rot) * outerRadius;
235
- let y = cy + Math.sin(rot) * outerRadius;
307
+ x = cx + Math.cos(rot) * outerRadius;
308
+ y = cy + Math.sin(rot) * outerRadius;
236
309
  this.ctx.lineTo(x, y);
237
310
  rot += step;
238
311
 
@@ -241,16 +314,13 @@ export class ParticleSystem {
241
314
  this.ctx.lineTo(x, y);
242
315
  rot += step;
243
316
  }
244
-
245
317
  this.ctx.lineTo(cx, cy - outerRadius);
246
318
  this.ctx.closePath();
247
- this.ctx.fill();
248
319
  }
249
320
 
250
- /**
251
- * Clear all particles
252
- */
253
321
  clear() {
322
+ // Recycle all
323
+ this.particles.forEach(p => this._recycleParticle(p));
254
324
  this.particles = [];
255
325
  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
256
326
  }