diff --git a/public/src/games/catan/CatanAI.js b/public/src/games/catan/CatanAI.js index 4e41fc1..73dcdf3 100644 --- a/public/src/games/catan/CatanAI.js +++ b/public/src/games/catan/CatanAI.js @@ -6,7 +6,7 @@ import { NODES, EDGES, HEXES, pipCount, COSTS, RESOURCE_TYPES } from './CatanBoa import { legalSettlementNodes, legalRoadEdges, canAfford, bestTradeRatio, handSize, nodeBuilding, stealTargets, publicVictoryPoints, - victoryPoints, WIN_VP, + victoryPoints, WIN_VP, longestRoadFor, } from './CatanLogic.js'; // Value of a vertex = production potential of its adjacent hexes + diversity. @@ -178,9 +178,11 @@ export function chooseAction(state, seat) { return { type: 'buildSettlement', nodeId: settleSpots[0] }; } - // 4. Build a road only when no settlement spot is already reachable. - // If a spot exists, hold resources and save up for the settlement instead. - if (canAfford(p, COSTS.road) && settleSpots.length === 0) { + // 4. Build a road when no settlement spot is reachable, or when the best + // reachable spot is low-value and a better target is accessible further out. + const EXPANSION_THRESHOLD = 8; + const bestSpotValue = settleSpots.length ? nodeValue(state, settleSpots[0]) : 0; + if (canAfford(p, COSTS.road) && (settleSpots.length === 0 || bestSpotValue < EXPANSION_THRESHOLD)) { const road = chooseExpansionRoad(state, seat); if (road != null) return { type: 'buildRoad', edgeId: road }; } @@ -208,23 +210,66 @@ function canReachNewSpot(state, seat) { function chooseExpansionRoad(state, seat) { const edges = legalRoadEdges(state, seat, false); if (!edges.length) return null; + + // Build road adjacency and find connected components of the player's network. + const roadAdj = new Map(); + for (const rid of state.players[seat].roads) { + const [ra, rb] = EDGES[rid].nodes; + if (!roadAdj.has(ra)) roadAdj.set(ra, new Set()); + if (!roadAdj.has(rb)) roadAdj.set(rb, new Set()); + roadAdj.get(ra).add(rb); + roadAdj.get(rb).add(ra); + } + const componentOf = new Map(); + let compId = 0; + for (const start of roadAdj.keys()) { + if (componentOf.has(start)) continue; + const queue = [start]; + while (queue.length) { + const n = queue.shift(); + if (componentOf.has(n)) continue; + componentOf.set(n, compId); + for (const nb of roadAdj.get(n)) queue.push(nb); + } + compId++; + } + const curLongest = longestRoadFor(state, seat); + let best = null, bestScore = -Infinity; for (const eid of edges) { const [a, b] = EDGES[eid].nodes; let score = 0; + for (const node of [a, b]) { - // Reward roads pointing at empty, distance-rule-legal vertices. + // Direct endpoint: full value if buildable. if (!nodeBuilding(state, node) && !NODES[node].adj.some((x) => nodeBuilding(state, x))) { score += nodeValue(state, node); } + // 1-hop lookahead: nodes one road-length further, half weight. + for (const adj of NODES[node].adj) { + if (adj === a || adj === b) continue; + if (!nodeBuilding(state, adj) && !NODES[adj].adj.some((x) => nodeBuilding(state, x))) { + score += nodeValue(state, adj) * 0.5; + } + } } - // Slight bias to chase Longest Road when we're close. - const ourLen = state.longestRoad.length; - if (state.longestRoad.owner !== seat && state.players[seat].roads.length >= 4) score += 1.5; + + // Bridge bonus: edge connects two disconnected road segments. + const ca = componentOf.get(a), cb = componentOf.get(b); + if (ca !== undefined && cb !== undefined && ca !== cb) { + score += 5; + } else if (state.longestRoad.owner !== seat) { + // Chain-extension bonus: only reward roads that genuinely grow the chain. + const tempPlayers = state.players.map((p, i) => + i === seat ? { ...p, roads: [...p.roads, eid] } : p + ); + if (longestRoadFor({ ...state, players: tempPlayers }, seat) > curLongest) score += 2; + } + if (score > bestScore) { bestScore = score; best = eid; } } - // Only build a road if it actually heads somewhere useful. - return bestScore > 0 ? best : (state.players[seat].roads.length < 4 ? null : best); + + return bestScore > 0 ? best : null; } function chooseHelpfulDev(state, seat, citySettlements, settleSpots) { diff --git a/public/src/games/catan/CatanGame.js b/public/src/games/catan/CatanGame.js index 539c048..774be4e 100644 --- a/public/src/games/catan/CatanGame.js +++ b/public/src/games/catan/CatanGame.js @@ -1652,7 +1652,7 @@ export default class CatanGame extends Phaser.Scene { } this.gs = this.applyAction(seat, a); if (a.type === 'playDev' && a.card !== 'vp') { - await this.animateOppDevCardPlay(seat, a.card); + await this.animateOppDevCardPlay(seat, a.card, a.resource); } if (this.gs.phase === 'moveRobber') { const m = AI.chooseRobberMove(this.gs, seat); @@ -1818,7 +1818,7 @@ export default class CatanGame extends Phaser.Scene { } // ── opponent dev card reveal ────────────────────────────────────────────────── - async animateOppDevCardPlay(seat, cardType) { + async animateOppDevCardPlay(seat, cardType, resource) { const VISUAL = { knight: { frame: 5, border: 0xb03030 }, roadBuilding: { frame: 6, border: 0x8b5a2b }, @@ -1853,6 +1853,33 @@ export default class CatanGame extends Phaser.Scene { this.tweens.add({ targets: card, x: toX, y: toY, scale: 1, duration: 500, ease: 'Back.easeOut', onComplete: resolve }) ); + let resourceText = null; + let fireworksEmitter = null; + if (cardType === 'monopoly' && resource) { + const textY = toY + 138; + resourceText = this.add.text(toX, textY, resource.toUpperCase(), { + fontFamily: 'Righteous', fontSize: '40px', + color: '#ffd700', stroke: '#000000', strokeThickness: 5, + }).setOrigin(0.5, 0.5).setDepth(D.banner + 6); + + fireworksEmitter = this.add.particles(toX, textY, 'catanParticle', { + speed: { min: 60, max: 190 }, lifespan: 950, + scale: { start: 1.3, end: 0 }, alpha: { start: 1, end: 0 }, + quantity: 12, frequency: -1, + tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0xff44aa, 0x88ff44], + angle: { min: 0, max: 360 }, gravityY: 50, + }).setDepth(D.banner + 5); + + const bursts = [ + { x: toX - 90, y: textY - 10 }, { x: toX + 90, y: textY - 10 }, + { x: toX, y: textY - 28 }, { x: toX - 50, y: textY + 22 }, + { x: toX + 50, y: textY + 22 }, + ]; + bursts.forEach((b, i) => + this.time.delayedCall(i * 160, () => fireworksEmitter?.emitParticleAt(b.x, b.y, 14)) + ); + } + const speechFile = SPEECH[cardType]; await new Promise(resolve => { if (!speechFile) { this.time.delayedCall(800, resolve); return; } @@ -1862,11 +1889,17 @@ export default class CatanGame extends Phaser.Scene { audio.play().catch(resolve); }); + const fadeTargets = resourceText ? [card, resourceText] : [card]; await new Promise(resolve => - this.tweens.add({ targets: card, alpha: 0, duration: 400, ease: 'Quad.In', onComplete: resolve }) + this.tweens.add({ targets: fadeTargets, alpha: 0, duration: 400, ease: 'Quad.In', onComplete: resolve }) ); card.destroy(); + if (resourceText) resourceText.destroy(); + if (fireworksEmitter) { + fireworksEmitter.stop(); + this.time.delayedCall(950, () => fireworksEmitter.destroy()); + } } // ── human: roll ─────────────────────────────────────────────────────────────── @@ -2249,31 +2282,122 @@ export default class CatanGame extends Phaser.Scene { this.clearHighlights(); const winner = this.gs.winner; const isHuman = winner === 0; - if (isHuman) { - const emitter = this.add.particles(1000, 470, 'catanParticle', { - speed: { min: 120, max: 420 }, lifespan: 1300, scale: { start: 1.2, end: 0 }, - alpha: { start: 1, end: 0 }, quantity: 4, frequency: 30, - tint: [0xffd700, 0xffffff, COLORS.accent], angle: { min: 0, max: 360 }, - }).setDepth(D.banner); - this.time.delayedCall(1800, () => emitter.destroy()); - } this.recordHistory(); - const overlay = this.add.rectangle(1000, 470, 760, 420, 0x0a0e14, 0.94).setStrokeStyle(3, COLORS.accent).setDepth(D.banner); + const PW = 760, PH = 660, PX = 1000, PY = 540; + const titleY = PY - PH / 2 + 70; // 280 + const RADIUS = 80; + const portraitY = titleY + 140; // 420 + const bodyY = portraitY + RADIUS + 95; // 595 + const buttonsY = PY + PH / 2 - 72; // 798 + + // Fireworks across the popup for all winners + const fwEmitter = this.add.particles(PX, PY, 'catanParticle', { + speed: { min: 80, max: 480 }, lifespan: 1400, + scale: { start: 1.2, end: 0 }, alpha: { start: 1, end: 0 }, + quantity: 3, frequency: 35, + tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0xff44aa, 0x88ff44], + angle: { min: 0, max: 360 }, + emitZone: { type: 'random', source: new Phaser.Geom.Rectangle(-PW / 2, -PH / 2, PW, PH) }, + }).setDepth(D.banner + 8); + this.time.delayedCall(3200, () => { + fwEmitter.stop(); + this.time.delayedCall(1400, () => fwEmitter.destroy()); + }); + + const overlay = this.add.rectangle(PX, PY, PW, PH, 0x0a0e14, 0.94) + .setStrokeStyle(3, COLORS.accent).setDepth(D.banner); + + const title = this.add.text(PX, titleY, isHuman ? 'Victory!' : `${this.pname(winner)} wins`, { + fontFamily: 'Righteous', fontSize: '44px', color: isHuman ? '#ffd700' : COLORS.textHex, + }).setOrigin(0.5).setDepth(D.banner + 1); + + // Portrait backing circle + const backingG = this.add.graphics().setDepth(D.banner + 1); + backingG.fillStyle(0x1a1a2e, 1); + backingG.fillCircle(PX, portraitY, RADIUS + 3); + backingG.fillStyle(COLORS.panel, 1); + backingG.fillCircle(PX, portraitY, RADIUS + 1); + + const size = RADIUS * 2; + let portraitDom = null; + let fallbackSprite = null; + let avatarActive = true; + + if (!isHuman) { + // AI winner: sprite fallback behind, happy video on top + const opp = this.opponents[winner - 1]; + if (opp?.id) { + if (this.textures.exists('opponents')) { + const maskG = this.make.graphics({ x: 0, y: 0, add: false }); + maskG.fillStyle(0xffffff); + maskG.fillCircle(PX, portraitY, RADIUS); + fallbackSprite = this.add.image(PX, portraitY, 'opponents', opp.spriteIndex ?? 0) + .setDisplaySize(size, size) + .setMask(maskG.createGeometryMask()) + .setDepth(D.banner + 2); + } + 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/${opp.id}-happy.mp4`; + videoEl.play().catch(() => {}); + videoEl.addEventListener('error', () => { videoEl.style.display = 'none'; }, { once: true }); + portraitDom = this.add.dom(PX, portraitY, videoEl).setDepth(D.banner + 3); + } + } else { + // Human winner: canvas initial placeholder, replaced by avatar if available + const canvasEl = document.createElement('canvas'); + canvasEl.width = size; canvasEl.height = size; + canvasEl.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;display:block;`; + const ctx = canvasEl.getContext('2d'); + const initial = (auth.user?.username ?? 'You').charAt(0).toUpperCase(); + ctx.fillStyle = '#1a1a2e'; + ctx.fillRect(0, 0, size, size); + ctx.fillStyle = COLORS.accentHex; + ctx.font = `bold ${Math.round(RADIUS * 0.9)}px "Julius Sans One", sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(initial, size / 2, size / 2); + portraitDom = this.add.dom(PX, portraitY, canvasEl).setDepth(D.banner + 3); + + (async () => { + try { + const { profile } = await api.get('/profile'); + if (!avatarActive || !profile?.avatarPath) return; + const imgEl = document.createElement('img'); + imgEl.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;`; + await new Promise((res, rej) => { imgEl.onload = res; imgEl.onerror = rej; imgEl.src = profile.avatarPath; }); + if (!avatarActive) return; + canvasEl.style.display = 'none'; + this.add.dom(PX, portraitY, imgEl).setDepth(D.banner + 3); + } catch { /* keep initial placeholder */ } + })(); + } + const lines = this.gs.players .map((p, i) => `${this.pname(i)}: ${L.victoryPoints(this.gs, i)} VP`) .join('\n'); - const title = this.add.text(1000, 330, isHuman ? 'Victory!' : `${this.pname(winner)} wins`, { - fontFamily: 'Righteous', fontSize: '44px', color: isHuman ? '#ffd700' : COLORS.textHex, - }).setOrigin(0.5).setDepth(D.banner + 1); - const body = this.add.text(1000, 460, lines, { + const body = this.add.text(PX, bodyY, lines, { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center', }).setOrigin(0.5).setDepth(D.banner + 1); - const playAgain = new Button(this, 900, 600, 'Play Again', () => { - overlay.destroy(); title.destroy(); body.destroy(); playAgain.destroy(); leave.destroy(); - this.startNewMatch(); + + const cleanup = () => { + avatarActive = false; + overlay.destroy(); title.destroy(); body.destroy(); backingG.destroy(); + if (fallbackSprite) fallbackSprite.destroy(); + if (portraitDom) portraitDom.destroy(); + playAgain.destroy(); leave.destroy(); + }; + const playAgain = new Button(this, PX - 110, buttonsY, 'Play Again', () => { + cleanup(); this.startNewMatch(); }, { width: 200, fontSize: 22 }).setDepth(D.banner + 1); - const leave = new Button(this, 1110, 600, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 200, fontSize: 22 }).setDepth(D.banner + 1); + const leave = new Button(this, PX + 110, buttonsY, 'Leave', () => { + cleanup(); this.scene.start('GameMenu'); + }, { variant: 'ghost', width: 200, fontSize: 22 }).setDepth(D.banner + 1); } async recordHistory() { diff --git a/public/src/games/catan/CatanLogic.js b/public/src/games/catan/CatanLogic.js index fe738bb..8125a96 100644 --- a/public/src/games/catan/CatanLogic.js +++ b/public/src/games/catan/CatanLogic.js @@ -497,7 +497,7 @@ export function endTurn(state) { } // ── longest road / largest army / victory ────────────────────────────────────── -function longestRoadFor(state, seat) { +export function longestRoadFor(state, seat) { const roads = state.players[seat].roads; if (roads.length === 0) return 0; const incident = new Map(); diff --git a/public/src/games/holdem/HoldemGame.js b/public/src/games/holdem/HoldemGame.js index ea881a2..df5caab 100644 --- a/public/src/games/holdem/HoldemGame.js +++ b/public/src/games/holdem/HoldemGame.js @@ -52,6 +52,12 @@ export default class HoldemGame extends Phaser.Scene { this.globalChips = 0; this.animating = false; + // Elimination tracking + this.eliminatedSeats = new Set(); + this.eliminatedBg = null; + this.eliminatedTitleTxt = null; + this.eliminatedPicList = []; // { backing, portraitObj, maskG } + // UI references this.portraits = []; // portrait handles per seat (for cleanup) this.foldedCardDisplays = {}; // seat → [container] kept on table after fold @@ -83,9 +89,11 @@ export default class HoldemGame extends Phaser.Scene { this.buildSeatContainers(); this.buildActionButtons(); this.buildLeaveButton(); + this.buildEliminatedPanel(); this.showBuyInModal(); this.events.once('shutdown', () => { for (const p of this.portraits) p.destroy(); + for (const e of this.eliminatedPicList) e.maskG?.destroy(); }); } @@ -496,6 +504,23 @@ export default class HoldemGame extends Phaser.Scene { } this.foldedCardDisplays = {}; this.gs = startHand(this.gs); + + const newlyEliminated = this.gs.players.filter( + p => p.eliminated && !this.eliminatedSeats.has(p.seat) + ); + + if (newlyEliminated.length > 0) { + this.animating = true; + this._animateEliminations(newlyEliminated, () => { + this.animating = false; + this._proceedWithHand(); + }); + } else { + this._proceedWithHand(); + } + } + + _proceedWithHand() { playSound(this, SFX.CARD_SHUFFLE); if (this.gs.phase === 'game_over') { @@ -524,6 +549,160 @@ export default class HoldemGame extends Phaser.Scene { }); } + // ── Eliminated panel ───────────────────────────────────────────────────────── + + // Panel constants (game coordinates). PANEL_W is wide enough for all 8 seats. + // Pics are placed right-to-left (rightmost = first eliminated). + // Anchored to the lower-right corner regardless of size. + static get ELIM() { + return { + RIGHT: GAME_WIDTH - 18, // 1902 — right anchor + BOTTOM: GAME_HEIGHT - 18, // 1062 — bottom anchor + PANEL_H: 120, + PANEL_W: 380, // fits title + up to 8 × 36 px pics + gaps + PAD_X: 16, + PAD_Y: 14, + PIC_R: 18, // portrait radius (36 px diameter ≈ title font height) + PIC_GAP: 8, + DEPTH: 31, // D.ui + 1 — above gameplay, below modals + }; + } + + buildEliminatedPanel() { + const { RIGHT, BOTTOM, PANEL_H, PANEL_W, PAD_X, PAD_Y, DEPTH } = HoldemGame.ELIM; + const panelCY = BOTTOM - PANEL_H / 2; + const panelCX = RIGHT - PANEL_W / 2; + + this.eliminatedBg = this.add.rectangle(panelCX, panelCY, PANEL_W, PANEL_H, 0x0a0a16, 0.85) + .setStrokeStyle(1, 0x8a7050) + .setDepth(DEPTH) + .setAlpha(0); + + this.eliminatedTitleTxt = this.add.text(RIGHT - PAD_X, BOTTOM - PANEL_H + PAD_Y, 'Eliminated', { + fontFamily: 'Righteous', + fontSize: '34px', + color: COLORS.mutedHex, + }).setOrigin(1, 0).setDepth(DEPTH + 1).setAlpha(0); + + this.eliminatedPicList = []; // { backing, portraitObj, maskG } + } + + addToEliminatedPanel(player) { + const { RIGHT, BOTTOM, PAD_X, PAD_Y, PIC_R, PIC_GAP, DEPTH } = HoldemGame.ELIM; + + const n = this.eliminatedPicList.length; // pics already in panel + const picX = RIGHT - PAD_X - PIC_R - n * (PIC_R * 2 + PIC_GAP); + const picY = BOTTOM - PAD_Y - PIC_R; + + // Fade in panel on first elimination + if (n === 0) { + this.tweens.add({ targets: [this.eliminatedBg, this.eliminatedTitleTxt], alpha: 1, duration: 300 }); + } + + // Backing circle + const backing = this.add.circle(picX, picY, PIC_R, 0x1a1a2e).setDepth(DEPTH + 1).setAlpha(0); + + let portraitObj = null; + let maskG = null; + + if (!player.isHuman) { + const opp = this.opponents[player.seat - 1]; + if (opp && this.textures.exists('opponents')) { + maskG = this.make.graphics({ x: 0, y: 0, add: false }); + maskG.fillStyle(0xffffff); + maskG.fillCircle(picX, picY, PIC_R); + portraitObj = this.add.image(picX, picY, 'opponents', opp.spriteIndex ?? 0) + .setDisplaySize(PIC_R * 2, PIC_R * 2) + .setMask(maskG.createGeometryMask()) + .setDepth(DEPTH + 2) + .setAlpha(0); + } + } else { + // Try a loaded avatar texture + const avatarKey = Object.keys(this.textures.list).find(k => k.startsWith('player-avatar-')); + if (avatarKey) { + maskG = this.make.graphics({ x: 0, y: 0, add: false }); + maskG.fillStyle(0xffffff); + maskG.fillCircle(picX, picY, PIC_R); + portraitObj = this.add.image(picX, picY, avatarKey) + .setDisplaySize(PIC_R * 2, PIC_R * 2) + .setMask(maskG.createGeometryMask()) + .setDepth(DEPTH + 2) + .setAlpha(0); + } else { + const initial = (auth.user?.username ?? 'You').charAt(0).toUpperCase(); + portraitObj = this.add.text(picX, picY, initial, { + fontFamily: '"Julius Sans One"', + fontSize: `${PIC_R + 2}px`, + color: COLORS.accentHex, + }).setOrigin(0.5).setDepth(DEPTH + 2).setAlpha(0); + } + } + + const fadeIn = [backing]; + if (portraitObj) fadeIn.push(portraitObj); + this.tweens.add({ targets: fadeIn, alpha: 1, duration: 300 }); + + this.eliminatedPicList.push({ backing, portraitObj, maskG }); + } + + _animateEliminations(newlyEliminated, callback) { + const { RIGHT, BOTTOM, PAD_X, PAD_Y, PIC_R } = HoldemGame.ELIM; + const targetX = RIGHT - PAD_X - PIC_R; // world x of first panel pic + const targetY = BOTTOM - PAD_Y - PIC_R; // world y of pic row + + let pending = newlyEliminated.length; + const done = () => { if (--pending === 0) callback(); }; + + for (const player of newlyEliminated) { + const seat = player.seat; + const sp = this.seatPos[seat]; + const px = sp.portraitX ?? sp.x; + const py = sp.portraitY ?? sp.y; + const pr = sp.portraitR; + + this.portraits[seat]?.stopVideo(); + this.portraits[seat]?.fadeToEliminated(700); + + // Build an animation clone: backing circle + face + const animContainer = this.add.container(px, py).setDepth(D.modal); + const bg = this.add.circle(0, 0, pr, 0x1a1a2e); + animContainer.add(bg); + + if (!player.isHuman) { + const opp = this.opponents[seat - 1]; + if (opp && this.textures.exists('opponents')) { + const face = this.add.image(0, 0, 'opponents', opp.spriteIndex ?? 0) + .setDisplaySize(pr * 2, pr * 2); + animContainer.add(face); + } + } else { + const initial = (auth.user?.username ?? 'You').charAt(0).toUpperCase(); + const lbl = this.add.text(0, 0, initial, { + fontFamily: '"Julius Sans One"', + fontSize: `${Math.round(pr * 0.9)}px`, + color: COLORS.accentHex, + }).setOrigin(0.5); + animContainer.add(lbl); + } + + this.tweens.add({ + targets: animContainer, + x: targetX, y: targetY, + scaleX: PIC_R / pr, scaleY: PIC_R / pr, + alpha: 0, + duration: 750, + ease: 'Power2.easeIn', + onComplete: () => { + this.addToEliminatedPanel(player); + this.eliminatedSeats.add(seat); + animContainer.destroy(); + done(); + }, + }); + } + } + scheduleNextAction() { if (!this.gs || this.gs.phase === 'game_over' || this.gs.phase === 'between_hands') return; diff --git a/public/src/ui/Portrait.js b/public/src/ui/Portrait.js index fa8c1b4..785d33e 100644 --- a/public/src/ui/Portrait.js +++ b/public/src/ui/Portrait.js @@ -192,6 +192,21 @@ export function createOpponentPortrait(scene, opponent, worldX, worldY, radius, 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 }); + } + function destroy() { videoEl.pause(); videoEl.src = ''; @@ -213,7 +228,7 @@ export function createOpponentPortrait(scene, opponent, worldX, worldY, radius, }); } - return { playEmotion, hide, show, destroy }; + return { playEmotion, hide, show, stopVideo, fadeToEliminated, destroy }; } // ── Player portrait (profile avatar with letter fallback) ───────────────────── @@ -265,5 +280,12 @@ export function createPlayerPortrait(scene, worldX, worldY, radius, depth, scene function hide() { for (const o of allObjs) o.setVisible?.(false); } function show() { for (const o of allObjs) o.setVisible?.(true); } - return { hide, show, destroy() {} }; + 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 }); + } + + return { hide, show, stopVideo, fadeToEliminated, destroy() {} }; }