Files
FaraonCardGame/Faraon.Client/wwwroot/js/gameFx.js
T
2026-09-04 11:31:52 +02:00

1394 lines
51 KiB
JavaScript

// Faraón Game FX Engine: Procedural Audio, Particles, Screen Shake & Animations
window.FaraonFx = (function () {
let audioCtx = null;
let isMuted = false;
let speedMultiplier = 1.0;
let particleCanvas = null;
let particleCtx = null;
let particles = [];
let animationFrameId = null;
function setSpeedLevel(level) {
switch (level) {
case -5: speedMultiplier = 0.35; break; // 0.2x Zen
case -4: speedMultiplier = 0.45; break; // 0.35x Veľmi pomalá
case -3: speedMultiplier = 0.60; break; // 0.5x Pomalá
case -2: speedMultiplier = 0.75; break; // 0.65x Pokojná
case -1: speedMultiplier = 0.88; break; // 0.8x Zvoľnená
case 0: speedMultiplier = 1.0; break; // 1x Normálna
case 1: speedMultiplier = 1.25; break; // 1.25x Svižná
case 2: speedMultiplier = 1.55; break; // 1.5x Rýchla
case 3: speedMultiplier = 2.0; break; // 2x Veľmi rýchla
case 4: speedMultiplier = 2.75; break; // 2.75x Turbo
case 5: speedMultiplier = 3.8; break; // 4x Blesková
default: speedMultiplier = 1.0; break;
}
}
function getAudioContext() {
if (!audioCtx) {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (AudioContext) {
audioCtx = new AudioContext();
}
}
if (audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume();
}
return audioCtx;
}
// Automatické prebudenie AudioContextu pri prvom kliknutí používateľa kdekoľvek
if (typeof window !== 'undefined') {
const unlockAudio = () => {
getAudioContext();
window.removeEventListener('pointerdown', unlockAudio);
window.removeEventListener('keydown', unlockAudio);
};
window.addEventListener('pointerdown', unlockAudio, { passive: true });
window.addEventListener('keydown', unlockAudio, { passive: true });
}
// --- PROCEDURAL SOUND GENERATORS ---
function playCardSnap() {
if (isMuted) return;
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
const bufferSize = ctx.sampleRate * 0.05;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (ctx.sampleRate * 0.01));
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(1400 + Math.random() * 400, now);
filter.Q.setValueAtTime(3, now);
const osc = ctx.createOscillator();
const oscGain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(160, now);
osc.frequency.exponentialRampToValueAtTime(40, now + 0.08);
oscGain.gain.setValueAtTime(0.3, now);
oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
const noiseGain = ctx.createGain();
noiseGain.gain.setValueAtTime(0.5, now);
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
noise.connect(filter);
filter.connect(noiseGain);
noiseGain.connect(ctx.destination);
osc.connect(oscGain);
oscGain.connect(ctx.destination);
noise.start(now);
osc.start(now);
osc.stop(now + 0.09);
}
function playCardDraw() {
if (isMuted) return;
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
const bufferSize = ctx.sampleRate * 0.08;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize);
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(2200, now);
filter.frequency.exponentialRampToValueAtTime(800, now + 0.08);
filter.Q.setValueAtTime(2, now);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.25, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
noise.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
noise.start(now);
}
function playSevenStack(level) {
if (isMuted) return;
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
const rootFreq = 180 + Math.min(level, 4) * 80;
const osc1 = ctx.createOscillator();
const osc2 = ctx.createOscillator();
const gain = ctx.createGain();
osc1.type = 'sawtooth';
osc1.frequency.setValueAtTime(rootFreq, now);
osc1.frequency.exponentialRampToValueAtTime(rootFreq * 0.5, now + 0.4);
osc2.type = 'triangle';
osc2.frequency.setValueAtTime(rootFreq * 1.5, now);
osc2.frequency.exponentialRampToValueAtTime(rootFreq * 0.75, now + 0.4);
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(2000, now);
filter.frequency.exponentialRampToValueAtTime(200, now + 0.4);
gain.gain.setValueAtTime(0.4 + Math.min(level * 0.1, 0.4), now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.45);
osc1.connect(filter);
osc2.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
osc1.start(now);
osc2.start(now);
osc1.stop(now + 0.45);
osc2.stop(now + 0.45);
triggerScreenShake(level * 3 + 2);
}
function playAceFreeze() {
if (isMuted) return;
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
const freqs = [880, 1174, 1567, 2093];
freqs.forEach((freq, idx) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, now + idx * 0.04);
gain.gain.setValueAtTime(0.15, now + idx * 0.04);
gain.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.04 + 0.35);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now + idx * 0.04);
osc.stop(now + idx * 0.04 + 0.36);
});
triggerScreenShake(3);
}
function playIceCrack() {
if (isMuted) return;
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
// Sharp ice snap noise
const bufferSize = ctx.sampleRate * 0.12;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (ctx.sampleRate * 0.02));
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'highpass';
filter.frequency.setValueAtTime(3200, now);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.45, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
noise.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
noise.start(now);
// Resonant high crystal ringing
[2400, 3600, 4800].forEach((freq, idx) => {
const osc = ctx.createOscillator();
const oscGain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq + Math.random() * 200, now + idx * 0.02);
oscGain.gain.setValueAtTime(0.12, now + idx * 0.02);
oscGain.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.02 + 0.22);
osc.connect(oscGain);
oscGain.connect(ctx.destination);
osc.start(now + idx * 0.02);
osc.stop(now + idx * 0.02 + 0.23);
});
triggerScreenShake(4);
}
function playLeafShield() {
if (isMuted) return;
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
[523.25, 659.25, 783.99, 1046.50].forEach((freq, idx) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(freq, now + idx * 0.05);
gain.gain.setValueAtTime(0.2, now + idx * 0.05);
gain.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.05 + 0.35);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now + idx * 0.05);
osc.stop(now + idx * 0.05 + 0.36);
});
triggerScreenShake(2);
}
function playHornikChime() {
if (isMuted) return;
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
const notes = [523.25, 659.25, 783.99, 1046.50];
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, now + i * 0.05);
gain.gain.setValueAtTime(0.2, now + i * 0.05);
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.05 + 0.6);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now + i * 0.05);
osc.stop(now + i * 0.05 + 0.61);
});
}
function playBurnExplosion() {
if (isMuted) return;
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const oscGain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(140, now);
osc.frequency.exponentialRampToValueAtTime(30, now + 0.7);
oscGain.gain.setValueAtTime(0.6, now);
oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.7);
const bufferSize = ctx.sampleRate * 0.6;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (ctx.sampleRate * 0.15));
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(800, now);
filter.frequency.exponentialRampToValueAtTime(100, now + 0.6);
const noiseGain = ctx.createGain();
noiseGain.gain.setValueAtTime(0.6, now);
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.6);
noise.connect(filter);
filter.connect(noiseGain);
noiseGain.connect(ctx.destination);
osc.connect(oscGain);
oscGain.connect(ctx.destination);
noise.start(now);
osc.start(now);
osc.stop(now + 0.71);
triggerScreenShake(12);
}
function playVictory() {
if (isMuted) return;
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
const melody = [
{ f: 261.63, t: 0.00, d: 0.15 },
{ f: 392.00, t: 0.15, d: 0.15 },
{ f: 523.25, t: 0.30, d: 0.20 },
{ f: 659.25, t: 0.50, d: 0.20 },
{ f: 783.99, t: 0.70, d: 0.60 }
];
melody.forEach(note => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(note.f, now + note.t);
gain.gain.setValueAtTime(0.3, now + note.t);
gain.gain.exponentialRampToValueAtTime(0.001, now + note.t + note.d);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now + note.t);
osc.stop(now + note.t + note.d + 0.05);
});
triggerConfetti();
}
function playHeartbeat() {
if (isMuted) return;
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
[0, 0.18].forEach(offset => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(80, now + offset);
osc.frequency.exponentialRampToValueAtTime(45, now + offset + 0.1);
gain.gain.setValueAtTime(0.4, now + offset);
gain.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.12);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now + offset);
osc.stop(now + offset + 0.13);
});
}
function playCardWhoosh() {
if (isMuted) return;
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
const bufferSize = Math.floor(ctx.sampleRate * 0.22);
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.sin((i / bufferSize) * Math.PI);
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(450, now);
filter.frequency.exponentialRampToValueAtTime(1200, now + 0.1);
filter.frequency.exponentialRampToValueAtTime(350, now + 0.22);
filter.Q.value = 2.5;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.3, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.22);
noise.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
noise.start(now);
}
function playBotPersonalitySfx(personalityId, emotion) {
if (isMuted) return;
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
const pId = (personalityId || 'fox').toLowerCase();
const emo = (emotion || 'thinking').toLowerCase();
// Mierny posun tóniny podľa emócie
let pitchMul = 1.0;
if (emo === 'shocked' || emo === 'desperate') pitchMul = 0.82;
else if (emo === 'confident' || emo === 'celebrating') pitchMul = 1.22;
else if (emo === 'freezing') pitchMul = 1.45;
else if (emo === 'angry') pitchMul = 0.92;
switch (pId) {
case 'fox': { // Lišiak Filip - sly playful rising arpeggio
const baseFreqs = [440, 554.37, 659.25, 880];
baseFreqs.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const t = now + i * 0.045;
osc.type = 'triangle';
osc.frequency.setValueAtTime(freq * pitchMul, t);
gain.gain.setValueAtTime(0.28, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.18);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t);
osc.stop(t + 0.19);
});
break;
}
case 'queen': { // Kráľovná Alžbeta - regal royal chime / bell chords
const baseFreqs = [523.25, 659.25, 783.99, 1046.50];
baseFreqs.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const t = now + i * 0.055;
osc.type = 'sine';
osc.frequency.setValueAtTime(freq * pitchMul, t);
gain.gain.setValueAtTime(0.32, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.42);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t);
osc.stop(t + 0.43);
});
break;
}
case 'robot': { // Kyber-Robo - 8-bit digital synth crunch
const tones = [880, 1320, 660, 1760];
tones.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const t = now + i * 0.04;
osc.type = 'square';
osc.frequency.setValueAtTime(freq * pitchMul, t);
gain.gain.setValueAtTime(0.22, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.06);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t);
osc.stop(t + 0.065);
});
break;
}
case 'grandpa': { // Dedo Jozef - warm vintage harmonic bell
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(987.77 * pitchMul, now);
gain.gain.setValueAtTime(0.35, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.55);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.56);
break;
}
case 'cyberkid': { // Hacker Miško - futuristic laser zap
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(1500 * pitchMul, now);
osc.frequency.exponentialRampToValueAtTime(180 * pitchMul, now + 0.18);
gain.gain.setValueAtTime(0.3, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.18);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.19);
break;
}
case 'cat': { // Mačka Minda - playful synth meow
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(580 * pitchMul, now);
osc.frequency.exponentialRampToValueAtTime(980 * pitchMul, now + 0.11);
osc.frequency.exponentialRampToValueAtTime(680 * pitchMul, now + 0.28);
gain.gain.setValueAtTime(0.32, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.30);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.31);
break;
}
case 'wizard': { // Čarodejník Baltazár - mystical shimmering chord
[392.00, 587.33, 880.00, 1174.66].forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const t = now + i * 0.035;
osc.type = 'sine';
osc.frequency.setValueAtTime(freq * pitchMul, t);
gain.gain.setValueAtTime(0.24, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t);
osc.stop(t + 0.46);
});
break;
}
case 'bear': { // Medveď Mišo - deep bass growl thud
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(95 * pitchMul, now);
osc.frequency.exponentialRampToValueAtTime(38 * pitchMul, now + 0.35);
gain.gain.setValueAtTime(0.42, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.36);
break;
}
default: {
playCardSnap();
break;
}
}
}
// --- SCREEN SHAKE ---
function triggerScreenShake(intensity = 5) {
const board = document.getElementById('game-table-container');
if (!board) return;
board.classList.remove('shake-fx');
void board.offsetWidth;
board.style.setProperty('--shake-intensity', `${intensity}px`);
board.classList.add('shake-fx');
setTimeout(() => {
board.classList.remove('shake-fx');
}, 400);
}
// --- 3D CARD PLAY & DRAW ANIMATIONS ---
function getElementCenter(elem) {
if (!elem) return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
const rect = elem.getBoundingClientRect();
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
};
}
function getPositionCoords(position) {
const w = window.innerWidth;
const h = window.innerHeight;
switch (position) {
case 'left':
case 'slot-left':
return { x: w * 0.12, y: h * 0.45 };
case 'top':
case 'slot-top':
return { x: w * 0.50, y: h * 0.14 };
case 'right':
case 'slot-right':
return { x: w * 0.88, y: h * 0.45 };
case 'bottom':
case 'slot-bottom':
default:
return { x: w * 0.50, y: h * 0.82 };
}
}
function animateCardPlay(sourcePosition, cardInfo) {
return new Promise((resolve) => {
const container = document.getElementById('game-table-container') || document.body;
const discardTargetElem = document.querySelector('.discard-pile-container');
const targetPos = discardTargetElem ? getElementCenter(discardTargetElem) : { x: window.innerWidth / 2 + 50, y: window.innerHeight / 2 };
const startPos = getPositionCoords(sourcePosition);
// Vytvoríme lietajúcu kartu
const flyingCard = document.createElement('div');
flyingCard.className = 'game-card marias-card flying-card-anim';
flyingCard.style.position = 'fixed';
flyingCard.style.left = `${startPos.x}px`;
flyingCard.style.top = `${startPos.y}px`;
flyingCard.style.transform = 'translate(-50%, -50%)';
flyingCard.style.zIndex = '999';
flyingCard.style.pointerEvents = 'none';
flyingCard.style.boxShadow = '0 20px 40px rgba(0,0,0,0.8), 0 0 30px rgba(245, 176, 65, 0.9)';
const deck = cardInfo.deck || 'original';
const imgPath = (cardInfo.suit && cardInfo.rank)
? `assets/cards/${deck}/${cardInfo.suit}_${cardInfo.rank}.webp`
: `assets/cards/${deck}/Card_Back.webp`;
flyingCard.innerHTML = `<img src="${imgPath}" class="marias-card-img" />`;
container.appendChild(flyingCard);
const dx = targetPos.x - startPos.x;
const dy = targetPos.y - startPos.y;
const randomRotation = (Math.random() - 0.5) * 16; // -8 to +8 deg landing tilt
const animation = flyingCard.animate([
{
transform: 'translate(-50%, -50%) translate3d(0, 0, 0) scale(0.85) rotate(0deg)',
opacity: 0.9
},
{
transform: `translate(-50%, -50%) translate3d(${dx * 0.45}px, ${dy * 0.45 - 60}px, 0) scale(1.35) rotate(${randomRotation * 0.5}deg)`,
opacity: 1,
offset: 0.5
},
{
transform: `translate(-50%, -50%) translate3d(${dx}px, ${dy}px, 0) scale(1) rotate(${randomRotation}deg)`,
opacity: 1
}
], {
duration: Math.round(420 / speedMultiplier),
easing: 'cubic-bezier(0.175, 0.885, 0.32, 1.2)'
});
animation.onfinish = () => {
playCardSnap();
triggerImpactRipple(targetPos.x, targetPos.y);
flyingCard.remove();
resolve();
};
});
}
function animateCardDraw(targetPosition, deck = 'original') {
return new Promise((resolve) => {
const container = document.getElementById('game-table-container') || document.body;
const deckElem = document.querySelector('.deck-card-container');
const startPos = deckElem ? getElementCenter(deckElem) : { x: window.innerWidth / 2 - 50, y: window.innerHeight / 2 };
const targetPos = getPositionCoords(targetPosition);
const flyingBack = document.createElement('div');
flyingBack.className = 'game-card marias-card flying-card-anim';
flyingBack.style.position = 'fixed';
flyingBack.style.left = `${startPos.x}px`;
flyingBack.style.top = `${startPos.y}px`;
flyingBack.style.transform = 'translate(-50%, -50%)';
flyingBack.style.zIndex = '999';
flyingBack.style.pointerEvents = 'none';
flyingBack.style.boxShadow = '0 10px 25px rgba(0,0,0,0.7)';
flyingBack.innerHTML = `<img src="assets/cards/${deck || 'original'}/Card_Back.webp" class="marias-card-img" />`;
container.appendChild(flyingBack);
playCardDraw();
const dx = targetPos.x - startPos.x;
const dy = targetPos.y - startPos.y;
const animation = flyingBack.animate([
{ transform: 'translate(-50%, -50%) translate3d(0, 0, 0) scale(1) rotate(0deg)', opacity: 1 },
{ transform: `translate(-50%, -50%) translate3d(${dx * 0.5}px, ${dy * 0.5 - 30}px, 0) scale(1.15) rotate(10deg)`, opacity: 1, offset: 0.5 },
{ transform: `translate(-50%, -50%) translate3d(${dx}px, ${dy}px, 0) scale(0.6) rotate(0deg)`, opacity: 0.2 }
], {
duration: Math.round(380 / speedMultiplier),
easing: 'cubic-bezier(0.25, 1, 0.5, 1)'
});
animation.onfinish = () => {
flyingBack.remove();
resolve();
};
});
}
function triggerImpactRipple(x, y) {
if (!particleCanvas) return;
for (let i = 0; i < 12; i++) {
const angle = (i / 12) * Math.PI * 2;
const speed = 2 + Math.random() * 3;
particles.push({
x: x,
y: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
gravity: 0,
size: 3 + Math.random() * 3,
color: '#ffd700',
shape: 'circle',
life: 0.6,
decay: 0.04,
rotation: 0
});
}
}
// --- TURN TRANSITION ANIMATION ---
function getSlotElement(pos) {
if (!pos) return null;
const normalized = pos.replace('slot-', '');
if (normalized === 'bottom') {
return document.querySelector('.slot-bottom .avatar-badge') || document.querySelector('.slot-bottom');
}
return document.querySelector('.slot-' + normalized + ' .avatar-badge') || document.querySelector('.slot-' + normalized);
}
function playTurnPassSound() {
if (isMuted) return;
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(540, now);
osc.frequency.exponentialRampToValueAtTime(820, now + 0.11);
gain.gain.setValueAtTime(0.07, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.13);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.14);
}
function animateTurnTransition(fromPosition, toPosition) {
if (!fromPosition || !toPosition || fromPosition === toPosition) return Promise.resolve();
return new Promise((resolve) => {
const fromElem = getSlotElement(fromPosition);
const toElem = getSlotElement(toPosition);
const startPos = fromElem ? getElementCenter(fromElem) : getPositionCoords(fromPosition);
const endPos = toElem ? getElementCenter(toElem) : getPositionCoords(toPosition);
const container = document.getElementById('game-table-container') || document.body;
const orb = document.createElement('div');
orb.className = 'turn-transfer-orb';
orb.style.left = `${startPos.x}px`;
orb.style.top = `${startPos.y}px`;
container.appendChild(orb);
const midX = (startPos.x + endPos.x) / 2;
const midY = Math.min(startPos.y, endPos.y) - 35;
const duration = Math.round(380 / speedMultiplier);
const startTime = performance.now();
function step(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const t = 1 - Math.pow(1 - progress, 3); // Ease out cubic
// Quadratic Bezier curve
const currentX = (1 - t) * (1 - t) * startPos.x + 2 * (1 - t) * t * midX + t * t * endPos.x;
const currentY = (1 - t) * (1 - t) * startPos.y + 2 * (1 - t) * t * midY + t * t * endPos.y;
orb.style.left = `${currentX}px`;
orb.style.top = `${currentY}px`;
if (particleCanvas && Math.random() < 0.4) {
particles.push({
x: currentX,
y: currentY,
vx: (Math.random() - 0.5) * 1.5,
vy: (Math.random() - 0.5) * 1.5,
gravity: 0,
size: 3 + Math.random() * 3,
color: Math.random() > 0.4 ? '#ffd700' : '#f5b041',
shape: 'spark',
life: 0.6,
decay: 0.04,
rotation: Math.random() * Math.PI
});
}
if (progress < 1) {
requestAnimationFrame(step);
} else {
orb.remove();
if (toElem) {
toElem.classList.remove('turn-arrived-pulse');
void toElem.offsetWidth;
toElem.classList.add('turn-arrived-pulse');
setTimeout(() => toElem.classList.remove('turn-arrived-pulse'), 600);
}
if (particleCanvas) {
for (let i = 0; i < 8; i++) {
const angle = Math.random() * Math.PI * 2;
const spd = 1.5 + Math.random() * 2.5;
particles.push({
x: endPos.x,
y: endPos.y,
vx: Math.cos(angle) * spd,
vy: Math.sin(angle) * spd,
gravity: 0.02,
size: 3 + Math.random() * 3,
color: '#ffd700',
shape: 'spark',
life: 0.7,
decay: 0.035,
rotation: Math.random() * Math.PI
});
}
}
playTurnPassSound();
resolve();
}
}
requestAnimationFrame(step);
});
}
// --- PARTICLE ENGINE ---
function initParticles(canvasId) {
particleCanvas = document.getElementById(canvasId);
if (!particleCanvas) return;
particleCtx = particleCanvas.getContext('2d');
function resize() {
if (particleCanvas) {
particleCanvas.width = window.innerWidth;
particleCanvas.height = window.innerHeight;
}
}
window.addEventListener('resize', resize);
resize();
if (!animationFrameId) {
renderParticles();
}
}
function renderParticles() {
if (particleCtx && particleCanvas) {
particleCtx.clearRect(0, 0, particleCanvas.width, particleCanvas.height);
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.vy += p.gravity || 0;
p.rotation += p.vRot || 0;
p.life -= p.decay || 0.02;
if (p.life <= 0) {
particles.splice(i, 1);
continue;
}
particleCtx.save();
particleCtx.translate(p.x, p.y);
particleCtx.rotate(p.rotation);
particleCtx.globalAlpha = Math.max(0, p.life);
if (p.shape === 'circle') {
particleCtx.fillStyle = p.color;
particleCtx.beginPath();
particleCtx.arc(0, 0, p.size, 0, Math.PI * 2);
particleCtx.fill();
} else if (p.shape === 'rect') {
particleCtx.fillStyle = p.color;
particleCtx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * (p.aspect || 1));
} else if (p.shape === 'spark') {
particleCtx.strokeStyle = p.color;
particleCtx.lineWidth = p.size;
particleCtx.beginPath();
particleCtx.moveTo(-p.vx * 3, -p.vy * 3);
particleCtx.lineTo(0, 0);
particleCtx.stroke();
}
particleCtx.restore();
}
}
animationFrameId = requestAnimationFrame(renderParticles);
}
function triggerFireParticles(x, y, count = 25) {
if (!particleCanvas) return;
const colors = ['#ff4500', '#ff8c00', '#ffd700', '#ffffff'];
for (let i = 0; i < count; i++) {
const angle = -Math.PI / 2 + (Math.random() - 0.5) * 1.5;
const speed = 3 + Math.random() * 6;
particles.push({
x: x || window.innerWidth / 2,
y: y || window.innerHeight / 2,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
gravity: -0.05,
size: 4 + Math.random() * 6,
color: colors[Math.floor(Math.random() * colors.length)],
shape: 'circle',
life: 1.0,
decay: 0.03 + Math.random() * 0.03,
rotation: 0
});
}
}
function triggerIceParticles(x, y, count = 30) {
if (!particleCanvas) return;
const colors = ['#00ffff', '#e0ffff', '#87cefa', '#ffffff'];
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 2 + Math.random() * 7;
particles.push({
x: x || window.innerWidth / 2,
y: y || window.innerHeight / 2,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
gravity: 0.1,
size: 3 + Math.random() * 5,
color: colors[Math.floor(Math.random() * colors.length)],
shape: 'spark',
life: 1.0,
decay: 0.02 + Math.random() * 0.02,
rotation: Math.random() * Math.PI
});
}
}
function triggerIceShatter(position) {
if (!particleCanvas) return;
let x = window.innerWidth / 2;
let y = window.innerHeight / 2;
if (typeof position === 'string') {
const elem = getSlotElement(position);
const coords = elem ? getElementCenter(elem) : getPositionCoords(position);
x = coords.x;
y = coords.y;
} else if (position && position.x && position.y) {
x = position.x;
y = position.y;
}
playIceCrack();
const colors = ['#00ffff', '#80d8ff', '#e0f7fa', '#ffffff', '#b2ebf2', '#87cefa'];
for (let i = 0; i < 40; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 3 + Math.random() * 10;
particles.push({
x: x + (Math.random() - 0.5) * 50,
y: y + (Math.random() - 0.5) * 40,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 2.5,
gravity: 0.22,
size: 6 + Math.random() * 9,
aspect: 0.35 + Math.random() * 1.5,
color: colors[Math.floor(Math.random() * colors.length)],
shape: Math.random() > 0.35 ? 'rect' : 'spark',
life: 1.1,
decay: 0.022 + Math.random() * 0.02,
rotation: Math.random() * Math.PI * 2,
vRot: (Math.random() - 0.5) * 0.4
});
}
}
function triggerLeafParticles(x, y, count = 35) {
if (!particleCanvas) return;
const startX = x || window.innerWidth / 2;
const startY = y || window.innerHeight / 2;
const colors = ['#2d6a4f', '#52b788', '#74c69d', '#b7e4c7', '#d8f3dc', '#ffd700'];
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 2.5 + Math.random() * 8;
particles.push({
x: startX + (Math.random() - 0.5) * 30,
y: startY + (Math.random() - 0.5) * 30,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 1.5,
gravity: 0.12,
size: 5 + Math.random() * 7,
aspect: 0.4 + Math.random() * 1.5,
color: colors[Math.floor(Math.random() * colors.length)],
shape: 'spark',
life: 1.0,
decay: 0.025 + Math.random() * 0.02,
rotation: Math.random() * Math.PI * 2,
vRot: (Math.random() - 0.5) * 0.3
});
}
}
function triggerBurnExplosion(x, y) {
if (!particleCanvas) return;
const colors = ['#ff0000', '#ff4500', '#ffaa00', '#333333', '#ffffff'];
for (let i = 0; i < 60; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 4 + Math.random() * 10;
particles.push({
x: x || window.innerWidth / 2,
y: y || window.innerHeight / 2,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
gravity: 0.15,
size: 5 + Math.random() * 8,
color: colors[Math.floor(Math.random() * colors.length)],
shape: Math.random() > 0.5 ? 'rect' : 'circle',
aspect: 1 + Math.random() * 2,
life: 1.0,
decay: 0.02 + Math.random() * 0.02,
rotation: Math.random() * Math.PI,
vRot: (Math.random() - 0.5) * 0.2
});
}
}
function triggerConfetti() {
if (!particleCanvas) return;
const colors = ['#ffd700', '#ff4500', '#00ffcc', '#ff007f', '#ffffff', '#7b2cbf'];
for (let i = 0; i < 150; i++) {
const x = Math.random() * window.innerWidth;
const y = -20 - Math.random() * 50;
particles.push({
x: x,
y: y,
vx: (Math.random() - 0.5) * 4,
vy: 3 + Math.random() * 5,
gravity: 0.08,
size: 8 + Math.random() * 6,
aspect: 0.6 + Math.random() * 0.8,
color: colors[Math.floor(Math.random() * colors.length)],
shape: 'rect',
life: 1.0,
decay: 0.005 + Math.random() * 0.005,
rotation: Math.random() * Math.PI * 2,
vRot: (Math.random() - 0.5) * 0.15
});
}
}
// --- DOPAMINE PROCEDURAL AUDIO ---
function playSelectStep(step = 1) {
if (isMuted) return;
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
// Pentatonic rising scale (C5, E5, G5, C6, E6)
const freqs = [523.25, 659.25, 783.99, 1046.50, 1318.51];
const freq = freqs[Math.min(step - 1, freqs.length - 1)] || 523.25;
const osc = ctx.createOscillator();
const harm = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(freq, now);
osc.frequency.exponentialRampToValueAtTime(freq * 1.02, now + 0.12);
harm.type = 'sine';
harm.frequency.setValueAtTime(freq * 2, now);
gain.gain.setValueAtTime(0.28, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.14);
osc.connect(gain);
harm.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
harm.start(now);
osc.stop(now + 0.15);
harm.stop(now + 0.15);
}
function playComboSlam(count = 2) {
if (isMuted) return;
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
// Heavy 808 Sub-bass drop
const sub = ctx.createOscillator();
const subGain = ctx.createGain();
sub.type = 'sine';
sub.frequency.setValueAtTime(140 + count * 20, now);
sub.frequency.exponentialRampToValueAtTime(32, now + 0.45);
subGain.gain.setValueAtTime(0.7, now);
subGain.gain.exponentialRampToValueAtTime(0.001, now + 0.45);
sub.connect(subGain);
subGain.connect(ctx.destination);
sub.start(now);
sub.stop(now + 0.46);
// Metallic punch noise
const bufferSize = ctx.sampleRate * 0.12;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (ctx.sampleRate * 0.03));
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(1800, now);
filter.Q.setValueAtTime(4, now);
const noiseGain = ctx.createGain();
noiseGain.gain.setValueAtTime(0.5, now);
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
noise.connect(filter);
filter.connect(noiseGain);
noiseGain.connect(ctx.destination);
noise.start(now);
// Escalating screen shake
triggerScreenShake(count >= 4 ? 16 : (count === 3 ? 10 : 6));
}
function playJackpotCoins() {
if (isMuted) return;
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
const notes = [987.77, 1174.66, 1318.51, 1567.98, 1760.00, 2093.00, 2349.32, 2793.83];
notes.forEach((freq, idx) => {
const time = now + idx * 0.06;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, time);
gain.gain.setValueAtTime(0.22, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.22);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(time);
osc.stop(time + 0.23);
});
}
function playReviveSound() {
if (isMuted) return;
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
const chord = [440, 554.37, 659.25, 880, 1108.73];
chord.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(freq, now + i * 0.04);
osc.frequency.linearRampToValueAtTime(freq * 1.5, now + 0.5);
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(800, now);
filter.frequency.exponentialRampToValueAtTime(3500, now + 0.4);
gain.gain.setValueAtTime(0.25, now + i * 0.04);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.6);
osc.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
osc.start(now + i * 0.04);
osc.stop(now + 0.62);
});
triggerScreenShake(8);
}
// --- DOPAMINE BANNER SYSTEM ---
function triggerDopamineBanner(title, subtitle = '', type = 'combo') {
const container = document.getElementById('game-table-container') || document.body;
// Remove existing banner if any
const oldBanner = document.querySelector('.dopamine-banner');
if (oldBanner) oldBanner.remove();
const banner = document.createElement('div');
banner.className = `dopamine-banner dopamine-${type}`;
banner.innerHTML = `
<div class="dopamine-halo"></div>
<div class="dopamine-content">
<div class="dopamine-title">${title}</div>
${subtitle ? `<div class="dopamine-sub">${subtitle}</div>` : ''}
</div>
`;
container.appendChild(banner);
// Sound & Particles based on type
if (type === 'quad' || type === 'mega') {
playComboSlam(4);
triggerGoldStarExplosion();
triggerConfetti();
} else if (type === 'triple') {
playComboSlam(3);
triggerFireParticles(window.innerWidth / 2, window.innerHeight / 2, 45);
} else if (type === 'double') {
playComboSlam(2);
triggerLightningSparks();
} else if (type === 'freeze') {
playAceFreeze();
triggerIceParticles(window.innerWidth / 2, window.innerHeight / 2, 50);
} else if (type === 'revive') {
playReviveSound();
triggerLightningSparks(window.innerWidth / 2, window.innerHeight / 2, 60);
} else if (type === 'jackpot') {
playJackpotCoins();
triggerCoinJackpot();
triggerConfetti();
}
// Auto remove banner after animation
setTimeout(() => {
banner.classList.add('dopamine-fadeout');
setTimeout(() => banner.remove(), 400);
}, 1800);
}
// --- ADVANCED PARTICLES ---
function triggerCoinJackpot(x, y, count = 70) {
if (!particleCanvas) return;
const startX = x || window.innerWidth / 2;
const startY = y || window.innerHeight / 2;
for (let i = 0; i < count; i++) {
const angle = -Math.PI / 2 + (Math.random() - 0.5) * 2.2;
const speed = 6 + Math.random() * 12;
particles.push({
x: startX,
y: startY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
gravity: 0.28,
size: 10 + Math.random() * 6,
aspect: 0.5 + Math.random() * 0.5,
color: Math.random() > 0.3 ? '#ffd700' : '#f5b041',
shape: 'rect',
life: 1.2,
decay: 0.012 + Math.random() * 0.008,
rotation: Math.random() * Math.PI * 2,
vRot: (Math.random() - 0.5) * 0.25
});
}
}
function triggerLightningSparks(x, y, count = 40) {
if (!particleCanvas) return;
const colors = ['#00e5ff', '#ffd700', '#ffffff', '#e040fb'];
const startX = x || window.innerWidth / 2;
const startY = y || window.innerHeight / 2;
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 4 + Math.random() * 10;
particles.push({
x: startX,
y: startY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
gravity: 0,
size: 3 + Math.random() * 4,
color: colors[Math.floor(Math.random() * colors.length)],
shape: 'spark',
life: 0.8,
decay: 0.035,
rotation: Math.random() * Math.PI
});
}
}
function triggerGoldStarExplosion(x, y, count = 60) {
if (!particleCanvas) return;
const startX = x || window.innerWidth / 2;
const startY = y || window.innerHeight / 2;
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 3 + Math.random() * 11;
particles.push({
x: startX,
y: startY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
gravity: 0.06,
size: 6 + Math.random() * 6,
color: '#ffd700',
shape: 'circle',
life: 1.1,
decay: 0.02,
rotation: 0
});
}
}
function toggleMute() {
isMuted = !isMuted;
return isMuted;
}
return {
playCardSnap,
playCardDraw,
playSevenStack,
playAceFreeze,
playIceCrack,
playLeafShield,
playHornikChime,
playBurnExplosion,
playVictory,
playHeartbeat,
playSelectStep,
playComboSlam,
playJackpotCoins,
playReviveSound,
triggerDopamineBanner,
triggerScreenShake,
initParticles,
triggerFireParticles,
triggerIceParticles,
triggerIceShatter,
triggerLeafParticles,
triggerBurnExplosion,
triggerConfetti,
triggerCoinJackpot,
triggerLightningSparks,
triggerGoldStarExplosion,
animateCardPlay,
animateCardDraw,
animateTurnTransition,
playCardWhoosh,
playBotPersonalitySfx,
setSpeedLevel,
toggleMute
};
})();