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:
parent
0b2657b954
commit
6508a80c94
|
|
@ -6,7 +6,7 @@ import { NODES, EDGES, HEXES, pipCount, COSTS, RESOURCE_TYPES } from './CatanBoa
|
||||||
import {
|
import {
|
||||||
legalSettlementNodes, legalRoadEdges, canAfford, bestTradeRatio,
|
legalSettlementNodes, legalRoadEdges, canAfford, bestTradeRatio,
|
||||||
handSize, nodeBuilding, stealTargets, publicVictoryPoints,
|
handSize, nodeBuilding, stealTargets, publicVictoryPoints,
|
||||||
victoryPoints, WIN_VP,
|
victoryPoints, WIN_VP, longestRoadFor,
|
||||||
} from './CatanLogic.js';
|
} from './CatanLogic.js';
|
||||||
|
|
||||||
// Value of a vertex = production potential of its adjacent hexes + diversity.
|
// 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] };
|
return { type: 'buildSettlement', nodeId: settleSpots[0] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Build a road only when no settlement spot is already reachable.
|
// 4. Build a road when no settlement spot is reachable, or when the best
|
||||||
// If a spot exists, hold resources and save up for the settlement instead.
|
// reachable spot is low-value and a better target is accessible further out.
|
||||||
if (canAfford(p, COSTS.road) && settleSpots.length === 0) {
|
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);
|
const road = chooseExpansionRoad(state, seat);
|
||||||
if (road != null) return { type: 'buildRoad', edgeId: road };
|
if (road != null) return { type: 'buildRoad', edgeId: road };
|
||||||
}
|
}
|
||||||
|
|
@ -208,23 +210,66 @@ function canReachNewSpot(state, seat) {
|
||||||
function chooseExpansionRoad(state, seat) {
|
function chooseExpansionRoad(state, seat) {
|
||||||
const edges = legalRoadEdges(state, seat, false);
|
const edges = legalRoadEdges(state, seat, false);
|
||||||
if (!edges.length) return null;
|
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;
|
let best = null, bestScore = -Infinity;
|
||||||
for (const eid of edges) {
|
for (const eid of edges) {
|
||||||
const [a, b] = EDGES[eid].nodes;
|
const [a, b] = EDGES[eid].nodes;
|
||||||
let score = 0;
|
let score = 0;
|
||||||
|
|
||||||
for (const node of [a, b]) {
|
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))) {
|
if (!nodeBuilding(state, node) && !NODES[node].adj.some((x) => nodeBuilding(state, x))) {
|
||||||
score += nodeValue(state, node);
|
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; }
|
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) {
|
function chooseHelpfulDev(state, seat, citySettlements, settleSpots) {
|
||||||
|
|
|
||||||
|
|
@ -1652,7 +1652,7 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
this.gs = this.applyAction(seat, a);
|
this.gs = this.applyAction(seat, a);
|
||||||
if (a.type === 'playDev' && a.card !== 'vp') {
|
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') {
|
if (this.gs.phase === 'moveRobber') {
|
||||||
const m = AI.chooseRobberMove(this.gs, seat);
|
const m = AI.chooseRobberMove(this.gs, seat);
|
||||||
|
|
@ -1818,7 +1818,7 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── opponent dev card reveal ──────────────────────────────────────────────────
|
// ── opponent dev card reveal ──────────────────────────────────────────────────
|
||||||
async animateOppDevCardPlay(seat, cardType) {
|
async animateOppDevCardPlay(seat, cardType, resource) {
|
||||||
const VISUAL = {
|
const VISUAL = {
|
||||||
knight: { frame: 5, border: 0xb03030 },
|
knight: { frame: 5, border: 0xb03030 },
|
||||||
roadBuilding: { frame: 6, border: 0x8b5a2b },
|
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 })
|
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];
|
const speechFile = SPEECH[cardType];
|
||||||
await new Promise(resolve => {
|
await new Promise(resolve => {
|
||||||
if (!speechFile) { this.time.delayedCall(800, resolve); return; }
|
if (!speechFile) { this.time.delayedCall(800, resolve); return; }
|
||||||
|
|
@ -1862,11 +1889,17 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
audio.play().catch(resolve);
|
audio.play().catch(resolve);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fadeTargets = resourceText ? [card, resourceText] : [card];
|
||||||
await new Promise(resolve =>
|
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();
|
card.destroy();
|
||||||
|
if (resourceText) resourceText.destroy();
|
||||||
|
if (fireworksEmitter) {
|
||||||
|
fireworksEmitter.stop();
|
||||||
|
this.time.delayedCall(950, () => fireworksEmitter.destroy());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── human: roll ───────────────────────────────────────────────────────────────
|
// ── human: roll ───────────────────────────────────────────────────────────────
|
||||||
|
|
@ -2249,31 +2282,122 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
this.clearHighlights();
|
this.clearHighlights();
|
||||||
const winner = this.gs.winner;
|
const winner = this.gs.winner;
|
||||||
const isHuman = winner === 0;
|
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();
|
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
|
const lines = this.gs.players
|
||||||
.map((p, i) => `${this.pname(i)}: ${L.victoryPoints(this.gs, i)} VP`)
|
.map((p, i) => `${this.pname(i)}: ${L.victoryPoints(this.gs, i)} VP`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
const title = this.add.text(1000, 330, isHuman ? 'Victory!' : `${this.pname(winner)} wins`, {
|
const body = this.add.text(PX, bodyY, lines, {
|
||||||
fontFamily: 'Righteous', fontSize: '44px', color: isHuman ? '#ffd700' : COLORS.textHex,
|
|
||||||
}).setOrigin(0.5).setDepth(D.banner + 1);
|
|
||||||
const body = this.add.text(1000, 460, lines, {
|
|
||||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center',
|
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center',
|
||||||
}).setOrigin(0.5).setDepth(D.banner + 1);
|
}).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();
|
const cleanup = () => {
|
||||||
this.startNewMatch();
|
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);
|
}, { 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() {
|
async recordHistory() {
|
||||||
|
|
|
||||||
|
|
@ -497,7 +497,7 @@ export function endTurn(state) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── longest road / largest army / victory ──────────────────────────────────────
|
// ── longest road / largest army / victory ──────────────────────────────────────
|
||||||
function longestRoadFor(state, seat) {
|
export function longestRoadFor(state, seat) {
|
||||||
const roads = state.players[seat].roads;
|
const roads = state.players[seat].roads;
|
||||||
if (roads.length === 0) return 0;
|
if (roads.length === 0) return 0;
|
||||||
const incident = new Map();
|
const incident = new Map();
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,12 @@ export default class HoldemGame extends Phaser.Scene {
|
||||||
this.globalChips = 0;
|
this.globalChips = 0;
|
||||||
this.animating = false;
|
this.animating = false;
|
||||||
|
|
||||||
|
// Elimination tracking
|
||||||
|
this.eliminatedSeats = new Set();
|
||||||
|
this.eliminatedBg = null;
|
||||||
|
this.eliminatedTitleTxt = null;
|
||||||
|
this.eliminatedPicList = []; // { backing, portraitObj, maskG }
|
||||||
|
|
||||||
// UI references
|
// UI references
|
||||||
this.portraits = []; // portrait handles per seat (for cleanup)
|
this.portraits = []; // portrait handles per seat (for cleanup)
|
||||||
this.foldedCardDisplays = {}; // seat → [container] kept on table after fold
|
this.foldedCardDisplays = {}; // seat → [container] kept on table after fold
|
||||||
|
|
@ -83,9 +89,11 @@ export default class HoldemGame extends Phaser.Scene {
|
||||||
this.buildSeatContainers();
|
this.buildSeatContainers();
|
||||||
this.buildActionButtons();
|
this.buildActionButtons();
|
||||||
this.buildLeaveButton();
|
this.buildLeaveButton();
|
||||||
|
this.buildEliminatedPanel();
|
||||||
this.showBuyInModal();
|
this.showBuyInModal();
|
||||||
this.events.once('shutdown', () => {
|
this.events.once('shutdown', () => {
|
||||||
for (const p of this.portraits) p.destroy();
|
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.foldedCardDisplays = {};
|
||||||
this.gs = startHand(this.gs);
|
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);
|
playSound(this, SFX.CARD_SHUFFLE);
|
||||||
|
|
||||||
if (this.gs.phase === 'game_over') {
|
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() {
|
scheduleNextAction() {
|
||||||
if (!this.gs || this.gs.phase === 'game_over' || this.gs.phase === 'between_hands') return;
|
if (!this.gs || this.gs.phase === 'game_over' || this.gs.phase === 'between_hands') return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,21 @@ export function createOpponentPortrait(scene, opponent, worldX, worldY, radius,
|
||||||
if (!videoError) domEl.setVisible(true);
|
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() {
|
function destroy() {
|
||||||
videoEl.pause();
|
videoEl.pause();
|
||||||
videoEl.src = '';
|
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) ─────────────────────
|
// ── 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 hide() { for (const o of allObjs) o.setVisible?.(false); }
|
||||||
function show() { for (const o of allObjs) o.setVisible?.(true); }
|
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() {} };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue