1389 lines
51 KiB
JavaScript
1389 lines
51 KiB
JavaScript
import * as Phaser from 'phaser';
|
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
|
import { Button } from '../../ui/Button.js';
|
|
import { Modal } from '../../ui/Modal.js';
|
|
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
|
import { auth } from '../../services/auth.js';
|
|
import { playSound, SFX } from '../../ui/Sounds.js';
|
|
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
|
import {
|
|
createInitialState,
|
|
applyAsk,
|
|
applyFishPick,
|
|
legalRanksToAsk,
|
|
canAsk,
|
|
isGameOver,
|
|
} from './GoFishLogic.js';
|
|
import { createMemory, observeLog, chooseAction } from './GoFishAI.js';
|
|
|
|
// ── Layout constants ────────────────────────────────────────────────────────
|
|
const CX = GAME_WIDTH / 2;
|
|
const CY = GAME_HEIGHT / 2;
|
|
|
|
const CARD_W = 90;
|
|
const CARD_H = 126;
|
|
const CARD_R = 8;
|
|
const HAND_SPREAD = 70;
|
|
|
|
const D = {
|
|
felt: -1, board: 0, pool: 5, card: 10, highlight: 20,
|
|
ui: 30, portrait: 35, chip: 40, banner: 60, modal: 80,
|
|
};
|
|
|
|
const SLOTS_USED = {
|
|
2: ['bottom', 'top'],
|
|
3: ['bottom', 'left', 'right'],
|
|
4: ['bottom', 'left', 'top', 'right'],
|
|
};
|
|
|
|
function slotLayout(slot) {
|
|
switch (slot) {
|
|
case 'bottom': {
|
|
// chip bottom edge = bchipY + 22; portrait bottom aligns with chip bottom
|
|
// portrait left of chip with 12px gap
|
|
const bchipY = GAME_HEIGHT - 100 - CARD_H / 2 - 30;
|
|
const bpr = 56, bpx = CX - 90 - 12 - bpr, bpy = bchipY + 22 - bpr;
|
|
return {
|
|
handCenter: { x: CX, y: GAME_HEIGHT - 100 },
|
|
handAxis: 'x',
|
|
handFaceUp: true,
|
|
portrait: { x: bpx, y: bpy, r: bpr },
|
|
nameLabel: { x: bpx, y: bpy - bpr - 14 },
|
|
chip: { x: CX, y: bchipY },
|
|
chipRotation: 0,
|
|
rotateCards: 0,
|
|
};
|
|
}
|
|
case 'top': {
|
|
// chip top edge = tchipY - 22; portrait top aligns with chip top
|
|
// portrait left of chip with 12px gap
|
|
const tchipY = 110 + CARD_H / 2 + 30;
|
|
const tpr = 50, tpx = CX - 90 - 12 - tpr, tpy = tchipY - 22 + tpr;
|
|
return {
|
|
handCenter: { x: CX, y: 110 },
|
|
handAxis: 'x',
|
|
handFaceUp: false,
|
|
portrait: { x: tpx, y: tpy, r: tpr },
|
|
nameLabel: { x: tpx, y: tpy + tpr + 14 },
|
|
chip: { x: CX, y: tchipY },
|
|
chipRotation: 0,
|
|
rotateCards: 180,
|
|
};
|
|
}
|
|
case 'left': {
|
|
// chip at (205, CY) rotated — extends ±90 in y, ±22 in x
|
|
// portrait left edge aligned with chip left edge (chipX - 22)
|
|
const lchipX = 110 + CARD_H / 2 + 10 + 22;
|
|
const lpr = 50, lpx = lchipX - 22 + lpr, lpy = CY - 90 - 12 - lpr;
|
|
return {
|
|
handCenter: { x: 110, y: CY },
|
|
handAxis: 'y',
|
|
handFaceUp: false,
|
|
portrait: { x: lpx, y: lpy, r: lpr },
|
|
nameLabel: { x: lpx, y: lpy - lpr - 14 },
|
|
chip: { x: lchipX, y: CY },
|
|
chipRotation: Math.PI / 2,
|
|
rotateCards: 90,
|
|
};
|
|
}
|
|
case 'right': {
|
|
// chip at (1715, CY) rotated — extends ±90 in y, ±22 in x
|
|
// portrait right edge aligned with chip right edge (chipX + 22)
|
|
const rchipX = GAME_WIDTH - 110 - CARD_H / 2 - 10 - 22;
|
|
const rpr = 50, rpx = rchipX + 22 - rpr, rpy = CY - 90 - 12 - rpr;
|
|
return {
|
|
handCenter: { x: GAME_WIDTH - 110, y: CY },
|
|
handAxis: 'y',
|
|
handFaceUp: false,
|
|
portrait: { x: rpx, y: rpy, r: rpr },
|
|
nameLabel: { x: rpx, y: rpy - rpr - 14 },
|
|
chip: { x: rchipX, y: CY },
|
|
chipRotation: -Math.PI / 2,
|
|
rotateCards: 270,
|
|
};
|
|
}
|
|
default:
|
|
throw new Error(`Unknown slot: ${slot}`);
|
|
}
|
|
}
|
|
|
|
const POOL_POS = { x: CX, y: CY };
|
|
|
|
const SUIT_COLORS = {
|
|
s: { fill: 0xf2ead8, stroke: 0x1a1208, glyph: '#1a1208' },
|
|
c: { fill: 0xf2ead8, stroke: 0x1a1208, glyph: '#1a1208' },
|
|
h: { fill: 0xfbe7e2, stroke: 0xc92a2a, glyph: '#c92a2a' },
|
|
d: { fill: 0xfbe7e2, stroke: 0xc92a2a, glyph: '#c92a2a' },
|
|
};
|
|
|
|
const GOFISH_CARD_FRAME = { A: 0, '2': 1, '3': 2, '4': 3, '5': 4, '6': 5, '7': 6, '8': 7, '9': 8, T: 9, J: 10, Q: 11, K: 12 };
|
|
|
|
// ── Scene ───────────────────────────────────────────────────────────────────
|
|
export default class GoFishGame extends Phaser.Scene {
|
|
constructor() { super('GoFishGame'); }
|
|
|
|
init(data) {
|
|
this.gameDef = data.game;
|
|
this.opponents = data.opponents ?? [];
|
|
this.playfield = data.playfield ?? null;
|
|
this.cardBack = data.cardBack ?? null;
|
|
this.matchVariant = data.matchVariant ?? 4;
|
|
|
|
this.gs = null;
|
|
this.animating = false;
|
|
this.gameOver = false;
|
|
|
|
this.cardObjs = new Map();
|
|
this.transientObjs = [];
|
|
this.opponentPortraits = [];
|
|
this.seatChips = [];
|
|
this.slotForSeat = [];
|
|
this.aiMemory = [];
|
|
this.selectedRank = null;
|
|
this.bannerText = null;
|
|
this.poolCardPositions = new Map();
|
|
}
|
|
|
|
create() {
|
|
new MusicPlayer(this, this.cache.json.get('music').tracks);
|
|
this.buildPlayfield();
|
|
this.assignSeats();
|
|
this.buildSeatAreas();
|
|
this.buildCenter();
|
|
this.buildHUD();
|
|
this.buildMatchesPanel();
|
|
this.startNewMatch();
|
|
}
|
|
|
|
buildPlayfield() {
|
|
const pf = this.playfield;
|
|
if (pf?.key && this.textures.exists(pf.key)) {
|
|
this.add.image(CX, CY, pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.felt);
|
|
} else {
|
|
const color = pf?.fallbackColor
|
|
? parseInt(pf.fallbackColor.replace('#', ''), 16) : 0x14532d;
|
|
this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, color).setDepth(D.felt);
|
|
}
|
|
}
|
|
|
|
assignSeats() {
|
|
const playerCount = 1 + this.opponents.length;
|
|
const slots = SLOTS_USED[playerCount];
|
|
if (!slots) throw new Error(`Go Fish needs 2..4 players, got ${playerCount}`);
|
|
this.slotForSeat = slots.slice();
|
|
}
|
|
|
|
buildSeatAreas() {
|
|
for (let seat = 0; seat < this.slotForSeat.length; seat++) {
|
|
const slot = this.slotForSeat[seat];
|
|
const layout = slotLayout(slot);
|
|
|
|
if (seat === 0) {
|
|
createPlayerPortrait(this, layout.portrait.x, layout.portrait.y, layout.portrait.r, D.portrait, 'GoFishGame');
|
|
this.add.text(layout.nameLabel.x, layout.nameLabel.y, auth.user?.username ?? 'You', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
|
|
}).setOrigin(0.5).setDepth(D.ui);
|
|
} else {
|
|
const opp = this.opponents[seat - 1];
|
|
if (opp) {
|
|
this.opponentPortraits[seat] = createOpponentPortrait(this, opp, layout.portrait.x, layout.portrait.y, layout.portrait.r, D.portrait);
|
|
this.add.text(layout.nameLabel.x, layout.nameLabel.y, opp.name ?? `P${seat + 1}`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
|
|
}).setOrigin(0.5).setDepth(D.ui);
|
|
}
|
|
}
|
|
|
|
// Pair counter chip.
|
|
this.seatChips[seat] = this.makeSeatChip(layout.chip.x, layout.chip.y);
|
|
this.seatChips[seat].container.setRotation(layout.chipRotation);
|
|
}
|
|
|
|
// Make opponent portraits clickable as ask targets (seats 1..N).
|
|
for (let seat = 1; seat < this.slotForSeat.length; seat++) {
|
|
const layout = slotLayout(this.slotForSeat[seat]);
|
|
const hot = this.add.circle(layout.portrait.x, layout.portrait.y, layout.portrait.r + 8, 0xffffff, 0)
|
|
.setDepth(D.portrait + 5)
|
|
.setInteractive({ useHandCursor: true });
|
|
hot.on('pointerdown', () => this.onOpponentClick(seat));
|
|
hot.on('pointerover', () => this.highlightOpponent(seat, true));
|
|
hot.on('pointerout', () => this.highlightOpponent(seat, false));
|
|
this.transientObjs.push(hot);
|
|
}
|
|
}
|
|
|
|
makeSeatChip(x, y) {
|
|
const container = this.add.container(x, y).setDepth(D.chip);
|
|
const bg = this.add.graphics();
|
|
bg.fillStyle(COLORS.panel, 0.92);
|
|
bg.fillRoundedRect(-90, -22, 180, 44, 10);
|
|
bg.lineStyle(2, COLORS.accent, 1);
|
|
bg.strokeRoundedRect(-90, -22, 180, 44, 10);
|
|
const chipLabel = this.matchVariant === 4 ? 'BOOKS' : 'PAIRS';
|
|
const label = this.add.text(-78, 0, chipLabel, {
|
|
fontFamily: 'Righteous', fontSize: '16px', color: COLORS.goldHex,
|
|
}).setOrigin(0, 0.5);
|
|
const count = this.add.text(78, 0, '0', {
|
|
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.accentHex,
|
|
}).setOrigin(1, 0.5);
|
|
container.add([bg, label, count]);
|
|
return { container, count };
|
|
}
|
|
|
|
buildCenter() {
|
|
// Last-action banner.
|
|
this.bannerBg = this.add.graphics().setDepth(D.banner - 1).setVisible(false);
|
|
this.bannerText = this.add.text(CX, CY + CARD_H + 80, '', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex, align: 'center',
|
|
wordWrap: { width: 900 },
|
|
}).setOrigin(0.5).setDepth(D.banner);
|
|
}
|
|
|
|
getCentralBounds() {
|
|
const PAD = 20;
|
|
let left = PAD, right = GAME_WIDTH - PAD, top = PAD, bottom = GAME_HEIGHT - PAD;
|
|
for (const slot of this.slotForSeat) {
|
|
if (slot === 'bottom') bottom = Math.min(bottom, (GAME_HEIGHT - 100) - CARD_H / 2 - PAD);
|
|
if (slot === 'top') top = Math.max(top, 110 + CARD_H / 2 + PAD);
|
|
if (slot === 'left') left = Math.max(left, 110 + CARD_H / 2 + PAD);
|
|
if (slot === 'right') right = Math.min(right, (GAME_WIDTH - 110) - CARD_H / 2 - PAD);
|
|
}
|
|
return { left, right, top, bottom };
|
|
}
|
|
|
|
scatterPool(pool) {
|
|
this.poolCardPositions.clear();
|
|
const { left, right, top, bottom } = this.getCentralBounds();
|
|
const xMin = left + CARD_W / 2, xMax = right - CARD_W / 2;
|
|
const yMin = top + CARD_H / 2, yMax = bottom - CARD_H / 2;
|
|
for (const card of pool) {
|
|
this.poolCardPositions.set(card.id, {
|
|
x: xMin + Math.random() * (xMax - xMin),
|
|
y: yMin + Math.random() * (yMax - yMin),
|
|
rot: Math.random() * 60 - 30,
|
|
});
|
|
}
|
|
}
|
|
|
|
buildHUD() {
|
|
// Sit just right of bottom portrait (right edge = CX - 90) and just above chip top (bchipY - 22).
|
|
const bchipY = GAME_HEIGHT - 100 - CARD_H / 2 - 30;
|
|
this.statusBg = this.add.graphics().setDepth(D.ui - 1);
|
|
this.statusText = this.add.text(CX - 90, bchipY - 22 - 14, '', {
|
|
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, align: 'left',
|
|
}).setOrigin(0, 1).setDepth(D.ui);
|
|
|
|
new Button(this, 80, GAME_HEIGHT - 30, 'Leave', () => this.scene.start('GameMenu'), {
|
|
variant: 'ghost', width: 120, height: 40, fontSize: 18,
|
|
}).setDepth(D.ui);
|
|
new Button(this, 80, GAME_HEIGHT - 75, 'New', () => this.startNewMatch(), {
|
|
variant: 'ghost', width: 120, height: 40, fontSize: 18,
|
|
}).setDepth(D.ui);
|
|
}
|
|
|
|
// ── Matches panel ─────────────────────────────────────────────────────────
|
|
|
|
buildMatchesPanel() {
|
|
const RANK_ORDER = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2'];
|
|
const maxPerRank = this.matchVariant === 4 ? 1 : 2;
|
|
const totalPossible = 13 * maxPerRank;
|
|
const panelTitle = this.matchVariant === 4 ? 'Books so far' : 'Matches so far';
|
|
const remainingPrefix = this.matchVariant === 4 ? 'Remaining Books: ' : 'Remaining Matches: ';
|
|
this._matchMaxPerRank = maxPerRank;
|
|
this._matchTotalPossible = totalPossible;
|
|
this._matchRemainingPrefix = remainingPrefix;
|
|
|
|
const px = 10, py = 10;
|
|
const padX = 9, padY = 8;
|
|
const titleH = 18;
|
|
const rowH = 16;
|
|
const panelW = 188;
|
|
const panelH = padY + titleH + 5 + RANK_ORDER.length * rowH + 5 + rowH + padY;
|
|
|
|
const bg = this.add.graphics().setDepth(D.ui - 0.5);
|
|
bg.fillStyle(0x000000, 0.70);
|
|
bg.fillRoundedRect(px, py, panelW, panelH, 7);
|
|
bg.lineStyle(1, COLORS.accent, 0.35);
|
|
bg.strokeRoundedRect(px, py, panelW, panelH, 7);
|
|
|
|
this.add.text(px + padX, py + padY, panelTitle, {
|
|
fontFamily: 'Righteous', fontSize: '13px', color: COLORS.goldHex,
|
|
}).setDepth(D.ui);
|
|
|
|
this._matchPanelRows = RANK_ORDER.map((rank, i) => {
|
|
const label = rank === 'T' ? '10' : rank;
|
|
const t = this.add.text(
|
|
px + padX,
|
|
py + padY + titleH + 5 + i * rowH,
|
|
`${label.padEnd(2)} = 0 / ${maxPerRank}`,
|
|
{ fontFamily: 'Righteous', fontSize: '12px', color: COLORS.textHex }
|
|
).setDepth(D.ui);
|
|
return { rank, text: t };
|
|
});
|
|
|
|
this._matchRemainingText = this.add.text(
|
|
px + padX,
|
|
py + padY + titleH + 5 + RANK_ORDER.length * rowH + 5,
|
|
`${remainingPrefix}${totalPossible}`,
|
|
{ fontFamily: 'Righteous', fontSize: '12px', color: COLORS.goldHex }
|
|
).setDepth(D.ui);
|
|
}
|
|
|
|
updateMatchesPanel() {
|
|
if (!this.gs || !this._matchPanelRows) return;
|
|
const tally = new Map(this._matchPanelRows.map(({ rank }) => [rank, 0]));
|
|
for (const player of this.gs.players) {
|
|
for (const rank of player.pairedRanks) {
|
|
tally.set(rank, (tally.get(rank) ?? 0) + 1);
|
|
}
|
|
}
|
|
let totalScored = 0;
|
|
for (const { rank, text } of this._matchPanelRows) {
|
|
const scored = tally.get(rank) ?? 0;
|
|
totalScored += scored;
|
|
const label = rank === 'T' ? '10' : rank;
|
|
text.setText(`${label.padEnd(2)} = ${scored} / ${this._matchMaxPerRank}`);
|
|
text.setColor(scored === this._matchMaxPerRank ? COLORS.mutedHex : COLORS.textHex);
|
|
}
|
|
this._matchRemainingText.setText(`${this._matchRemainingPrefix}${this._matchTotalPossible - totalScored}`);
|
|
}
|
|
|
|
// ── Match lifecycle ────────────────────────────────────────────────────────
|
|
|
|
startNewMatch() {
|
|
if (this.animating) return;
|
|
this.gameOver = false;
|
|
this.selectedRank = null;
|
|
this.clearAllCardObjs();
|
|
this.hideBanner();
|
|
|
|
const playerCount = this.slotForSeat.length;
|
|
const finalState = createInitialState({ playerCount, matchSize: this.matchVariant });
|
|
this.aiMemory = [];
|
|
this.aiTurnExclusions = new Map();
|
|
for (let s = 0; s < playerCount; s++) {
|
|
this.aiMemory[s] = createMemory(playerCount);
|
|
observeLog(this.aiMemory[s], finalState, s);
|
|
}
|
|
|
|
playSound(this, SFX.CARD_SHUFFLE);
|
|
this.animating = true;
|
|
|
|
// Show the deck as the deal source and reset chip display.
|
|
const deck = this.makeCardSprite(
|
|
{ label: '', suit: 's', suitSymbol: '' }, POOL_POS.x, POOL_POS.y, { faceUp: false }
|
|
);
|
|
this.transientObjs.push(deck);
|
|
for (const chip of this.seatChips) { if (chip) chip.count.setText('0'); }
|
|
|
|
// Build round-robin deal sequence.
|
|
const hands = finalState.players.map(p => [...p.hand]);
|
|
const maxCards = Math.max(...hands.map(h => h.length));
|
|
const sequence = [];
|
|
for (let round = 0; round < maxCards; round++) {
|
|
for (let seat = 0; seat < playerCount; seat++) {
|
|
if (round < hands[seat].length) {
|
|
sequence.push({ seat, card: hands[seat][round], handIndex: round });
|
|
}
|
|
}
|
|
}
|
|
|
|
const STAGGER = 90; // ms between each card launch
|
|
const DURATION = 200; // ms for the card to fly
|
|
|
|
sequence.forEach(({ seat, card, handIndex }, idx) => {
|
|
this.time.delayedCall(idx * STAGGER, () => {
|
|
const layout = slotLayout(this.slotForSeat[seat]);
|
|
const n = hands[seat].length;
|
|
const offset = handIndex - (n - 1) / 2;
|
|
const tx = layout.handAxis === 'x'
|
|
? layout.handCenter.x + offset * HAND_SPREAD
|
|
: layout.handCenter.x;
|
|
const ty = layout.handAxis === 'x'
|
|
? layout.handCenter.y
|
|
: layout.handCenter.y + offset * HAND_SPREAD;
|
|
|
|
const sprite = this.makeCardSprite(card, POOL_POS.x, POOL_POS.y, {
|
|
faceUp: false, rotation: layout.rotateCards,
|
|
});
|
|
sprite.setDepth(D.card + 5);
|
|
this.transientObjs.push(sprite);
|
|
|
|
if (idx % 4 === 0) playSound(this, SFX.CARD_DEAL);
|
|
|
|
this.tweens.add({
|
|
targets: sprite,
|
|
x: tx, y: ty,
|
|
duration: DURATION,
|
|
ease: 'Cubic.easeOut',
|
|
onComplete: () => {
|
|
if (!sprite.active) return;
|
|
if (layout.handFaceUp) this.renderCardFace(sprite, card, true);
|
|
sprite.setDepth(D.card);
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
// Once the last card has landed, switch to live game state.
|
|
const doneAt = (sequence.length - 1) * STAGGER + DURATION + 250;
|
|
this.time.delayedCall(doneAt, () => {
|
|
this.scatterPool(finalState.pool);
|
|
this.gs = finalState;
|
|
this.renderAll();
|
|
this.updateStatus();
|
|
const initialPairs = (finalState.initialDealPairs ?? []).filter((e) => e.pairedCards.length >= 2);
|
|
if (initialPairs.length > 0) {
|
|
this.playInitialDealPairs(initialPairs, 0, () => {
|
|
this.animating = false;
|
|
this.maybeStartAITurn();
|
|
});
|
|
} else {
|
|
this.animating = false;
|
|
this.maybeStartAITurn();
|
|
}
|
|
});
|
|
}
|
|
|
|
playInitialDealPairs(pairs, idx, onComplete) {
|
|
if (idx >= pairs.length) { onComplete(); return; }
|
|
const { seat, pairedCards } = pairs[idx];
|
|
const name = this.opponentName(seat);
|
|
const rank = pairedCards[0].rank === 'T' ? '10' : pairedCards[0].rank;
|
|
const unit = this.matchVariant === 4 ? 'book' : 'pair';
|
|
const bookCount = pairedCards.length / this.matchVariant;
|
|
const label = bookCount === 1
|
|
? `${name} dealt a starting ${unit} of ${rank}s!`
|
|
: `${name} dealt ${bookCount} starting ${unit}s!`;
|
|
this.showBanner(label);
|
|
this.animatePairedCards(seat, pairedCards, () => {
|
|
this.hideBanner();
|
|
this.time.delayedCall(250, () => this.playInitialDealPairs(pairs, idx + 1, onComplete));
|
|
});
|
|
}
|
|
|
|
// ── Card sprite factory ───────────────────────────────────────────────────
|
|
|
|
makeCardSprite(card, x, y, { faceUp = true, rotation = 0, scale = 1 } = {}) {
|
|
const c = this.add.container(x, y).setDepth(D.card);
|
|
c.setRotation((rotation * Math.PI) / 180);
|
|
c.setScale(scale);
|
|
this.renderCardFace(c, card, faceUp);
|
|
c.card = card;
|
|
return c;
|
|
}
|
|
|
|
renderCardFace(container, card, faceUp) {
|
|
container.removeAll(true);
|
|
const x = -CARD_W / 2, y = -CARD_H / 2;
|
|
const g = this.add.graphics();
|
|
|
|
if (!faceUp) {
|
|
if (this.cardBack?.spriteIndex !== undefined && this.textures.exists('cardbacks')) {
|
|
g.destroy();
|
|
container.add(
|
|
this.add.image(0, 0, 'cardbacks', this.cardBack.spriteIndex)
|
|
.setDisplaySize(CARD_W, CARD_H)
|
|
.setOrigin(0.5)
|
|
);
|
|
} else {
|
|
const color = this.cardBack?.fallbackColor
|
|
? parseInt(this.cardBack.fallbackColor.replace('#', ''), 16) : 0x1a3a6b;
|
|
g.fillStyle(color, 1);
|
|
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
|
|
g.lineStyle(2, COLORS.accent, 0.6);
|
|
g.strokeRoundedRect(x + 6, y + 6, CARD_W - 12, CARD_H - 12, CARD_R - 2);
|
|
g.lineStyle(1, 0xffffff, 0.15);
|
|
g.strokeRoundedRect(x + 10, y + 10, CARD_W - 20, CARD_H - 20, CARD_R - 4);
|
|
container.add(g);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const frame = GOFISH_CARD_FRAME[card.rank];
|
|
if (frame !== undefined && this.textures.exists('gofish-cards')) {
|
|
g.destroy();
|
|
container.add(
|
|
this.add.image(0, 0, 'gofish-cards', frame)
|
|
.setDisplaySize(CARD_W, CARD_H)
|
|
.setOrigin(0.5)
|
|
);
|
|
return;
|
|
}
|
|
|
|
const suit = SUIT_COLORS[card.suit] ?? SUIT_COLORS.s;
|
|
g.fillStyle(suit.fill, 1);
|
|
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
|
|
g.lineStyle(3, suit.stroke, 1);
|
|
g.strokeRoundedRect(x + 2, y + 2, CARD_W - 4, CARD_H - 4, CARD_R - 1);
|
|
container.add(g);
|
|
|
|
const labelStyle = (sz) => ({ fontFamily: 'Righteous', fontSize: `${sz}px`, color: suit.glyph });
|
|
container.add(this.add.text(x + 8, y + 6, card.label, labelStyle(20)));
|
|
container.add(this.add.text(x + 8, y + 28, card.suitSymbol, labelStyle(20)));
|
|
container.add(this.add.text(0, 0, card.suitSymbol, labelStyle(54)).setOrigin(0.5));
|
|
container.add(this.add.text(x + CARD_W - 8, y + CARD_H - 8, card.label, labelStyle(20)).setOrigin(1, 1));
|
|
}
|
|
|
|
clearAllCardObjs() {
|
|
for (const c of this.cardObjs.values()) c.destroy();
|
|
this.cardObjs.clear();
|
|
for (const o of this.transientObjs) o.destroy();
|
|
this.transientObjs = [];
|
|
}
|
|
|
|
// ── Rendering ─────────────────────────────────────────────────────────────
|
|
|
|
renderAll() {
|
|
this.clearAllCardObjs();
|
|
this.renderScatteredPool();
|
|
for (let seat = 0; seat < this.gs.players.length; seat++) {
|
|
this.renderSeat(seat);
|
|
}
|
|
this.renderSeatChips();
|
|
this.renderTurnIndicator();
|
|
this.updateMatchesPanel();
|
|
}
|
|
|
|
renderScatteredPool() {
|
|
const localPickable = this.gs.phase === 'pick' && this.isLocalTurn();
|
|
for (const card of this.gs.pool) {
|
|
const pos = this.poolCardPositions.get(card.id);
|
|
if (!pos) continue;
|
|
const c = this.makeCardSprite(
|
|
{ label: '', suit: 's', suitSymbol: '' }, pos.x, pos.y,
|
|
{ faceUp: false, rotation: pos.rot }
|
|
);
|
|
c.setDepth(D.pool);
|
|
this.cardObjs.set(`pool-${card.id}`, c);
|
|
if (localPickable) {
|
|
c.setInteractive(
|
|
new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H),
|
|
Phaser.Geom.Rectangle.Contains
|
|
);
|
|
c.input.cursor = 'pointer';
|
|
c.on('pointerover', () => {
|
|
this.tweens.add({ targets: c, scaleX: 1.08, scaleY: 1.08, duration: 100 });
|
|
c.setDepth(D.highlight);
|
|
});
|
|
c.on('pointerout', () => {
|
|
this.tweens.add({ targets: c, scaleX: 1, scaleY: 1, duration: 100 });
|
|
c.setDepth(D.pool);
|
|
});
|
|
c.on('pointerdown', () => this.onPoolCardClick(card.id));
|
|
}
|
|
}
|
|
}
|
|
|
|
renderSeat(seat) {
|
|
const player = this.gs.players[seat];
|
|
const slot = this.slotForSeat[seat];
|
|
const layout = slotLayout(slot);
|
|
const n = player.hand.length;
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const card = player.hand[i];
|
|
const offset = i - (n - 1) / 2;
|
|
let x, y;
|
|
if (layout.handAxis === 'x') {
|
|
x = layout.handCenter.x + offset * HAND_SPREAD;
|
|
y = layout.handCenter.y;
|
|
} else {
|
|
x = layout.handCenter.x;
|
|
y = layout.handCenter.y + offset * HAND_SPREAD;
|
|
}
|
|
const c = this.makeCardSprite(card, x, y, {
|
|
faceUp: layout.handFaceUp,
|
|
rotation: layout.rotateCards,
|
|
});
|
|
this.cardObjs.set(`hand-${seat}-${card.id}`, c);
|
|
|
|
c.setInteractive(new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
|
|
c.input.cursor = 'pointer';
|
|
if (seat === 0) {
|
|
c.on('pointerdown', () => this.onHandCardClick(card.rank));
|
|
} else {
|
|
c.on('pointerdown', () => this.onOpponentClick(seat));
|
|
c.on('pointerover', () => this.highlightOpponent(seat, true));
|
|
c.on('pointerout', () => this.highlightOpponent(seat, false));
|
|
}
|
|
}
|
|
|
|
// Highlight selected rank for human seat.
|
|
if (seat === 0 && this.selectedRank) {
|
|
for (let i = 0; i < n; i++) {
|
|
const card = player.hand[i];
|
|
if (card.rank !== this.selectedRank) continue;
|
|
const obj = this.cardObjs.get(`hand-${seat}-${card.id}`);
|
|
if (!obj) continue;
|
|
const ring = this.add.graphics();
|
|
ring.lineStyle(3, COLORS.accent, 1);
|
|
ring.strokeRoundedRect(-CARD_W / 2 - 3, -CARD_H / 2 - 3, CARD_W + 6, CARD_H + 6, CARD_R + 2);
|
|
ring.setPosition(obj.x, obj.y);
|
|
ring.setDepth(D.highlight);
|
|
this.transientObjs.push(ring);
|
|
obj.setDepth(D.card + 2);
|
|
}
|
|
}
|
|
}
|
|
|
|
renderSeatChips() {
|
|
for (let s = 0; s < this.gs.players.length; s++) {
|
|
const p = this.gs.players[s];
|
|
const chip = this.seatChips[s];
|
|
if (!chip) continue;
|
|
chip.count.setText(`${p.pairs}`);
|
|
}
|
|
}
|
|
|
|
renderTurnIndicator() {
|
|
const seat = this.gs.currentPlayer;
|
|
const slot = this.slotForSeat[seat];
|
|
const lay = slotLayout(slot);
|
|
if (!this.turnGlow) {
|
|
this.turnGlow = this.add.circle(0, 0, 70, COLORS.accent, 0.18).setDepth(D.portrait - 1);
|
|
}
|
|
this.turnGlow.setPosition(lay.portrait.x, lay.portrait.y);
|
|
}
|
|
|
|
highlightOpponent(seat, on) {
|
|
if (!this.isLocalTurn() || !this.selectedRank) return;
|
|
const slot = this.slotForSeat[seat];
|
|
const lay = slotLayout(slot);
|
|
if (on) {
|
|
const ring = this.add.graphics();
|
|
ring.lineStyle(4, COLORS.gold, 0.9);
|
|
ring.strokeCircle(lay.portrait.x, lay.portrait.y, lay.portrait.r + 6);
|
|
ring.setDepth(D.portrait - 2);
|
|
this._hoverRing = ring;
|
|
} else {
|
|
this._hoverRing?.destroy();
|
|
this._hoverRing = null;
|
|
}
|
|
}
|
|
|
|
// ── Status / banner ────────────────────────────────────────────────────────
|
|
|
|
updateStatus() {
|
|
if (this.gameOver) {
|
|
this.statusText.setText('');
|
|
this.statusBg.setVisible(false);
|
|
return;
|
|
}
|
|
if (this.gs?.phase === 'pick') {
|
|
this.statusText.setText(
|
|
this.isLocalTurn()
|
|
? 'Click any face-down card from the center to draw.'
|
|
: `${this.opponentName(this.gs.currentPlayer)} is drawing…`
|
|
);
|
|
this.refreshStatusBg();
|
|
return;
|
|
}
|
|
if (this.isLocalTurn()) {
|
|
if (this.selectedRank) {
|
|
this.statusText.setText(`Asking for ${this.selectedRank}s — tap an opponent to ask.`);
|
|
} else {
|
|
this.statusText.setText('Your turn — tap a card to choose what to ask for.');
|
|
}
|
|
} else {
|
|
this.statusText.setText(`${this.opponentName(this.gs.currentPlayer)} is thinking…`);
|
|
}
|
|
this.refreshStatusBg();
|
|
}
|
|
|
|
refreshStatusBg() {
|
|
const t = this.statusText;
|
|
const pad = 8;
|
|
this.statusBg.clear();
|
|
this.statusBg.fillStyle(0x000000, 0.55);
|
|
this.statusBg.fillRoundedRect(t.x - pad, t.y - t.height - pad, t.width + pad * 2, t.height + pad * 2, 6);
|
|
this.statusBg.setVisible(true);
|
|
}
|
|
|
|
showBanner(text) {
|
|
this.bannerText.setText(text);
|
|
this.bannerText.setVisible(true);
|
|
this.bannerBg.clear();
|
|
this.bannerBg.fillStyle(COLORS.panel, 0.85);
|
|
const w = Math.max(this.bannerText.width + 60, 360);
|
|
const h = this.bannerText.height + 28;
|
|
this.bannerBg.fillRoundedRect(this.bannerText.x - w / 2, this.bannerText.y - h / 2, w, h, 10);
|
|
this.bannerBg.lineStyle(2, COLORS.accent, 1);
|
|
this.bannerBg.strokeRoundedRect(this.bannerText.x - w / 2, this.bannerText.y - h / 2, w, h, 10);
|
|
this.bannerBg.setVisible(true);
|
|
}
|
|
|
|
hideBanner() {
|
|
this.bannerText?.setVisible(false);
|
|
this.bannerBg?.setVisible(false);
|
|
}
|
|
|
|
// ── Turn flow ──────────────────────────────────────────────────────────────
|
|
|
|
isLocalTurn() {
|
|
return !this.gameOver && this.gs && this.gs.currentPlayer === 0;
|
|
}
|
|
|
|
opponentName(seat) {
|
|
if (seat === 0) return 'You';
|
|
const opp = this.opponents[seat - 1];
|
|
return opp?.name ?? `P${seat + 1}`;
|
|
}
|
|
|
|
onHandCardClick(rank) {
|
|
if (!this.isLocalTurn() || this.animating) return;
|
|
const legal = legalRanksToAsk(this.gs, 0);
|
|
if (!legal.includes(rank)) return;
|
|
this.selectedRank = (this.selectedRank === rank) ? null : rank;
|
|
this.renderAll();
|
|
this.updateStatus();
|
|
}
|
|
|
|
onOpponentClick(targetSeat) {
|
|
if (!this.isLocalTurn() || this.animating) return;
|
|
if (!this.selectedRank) {
|
|
this.showBanner('Pick a card from your hand first.');
|
|
this.time.delayedCall(1400, () => this.hideBanner());
|
|
return;
|
|
}
|
|
const target = this.gs.players[targetSeat];
|
|
if (!target || target.sittingOut || target.hand.length === 0) {
|
|
this.showBanner(`${this.opponentName(targetSeat)} has no cards to give.`);
|
|
this.time.delayedCall(1400, () => this.hideBanner());
|
|
return;
|
|
}
|
|
this.executeAsk(0, targetSeat, this.selectedRank);
|
|
}
|
|
|
|
executeAsk(askerSeat, targetSeat, rank) {
|
|
this.animating = true;
|
|
const before = this.gs;
|
|
const after = applyAsk(before, targetSeat, rank);
|
|
if (after === before) { this.animating = false; return; }
|
|
const last = after.lastAsk;
|
|
|
|
this.animateAsk(askerSeat, targetSeat, rank, last, before, (liveSprites) => {
|
|
// Fish with pool cards remaining — player must pick interactively.
|
|
if (after.phase === 'pick') {
|
|
this.gs = after;
|
|
this.selectedRank = null;
|
|
for (let s = 0; s < this.gs.players.length; s++) {
|
|
observeLog(this.aiMemory[s], this.gs, s);
|
|
}
|
|
this.renderAll();
|
|
this.updateStatus();
|
|
this.animating = false;
|
|
this.maybeStartAITurn();
|
|
return;
|
|
}
|
|
|
|
// Catch, or fish with empty pool — normal completion.
|
|
if (last.result === 'catch' && askerSeat !== 0) {
|
|
if (!this.aiTurnExclusions.has(askerSeat)) this.aiTurnExclusions.set(askerSeat, new Set());
|
|
this.aiTurnExclusions.get(askerSeat).add(`${targetSeat}:${rank}`);
|
|
}
|
|
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);
|
|
|
|
this.gs = after;
|
|
this.selectedRank = null;
|
|
|
|
for (let s = 0; s < this.gs.players.length; s++) {
|
|
observeLog(this.aiMemory[s], this.gs, s);
|
|
}
|
|
|
|
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.
|
|
this.hideBanner();
|
|
this.animatePairedCards(askerSeat, last.pairedCards, () => {
|
|
this.playRefillsThenFinish(() => {
|
|
this.animating = false;
|
|
if (isGameOver(this.gs)) { this.endGame(); return; }
|
|
this.maybeStartAITurn();
|
|
});
|
|
}, liveSprites ?? null);
|
|
} else {
|
|
this.time.delayedCall(900, () => {
|
|
this.hideBanner();
|
|
this.playRefillsThenFinish(() => {
|
|
this.animating = false;
|
|
if (isGameOver(this.gs)) { this.endGame(); return; }
|
|
this.maybeStartAITurn();
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
animateAsk(askerSeat, targetSeat, rank, last, beforeState, onComplete) {
|
|
const askerSlot = this.slotForSeat[askerSeat];
|
|
const askerLayout = slotLayout(askerSlot);
|
|
const askerShow = this.slotShowPos(askerSlot, askerLayout);
|
|
const targetSlot = this.slotForSeat[targetSeat];
|
|
const targetLayout = slotLayout(targetSlot);
|
|
const targetShow = this.slotShowPos(targetSlot, targetLayout);
|
|
|
|
const askerCard = beforeState.players[askerSeat].hand.find(c => c.rank === rank);
|
|
if (!askerCard) { onComplete(); return; }
|
|
|
|
const existingSprite = this.cardObjs.get(`hand-${askerSeat}-${askerCard.id}`);
|
|
const askerSprite = existingSprite
|
|
?? this.makeCardSprite(askerCard, askerLayout.handCenter.x, askerLayout.handCenter.y, {
|
|
faceUp: askerLayout.handFaceUp, rotation: 0,
|
|
});
|
|
if (existingSprite) this.cardObjs.delete(`hand-${askerSeat}-${askerCard.id}`);
|
|
const origX = askerSprite.x;
|
|
const origY = askerSprite.y;
|
|
askerSprite.setDepth(D.banner - 4);
|
|
this.transientObjs.push(askerSprite);
|
|
|
|
// Move asker card to show zone then flip it face-up (skip flip if already face-up).
|
|
this.tweens.add({
|
|
targets: askerSprite,
|
|
x: askerShow.x, y: askerShow.y,
|
|
duration: 280,
|
|
ease: 'Cubic.easeOut',
|
|
onComplete: () => {
|
|
if (!askerLayout.handFaceUp) this.flipCardFaceUp(askerSprite, askerCard);
|
|
},
|
|
});
|
|
|
|
// After card is revealed, cast fishing line toward the target, then show result.
|
|
this.time.delayedCall(640, () => {
|
|
this.animateFishingLine(askerShow.x, askerShow.y, targetShow.x, targetShow.y, () => {
|
|
if (last.result === 'catch') {
|
|
const n = last.cardsTransferred.length;
|
|
const isTargetVert = targetSlot === 'left' || targetSlot === 'right';
|
|
const targetSprites = last.cardsTransferred.map((card, i) => {
|
|
const tx = isTargetVert ? targetShow.x
|
|
: targetShow.x + (i - (n - 1) / 2) * (CARD_W + 8);
|
|
const ty = isTargetVert ? targetShow.y + (i - (n - 1) / 2) * (CARD_H + 8)
|
|
: targetShow.y;
|
|
const existingTargetSprite = this.cardObjs.get(`hand-${targetSeat}-${card.id}`);
|
|
const sprite = existingTargetSprite
|
|
?? this.makeCardSprite(card, targetLayout.handCenter.x, targetLayout.handCenter.y, {
|
|
faceUp: targetLayout.handFaceUp, rotation: 0,
|
|
});
|
|
if (existingTargetSprite) this.cardObjs.delete(`hand-${targetSeat}-${card.id}`);
|
|
sprite.setDepth(D.banner - 4);
|
|
this.transientObjs.push(sprite);
|
|
this.tweens.add({
|
|
targets: sprite,
|
|
x: tx, y: ty,
|
|
duration: 250,
|
|
delay: i * 80,
|
|
ease: 'Cubic.easeOut',
|
|
onComplete: () => {
|
|
if (!targetLayout.handFaceUp) this.flipCardFaceUp(sprite, card);
|
|
},
|
|
});
|
|
return { sprite, tx, ty };
|
|
});
|
|
|
|
// After target cards are shown, fan all sprites into a holding position
|
|
// at the asker's show zone — they stay visible for the pair animation.
|
|
const waitMs = 250 + (n - 1) * 80 + 380;
|
|
this.time.delayedCall(waitMs, () => {
|
|
const FGAP = 8;
|
|
const nTotal = 1 + targetSprites.length;
|
|
const isAskerVert = askerSlot === 'left' || askerSlot === 'right';
|
|
const allSprites = [
|
|
askerSprite,
|
|
...targetSprites.map(({ sprite }) => sprite),
|
|
].map((sprite, i) => {
|
|
const x = isAskerVert
|
|
? askerShow.x
|
|
: askerShow.x + (i - (nTotal - 1) / 2) * (CARD_W + FGAP);
|
|
const y = isAskerVert
|
|
? askerShow.y + (i - (nTotal - 1) / 2) * (CARD_H + FGAP)
|
|
: askerShow.y;
|
|
return { sprite, x, y };
|
|
});
|
|
|
|
for (const { sprite, x, y } of allSprites) {
|
|
if (!sprite.active) continue;
|
|
this.tweens.add({ targets: sprite, x, y, duration: 280, ease: 'Cubic.easeOut' });
|
|
}
|
|
this.time.delayedCall(320, () => onComplete(allSprites));
|
|
});
|
|
} else {
|
|
// Fish — flip card back face-down (if needed) and return it to hand.
|
|
if (!askerLayout.handFaceUp) this.flipCardFaceDown(askerSprite);
|
|
const returnDelay = askerLayout.handFaceUp ? 80 : 340;
|
|
this.time.delayedCall(returnDelay, () => {
|
|
this.tweens.add({
|
|
targets: askerSprite,
|
|
x: origX, y: origY,
|
|
duration: 280, ease: 'Cubic.easeIn',
|
|
onComplete: () => { if (askerSprite.active) askerSprite.destroy(); },
|
|
});
|
|
this.time.delayedCall(320, onComplete);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
slotShowPos(slot, layout) {
|
|
const GAP = 10;
|
|
switch (slot) {
|
|
case 'bottom': return { x: layout.handCenter.x, y: layout.handCenter.y - CARD_H / 2 - GAP - CARD_H / 2 };
|
|
case 'top': return { x: layout.handCenter.x, y: layout.handCenter.y + CARD_H / 2 + GAP + CARD_H / 2 };
|
|
case 'left': return { x: layout.handCenter.x + CARD_H / 2 + GAP + CARD_W / 2, y: layout.handCenter.y };
|
|
case 'right': return { x: layout.handCenter.x - CARD_H / 2 - GAP - CARD_W / 2, y: layout.handCenter.y };
|
|
default: return { x: CX, y: CY };
|
|
}
|
|
}
|
|
|
|
flipCardFaceUp(container, card) {
|
|
this.tweens.add({
|
|
targets: container, scaleX: 0, duration: 150, ease: 'Linear',
|
|
onComplete: () => {
|
|
container.setRotation(0);
|
|
this.renderCardFace(container, card, true);
|
|
this.tweens.add({ targets: container, scaleX: 1, duration: 150, ease: 'Linear' });
|
|
},
|
|
});
|
|
}
|
|
|
|
flipCardFaceDown(container) {
|
|
this.tweens.add({
|
|
targets: container, scaleX: 0, duration: 150, ease: 'Linear',
|
|
onComplete: () => {
|
|
this.renderCardFace(container, container.card, false);
|
|
this.tweens.add({ targets: container, scaleX: 1, duration: 150, ease: 'Linear' });
|
|
},
|
|
});
|
|
}
|
|
|
|
animatePairedCards(askerSeat, pairedCards, onComplete, preSprites = null) {
|
|
if (preSprites) {
|
|
// Cards are already face-up and in position from animateAsk — skip sprite creation.
|
|
const sprites = preSprites.map(({ sprite, x, y }) => ({ sprite, targetX: x, targetY: y }));
|
|
playSound(this, SFX.CARD_PLACE);
|
|
this.time.delayedCall(200, () => {
|
|
const cx = sprites.reduce((s, { targetX }) => s + targetX, 0) / sprites.length;
|
|
const cy = sprites.reduce((s, { targetY }) => s + targetY, 0) / sprites.length;
|
|
this.spawnFireworks(cx, cy);
|
|
this.time.delayedCall(350, () => this.spawnFireworks(cx, cy));
|
|
});
|
|
this.time.delayedCall(1200, () => {
|
|
for (const { sprite } of sprites) {
|
|
if (!sprite.active) continue;
|
|
this.tweens.add({
|
|
targets: sprite, x: CX, y: CY, alpha: 0, duration: 500, ease: 'Cubic.easeIn',
|
|
onComplete: () => { if (sprite.active) sprite.destroy(); },
|
|
});
|
|
}
|
|
});
|
|
this.time.delayedCall(1800, onComplete);
|
|
return;
|
|
}
|
|
|
|
const slot = this.slotForSeat[askerSeat];
|
|
const layout = slotLayout(slot);
|
|
const n = pairedCards.length;
|
|
const GAP = 10;
|
|
const totalW = n * CARD_W + (n - 1) * GAP;
|
|
|
|
const sprites = [];
|
|
for (let i = 0; i < n; i++) {
|
|
let targetX, targetY;
|
|
switch (slot) {
|
|
case 'bottom':
|
|
targetX = CX - totalW / 2 + CARD_W / 2 + i * (CARD_W + GAP);
|
|
targetY = layout.handCenter.y - CARD_H / 2 - GAP - CARD_H / 2;
|
|
break;
|
|
case 'top':
|
|
targetX = CX - totalW / 2 + CARD_W / 2 + i * (CARD_W + GAP);
|
|
targetY = layout.handCenter.y + CARD_H / 2 + GAP + CARD_H / 2;
|
|
break;
|
|
case 'left': {
|
|
const leftmostX = layout.handCenter.x + CARD_H / 2 + GAP + CARD_W / 2;
|
|
targetX = leftmostX + i * (CARD_W + GAP);
|
|
targetY = CY;
|
|
break;
|
|
}
|
|
case 'right': {
|
|
const rightmostX = layout.handCenter.x - CARD_H / 2 - GAP - CARD_W / 2;
|
|
targetX = rightmostX - (n - 1 - i) * (CARD_W + GAP);
|
|
targetY = CY;
|
|
break;
|
|
}
|
|
default:
|
|
targetX = CX; targetY = CY;
|
|
}
|
|
|
|
const sprite = this.makeCardSprite(pairedCards[i], CX, CY, { faceUp: true, rotation: 0 });
|
|
sprite.setDepth(D.banner - 5);
|
|
sprite.setAlpha(0);
|
|
this.transientObjs.push(sprite);
|
|
|
|
this.tweens.add({
|
|
targets: sprite,
|
|
x: targetX, y: targetY, alpha: 1,
|
|
duration: 300,
|
|
ease: 'Back.easeOut',
|
|
});
|
|
|
|
sprites.push({ sprite, targetX, targetY });
|
|
}
|
|
|
|
playSound(this, SFX.CARD_PLACE);
|
|
|
|
this.time.delayedCall(350, () => {
|
|
const cx = sprites.reduce((s, { targetX: tx }) => s + tx, 0) / sprites.length;
|
|
const cy = sprites.reduce((s, { targetY: ty }) => s + ty, 0) / sprites.length;
|
|
this.spawnFireworks(cx, cy);
|
|
this.time.delayedCall(350, () => this.spawnFireworks(cx, cy));
|
|
});
|
|
|
|
this.time.delayedCall(1600, () => {
|
|
for (const { sprite } of sprites) {
|
|
if (!sprite.active) continue;
|
|
this.tweens.add({
|
|
targets: sprite,
|
|
x: CX, y: CY, alpha: 0,
|
|
duration: 500,
|
|
ease: 'Cubic.easeIn',
|
|
onComplete: () => { if (sprite.active) sprite.destroy(); },
|
|
});
|
|
}
|
|
});
|
|
|
|
this.time.delayedCall(2200, onComplete);
|
|
}
|
|
|
|
// ── Refill animation ──────────────────────────────────────────────────────
|
|
|
|
animateRefill(seat, drawnCards, onComplete) {
|
|
if (drawnCards.length === 0) { onComplete(); return; }
|
|
const slot = this.slotForSeat[seat];
|
|
const layout = slotLayout(slot);
|
|
const finalHand = this.gs.players[seat].hand;
|
|
const STAGGER = 130;
|
|
const FLY = 340;
|
|
const targetRot = (layout.rotateCards * Math.PI) / 180;
|
|
|
|
const name = this.opponentName(seat);
|
|
this.showBanner(`${name}'s hand was empty — drawing new cards…`);
|
|
|
|
drawnCards.forEach((card, i) => {
|
|
this.time.delayedCall(i * STAGGER, () => {
|
|
// Re-use the existing face-down pool sprite if still in cardObjs.
|
|
const poolKey = `pool-${card.id}`;
|
|
const existing = this.cardObjs.get(poolKey);
|
|
let sprite;
|
|
if (existing) {
|
|
this.cardObjs.delete(poolKey);
|
|
this.transientObjs.push(existing);
|
|
sprite = existing;
|
|
} else {
|
|
const pos = this.poolCardPositions.get(card.id) ?? POOL_POS;
|
|
sprite = this.makeCardSprite(card, pos.x, pos.y, { faceUp: false });
|
|
this.transientObjs.push(sprite);
|
|
}
|
|
sprite.setDepth(D.card + 5);
|
|
|
|
// Destination: the card's actual position in the final hand.
|
|
const cardInHand = finalHand.find(c => c.id === card.id);
|
|
let destX = layout.handCenter.x;
|
|
let destY = layout.handCenter.y;
|
|
if (cardInHand) {
|
|
const n = finalHand.length;
|
|
const idx = finalHand.indexOf(cardInHand);
|
|
const offset = idx - (n - 1) / 2;
|
|
if (layout.handAxis === 'x') destX = layout.handCenter.x + offset * HAND_SPREAD;
|
|
else destY = layout.handCenter.y + offset * HAND_SPREAD;
|
|
}
|
|
|
|
if (i % 2 === 0) playSound(this, SFX.CARD_DEAL);
|
|
|
|
this.tweens.add({
|
|
targets: sprite, x: destX, y: destY, rotation: targetRot,
|
|
duration: FLY, ease: 'Cubic.easeOut',
|
|
onComplete: () => {
|
|
if (seat === 0 && cardInHand) this.flipCardFaceUp(sprite, card);
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
const total = (drawnCards.length - 1) * STAGGER + FLY + (seat === 0 ? 380 : 160);
|
|
this.time.delayedCall(total, () => {
|
|
this.hideBanner();
|
|
onComplete();
|
|
});
|
|
}
|
|
|
|
// Run each refill entry sequentially, then call renderAll + updateStatus + onDone.
|
|
playRefillsThenFinish(onDone) {
|
|
const refills = (this.gs.lastAsk?.refills ?? []).filter(r => r.cards.length > 0);
|
|
const next = (idx) => {
|
|
if (idx >= refills.length) {
|
|
this.renderAll();
|
|
this.updateStatus();
|
|
onDone();
|
|
return;
|
|
}
|
|
const { seat, cards } = refills[idx];
|
|
this.animateRefill(seat, cards, () => next(idx + 1));
|
|
};
|
|
next(0);
|
|
}
|
|
|
|
spawnFireworks(cx, cy) {
|
|
const BURST_COLORS = [0xd4a017, 0xe06c75, 0x61afef, 0xc678dd, 0x98c379, 0xe5c07b];
|
|
const COUNT = 20;
|
|
for (let i = 0; i < COUNT; i++) {
|
|
const angle = (i / COUNT) * Math.PI * 2;
|
|
const dist = 70 + Math.random() * 70;
|
|
const color = BURST_COLORS[i % BURST_COLORS.length];
|
|
const r = 3 + Math.random() * 4;
|
|
const g = this.add.graphics().setDepth(D.banner - 2);
|
|
g.fillStyle(color, 1);
|
|
g.fillCircle(0, 0, r);
|
|
g.setPosition(cx, cy);
|
|
this.transientObjs.push(g);
|
|
this.tweens.add({
|
|
targets: g,
|
|
x: cx + Math.cos(angle) * dist,
|
|
y: cy + Math.sin(angle) * dist,
|
|
alpha: 0,
|
|
duration: 600 + Math.random() * 300,
|
|
ease: 'Quad.easeOut',
|
|
onComplete: () => { if (g.active) g.destroy(); },
|
|
});
|
|
}
|
|
}
|
|
|
|
onPoolCardClick(cardId) {
|
|
if (!this.isLocalTurn() || this.animating || this.gs.phase !== 'pick') return;
|
|
this.doFishPick(cardId);
|
|
}
|
|
|
|
runAIFishPick() {
|
|
if (this.gameOver || this.animating) return;
|
|
if (this.gs.phase !== 'pick' || this.isLocalTurn()) return;
|
|
const pool = this.gs.pool;
|
|
if (pool.length === 0) return;
|
|
const card = pool[Math.floor(Math.random() * pool.length)];
|
|
this.doFishPick(card.id);
|
|
}
|
|
|
|
doFishPick(cardId) {
|
|
this.animating = true;
|
|
const askerSeat = this.gs.lastAsk.askerSeat;
|
|
const after = applyFishPick(this.gs, cardId);
|
|
if (after === this.gs) { this.animating = false; return; }
|
|
|
|
const sprite = this.cardObjs.get(`pool-${cardId}`);
|
|
if (sprite) this.cardObjs.delete(`pool-${cardId}`);
|
|
this.poolCardPositions.delete(cardId);
|
|
|
|
const continueAfterDraw = () => {
|
|
const last = after.lastAsk;
|
|
this.showBanner(this.formatAskBanner(last));
|
|
playSound(this, SFX.CARD_DEAL);
|
|
this.gs = after;
|
|
this.selectedRank = null;
|
|
for (let s = 0; s < this.gs.players.length; s++) {
|
|
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, () => {
|
|
this.playRefillsThenFinish(() => {
|
|
this.animating = false;
|
|
if (isGameOver(this.gs)) { this.endGame(); return; }
|
|
this.maybeStartAITurn();
|
|
});
|
|
});
|
|
});
|
|
} else {
|
|
this.time.delayedCall(900, () => {
|
|
this.hideBanner();
|
|
this.playRefillsThenFinish(() => {
|
|
this.animating = false;
|
|
if (isGameOver(this.gs)) { this.endGame(); return; }
|
|
this.maybeStartAITurn();
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
if (sprite) {
|
|
const revealCard = askerSeat === 0 ? after.lastAsk.drawnCard : null;
|
|
const drawnCard = after.lastAsk.drawnCard;
|
|
const newHand = after.players[askerSeat].hand;
|
|
const cardIndex = newHand.findIndex(c => c.id === drawnCard.id);
|
|
const askerLayout = slotLayout(this.slotForSeat[askerSeat]);
|
|
let destX, destY;
|
|
if (askerLayout.handAxis === 'x') {
|
|
destX = askerLayout.handCenter.x + (cardIndex - (newHand.length - 1) / 2) * HAND_SPREAD;
|
|
destY = askerLayout.handCenter.y;
|
|
} else {
|
|
destX = askerLayout.handCenter.x;
|
|
destY = askerLayout.handCenter.y + (cardIndex - (newHand.length - 1) / 2) * HAND_SPREAD;
|
|
}
|
|
this.animateFishDraw(sprite, revealCard, destX, destY, continueAfterDraw);
|
|
} else {
|
|
this.time.delayedCall(50, continueAfterDraw);
|
|
}
|
|
}
|
|
|
|
animateFishDraw(sprite, revealCard, destX, destY, onComplete) {
|
|
sprite.setDepth(D.banner - 4);
|
|
this.transientObjs.push(sprite);
|
|
|
|
const flyToHand = () => {
|
|
this.tweens.add({
|
|
targets: sprite,
|
|
x: destX, y: destY,
|
|
rotation: 0,
|
|
duration: 400,
|
|
ease: 'Cubic.easeIn',
|
|
onComplete: () => onComplete(),
|
|
});
|
|
};
|
|
|
|
if (revealCard) {
|
|
// Human pick: flip face-up in place, pause to show the card, then fly to hand.
|
|
this.flipCardFaceUp(sprite, revealCard);
|
|
this.time.delayedCall(700, flyToHand);
|
|
} else {
|
|
flyToHand();
|
|
}
|
|
}
|
|
|
|
animateFishingLine(fromX, fromY, toX, toY, onComplete) {
|
|
const gfx = this.add.graphics().setDepth(D.banner - 3);
|
|
const totalDx = toX - fromX, totalDy = toY - fromY;
|
|
const totalLen = Math.sqrt(totalDx * totalDx + totalDy * totalDy) || 1;
|
|
const ndx = totalDx / totalLen, ndy = totalDy / totalLen;
|
|
const px = -ndy, py = ndx; // left-perpendicular to line direction
|
|
const HOOK_R = 10;
|
|
const BARB_LEN = 7;
|
|
const SWEEP = (4 * Math.PI) / 3; // 240°
|
|
|
|
const drawFrame = (t) => {
|
|
gfx.clear();
|
|
gfx.lineStyle(2, 0xffe066, 1);
|
|
const tipX = fromX + t * totalDx;
|
|
const tipY = fromY + t * totalDy;
|
|
|
|
gfx.beginPath();
|
|
gfx.moveTo(fromX, fromY);
|
|
gfx.lineTo(tipX, tipY);
|
|
gfx.strokePath();
|
|
|
|
// Hook arc offset perpendicular from tip
|
|
const hookCx = tipX + px * HOOK_R;
|
|
const hookCy = tipY + py * HOOK_R;
|
|
const startAngle = Math.atan2(tipY - hookCy, tipX - hookCx);
|
|
const endAngle = startAngle + SWEEP;
|
|
gfx.beginPath();
|
|
gfx.arc(hookCx, hookCy, HOOK_R, startAngle, endAngle, false);
|
|
gfx.strokePath();
|
|
|
|
// Barb at hook tip
|
|
const barbX = hookCx + HOOK_R * Math.cos(endAngle);
|
|
const barbY = hookCy + HOOK_R * Math.sin(endAngle);
|
|
gfx.beginPath();
|
|
gfx.moveTo(barbX, barbY);
|
|
gfx.lineTo(barbX - Math.sin(endAngle) * BARB_LEN, barbY + Math.cos(endAngle) * BARB_LEN);
|
|
gfx.strokePath();
|
|
};
|
|
|
|
const progress = { t: 0 };
|
|
this.tweens.add({
|
|
targets: progress,
|
|
t: 1,
|
|
duration: 1000,
|
|
ease: 'Cubic.easeOut',
|
|
onUpdate: () => drawFrame(progress.t),
|
|
onComplete: () => {
|
|
drawFrame(1);
|
|
this.time.delayedCall(150, () => {
|
|
this.tweens.add({
|
|
targets: gfx,
|
|
alpha: 0,
|
|
duration: 250,
|
|
ease: 'Linear',
|
|
onComplete: () => { gfx.destroy(); onComplete(); },
|
|
});
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
formatAskBanner(last) {
|
|
const asker = this.opponentName(last.askerSeat);
|
|
const target = this.opponentName(last.targetSeat);
|
|
const rank = last.rank === 'T' ? '10' : last.rank;
|
|
if (last.result === 'catch') {
|
|
const unit = this.matchVariant === 4 ? 'book' : 'pair';
|
|
const tail = last.newPairs > 0 ? ` +${last.newPairs} ${unit}${last.newPairs > 1 ? 's' : ''}!` : '';
|
|
return `${asker} asked ${target} for ${rank}s — caught ${last.cardsTransferred.length}!${tail}`;
|
|
}
|
|
if (last.result === 'lucky') {
|
|
return `${asker} asked ${target} for ${rank}s — Go Fish… lucky draw!`;
|
|
}
|
|
return `${asker} asked ${target} for ${rank}s — Go Fish.`;
|
|
}
|
|
|
|
maybeStartAITurn() {
|
|
if (this.gameOver || this.animating) return;
|
|
if (this.gs.phase === 'pick' && !this.isLocalTurn()) {
|
|
this.time.delayedCall(700, () => this.runAIFishPick());
|
|
return;
|
|
}
|
|
if (this.isLocalTurn()) return;
|
|
this.time.delayedCall(700, () => this.runAITurn());
|
|
}
|
|
|
|
runAITurn() {
|
|
if (this.gameOver || this.animating) return;
|
|
const seat = this.gs.currentPlayer;
|
|
if (seat === 0) return;
|
|
// Clear exclusions when starting a fresh turn (not continuing after a catch).
|
|
if (this.gs.lastAsk?.askerSeat !== seat || this.gs.lastAsk?.result !== 'catch') {
|
|
this.aiTurnExclusions.set(seat, new Set());
|
|
}
|
|
const exclude = this.aiTurnExclusions.get(seat) ?? new Set();
|
|
const action = chooseAction(this.gs, seat, this.aiMemory[seat], exclude);
|
|
if (!action) {
|
|
// Shouldn't happen — fail-safe: end the game if no action available.
|
|
this.endGame();
|
|
return;
|
|
}
|
|
this.executeAsk(seat, action.targetSeat, action.rank);
|
|
}
|
|
|
|
// ── Game over ──────────────────────────────────────────────────────────────
|
|
|
|
endGame() {
|
|
if (this.gameOver) return;
|
|
this.gameOver = true;
|
|
this.hideBanner();
|
|
const rows = this.gs.players
|
|
.map((p) => ({ seat: p.seat, pairs: p.pairs }))
|
|
.sort((a, b) => b.pairs - a.pairs);
|
|
const winners = this.gs.winnerSeats.map((s) => this.opponentName(s)).join(', ');
|
|
const unit = this.matchVariant === 4 ? 'book' : 'pair';
|
|
const lines = [`Game over — winner: ${winners}`];
|
|
for (const r of rows) {
|
|
lines.push(`${this.opponentName(r.seat)}: ${r.pairs} ${unit}${r.pairs === 1 ? '' : 's'}`);
|
|
}
|
|
playSound(this, SFX.CASINO_WIN);
|
|
new Modal(this, lines.join('\n'), {}).setDepth(D.modal);
|
|
}
|
|
}
|