feat(phase10): replace static "Lay Down" button with interactive staging area

- Remove the hardcoded "Lay Down Phase" button and `findLaydown` auto-complete.
- Add a dynamic staging area at the bottom of the screen that renders empty slots for each phase group.
- Enable drag-and-drop of hand cards into staging slots to manually build phases.
- Add "Submit Phase" and "Clear" buttons to commit or reset the staged layout.
- Implement validation using `validateLaydown` before submitting.
- Fix Button hit area coordinates to ensure reliable click detection.
- Update status text rendering with a background graphic for better visibility.
This commit is contained in:
Brian Fertig 2026-05-17 14:37:42 -06:00
parent 3e64c2dafd
commit 208645246c
2 changed files with 252 additions and 52 deletions

View File

@ -5,7 +5,7 @@ import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.
import { auth } from '../../services/auth.js'; import { auth } from '../../services/auth.js';
import { api } from '../../services/api.js'; import { api } from '../../services/api.js';
import { playSound, SFX } from '../../ui/Sounds.js'; import { playSound, SFX } from '../../ui/Sounds.js';
import { PHASES, getPhase } from './PhaseSpec.js'; import { PHASES, getPhase, validateLaydown } from './PhaseSpec.js';
import { import {
applyDrawFromDeck, applyDrawFromDeck,
applyDrawFromDiscard, applyDrawFromDiscard,
@ -147,11 +147,11 @@ export default class Phase10Game extends Phaser.Scene {
this.seatChips = []; // seat → { container, phaseText, scoreText, doneRibbon } this.seatChips = []; // seat → { container, phaseText, scoreText, doneRibbon }
this.playbookOpen = false; this.playbookOpen = false;
this.playbookPanel = null; this.playbookPanel = null;
this.layDownBtn = null;
this.localHandCards = []; this.localHandCards = [];
this.dragState = null; this.dragState = null;
this.potentialDrag = null; this.potentialDrag = null;
this.stagingGroups = null;
} }
create() { create() {
@ -413,9 +413,10 @@ export default class Phase10Game extends Phaser.Scene {
} }
buildHUD() { buildHUD() {
this.statusText = this.add.text(CX, 36, '', { this.statusBg = this.add.graphics().setDepth(D.ui - 1).setVisible(false);
this.statusText = this.add.text(1052, 870, '', {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui); }).setOrigin(0, 0.5).setDepth(D.ui);
new Button(this, 55, GAME_HEIGHT - 20, 'Leave', () => this.scene.start('GameMenu'), { new Button(this, 55, GAME_HEIGHT - 20, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 110, height: 40, fontSize: 18, variant: 'ghost', width: 110, height: 40, fontSize: 18,
@ -424,11 +425,6 @@ export default class Phase10Game extends Phaser.Scene {
variant: 'ghost', width: 110, height: 40, fontSize: 18, variant: 'ghost', width: 110, height: 40, fontSize: 18,
}).setDepth(D.ui); }).setDepth(D.ui);
// "Lay Down" button — hidden until the local player can lay down.
this.layDownBtn = new Button(this, CX, CY + 200, 'Lay Down Phase', () => this.onLayDownClick(), {
width: 280, height: 56, fontSize: 22,
}).setDepth(D.ui);
this.layDownBtn.setVisible(false);
} }
// ── Drag-and-drop hand reordering ─────────────────────────────────────── // ── Drag-and-drop hand reordering ───────────────────────────────────────
@ -533,7 +529,10 @@ export default class Phase10Game extends Phaser.Scene {
} }
if (!newDropTarget) { if (!newDropTarget) {
const newInsertIdx = Phaser.Math.Clamp(Math.round((card.x - 460) / HAND_SPREAD), 0, n - 1); const stagedCount = this.stagingGroups
? this.stagingGroups.reduce((s, g) => s + g.slots.filter((id) => id !== null).length, 0) : 0;
const effectiveN = n - stagedCount;
const newInsertIdx = Phaser.Math.Clamp(Math.round((card.x - 460) / HAND_SPREAD), 0, Math.max(0, effectiveN - 1));
if (newInsertIdx !== ds.insertIdx) { if (newInsertIdx !== ds.insertIdx) {
ds.insertIdx = newInsertIdx; ds.insertIdx = newInsertIdx;
this.tweens.killTweensOf(ds.slotIndicator); this.tweens.killTweensOf(ds.slotIndicator);
@ -545,9 +544,15 @@ export default class Phase10Game extends Phaser.Scene {
_updateNonDraggedCards() { _updateNonDraggedCards() {
const ds = this.dragState; const ds = this.dragState;
const n = this.gs.players[0].hand.length; const hand = this.gs.players[0].hand;
const n = hand.length;
const stagedIds = this.stagingGroups
? new Set(this.stagingGroups.flatMap((g) => g.slots.filter((id) => id !== null)))
: new Set();
const others = []; const others = [];
for (let i = 0; i < n; i++) { if (i !== ds.cardIdx) others.push(i); } for (let i = 0; i < n; i++) {
if (i !== ds.cardIdx && !stagedIds.has(hand[i].id)) others.push(i);
}
for (let k = 0; k < others.length; k++) { for (let k = 0; k < others.length; k++) {
const j = others[k]; const j = others[k];
@ -566,10 +571,15 @@ export default class Phase10Game extends Phaser.Scene {
_settleNonDraggedCards() { _settleNonDraggedCards() {
const ds = this.dragState; const ds = this.dragState;
const n = this.gs.players[0].hand.length; const hand = this.gs.players[0].hand;
const n = hand.length;
const stagedIds = this.stagingGroups
? new Set(this.stagingGroups.flatMap((g) => g.slots.filter((id) => id !== null)))
: new Set();
let k = 0; let k = 0;
for (let j = 0; j < n; j++) { for (let j = 0; j < n; j++) {
if (j === ds.cardIdx) continue; if (j === ds.cardIdx) continue;
if (stagedIds.has(hand[j].id)) continue;
const cardObj = this.localHandCards[j]; const cardObj = this.localHandCards[j];
this.tweens.killTweensOf(cardObj); this.tweens.killTweensOf(cardObj);
this.tweens.add({ targets: cardObj, x: 460 + k * HAND_SPREAD, rotation: 0, scaleX: 1, scaleY: 1, duration: 100, ease: 'Cubic.easeOut' }); this.tweens.add({ targets: cardObj, x: 460 + k * HAND_SPREAD, rotation: 0, scaleX: 1, scaleY: 1, duration: 100, ease: 'Cubic.easeOut' });
@ -623,7 +633,41 @@ export default class Phase10Game extends Phaser.Scene {
return; return;
} }
// ── Stage path ────────────────────────────────────────────────────────
if (dropTarget?.type === 'stage') {
const { groupIdx } = dropTarget;
const g = this.stagingGroups[groupIdx];
const emptySlotIdx = g.slots.indexOf(null);
if (emptySlotIdx !== -1) {
const handCard = this.gs.players[0].hand[ds.cardIdx];
for (const sg of this.stagingGroups) {
const old = sg.slots.indexOf(handCard.id);
if (old !== -1) { sg.slots[old] = null; break; }
}
g.slots[emptySlotIdx] = handCard.id;
const pos = this._getStagingSlotPos(groupIdx, emptySlotIdx);
const scale = LAIDOWN_CARD_W / CARD_W;
this.tweens.killTweensOf(card);
this._settleNonDraggedCards();
this.dragState = null;
this.tweens.add({
targets: card, x: pos.x, y: pos.y, rotation: 0, scaleX: scale, scaleY: scale,
duration: 160, ease: 'Cubic.easeOut',
onComplete: () => this.renderAll(),
});
return;
}
}
// ── Reorder path (existing) ─────────────────────────────────────────── // ── Reorder path (existing) ───────────────────────────────────────────
// Clear staging slot if this card was staged
if (this.stagingGroups) {
const handCard = this.gs.players[0].hand[ds.cardIdx];
for (const sg of this.stagingGroups) {
const old = sg.slots.indexOf(handCard.id);
if (old !== -1) { sg.slots[old] = null; break; }
}
}
const finalIdx = Phaser.Math.Clamp(Math.round((card.x - 460) / HAND_SPREAD), 0, n - 1); const finalIdx = Phaser.Math.Clamp(Math.round((card.x - 460) / HAND_SPREAD), 0, n - 1);
this.selectedHandIdx = null; this.selectedHandIdx = null;
@ -649,6 +693,124 @@ export default class Phase10Game extends Phaser.Scene {
this.time.delayedCall(240, () => this.renderAll()); this.time.delayedCall(240, () => this.renderAll());
} }
_getStagingSlotPos(gi, si) {
const { laidStart } = slotLayout('bottom');
let cx = laidStart.x;
for (let k = 0; k < gi; k++) {
cx += this.stagingGroups[k].count * (LAIDOWN_CARD_W * 0.75) + (LAIDOWN_CARD_W * 0.25) + GROUP_GAP;
}
return { x: cx + si * (LAIDOWN_CARD_W * 0.75) + LAIDOWN_CARD_W / 2, y: laidStart.y };
}
_getStagingGroupBounds(gi) {
const { laidStart } = slotLayout('bottom');
let cx = laidStart.x;
for (let k = 0; k < gi; k++) {
cx += this.stagingGroups[k].count * (LAIDOWN_CARD_W * 0.75) + (LAIDOWN_CARD_W * 0.25) + GROUP_GAP;
}
const gw = this.stagingGroups[gi].count * (LAIDOWN_CARD_W * 0.75) + (LAIDOWN_CARD_W * 0.25);
return { cx: cx + gw / 2, cy: laidStart.y, hw: gw / 2 + 16, hh: LAIDOWN_CARD_H / 2 + 16 };
}
renderStagingArea() {
if (!this.stagingGroups) return;
const { laidStart } = slotLayout('bottom');
let totalW = 0;
for (let gi = 0; gi < this.stagingGroups.length; gi++) {
if (gi > 0) totalW += GROUP_GAP;
totalW += this.stagingGroups[gi].count * (LAIDOWN_CARD_W * 0.75) + (LAIDOWN_CARD_W * 0.25);
}
const PX = 14, LABEL_H = 22, PY = 8;
const panelX = laidStart.x - PX;
const panelY = laidStart.y - LAIDOWN_CARD_H / 2 - LABEL_H - PY;
const panelW = totalW + PX * 2;
const panelH = LAIDOWN_CARD_H + LABEL_H + PY * 2;
const bg = this.add.graphics().setDepth(D.card - 2);
bg.fillStyle(COLORS.panel, 0.92);
bg.fillRoundedRect(panelX, panelY, panelW, panelH, 10);
bg.lineStyle(2, COLORS.accent, 0.6);
bg.strokeRoundedRect(panelX, panelY, panelW, panelH, 10);
this.transientObjs.push(bg);
let cx = laidStart.x;
for (let gi = 0; gi < this.stagingGroups.length; gi++) {
const g = this.stagingGroups[gi];
const gw = g.count * (LAIDOWN_CARD_W * 0.75) + (LAIDOWN_CARD_W * 0.25);
const kindLabel = g.kind === 'set' ? `Set of ${g.count}`
: g.kind === 'run' ? `Run of ${g.count}`
: `${g.count} of one color`;
const lbl = this.add.text(cx + gw / 2, panelY + PY + 2, kindLabel, {
fontFamily: 'Righteous', fontSize: '13px', color: COLORS.goldHex,
}).setOrigin(0.5, 0).setDepth(D.card - 1);
this.transientObjs.push(lbl);
for (let si = 0; si < g.count; si++) {
if (g.slots[si] === null) {
const pos = this._getStagingSlotPos(gi, si);
const sg = this.add.graphics().setDepth(D.card - 1);
sg.lineStyle(2, COLORS.accent, 0.35);
sg.strokeRoundedRect(pos.x - LAIDOWN_CARD_W / 2, pos.y - LAIDOWN_CARD_H / 2, LAIDOWN_CARD_W, LAIDOWN_CARD_H, 6);
sg.fillStyle(COLORS.accent, 0.04);
sg.fillRoundedRect(pos.x - LAIDOWN_CARD_W / 2, pos.y - LAIDOWN_CARD_H / 2, LAIDOWN_CARD_W, LAIDOWN_CARD_H, 6);
this.transientObjs.push(sg);
}
}
cx += gw + GROUP_GAP;
}
const btnX = panelX + panelW + 160;
const submitBtn = new Button(this, btnX, laidStart.y - 18, 'Submit Phase', () => this.submitStagingLaydown(), {
width: 200, height: 44, fontSize: 18,
}).setDepth(D.ui);
this.transientObjs.push(submitBtn);
const clearBtn = new Button(this, btnX, laidStart.y + 34, 'Clear', () => this.clearStagingLaydown(), {
variant: 'ghost', width: 200, height: 34, fontSize: 14,
}).setDepth(D.ui);
this.transientObjs.push(clearBtn);
}
submitStagingLaydown() {
const player = this.gs.players[0];
for (let gi = 0; gi < this.stagingGroups.length; gi++) {
if (this.stagingGroups[gi].slots.some((s) => s === null)) {
this.setStatus(`Group ${gi + 1} needs ${this.stagingGroups[gi].count} cards — fill all slots.`);
return;
}
}
const resolved = this.stagingGroups.map((g) => ({
kind: g.kind,
cards: g.slots.map((id) => player.hand.find((c) => c.id === id)),
}));
const vr = validateLaydown(player.phase, resolved);
if (!vr.ok) {
this.setStatus(`Invalid: ${vr.reason}`);
return;
}
const next = applyLaydown(this.gs, this.stagingGroups.map((g) => ({
kind: g.kind, cardIds: g.slots.slice(),
})));
if (next === this.gs) {
this.setStatus("Couldn't lay down — check your cards.");
return;
}
this.stagingGroups = null;
this.gs = next;
this.selectedHandIdx = null;
this.clearHighlights();
this.renderAll();
this.setStatus('Laid down! Now hit or discard.');
}
clearStagingLaydown() {
if (!this.stagingGroups) return;
for (const g of this.stagingGroups) g.slots.fill(null);
this.renderAll();
}
_dropTargetsEqual(a, b) { _dropTargetsEqual(a, b) {
if (!a && !b) return true; if (!a && !b) return true;
if (!a || !b) return false; if (!a || !b) return false;
@ -661,6 +823,17 @@ export default class Phase10Game extends Phaser.Scene {
if (!this.isLocalTurn() || !this.gs.drawnThisTurn) return null; if (!this.isLocalTurn() || !this.gs.drawnThisTurn) return null;
const handCard = this.gs.players[0].hand[this.dragState.cardIdx]; const handCard = this.gs.players[0].hand[this.dragState.cardIdx];
// Staging area drop targets
if (this.stagingGroups && !this.gs.players[0].laidDown) {
for (let gi = 0; gi < this.stagingGroups.length; gi++) {
if (!this.stagingGroups[gi].slots.some((s) => s === null)) continue;
const b = this._getStagingGroupBounds(gi);
if (Math.abs(cardX - b.cx) < b.hw && Math.abs(cardY - b.cy) < b.hh) {
return { type: 'stage', groupIdx: gi };
}
}
}
// Discard pile — generous hit area // Discard pile — generous hit area
if (Math.abs(cardX - DISCARD_POS.x) < CARD_W && Math.abs(cardY - DISCARD_POS.y) < CARD_H) { if (Math.abs(cardX - DISCARD_POS.x) < CARD_W && Math.abs(cardY - DISCARD_POS.y) < CARD_H) {
return { type: 'discard' }; return { type: 'discard' };
@ -746,6 +919,12 @@ export default class Phase10Game extends Phaser.Scene {
g.fillStyle(0xffd700, 0.22); g.fillStyle(0xffd700, 0.22);
g.fillRoundedRect(gc.x - gc.w / 2, gc.y - gc.h / 2, gc.w, gc.h, 8); g.fillRoundedRect(gc.x - gc.w / 2, gc.y - gc.h / 2, gc.w, gc.h, 8);
} }
} else if (dropTarget.type === 'stage') {
const b = this._getStagingGroupBounds(dropTarget.groupIdx);
g.lineStyle(4, COLORS.accent, 1);
g.strokeRoundedRect(b.cx - b.hw, b.cy - b.hh, b.hw * 2, b.hh * 2, 8);
g.fillStyle(COLORS.accent, 0.18);
g.fillRoundedRect(b.cx - b.hw, b.cy - b.hh, b.hw * 2, b.hh * 2, 8);
} }
g.setDepth(D.highlight); g.setDepth(D.highlight);
ds.actionHighlight = g; ds.actionHighlight = g;
@ -774,6 +953,7 @@ export default class Phase10Game extends Phaser.Scene {
if (this.animating) return; if (this.animating) return;
this.matchOver = false; this.matchOver = false;
if (this.dragState) this.endCardDragImmediate(); if (this.dragState) this.endCardDragImmediate();
this.stagingGroups = null;
this.clearAllCardObjs(); this.clearAllCardObjs();
this.clearHighlights(); this.clearHighlights();
this.selectedHandIdx = null; this.selectedHandIdx = null;
@ -894,6 +1074,7 @@ export default class Phase10Game extends Phaser.Scene {
renderAll() { renderAll() {
if (this.dragState) this.endCardDragImmediate(); if (this.dragState) this.endCardDragImmediate();
this.refreshStagingArea();
this.clearAllCardObjs(); this.clearAllCardObjs();
this.renderCenter(); this.renderCenter();
for (let seat = 0; seat < this.gs.players.length; seat++) { for (let seat = 0; seat < this.gs.players.length; seat++) {
@ -902,7 +1083,7 @@ export default class Phase10Game extends Phaser.Scene {
this.renderSeatChips(); this.renderSeatChips();
this.renderTurnIndicator(); this.renderTurnIndicator();
this.refreshPlaybook(); this.refreshPlaybook();
this.refreshLayDownBtn(); this.renderStagingArea();
} }
renderCenter() { renderCenter() {
@ -928,19 +1109,42 @@ export default class Phase10Game extends Phaser.Scene {
if (seat === 0) this.localHandCards = []; if (seat === 0) this.localHandCards = [];
// Build staged-card lookup for seat 0
const stagedMap = new Map(); // cardId → { gi, si }
if (seat === 0 && this.stagingGroups) {
for (let gi = 0; gi < this.stagingGroups.length; gi++) {
this.stagingGroups[gi].slots.forEach((id, si) => {
if (id !== null) stagedMap.set(id, { gi, si });
});
}
}
let handPosCounter = 0;
// Hand // Hand
for (let i = 0; i < player.hand.length; i++) { for (let i = 0; i < player.hand.length; i++) {
const card = player.hand[i]; const card = player.hand[i];
let x, y; let x, y, cardScale = 1;
if (layout.handAxis === 'x') { if (seat === 0 && this.stagingGroups) {
x = layout.handStart.x + i * HAND_SPREAD; const staged = stagedMap.get(card.id);
y = layout.handStart.y; if (staged) {
const pos = this._getStagingSlotPos(staged.gi, staged.si);
x = pos.x; y = pos.y; cardScale = LAIDOWN_CARD_W / CARD_W;
} else {
x = layout.handStart.x + handPosCounter * HAND_SPREAD;
y = layout.handStart.y;
handPosCounter++;
}
} else { } else {
x = layout.handStart.x; if (layout.handAxis === 'x') {
y = layout.handStart.y - i * HAND_SPREAD; x = layout.handStart.x + i * HAND_SPREAD;
y = layout.handStart.y;
} else {
x = layout.handStart.x;
y = layout.handStart.y - i * HAND_SPREAD;
}
} }
const c = this.makeCardSprite(card, x, y, { const c = this.makeCardSprite(card, x, y, {
faceUp: layout.handFaceUp, rotation: layout.rotateCards, faceUp: layout.handFaceUp, rotation: layout.rotateCards, scale: cardScale,
}); });
this.cardObjs.set(`hand-${seat}-${card.id}`, c); this.cardObjs.set(`hand-${seat}-${card.id}`, c);
if (seat === 0) { if (seat === 0) {
@ -1032,16 +1236,22 @@ export default class Phase10Game extends Phaser.Scene {
this.turnGlow.setPosition(lay.portrait.x, lay.portrait.y); this.turnGlow.setPosition(lay.portrait.x, lay.portrait.y);
} }
refreshLayDownBtn() { refreshStagingArea() {
if (!this.layDownBtn) return; const player = this.gs.players[0];
const isLocal = this.isLocalTurn(); const eligible = this.isLocalTurn() && this.gs.drawnThisTurn && !player.laidDown;
const localPlayer = this.gs.players[0]; if (!eligible) {
if (!isLocal || !this.gs.drawnThisTurn || localPlayer.laidDown) { this.stagingGroups = null;
this.layDownBtn.setVisible(false);
return; return;
} }
const layout = findLaydown(localPlayer.hand, localPlayer.phase); if (!this.stagingGroups) {
this.layDownBtn.setVisible(!!layout); const spec = getPhase(player.phase);
if (!spec) return;
this.stagingGroups = spec.groups.map((g) => ({
kind: g.kind,
count: g.count,
slots: new Array(g.count).fill(null),
}));
}
} }
// ── Local input ───────────────────────────────────────────────────────── // ── Local input ─────────────────────────────────────────────────────────
@ -1101,27 +1311,6 @@ export default class Phase10Game extends Phaser.Scene {
} }
} }
onLayDownClick() {
if (!this.isLocalTurn() || this.animating) return;
const player = this.gs.players[0];
if (player.laidDown) return;
const layout = findLaydown(player.hand, player.phase);
if (!layout) {
this.setStatus("You don't have the cards for this phase yet.");
return;
}
const groups = layout.map((g) => ({ kind: g.kind, cardIds: g.cards.map((c) => c.id) }));
const next = applyLaydown(this.gs, groups);
if (next === this.gs) {
this.setStatus("Couldn't lay down — invalid grouping.");
return;
}
this.gs = next;
this.selectedHandIdx = null;
this.clearHighlights();
this.renderAll();
this.setStatus('Laid down! Now hit or discard.');
}
highlightTargetsForCard() { highlightTargetsForCard() {
this.clearHighlights(); this.clearHighlights();
@ -1440,7 +1629,18 @@ export default class Phase10Game extends Phaser.Scene {
// ── HUD helpers ───────────────────────────────────────────────────────── // ── HUD helpers ─────────────────────────────────────────────────────────
setStatus(s) { setStatus(s) {
if (this.statusText) this.statusText.setText(s); if (!this.statusText) return;
this.statusText.setText(s);
if (!s) {
this.statusBg.setVisible(false);
return;
}
const pad = 12;
const w = this.statusText.width + pad * 2;
this.statusBg.clear();
this.statusBg.fillStyle(0x000000, 0.5);
this.statusBg.fillRoundedRect(1040, 838, w, 64, 8);
this.statusBg.setVisible(true);
} }
nameForSeat(seat) { nameForSeat(seat) {

View File

@ -47,7 +47,7 @@ export class Button extends Phaser.GameObjects.Container {
this.add([this.bgRect, this.text]); this.add([this.bgRect, this.text]);
const bgHitArea = new Phaser.Geom.Rectangle(-hw, -hh, width, height); const bgHitArea = new Phaser.Geom.Rectangle(-hw, -hh, width, height);
const textHitArea = new Phaser.Geom.Rectangle(0, 0, width, height); const textHitArea = new Phaser.Geom.Rectangle(-hw, -hh, width, height);
const hitCb = Phaser.Geom.Rectangle.Contains; const hitCb = Phaser.Geom.Rectangle.Contains;
this.setSize(width, height); this.setSize(width, height);
this.setInteractive({ useHandCursor: true, hitArea: bgHitArea, hitAreaCallback: hitCb }); this.setInteractive({ useHandCursor: true, hitArea: bgHitArea, hitAreaCallback: hitCb });