From 83ada104566802e63ff7c57b28e9cc494e16dd2a Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 17 May 2026 12:51:31 -0600 Subject: [PATCH] feat: add drag-and-drop reordering for Phase10 hand cards - Implement pointer-based drag handlers for Phase10 player hand. - Add visual feedback including card scaling, rotation tilt, shadow, and slot indicators during drag. - Reorder hand array and animate cards to new positions on drop. - Prevent conflicts by ending active drags during scene transitions or state updates. --- public/src/games/phase10/Phase10Game.js | 173 +++++++++++++++++++++++- 1 file changed, 172 insertions(+), 1 deletion(-) diff --git a/public/src/games/phase10/Phase10Game.js b/public/src/games/phase10/Phase10Game.js index 456ef34..b060d25 100644 --- a/public/src/games/phase10/Phase10Game.js +++ b/public/src/games/phase10/Phase10Game.js @@ -146,6 +146,10 @@ export default class Phase10Game extends Phaser.Scene { this.playbookOpen = false; this.playbookPanel = null; this.layDownBtn = null; + + this.localHandCards = []; + this.dragState = null; + this.potentialDrag = null; } create() { @@ -155,6 +159,7 @@ export default class Phase10Game extends Phaser.Scene { this.buildCenter(); this.buildPlaybook(); this.buildHUD(); + this.setupDragHandlers(); this.startNewMatch(); } @@ -423,11 +428,172 @@ export default class Phase10Game extends Phaser.Scene { this.layDownBtn.setVisible(false); } + // ── Drag-and-drop hand reordering ─────────────────────────────────────── + + setupDragHandlers() { + this.input.on('pointermove', (pointer) => { + if (this.potentialDrag && !this.dragState) { + const dx = pointer.x - this.potentialDrag.startX; + const dy = pointer.y - this.potentialDrag.startY; + if (Math.sqrt(dx * dx + dy * dy) >= 8) { + const pd = this.potentialDrag; + this.potentialDrag = null; + this.startCardDrag(pd.handIdx, pd.offsetX, pd.offsetY); + } + } + if (this.dragState) { + this.updateCardDrag(pointer); + } + }); + + this.input.on('pointerup', (pointer) => { + if (this.potentialDrag) { + const handIdx = this.potentialDrag.handIdx; + this.potentialDrag = null; + this.onHandClick(handIdx); + return; + } + if (this.dragState) { + this.endCardDrag(); + } + }); + } + + onHandPointerDown(handIdx, pointer) { + if (this.dragState) return; + const card = this.localHandCards[handIdx]; + if (!card) return; + this.potentialDrag = { + handIdx, + startX: pointer.x, + startY: pointer.y, + offsetX: pointer.x - card.x, + offsetY: pointer.y - card.y, + }; + } + + startCardDrag(handIdx, offsetX, offsetY) { + const card = this.localHandCards[handIdx]; + if (!card) return; + const n = this.gs.players[0].hand.length; + const insertIdx = Phaser.Math.Clamp(Math.round((card.x - 460) / HAND_SPREAD), 0, n - 1); + + const shadow = this.add.graphics(); + shadow.fillStyle(0x000000, 0.35); + shadow.fillEllipse(0, 0, CARD_W * 1.1, 28); + shadow.setPosition(card.x + 6, card.y + 16); + shadow.setDepth(D.card + 9); + this.transientObjs.push(shadow); + + const slotIndicator = this.add.graphics(); + slotIndicator.lineStyle(3, COLORS.accent, 0.75); + slotIndicator.strokeRoundedRect(-CARD_W / 2 - 4, -CARD_H / 2 - 4, CARD_W + 8, CARD_H + 8, 10); + slotIndicator.fillStyle(COLORS.accent, 0.1); + slotIndicator.fillRoundedRect(-CARD_W / 2 - 4, -CARD_H / 2 - 4, CARD_W + 8, CARD_H + 8, 10); + slotIndicator.setPosition(460 + insertIdx * HAND_SPREAD, 990); + slotIndicator.setDepth(D.card - 1); + this.transientObjs.push(slotIndicator); + + this.dragState = { cardIdx: handIdx, offsetX, offsetY, prevX: card.x, shadow, slotIndicator, insertIdx }; + + card.setDepth(D.card + 10); + this.tweens.add({ targets: card, scaleX: 1.08, scaleY: 1.08, duration: 120, ease: 'Cubic.easeOut' }); + } + + updateCardDrag(pointer) { + const ds = this.dragState; + const card = this.localHandCards[ds.cardIdx]; + const n = this.gs.players[0].hand.length; + + card.x = pointer.x - ds.offsetX; + card.y = pointer.y - ds.offsetY; + ds.shadow.setPosition(card.x + 6, card.y + 16); + + const tiltRad = Phaser.Math.Clamp((pointer.x - ds.prevX) * 0.008, -0.17, 0.17); + 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(); + } + } + + _updateNonDraggedCards() { + const ds = this.dragState; + const n = this.gs.players[0].hand.length; + const others = []; + for (let i = 0; i < n; i++) { if (i !== ds.cardIdx) others.push(i); } + + for (let k = 0; k < others.length; k++) { + const j = others[k]; + const finalPos = k < ds.insertIdx ? k : k + 1; + const targetX = 460 + finalPos * HAND_SPREAD; + const distFromGap = Math.abs(finalPos - ds.insertIdx); + const leanDir = finalPos < ds.insertIdx ? -1 : 1; + const targetRot = leanDir * Math.max(0, 2 - distFromGap) * 0.04; + const targetScale = distFromGap <= 1 ? 0.95 : 1.0; + + const cardObj = this.localHandCards[j]; + this.tweens.killTweensOf(cardObj); + this.tweens.add({ targets: cardObj, x: targetX, rotation: targetRot, scaleX: targetScale, scaleY: targetScale, duration: 100, ease: 'Cubic.easeOut' }); + } + } + + endCardDrag() { + const ds = this.dragState; + const card = this.localHandCards[ds.cardIdx]; + const n = this.gs.players[0].hand.length; + const finalIdx = Phaser.Math.Clamp(Math.round((card.x - 460) / HAND_SPREAD), 0, n - 1); + + this.selectedHandIdx = null; + + this.tweens.killTweensOf(card); + this.tweens.add({ targets: card, x: 460 + finalIdx * HAND_SPREAD, y: 990, rotation: 0, scaleX: 1, scaleY: 1, duration: 220, ease: 'Back.easeOut' }); + + const others = []; + for (let i = 0; i < n; i++) { if (i !== ds.cardIdx) others.push(i); } + for (let k = 0; k < others.length; k++) { + const j = others[k]; + const finalPos = k < finalIdx ? k : k + 1; + const cardObj = this.localHandCards[j]; + this.tweens.killTweensOf(cardObj); + this.tweens.add({ targets: cardObj, x: 460 + finalPos * HAND_SPREAD, rotation: 0, scaleX: 1, scaleY: 1, duration: 120, ease: 'Cubic.easeOut' }); + } + + const hand = this.gs.players[0].hand; + const [moved] = hand.splice(ds.cardIdx, 1); + hand.splice(finalIdx, 0, moved); + + this.dragState = null; + this.time.delayedCall(240, () => this.renderAll()); + } + + endCardDragImmediate() { + if (!this.dragState) return; + const ds = this.dragState; + const card = this.localHandCards[ds.cardIdx]; + this.tweens.killTweensOf(card); + this.tweens.killTweensOf(ds.slotIndicator); + + const hand = this.gs.players[0].hand; + const [moved] = hand.splice(ds.cardIdx, 1); + hand.splice(ds.insertIdx, 0, moved); + + this.selectedHandIdx = null; + this.dragState = null; + this.potentialDrag = null; + } + // ── Match lifecycle ────────────────────────────────────────────────────── startNewMatch() { if (this.animating) return; this.matchOver = false; + if (this.dragState) this.endCardDragImmediate(); this.clearAllCardObjs(); this.clearHighlights(); this.selectedHandIdx = null; @@ -541,11 +707,13 @@ export default class Phase10Game extends Phaser.Scene { this.cardObjs.clear(); for (const o of this.transientObjs) o.destroy(); this.transientObjs = []; + this.localHandCards = []; } // ── Rendering ──────────────────────────────────────────────────────────── renderAll() { + if (this.dragState) this.endCardDragImmediate(); this.clearAllCardObjs(); this.renderCenter(); for (let seat = 0; seat < this.gs.players.length; seat++) { @@ -578,6 +746,8 @@ export default class Phase10Game extends Phaser.Scene { const slot = this.slotForSeat[seat]; const layout = slotLayout(slot); + if (seat === 0) this.localHandCards = []; + // Hand for (let i = 0; i < player.hand.length; i++) { const card = player.hand[i]; @@ -594,9 +764,10 @@ export default class Phase10Game extends Phaser.Scene { }); this.cardObjs.set(`hand-${seat}-${card.id}`, c); if (seat === 0) { + this.localHandCards.push(c); c.setInteractive(new Phaser.Geom.Rectangle(-CARD_W/2, -CARD_H/2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains); c.input.cursor = 'pointer'; - c.on('pointerdown', () => this.onHandClick(i)); + c.on('pointerdown', (pointer) => this.onHandPointerDown(i, pointer)); } }