diff --git a/public/src/games/uno/UnoGame.js b/public/src/games/uno/UnoGame.js index badb327..9e1cb1a 100644 --- a/public/src/games/uno/UnoGame.js +++ b/public/src/games/uno/UnoGame.js @@ -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];