diff --git a/public/assets/images/opponents.png b/public/assets/images/opponents.png index dd7a4c2..f30089b 100644 Binary files a/public/assets/images/opponents.png and b/public/assets/images/opponents.png differ diff --git a/public/assets/images/opponents.psd b/public/assets/images/opponents.psd index ec0d54c..688544a 100644 Binary files a/public/assets/images/opponents.psd and b/public/assets/images/opponents.psd differ diff --git a/public/assets/images/originals/Victor2.png b/public/assets/images/originals/Victor2.png new file mode 100644 index 0000000..de5c0ca Binary files /dev/null and b/public/assets/images/originals/Victor2.png differ diff --git a/public/assets/videos/victor-happy.mp4 b/public/assets/videos/victor-happy.mp4 index 78048db..08b9c8c 100644 Binary files a/public/assets/videos/victor-happy.mp4 and b/public/assets/videos/victor-happy.mp4 differ diff --git a/public/assets/videos/victor-idle.mp4 b/public/assets/videos/victor-idle.mp4 index 3ffdc00..b4974df 100644 Binary files a/public/assets/videos/victor-idle.mp4 and b/public/assets/videos/victor-idle.mp4 differ diff --git a/public/assets/videos/victor-upset.mp4 b/public/assets/videos/victor-upset.mp4 index abac04a..acc4690 100644 Binary files a/public/assets/videos/victor-upset.mp4 and b/public/assets/videos/victor-upset.mp4 differ diff --git a/public/src/games/gofish/GoFishGame.js b/public/src/games/gofish/GoFishGame.js index 37fb408..424ac17 100644 --- a/public/src/games/gofish/GoFishGame.js +++ b/public/src/games/gofish/GoFishGame.js @@ -752,6 +752,9 @@ export default class GoFishGame extends Phaser.Scene { } // Catch, or fish with empty pool — normal completion. + if (last.result === 'catch' && targetSeat !== 0) { + this.opponentPortraits[targetSeat]?.playEmotion('upset'); + } this.showBanner(this.formatAskBanner(last)); playSound(this, last.result === 'catch' ? SFX.CARD_PLACE : SFX.CARD_DEAL); @@ -763,6 +766,7 @@ export default class GoFishGame extends Phaser.Scene { } if (last.newPairs > 0 && last.pairedCards?.length >= 2) { + if (askerSeat !== 0) this.opponentPortraits[askerSeat]?.playEmotion('happy'); // Cards from animateAsk are already visible in position. // Do NOT call renderAll() here — it would destroy the liveSprites. // renderAll() is deferred to after the pair + refill animations complete. @@ -1163,6 +1167,7 @@ export default class GoFishGame extends Phaser.Scene { observeLog(this.aiMemory[s], this.gs, s); } if (last.newPairs > 0 && last.pairedCards?.length >= 2) { + if (askerSeat !== 0) this.opponentPortraits[askerSeat]?.playEmotion('happy'); this.time.delayedCall(600, () => { this.hideBanner(); this.animatePairedCards(askerSeat, last.pairedCards, () => { diff --git a/public/src/games/phase10/Phase10Game.js b/public/src/games/phase10/Phase10Game.js index 9d88ec4..d1bad7e 100644 --- a/public/src/games/phase10/Phase10Game.js +++ b/public/src/games/phase10/Phase10Game.js @@ -1448,6 +1448,7 @@ export default class Phase10Game extends Phaser.Scene { cancelBtn.destroy(); const next = applyDiscard(this.gs, handIdx, s); if (next === this.gs) { this.setStatus("Can't skip that player."); return; } + this.opponentPortraits[s]?.playEmotion('upset'); this.gs = next; this.renderAll(); this.afterTurnTransition(); @@ -1535,6 +1536,9 @@ export default class Phase10Game extends Phaser.Scene { if (next === this.gs) { next = applyDiscard(this.gs, 0); } + if (action.type === 'discard' && action.skipTargetSeat != null) { + this.opponentPortraits[action.skipTargetSeat]?.playEmotion('upset'); + } this.gs = next; this.renderAll(); if (this.gs.roundPhase === 'roundOver' || this.gs.roundPhase === 'gameOver') { @@ -1563,6 +1567,7 @@ export default class Phase10Game extends Phaser.Scene { this._flyCard(layout.handStart.x, layout.handStart.y, DISCARD_POS.x, DISCARD_POS.y, card, { faceUp: true, rotation: layout.rotateCards, duration: 380 }, apply); } else if (action.type === 'laydown') { + this.opponentPortraits[seat]?.playEmotion('happy'); playSound(this, SFX.CARD_SHOW); this._animateAILaydown(seat, layout, action.groups, apply); } else if (action.type === 'hit') { diff --git a/public/src/games/skipbo/SkipBoGame.js b/public/src/games/skipbo/SkipBoGame.js index 0ad01fe..e3dbf67 100644 --- a/public/src/games/skipbo/SkipBoGame.js +++ b/public/src/games/skipbo/SkipBoGame.js @@ -959,6 +959,10 @@ export default class SkipBoGame extends Phaser.Scene { } if (action.type === 'play') { this.animating = true; + const seat = this.gs.currentPlayer; + if (action.source === 'stock') { + this.opponentPortraits[seat]?.playEmotion('happy'); + } const next = applyPlay(this.gs, action); this.animatePlay(this.gs, next, action, () => { this.gs = next; diff --git a/public/src/ui/Portrait.js b/public/src/ui/Portrait.js index a603870..7d53f91 100644 --- a/public/src/ui/Portrait.js +++ b/public/src/ui/Portrait.js @@ -6,6 +6,26 @@ import { api } from '../services/api.js'; // 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); @@ -18,6 +38,7 @@ function drawBacking(scene, x, y, radius, depth) { // ── Opponent portrait (video + sprite fallback) ─────────────────────────────── // Returns { playEmotion(emotion), destroy() } export function createOpponentPortrait(scene, opponent, worldX, worldY, radius, depth) { + injectEmotionStyles(); const size = radius * 2; const backingG = drawBacking(scene, worldX, worldY, radius, depth); @@ -58,10 +79,14 @@ export function createOpponentPortrait(scene, opponent, worldX, worldY, radius, 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(() => {});