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.
This commit is contained in:
Brian Fertig 2026-05-17 12:51:31 -06:00
parent 5b65134ecc
commit 83ada10456
1 changed files with 172 additions and 1 deletions

View File

@ -146,6 +146,10 @@ export default class Phase10Game extends Phaser.Scene {
this.playbookOpen = false; this.playbookOpen = false;
this.playbookPanel = null; this.playbookPanel = null;
this.layDownBtn = null; this.layDownBtn = null;
this.localHandCards = [];
this.dragState = null;
this.potentialDrag = null;
} }
create() { create() {
@ -155,6 +159,7 @@ export default class Phase10Game extends Phaser.Scene {
this.buildCenter(); this.buildCenter();
this.buildPlaybook(); this.buildPlaybook();
this.buildHUD(); this.buildHUD();
this.setupDragHandlers();
this.startNewMatch(); this.startNewMatch();
} }
@ -423,11 +428,172 @@ export default class Phase10Game extends Phaser.Scene {
this.layDownBtn.setVisible(false); 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 ────────────────────────────────────────────────────── // ── Match lifecycle ──────────────────────────────────────────────────────
startNewMatch() { startNewMatch() {
if (this.animating) return; if (this.animating) return;
this.matchOver = false; this.matchOver = false;
if (this.dragState) this.endCardDragImmediate();
this.clearAllCardObjs(); this.clearAllCardObjs();
this.clearHighlights(); this.clearHighlights();
this.selectedHandIdx = null; this.selectedHandIdx = null;
@ -541,11 +707,13 @@ export default class Phase10Game extends Phaser.Scene {
this.cardObjs.clear(); this.cardObjs.clear();
for (const o of this.transientObjs) o.destroy(); for (const o of this.transientObjs) o.destroy();
this.transientObjs = []; this.transientObjs = [];
this.localHandCards = [];
} }
// ── Rendering ──────────────────────────────────────────────────────────── // ── Rendering ────────────────────────────────────────────────────────────
renderAll() { renderAll() {
if (this.dragState) this.endCardDragImmediate();
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++) {
@ -578,6 +746,8 @@ export default class Phase10Game extends Phaser.Scene {
const slot = this.slotForSeat[seat]; const slot = this.slotForSeat[seat];
const layout = slotLayout(slot); const layout = slotLayout(slot);
if (seat === 0) this.localHandCards = [];
// 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];
@ -594,9 +764,10 @@ export default class Phase10Game extends Phaser.Scene {
}); });
this.cardObjs.set(`hand-${seat}-${card.id}`, c); this.cardObjs.set(`hand-${seat}-${card.id}`, c);
if (seat === 0) { 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.setInteractive(new Phaser.Geom.Rectangle(-CARD_W/2, -CARD_H/2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
c.input.cursor = 'pointer'; c.input.cursor = 'pointer';
c.on('pointerdown', () => this.onHandClick(i)); c.on('pointerdown', (pointer) => this.onHandPointerDown(i, pointer));
} }
} }