From a4c8f659eee5be589297f3d84d7c1446f8426e9b Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 17 May 2026 13:15:51 -0600 Subject: [PATCH] feat: enable drag-to-discard and drag-to-hit in Phase10 - Allow dragging local hand cards to discard pile or valid hit targets. - Add visual feedback with action highlights for discard (red) and hit (gold) zones. - Handle drag state transitions, including settling cards and animating to target. - Support 'skip' card drag with immediate modal opening. - Refactor drop target detection and group center calculation for reuse. - Adjust portrait and button positions for better layout alignment. --- public/src/games/phase10/Phase10Game.js | 244 +++++++++++++++++++----- 1 file changed, 196 insertions(+), 48 deletions(-) diff --git a/public/src/games/phase10/Phase10Game.js b/public/src/games/phase10/Phase10Game.js index b060d25..e4a9ba1 100644 --- a/public/src/games/phase10/Phase10Game.js +++ b/public/src/games/phase10/Phase10Game.js @@ -68,8 +68,8 @@ function slotLayout(slot) { handFaceUp: true, laidStart: { x: 200, y: 820 }, laidAxis: 'x', - portrait: { x: 120, y: 990, r: 56 }, - nameLabel: { x: 120, y: 1056 }, + portrait: { x: 350, y: 990, r: 56 }, + nameLabel: { x: 350, y: 1056 }, chip: { x: 752, y: 870 }, rotateCards: 0, }; @@ -81,8 +81,8 @@ function slotLayout(slot) { handFaceUp: false, laidStart: { x: 200, y: 200 }, laidAxis: 'x', - portrait: { x: 120, y: 70, r: 50 }, - nameLabel: { x: 120, y: 134 }, + portrait: { x: 350, y: 70, r: 50 }, + nameLabel: { x: 350, y: 134 }, chip: { x: 752, y: 180 }, rotateCards: 180, }; @@ -414,10 +414,10 @@ export default class Phase10Game extends Phaser.Scene { fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.ui); - new Button(this, 110, GAME_HEIGHT - 50, '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, }).setDepth(D.ui); - new Button(this, 110, GAME_HEIGHT - 100, 'New', () => this.startNewMatch(), { + new Button(this, 55, GAME_HEIGHT - 60, 'New', () => this.startNewMatch(), { variant: 'ghost', width: 110, height: 40, fontSize: 18, }).setDepth(D.ui); @@ -494,7 +494,7 @@ export default class Phase10Game extends Phaser.Scene { slotIndicator.setDepth(D.card - 1); this.transientObjs.push(slotIndicator); - this.dragState = { cardIdx: handIdx, offsetX, offsetY, prevX: card.x, shadow, slotIndicator, insertIdx }; + this.dragState = { cardIdx: handIdx, offsetX, offsetY, prevX: card.x, shadow, slotIndicator, insertIdx, dropTarget: null, actionHighlight: null }; card.setDepth(D.card + 10); this.tweens.add({ targets: card, scaleX: 1.08, scaleY: 1.08, duration: 120, ease: 'Cubic.easeOut' }); @@ -513,12 +513,30 @@ export default class Phase10Game extends Phaser.Scene { card.setRotation(tiltRad); ds.prevX = pointer.x; - const newInsertIdx = Phaser.Math.Clamp(Math.round((card.x - 460) / HAND_SPREAD), 0, n - 1); - if (newInsertIdx !== ds.insertIdx) { - ds.insertIdx = newInsertIdx; - this.tweens.killTweensOf(ds.slotIndicator); - this.tweens.add({ targets: ds.slotIndicator, x: 460 + newInsertIdx * HAND_SPREAD, duration: 100, ease: 'Cubic.easeOut' }); - this._updateNonDraggedCards(); + const newDropTarget = this._getDropTarget(card.x, card.y); + if (!this._dropTargetsEqual(newDropTarget, ds.dropTarget)) { + ds.dropTarget = newDropTarget; + this._updateActionHighlight(newDropTarget); + if (newDropTarget) { + // Entered a drop zone — hide slot indicator, settle hand to no-gap + this.tweens.killTweensOf(ds.slotIndicator); + ds.slotIndicator.setAlpha(0); + this._settleNonDraggedCards(); + } else { + // Left drop zone — restore slot indicator and gap + ds.slotIndicator.setAlpha(1); + this._updateNonDraggedCards(); + } + } + + if (!newDropTarget) { + const newInsertIdx = Phaser.Math.Clamp(Math.round((card.x - 460) / HAND_SPREAD), 0, n - 1); + if (newInsertIdx !== ds.insertIdx) { + ds.insertIdx = newInsertIdx; + this.tweens.killTweensOf(ds.slotIndicator); + this.tweens.add({ targets: ds.slotIndicator, x: 460 + newInsertIdx * HAND_SPREAD, duration: 100, ease: 'Cubic.easeOut' }); + this._updateNonDraggedCards(); + } } } @@ -543,10 +561,66 @@ export default class Phase10Game extends Phaser.Scene { } } + _settleNonDraggedCards() { + const ds = this.dragState; + const n = this.gs.players[0].hand.length; + let k = 0; + for (let j = 0; j < n; j++) { + if (j === ds.cardIdx) 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' }); + k++; + } + } + endCardDrag() { const ds = this.dragState; const card = this.localHandCards[ds.cardIdx]; const n = this.gs.players[0].hand.length; + const dropTarget = this._getDropTarget(card.x, card.y); + + // ── Discard path ────────────────────────────────────────────────────── + if (dropTarget?.type === 'discard') { + const handIdx = ds.cardIdx; + const handCard = this.gs.players[0].hand[handIdx]; + this.tweens.killTweensOf(card); + this._settleNonDraggedCards(); + this.dragState = null; + + if (handCard.value === 'skip') { + // Snap back to hand, then immediately open skip-target modal. + this.tweens.add({ targets: card, x: 460 + handIdx * HAND_SPREAD, y: 990, rotation: 0, scaleX: 1, scaleY: 1, duration: 140, ease: 'Back.easeOut' }); + this.time.delayedCall(160, () => { this.renderAll(); this.openSkipTargetModal(handIdx); }); + return; + } + + this.tweens.add({ + targets: card, x: DISCARD_POS.x, y: DISCARD_POS.y, rotation: 0, scaleX: 1, scaleY: 1, + duration: 160, ease: 'Cubic.easeOut', + onComplete: () => { this.selectedHandIdx = handIdx; this.commitDiscard(handIdx); }, + }); + return; + } + + // ── Hit path ────────────────────────────────────────────────────────── + if (dropTarget?.type === 'hit') { + const handIdx = ds.cardIdx; + const target = { targetSeat: dropTarget.targetSeat, groupIdx: dropTarget.groupIdx, position: dropTarget.position }; + const gc = this._getGroupCenter(target); + this.tweens.killTweensOf(card); + this._settleNonDraggedCards(); + this.dragState = null; + + this.tweens.add({ + targets: card, x: gc ? gc.x : card.x, y: gc ? gc.y : card.y, rotation: 0, scaleX: 1, scaleY: 1, + duration: 160, ease: 'Cubic.easeOut', + onComplete: () => { this.selectedHandIdx = handIdx; this.commitHit(target); }, + }); + return; + } + + // ── Reorder path (existing) ─────────────────────────────────────────── const finalIdx = Phaser.Math.Clamp(Math.round((card.x - 460) / HAND_SPREAD), 0, n - 1); this.selectedHandIdx = null; @@ -572,6 +646,109 @@ export default class Phase10Game extends Phaser.Scene { this.time.delayedCall(240, () => this.renderAll()); } + _dropTargetsEqual(a, b) { + if (!a && !b) return true; + if (!a || !b) return false; + if (a.type !== b.type) return false; + if (a.type === 'hit') return a.targetSeat === b.targetSeat && a.groupIdx === b.groupIdx && a.position === b.position; + return true; + } + + _getDropTarget(cardX, cardY) { + if (!this.isLocalTurn() || !this.gs.drawnThisTurn) return null; + const handCard = this.gs.players[0].hand[this.dragState.cardIdx]; + + // 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' }; + } + + // Hit targets — only when the local player has already laid down + if (this.gs.players[0].laidDown) { + const targets = getHitTargets(this.gs, handCard); + // Bucket by group so runs with both low+high positions are handled together. + const byGroup = new Map(); + for (const t of targets) { + const key = `${t.targetSeat}:${t.groupIdx}`; + if (!byGroup.has(key)) byGroup.set(key, []); + byGroup.get(key).push(t); + } + for (const groupTargets of byGroup.values()) { + const gc = this._getGroupCenter(groupTargets[0]); + if (!gc) continue; + if (Math.abs(cardX - gc.x) < gc.w / 2 + 20 && Math.abs(cardY - gc.y) < gc.h / 2 + 20) { + if (groupTargets.length === 1) return { type: 'hit', ...groupTargets[0] }; + // Run with both low and high: pick based on which side the card is on. + const low = groupTargets.find((t) => t.position === 'low'); + const high = groupTargets.find((t) => t.position === 'high'); + const chosen = (low && cardX < gc.x) ? low : (high ?? low); + return { type: 'hit', ...chosen }; + } + } + } + + return null; + } + + _getGroupCenter(target) { + const slot = this.slotForSeat[target.targetSeat]; + const layout = slotLayout(slot); + const laid = this.gs.players[target.targetSeat].laidDown; + if (!laid) return null; + + let cursorX = layout.laidStart.x; + let cursorY = layout.laidStart.y; + for (let gi = 0; gi < laid.length; gi++) { + const group = laid[gi]; + const N = group.cards.length; + const groupW = N * (LAIDOWN_CARD_W * 0.75) + (LAIDOWN_CARD_W * 0.25); + if (gi === target.groupIdx) { + if (layout.laidAxis === 'x') { + const cx = cursorX + groupW / 2; + return { x: cx, y: cursorY, w: groupW + 6, h: LAIDOWN_CARD_H + 8, lowX: cursorX - 8, highX: cursorX + groupW + 8, axis: 'x' }; + } else { + const groupH = N * (LAIDOWN_CARD_H * 0.5) + (LAIDOWN_CARD_H * 0.5); + return { x: cursorX, y: cursorY + groupH / 2, w: LAIDOWN_CARD_W + 8, h: groupH + 6, axis: 'y' }; + } + } + if (layout.laidAxis === 'x') cursorX += groupW + GROUP_GAP; + else cursorY += N * (LAIDOWN_CARD_H * 0.5) + GROUP_GAP; + } + return null; + } + + _updateActionHighlight(dropTarget) { + const ds = this.dragState; + if (ds.actionHighlight) { + const idx = this.transientObjs.indexOf(ds.actionHighlight); + if (idx !== -1) this.transientObjs.splice(idx, 1); + ds.actionHighlight.destroy(); + ds.actionHighlight = null; + } + if (!dropTarget) return; + + const g = this.add.graphics(); + if (dropTarget.type === 'discard') { + const px = DISCARD_POS.x - CARD_W / 2 - 8; + const py = DISCARD_POS.y - CARD_H / 2 - 8; + g.lineStyle(4, 0xff6b6b, 1); + g.strokeRoundedRect(px, py, CARD_W + 16, CARD_H + 16, 10); + g.fillStyle(0xff6b6b, 0.18); + g.fillRoundedRect(px, py, CARD_W + 16, CARD_H + 16, 10); + } else if (dropTarget.type === 'hit') { + const gc = this._getGroupCenter(dropTarget); + if (gc) { + g.lineStyle(4, 0xffd700, 1); + g.strokeRoundedRect(gc.x - gc.w / 2, gc.y - gc.h / 2, gc.w, gc.h, 8); + g.fillStyle(0xffd700, 0.22); + g.fillRoundedRect(gc.x - gc.w / 2, gc.y - gc.h / 2, gc.w, gc.h, 8); + } + } + g.setDepth(D.highlight); + ds.actionHighlight = g; + this.transientObjs.push(g); + } + endCardDragImmediate() { if (!this.dragState) return; const ds = this.dragState; @@ -966,46 +1143,17 @@ export default class Phase10Game extends Phaser.Scene { } hitTargetHighlight(target) { - const slot = this.slotForSeat[target.targetSeat]; - const layout = slotLayout(slot); - const player = this.gs.players[target.targetSeat]; - const laid = player.laidDown; - if (!laid) return null; + const gc = this._getGroupCenter(target); + if (!gc) return null; - // Replicate cursor walk from renderLaidDown to find this group's centroid. - let cursorX = layout.laidStart.x; - let cursorY = layout.laidStart.y; - let groupCenter = null; - let lowX = 0, highX = 0; - for (let gi = 0; gi < laid.length; gi++) { - const group = laid[gi]; - const N = group.cards.length; - const groupW = N * (LAIDOWN_CARD_W * 0.75) + (LAIDOWN_CARD_W * 0.25); - if (gi === target.groupIdx) { - if (layout.laidAxis === 'x') { - const cx = cursorX + groupW / 2; - groupCenter = { x: cx, y: cursorY, w: groupW + 6, h: LAIDOWN_CARD_H + 8 }; - lowX = cursorX - 8; - highX = cursorX + groupW + 8; - } else { - const groupH = N * (LAIDOWN_CARD_H * 0.5) + (LAIDOWN_CARD_H * 0.5); - groupCenter = { x: cursorX, y: cursorY + groupH / 2, w: LAIDOWN_CARD_W + 8, h: groupH + 6 }; - } - break; - } - if (layout.laidAxis === 'x') cursorX += groupW + GROUP_GAP; - else cursorY += N * (LAIDOWN_CARD_H * 0.5) + GROUP_GAP; - } - if (!groupCenter) return null; - - // For runs with position, draw a small marker on the relevant end too. const seatColor = 0xffd700; - const rect = this.add.rectangle(groupCenter.x, groupCenter.y, groupCenter.w, groupCenter.h, seatColor, 0.12) + const rect = this.add.rectangle(gc.x, gc.y, gc.w, gc.h, seatColor, 0.12) .setStrokeStyle(2, seatColor, 0.9).setDepth(D.highlight) .setInteractive({ useHandCursor: true }); rect.on('pointerdown', () => this.commitHit(target)); - if (target.position && layout.laidAxis === 'x') { - const tip = this.add.text(target.position === 'low' ? lowX : highX, groupCenter.y, target.position === 'low' ? '◀' : '▶', { + if (target.position && gc.axis === 'x') { + const tipX = target.position === 'low' ? gc.lowX : gc.highX; + const tip = this.add.text(tipX, gc.y, target.position === 'low' ? '◀' : '▶', { fontFamily: 'Righteous', fontSize: '20px', color: '#ffd700', }).setOrigin(0.5).setDepth(D.highlight); this.transientObjs.push(tip);