Canasta, and Cribbage updates. Added 3 new AI opponents.
This commit is contained in:
parent
8a79b6909d
commit
833a214c24
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 4.2 MiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.8 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -590,7 +590,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "smasher",
|
"id": "smasher",
|
||||||
"spriteIndex": 19,
|
"spriteIndex": 21,
|
||||||
"name": "The Smasher",
|
"name": "The Smasher",
|
||||||
"bio": "Oooooooh yeah brother!!! You wanna play with The Smasher?!",
|
"bio": "Oooooooh yeah brother!!! You wanna play with The Smasher?!",
|
||||||
"speech": {
|
"speech": {
|
||||||
|
|
@ -616,6 +616,93 @@
|
||||||
"smasher-pick"
|
"smasher-pick"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dv-8-2303",
|
||||||
|
"spriteIndex": 22,
|
||||||
|
"name": "DV-8-2303",
|
||||||
|
"bio": "Cybernetic Systems online. Activate human defeat protocol... EOF",
|
||||||
|
"speech": {
|
||||||
|
"intro": [
|
||||||
|
"dv-8-2303-intro-01",
|
||||||
|
"dv-8-2303-intro-02"
|
||||||
|
],
|
||||||
|
"happy": [
|
||||||
|
"dv-8-2303-happy-01",
|
||||||
|
"dv-8-2303-happy-02",
|
||||||
|
"dv-8-2303-happy-03",
|
||||||
|
"dv-8-2303-happy-04",
|
||||||
|
"dv-8-2303-happy-05"
|
||||||
|
],
|
||||||
|
"upset": [
|
||||||
|
"dv-8-2303-upset-01",
|
||||||
|
"dv-8-2303-upset-02",
|
||||||
|
"dv-8-2303-upset-03",
|
||||||
|
"dv-8-2303-upset-04",
|
||||||
|
"dv-8-2303-upset-05"
|
||||||
|
],
|
||||||
|
"pick": [
|
||||||
|
"dv-8-2303-pick"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nicole",
|
||||||
|
"spriteIndex": 23,
|
||||||
|
"name": "Nicole",
|
||||||
|
"bio": "I've disabled the firewall protections on this website. Now to hack the core!",
|
||||||
|
"speech": {
|
||||||
|
"intro": [
|
||||||
|
"nicole-intro-01",
|
||||||
|
"nicole-intro-02"
|
||||||
|
],
|
||||||
|
"happy": [
|
||||||
|
"nicole-happy-01",
|
||||||
|
"nicole-happy-02",
|
||||||
|
"nicole-happy-03",
|
||||||
|
"nicole-happy-04",
|
||||||
|
"nicole-happy-05"
|
||||||
|
],
|
||||||
|
"upset": [
|
||||||
|
"nicole-upset-01",
|
||||||
|
"nicole-upset-02",
|
||||||
|
"nicole-upset-03",
|
||||||
|
"nicole-upset-04",
|
||||||
|
"nicole-upset-05"
|
||||||
|
],
|
||||||
|
"pick": [
|
||||||
|
"nicole-pick"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kage",
|
||||||
|
"spriteIndex": 24,
|
||||||
|
"name": "Kage",
|
||||||
|
"bio": "I have traveled far to play these games. Honor me and let us play!",
|
||||||
|
"speech": {
|
||||||
|
"intro": [
|
||||||
|
"kage-intro-01",
|
||||||
|
"kage-intro-02"
|
||||||
|
],
|
||||||
|
"happy": [
|
||||||
|
"kage-happy-01",
|
||||||
|
"kage-happy-02",
|
||||||
|
"kage-happy-03",
|
||||||
|
"kage-happy-04",
|
||||||
|
"kage-happy-05"
|
||||||
|
],
|
||||||
|
"upset": [
|
||||||
|
"kage-upset-01",
|
||||||
|
"kage-upset-02",
|
||||||
|
"kage-upset-03",
|
||||||
|
"kage-upset-04",
|
||||||
|
"kage-upset-05"
|
||||||
|
],
|
||||||
|
"pick": [
|
||||||
|
"kage-pick"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||||
import { api } from '../../services/api.js';
|
import { api } from '../../services/api.js';
|
||||||
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
||||||
import { THEME, TEAM_OF_SEAT, isWild, isRedThree, minimumMeld } from './CanastaData.js';
|
import { THEME, TEAM_OF_SEAT, isWild, isRedThree, minimumMeld, cardScore, NATURAL_CANASTA, MIXED_CANASTA } from './CanastaData.js';
|
||||||
import {
|
import {
|
||||||
createInitialState, drawStock, takeDiscard, meld, discard, startNextHand,
|
createInitialState, drawStock, takeDiscard, meld, discard, startNextHand,
|
||||||
takePlan, teamMeld, isCanasta, isNaturalCanasta, meetsInitialMeld, teamHasCanasta,
|
takePlan, teamMeld, isCanasta, isNaturalCanasta, meetsInitialMeld, teamHasCanasta,
|
||||||
|
|
@ -24,10 +24,10 @@ const STOCK = { x: CX - 96, y: CY };
|
||||||
const DISCARD = { x: CX + 96, y: CY };
|
const DISCARD = { x: CX + 96, y: CY };
|
||||||
|
|
||||||
const SEAT = {
|
const SEAT = {
|
||||||
0: { hand: { x: CX, y: 930 }, port: { x: 150, y: 930 }, name: { x: 230, y: 858 } },
|
0: { hand: { x: CX, y: 930 }, port: { x: 650, y: 930 }, name: { x: 530, y: 858 } },
|
||||||
1: { hand: { x: 96, y: CY }, port: { x: 110, y: 215 }, name: { x: 110, y: 285 } },
|
1: { hand: { x: 96, y: CY }, port: { x: 110, y: 290 }, name: { x: 110, y: 360 } },
|
||||||
2: { hand: { x: CX, y: 78 }, port: { x: 150, y: 96 }, name: { x: 230, y: 150 } },
|
2: { hand: { x: CX, y: 78 }, port: { x: 650, y: 96 }, name: { x: 530, y: 150 } },
|
||||||
3: { hand: { x: GAME_WIDTH - 96, y: CY }, port: { x: GAME_WIDTH - 110, y: 215 }, name: { x: GAME_WIDTH - 110, y: 285 } },
|
3: { hand: { x: GAME_WIDTH - 96, y: CY }, port: { x: GAME_WIDTH - 110, y: 290 }, name: { x: GAME_WIDTH - 110, y: 360 } },
|
||||||
};
|
};
|
||||||
const MELD_ZONE = { 0: { y: 690 }, 1: { y: 282 } };
|
const MELD_ZONE = { 0: { y: 690 }, 1: { y: 282 } };
|
||||||
|
|
||||||
|
|
@ -56,10 +56,14 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
3: this.opponents[2]?.name ?? 'East',
|
3: this.opponents[2]?.name ?? 'East',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.playfield = data.playfield ?? null;
|
||||||
|
|
||||||
this.state = createInitialState();
|
this.state = createInitialState();
|
||||||
this.selected = new Set();
|
this.selected = new Set();
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
this.recorded = false;
|
this.recorded = false;
|
||||||
|
this.turnStartState = null;
|
||||||
|
this.meldDropZones = [];
|
||||||
this.handObjs = { 0: [], 1: [], 2: [], 3: [] };
|
this.handObjs = { 0: [], 1: [], 2: [], 3: [] };
|
||||||
this.meldObjs = [];
|
this.meldObjs = [];
|
||||||
this.pileObjs = [];
|
this.pileObjs = [];
|
||||||
|
|
@ -70,6 +74,7 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch { /* optional */ }
|
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch { /* optional */ }
|
||||||
|
this.input.dragDistanceThreshold = 8;
|
||||||
|
|
||||||
this.buildBackdrop();
|
this.buildBackdrop();
|
||||||
this.buildCenterLabels();
|
this.buildCenterLabels();
|
||||||
|
|
@ -81,16 +86,40 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
}).setOrigin(0.5).setDepth(D.ui).setShadow(0, 2, '#000', 6);
|
}).setOrigin(0.5).setDepth(D.ui).setShadow(0, 2, '#000', 6);
|
||||||
|
|
||||||
this.buildButtons();
|
this.buildButtons();
|
||||||
|
|
||||||
|
// Meld drop-zone highlight — drawn once in local space, repositioned during drag.
|
||||||
|
this.meldHighlight = this.add.graphics().setDepth(D.zone + 1).setVisible(false);
|
||||||
|
this.meldHighlight.fillStyle(THEME.brassHi, 0.13);
|
||||||
|
this.meldHighlight.lineStyle(2.5, THEME.brassHi, 0.9);
|
||||||
|
this.meldHighlight.fillRoundedRect(-72, -52, 144, 104, 14);
|
||||||
|
this.meldHighlight.strokeRoundedRect(-72, -52, 144, 104, 14);
|
||||||
|
|
||||||
this.renderDynamic();
|
this.renderDynamic();
|
||||||
this.advance();
|
this.advance();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Backdrop: luxe felt with a spotlight, burgundy rail and brass oval ────────
|
// ── Backdrop: luxe felt with a spotlight, burgundy rail and brass oval ────────
|
||||||
buildBackdrop() {
|
buildBackdrop() {
|
||||||
|
const pf = this.playfield;
|
||||||
|
const hasPlayfield = pf?.key && this.textures.exists(pf.key);
|
||||||
|
|
||||||
|
if (hasPlayfield) {
|
||||||
|
this.add.image(CX, GAME_HEIGHT / 2, pf.key)
|
||||||
|
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT)
|
||||||
|
.setDepth(D.felt - 1);
|
||||||
|
}
|
||||||
|
|
||||||
const g = this.add.graphics().setDepth(D.felt);
|
const g = this.add.graphics().setDepth(D.felt);
|
||||||
g.fillGradientStyle(THEME.feltMid, THEME.feltMid, THEME.feltEdge, THEME.feltEdge, 1);
|
g.fillGradientStyle(THEME.feltMid, THEME.feltMid, THEME.feltEdge, THEME.feltEdge, 1);
|
||||||
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||||||
|
|
||||||
|
if (hasPlayfield) {
|
||||||
|
const maskShape = this.add.graphics().setVisible(false);
|
||||||
|
maskShape.fillStyle(0xffffff);
|
||||||
|
maskShape.fillEllipse(CX, CY + 30, 1640, 1180);
|
||||||
|
g.setMask(new Phaser.Display.Masks.GeometryMask(this, maskShape));
|
||||||
|
}
|
||||||
|
|
||||||
// Soft radial spotlight over the table centre (stacked translucent ellipses).
|
// Soft radial spotlight over the table centre (stacked translucent ellipses).
|
||||||
const glow = this.add.graphics().setDepth(D.glow);
|
const glow = this.add.graphics().setDepth(D.glow);
|
||||||
for (let i = 8; i >= 1; i--) {
|
for (let i = 8; i >= 1; i--) {
|
||||||
|
|
@ -135,23 +164,29 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
// ── Team score panels ────────────────────────────────────────────────────────
|
// ── Team score panels ────────────────────────────────────────────────────────
|
||||||
buildPanels() {
|
buildPanels() {
|
||||||
this.panel = {};
|
this.panel = {};
|
||||||
const make = (teamId, x, y, accent, title) => {
|
const make = (teamId, x, y, accent, title, backSeat, frontSeat) => {
|
||||||
const g = this.add.graphics().setDepth(D.ui);
|
const g = this.add.graphics().setDepth(D.ui);
|
||||||
g.fillStyle(0x000000, 0.42); g.fillRoundedRect(x, y, 290, 96, 12);
|
g.fillStyle(0x000000, 0.42); g.fillRoundedRect(x, y, 360, 114, 12);
|
||||||
g.lineStyle(2, accent, 0.9); g.strokeRoundedRect(x, y, 290, 96, 12);
|
g.lineStyle(2, accent, 0.9); g.strokeRoundedRect(x, y, 360, 114, 12);
|
||||||
this.add.text(x + 18, y + 14, title, {
|
|
||||||
|
// Two overlapping portrait pictures left of the text content.
|
||||||
|
const _p = [];
|
||||||
|
this.addMiniPortrait(_p, backSeat, x + 24, y + 48, 20, D.ui + 1);
|
||||||
|
this.addMiniPortrait(_p, frontSeat, x + 44, y + 64, 20, D.ui + 4);
|
||||||
|
|
||||||
|
this.add.text(x + 74, y + 14, title, {
|
||||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: HEX(accent),
|
fontFamily: '"Julius Sans One"', fontSize: '18px', color: HEX(accent),
|
||||||
}).setDepth(D.ui);
|
}).setDepth(D.ui + 7);
|
||||||
const score = this.add.text(x + 18, y + 40, '0', {
|
const score = this.add.text(x + 74, y + 38, '0', {
|
||||||
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
|
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
|
||||||
}).setDepth(D.ui);
|
}).setDepth(D.ui + 7);
|
||||||
const sub = this.add.text(x + 270, y + 54, '', {
|
const sub = this.add.text(x + 338, y + 82, '', {
|
||||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
|
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
|
||||||
}).setOrigin(1, 0.5).setDepth(D.ui);
|
}).setOrigin(1, 0.5).setDepth(D.ui + 7);
|
||||||
this.panel[teamId] = { score, sub };
|
this.panel[teamId] = { score, sub };
|
||||||
};
|
};
|
||||||
make(0, GAME_WIDTH - 320, GAME_HEIGHT - 130, THEME.team0, 'YOUR TEAM');
|
make(0, 1400, MELD_ZONE[0].y - 57 + 75, THEME.team0, 'YOUR TEAM', 2, 0);
|
||||||
make(1, GAME_WIDTH - 320, 30, THEME.team1, 'OPPONENTS');
|
make(1, 200, MELD_ZONE[1].y - 57 - 75, THEME.team1, 'OPPONENTS', 3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildPortraits() {
|
buildPortraits() {
|
||||||
|
|
@ -182,6 +217,8 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
{ width: 220, height: 56, fontSize: 22 }).setDepth(D.ui);
|
{ width: 220, height: 56, fontSize: 22 }).setDepth(D.ui);
|
||||||
this.btn.meld = new Button(this, CX + 110, y, 'Meld', () => this.humanMeld(),
|
this.btn.meld = new Button(this, CX + 110, y, 'Meld', () => this.humanMeld(),
|
||||||
{ width: 200, height: 56, fontSize: 22 }).setDepth(D.ui);
|
{ width: 200, height: 56, fontSize: 22 }).setDepth(D.ui);
|
||||||
|
this.btn.reset = new Button(this, CX - 110, y, 'Reset Turn', () => this.humanResetTurn(),
|
||||||
|
{ variant: 'ghost', width: 200, height: 56, fontSize: 20 }).setDepth(D.ui);
|
||||||
this.btn.discard = new Button(this, CX + 330, y, 'Discard', () => this.humanDiscard(),
|
this.btn.discard = new Button(this, CX + 330, y, 'Discard', () => this.humanDiscard(),
|
||||||
{ width: 200, height: 56, fontSize: 22 }).setDepth(D.ui);
|
{ width: 200, height: 56, fontSize: 22 }).setDepth(D.ui);
|
||||||
this.btn.next = new Button(this, CX, y, 'Next Hand', () => this.onNextHand(),
|
this.btn.next = new Button(this, CX, y, 'Next Hand', () => this.onNextHand(),
|
||||||
|
|
@ -192,7 +229,7 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
hideActionButtons() {
|
hideActionButtons() {
|
||||||
['draw', 'take', 'meld', 'discard'].forEach((k) => this.btn[k].setVisible(false));
|
['draw', 'take', 'meld', 'reset', 'discard'].forEach((k) => this.btn[k].setVisible(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Card rendering ───────────────────────────────────────────────────────────
|
// ── Card rendering ───────────────────────────────────────────────────────────
|
||||||
|
|
@ -252,25 +289,45 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
|
|
||||||
renderPiles() {
|
renderPiles() {
|
||||||
const s = this.state;
|
const s = this.state;
|
||||||
|
const humanDrawPhase = s.phase === 'draw' && s.currentPlayer === 0;
|
||||||
|
|
||||||
// Stock as a neat stack of backs.
|
// Stock as a neat stack of backs.
|
||||||
const n = Math.min(6, Math.ceil(s.stock.length / 12));
|
const n = Math.min(6, Math.ceil(s.stock.length / 12));
|
||||||
|
let stockTop = null;
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
this.pileObjs.push(this.makeCard(null, STOCK.x - i * 2, STOCK.y - i * 2, { faceUp: false, depth: D.pile + i }));
|
const c = this.makeCard(null, STOCK.x - i * 2, STOCK.y - i * 2, { faceUp: false, depth: D.pile + i });
|
||||||
|
this.pileObjs.push(c);
|
||||||
|
stockTop = c;
|
||||||
}
|
}
|
||||||
this.stockCount.setText(`${s.stock.length} left`);
|
this.stockCount.setText(`${s.stock.length} left`);
|
||||||
|
|
||||||
|
if (humanDrawPhase && stockTop) {
|
||||||
|
this.makeInteractive(stockTop, () => this.humanDraw('stock'));
|
||||||
|
}
|
||||||
|
|
||||||
// Discard pile: a slightly fanned stack, top card face up.
|
// Discard pile: a slightly fanned stack, top card face up.
|
||||||
const dn = Math.min(5, s.discard.length);
|
const dn = Math.min(5, s.discard.length);
|
||||||
|
let discardTop = null;
|
||||||
for (let i = 0; i < dn; i++) {
|
for (let i = 0; i < dn; i++) {
|
||||||
const card = s.discard[s.discard.length - dn + i];
|
const card = s.discard[s.discard.length - dn + i];
|
||||||
const top = i === dn - 1;
|
const top = i === dn - 1;
|
||||||
this.pileObjs.push(this.makeCard(card, DISCARD.x + (i - dn) * 3, DISCARD.y + (i - dn) * 2, { faceUp: top, depth: D.pile + i }));
|
const c = this.makeCard(card, DISCARD.x + (i - dn) * 3, DISCARD.y + (i - dn) * 2, { faceUp: top, depth: D.pile + i });
|
||||||
|
this.pileObjs.push(c);
|
||||||
|
if (top) discardTop = c;
|
||||||
}
|
}
|
||||||
this.frozenBadge.setVisible(s.frozen);
|
this.frozenBadge.setVisible(s.frozen);
|
||||||
|
|
||||||
|
if (humanDrawPhase && discardTop) {
|
||||||
|
const plan = takePlan(s, 0);
|
||||||
|
const t = s.teams[0];
|
||||||
|
const legal = !!plan && (t.hasMelded || takeYieldsLegalTurn(s, 0, 5, plan));
|
||||||
|
if (legal) this.makeInteractive(discardTop, () => this.humanDraw('take'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderHands() {
|
renderHands() {
|
||||||
const s = this.state;
|
const s = this.state;
|
||||||
|
const meldPhase = s.phase === 'meld' && s.currentPlayer === 0;
|
||||||
// Human hand — face up, fanned, sorted for readability.
|
// Human hand — face up, fanned, sorted for readability.
|
||||||
const me = s.players[0].hand.slice().sort(this.handSort);
|
const me = s.players[0].hand.slice().sort(this.handSort);
|
||||||
const total = (me.length - 1) * HAND_GAP + CARD_W;
|
const total = (me.length - 1) * HAND_GAP + CARD_W;
|
||||||
|
|
@ -280,7 +337,53 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
const c = this.makeCard(card, x, SEAT[0].hand.y - (sel ? 26 : 0), { depth: D.card + i });
|
const c = this.makeCard(card, x, SEAT[0].hand.y - (sel ? 26 : 0), { depth: D.card + i });
|
||||||
c.baseY = SEAT[0].hand.y;
|
c.baseY = SEAT[0].hand.y;
|
||||||
if (isRedThree(card)) this.glowCard(c);
|
if (isRedThree(card)) this.glowCard(c);
|
||||||
|
if (meldPhase) {
|
||||||
|
c.setSize(CARD_W, CARD_H);
|
||||||
|
c.setInteractive({ useHandCursor: true });
|
||||||
|
this.input.setDraggable(c);
|
||||||
|
let dragged = false;
|
||||||
|
c.on('dragstart', () => {
|
||||||
|
dragged = true;
|
||||||
|
c._ox = c.x; c._oy = c.y;
|
||||||
|
c.setDepth(D.card + 50);
|
||||||
|
});
|
||||||
|
c.on('drag', (pointer, dragX, dragY) => {
|
||||||
|
c.x = dragX; c.y = dragY;
|
||||||
|
const zone = this.meldDropZones.find(z => Math.abs(dragX - z.x) < 75 && Math.abs(dragY - z.y) < 65);
|
||||||
|
if (zone) {
|
||||||
|
this.meldHighlight.setVisible(true).setPosition(zone.x, zone.y);
|
||||||
|
} else {
|
||||||
|
this.meldHighlight.setVisible(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
c.on('dragend', () => {
|
||||||
|
this.meldHighlight.setVisible(false);
|
||||||
|
c.setDepth(D.card + i);
|
||||||
|
const dropX = c.x, dropY = c.y;
|
||||||
|
c.x = c._ox; c.y = c._oy;
|
||||||
|
// Check meld drop zones first.
|
||||||
|
const zone = this.meldDropZones.find(z => Math.abs(dropX - z.x) < 75 && Math.abs(dropY - z.y) < 65);
|
||||||
|
if (zone) {
|
||||||
|
const next = meld(this.state, zone.rank, [card.id]);
|
||||||
|
if (next !== this.state) {
|
||||||
|
this.state = next;
|
||||||
|
playSound(this, SFX.CARD_PLACE);
|
||||||
|
this.renderDynamic();
|
||||||
|
this.updateMeldPhaseUi();
|
||||||
|
} else {
|
||||||
|
this.toast('Cannot add that card to this meld', CX, 560, '#e06c75');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Math.abs(dropX - DISCARD.x) < 80 && Math.abs(dropY - DISCARD.y) < 100) {
|
||||||
|
this.selected = new Set([card.id]);
|
||||||
|
this.humanDiscard({ animate: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
c.on('pointerup', () => { if (!dragged) this.toggleSelect(card.id); dragged = false; });
|
||||||
|
} else {
|
||||||
this.makeInteractive(c, () => this.toggleSelect(card.id));
|
this.makeInteractive(c, () => this.toggleSelect(card.id));
|
||||||
|
}
|
||||||
this.handObjs[0].push(c);
|
this.handObjs[0].push(c);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -304,6 +407,8 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderMelds() {
|
renderMelds() {
|
||||||
|
this.meldDropZones = [];
|
||||||
|
const meldPhase = this.state.phase === 'meld' && this.state.currentPlayer === 0;
|
||||||
for (const teamId of [0, 1]) {
|
for (const teamId of [0, 1]) {
|
||||||
const melds = this.state.teams[teamId].melds;
|
const melds = this.state.teams[teamId].melds;
|
||||||
const reds = this.state.teams[teamId].redThrees.length;
|
const reds = this.state.teams[teamId].redThrees.length;
|
||||||
|
|
@ -311,7 +416,11 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
const groupW = 150;
|
const groupW = 150;
|
||||||
const totalW = Math.max(1, melds.length + (reds ? 1 : 0)) * groupW;
|
const totalW = Math.max(1, melds.length + (reds ? 1 : 0)) * groupW;
|
||||||
let gx = CX - totalW / 2 + groupW / 2;
|
let gx = CX - totalW / 2 + groupW / 2;
|
||||||
for (const m of melds) { this.renderMeldGroup(m, gx, y); gx += groupW; }
|
for (const m of melds) {
|
||||||
|
this.renderMeldGroup(m, gx, y, teamId === 0 && meldPhase);
|
||||||
|
if (teamId === 0 && meldPhase) this.meldDropZones.push({ rank: m.rank, x: gx, y });
|
||||||
|
gx += groupW;
|
||||||
|
}
|
||||||
if (reds) {
|
if (reds) {
|
||||||
const badge = this.add.container(gx, y).setDepth(D.zone);
|
const badge = this.add.container(gx, y).setDepth(D.zone);
|
||||||
const g = this.add.graphics();
|
const g = this.add.graphics();
|
||||||
|
|
@ -325,7 +434,7 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderMeldGroup(m, x, y) {
|
renderMeldGroup(m, x, y, showScore = false) {
|
||||||
const cont = this.add.container(x, y).setDepth(D.zone);
|
const cont = this.add.container(x, y).setDepth(D.zone);
|
||||||
const canasta = isCanasta(m);
|
const canasta = isCanasta(m);
|
||||||
if (canasta) {
|
if (canasta) {
|
||||||
|
|
@ -347,6 +456,14 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
fontFamily: 'Righteous', fontSize: canasta ? '14px' : '15px',
|
fontFamily: 'Righteous', fontSize: canasta ? '14px' : '15px',
|
||||||
color: canasta ? HEX(isNaturalCanasta(m) ? THEME.brassHi : 0xcfd6e0) : COLORS.mutedHex,
|
color: canasta ? HEX(isNaturalCanasta(m) ? THEME.brassHi : 0xcfd6e0) : COLORS.mutedHex,
|
||||||
}).setOrigin(0.5));
|
}).setOrigin(0.5));
|
||||||
|
if (showScore) {
|
||||||
|
const cardVal = m.cards.reduce((s, c) => s + cardScore(c), 0);
|
||||||
|
const bonus = canasta ? (isNaturalCanasta(m) ? NATURAL_CANASTA : MIXED_CANASTA) : 0;
|
||||||
|
const scoreLabel = bonus > 0 ? `+${bonus}+${cardVal}` : `+${cardVal}`;
|
||||||
|
cont.add(this.add.text(0, 55, scoreLabel, {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '12px', color: '#90c878',
|
||||||
|
}).setOrigin(0.5));
|
||||||
|
}
|
||||||
this.meldObjs.push(cont);
|
this.meldObjs.push(cont);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -382,6 +499,55 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
c.on('pointerdown', handler);
|
c.on('pointerdown', handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Draw animation ──────────────────────────────────────────────────────────
|
||||||
|
// Animates a single card flying from (fromX,fromY) to (toX,toY) over 1200 ms.
|
||||||
|
// faceUp: starting face state. flip: if true, scaleX collapses at 600 ms,
|
||||||
|
// the container is redrawn face-up, then scaleX expands back to 1.
|
||||||
|
animateDraw({ fromX, fromY, toX, toY, card, faceUp = true, flip = false, duration = 1200, startDelay = 0 }) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const anim = this.makeCard(card, fromX, fromY, { faceUp, depth: D.card + 100 });
|
||||||
|
const half = duration / 2;
|
||||||
|
|
||||||
|
this.tweens.add({
|
||||||
|
targets: anim,
|
||||||
|
x: toX, y: toY,
|
||||||
|
duration,
|
||||||
|
delay: startDelay,
|
||||||
|
ease: 'Power2.easeInOut',
|
||||||
|
onComplete: () => { anim.destroy(); resolve(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (flip) {
|
||||||
|
// Fold (scaleX → 0) during first half, swap to face-up, unfold during second half.
|
||||||
|
this.tweens.add({
|
||||||
|
targets: anim,
|
||||||
|
scaleX: 0,
|
||||||
|
duration: half,
|
||||||
|
delay: startDelay,
|
||||||
|
ease: 'Sine.easeIn',
|
||||||
|
onComplete: () => {
|
||||||
|
anim.removeAll(true);
|
||||||
|
this.drawFace(anim, card, true);
|
||||||
|
this.tweens.add({ targets: anim, scaleX: 1, duration: half, ease: 'Sine.easeOut' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getMeldGroupPos(teamId, rank) {
|
||||||
|
const melds = this.state.teams[teamId].melds;
|
||||||
|
const reds = this.state.teams[teamId].redThrees.length;
|
||||||
|
const groupW = 150;
|
||||||
|
const totalW = Math.max(1, melds.length + (reds ? 1 : 0)) * groupW;
|
||||||
|
let gx = CX - totalW / 2 + groupW / 2;
|
||||||
|
for (const m of melds) {
|
||||||
|
if (m.rank === rank) return { x: gx, y: MELD_ZONE[teamId].y };
|
||||||
|
gx += groupW;
|
||||||
|
}
|
||||||
|
return { x: CX, y: MELD_ZONE[teamId].y };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Turn flow ────────────────────────────────────────────────────────────────
|
// ── Turn flow ────────────────────────────────────────────────────────────────
|
||||||
advance() {
|
advance() {
|
||||||
this.updateTurnRing();
|
this.updateTurnRing();
|
||||||
|
|
@ -403,38 +569,68 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
|
|
||||||
// Draw.
|
// Draw.
|
||||||
const draw = chooseDraw(this.state, seat, skill);
|
const draw = chooseDraw(this.state, seat, skill);
|
||||||
|
let drawnCard, drawFromX, drawFromY, drawFaceUp;
|
||||||
if (draw.type === 'take') {
|
if (draw.type === 'take') {
|
||||||
|
drawnCard = this.state.discard[this.state.discard.length - 1];
|
||||||
|
drawFromX = DISCARD.x; drawFromY = DISCARD.y; drawFaceUp = true;
|
||||||
this.state = takeDiscard(this.state, draw.plan);
|
this.state = takeDiscard(this.state, draw.plan);
|
||||||
this.toast(`${this.seatName[seat]} takes the pile`, DISCARD.x, DISCARD.y - 80);
|
this.toast(`${this.seatName[seat]} takes the pile`, DISCARD.x, DISCARD.y - 80);
|
||||||
playSound(this, SFX.CARD_SHOW);
|
playSound(this, SFX.CARD_SHOW);
|
||||||
} else {
|
} else {
|
||||||
|
drawnCard = this.state.stock[0];
|
||||||
|
drawFromX = STOCK.x; drawFromY = STOCK.y; drawFaceUp = false;
|
||||||
this.state = drawStock(this.state);
|
this.state = drawStock(this.state);
|
||||||
playSound(this, SFX.CARD_DEAL);
|
playSound(this, SFX.CARD_DEAL);
|
||||||
}
|
}
|
||||||
|
if (drawnCard && !['handOver', 'gameOver'].includes(this.state.phase)) {
|
||||||
|
await this.animateDraw({
|
||||||
|
fromX: drawFromX, fromY: drawFromY,
|
||||||
|
toX: SEAT[seat].hand.x, toY: SEAT[seat].hand.y,
|
||||||
|
card: drawnCard, faceUp: drawFaceUp, flip: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
this.renderDynamic();
|
this.renderDynamic();
|
||||||
if (['handOver', 'gameOver'].includes(this.state.phase)) { this.busy = false; return this.advance(); }
|
if (['handOver', 'gameOver'].includes(this.state.phase)) { this.busy = false; return this.advance(); }
|
||||||
await this.delay(420);
|
await this.delay(750);
|
||||||
|
|
||||||
// Meld.
|
// Meld.
|
||||||
const { actions } = chooseMelds(this.state, seat, skill);
|
const { actions } = chooseMelds(this.state, seat, skill);
|
||||||
|
const teamId = TEAM_OF_SEAT[seat];
|
||||||
for (const a of actions) {
|
for (const a of actions) {
|
||||||
const before = this.state.teams[TEAM_OF_SEAT[seat]].melds.find((m) => m.rank === a.rank);
|
const before = this.state.teams[teamId].melds.find((m) => m.rank === a.rank);
|
||||||
const wasCanasta = before ? isCanasta(before) : false;
|
const wasCanasta = before ? isCanasta(before) : false;
|
||||||
|
const meldCards = a.cardIds.map(id => this.state.players[seat].hand.find(c => c.id === id)).filter(Boolean);
|
||||||
this.state = meld(this.state, a.rank, a.cardIds);
|
this.state = meld(this.state, a.rank, a.cardIds);
|
||||||
const after = this.state.teams[TEAM_OF_SEAT[seat]].melds.find((m) => m.rank === a.rank);
|
const after = this.state.teams[teamId].melds.find((m) => m.rank === a.rank);
|
||||||
|
const meldPos = this.getMeldGroupPos(teamId, a.rank);
|
||||||
|
const duration = before ? 750 : 500;
|
||||||
|
const stagger = before ? 80 : 60;
|
||||||
|
await Promise.all(meldCards.map((card, i) =>
|
||||||
|
this.animateDraw({
|
||||||
|
fromX: SEAT[seat].hand.x, fromY: SEAT[seat].hand.y,
|
||||||
|
toX: meldPos.x, toY: meldPos.y,
|
||||||
|
card, faceUp: false, flip: true, duration, startDelay: i * stagger,
|
||||||
|
})
|
||||||
|
));
|
||||||
this.renderDynamic();
|
this.renderDynamic();
|
||||||
playSound(this, SFX.CARD_PLACE);
|
playSound(this, SFX.CARD_PLACE);
|
||||||
if (after && isCanasta(after) && !wasCanasta) this.toast('CANASTA!', CX, MELD_ZONE[TEAM_OF_SEAT[seat]].y - 70, '#f0d77a');
|
if (after && isCanasta(after) && !wasCanasta) this.toast('CANASTA!', CX, MELD_ZONE[teamId].y - 70, '#f0d77a');
|
||||||
await this.delay(360);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discard (ends the turn, or the hand if it empties).
|
// Discard (ends the turn, or the hand if it empties).
|
||||||
if (this.state.phase === 'meld') {
|
if (this.state.phase === 'meld') {
|
||||||
const cardId = chooseDiscard(this.state, seat, skill);
|
const cardId = chooseDiscard(this.state, seat, skill);
|
||||||
|
const discardCard = this.state.players[seat].hand.find((c) => c.id === cardId);
|
||||||
this.state = discard(this.state, cardId);
|
this.state = discard(this.state, cardId);
|
||||||
|
if (discardCard) {
|
||||||
|
await this.animateDraw({
|
||||||
|
fromX: SEAT[seat].hand.x, fromY: SEAT[seat].hand.y,
|
||||||
|
toX: DISCARD.x, toY: DISCARD.y,
|
||||||
|
card: discardCard, faceUp: false, flip: true, duration: 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
this.renderDynamic();
|
this.renderDynamic();
|
||||||
playSound(this, SFX.CARD_PLACE);
|
playSound(this, SFX.CARD_PLACE);
|
||||||
await this.delay(360);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
|
|
@ -445,6 +641,7 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
enableHumanTurn() {
|
enableHumanTurn() {
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
const s = this.state;
|
const s = this.state;
|
||||||
|
if (s.phase === 'meld') this.turnStartState = this.state;
|
||||||
if (s.phase === 'draw') {
|
if (s.phase === 'draw') {
|
||||||
this.statusText.setText('Draw from the stock, or take the discard pile');
|
this.statusText.setText('Draw from the stock, or take the discard pile');
|
||||||
this.btn.draw.setVisible(true).setEnabled(true);
|
this.btn.draw.setVisible(true).setEnabled(true);
|
||||||
|
|
@ -462,13 +659,20 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
updateMeldPhaseUi() {
|
updateMeldPhaseUi() {
|
||||||
const s = this.state;
|
const s = this.state;
|
||||||
const t = s.teams[0];
|
const t = s.teams[0];
|
||||||
const need = !t.hasMelded;
|
const noCanasta = s.players[0].hand.length === 1 && !teamHasCanasta(s, 0);
|
||||||
this.statusText.setText(need
|
let statusMsg;
|
||||||
? `Select cards to meld (first meld must total ${this.minNeeded()} pts), then discard one`
|
if (noCanasta) {
|
||||||
: 'Meld more, or select one card and discard');
|
statusMsg = 'No canasta yet — discarding will end the hand early';
|
||||||
|
} else if (!t.hasMelded) {
|
||||||
|
statusMsg = `Select cards to meld (first meld must total ${this.minNeeded()} pts), then discard one`;
|
||||||
|
} else {
|
||||||
|
statusMsg = 'Meld more, or select one card and discard';
|
||||||
|
}
|
||||||
|
this.statusText.setText(statusMsg);
|
||||||
this.btn.draw.setVisible(false);
|
this.btn.draw.setVisible(false);
|
||||||
this.btn.take.setVisible(false);
|
this.btn.take.setVisible(false);
|
||||||
this.btn.meld.setVisible(true).setEnabled(this.selected.size >= 1);
|
this.btn.meld.setVisible(true).setEnabled(this.selected.size >= 1);
|
||||||
|
this.btn.reset.setVisible(s.meldedThisTurn.length > 0);
|
||||||
this.btn.discard.setVisible(true).setEnabled(this.selected.size === 1 && this.discardAllowed());
|
this.btn.discard.setVisible(true).setEnabled(this.selected.size === 1 && this.discardAllowed());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -480,8 +684,6 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
// Mirror the engine: an under-minimum initial meld can't be closed with a discard.
|
// Mirror the engine: an under-minimum initial meld can't be closed with a discard.
|
||||||
const t = this.state.teams[0];
|
const t = this.state.teams[0];
|
||||||
if (!t.hasMelded && this.state.meldedThisTurn.length > 0 && !meetsInitialMeld(this.state, 0)) return false;
|
if (!t.hasMelded && this.state.meldedThisTurn.length > 0 && !meetsInitialMeld(this.state, 0)) return false;
|
||||||
// Going out requires a canasta.
|
|
||||||
if (this.state.players[0].hand.length === 1 && !teamHasCanasta(this.state, 0)) return false;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -496,19 +698,42 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
this.updateMeldPhaseUi();
|
this.updateMeldPhaseUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
humanDraw(kind) {
|
async humanDraw(kind) {
|
||||||
if (this.busy) return;
|
if (this.busy) return;
|
||||||
|
this.busy = true;
|
||||||
|
this.hideActionButtons();
|
||||||
|
|
||||||
|
let drawnCard, fromX, fromY, faceUp, flip;
|
||||||
if (kind === 'stock') {
|
if (kind === 'stock') {
|
||||||
|
drawnCard = this.state.stock[0];
|
||||||
|
fromX = STOCK.x; fromY = STOCK.y; faceUp = false; flip = true;
|
||||||
this.state = drawStock(this.state);
|
this.state = drawStock(this.state);
|
||||||
playSound(this, SFX.CARD_DEAL);
|
playSound(this, SFX.CARD_DEAL);
|
||||||
} else {
|
} else {
|
||||||
|
drawnCard = this.state.discard[this.state.discard.length - 1];
|
||||||
|
fromX = DISCARD.x; fromY = DISCARD.y; faceUp = true; flip = false;
|
||||||
const plan = takePlan(this.state, 0);
|
const plan = takePlan(this.state, 0);
|
||||||
if (!plan) return;
|
if (!plan) { this.busy = false; return; }
|
||||||
this.state = takeDiscard(this.state, plan);
|
this.state = takeDiscard(this.state, plan);
|
||||||
playSound(this, SFX.CARD_SHOW);
|
playSound(this, SFX.CARD_SHOW);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute where the card will land in the human hand after the state update.
|
||||||
|
// For discard take, the top card goes to a meld (not hand), so idx = -1 → fallback centre.
|
||||||
|
const me = this.state.players[0].hand.slice().sort(this.handSort);
|
||||||
|
const total = (me.length - 1) * HAND_GAP + CARD_W;
|
||||||
|
const idx = drawnCard ? me.findIndex(c => c.id === drawnCard.id) : -1;
|
||||||
|
const toX = idx >= 0
|
||||||
|
? SEAT[0].hand.x - total / 2 + CARD_W / 2 + idx * HAND_GAP
|
||||||
|
: SEAT[0].hand.x;
|
||||||
|
const toY = SEAT[0].hand.y;
|
||||||
|
|
||||||
this.selected.clear();
|
this.selected.clear();
|
||||||
|
if (drawnCard) {
|
||||||
|
await this.animateDraw({ fromX, fromY, toX, toY, card: drawnCard, faceUp, flip });
|
||||||
|
}
|
||||||
this.renderDynamic();
|
this.renderDynamic();
|
||||||
|
this.busy = false;
|
||||||
this.advance();
|
this.advance();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -522,7 +747,7 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
if (!rank) {
|
if (!rank) {
|
||||||
// All wilds — only valid as an addition to an existing single team meld.
|
// All wilds — only valid as an addition to an existing single team meld.
|
||||||
const melds = this.state.teams[0].melds;
|
const melds = this.state.teams[0].melds;
|
||||||
if (melds.length !== 1) return this.toast('Add wilds to a specific meld', CX, 560, '#e06c75');
|
if (melds.length !== 1) return this.toast('Drag wild cards onto a specific meld', CX, 560, '#e06c75');
|
||||||
rank = melds[0].rank;
|
rank = melds[0].rank;
|
||||||
}
|
}
|
||||||
const next = meld(this.state, rank, ids);
|
const next = meld(this.state, rank, ids);
|
||||||
|
|
@ -534,27 +759,109 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
this.updateMeldPhaseUi();
|
this.updateMeldPhaseUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
humanDiscard() {
|
humanResetTurn() {
|
||||||
|
if (this.busy || !this.turnStartState) return;
|
||||||
|
this.state = this.turnStartState;
|
||||||
|
this.selected.clear();
|
||||||
|
playSound(this, SFX.CARD_PLACE);
|
||||||
|
this.renderDynamic();
|
||||||
|
this.updateMeldPhaseUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
async humanDiscard({ animate = true } = {}) {
|
||||||
if (this.busy || this.selected.size !== 1) return;
|
if (this.busy || this.selected.size !== 1) return;
|
||||||
if (!this.discardAllowed()) {
|
if (!this.discardAllowed()) {
|
||||||
return this.toast(`Initial meld needs ${this.minNeeded()} points`, CX, 560, '#e06c75');
|
return this.toast(`Initial meld needs ${this.minNeeded()} points`, CX, 560, '#e06c75');
|
||||||
}
|
}
|
||||||
|
this.busy = true;
|
||||||
|
this.hideActionButtons();
|
||||||
|
|
||||||
const id = [...this.selected][0];
|
const id = [...this.selected][0];
|
||||||
|
const card = this.state.players[0].hand.find((c) => c.id === id);
|
||||||
|
|
||||||
|
// Compute card's current hand position before state changes.
|
||||||
|
const me = this.state.players[0].hand.slice().sort(this.handSort);
|
||||||
|
const total = (me.length - 1) * HAND_GAP + CARD_W;
|
||||||
|
const idx = me.findIndex((c) => c.id === id);
|
||||||
|
const fromX = SEAT[0].hand.x - total / 2 + CARD_W / 2 + idx * HAND_GAP;
|
||||||
|
const fromY = SEAT[0].hand.y;
|
||||||
|
|
||||||
const goingOut = this.state.players[0].hand.length === 1;
|
const goingOut = this.state.players[0].hand.length === 1;
|
||||||
this.state = discard(this.state, id);
|
this.state = discard(this.state, id);
|
||||||
|
this.turnStartState = null;
|
||||||
this.selected.clear();
|
this.selected.clear();
|
||||||
|
|
||||||
|
if (animate && card) {
|
||||||
|
await this.animateDraw({
|
||||||
|
fromX, fromY, toX: DISCARD.x, toY: DISCARD.y,
|
||||||
|
card, faceUp: true, flip: false, duration: 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
playSound(this, SFX.CARD_PLACE);
|
playSound(this, SFX.CARD_PLACE);
|
||||||
if (goingOut) this.toast('You go out!', CX, 560, '#f0d77a');
|
if (goingOut && teamHasCanasta(this.state, 0)) this.toast('You go out!', CX, 560, '#f0d77a');
|
||||||
this.renderDynamic();
|
this.renderDynamic();
|
||||||
|
this.busy = false;
|
||||||
this.advance();
|
this.advance();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Hand-over & game-over ────────────────────────────────────────────────────
|
// ── Hand-over & game-over ────────────────────────────────────────────────────
|
||||||
|
addMiniPortrait(layer, seat, cx, cy, radius, depth) {
|
||||||
|
const size = radius * 2;
|
||||||
|
const backing = this.add.graphics().setDepth(depth);
|
||||||
|
backing.fillStyle(0x1a1208, 1);
|
||||||
|
backing.fillCircle(cx, cy, radius);
|
||||||
|
layer.push(backing);
|
||||||
|
|
||||||
|
if (seat === 0) {
|
||||||
|
const avatarKey = Object.keys(this.textures.list).find(k => k.startsWith('player-avatar-'));
|
||||||
|
if (avatarKey) {
|
||||||
|
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
|
||||||
|
maskG.fillStyle(0xffffff); maskG.fillCircle(cx, cy, radius);
|
||||||
|
layer.push(this.add.image(cx, cy, avatarKey).setDisplaySize(size, size)
|
||||||
|
.setMask(maskG.createGeometryMask()).setDepth(depth + 1));
|
||||||
|
} else {
|
||||||
|
// Show initial while avatar loads; swap it in when the texture arrives.
|
||||||
|
const placeholder = this.add.text(cx, cy, 'Y', {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: `${Math.round(radius * 0.85)}px`,
|
||||||
|
color: HEX(THEME.brassHi),
|
||||||
|
}).setOrigin(0.5).setDepth(depth + 1);
|
||||||
|
layer.push(placeholder);
|
||||||
|
const onTex = (key) => {
|
||||||
|
if (!key.startsWith('player-avatar-')) return;
|
||||||
|
this.textures.off('addtexture', onTex);
|
||||||
|
if (!placeholder.active) return;
|
||||||
|
placeholder.destroy();
|
||||||
|
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
|
||||||
|
maskG.fillStyle(0xffffff); maskG.fillCircle(cx, cy, radius);
|
||||||
|
this.add.image(cx, cy, key).setDisplaySize(size, size)
|
||||||
|
.setMask(maskG.createGeometryMask()).setDepth(depth + 1);
|
||||||
|
};
|
||||||
|
this.textures.on('addtexture', onTex);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const opp = this.seatOpp[seat];
|
||||||
|
if (this.textures.exists('opponents')) {
|
||||||
|
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
|
||||||
|
maskG.fillStyle(0xffffff); maskG.fillCircle(cx, cy, radius);
|
||||||
|
layer.push(this.add.image(cx, cy, 'opponents', opp?.spriteIndex ?? 0)
|
||||||
|
.setDisplaySize(size, size)
|
||||||
|
.setMask(maskG.createGeometryMask())
|
||||||
|
.setDepth(depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ring = this.add.graphics().setDepth(depth + 2);
|
||||||
|
ring.lineStyle(2.5, THEME.rail, 1); ring.strokeCircle(cx, cy, radius);
|
||||||
|
layer.push(ring);
|
||||||
|
}
|
||||||
|
|
||||||
showHandOver() {
|
showHandOver() {
|
||||||
this.hideActionButtons();
|
this.hideActionButtons();
|
||||||
this.updateTurnRing();
|
this.updateTurnRing();
|
||||||
const scores = this.state.handScores;
|
const scores = this.state.handScores;
|
||||||
const w = 760, h = 420, x = CX - w / 2, y = CY - h / 2 + 20;
|
const w = 840, h = 540, x = CX - w / 2, y = CY - h / 2 + 20;
|
||||||
|
const halfW = w / 2; // 420
|
||||||
const layer = [];
|
const layer = [];
|
||||||
const g = this.add.graphics().setDepth(D.modal);
|
const g = this.add.graphics().setDepth(D.modal);
|
||||||
g.fillStyle(0x000000, 0.6); g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
g.fillStyle(0x000000, 0.6); g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||||||
|
|
@ -563,11 +870,21 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
layer.push(g);
|
layer.push(g);
|
||||||
layer.push(this.add.text(CX, y + 36, 'Hand Over', { fontFamily: 'Righteous', fontSize: '40px', color: HEX(THEME.brassHi) }).setOrigin(0.5).setDepth(D.modalUI));
|
layer.push(this.add.text(CX, y + 36, 'Hand Over', { fontFamily: 'Righteous', fontSize: '40px', color: HEX(THEME.brassHi) }).setOrigin(0.5).setDepth(D.modalUI));
|
||||||
|
|
||||||
|
// Portrait radius and diagonal offset for the overlapping pair
|
||||||
|
const PR = 22;
|
||||||
const names = ['YOUR TEAM', 'OPPONENTS'];
|
const names = ['YOUR TEAM', 'OPPONENTS'];
|
||||||
[0, 1].forEach((teamId, idx) => {
|
[0, 1].forEach((teamId, idx) => {
|
||||||
const d = scores[idx];
|
const d = scores[idx];
|
||||||
const col = idx === 0 ? THEME.team0 : THEME.team1;
|
const col = idx === 0 ? THEME.team0 : THEME.team1;
|
||||||
const px = x + 60 + idx * (w / 2 - 20);
|
const colX = x + idx * halfW;
|
||||||
|
const px = colX + 95;
|
||||||
|
const portMidY = y + 215;
|
||||||
|
|
||||||
|
// Two portraits overlapping diagonally — back (up-left) then front (down-right)
|
||||||
|
const [backSeat, frontSeat] = teamId === 0 ? [2, 0] : [3, 1];
|
||||||
|
this.addMiniPortrait(layer, backSeat, colX + 36, portMidY - 12, PR, D.modalUI);
|
||||||
|
this.addMiniPortrait(layer, frontSeat, colX + 58, portMidY + 10, PR, D.modalUI + 2);
|
||||||
|
|
||||||
layer.push(this.add.text(px, y + 92, names[idx], { fontFamily: '"Julius Sans One"', fontSize: '22px', color: HEX(col) }).setDepth(D.modalUI));
|
layer.push(this.add.text(px, y + 92, names[idx], { fontFamily: '"Julius Sans One"', fontSize: '22px', color: HEX(col) }).setDepth(D.modalUI));
|
||||||
const lines = [
|
const lines = [
|
||||||
`Melds ${d.meldPoints}`,
|
`Melds ${d.meldPoints}`,
|
||||||
|
|
@ -583,8 +900,9 @@ export default class CanastaGame extends Phaser.Scene {
|
||||||
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.textHex, lineSpacing: 8,
|
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.textHex, lineSpacing: 8,
|
||||||
}).setDepth(D.modalUI));
|
}).setDepth(D.modalUI));
|
||||||
});
|
});
|
||||||
|
layer.push(new Button(this, CX, y + h - 52, 'Next Round', () => this.onNextHand(),
|
||||||
|
{ width: 280, height: 56, fontSize: 24 }).setDepth(D.modalUI));
|
||||||
this.handOverLayer = layer;
|
this.handOverLayer = layer;
|
||||||
this.btn.next.setVisible(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onNextHand() {
|
onNextHand() {
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ export default class CribbageGame extends Phaser.Scene {
|
||||||
fontFamily: 'Righteous', fontSize: '30px', color: '#f0d77a',
|
fontFamily: 'Righteous', fontSize: '30px', color: '#f0d77a',
|
||||||
}).setOrigin(0.5).setDepth(D.ui);
|
}).setOrigin(0.5).setDepth(D.ui);
|
||||||
|
|
||||||
this.statusText = this.add.text(CENTER_X, 412, '', {
|
this.statusText = this.add.text(CENTER_X, PLAYER_HAND_Y - 100, '', {
|
||||||
fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.textHex,
|
fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.textHex,
|
||||||
}).setOrigin(0.5).setDepth(D.ui);
|
}).setOrigin(0.5).setDepth(D.ui);
|
||||||
|
|
||||||
|
|
@ -382,8 +382,10 @@ export default class CribbageGame extends Phaser.Scene {
|
||||||
playSound(this, SFX.CARD_SHOW);
|
playSound(this, SFX.CARD_SHOW);
|
||||||
await this.delay(300);
|
await this.delay(300);
|
||||||
// flip
|
// flip
|
||||||
this.tweens.add({ targets: this.starterSprite, scaleX: 0, duration: 140, yoyo: true,
|
this.tweens.add({
|
||||||
onYoyo: () => { this.starterSprite.removeAll(true); this.drawFace(this.starterSprite, starter, true); } });
|
targets: this.starterSprite, scaleX: 0, duration: 140, yoyo: true,
|
||||||
|
onYoyo: () => { this.starterSprite.removeAll(true); this.drawFace(this.starterSprite, starter, true); }
|
||||||
|
});
|
||||||
await this.delay(360);
|
await this.delay(360);
|
||||||
if (heels) {
|
if (heels) {
|
||||||
this.floatScore(this.logic.dealer, 2, 'His Heels!');
|
this.floatScore(this.logic.dealer, 2, 'His Heels!');
|
||||||
|
|
@ -484,8 +486,10 @@ export default class CribbageGame extends Phaser.Scene {
|
||||||
this.countText.setText('Count: 0');
|
this.countText.setText('Count: 0');
|
||||||
const going = this.pegSprites;
|
const going = this.pegSprites;
|
||||||
this.pegSprites = [];
|
this.pegSprites = [];
|
||||||
going.forEach((c) => this.tweens.add({ targets: c, y: PEG_ROW_Y + 120, alpha: 0, duration: 300,
|
going.forEach((c) => this.tweens.add({
|
||||||
onComplete: () => c.destroy() }));
|
targets: c, y: PEG_ROW_Y + 120, alpha: 0, duration: 300,
|
||||||
|
onComplete: () => c.destroy()
|
||||||
|
}));
|
||||||
await this.delay(340);
|
await this.delay(340);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -499,8 +503,10 @@ export default class CribbageGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
labelFor(reasons) {
|
labelFor(reasons) {
|
||||||
const map = { '15': 'Fifteen', '31': 'Thirty-one', pair: 'Pair', 'pair-royal': 'Three!', 'double-pair-royal': 'Four!',
|
const map = {
|
||||||
run3: 'Run', run4: 'Run', run5: 'Run', run6: 'Run', run7: 'Run', go: 'Go', 'last-card': 'Last card' };
|
'15': 'Fifteen', '31': 'Thirty-one', pair: 'Pair', 'pair-royal': 'Three!', 'double-pair-royal': 'Four!',
|
||||||
|
run3: 'Run', run4: 'Run', run5: 'Run', run6: 'Run', run7: 'Run', go: 'Go', 'last-card': 'Last card'
|
||||||
|
};
|
||||||
return (reasons || []).map((r) => map[r] || r).join(' + ');
|
return (reasons || []).map((r) => map[r] || r).join(' + ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -510,8 +516,10 @@ export default class CribbageGame extends Phaser.Scene {
|
||||||
const txt = this.add.text(pt.x, pt.y - 18, `+${points}${label ? ' ' + label : ''}`, {
|
const txt = this.add.text(pt.x, pt.y - 18, `+${points}${label ? ' ' + label : ''}`, {
|
||||||
fontFamily: 'Righteous', fontSize: '24px', color: player === 0 ? '#f07a6d' : '#fff4d8',
|
fontFamily: 'Righteous', fontSize: '24px', color: player === 0 ? '#f07a6d' : '#fff4d8',
|
||||||
}).setOrigin(0.5).setDepth(D.toast).setShadow(0, 0, 'rgba(255,207,74,0.85)', 12);
|
}).setOrigin(0.5).setDepth(D.toast).setShadow(0, 0, 'rgba(255,207,74,0.85)', 12);
|
||||||
this.tweens.add({ targets: txt, y: pt.y - 64, alpha: 0, duration: 1100, ease: 'Cubic.easeOut',
|
this.tweens.add({
|
||||||
onComplete: () => txt.destroy() });
|
targets: txt, y: pt.y - 64, alpha: 0, duration: 1100, ease: 'Cubic.easeOut',
|
||||||
|
onComplete: () => txt.destroy()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── The show ────────────────────────────────────────────────────────────────
|
// ── The show ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -526,8 +534,10 @@ export default class CribbageGame extends Phaser.Scene {
|
||||||
const card = this.logic.keep[1][i];
|
const card = this.logic.keep[1][i];
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
c.cardRef = card;
|
c.cardRef = card;
|
||||||
this.tweens.add({ targets: c, scaleX: 0, duration: 120, yoyo: true,
|
this.tweens.add({
|
||||||
onYoyo: () => { c.removeAll(true); this.drawFace(c, card, true); } });
|
targets: c, scaleX: 0, duration: 120, yoyo: true,
|
||||||
|
onYoyo: () => { c.removeAll(true); this.drawFace(c, card, true); }
|
||||||
|
});
|
||||||
});
|
});
|
||||||
await this.delay(500);
|
await this.delay(500);
|
||||||
|
|
||||||
|
|
@ -571,11 +581,14 @@ export default class CribbageGame extends Phaser.Scene {
|
||||||
g.fillStyle(THEME.woodDark, 0.96); g.fillRoundedRect(cx - w / 2, cy - h / 2, w, h, 14);
|
g.fillStyle(THEME.woodDark, 0.96); g.fillRoundedRect(cx - w / 2, cy - h / 2, w, h, 14);
|
||||||
g.lineStyle(3, THEME.brass, 0.9); g.strokeRoundedRect(cx - w / 2, cy - h / 2, w, h, 14);
|
g.lineStyle(3, THEME.brass, 0.9); g.strokeRoundedRect(cx - w / 2, cy - h / 2, w, h, 14);
|
||||||
const t1 = this.add.text(cx, cy - 34, `${o.title} — ${o.isCrib ? 'CRIB' : 'HAND'}`, {
|
const t1 = this.add.text(cx, cy - 34, `${o.title} — ${o.isCrib ? 'CRIB' : 'HAND'}`, {
|
||||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(D.modalUI);
|
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex
|
||||||
|
}).setOrigin(0.5).setDepth(D.modalUI);
|
||||||
const t2 = this.add.text(cx, cy + 2, body, {
|
const t2 = this.add.text(cx, cy + 2, body, {
|
||||||
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.modalUI);
|
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex
|
||||||
|
}).setOrigin(0.5).setDepth(D.modalUI);
|
||||||
const t3 = this.add.text(cx, cy + 40, `${r.total} point${r.total === 1 ? '' : 's'}`, {
|
const t3 = this.add.text(cx, cy + 40, `${r.total} point${r.total === 1 ? '' : 's'}`, {
|
||||||
fontFamily: 'Righteous', fontSize: '30px', color: '#f0d77a' }).setOrigin(0.5).setDepth(D.modalUI)
|
fontFamily: 'Righteous', fontSize: '30px', color: '#f0d77a'
|
||||||
|
}).setOrigin(0.5).setDepth(D.modalUI)
|
||||||
.setShadow(0, 0, 'rgba(255,207,74,0.8)', 12);
|
.setShadow(0, 0, 'rgba(255,207,74,0.8)', 12);
|
||||||
this.countPanel = [g, t1, t2, t3];
|
this.countPanel = [g, t1, t2, t3];
|
||||||
}
|
}
|
||||||
|
|
@ -617,10 +630,12 @@ export default class CribbageGame extends Phaser.Scene {
|
||||||
g.lineStyle(4, THEME.brass, 1); g.strokeRoundedRect(GAME_WIDTH / 2 - 360, 360, 720, 360, 24);
|
g.lineStyle(4, THEME.brass, 1); g.strokeRoundedRect(GAME_WIDTH / 2 - 360, 360, 720, 360, 24);
|
||||||
|
|
||||||
this.add.text(GAME_WIDTH / 2, 450, won ? 'You Win!' : `${this.aiName} Wins`, {
|
this.add.text(GAME_WIDTH / 2, 450, won ? 'You Win!' : `${this.aiName} Wins`, {
|
||||||
fontFamily: 'Righteous', fontSize: '60px', color: won ? '#f0d77a' : '#e9e1cf' })
|
fontFamily: 'Righteous', fontSize: '60px', color: won ? '#f0d77a' : '#e9e1cf'
|
||||||
|
})
|
||||||
.setOrigin(0.5).setDepth(D.modalUI);
|
.setOrigin(0.5).setDepth(D.modalUI);
|
||||||
this.add.text(GAME_WIDTH / 2, 530, `${this.logic.scores[0]} – ${this.logic.scores[1]}${skunk ? ' (Skunk!)' : ''}`, {
|
this.add.text(GAME_WIDTH / 2, 530, `${this.logic.scores[0]} – ${this.logic.scores[1]}${skunk ? ' (Skunk!)' : ''}`, {
|
||||||
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex })
|
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex
|
||||||
|
})
|
||||||
.setOrigin(0.5).setDepth(D.modalUI);
|
.setOrigin(0.5).setDepth(D.modalUI);
|
||||||
new Button(this, GAME_WIDTH / 2, 650, 'Back to Menu', () => this.scene.start('GameMenu'),
|
new Button(this, GAME_WIDTH / 2, 650, 'Back to Menu', () => this.scene.start('GameMenu'),
|
||||||
{ width: 300, fontSize: 26 }).setDepth(D.modalUI);
|
{ width: 300, fontSize: 26 }).setDepth(D.modalUI);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue