From 208645246cd96b0d19042742e0e71f879a37d594 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 17 May 2026 14:37:42 -0600 Subject: [PATCH] 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. --- public/src/games/phase10/Phase10Game.js | 302 ++++++++++++++++++++---- public/src/ui/Button.js | 2 +- 2 files changed, 252 insertions(+), 52 deletions(-) diff --git a/public/src/games/phase10/Phase10Game.js b/public/src/games/phase10/Phase10Game.js index ae7d36b..3c9c955 100644 --- a/public/src/games/phase10/Phase10Game.js +++ b/public/src/games/phase10/Phase10Game.js @@ -5,7 +5,7 @@ import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait. import { auth } from '../../services/auth.js'; import { api } from '../../services/api.js'; import { playSound, SFX } from '../../ui/Sounds.js'; -import { PHASES, getPhase } from './PhaseSpec.js'; +import { PHASES, getPhase, validateLaydown } from './PhaseSpec.js'; import { applyDrawFromDeck, applyDrawFromDiscard, @@ -147,11 +147,11 @@ export default class Phase10Game extends Phaser.Scene { this.seatChips = []; // seat → { container, phaseText, scoreText, doneRibbon } this.playbookOpen = false; this.playbookPanel = null; - this.layDownBtn = null; this.localHandCards = []; this.dragState = null; this.potentialDrag = null; + this.stagingGroups = null; } create() { @@ -413,9 +413,10 @@ export default class Phase10Game extends Phaser.Scene { } 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, - }).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'), { 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, }).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 ─────────────────────────────────────── @@ -533,7 +529,10 @@ export default class Phase10Game extends Phaser.Scene { } 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) { ds.insertIdx = newInsertIdx; this.tweens.killTweensOf(ds.slotIndicator); @@ -545,9 +544,15 @@ export default class Phase10Game extends Phaser.Scene { _updateNonDraggedCards() { 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 = []; - 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++) { const j = others[k]; @@ -566,10 +571,15 @@ export default class Phase10Game extends Phaser.Scene { _settleNonDraggedCards() { 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; for (let j = 0; j < n; j++) { if (j === ds.cardIdx) continue; + if (stagedIds.has(hand[j].id)) continue; const cardObj = this.localHandCards[j]; 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' }); @@ -623,7 +633,41 @@ export default class Phase10Game extends Phaser.Scene { 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) ─────────────────────────────────────────── + // 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); this.selectedHandIdx = null; @@ -649,6 +693,124 @@ export default class Phase10Game extends Phaser.Scene { 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) { if (!a && !b) return true; if (!a || !b) return false; @@ -661,6 +823,17 @@ export default class Phase10Game extends Phaser.Scene { if (!this.isLocalTurn() || !this.gs.drawnThisTurn) return null; 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 if (Math.abs(cardX - DISCARD_POS.x) < CARD_W && Math.abs(cardY - DISCARD_POS.y) < CARD_H) { return { type: 'discard' }; @@ -746,6 +919,12 @@ export default class Phase10Game extends Phaser.Scene { g.fillStyle(0xffd700, 0.22); 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); ds.actionHighlight = g; @@ -774,6 +953,7 @@ export default class Phase10Game extends Phaser.Scene { if (this.animating) return; this.matchOver = false; if (this.dragState) this.endCardDragImmediate(); + this.stagingGroups = null; this.clearAllCardObjs(); this.clearHighlights(); this.selectedHandIdx = null; @@ -894,6 +1074,7 @@ export default class Phase10Game extends Phaser.Scene { renderAll() { if (this.dragState) this.endCardDragImmediate(); + this.refreshStagingArea(); this.clearAllCardObjs(); this.renderCenter(); for (let seat = 0; seat < this.gs.players.length; seat++) { @@ -902,7 +1083,7 @@ export default class Phase10Game extends Phaser.Scene { this.renderSeatChips(); this.renderTurnIndicator(); this.refreshPlaybook(); - this.refreshLayDownBtn(); + this.renderStagingArea(); } renderCenter() { @@ -928,19 +1109,42 @@ export default class Phase10Game extends Phaser.Scene { 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 for (let i = 0; i < player.hand.length; i++) { const card = player.hand[i]; - let x, y; - if (layout.handAxis === 'x') { - x = layout.handStart.x + i * HAND_SPREAD; - y = layout.handStart.y; + let x, y, cardScale = 1; + if (seat === 0 && this.stagingGroups) { + const staged = stagedMap.get(card.id); + 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 { - x = layout.handStart.x; - y = layout.handStart.y - i * HAND_SPREAD; + if (layout.handAxis === 'x') { + 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, { - faceUp: layout.handFaceUp, rotation: layout.rotateCards, + faceUp: layout.handFaceUp, rotation: layout.rotateCards, scale: cardScale, }); this.cardObjs.set(`hand-${seat}-${card.id}`, c); if (seat === 0) { @@ -1032,16 +1236,22 @@ export default class Phase10Game extends Phaser.Scene { this.turnGlow.setPosition(lay.portrait.x, lay.portrait.y); } - refreshLayDownBtn() { - if (!this.layDownBtn) return; - const isLocal = this.isLocalTurn(); - const localPlayer = this.gs.players[0]; - if (!isLocal || !this.gs.drawnThisTurn || localPlayer.laidDown) { - this.layDownBtn.setVisible(false); + refreshStagingArea() { + const player = this.gs.players[0]; + const eligible = this.isLocalTurn() && this.gs.drawnThisTurn && !player.laidDown; + if (!eligible) { + this.stagingGroups = null; return; } - const layout = findLaydown(localPlayer.hand, localPlayer.phase); - this.layDownBtn.setVisible(!!layout); + if (!this.stagingGroups) { + 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 ───────────────────────────────────────────────────────── @@ -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() { this.clearHighlights(); @@ -1440,7 +1629,18 @@ export default class Phase10Game extends Phaser.Scene { // ── HUD helpers ───────────────────────────────────────────────────────── 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) { diff --git a/public/src/ui/Button.js b/public/src/ui/Button.js index c940497..7025e27 100644 --- a/public/src/ui/Button.js +++ b/public/src/ui/Button.js @@ -47,7 +47,7 @@ export class Button extends Phaser.GameObjects.Container { this.add([this.bgRect, this.text]); 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; this.setSize(width, height); this.setInteractive({ useHandCursor: true, hitArea: bgHitArea, hitAreaCallback: hitCb });