feat: add animated emotion feedback to opponent portraits

Introduces pulsing box-shadow animations to opponent portraits to provide visual feedback during gameplay. Opponents now display "happy" (green pulse) or "upset" (red pulse) emotions in response to specific game actions across Go Fish, Phase 10, and Skip Bo. Includes new video assets for Victor and updated sprite sheets.
This commit is contained in:
Brian Fertig 2026-05-20 21:11:06 -06:00
parent 2ee763fb7a
commit 8a801b0f05
10 changed files with 39 additions and 0 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

View File

@ -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, () => {

View File

@ -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') {

View File

@ -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;

View File

@ -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(() => {});