307 lines
11 KiB
JavaScript
307 lines
11 KiB
JavaScript
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 };
|
|
}
|