feat(uno): implement drag-to-sort for player hand

- Add `pointermove`, `pointerup`, and `pointerdown` handlers for smooth card dragging
- Implement visual feedback including card tilt, scale, and insertion slot indicators
- Update hand array order and re-render layout upon drop completion
- Refactor hover/click logic to support both interaction modes
This commit is contained in:
Brian Fertig 2026-05-19 19:28:18 -06:00
parent 3d5c49d325
commit f829ecf93b
1 changed files with 161 additions and 2 deletions

View File

@ -145,6 +145,10 @@ export default class UnoGame extends Phaser.Scene {
this.directionArrow = null;
this.colorSwatch = null;
this.pendingWildPlayCardId = null; // local: card chosen but awaiting color
this.localHandCards = [];
this.dragState = null;
this.potentialDrag = null;
}
create() {
@ -154,6 +158,7 @@ export default class UnoGame extends Phaser.Scene {
this.buildSeatAreas();
this.buildCenter();
this.buildHUD();
this.setupDragHandlers();
this.startNewMatch();
}
@ -565,6 +570,8 @@ export default class UnoGame extends Phaser.Scene {
? new Set(legalPlays(this.gs, 0))
: null;
if (seat === 0) this.localHandCards = [];
for (let i = 0; i < n; i++) {
const card = player.hand[i];
const offset = i - (n - 1) / 2;
@ -583,6 +590,7 @@ export default class UnoGame 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,
@ -590,15 +598,16 @@ export default class UnoGame extends Phaser.Scene {
c.input.cursor = 'pointer';
const baseY = y;
c.on('pointerover', () => {
if (this.animating) return;
if (this.animating || this.dragState) return;
this.tweens.add({ targets: c, y: baseY - 14, duration: 120 });
c.setDepth(D.highlight);
});
c.on('pointerout', () => {
if (this.dragState) return;
this.tweens.add({ targets: c, y: baseY, duration: 120 });
c.setDepth(D.card);
});
c.on('pointerdown', () => this.onHandCardClick(card.id));
c.on('pointerdown', (pointer) => this.onHandPointerDown(i, pointer));
if (localLegal && !localLegal.has(card.id)) {
c.setAlpha(0.6);
}
@ -615,6 +624,156 @@ export default class UnoGame extends Phaser.Scene {
}
}
// ── Hand drag-to-sort ─────────────────────────────────────────────────────
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', () => {
if (this.potentialDrag) {
const { cardId } = this.potentialDrag;
this.potentialDrag = null;
this.onHandCardClick(cardId);
return;
}
if (this.dragState) {
this.endCardDrag();
}
});
}
onHandPointerDown(handIdx, pointer) {
if (this.dragState || this.animating) return;
const card = this.localHandCards[handIdx];
if (!card) return;
const cardId = this.gs.players[0].hand[handIdx]?.id;
if (!cardId) return;
this.potentialDrag = {
handIdx,
cardId,
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 layout = slotLayout('bottom', this.gs.players.length);
const spread = this.handSpreadFor(0, n);
const handStartX = layout.handCenter.x - (n - 1) / 2 * spread;
const handCenterY = layout.handCenter.y;
const insertIdx = Phaser.Math.Clamp(Math.round((card.x - handStartX) / spread), 0, n - 1);
this.tweens.killTweensOf(card);
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(handStartX + insertIdx * spread, handCenterY);
slotIndicator.setDepth(D.card - 1);
this.transientObjs.push(slotIndicator);
this.dragState = { cardIdx: handIdx, offsetX, offsetY, prevX: card.x, shadow, slotIndicator, insertIdx, spread, handStartX, handCenterY };
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 - ds.handStartX) / ds.spread), 0, Math.max(0, n - 1));
if (newInsertIdx !== ds.insertIdx) {
ds.insertIdx = newInsertIdx;
this.tweens.killTweensOf(ds.slotIndicator);
this.tweens.add({ targets: ds.slotIndicator, x: ds.handStartX + newInsertIdx * ds.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 = ds.handStartX + finalPos * ds.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 hand = this.gs.players[0].hand;
const n = hand.length;
const finalIdx = Phaser.Math.Clamp(Math.round((card.x - ds.handStartX) / ds.spread), 0, n - 1);
this.tweens.killTweensOf(card);
this.tweens.add({ targets: card, x: ds.handStartX + finalIdx * ds.spread, y: ds.handCenterY, 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: ds.handStartX + finalPos * ds.spread, rotation: 0, scaleX: 1, scaleY: 1, duration: 120, ease: 'Cubic.easeOut' });
}
const [moved] = hand.splice(ds.cardIdx, 1);
hand.splice(finalIdx, 0, moved);
this.dragState = null;
this.time.delayedCall(240, () => this.renderAll());
}
renderTurnIndicator() {
const seat = this.gs.currentPlayer;
const slot = this.slotForSeat[seat];