|
|
|
|
@ -9,6 +9,7 @@ import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
|
|
|
|
import {
|
|
|
|
|
createInitialState,
|
|
|
|
|
applyAsk,
|
|
|
|
|
applyFishPick,
|
|
|
|
|
legalRanksToAsk,
|
|
|
|
|
canAsk,
|
|
|
|
|
isGameOver,
|
|
|
|
|
@ -37,50 +38,70 @@ const SLOTS_USED = {
|
|
|
|
|
|
|
|
|
|
function slotLayout(slot) {
|
|
|
|
|
switch (slot) {
|
|
|
|
|
case 'bottom':
|
|
|
|
|
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: 220, y: GAME_HEIGHT - 130, r: 56 },
|
|
|
|
|
nameLabel: { x: 220, y: GAME_HEIGHT - 60 },
|
|
|
|
|
chip: { x: CX, y: GAME_HEIGHT - 100 - CARD_H / 2 - 30 },
|
|
|
|
|
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':
|
|
|
|
|
}
|
|
|
|
|
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: 220, y: 130, r: 50 },
|
|
|
|
|
nameLabel: { x: 220, y: 200 },
|
|
|
|
|
chip: { x: CX, y: 110 + CARD_H / 2 + 30 },
|
|
|
|
|
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':
|
|
|
|
|
}
|
|
|
|
|
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: 130, y: 220, r: 50 },
|
|
|
|
|
nameLabel: { x: 130, y: 290 },
|
|
|
|
|
chip: { x: 110 + CARD_H / 2 + 10 + 22, y: CY },
|
|
|
|
|
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':
|
|
|
|
|
}
|
|
|
|
|
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: GAME_WIDTH - 130, y: 220, r: 50 },
|
|
|
|
|
nameLabel: { x: GAME_WIDTH - 130, y: 290 },
|
|
|
|
|
chip: { x: GAME_WIDTH - 110 - CARD_H / 2 - 10 - 22, y: CY },
|
|
|
|
|
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}`);
|
|
|
|
|
}
|
|
|
|
|
@ -117,6 +138,7 @@ export default class GoFishGame extends Phaser.Scene {
|
|
|
|
|
this.aiMemory = [];
|
|
|
|
|
this.selectedRank = null;
|
|
|
|
|
this.bannerText = null;
|
|
|
|
|
this.poolCardPositions = new Map();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
create() {
|
|
|
|
|
@ -203,16 +225,6 @@ export default class GoFishGame extends Phaser.Scene {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
buildCenter() {
|
|
|
|
|
// Pool placeholder.
|
|
|
|
|
this.add.rectangle(POOL_POS.x, POOL_POS.y, CARD_W + 8, CARD_H + 8, 0x000000, 0.4)
|
|
|
|
|
.setStrokeStyle(2, COLORS.accent).setDepth(D.pool);
|
|
|
|
|
this.add.text(POOL_POS.x, POOL_POS.y - CARD_H / 2 - 18, 'POOL', {
|
|
|
|
|
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
|
|
|
|
}).setOrigin(0.5).setDepth(D.ui);
|
|
|
|
|
this.poolCountText = this.add.text(POOL_POS.x, POOL_POS.y + CARD_H / 2 + 16, '', {
|
|
|
|
|
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.accentHex,
|
|
|
|
|
}).setOrigin(0.5).setDepth(D.ui);
|
|
|
|
|
|
|
|
|
|
// Last-action banner.
|
|
|
|
|
this.bannerBg = this.add.graphics().setDepth(D.banner - 1).setVisible(false);
|
|
|
|
|
this.bannerText = this.add.text(CX, CY + CARD_H + 80, '', {
|
|
|
|
|
@ -221,10 +233,39 @@ export default class GoFishGame extends Phaser.Scene {
|
|
|
|
|
}).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() {
|
|
|
|
|
this.statusText = this.add.text(CX, GAME_HEIGHT - 230, '', {
|
|
|
|
|
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, align: 'center',
|
|
|
|
|
}).setOrigin(0.5).setDepth(D.ui);
|
|
|
|
|
// 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,
|
|
|
|
|
@ -244,16 +285,82 @@ export default class GoFishGame extends Phaser.Scene {
|
|
|
|
|
this.hideBanner();
|
|
|
|
|
|
|
|
|
|
const playerCount = this.slotForSeat.length;
|
|
|
|
|
this.gs = createInitialState({ playerCount });
|
|
|
|
|
const finalState = createInitialState({ playerCount });
|
|
|
|
|
this.aiMemory = [];
|
|
|
|
|
for (let s = 0; s < playerCount; s++) {
|
|
|
|
|
this.aiMemory[s] = createMemory(playerCount);
|
|
|
|
|
observeLog(this.aiMemory[s], this.gs, s);
|
|
|
|
|
observeLog(this.aiMemory[s], finalState, s);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
playSound(this, SFX.CARD_SHUFFLE);
|
|
|
|
|
this.renderAll();
|
|
|
|
|
this.updateStatus();
|
|
|
|
|
this.maybeStartAITurn();
|
|
|
|
|
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.animating = false;
|
|
|
|
|
this.renderAll();
|
|
|
|
|
this.updateStatus();
|
|
|
|
|
this.maybeStartAITurn();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Card sprite factory ───────────────────────────────────────────────────
|
|
|
|
|
@ -319,7 +426,7 @@ export default class GoFishGame extends Phaser.Scene {
|
|
|
|
|
|
|
|
|
|
renderAll() {
|
|
|
|
|
this.clearAllCardObjs();
|
|
|
|
|
this.renderPool();
|
|
|
|
|
this.renderScatteredPool();
|
|
|
|
|
for (let seat = 0; seat < this.gs.players.length; seat++) {
|
|
|
|
|
this.renderSeat(seat);
|
|
|
|
|
}
|
|
|
|
|
@ -327,11 +434,33 @@ export default class GoFishGame extends Phaser.Scene {
|
|
|
|
|
this.renderTurnIndicator();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderPool() {
|
|
|
|
|
this.poolCountText.setText(`${this.gs.pool.length}`);
|
|
|
|
|
if (this.gs.pool.length > 0) {
|
|
|
|
|
const c = this.makeCardSprite({ label: '', suit: 's', suitSymbol: '' }, POOL_POS.x, POOL_POS.y, { faceUp: false });
|
|
|
|
|
this.cardObjs.set('pool', c);
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -427,6 +556,16 @@ export default class GoFishGame extends Phaser.Scene {
|
|
|
|
|
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()) {
|
|
|
|
|
@ -438,6 +577,16 @@ export default class GoFishGame extends Phaser.Scene {
|
|
|
|
|
} 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) {
|
|
|
|
|
@ -499,33 +648,428 @@ export default class GoFishGame extends Phaser.Scene {
|
|
|
|
|
this.animating = true;
|
|
|
|
|
const before = this.gs;
|
|
|
|
|
const after = applyAsk(before, targetSeat, rank);
|
|
|
|
|
if (after === before) {
|
|
|
|
|
this.animating = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (after === before) { this.animating = false; return; }
|
|
|
|
|
const last = after.lastAsk;
|
|
|
|
|
const summary = this.formatAskBanner(last);
|
|
|
|
|
this.showBanner(summary);
|
|
|
|
|
playSound(this, last.result === 'catch' ? SFX.CARD_PLACE : SFX.CARD_DEAL);
|
|
|
|
|
|
|
|
|
|
this.gs = after;
|
|
|
|
|
this.selectedRank = null;
|
|
|
|
|
|
|
|
|
|
// Update each AI's memory from the new log entries.
|
|
|
|
|
for (let s = 0; s < this.gs.players.length; s++) {
|
|
|
|
|
observeLog(this.aiMemory[s], this.gs, s);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.time.delayedCall(900, () => {
|
|
|
|
|
this.renderAll();
|
|
|
|
|
this.updateStatus();
|
|
|
|
|
this.animating = false;
|
|
|
|
|
if (isGameOver(this.gs)) {
|
|
|
|
|
this.endGame();
|
|
|
|
|
this.animateAsk(askerSeat, targetSeat, rank, last, before, () => {
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
this.hideBanner();
|
|
|
|
|
this.maybeStartAITurn();
|
|
|
|
|
|
|
|
|
|
// Catch, or fish with empty pool — normal completion.
|
|
|
|
|
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) {
|
|
|
|
|
this.time.delayedCall(600, () => {
|
|
|
|
|
this.hideBanner();
|
|
|
|
|
this.renderAll();
|
|
|
|
|
this.updateStatus();
|
|
|
|
|
this.animatePairedCards(askerSeat, last.pairedCards, () => {
|
|
|
|
|
this.animating = false;
|
|
|
|
|
if (isGameOver(this.gs)) { this.endGame(); return; }
|
|
|
|
|
this.maybeStartAITurn();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
this.time.delayedCall(900, () => {
|
|
|
|
|
this.renderAll();
|
|
|
|
|
this.updateStatus();
|
|
|
|
|
this.animating = false;
|
|
|
|
|
if (isGameOver(this.gs)) { this.endGame(); return; }
|
|
|
|
|
this.hideBanner();
|
|
|
|
|
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 askerSprite = this.makeCardSprite(askerCard, askerLayout.handCenter.x, askerLayout.handCenter.y, {
|
|
|
|
|
faceUp: false, rotation: 0,
|
|
|
|
|
});
|
|
|
|
|
askerSprite.setDepth(D.banner - 4);
|
|
|
|
|
this.transientObjs.push(askerSprite);
|
|
|
|
|
|
|
|
|
|
// Move asker card to show zone then flip it face-up.
|
|
|
|
|
this.tweens.add({
|
|
|
|
|
targets: askerSprite,
|
|
|
|
|
x: askerShow.x, y: askerShow.y,
|
|
|
|
|
duration: 280,
|
|
|
|
|
ease: 'Cubic.easeOut',
|
|
|
|
|
onComplete: () => 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 sprite = this.makeCardSprite(card, targetLayout.handCenter.x, targetLayout.handCenter.y, {
|
|
|
|
|
faceUp: false, rotation: 0,
|
|
|
|
|
});
|
|
|
|
|
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: () => this.flipCardFaceUp(sprite, card),
|
|
|
|
|
});
|
|
|
|
|
return { sprite, tx, ty };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const waitMs = 250 + (n - 1) * 80 + 380;
|
|
|
|
|
this.time.delayedCall(waitMs, () => {
|
|
|
|
|
if (askerSprite.active) {
|
|
|
|
|
this.tweens.add({
|
|
|
|
|
targets: askerSprite, alpha: 0, duration: 200,
|
|
|
|
|
onComplete: () => { if (askerSprite.active) askerSprite.destroy(); },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
for (const { sprite } of targetSprites) {
|
|
|
|
|
if (!sprite.active) continue;
|
|
|
|
|
this.tweens.add({
|
|
|
|
|
targets: sprite,
|
|
|
|
|
x: askerLayout.handCenter.x, y: askerLayout.handCenter.y,
|
|
|
|
|
alpha: 0, duration: 380, ease: 'Cubic.easeIn',
|
|
|
|
|
onComplete: () => { if (sprite.active) sprite.destroy(); },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
this.time.delayedCall(420, onComplete);
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
// Fish — flip card back face-down and return it to hand.
|
|
|
|
|
this.flipCardFaceDown(askerSprite);
|
|
|
|
|
this.time.delayedCall(340, () => {
|
|
|
|
|
this.tweens.add({
|
|
|
|
|
targets: askerSprite,
|
|
|
|
|
x: askerLayout.handCenter.x, y: askerLayout.handCenter.y,
|
|
|
|
|
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) {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
this.time.delayedCall(600, () => {
|
|
|
|
|
this.hideBanner();
|
|
|
|
|
this.renderAll();
|
|
|
|
|
this.updateStatus();
|
|
|
|
|
this.animatePairedCards(askerSeat, last.pairedCards, () => {
|
|
|
|
|
this.animating = false;
|
|
|
|
|
if (isGameOver(this.gs)) { this.endGame(); return; }
|
|
|
|
|
this.maybeStartAITurn();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
this.time.delayedCall(900, () => {
|
|
|
|
|
this.renderAll();
|
|
|
|
|
this.updateStatus();
|
|
|
|
|
this.animating = false;
|
|
|
|
|
if (isGameOver(this.gs)) { this.endGame(); return; }
|
|
|
|
|
this.hideBanner();
|
|
|
|
|
this.maybeStartAITurn();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (sprite) {
|
|
|
|
|
const revealCard = askerSeat === 0 ? after.lastAsk.drawnCard : null;
|
|
|
|
|
this.animateFishDraw(sprite, askerSeat, revealCard, continueAfterDraw);
|
|
|
|
|
} else {
|
|
|
|
|
this.time.delayedCall(50, continueAfterDraw);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
animateFishDraw(sprite, askerSeat, revealCard, onComplete) {
|
|
|
|
|
const layout = slotLayout(this.slotForSeat[askerSeat]);
|
|
|
|
|
sprite.setDepth(D.banner - 4);
|
|
|
|
|
|
|
|
|
|
const flyToHand = () => {
|
|
|
|
|
this.tweens.add({
|
|
|
|
|
targets: sprite,
|
|
|
|
|
x: layout.handCenter.x, y: layout.handCenter.y,
|
|
|
|
|
rotation: 0,
|
|
|
|
|
alpha: 0,
|
|
|
|
|
duration: 400,
|
|
|
|
|
ease: 'Cubic.easeIn',
|
|
|
|
|
onComplete: () => { if (sprite.active) sprite.destroy(); },
|
|
|
|
|
});
|
|
|
|
|
this.time.delayedCall(450, 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(); },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -545,6 +1089,10 @@ export default class GoFishGame extends Phaser.Scene {
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
|