import { COLORS } from '../config.js'; import { auth } from '../services/auth.js'; import { api } from '../services/api.js'; import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from './SpeechQueue.js'; // ── Shared portrait utility ─────────────────────────────────────────────────── // Both factories add elements directly to the scene at world coordinates so // that GeometryMasks and DOM video elements work without container offsets. let _emotionStylesInjected = false; function injectEmotionStyles() { if (_emotionStylesInjected) return; _emotionStylesInjected = true; const s = document.createElement('style'); s.textContent = ` @keyframes portrait-pulse-happy { 0%,100% { box-shadow: 0 0 0 4px rgba(0,210,60,0.9), 0 0 8px 6px rgba(0,210,60,0.2); } 50% { box-shadow: 0 0 0 7px rgba(40,255,90,1), 0 0 18px 10px rgba(40,255,90,0.55); } } @keyframes portrait-pulse-upset { 0%,100% { box-shadow: 0 0 0 4px rgba(210,30,30,0.9), 0 0 8px 6px rgba(210,30,30,0.2); } 50% { box-shadow: 0 0 0 7px rgba(255,60,60,1), 0 0 18px 10px rgba(255,60,60,0.55); } } .portrait-happy { animation: portrait-pulse-happy 0.8s ease-in-out infinite; } .portrait-upset { animation: portrait-pulse-upset 0.6s ease-in-out infinite; } `; document.head.appendChild(s); } function drawBacking(scene, x, y, radius, depth) { const g = scene.add.graphics().setDepth(depth); g.fillStyle(0x1a1a2e, 1); g.fillCircle(x, y, radius + 3); g.fillStyle(COLORS.panel, 1); g.fillCircle(x, y, radius + 1); return g; } // ── Opponent portrait (video + sprite fallback) ─────────────────────────────── // Returns { playEmotion(emotion), destroy() } export function createOpponentPortrait(scene, opponent, worldX, worldY, radius, depth, { playIntro = true } = {}) { injectEmotionStyles(); const size = radius * 2; const backingG = drawBacking(scene, worldX, worldY, radius, depth); // Always draw the sprite first as a fallback layer let spriteImg = null; if (scene.textures.exists('opponents')) { const maskG = scene.make.graphics({ x: 0, y: 0, add: false }); maskG.fillStyle(0xffffff); maskG.fillCircle(worldX, worldY, radius); spriteImg = scene.add.image(worldX, worldY, 'opponents', opponent?.spriteIndex ?? 0) .setDisplaySize(size, size) .setMask(maskG.createGeometryMask()) .setDepth(depth + 1); } // Attempt video on top — CSS border-radius handles circular clipping const videoEl = document.createElement('video'); videoEl.muted = true; videoEl.loop = true; videoEl.playsInline = true; videoEl.autoplay = true; videoEl.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;`; videoEl.src = `/assets/videos/${opponent?.id}-idle.mp4`; videoEl.play().catch(() => {}); // Hide the video element if the file doesn't exist so sprite shows through let videoError = false; videoEl.addEventListener('error', () => { videoEl.style.display = 'none'; videoError = true; }, { once: true }); const domEl = scene.add.dom(worldX, worldY, videoEl).setDepth(depth + 2); // Speech visualizer canvas — overlays the portrait when audio is playing const canvasEl = document.createElement('canvas'); canvasEl.width = size; canvasEl.height = size; canvasEl.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;pointer-events:none;opacity:0;transition:opacity 0.2s ease;`; const canvasDom = scene.add.dom(worldX, worldY, canvasEl).setDepth(depth + 3); const ctx = canvasEl.getContext('2d'); const BAR_COUNT = 6; const bars = Array.from({ length: BAR_COUNT }, () => ({ cur: 0.1, target: 0.5 })); let vizActive = false; let rafId = null; let retargetTimer = null; let vizColor = [0, 200, 255]; function _vizFrame() { if (!vizActive) { rafId = null; ctx.clearRect(0, 0, size, size); return; } ctx.clearRect(0, 0, size, size); const gy = size * 0.58; const grad = ctx.createLinearGradient(0, gy, 0, size); grad.addColorStop(0, 'rgba(0,0,0,0)'); grad.addColorStop(1, 'rgba(0,0,0,0.38)'); ctx.fillStyle = grad; ctx.fillRect(0, gy, size, size - gy); const [r, g, b] = vizColor; const barW = size * 0.072; const gap = size * 0.03; const totalW = BAR_COUNT * barW + (BAR_COUNT - 1) * gap; const startX = (size - totalW) / 2; const maxH = size * 0.36; const baseY = size * 0.88; bars.forEach((bar, i) => { bar.cur += (bar.target - bar.cur) * 0.14; const h = Math.max(bar.cur * maxH, size * 0.04); const x = startX + i * (barW + gap); ctx.shadowColor = `rgba(${r},${g},${b},0.7)`; ctx.shadowBlur = 5; ctx.fillStyle = `rgba(${r},${g},${b},0.88)`; ctx.beginPath(); ctx.roundRect(x, baseY - h, barW, h, 2); ctx.fill(); }); ctx.shadowBlur = 0; rafId = requestAnimationFrame(_vizFrame); } function startVisualizer(emotion) { if (emotion === 'happy') vizColor = [0, 210, 60]; else if (emotion === 'upset') vizColor = [210, 60, 60]; else vizColor = [0, 200, 255]; vizActive = true; canvasEl.style.opacity = '1'; if (!retargetTimer) { retargetTimer = setInterval(() => { bars.forEach(b => { b.target = 0.2 + Math.random() * 0.8; }); }, 120); } if (!rafId) rafId = requestAnimationFrame(_vizFrame); } function stopVisualizer() { vizActive = false; canvasEl.style.opacity = '0'; clearInterval(retargetTimer); retargetTimer = null; } let emotionPlaying = false; function playEmotion(emotion) { if (!opponent || emotionPlaying || videoEl.style.display === 'none') return; emotionPlaying = true; videoEl.classList.remove('portrait-happy', 'portrait-upset'); if (emotion === 'happy') videoEl.classList.add('portrait-happy'); else if (emotion === 'upset') videoEl.classList.add('portrait-upset'); const idleSrc = `/assets/videos/${opponent.id}-idle.mp4`; const returnToIdle = () => { videoEl.onended = null; videoEl.onerror = null; videoEl.classList.remove('portrait-happy', 'portrait-upset'); videoEl.loop = true; videoEl.src = idleSrc; videoEl.play().catch(() => {}); emotionPlaying = false; }; videoEl.loop = false; videoEl.src = `/assets/videos/${opponent.id}-${emotion}.mp4`; videoEl.play().catch(returnToIdle); videoEl.onended = returnToIdle; videoEl.onerror = returnToIdle; const speechClips = opponent?.speech?.[emotion]; if (speechClips?.length && Math.random() < 0.6) { enqueueSpeech(speechClips[Math.floor(Math.random() * speechClips.length)], { onStart: () => startVisualizer(emotion), onEnd: stopVisualizer, }); } } function hide() { backingG.setVisible(false); if (spriteImg) spriteImg.setVisible(false); domEl.setVisible(false); } function show() { backingG.setVisible(true); if (spriteImg) spriteImg.setVisible(true); if (!videoError) domEl.setVisible(true); } function stopVideo() { videoEl.loop = false; videoEl.pause(); videoEl.src = ''; videoEl.style.display = 'none'; emotionPlaying = false; stopVisualizer(); } function fadeToEliminated(duration = 700) { const targets = [backingG]; if (spriteImg) targets.push(spriteImg); scene.tweens.add({ targets, alpha: 0.2, duration }); } let destroyed = false; function destroy() { if (destroyed) return; destroyed = true; scene.events.off('shutdown', destroy); videoEl.pause(); videoEl.src = ''; resetSpeechQueue(); vizActive = false; if (rafId) { cancelAnimationFrame(rafId); rafId = null; } clearInterval(retargetTimer); retargetTimer = null; canvasDom.destroy(); domEl.destroy(); backingG.destroy(); if (spriteImg) spriteImg.destroy(); } scene.events.once('shutdown', destroy); if (playIntro && opponent?.speech?.intro?.length) { const clips = opponent.speech.intro; enqueueSpeech(clips[Math.floor(Math.random() * clips.length)], { onStart: () => startVisualizer('intro'), onEnd: stopVisualizer, }); } return { playEmotion, hide, show, stopVideo, fadeToEliminated, destroy }; } // ── Player portrait (profile avatar with letter fallback) ───────────────────── // Returns { destroy() } export function createPlayerPortrait(scene, worldX, worldY, radius, depth, sceneName) { const size = radius * 2; const backingG = drawBacking(scene, worldX, worldY, radius, depth); const initial = (auth.user?.username ?? 'You').charAt(0).toUpperCase(); const placeholder = scene.add.text(worldX, worldY, initial, { fontFamily: '"Julius Sans One"', fontSize: `${Math.round(radius * 0.9)}px`, color: COLORS.accentHex, }).setOrigin(0.5).setDepth(depth + 1); const allObjs = [backingG, placeholder]; let destroyed = false; // Async avatar load (async () => { try { const { profile } = await api.get('/profile'); if (!profile?.avatarPath) return; if (destroyed || !scene.scene.isActive(sceneName)) return; const key = `player-avatar-${profile.id}`; if (!scene.textures.exists(key)) { await new Promise((resolve) => { scene.load.image(key, profile.avatarPath); scene.load.once('complete', resolve); scene.load.start(); }); } if (destroyed || !scene.scene.isActive(sceneName)) return; const maskG = scene.make.graphics({ x: 0, y: 0, add: false }); maskG.fillStyle(0xffffff); maskG.fillCircle(worldX, worldY, radius); placeholder.destroy(); const avatarImg = scene.add.image(worldX, worldY, key) .setDisplaySize(size, size) .setMask(maskG.createGeometryMask()) .setDepth(depth + 1); allObjs.push(avatarImg); } catch { /* placeholder remains */ } })(); function hide() { for (const o of allObjs) o.setVisible?.(false); } function show() { for (const o of allObjs) o.setVisible?.(true); } function stopVideo() { /* no video on player portrait */ } function fadeToEliminated(duration = 700) { const targets = allObjs.filter(o => o?.active !== false && o?.setAlpha); if (targets.length > 0) scene.tweens.add({ targets, alpha: 0.2, duration }); } function destroy() { if (destroyed) return; destroyed = true; for (const o of allObjs) { try { o.destroy(); } catch (_) { /* already gone */ } } allObjs.length = 0; } return { hide, show, stopVideo, fadeToEliminated, destroy }; }