Added Nerts

This commit is contained in:
Brian Fertig 2026-05-24 00:50:46 -06:00
parent f9e80eaedc
commit 7e022e8e9b
9 changed files with 1529 additions and 63 deletions

View File

@ -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()}`, {

View File

@ -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) ──────────────────────────────────────────────────────--

View File

@ -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);
}

View File

@ -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);
}
}
}

View File

@ -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;
}

View File

@ -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,
],
};

View File

@ -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,

View File

@ -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,

View File

@ -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 });