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:
parent
2ee763fb7a
commit
8a801b0f05
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.
Binary file not shown.
Binary file not shown.
|
|
@ -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, () => {
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
|
|
|
|||
Loading…
Reference in New Issue