feat(catan): enhance AI expansion logic and polish game visuals

- Improve Catan AI to evaluate 1-hop lookahead nodes and prioritize bridge connections for better road expansion.
- Add visual feedback for opponent Monopoly plays (resource text + fireworks).
- Redesign game-over screen with dynamic winner portraits (video for AI, avatar fallback for human) and fireworks.
- Add eliminated player panel in Hold'em with animated portrait transitions.
- Export `longestRoadFor` for use in AI logic.
This commit is contained in:
Brian Fertig 2026-05-25 09:46:27 -06:00
parent 0b2657b954
commit 6508a80c94
5 changed files with 403 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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

View File

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