Added Nerts
This commit is contained in:
parent
f9e80eaedc
commit
7e022e8e9b
|
|
@ -381,7 +381,7 @@ export default class CatanGame extends Phaser.Scene {
|
|||
|
||||
// action buttons (vertical column, right)
|
||||
const bx = 1815; let by = 250; const step = 60;
|
||||
const mk = (key, label, fn) => { const b = new Button(this, bx, by, label, fn, { width: 168, height: 46, fontSize: 19 }).setDepth(D.hud); this.buttons[key] = b; by += step; return b; };
|
||||
const mk = (key, label, fn) => { const b = new Button(this, bx, by, label, fn, { width: 200, height: 46, fontSize: 19 }).setDepth(D.hud); this.buttons[key] = b; by += step; return b; };
|
||||
mk('roll', 'Roll Dice', () => this.onRoll());
|
||||
mk('road', 'Build Road', () => this.enterPlace('road'));
|
||||
mk('settlement', 'Build Settlement', () => this.enterPlace('settlement'));
|
||||
|
|
@ -554,6 +554,7 @@ export default class CatanGame extends Phaser.Scene {
|
|||
RESOURCE_TYPES.forEach((r, i) => {
|
||||
this.bankCardPos[r] = { x: cardCx, y: stackTops[i] + cardH / 2 };
|
||||
});
|
||||
this.bankCardPos.dev = { x: cardCx, y: stackTops[5] + cardH / 2 };
|
||||
|
||||
// Layer 1: panel background + card shadow fills
|
||||
const gBg = this.add.graphics().setDepth(D.hud - 1);
|
||||
|
|
@ -669,23 +670,19 @@ export default class CatanGame extends Phaser.Scene {
|
|||
const frameIdx = RESOURCE_TYPES.indexOf(resource);
|
||||
const src = this.bankCardPos?.[resource] ?? { x: 1562, y: 400 };
|
||||
const dst = this.portraitPos(seat);
|
||||
const img = this.add.image(src.x, src.y, 'catan-cards', frameIdx)
|
||||
.setDisplaySize(81, 117).setDepth(D.banner - 1);
|
||||
let flipped = false;
|
||||
const cardW = 81, cardH = 117;
|
||||
|
||||
const img = this.add.image(0, 0, 'catan-cards', frameIdx).setDisplaySize(cardW, cardH);
|
||||
const border = this.add.graphics();
|
||||
border.lineStyle(3, RESOURCE_INFO[resource].swatch, 1);
|
||||
border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5);
|
||||
const container = this.add.container(src.x, src.y, [img, border]).setDepth(D.banner - 1);
|
||||
|
||||
this.tweens.add({
|
||||
targets: img, x: dst.x, y: dst.y, duration: 500, ease: 'Quad.InOut',
|
||||
onUpdate: (tween) => {
|
||||
if (!flipped && tween.progress > 0.4) {
|
||||
flipped = true;
|
||||
this.tweens.add({
|
||||
targets: img, scaleX: 0, duration: 100, ease: 'Linear',
|
||||
onComplete: () => this.tweens.add({ targets: img, scaleX: 1, duration: 100, ease: 'Linear' }),
|
||||
});
|
||||
}
|
||||
},
|
||||
targets: container, x: dst.x, y: dst.y, duration: 1000, ease: 'Quad.InOut',
|
||||
onComplete: () => {
|
||||
playSound(this, SFX.CASINO_WIN);
|
||||
img.destroy();
|
||||
container.destroy();
|
||||
const radius = seat === 0 ? 64 : 56;
|
||||
const label = this.add.text(dst.x + radius + 10, dst.y,
|
||||
RESOURCE_INFO[resource].label.toUpperCase(), {
|
||||
|
|
@ -962,50 +959,61 @@ export default class CatanGame extends Phaser.Scene {
|
|||
return new Promise(resolve => {
|
||||
const cardW = 60, cardH = 84;
|
||||
const bigW = 90, bigH = 126;
|
||||
const resource = RESOURCE_TYPES[frameIdx];
|
||||
|
||||
const makeBorder = (color) => {
|
||||
const g = this.add.graphics();
|
||||
g.lineStyle(3, color, 1);
|
||||
g.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5);
|
||||
return g;
|
||||
};
|
||||
|
||||
if (startFaceUp) {
|
||||
// Human card: already face-up, just fly to bank.
|
||||
const img = this.add.image(srcX, srcY, 'catan-cards', frameIdx)
|
||||
.setDisplaySize(cardW, cardH).setDepth(D.banner);
|
||||
const img = this.add.image(0, 0, 'catan-cards', frameIdx).setDisplaySize(cardW, cardH);
|
||||
const border = makeBorder(RESOURCE_INFO[resource]?.swatch ?? COLORS.accent);
|
||||
const container = this.add.container(srcX, srcY, [img, border]).setDepth(D.banner);
|
||||
this.tweens.add({
|
||||
targets: img, x: bankPos.x, y: bankPos.y, duration: 600, ease: 'Quad.InOut',
|
||||
targets: container, x: bankPos.x, y: bankPos.y, duration: 600, ease: 'Quad.InOut',
|
||||
onComplete: () => this.tweens.add({
|
||||
targets: img, alpha: 0, duration: 200,
|
||||
onComplete: () => { img.destroy(); resolve(); },
|
||||
targets: container, alpha: 0, duration: 200,
|
||||
onComplete: () => { container.destroy(); resolve(); },
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Opponent card: show face-down → flip → resize → fly.
|
||||
const img = this.add.image(srcX, srcY, 'cardbacks', this.cardBack?.spriteIndex ?? 0)
|
||||
.setDisplaySize(cardW, cardH).setDepth(D.banner);
|
||||
const img = this.add.image(0, 0, 'cardbacks', this.cardBack?.spriteIndex ?? 0).setDisplaySize(cardW, cardH);
|
||||
const border = makeBorder(COLORS.accent);
|
||||
const container = this.add.container(srcX, srcY, [img, border]).setDepth(D.banner);
|
||||
|
||||
// Brief beat so the player can register the face-down card.
|
||||
this.time.delayedCall(120, () => {
|
||||
// Flip first half: collapse width to 0.
|
||||
// Flip first half: collapse container width to 0.
|
||||
this.tweens.add({
|
||||
targets: img, scaleX: 0, duration: 110, ease: 'Linear',
|
||||
targets: container, scaleX: 0, duration: 110, ease: 'Linear',
|
||||
onComplete: () => {
|
||||
// Swap to face-up artwork (still at small size for now).
|
||||
// Swap to face-up artwork and recolor border (hidden at scaleX=0).
|
||||
img.setTexture('catan-cards', frameIdx).setDisplaySize(cardW, cardH);
|
||||
const sx = img.scaleX, sy = img.scaleY;
|
||||
img.scaleX = 0;
|
||||
border.clear();
|
||||
border.lineStyle(3, RESOURCE_INFO[resource]?.swatch ?? COLORS.accent, 1);
|
||||
border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5);
|
||||
// Flip second half: restore width.
|
||||
this.tweens.add({
|
||||
targets: img, scaleX: sx, duration: 110, ease: 'Linear',
|
||||
targets: container, scaleX: 1, duration: 110, ease: 'Linear',
|
||||
onComplete: () => {
|
||||
// Scale up to bank card size with a little bounce.
|
||||
this.tweens.add({
|
||||
targets: img, scaleX: sx * (bigW / cardW), scaleY: sy * (bigH / cardH),
|
||||
targets: container, scaleX: bigW / cardW, scaleY: bigH / cardH,
|
||||
duration: 230, ease: 'Back.Out',
|
||||
onComplete: () => {
|
||||
// Now fly to the bank.
|
||||
this.tweens.add({
|
||||
targets: img, x: bankPos.x, y: bankPos.y, duration: 720, ease: 'Quad.InOut',
|
||||
targets: container, x: bankPos.x, y: bankPos.y, duration: 720, ease: 'Quad.InOut',
|
||||
onComplete: () => this.tweens.add({
|
||||
targets: img, alpha: 0, duration: 180,
|
||||
onComplete: () => { img.destroy(); resolve(); },
|
||||
targets: container, alpha: 0, duration: 180,
|
||||
onComplete: () => { container.destroy(); resolve(); },
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
|
@ -1070,6 +1078,35 @@ export default class CatanGame extends Phaser.Scene {
|
|||
});
|
||||
}
|
||||
|
||||
animateDevCardFromBank(seat) {
|
||||
return new Promise(resolve => {
|
||||
const bankPos = this.bankCardPos?.dev ?? { x: 1562, y: 701 };
|
||||
const destPos = this._seatPortraitPos(seat) ?? { x: 90, y: 980 };
|
||||
const cardW = 60, cardH = 84;
|
||||
|
||||
const img = this.add.image(0, 0, 'cardbacks', this.cardBack?.spriteIndex ?? 0).setDisplaySize(cardW, cardH);
|
||||
const border = this.add.graphics();
|
||||
border.lineStyle(3, COLORS.accent, 1);
|
||||
border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5);
|
||||
const container = this.add.container(bankPos.x, bankPos.y, [img, border]).setDepth(D.banner);
|
||||
|
||||
const duration = 1400;
|
||||
const arcHeight = Math.max(150, Math.abs(destPos.y - bankPos.y) * 0.5 + 100);
|
||||
const peakY = Math.min(bankPos.y, destPos.y) - arcHeight;
|
||||
const half = duration / 2;
|
||||
this.tweens.add({ targets: container, x: destPos.x, duration, ease: 'Quad.InOut' });
|
||||
this.tweens.chain({ targets: container, tweens: [
|
||||
{ y: peakY, duration: half, ease: 'Quad.Out' },
|
||||
{ y: destPos.y, duration: half, ease: 'Quad.In', onComplete: () => {
|
||||
this.tweens.add({
|
||||
targets: container, alpha: 0, scaleX: 1.5, scaleY: 1.5, duration: 280,
|
||||
onComplete: () => { container.destroy(); resolve(); },
|
||||
});
|
||||
}},
|
||||
]});
|
||||
});
|
||||
}
|
||||
|
||||
updateHand() {
|
||||
const p = this.gs.players[0];
|
||||
|
||||
|
|
@ -1393,7 +1430,7 @@ export default class CatanGame extends Phaser.Scene {
|
|||
this.gs = L.moveRobber(this.gs, m.hexId, m.targetSeat);
|
||||
if (m.targetSeat === 0) {
|
||||
const stolen = RESOURCE_TYPES.find(r => this.gs.players[0].resources[r] < preRes0roll[r]);
|
||||
if (stolen) this._notifyStolenCard(stolen, this.pname(seat));
|
||||
if (stolen) this._notifyStolenCard(stolen, this.pname(seat), seat);
|
||||
}
|
||||
await this.animateRobber(preRobberHex, m.hexId);
|
||||
this.renderAll(); await this.delay(400);
|
||||
|
|
@ -1423,7 +1460,7 @@ export default class CatanGame extends Phaser.Scene {
|
|||
if (m.targetSeat != null) this.opponentPortraits[seat]?.playEmotion?.('happy');
|
||||
if (m.targetSeat === 0) {
|
||||
const stolen = RESOURCE_TYPES.find(r => this.gs.players[0].resources[r] < preRes0[r]);
|
||||
if (stolen) this._notifyStolenCard(stolen, this.pname(seat));
|
||||
if (stolen) this._notifyStolenCard(stolen, this.pname(seat), seat);
|
||||
}
|
||||
await animPromise;
|
||||
this.renderAll();
|
||||
|
|
@ -1450,6 +1487,12 @@ export default class CatanGame extends Phaser.Scene {
|
|||
this.opponentPortraits[seat]?.playEmotion?.('happy');
|
||||
}
|
||||
}
|
||||
if (a.type === 'buyDev') {
|
||||
enqueueSpeech('catan-purchase-development-card');
|
||||
await this.animateCostPayment(seat, 'devCard');
|
||||
playSound(this, SFX.CARD_DEAL);
|
||||
await this.animateDevCardFromBank(seat);
|
||||
}
|
||||
this.gs = this.applyAction(seat, a);
|
||||
if (this.gs.phase === 'moveRobber') {
|
||||
const m = AI.chooseRobberMove(this.gs, seat);
|
||||
|
|
@ -1458,7 +1501,7 @@ export default class CatanGame extends Phaser.Scene {
|
|||
this.gs = L.moveRobber(this.gs, m.hexId, m.targetSeat);
|
||||
if (m.targetSeat === 0) {
|
||||
const stolen = RESOURCE_TYPES.find(r => this.gs.players[0].resources[r] < preRes0action[r]);
|
||||
if (stolen) this._notifyStolenCard(stolen, this.pname(seat));
|
||||
if (stolen) this._notifyStolenCard(stolen, this.pname(seat), seat);
|
||||
}
|
||||
await this.animateRobber(preRobberHex, m.hexId);
|
||||
}
|
||||
|
|
@ -1478,7 +1521,7 @@ export default class CatanGame extends Phaser.Scene {
|
|||
case 'buildCity': return L.buildCity(this.gs, seat, a.nodeId);
|
||||
case 'buildSettlement': return L.buildSettlement(this.gs, seat, a.nodeId);
|
||||
case 'buildRoad': return L.buildRoad(this.gs, seat, a.edgeId);
|
||||
case 'buyDev': enqueueSpeech('catan-purchase-development-card'); return L.buyDevCard(this.gs, seat);
|
||||
case 'buyDev': return L.buyDevCard(this.gs, seat);
|
||||
case 'bankTrade': return L.tradeWithBank(this.gs, seat, a.give, a.get);
|
||||
case 'playDev':
|
||||
if (a.card === 'knight') return L.playKnight(this.gs, seat);
|
||||
|
|
@ -1598,12 +1641,16 @@ export default class CatanGame extends Phaser.Scene {
|
|||
this.advance();
|
||||
}
|
||||
|
||||
onBuyDev() {
|
||||
async onBuyDev() {
|
||||
if (this.busy || this.gs.phase !== 'action') return;
|
||||
this.busy = true;
|
||||
this.clearHighlights(); this.placeMode = null;
|
||||
this.gs = L.buyDevCard(this.gs, 0);
|
||||
enqueueSpeech('catan-purchase-development-card');
|
||||
await this.animateCostPayment(0, 'devCard');
|
||||
playSound(this, SFX.CARD_DEAL);
|
||||
await this.animateDevCardFromBank(0);
|
||||
this.gs = L.buyDevCard(this.gs, 0);
|
||||
this.busy = false;
|
||||
this.advance();
|
||||
}
|
||||
|
||||
|
|
@ -1655,11 +1702,11 @@ export default class CatanGame extends Phaser.Scene {
|
|||
pickResources(count, title, cb) {
|
||||
const chosen = [];
|
||||
const panel = this.modalPanel(540, title);
|
||||
const label = this.add.text(1000, 470, '', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(D.panel + 1);
|
||||
const label = this.add.text(1000, 430, '', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(D.panel + 1);
|
||||
panel.add(label);
|
||||
const refresh = () => label.setText(chosen.map((r) => RESOURCE_INFO[r].label).join(', ') || '—');
|
||||
RESOURCE_TYPES.forEach((r, i) => {
|
||||
this.modalButton(panel, 850 + (i % 3) * 150, 540 + Math.floor(i / 3) * 64, RESOURCE_INFO[r].label, () => {
|
||||
this.modalButton(panel, 1000, 490 + i * 58, RESOURCE_INFO[r].label, () => {
|
||||
chosen.push(r); refresh();
|
||||
if (chosen.length >= count) { panel.destroy(); label.destroy(); cb(chosen); }
|
||||
});
|
||||
|
|
@ -1671,36 +1718,44 @@ export default class CatanGame extends Phaser.Scene {
|
|||
if (this.busy || this.gs.phase !== 'action') return;
|
||||
this.clearHighlights();
|
||||
const give = { brick: 0, lumber: 0, wool: 0, grain: 0, ore: 0 };
|
||||
const get = { brick: 0, lumber: 0, wool: 0, grain: 0, ore: 0 };
|
||||
const get = { brick: 0, lumber: 0, wool: 0, grain: 0, ore: 0 };
|
||||
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setInteractive().setDepth(D.panel);
|
||||
const box = this.add.rectangle(1000, 470, 920, 540, COLORS.panel, 1).setStrokeStyle(3, COLORS.accent).setDepth(D.panel);
|
||||
const box = this.add.rectangle(1000, 480, 920, 580, COLORS.panel, 1).setStrokeStyle(3, COLORS.accent).setDepth(D.panel);
|
||||
const title = this.add.text(1000, 240, 'Trade', { fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(D.panel + 1);
|
||||
const hintGive = this.add.text(760, 300, 'You give', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.panel + 1);
|
||||
const hintGet = this.add.text(1240, 300, 'You get', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.panel + 1);
|
||||
const hintGive = this.add.text(760, 308, 'You give', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.panel + 1);
|
||||
const hintGet = this.add.text(1240, 308, 'You get', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.panel + 1);
|
||||
const objs = [overlay, box, title, hintGive, hintGet];
|
||||
const valTexts = {};
|
||||
|
||||
const stepper = (col, r, i, side) => {
|
||||
const x = col, y = 350 + i * 50;
|
||||
const hitBox = new Phaser.Geom.Rectangle(-22, -22, 44, 44);
|
||||
const lbl = this.add.text(x - 150, y, RESOURCE_INFO[r].label, { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex }).setOrigin(0, 0.5).setDepth(D.panel + 1);
|
||||
const minus = this.add.text(x - 16, y, '−', { fontFamily: 'Righteous', fontSize: '36px', color: COLORS.dangerHex }).setOrigin(0.5).setInteractive(hitBox, Phaser.Geom.Rectangle.Contains, true).setDepth(D.panel + 1);
|
||||
const val = this.add.text(x + 30, y, '0', { fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.panel + 1);
|
||||
const plus = this.add.text(x + 76, y, '+', { fontFamily: 'Righteous', fontSize: '36px', color: COLORS.goldHex }).setOrigin(0.5).setInteractive(hitBox, Phaser.Geom.Rectangle.Contains, true).setDepth(D.panel + 1);
|
||||
const y = 355 + i * 60;
|
||||
const bag = side === 'give' ? give : get;
|
||||
valTexts[side + r] = val;
|
||||
minus.on('pointerdown', () => { if (bag[r] > 0) { bag[r]--; val.setText(String(bag[r])); } });
|
||||
plus.on('pointerdown', () => {
|
||||
|
||||
const lbl = this.add.text(col - 165, y, RESOURCE_INFO[r].label, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.panel + 1);
|
||||
|
||||
const val = this.add.text(col + 20, y, '0', {
|
||||
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.panel + 1);
|
||||
|
||||
const minusBtn = new Button(this, col - 46, y, '−', () => {
|
||||
if (bag[r] > 0) { bag[r]--; val.setText(String(bag[r])); }
|
||||
}, { width: 52, height: 44, fontSize: 28, textColor: COLORS.dangerHex, bgHover: COLORS.danger, textHoverColor: '#ffffff' })
|
||||
.setDepth(D.panel + 1);
|
||||
|
||||
const plusBtn = new Button(this, col + 82, y, '+', () => {
|
||||
if (side === 'give' && bag[r] >= this.gs.players[0].resources[r]) return;
|
||||
bag[r]++; val.setText(String(bag[r]));
|
||||
});
|
||||
objs.push(lbl, minus, val, plus);
|
||||
}, { width: 52, height: 44, fontSize: 28, textColor: COLORS.goldHex })
|
||||
.setDepth(D.panel + 1);
|
||||
|
||||
objs.push(lbl, val, minusBtn, plusBtn);
|
||||
};
|
||||
RESOURCE_TYPES.forEach((r, i) => { stepper(760, r, i, 'give'); stepper(1240, r, i, 'get'); });
|
||||
|
||||
const close = () => objs.forEach((o) => o.destroy());
|
||||
|
||||
const bankBtn = new Button(this, 850, 640, 'Bank / Port', () => {
|
||||
const bankBtn = new Button(this, 850, 658, 'Bank / Port', () => {
|
||||
const gKeys = RESOURCE_TYPES.filter((r) => give[r] > 0);
|
||||
const tKeys = RESOURCE_TYPES.filter((r) => get[r] > 0);
|
||||
if (gKeys.length === 1 && tKeys.length === 1 && get[tKeys[0]] === 1) {
|
||||
|
|
@ -1716,7 +1771,7 @@ export default class CatanGame extends Phaser.Scene {
|
|||
this.flashStatus('Bank trade needs N of one resource for 1 of another (N = your ratio).');
|
||||
}, { width: 260, height: 48 }).setDepth(D.panel + 1);
|
||||
|
||||
const offerBtn = new Button(this, 1160, 640, 'Offer to Players', () => {
|
||||
const offerBtn = new Button(this, 1160, 658, 'Offer to Players', () => {
|
||||
const gCount = RESOURCE_TYPES.reduce((s, r) => s + give[r], 0);
|
||||
const tCount = RESOURCE_TYPES.reduce((s, r) => s + get[r], 0);
|
||||
if (!gCount || !tCount) { this.flashStatus('Set what you give and get.'); return; }
|
||||
|
|
@ -1733,7 +1788,7 @@ export default class CatanGame extends Phaser.Scene {
|
|||
this.advance();
|
||||
}, { width: 300, height: 48 }).setDepth(D.panel + 1);
|
||||
|
||||
const cancelBtn = new Button(this, 1000, 700, 'Cancel', () => close(), { variant: 'ghost', width: 160, height: 44 }).setDepth(D.panel + 1);
|
||||
const cancelBtn = new Button(this, 1000, 726, 'Cancel', () => close(), { variant: 'ghost', width: 160, height: 44 }).setDepth(D.panel + 1);
|
||||
objs.push(bankBtn, offerBtn, cancelBtn);
|
||||
}
|
||||
|
||||
|
|
@ -1794,8 +1849,42 @@ export default class CatanGame extends Phaser.Scene {
|
|||
this.statusText.setText(msg);
|
||||
}
|
||||
|
||||
_notifyStolenCard(resource, robberName) {
|
||||
_notifyStolenCard(resource, robberName, thiefSeat) {
|
||||
playSound(this, SFX.CASINO_LOSE);
|
||||
|
||||
// Card animation: grow + spin, then fly to thief portrait
|
||||
const frameIdx = RESOURCE_TYPES.indexOf(resource);
|
||||
const cardW = 60, cardH = 84;
|
||||
let srcX = 220, srcY = 950;
|
||||
for (let i = 0; i < this.handDisplay.length; i++) {
|
||||
if (this.handDisplay[i] === resource && this.handCardObjs[i]) {
|
||||
srcX = this.handCardObjs[i].x;
|
||||
srcY = this.handCardObjs[i].y;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const destPos = this._seatPortraitPos(thiefSeat) ?? { x: 130, y: 300 };
|
||||
const img = this.add.image(0, 0, 'catan-cards', frameIdx).setDisplaySize(cardW, cardH);
|
||||
const border = this.add.graphics();
|
||||
border.lineStyle(3, RESOURCE_INFO[resource]?.swatch ?? COLORS.accent, 1);
|
||||
border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5);
|
||||
const container = this.add.container(srcX, srcY, [img, border]).setDepth(D.banner + 3);
|
||||
this.tweens.add({
|
||||
targets: container, scaleX: 2, scaleY: 2, angle: 720,
|
||||
duration: 1000, ease: 'Quad.InOut',
|
||||
onComplete: () => {
|
||||
this.tweens.add({
|
||||
targets: container, x: destPos.x, y: destPos.y, scaleX: 1, scaleY: 1,
|
||||
duration: 800, ease: 'Quad.InOut',
|
||||
onComplete: () => this.tweens.add({
|
||||
targets: container, alpha: 0, duration: 280,
|
||||
onComplete: () => container.destroy(),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Text notification runs simultaneously
|
||||
const label = (RESOURCE_INFO[resource]?.label ?? resource).toUpperCase();
|
||||
const txt = this.add.text(GAME_WIDTH / 2, 855,
|
||||
`CARD STOLEN: ${label} BY ${robberName.toUpperCase()}`, {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,21 @@ const PIP_POS = {
|
|||
6: [[-1, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [1, 1]],
|
||||
};
|
||||
|
||||
// ─── Bet-zone help text ────────────────────────────────────────────────────--
|
||||
const ZONE_INFO = {
|
||||
pass: { title: 'PASS LINE', desc: 'Bet with the shooter. Win on 7 or 11 on the come-out roll. Lose on 2, 3, or 12 (craps). Once a point is set, win if that point rolls again before a 7.' },
|
||||
dontpass: { title: "DON'T PASS BAR", desc: "Bet against the shooter. Win on 2 or 3; push on 12. Lose on 7 or 11. After a point is set, win when 7 rolls before the point. One of the lowest house-edge bets on the table." },
|
||||
come: { title: 'COME', desc: 'Placed after a point is set — works exactly like the Pass Line from that moment. Win on 7 or 11; lose on 2, 3, or 12. Any other number becomes your personal come point; win when it repeats before a 7.' },
|
||||
dontcome: { title: "DON'T COME", desc: "Placed after a point is set — mirrors the Don't Pass. Lose on 7 or 11; win on 2 or 3 (12 pushes). Once your come point is established, you win if a 7 rolls first." },
|
||||
field: { title: 'FIELD', desc: 'A one-roll bet resolved on every throw. Win on 2, 3, 4, 9, 10, 11, or 12. Lose on 5, 6, 7, or 8. A 2 pays double; 12 pays triple. High action — wins or loses immediately.' },
|
||||
box4: { title: 'PLACE 4', desc: 'Win if a 4 rolls before a 7. Pays 9 to 5. Available any time after a point is set. You can take this bet down between rolls whenever you like.' },
|
||||
box5: { title: 'PLACE 5', desc: 'Win if a 5 rolls before a 7. Pays 7 to 5. A flexible standing bet — leave it up as long as you choose.' },
|
||||
box6: { title: 'PLACE 6', desc: 'Win if a 6 rolls before a 7. Pays 7 to 6. The 6 is the second most common number after 7, giving this bet a strong win rate.' },
|
||||
box8: { title: 'PLACE 8', desc: 'Win if an 8 rolls before a 7. Pays 7 to 6. Rolls just as often as the 6 — considered one of the best place bets on the table.' },
|
||||
box9: { title: 'PLACE 9', desc: 'Win if a 9 rolls before a 7. Pays 7 to 5. Solid mid-table bet with moderate odds.' },
|
||||
box10: { title: 'PLACE 10', desc: 'Win if a 10 rolls before a 7. Pays 9 to 5. Identical odds to the 4 — both roll four ways out of 36.' },
|
||||
};
|
||||
|
||||
export default class CrapsGame extends Phaser.Scene {
|
||||
constructor() { super('CrapsGame'); }
|
||||
|
||||
|
|
@ -73,6 +88,9 @@ export default class CrapsGame extends Phaser.Scene {
|
|||
this.portraits = [];
|
||||
this.chipBtns = [];
|
||||
this.startingChips = 2000;
|
||||
this.hoverTimer = null;
|
||||
this.tooltipW = 0;
|
||||
this.tooltipH = 0;
|
||||
|
||||
this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg).setDepth(D.bg);
|
||||
this.buildPlayfield();
|
||||
|
|
@ -88,6 +106,9 @@ export default class CrapsGame extends Phaser.Scene {
|
|||
variant: 'ghost', width: 150, fontSize: 20,
|
||||
}).setDepth(D.ui);
|
||||
|
||||
this.buildTooltip();
|
||||
this.input.on('pointermove', (ptr) => this.onPointerMove(ptr));
|
||||
|
||||
await this.loadChips();
|
||||
this.gs = createInitialState(this.opponents, this.startingChips, auth.user?.username ?? 'You');
|
||||
this.buildPortraits();
|
||||
|
|
@ -195,8 +216,73 @@ export default class CrapsGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
hoverZone(key, on) {
|
||||
if (this.animating) { this.drawZones(null); return; }
|
||||
if (this.animating) { this.drawZones(null); this.hideTooltip(); return; }
|
||||
this.drawZones(on ? key : null);
|
||||
if (on) {
|
||||
const delay = 500 + Math.random() * 250;
|
||||
this.hoverTimer = this.time.delayedCall(delay, () => this.showTooltip(key));
|
||||
} else {
|
||||
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
|
||||
this.hideTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hover-intent tooltip ──────────────────────────────────────────────────-
|
||||
buildTooltip() {
|
||||
const bg = this.add.graphics();
|
||||
const title = this.add.text(0, 0, '', {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.goldHex,
|
||||
});
|
||||
const desc = this.add.text(0, 0, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
|
||||
wordWrap: { width: 480 }, lineSpacing: 4,
|
||||
});
|
||||
this.tooltip = this.add.container(0, 0, [bg, title, desc]).setDepth(D.prompt + 10).setVisible(false);
|
||||
this.tooltipBg = bg;
|
||||
this.tooltipTitle = title;
|
||||
this.tooltipDesc = desc;
|
||||
}
|
||||
|
||||
showTooltip(key) {
|
||||
const info = ZONE_INFO[key];
|
||||
if (!info) return;
|
||||
const PAD_X = 22, PAD_Y = 18, GAP = 10, TOTAL_W = 524;
|
||||
this.tooltipTitle.setText(info.title);
|
||||
this.tooltipDesc.setText(info.desc);
|
||||
const titleH = this.tooltipTitle.height;
|
||||
const descH = this.tooltipDesc.height;
|
||||
const TOTAL_H = PAD_Y + titleH + GAP + descH + PAD_Y;
|
||||
this.tooltipTitle.setPosition(PAD_X, PAD_Y);
|
||||
this.tooltipDesc.setPosition(PAD_X, PAD_Y + titleH + GAP);
|
||||
this.tooltipBg.clear();
|
||||
this.tooltipBg.fillStyle(0x071a0e, 0.96);
|
||||
this.tooltipBg.fillRoundedRect(0, 0, TOTAL_W, TOTAL_H, 14);
|
||||
this.tooltipBg.lineStyle(3, COLORS.gold, 1);
|
||||
this.tooltipBg.strokeRoundedRect(0, 0, TOTAL_W, TOTAL_H, 14);
|
||||
this.tooltipW = TOTAL_W;
|
||||
this.tooltipH = TOTAL_H;
|
||||
const ptr = this.input.activePointer;
|
||||
this.positionTooltip(ptr.x, ptr.y);
|
||||
this.tooltip.setVisible(true);
|
||||
}
|
||||
|
||||
positionTooltip(px, py) {
|
||||
const OFF = 22, MARGIN = 10;
|
||||
let tx = px + OFF;
|
||||
let ty = py + OFF;
|
||||
if (tx + this.tooltipW > GAME_WIDTH - MARGIN) tx = px - this.tooltipW - OFF;
|
||||
if (ty + this.tooltipH > GAME_HEIGHT - MARGIN) ty = py - this.tooltipH - OFF;
|
||||
if (tx < MARGIN) tx = MARGIN;
|
||||
if (ty < MARGIN) ty = MARGIN;
|
||||
this.tooltip.setPosition(tx, ty);
|
||||
}
|
||||
|
||||
hideTooltip() {
|
||||
this.tooltip?.setVisible(false);
|
||||
}
|
||||
|
||||
onPointerMove(ptr) {
|
||||
if (this.tooltip?.visible) this.positionTooltip(ptr.x, ptr.y);
|
||||
}
|
||||
|
||||
// ── Puck (ON / OFF) ──────────────────────────────────────────────────────--
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
// Nerts AI — skill-aware, real-time decision making.
|
||||
//
|
||||
// The scene runs an independent timer per AI seat. Each tick it calls
|
||||
// chooseAction(state, seat, skill) for ONE atomic action, applies it, then
|
||||
// reschedules after nextThinkDelay(skill) ms. Skill (1..5) controls three axes:
|
||||
// • reaction speed — how fast the next tick fires (thinkDelay range)
|
||||
// • decision quality — how strictly moves are ordered by strategic value
|
||||
// • mistakes — missChance to dawdle (flip) or take a sloppy move
|
||||
// Level 1 is slow and sloppy; level 5 is fast and near-optimal.
|
||||
|
||||
import {
|
||||
canFlipStock,
|
||||
getValidPlays,
|
||||
} from './NertsLogic.js';
|
||||
|
||||
const SKILL_PROFILES = {
|
||||
1: { delay: [1500, 2300], miss: 0.55, quality: 0.15 },
|
||||
2: { delay: [1050, 1700], miss: 0.38, quality: 0.35 },
|
||||
3: { delay: [700, 1150], miss: 0.22, quality: 0.55 },
|
||||
4: { delay: [450, 750], miss: 0.10, quality: 0.78 },
|
||||
5: { delay: [280, 460], miss: 0.03, quality: 0.95 },
|
||||
};
|
||||
|
||||
function profileFor(skill) {
|
||||
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
|
||||
}
|
||||
|
||||
/** Milliseconds until this AI's next think tick, randomized within its band. */
|
||||
export function nextThinkDelay(skill) {
|
||||
const [lo, hi] = profileFor(skill).delay;
|
||||
return lo + Math.random() * (hi - lo);
|
||||
}
|
||||
|
||||
/** Strategic value of a play, before quality/noise weighting is applied. */
|
||||
function strategicScore(state, seat, play) {
|
||||
let s = play.kind === 'foundation' ? 100 : 40;
|
||||
|
||||
// Offloading the Nerts pile is the whole game — prize it heavily.
|
||||
if (play.source.type === 'nerts') s += 60;
|
||||
else if (play.source.type === 'waste') s += 8;
|
||||
|
||||
// Emptying a work pile (frees a column for any card) is worthwhile.
|
||||
if (play.source.type === 'work') {
|
||||
const pile = state.players[seat].work[play.source.idx];
|
||||
if ((play.source.count ?? 1) === pile.length) s += 15;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function toAction(play) {
|
||||
return { kind: play.kind, source: play.source, dest: play.dest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return one action for the AI at `seat`, or null if it can do nothing.
|
||||
* Action shapes (mirror the NertsLogic mutators):
|
||||
* { kind:'foundation', source, dest }
|
||||
* { kind:'work', source, dest }
|
||||
* { kind:'flip' }
|
||||
*/
|
||||
export function chooseAction(state, seat, skill) {
|
||||
if (state.phase !== 'playing') return null;
|
||||
const prof = profileFor(skill);
|
||||
const plays = getValidPlays(state, seat);
|
||||
const canFlip = canFlipStock(state, seat);
|
||||
|
||||
if (plays.length === 0) {
|
||||
return canFlip ? { kind: 'flip' } : null;
|
||||
}
|
||||
|
||||
// Mistake: dawdle (flip instead of playing) or take a random sloppy move.
|
||||
if (Math.random() < prof.miss) {
|
||||
if (canFlip && Math.random() < 0.5) return { kind: 'flip' };
|
||||
return toAction(plays[Math.floor(Math.random() * plays.length)]);
|
||||
}
|
||||
|
||||
// Weighted pick: low quality flattens strategy and adds large noise.
|
||||
let best = null;
|
||||
let bestScore = -Infinity;
|
||||
for (const p of plays) {
|
||||
const strategic = strategicScore(state, seat, p);
|
||||
const noise = Math.random() * 120 * (1 - prof.quality);
|
||||
const score = strategic * (0.4 + 0.6 * prof.quality) + noise;
|
||||
if (score > bestScore) { bestScore = score; best = p; }
|
||||
}
|
||||
return toAction(best);
|
||||
}
|
||||
|
|
@ -0,0 +1,786 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
||||
import { auth } from '../../services/auth.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import {
|
||||
WORK_PILE_COUNT,
|
||||
DEFAULT_TARGET_SCORE,
|
||||
createInitialState,
|
||||
canPlayOnFoundation,
|
||||
playToFoundation,
|
||||
playToWork,
|
||||
flipStock,
|
||||
endRound,
|
||||
allStuck,
|
||||
nertsTop,
|
||||
wasteTop,
|
||||
workTop,
|
||||
validRunFromIndex,
|
||||
} from './NertsLogic.js';
|
||||
import { chooseAction, nextThinkDelay } from './NertsAI.js';
|
||||
|
||||
const CX = GAME_WIDTH / 2;
|
||||
const CY = GAME_HEIGHT / 2;
|
||||
|
||||
const CARD_W = 84;
|
||||
const CARD_H = 118;
|
||||
const CARD_R = 8;
|
||||
const FAN_Y = 26; // vertical fan offset for work piles
|
||||
const WASTE_FAN = 30; // horizontal fan for the waste's visible cards
|
||||
|
||||
const D = {
|
||||
felt: -1, pile: 5, card: 10, drag: 40, ui: 30, fly: 50, panel: 28, modal: 80,
|
||||
};
|
||||
|
||||
// Per-seat owner colors — used as a rim on foundation cards so contributions read.
|
||||
const SEAT_COLORS = [0xf2c14e, 0x4dabf7, 0xe06c75, 0x69db7c];
|
||||
const SEAT_COLOR_HEX = ['#f2c14e', '#4dabf7', '#e06c75', '#69db7c'];
|
||||
|
||||
// ── Foundation layout ──────────────────────────────────────────────────────
|
||||
const FOUND_PER_ROW = 8;
|
||||
const FOUND_GAP_X = 18;
|
||||
const FOUND_ROW_Y = [286, 286 + CARD_H + 24];
|
||||
|
||||
// ── Local tableau layout ─────────────────────────────────────────────────────
|
||||
const NERTS_POS = { x: 340, y: 840 };
|
||||
const WORK_TOP_Y = 540;
|
||||
const WORK_X = [690, 870, 1050, 1230];
|
||||
const STOCK_POS = { x: 1470, y: 840 };
|
||||
const WASTE_POS = { x: 1600, y: 840 };
|
||||
const LOCAL_PORTRAIT = { x: 130, y: 820, r: 58 };
|
||||
|
||||
// ── Opponent panel layout ─────────────────────────────────────────────────────
|
||||
const PANEL_W = 320;
|
||||
const PANEL_H = 160;
|
||||
const PANEL_POSITIONS = {
|
||||
1: [{ x: 960, y: 120 }],
|
||||
2: [{ x: 620, y: 120 }, { x: 1300, y: 120 }],
|
||||
3: [{ x: 430, y: 120 }, { x: 960, y: 120 }, { x: 1490, y: 120 }],
|
||||
};
|
||||
|
||||
export default class NertsGame extends Phaser.Scene {
|
||||
constructor() { super('NertsGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game;
|
||||
this.opponents = data.opponents ?? [];
|
||||
this.playfield = data.playfield ?? null;
|
||||
this.cardBack = data.cardBack ?? null;
|
||||
this.targetScore = data.game?.targetScore ?? DEFAULT_TARGET_SCORE;
|
||||
|
||||
this.playerCount = 1 + this.opponents.length;
|
||||
this.gs = null;
|
||||
this.totals = new Array(this.playerCount).fill(0);
|
||||
|
||||
this.localCardObjs = new Map(); // card.id → container (local tableau)
|
||||
this.localExtraObjs = []; // non-keyed local sprites (face-down backs)
|
||||
this.foundationCardObjs = []; // foundation top-card sprites
|
||||
this.foundationPos = []; // idx → {x,y}
|
||||
this.foundationSlotRects = [];
|
||||
this.oppPanelPos = []; // seat → {x,y} (portrait pos, for fly origin)
|
||||
this.oppDynamic = []; // seat → { nertsText, scoreText }
|
||||
this.opponentPortraits = [];
|
||||
this.aiTimers = [];
|
||||
|
||||
this.potentialDrag = null;
|
||||
this.dragState = null;
|
||||
this.dropHighlight = null;
|
||||
this.roundEnding = false;
|
||||
this.panelObjs = [];
|
||||
}
|
||||
|
||||
create() {
|
||||
try {
|
||||
const music = this.cache.json.get('music');
|
||||
if (music?.tracks) new MusicPlayer(this, music.tracks);
|
||||
} catch (_) { /* music optional */ }
|
||||
|
||||
this.buildPlayfield();
|
||||
this.buildFoundations();
|
||||
this.buildLocalArea();
|
||||
this.buildOpponentPanels();
|
||||
this.buildHUD();
|
||||
this.setupDragHandlers();
|
||||
|
||||
this.events.once('shutdown', () => this.stopAITimers());
|
||||
|
||||
this.startRound();
|
||||
}
|
||||
|
||||
// ── Static layout ────────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
buildFoundations() {
|
||||
const total = 4 * this.playerCount;
|
||||
for (let idx = 0; idx < total; idx++) {
|
||||
const row = Math.floor(idx / FOUND_PER_ROW);
|
||||
const col = idx % FOUND_PER_ROW;
|
||||
const countInRow = Math.min(FOUND_PER_ROW, total - row * FOUND_PER_ROW);
|
||||
const rowW = countInRow * CARD_W + (countInRow - 1) * FOUND_GAP_X;
|
||||
const startX = CX - rowW / 2 + CARD_W / 2;
|
||||
const x = startX + col * (CARD_W + FOUND_GAP_X);
|
||||
const y = FOUND_ROW_Y[row];
|
||||
this.foundationPos[idx] = { x, y };
|
||||
const r = this.add.rectangle(x, y, CARD_W + 4, CARD_H + 4, 0x000000, 0.22)
|
||||
.setStrokeStyle(2, COLORS.muted, 0.5).setDepth(D.pile);
|
||||
this.foundationSlotRects[idx] = r;
|
||||
}
|
||||
this.add.text(CX, FOUND_ROW_Y[0] - CARD_H / 2 - 26, 'FOUNDATIONS — play Aces here, build up by suit', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.ui);
|
||||
}
|
||||
|
||||
buildLocalArea() {
|
||||
// Nerts pile
|
||||
this.add.rectangle(NERTS_POS.x, NERTS_POS.y, CARD_W + 8, CARD_H + 8, 0x000000, 0.4)
|
||||
.setStrokeStyle(3, COLORS.accent).setDepth(D.pile);
|
||||
this.add.text(NERTS_POS.x, NERTS_POS.y - CARD_H / 2 - 40, 'NERTS', {
|
||||
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(D.ui);
|
||||
this.localNertsText = this.add.text(NERTS_POS.x, NERTS_POS.y - CARD_H / 2 - 18, '13 left', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.ui);
|
||||
|
||||
// Work pile placeholders
|
||||
for (let i = 0; i < WORK_PILE_COUNT; i++) {
|
||||
this.add.rectangle(WORK_X[i], WORK_TOP_Y, CARD_W + 4, CARD_H + 4, 0x000000, 0.2)
|
||||
.setStrokeStyle(1, COLORS.muted, 0.5).setDepth(D.pile);
|
||||
}
|
||||
|
||||
// Stock + waste
|
||||
const stockRect = this.add.rectangle(STOCK_POS.x, STOCK_POS.y, CARD_W + 8, CARD_H + 8, 0x000000, 0.4)
|
||||
.setStrokeStyle(2, COLORS.muted).setDepth(D.pile).setInteractive({ useHandCursor: true });
|
||||
stockRect.on('pointerdown', () => this.onStockClick());
|
||||
this.add.text(STOCK_POS.x, STOCK_POS.y - CARD_H / 2 - 18, 'STOCK', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.ui);
|
||||
this.localStockText = this.add.text(STOCK_POS.x, STOCK_POS.y + CARD_H / 2 + 16, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.ui);
|
||||
|
||||
// Local portrait + name + score
|
||||
createPlayerPortrait(this, LOCAL_PORTRAIT.x, LOCAL_PORTRAIT.y, LOCAL_PORTRAIT.r, D.ui, 'NertsGame');
|
||||
this.add.text(LOCAL_PORTRAIT.x, LOCAL_PORTRAIT.y + LOCAL_PORTRAIT.r + 16, auth.user?.username ?? 'You', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.ui);
|
||||
this.localScoreText = this.add.text(LOCAL_PORTRAIT.x, LOCAL_PORTRAIT.y + LOCAL_PORTRAIT.r + 44, 'Score: 0', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(D.ui);
|
||||
}
|
||||
|
||||
buildOpponentPanels() {
|
||||
const positions = PANEL_POSITIONS[this.opponents.length] ?? [];
|
||||
for (let i = 0; i < this.opponents.length; i++) {
|
||||
const seat = i + 1;
|
||||
const opp = this.opponents[i];
|
||||
const pos = positions[i] ?? { x: 960, y: 120 };
|
||||
|
||||
this.add.rectangle(pos.x, pos.y, PANEL_W, PANEL_H, COLORS.panel, 0.82)
|
||||
.setStrokeStyle(2, SEAT_COLORS[seat] ?? COLORS.muted).setDepth(D.panel);
|
||||
|
||||
const portX = pos.x - PANEL_W / 2 + 56;
|
||||
this.opponentPortraits[seat] = createOpponentPortrait(this, opp, portX, pos.y, 46, D.panel + 1);
|
||||
this.oppPanelPos[seat] = { x: portX, y: pos.y };
|
||||
|
||||
const textX = portX + 64;
|
||||
this.add.text(textX, pos.y - 56, opp.name ?? `Player ${seat + 1}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.panel + 1);
|
||||
|
||||
this.buildSkillPips(textX, pos.y - 26, opp.skill ?? 3);
|
||||
|
||||
const nertsText = this.add.text(textX, pos.y + 8, 'Nerts: 13', {
|
||||
fontFamily: 'Righteous', fontSize: '24px', color: SEAT_COLOR_HEX[seat] ?? COLORS.textHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.panel + 1);
|
||||
const scoreText = this.add.text(textX, pos.y + 40, 'Score: 0', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.panel + 1);
|
||||
|
||||
this.oppDynamic[seat] = { nertsText, scoreText };
|
||||
}
|
||||
}
|
||||
|
||||
buildSkillPips(x, y, skill) {
|
||||
this.add.text(x, y, 'SKILL', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '12px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.panel + 1);
|
||||
const pipX = x + 52;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const filled = i < skill;
|
||||
this.add.circle(pipX + i * 18, y, 6, filled ? COLORS.accent : COLORS.muted, filled ? 1 : 0.35)
|
||||
.setStrokeStyle(1, COLORS.accent, filled ? 1 : 0.4).setDepth(D.panel + 1);
|
||||
}
|
||||
}
|
||||
|
||||
buildHUD() {
|
||||
this.statusText = this.add.text(CX, 36, `Nerts — first to ${this.targetScore} points`, {
|
||||
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.ui);
|
||||
|
||||
new Button(this, GAME_WIDTH - 90, GAME_HEIGHT - 50, 'Leave',
|
||||
() => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 130, height: 40, fontSize: 18 }).setDepth(D.ui);
|
||||
}
|
||||
|
||||
// ── Round lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
startRound() {
|
||||
this.roundEnding = false;
|
||||
this.gs = createInitialState({
|
||||
playerCount: this.playerCount,
|
||||
targetScore: this.targetScore,
|
||||
totals: this.totals,
|
||||
});
|
||||
playSound(this, SFX.CARD_SHUFFLE);
|
||||
this.renderAll();
|
||||
this.startAITimers();
|
||||
}
|
||||
|
||||
startAITimers() {
|
||||
this.stopAITimers();
|
||||
for (let seat = 1; seat < this.playerCount; seat++) {
|
||||
this.scheduleAITick(seat);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleAITick(seat) {
|
||||
const skill = this.opponents[seat - 1]?.skill ?? 3;
|
||||
this.aiTimers[seat] = this.time.delayedCall(nextThinkDelay(skill), () => this.aiTick(seat));
|
||||
}
|
||||
|
||||
stopAITimers() {
|
||||
for (const t of this.aiTimers) { if (t) t.remove(false); }
|
||||
this.aiTimers = [];
|
||||
}
|
||||
|
||||
aiTick(seat) {
|
||||
if (this.roundEnding || !this.gs || this.gs.phase !== 'playing') return;
|
||||
|
||||
const skill = this.opponents[seat - 1]?.skill ?? 3;
|
||||
const action = chooseAction(this.gs, seat, skill);
|
||||
if (action) this.applyAIAction(seat, action);
|
||||
|
||||
if (this.checkEnd()) return;
|
||||
this.scheduleAITick(seat);
|
||||
}
|
||||
|
||||
applyAIAction(seat, action) {
|
||||
if (action.kind === 'foundation') {
|
||||
const card = this.actionCard(seat, action);
|
||||
const log = playToFoundation(this.gs, seat, action.source, action.dest);
|
||||
if (log) {
|
||||
const dest = this.foundationPos[action.dest];
|
||||
const origin = this.oppPanelPos[seat];
|
||||
if (card && dest && origin) this.spawnFly(card, origin.x, origin.y, dest.x, dest.y, seat);
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
if (action.source.type === 'nerts' && Math.random() < 0.5) {
|
||||
this.opponentPortraits[seat]?.playEmotion('happy');
|
||||
}
|
||||
}
|
||||
} else if (action.kind === 'work') {
|
||||
playToWork(this.gs, seat, action.source, action.dest);
|
||||
} else if (action.kind === 'flip') {
|
||||
flipStock(this.gs, seat);
|
||||
}
|
||||
this.renderFoundations();
|
||||
this.renderOpponents();
|
||||
}
|
||||
|
||||
actionCard(seat, action) {
|
||||
const s = action.source;
|
||||
if (s.type === 'nerts') return nertsTop(this.gs, seat);
|
||||
if (s.type === 'waste') return wasteTop(this.gs, seat);
|
||||
if (s.type === 'work') return workTop(this.gs, seat, s.idx);
|
||||
return null;
|
||||
}
|
||||
|
||||
checkEnd() {
|
||||
if (this.roundEnding) return true;
|
||||
if (this.gs.nertsCaller !== null || allStuck(this.gs)) {
|
||||
this.finishRound();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
finishRound() {
|
||||
this.roundEnding = true;
|
||||
this.stopAITimers();
|
||||
this._clearDrag();
|
||||
|
||||
const summary = endRound(this.gs);
|
||||
this.totals = this.gs.players.map((p) => p.totalScore);
|
||||
this.renderAll();
|
||||
|
||||
const winner = this.gs.winner;
|
||||
if (this.gs.phase === 'matchover') {
|
||||
const youWon = this.gs.matchWinner === 0;
|
||||
this.recordHistory(youWon);
|
||||
}
|
||||
// Portrait reactions
|
||||
for (let s = 1; s < this.playerCount; s++) {
|
||||
this.opponentPortraits[s]?.playEmotion?.(winner === s ? 'happy' : 'upset');
|
||||
}
|
||||
playSound(this, winner === 0 ? SFX.CASINO_WIN : SFX.CARD_PLACE);
|
||||
|
||||
this.showRoundPanel(summary);
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────
|
||||
|
||||
renderAll() {
|
||||
this.renderLocal();
|
||||
this.renderFoundations();
|
||||
this.renderOpponents();
|
||||
}
|
||||
|
||||
clearLocalCards() {
|
||||
for (const c of this.localCardObjs.values()) c.destroy();
|
||||
this.localCardObjs.clear();
|
||||
for (const o of this.localExtraObjs) o.destroy();
|
||||
this.localExtraObjs = [];
|
||||
}
|
||||
|
||||
renderLocal() {
|
||||
this.clearLocalCards();
|
||||
const p = this.gs.players[0];
|
||||
|
||||
// Nerts pile: a back beneath (if >1) and the face-up top.
|
||||
if (p.nerts.length > 1) {
|
||||
this.localExtraObjs.push(
|
||||
this.makeCardSprite({ id: 'nerts-back' }, NERTS_POS.x, NERTS_POS.y, { faceUp: false, store: false })
|
||||
);
|
||||
}
|
||||
const nt = p.nerts[p.nerts.length - 1];
|
||||
if (nt) {
|
||||
const c = this.makeCardSprite(nt, NERTS_POS.x, NERTS_POS.y, { faceUp: true });
|
||||
this.makeDraggable(c, { kind: 'nerts' });
|
||||
}
|
||||
this.localNertsText.setText(`${p.nerts.length} left`);
|
||||
|
||||
// Work piles: fan downward; cards starting a valid run are draggable.
|
||||
for (let i = 0; i < WORK_PILE_COUNT; i++) {
|
||||
const pile = p.work[i];
|
||||
for (let k = 0; k < pile.length; k++) {
|
||||
const card = pile[k];
|
||||
const x = WORK_X[i];
|
||||
const y = WORK_TOP_Y + k * FAN_Y;
|
||||
const c = this.makeCardSprite(card, x, y, { faceUp: true });
|
||||
if (validRunFromIndex(pile, k)) this.makeDraggable(c, { kind: 'work', idx: i, k });
|
||||
}
|
||||
}
|
||||
|
||||
// Stock (face-down) + count.
|
||||
if (p.stockDraw.length > 0) {
|
||||
this.localExtraObjs.push(
|
||||
this.makeCardSprite({ id: 'stock-back' }, STOCK_POS.x, STOCK_POS.y, { faceUp: false, store: false })
|
||||
);
|
||||
}
|
||||
this.localStockText.setText(
|
||||
p.stockDraw.length > 0 ? `${p.stockDraw.length} (click to flip 3)` : 'click to recycle'
|
||||
);
|
||||
|
||||
// Waste: show up to the last 3, fanned right; top is draggable.
|
||||
const wasteShown = p.stockWaste.slice(-3);
|
||||
wasteShown.forEach((card, i) => {
|
||||
const x = WASTE_POS.x + i * WASTE_FAN;
|
||||
const isTop = i === wasteShown.length - 1;
|
||||
const c = this.makeCardSprite(card, x, WASTE_POS.y, { faceUp: true });
|
||||
if (isTop) this.makeDraggable(c, { kind: 'waste' });
|
||||
});
|
||||
|
||||
this.localScoreText.setText(`Score: ${this.gs.players[0].totalScore}`);
|
||||
}
|
||||
|
||||
renderFoundations() {
|
||||
for (const c of this.foundationCardObjs) c.destroy();
|
||||
this.foundationCardObjs = [];
|
||||
for (let idx = 0; idx < this.gs.foundations.length; idx++) {
|
||||
const slot = this.gs.foundations[idx];
|
||||
if (!slot || slot.cards.length === 0) continue;
|
||||
const top = slot.cards[slot.cards.length - 1];
|
||||
const pos = this.foundationPos[idx];
|
||||
const c = this.makeCardSprite(top, pos.x, pos.y, {
|
||||
faceUp: true, rim: SEAT_COLORS[top.owner], store: false,
|
||||
});
|
||||
this.foundationCardObjs.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
renderOpponents() {
|
||||
for (let seat = 1; seat < this.playerCount; seat++) {
|
||||
const dyn = this.oppDynamic[seat];
|
||||
if (!dyn) continue;
|
||||
dyn.nertsText.setText(`Nerts: ${this.gs.players[seat].nerts.length}`);
|
||||
dyn.scoreText.setText(`Score: ${this.gs.players[seat].totalScore}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Card sprites ─────────────────────────────────────────────────────────
|
||||
|
||||
makeCardSprite(card, x, y, { faceUp = true, rim = null, store = true } = {}) {
|
||||
const c = this.add.container(x, y).setDepth(D.card);
|
||||
this.renderCardFace(c, card, faceUp, rim);
|
||||
c.card = card;
|
||||
c.homeX = x;
|
||||
c.homeY = y;
|
||||
if (store && card && card.id !== undefined) this.localCardObjs.set(card.id, c);
|
||||
return c;
|
||||
}
|
||||
|
||||
renderCardFace(container, card, faceUp, rim) {
|
||||
container.removeAll(true);
|
||||
const x = -CARD_W / 2, y = -CARD_H / 2;
|
||||
|
||||
if (!faceUp) {
|
||||
if (this.cardBack?.spriteIndex !== undefined && this.textures.exists('cardbacks')) {
|
||||
container.add(this.add.image(0, 0, 'cardbacks', this.cardBack.spriteIndex)
|
||||
.setDisplaySize(CARD_W, CARD_H).setOrigin(0.5));
|
||||
} else {
|
||||
const g = this.add.graphics();
|
||||
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);
|
||||
container.add(g);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(0xfbf6e7, 1);
|
||||
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
|
||||
g.lineStyle(rim ? 4 : 2, rim ?? 0xcc803a, rim ? 1 : 0.5);
|
||||
g.strokeRoundedRect(x + 2, y + 2, CARD_W - 4, CARD_H - 4, CARD_R - 1);
|
||||
container.add(g);
|
||||
|
||||
const colorHex = card.isRed ? '#c0392b' : '#1a1208';
|
||||
const label = card.label;
|
||||
const sym = card.suitSymbol;
|
||||
container.add(this.add.text(x + 7, y + 5, label, {
|
||||
fontFamily: 'Righteous', fontSize: '20px', color: colorHex,
|
||||
}));
|
||||
container.add(this.add.text(x + 8, y + 28, sym, {
|
||||
fontFamily: 'sans-serif', fontSize: '18px', color: colorHex,
|
||||
}));
|
||||
container.add(this.add.text(0, 4, sym, {
|
||||
fontFamily: 'sans-serif', fontSize: '40px', color: colorHex,
|
||||
}).setOrigin(0.5));
|
||||
container.add(this.add.text(x + CARD_W - 7, y + CARD_H - 5, label, {
|
||||
fontFamily: 'Righteous', fontSize: '20px', color: colorHex,
|
||||
}).setOrigin(1, 1));
|
||||
}
|
||||
|
||||
makeDraggable(container, descriptor) {
|
||||
container.setInteractive(
|
||||
new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H),
|
||||
Phaser.Geom.Rectangle.Contains
|
||||
);
|
||||
container.input.cursor = 'grab';
|
||||
container.on('pointerdown', (pointer) => this.onCardDown(descriptor, container, pointer));
|
||||
}
|
||||
|
||||
/** A short throwaway sprite that flies from->to for AI foundation plays. */
|
||||
spawnFly(card, fromX, fromY, toX, toY, ownerSeat) {
|
||||
const c = this.add.container(fromX, fromY).setDepth(D.fly);
|
||||
this.renderCardFace(c, card, true, SEAT_COLORS[ownerSeat]);
|
||||
c.setScale(0.7);
|
||||
this.tweens.add({
|
||||
targets: c, x: toX, y: toY, scale: 1, duration: 300, ease: 'Cubic.easeOut',
|
||||
onComplete: () => c.destroy(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Local input: stock / drag-drop ───────────────────────────────────────
|
||||
|
||||
isPlayable() {
|
||||
return this.gs && this.gs.phase === 'playing' && !this.roundEnding;
|
||||
}
|
||||
|
||||
onStockClick() {
|
||||
if (!this.isPlayable() || this.dragState) return;
|
||||
const log = flipStock(this.gs, 0);
|
||||
if (log) {
|
||||
playSound(this, SFX.CARD_SHOW);
|
||||
this.renderLocal();
|
||||
}
|
||||
}
|
||||
|
||||
onCardDown(descriptor, container, pointer) {
|
||||
if (!this.isPlayable() || this.dragState) return;
|
||||
const sprites = this.dragSpritesFor(descriptor);
|
||||
if (sprites.length === 0) return;
|
||||
this.potentialDrag = {
|
||||
descriptor,
|
||||
sprites: sprites.map((obj) => ({ obj, offX: obj.x - pointer.x, offY: obj.y - pointer.y })),
|
||||
startX: pointer.x, startY: pointer.y,
|
||||
};
|
||||
}
|
||||
|
||||
dragSpritesFor(descriptor) {
|
||||
if (descriptor.kind === 'nerts') {
|
||||
const c = nertsTop(this.gs, 0);
|
||||
return c ? [this.localCardObjs.get(c.id)].filter(Boolean) : [];
|
||||
}
|
||||
if (descriptor.kind === 'waste') {
|
||||
const c = wasteTop(this.gs, 0);
|
||||
return c ? [this.localCardObjs.get(c.id)].filter(Boolean) : [];
|
||||
}
|
||||
if (descriptor.kind === 'work') {
|
||||
const pile = this.gs.players[0].work[descriptor.idx];
|
||||
return pile.slice(descriptor.k).map((card) => this.localCardObjs.get(card.id)).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
setupDragHandlers() {
|
||||
this.input.on('pointermove', (pointer) => {
|
||||
if (!pointer.isDown) return;
|
||||
if (this.dragState) {
|
||||
this.updateDrag(pointer);
|
||||
} else if (this.potentialDrag) {
|
||||
const dx = pointer.x - this.potentialDrag.startX;
|
||||
const dy = pointer.y - this.potentialDrag.startY;
|
||||
if (dx * dx + dy * dy > 64) this.promoteDrag();
|
||||
}
|
||||
});
|
||||
this.input.on('pointerup', () => {
|
||||
if (this.dragState) this.endDrag();
|
||||
else if (this.potentialDrag) {
|
||||
const pd = this.potentialDrag;
|
||||
this.potentialDrag = null;
|
||||
this.onCardClick(pd.descriptor); // tap = try auto-play to a foundation
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
promoteDrag() {
|
||||
const pd = this.potentialDrag;
|
||||
this.potentialDrag = null;
|
||||
pd.sprites.forEach(({ obj }, i) => {
|
||||
obj.setDepth(D.drag + i);
|
||||
this.tweens.add({ targets: obj, scaleX: 1.06, scaleY: 1.06, duration: 90 });
|
||||
});
|
||||
this.dragState = pd;
|
||||
}
|
||||
|
||||
updateDrag(pointer) {
|
||||
for (const { obj, offX, offY } of this.dragState.sprites) {
|
||||
obj.x = pointer.x + offX;
|
||||
obj.y = pointer.y + offY;
|
||||
}
|
||||
const primary = this.dragState.sprites[0].obj;
|
||||
this.updateDropHighlight(this.getDropTargetAt(primary.x, primary.y));
|
||||
}
|
||||
|
||||
getDropTargetAt(x, y) {
|
||||
for (let f = 0; f < this.foundationPos.length; f++) {
|
||||
const pos = this.foundationPos[f];
|
||||
if (Math.abs(x - pos.x) < CARD_W * 0.7 && Math.abs(y - pos.y) < CARD_H * 0.7) {
|
||||
return { type: 'foundation', idx: f };
|
||||
}
|
||||
}
|
||||
if (y > 470) {
|
||||
for (let i = 0; i < WORK_PILE_COUNT; i++) {
|
||||
if (Math.abs(x - WORK_X[i]) < CARD_W * 0.7) return { type: 'work', idx: i };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
updateDropHighlight(target) {
|
||||
if (this.dropHighlight) { this.dropHighlight.destroy(); this.dropHighlight = null; }
|
||||
if (!target) return;
|
||||
const pos = target.type === 'foundation' ? this.foundationPos[target.idx]
|
||||
: { x: WORK_X[target.idx], y: WORK_TOP_Y };
|
||||
const color = target.type === 'foundation' ? 0xffd700 : 0x4dabf7;
|
||||
this.dropHighlight = this.add.rectangle(pos.x, pos.y, CARD_W + 16, CARD_H + 16, color, 0.18)
|
||||
.setStrokeStyle(3, color, 0.9).setDepth(D.card - 1);
|
||||
}
|
||||
|
||||
endDrag() {
|
||||
const ds = this.dragState;
|
||||
this.dragState = null;
|
||||
if (this.dropHighlight) { this.dropHighlight.destroy(); this.dropHighlight = null; }
|
||||
|
||||
const primary = ds.sprites[0].obj;
|
||||
const target = this.getDropTargetAt(primary.x, primary.y);
|
||||
const committed = target ? this.commitDrop(ds.descriptor, target) : false;
|
||||
// On success the sprites are torn down by the re-render; only animate them
|
||||
// back home when the drop was rejected.
|
||||
if (!committed) {
|
||||
ds.sprites.forEach(({ obj }) => {
|
||||
this.tweens.add({
|
||||
targets: obj, x: obj.homeX, y: obj.homeY, scaleX: 1, scaleY: 1,
|
||||
duration: 240, ease: 'Back.easeOut',
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sourceFor(descriptor) {
|
||||
if (descriptor.kind === 'nerts') return { type: 'nerts' };
|
||||
if (descriptor.kind === 'waste') return { type: 'waste' };
|
||||
const pile = this.gs.players[0].work[descriptor.idx];
|
||||
return { type: 'work', idx: descriptor.idx, count: pile.length - descriptor.k };
|
||||
}
|
||||
|
||||
commitDrop(descriptor, target) {
|
||||
const source = this.sourceFor(descriptor);
|
||||
let log = null;
|
||||
if (target.type === 'foundation') {
|
||||
if ((source.count ?? 1) > 1) return false; // foundations take single cards only
|
||||
log = playToFoundation(this.gs, 0, source, target.idx);
|
||||
} else {
|
||||
log = playToWork(this.gs, 0, source, target.idx);
|
||||
}
|
||||
if (!log) return false;
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
this.afterLocalMove();
|
||||
return true;
|
||||
}
|
||||
|
||||
onCardClick(descriptor) {
|
||||
if (!this.isPlayable()) return;
|
||||
// Tap = try to play the single top card onto the first legal foundation.
|
||||
if (descriptor.kind === 'work') {
|
||||
const pile = this.gs.players[0].work[descriptor.idx];
|
||||
if (descriptor.k !== pile.length - 1) return; // only the visible top can quick-play
|
||||
}
|
||||
const source = this.sourceFor(descriptor);
|
||||
if ((source.count ?? 1) > 1) return;
|
||||
const card = descriptor.kind === 'nerts' ? nertsTop(this.gs, 0)
|
||||
: descriptor.kind === 'waste' ? wasteTop(this.gs, 0)
|
||||
: workTop(this.gs, 0, descriptor.idx);
|
||||
if (!card) return;
|
||||
for (let f = 0; f < this.gs.foundations.length; f++) {
|
||||
if (canPlayOnFoundation(this.gs, card, f)) {
|
||||
if (playToFoundation(this.gs, 0, source, f)) {
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
this.afterLocalMove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterLocalMove() {
|
||||
this.renderLocal();
|
||||
this.renderFoundations();
|
||||
this.renderOpponents();
|
||||
this.checkEnd();
|
||||
}
|
||||
|
||||
_clearDrag() {
|
||||
if (this.dropHighlight) { this.dropHighlight.destroy(); this.dropHighlight = null; }
|
||||
this.dragState = null;
|
||||
this.potentialDrag = null;
|
||||
}
|
||||
|
||||
// ── Round / match summary panel ──────────────────────────────────────────
|
||||
|
||||
showRoundPanel(summary) {
|
||||
const matchOver = this.gs.phase === 'matchover';
|
||||
const winner = this.gs.winner;
|
||||
const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.68)
|
||||
.setInteractive().setDepth(D.modal);
|
||||
this.panelObjs.push(overlay);
|
||||
|
||||
const panelW = 760;
|
||||
const panelH = 120 + this.playerCount * 52 + 90;
|
||||
const panel = this.add.rectangle(CX, CY, panelW, panelH, COLORS.panel, 1)
|
||||
.setStrokeStyle(2, COLORS.accent).setDepth(D.modal);
|
||||
this.panelObjs.push(panel);
|
||||
|
||||
const title = matchOver
|
||||
? (this.gs.matchWinner === 0 ? 'You win the match!'
|
||||
: this.gs.matchWinner === -1 ? "It's a tie!"
|
||||
: `${this.nameForSeat(this.gs.matchWinner)} wins the match!`)
|
||||
: (winner === 0 ? 'Nerts! You won the round' : `${this.nameForSeat(winner)} called Nerts!`);
|
||||
const t = this.add.text(CX, CY - panelH / 2 + 44, title, {
|
||||
fontFamily: 'Righteous', fontSize: '38px',
|
||||
color: (matchOver ? this.gs.matchWinner : winner) === 0 ? COLORS.goldHex : COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.modal + 1);
|
||||
this.panelObjs.push(t);
|
||||
|
||||
// Scoreboard rows
|
||||
const rowTop = CY - panelH / 2 + 96;
|
||||
summary.forEach((s, i) => {
|
||||
const y = rowTop + i * 52;
|
||||
const name = this.nameForSeat(s.seat);
|
||||
const deltaSign = s.roundScore >= 0 ? '+' : '';
|
||||
const line = `${name}: ${s.founded} on foundations, ${s.nertsLeft} left in Nerts (${deltaSign}${s.roundScore})`;
|
||||
this.panelObjs.push(this.add.text(CX - panelW / 2 + 50, y, line, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px',
|
||||
color: s.seat === winner ? COLORS.accentHex : COLORS.textHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.modal + 1));
|
||||
this.panelObjs.push(this.add.text(CX + panelW / 2 - 50, y, `Total: ${s.totalScore}`, {
|
||||
fontFamily: 'Righteous', fontSize: '22px', color: SEAT_COLOR_HEX[s.seat] ?? COLORS.textHex,
|
||||
}).setOrigin(1, 0.5).setDepth(D.modal + 1));
|
||||
});
|
||||
|
||||
const btnY = CY + panelH / 2 - 50;
|
||||
if (matchOver) {
|
||||
const b1 = new Button(this, CX - 130, btnY, 'Play again', () => this.restartMatch(),
|
||||
{ width: 220, fontSize: 22 }).setDepth(D.modal + 1);
|
||||
this.panelObjs.push(b1);
|
||||
} else {
|
||||
const b1 = new Button(this, CX - 130, btnY, 'Next round', () => this.nextRound(),
|
||||
{ width: 220, fontSize: 22, bg: COLORS.accent, textColor: COLORS.textDarkHex }).setDepth(D.modal + 1);
|
||||
this.panelObjs.push(b1);
|
||||
}
|
||||
const b2 = new Button(this, CX + 130, btnY, 'Leave', () => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 220, fontSize: 22 }).setDepth(D.modal + 1);
|
||||
this.panelObjs.push(b2);
|
||||
}
|
||||
|
||||
clearPanel() {
|
||||
for (const o of this.panelObjs) o.destroy();
|
||||
this.panelObjs = [];
|
||||
}
|
||||
|
||||
nextRound() {
|
||||
this.clearPanel();
|
||||
this.startRound();
|
||||
}
|
||||
|
||||
restartMatch() {
|
||||
this.totals = new Array(this.playerCount).fill(0);
|
||||
this.clearPanel();
|
||||
this.startRound();
|
||||
}
|
||||
|
||||
nameForSeat(seat) {
|
||||
if (seat === 0) return auth.user?.username ?? 'You';
|
||||
if (seat < 0) return 'Nobody';
|
||||
return this.opponents[seat - 1]?.name ?? `Player ${seat + 1}`;
|
||||
}
|
||||
|
||||
async recordHistory(youWon) {
|
||||
try {
|
||||
await api.post('/history/single-player', {
|
||||
slug: 'nerts',
|
||||
score: this.totals[0],
|
||||
opponentScores: this.totals.slice(1),
|
||||
result: youWon ? 'win' : 'loss',
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[nerts] failed to record history', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
// Nerts (Pounce / Racing Demon) — pure rules + state.
|
||||
//
|
||||
// IMPORTANT — state model: unlike the other games in this engine (which return
|
||||
// a brand-new immutable state per action), Nerts is REAL-TIME. Every player —
|
||||
// the human and every AI — fires many small actions per second with no turns.
|
||||
// Deep-cloning the whole state on each action would be wasteful, so this engine
|
||||
// uses a MUTABLE state with pure query helpers and in-place mutators. Mutators
|
||||
// return a small log entry the scene uses to drive animation.
|
||||
//
|
||||
// Rules implemented:
|
||||
// - Each player owns a full 52-card deck. Deal: 13 → Nerts pile (top face-up),
|
||||
// 1 each → 4 work piles (face-up), remaining 34 → stock (face-down draw).
|
||||
// - Work piles build DOWN, alternating color; movable sequences; empty work
|
||||
// pile accepts any card.
|
||||
// - Stock flips 3 at a time to a face-up waste; top of waste is playable; when
|
||||
// the draw empties, the waste recycles (no shuffle) back into the draw.
|
||||
// - Foundations (shared center): start on any Ace, build UP by suit to King.
|
||||
// Any player may play on any foundation.
|
||||
// - A round ends the instant a player empties their Nerts pile. Safeguard: if
|
||||
// every player is genuinely stuck, the round ends and is scored as-is.
|
||||
// - Scoring: +1 per card you put on foundations, -2 per card left in your Nerts
|
||||
// pile. Accumulate across rounds until a player reaches targetScore.
|
||||
|
||||
import { Deck } from '../cards/Deck.js';
|
||||
|
||||
export const NERTS_PILE_SIZE = 13;
|
||||
export const WORK_PILE_COUNT = 4;
|
||||
export const STOCK_FLIP = 3;
|
||||
export const DEFAULT_TARGET_SCORE = 100;
|
||||
|
||||
// Ace-low ranking for Nerts (A=1 … K=13). Note the shared Deck Card uses A=14,
|
||||
// which is wrong for both foundation build-up and work-pile build-down here.
|
||||
const NERTS_RANK = {
|
||||
A: 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
|
||||
'8': 8, '9': 9, T: 10, J: 11, Q: 12, K: 13,
|
||||
};
|
||||
|
||||
/** Ace-low rank value (1..13) for a card. */
|
||||
export function rv(card) {
|
||||
return NERTS_RANK[card.rank];
|
||||
}
|
||||
|
||||
function isRed(card) {
|
||||
return card.suit === 'h' || card.suit === 'd';
|
||||
}
|
||||
|
||||
// ── State construction ────────────────────────────────────────────────────────
|
||||
|
||||
export function createInitialState({ playerCount, targetScore = DEFAULT_TARGET_SCORE, totals = null } = {}) {
|
||||
if (playerCount < 2 || playerCount > 4) {
|
||||
throw new Error(`Nerts supports 2..4 players, got ${playerCount}`);
|
||||
}
|
||||
|
||||
let cardId = 0;
|
||||
const players = [];
|
||||
for (let seat = 0; seat < playerCount; seat++) {
|
||||
const deck = new Deck();
|
||||
deck.shuffle();
|
||||
// Tag every card with a unique id (decks repeat rank+suit) and its owner.
|
||||
for (const c of deck.cards) { c.id = cardId++; c.owner = seat; }
|
||||
|
||||
const nerts = deck.deal(NERTS_PILE_SIZE);
|
||||
const work = [];
|
||||
for (let i = 0; i < WORK_PILE_COUNT; i++) work.push(deck.deal(1));
|
||||
const stockDraw = deck.cards.splice(0); // remaining 35 (52 - 13 - 4)
|
||||
|
||||
players.push({
|
||||
seat,
|
||||
nerts, // last element = face-up top
|
||||
work, // 4 piles, build down alt-color; last = top
|
||||
stockDraw, // face-down; draw from the end
|
||||
stockWaste: [], // face-up; last = playable top
|
||||
roundScore: 0,
|
||||
totalScore: totals ? (totals[seat] ?? 0) : 0,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
phase: 'playing', // 'playing' | 'roundover' | 'matchover'
|
||||
targetScore,
|
||||
// Fixed-length slot array. Each slot is null (empty — accepts an Ace) or a
|
||||
// foundation { suit, cards: [...] }. Max foundations = 4 suits × playerCount.
|
||||
foundations: new Array(4 * playerCount).fill(null),
|
||||
players,
|
||||
nertsCaller: null, // seat that emptied its Nerts pile
|
||||
winner: null, // round/match winner seat, or -1 for draw
|
||||
matchWinner: null, // set when phase === 'matchover'
|
||||
};
|
||||
}
|
||||
|
||||
// ── Source helpers ──────────────────────────────────────────────────────────
|
||||
// A `source` is { type:'nerts'|'waste'|'work', idx?, count? }.
|
||||
|
||||
export function nertsTop(state, seat) {
|
||||
const n = state.players[seat].nerts;
|
||||
return n.length > 0 ? n[n.length - 1] : null;
|
||||
}
|
||||
|
||||
export function wasteTop(state, seat) {
|
||||
const w = state.players[seat].stockWaste;
|
||||
return w.length > 0 ? w[w.length - 1] : null;
|
||||
}
|
||||
|
||||
export function workTop(state, seat, wIdx) {
|
||||
const p = state.players[seat].work[wIdx];
|
||||
return p.length > 0 ? p[p.length - 1] : null;
|
||||
}
|
||||
|
||||
/** The single card referenced by a source's top (ignores count). */
|
||||
function sourceTopCard(state, seat, source) {
|
||||
if (source.type === 'nerts') return nertsTop(state, seat);
|
||||
if (source.type === 'waste') return wasteTop(state, seat);
|
||||
if (source.type === 'work') {
|
||||
const pile = state.players[seat].work[source.idx];
|
||||
const k = source.count ? pile.length - source.count : pile.length - 1;
|
||||
return pile[k] ?? null; // bottom card of the moved run
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Work-pile sequence validation ─────────────────────────────────────────────
|
||||
|
||||
/** True if pile[k..end] is a descending, alternating-color run. */
|
||||
export function validRunFromIndex(pile, k) {
|
||||
for (let i = k; i < pile.length - 1; i++) {
|
||||
const a = pile[i], b = pile[i + 1];
|
||||
if (rv(b) !== rv(a) - 1) return false;
|
||||
if (isRed(a) === isRed(b)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Smallest index whose tail forms a valid movable run (top single always qualifies). */
|
||||
export function maxRunStart(pile) {
|
||||
if (pile.length === 0) return 0;
|
||||
let k = pile.length - 1;
|
||||
while (k > 0) {
|
||||
const a = pile[k - 1], b = pile[k];
|
||||
if (rv(b) === rv(a) - 1 && isRed(a) !== isRed(b)) k--;
|
||||
else break;
|
||||
}
|
||||
return k;
|
||||
}
|
||||
|
||||
// ── Legality ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function foundationTopRank(slot) {
|
||||
return slot.cards.length > 0 ? rv(slot.cards[slot.cards.length - 1]) : 0;
|
||||
}
|
||||
|
||||
/** Can `card` (a single card) go onto foundation slot `fIdx`? */
|
||||
export function canPlayOnFoundation(state, card, fIdx) {
|
||||
if (!card) return false;
|
||||
const slot = state.foundations[fIdx];
|
||||
if (!slot) return rv(card) === 1; // empty slot needs an Ace
|
||||
if (card.suit !== slot.suit) return false;
|
||||
const top = foundationTopRank(slot);
|
||||
if (top >= 13) return false; // completed
|
||||
return rv(card) === top + 1;
|
||||
}
|
||||
|
||||
/** Can `card` (the bottom of a moved run) land on work pile `wIdx`? */
|
||||
export function canPlayOnWork(state, seat, card, wIdx) {
|
||||
if (!card) return false;
|
||||
const pile = state.players[seat].work[wIdx];
|
||||
if (pile.length === 0) return true; // empty accepts anything
|
||||
const top = pile[pile.length - 1];
|
||||
return rv(card) === rv(top) - 1 && isRed(card) !== isRed(top);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate legal moves for `seat`.
|
||||
* Returns { kind:'foundation'|'work', source, dest, card } entries.
|
||||
* source: { type, idx?, count? }
|
||||
* dest: foundation index (kind 'foundation') or work index (kind 'work')
|
||||
*/
|
||||
export function getValidPlays(state, seat) {
|
||||
if (state.phase !== 'playing') return [];
|
||||
const plays = [];
|
||||
const p = state.players[seat];
|
||||
|
||||
// Single-card sources that can hit foundations or work piles.
|
||||
const singleSources = [];
|
||||
const nt = nertsTop(state, seat);
|
||||
if (nt) singleSources.push({ source: { type: 'nerts' }, card: nt });
|
||||
const wt = wasteTop(state, seat);
|
||||
if (wt) singleSources.push({ source: { type: 'waste' }, card: wt });
|
||||
for (let w = 0; w < WORK_PILE_COUNT; w++) {
|
||||
const t = workTop(state, seat, w);
|
||||
if (t) singleSources.push({ source: { type: 'work', idx: w, count: 1 }, card: t });
|
||||
}
|
||||
|
||||
for (const { source, card } of singleSources) {
|
||||
for (let f = 0; f < state.foundations.length; f++) {
|
||||
if (canPlayOnFoundation(state, card, f)) {
|
||||
plays.push({ kind: 'foundation', source, dest: f, card });
|
||||
}
|
||||
}
|
||||
for (let w = 0; w < WORK_PILE_COUNT; w++) {
|
||||
if (source.type === 'work' && source.idx === w) continue;
|
||||
if (canPlayOnWork(state, seat, card, w)) {
|
||||
plays.push({ kind: 'work', source, dest: w, card });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Work → work sequence moves (more than the single top card).
|
||||
for (let s = 0; s < WORK_PILE_COUNT; s++) {
|
||||
const pile = p.work[s];
|
||||
const start = maxRunStart(pile);
|
||||
for (let k = start; k < pile.length - 1; k++) { // k = end is the single, handled above
|
||||
const bottom = pile[k];
|
||||
const count = pile.length - k;
|
||||
for (let d = 0; d < WORK_PILE_COUNT; d++) {
|
||||
if (d === s) continue;
|
||||
if (canPlayOnWork(state, seat, bottom, d)) {
|
||||
plays.push({ kind: 'work', source: { type: 'work', idx: s, count }, dest: d, card: bottom });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return plays;
|
||||
}
|
||||
|
||||
export function canFlipStock(state, seat) {
|
||||
const p = state.players[seat];
|
||||
return p.stockDraw.length > 0 || p.stockWaste.length > 0;
|
||||
}
|
||||
|
||||
/** A seat is stuck only when it has no legal play AND cannot flip its stock. */
|
||||
export function isSeatStuck(state, seat) {
|
||||
if (canFlipStock(state, seat)) return false;
|
||||
return getValidPlays(state, seat).length === 0;
|
||||
}
|
||||
|
||||
export function allStuck(state) {
|
||||
return state.players.every((_, seat) => isSeatStuck(state, seat));
|
||||
}
|
||||
|
||||
// ── Mutators (mutate in place, return a log entry) ──────────────────────────────
|
||||
|
||||
function removeSourceCards(state, seat, source) {
|
||||
const p = state.players[seat];
|
||||
if (source.type === 'nerts') return [p.nerts.pop()];
|
||||
if (source.type === 'waste') return [p.stockWaste.pop()];
|
||||
if (source.type === 'work') {
|
||||
const pile = p.work[source.idx];
|
||||
const count = source.count ?? 1;
|
||||
return pile.splice(pile.length - count, count);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Play the single top card of `source` onto foundation `fIdx`. */
|
||||
export function playToFoundation(state, seat, source, fIdx) {
|
||||
const card = sourceTopCard(state, seat, source);
|
||||
if (!canPlayOnFoundation(state, card, fIdx)) return null;
|
||||
const [moved] = removeSourceCards(state, seat, source);
|
||||
if (!state.foundations[fIdx]) {
|
||||
state.foundations[fIdx] = { suit: moved.suit, cards: [moved] };
|
||||
} else {
|
||||
state.foundations[fIdx].cards.push(moved);
|
||||
}
|
||||
const log = { type: 'foundation', seat, source, fIdx, card: moved };
|
||||
checkNertsCall(state, seat);
|
||||
return log;
|
||||
}
|
||||
|
||||
/** Move `count` cards (a valid run, or a single) from `source` onto work pile `dstIdx`. */
|
||||
export function playToWork(state, seat, source, dstIdx) {
|
||||
const bottom = sourceTopCard(state, seat, source);
|
||||
if (!canPlayOnWork(state, seat, bottom, dstIdx)) return null;
|
||||
if (source.type === 'work' && source.idx === dstIdx) return null;
|
||||
const moved = removeSourceCards(state, seat, source);
|
||||
state.players[seat].work[dstIdx].push(...moved);
|
||||
const log = { type: 'work', seat, source, dstIdx, cards: moved };
|
||||
checkNertsCall(state, seat);
|
||||
return log;
|
||||
}
|
||||
|
||||
/** Flip up to STOCK_FLIP cards from draw to waste, recycling the waste if needed. */
|
||||
export function flipStock(state, seat) {
|
||||
const p = state.players[seat];
|
||||
if (p.stockDraw.length === 0) {
|
||||
if (p.stockWaste.length === 0) return null;
|
||||
p.stockDraw = p.stockWaste.reverse();
|
||||
p.stockWaste = [];
|
||||
}
|
||||
const n = Math.min(STOCK_FLIP, p.stockDraw.length);
|
||||
for (let i = 0; i < n; i++) p.stockWaste.push(p.stockDraw.pop());
|
||||
return { type: 'flip', seat, n };
|
||||
}
|
||||
|
||||
function checkNertsCall(state, seat) {
|
||||
if (state.players[seat].nerts.length === 0 && state.nertsCaller === null) {
|
||||
state.nertsCaller = seat;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scoring / round end ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Score the round: +1 per foundation card by owner, -2 per remaining Nerts card.
|
||||
* Updates each player's roundScore/totalScore, sets winner + phase. Returns a
|
||||
* per-seat summary array.
|
||||
*/
|
||||
export function endRound(state) {
|
||||
const foundationByOwner = new Array(state.players.length).fill(0);
|
||||
for (const slot of state.foundations) {
|
||||
if (!slot) continue;
|
||||
for (const card of slot.cards) foundationByOwner[card.owner] += 1;
|
||||
}
|
||||
|
||||
const summary = state.players.map((p) => {
|
||||
const founded = foundationByOwner[p.seat];
|
||||
const nertsLeft = p.nerts.length;
|
||||
p.roundScore = founded - 2 * nertsLeft;
|
||||
p.totalScore += p.roundScore;
|
||||
return { seat: p.seat, founded, nertsLeft, roundScore: p.roundScore, totalScore: p.totalScore };
|
||||
});
|
||||
|
||||
// Round winner: the Nerts caller, else highest round score (deadlock case).
|
||||
if (state.nertsCaller !== null) {
|
||||
state.winner = state.nertsCaller;
|
||||
} else {
|
||||
let best = -1, bestScore = -Infinity;
|
||||
for (const p of state.players) {
|
||||
if (p.roundScore > bestScore) { bestScore = p.roundScore; best = p.seat; }
|
||||
}
|
||||
state.winner = best;
|
||||
}
|
||||
|
||||
// Match over if anyone has reached the target.
|
||||
const maxTotal = Math.max(...state.players.map((p) => p.totalScore));
|
||||
if (maxTotal >= state.targetScore) {
|
||||
state.phase = 'matchover';
|
||||
const leaders = state.players.filter((p) => p.totalScore === maxTotal);
|
||||
state.matchWinner = leaders.length === 1 ? leaders[0].seat : -1;
|
||||
} else {
|
||||
state.phase = 'roundover';
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import RouletteGame from './games/roulette/RouletteGame.js';
|
|||
import MexicanTrainGame from './games/mexicantrain/MexicanTrainGame.js';
|
||||
import HeartsGame from './games/hearts/HeartsGame.js';
|
||||
import CatanGame from './games/catan/CatanGame.js';
|
||||
import NertsGame from './games/nerts/NertsGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -63,6 +64,7 @@ const config = {
|
|||
MexicanTrainGame,
|
||||
HeartsGame,
|
||||
CatanGame,
|
||||
NertsGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame' };
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
this.cardBackTiles = [];
|
||||
this.selectedTilePlacement = 'standard';
|
||||
this._initializing = false;
|
||||
this.skillByOpp = {}; // opp.id → AI skill level 1..5 (Nerts only)
|
||||
}
|
||||
|
||||
async create() {
|
||||
|
|
@ -177,6 +178,11 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
buildOpponentCardEl(opp, cardH) {
|
||||
// Default each opponent to a random skill of 2 or 3 (Nerts uses this).
|
||||
if (this.skillByOpp[opp.id] === undefined) {
|
||||
this.skillByOpp[opp.id] = Math.random() < 0.5 ? 2 : 3;
|
||||
}
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = [
|
||||
'flex:1', // fill exactly half of the row
|
||||
|
|
@ -340,6 +346,67 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
|
||||
info.appendChild(name);
|
||||
info.appendChild(bio);
|
||||
|
||||
// Skill control (Nerts only): pips always show the level; the +/- buttons
|
||||
// appear only when this opponent is selected.
|
||||
if (this.gameDef.slug === 'nerts') {
|
||||
bio.style.webkitLineClamp = '1';
|
||||
|
||||
const skillRow = document.createElement('div');
|
||||
skillRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:2px;';
|
||||
|
||||
const skillLabel = document.createElement('span');
|
||||
skillLabel.textContent = 'Skill';
|
||||
skillLabel.style.cssText = `font-family:"Julius Sans One";font-size:14px;color:${COLORS.mutedHex};`;
|
||||
|
||||
const btnCss = `width:26px;height:26px;border-radius:6px;border:1px solid ${COLORS.accentHex};`
|
||||
+ `background:${COLORS.panelHex};color:${COLORS.accentHex};font-size:18px;line-height:1;`
|
||||
+ `cursor:pointer;display:none;padding:0;`;
|
||||
const minus = document.createElement('button');
|
||||
minus.textContent = '−';
|
||||
minus.style.cssText = btnCss;
|
||||
const plus = document.createElement('button');
|
||||
plus.textContent = '+';
|
||||
plus.style.cssText = btnCss;
|
||||
|
||||
const pips = document.createElement('div');
|
||||
pips.style.cssText = 'display:flex;gap:5px;align-items:center;';
|
||||
const pipEls = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const p = document.createElement('span');
|
||||
p.style.cssText = 'width:12px;height:12px;border-radius:50%;display:inline-block;';
|
||||
pips.appendChild(p);
|
||||
pipEls.push(p);
|
||||
}
|
||||
const renderPips = () => {
|
||||
const lvl = this.skillByOpp[opp.id];
|
||||
pipEls.forEach((p, i) => {
|
||||
p.style.background = i < lvl ? COLORS.accentHex : 'transparent';
|
||||
p.style.border = `1px solid ${i < lvl ? COLORS.accentHex : COLORS.mutedHex}`;
|
||||
});
|
||||
};
|
||||
renderPips();
|
||||
|
||||
const change = (delta) => {
|
||||
const cur = this.skillByOpp[opp.id] ?? 3;
|
||||
this.skillByOpp[opp.id] = Math.max(1, Math.min(5, cur + delta));
|
||||
renderPips();
|
||||
};
|
||||
minus.addEventListener('click', (e) => { e.stopPropagation(); change(-1); });
|
||||
plus.addEventListener('click', (e) => { e.stopPropagation(); change(1); });
|
||||
|
||||
skillRow.appendChild(skillLabel);
|
||||
skillRow.appendChild(minus);
|
||||
skillRow.appendChild(pips);
|
||||
skillRow.appendChild(plus);
|
||||
info.appendChild(skillRow);
|
||||
|
||||
el._skillBtns = [minus, plus];
|
||||
el._setSkillEditable = (on) => {
|
||||
for (const b of el._skillBtns) b.style.display = on ? 'inline-block' : 'none';
|
||||
};
|
||||
}
|
||||
|
||||
el.appendChild(portraitWrap);
|
||||
el.appendChild(info);
|
||||
el._nameEl = name;
|
||||
|
|
@ -357,6 +424,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
if (this.selected.has(opp.id)) {
|
||||
this.selected.delete(opp.id);
|
||||
this.applyOpponentStyle(el, false);
|
||||
el._setSkillEditable?.(false);
|
||||
const card = this.cards.find((c) => c.opp.id === opp.id);
|
||||
if (card) {
|
||||
card.stopViz();
|
||||
|
|
@ -374,6 +442,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
if (oldCard) {
|
||||
oldCard.stopViz();
|
||||
this.applyOpponentStyle(oldCard.el, false);
|
||||
oldCard.el._setSkillEditable?.(false);
|
||||
oldCard.video.pause();
|
||||
oldCard.video.style.display = 'none';
|
||||
oldCard.canvas.style.display = 'block';
|
||||
|
|
@ -381,6 +450,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
}
|
||||
this.selected.add(opp.id);
|
||||
this.applyOpponentStyle(el, true);
|
||||
el._setSkillEditable?.(true);
|
||||
const card = this.cards.find((c) => c.opp.id === opp.id);
|
||||
if (card) {
|
||||
card.canvas.style.display = 'none';
|
||||
|
|
@ -563,7 +633,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
stopMenuMusic();
|
||||
const opponents = this.cards
|
||||
.filter(({ opp }) => this.selected.has(opp.id))
|
||||
.map(({ opp }) => opp);
|
||||
.map(({ opp }) => ({ ...opp, skill: this.skillByOpp[opp.id] ?? 3 }));
|
||||
this.scene.start('GameRoom', {
|
||||
game: this.gameDef,
|
||||
opponents,
|
||||
|
|
|
|||
|
|
@ -38,3 +38,4 @@ registerGame({ slug: 'roulette', name: 'Roulette', category: 'casino', minPlayer
|
|||
registerGame({ slug: 'mexicantrain', name: 'Mexican Train', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
|
||||
registerGame({ slug: 'hearts', name: 'Hearts', category: 'cards', cardGame: true, minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
|
||||
registerGame({ slug: 'catan', name: 'Settlers of Catan', category: 'tabletop', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3 });
|
||||
registerGame({ slug: 'nerts', name: 'Nerts', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue