feat(hearts): enhance card sorting, drag interaction, and pass animations

- Sort player's hand by suit and value for consistent layout
- Implement drag-to-sort functionality for the local hand with visual feedback (slot indicators, card displacement, play zone highlighting)
- Refactor pass confirmation to include multi-phase animation: outgoing cards fly to target, incoming cards animate in from source, flip to reveal, and settle into sorted positions
- Add background to hearts broken indicator for better visibility
- Update card interaction handlers to support both click and drag events
This commit is contained in:
Brian Fertig 2026-05-21 23:29:52 -06:00
parent 3cea4f10b6
commit 4b0551c3cd
4 changed files with 348 additions and 15 deletions

Binary file not shown.

Binary file not shown.

View File

@ -24,6 +24,16 @@
"file": "track05.mp3",
"artist": "Guns 'n Roses",
"title": "Thunderbird"
},
{
"file": "track06.mp3",
"artist": "Simon Stalenhag",
"title": "Behind the Reactor"
},
{
"file": "track07.mp3",
"artist": "Jean Michel Jarre",
"title": "Oblivion"
}
]
}

View File

@ -12,6 +12,7 @@ import {
playCard,
legalPlays,
startNextHand,
passTargetSeat,
PASS_COUNT,
} from './HeartsLogic.js';
import { choosePass, choosePlay } from './HeartsAI.js';
@ -84,6 +85,10 @@ export default class HeartsGame extends Phaser.Scene {
this.passButton = null;
this.scoreTexts = [];
this.turnGlow = null;
this.localHandCards = []; // player 0 card containers in hand order
this.dragState = null;
this.potentialDrag = null;
}
create() {
@ -92,6 +97,7 @@ export default class HeartsGame extends Phaser.Scene {
this.buildSeats();
this.buildCenter();
this.buildHUD();
this.setupDragHandlers();
this.startNewMatch();
}
@ -129,7 +135,8 @@ export default class HeartsGame extends Phaser.Scene {
}
buildCenter() {
this.heartsText = this.add.text(CX, CY - 170, '', {
this.heartsBg = this.add.graphics().setDepth(D.ui - 1);
this.heartsText = this.add.text(CX, CY - 195, '', {
fontFamily: 'Righteous', fontSize: '18px', color: COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.ui);
@ -181,6 +188,11 @@ export default class HeartsGame extends Phaser.Scene {
this.updateHeartsIndicator();
playSound(this, SFX.CARD_SHUFFLE);
await this.dealAnimation();
const SUIT_ORDER = { c: 0, d: 1, s: 2, h: 3 };
this.gs.players[0].hand.sort((a, b) => {
const sd = SUIT_ORDER[a.suit] - SUIT_ORDER[b.suit];
return sd !== 0 ? sd : a.value - b.value;
});
this.renderHands();
this.updateScores();
this.busy = false;
@ -222,18 +234,111 @@ export default class HeartsGame extends Phaser.Scene {
async confirmPass() {
if (this.passSelection.size !== PASS_COUNT) return;
const ids = [...this.passSelection];
const outgoingIds = [...this.passSelection];
this.passButton?.destroy();
this.passButton = null;
this.passSelection.clear();
this.busy = true;
const dir = DIRECTION_LABEL[this.gs.passDirection];
this.gs = selectPass(this.gs, 0, ids); // all four are ready → pass resolves
this.showBanner(`Cards passed ${dir}.`);
const dir = this.gs.passDirection;
const targetSeat = passTargetSeat(0, dir);
// Find which opponent passes TO player 0.
let sourceSeat = 1;
for (let s = 1; s < 4; s++) {
if (passTargetSeat(s, dir) === 0) { sourceSeat = s; break; }
}
// Capture incoming card data before state is applied.
const incomingIds = new Set(this.gs.pendingPass[sourceSeat]);
const incomingCards = this.gs.players[sourceSeat].hand.filter((c) => incomingIds.has(c.id));
const targetLay = slotLayout(SLOTS[targetSeat]);
const sourceLay = slotLayout(SLOTS[sourceSeat]);
// ── Phase 1: Player's cards fly to the target opponent ───────────────────
const outgoingSprites = outgoingIds
.map((id) => this.handCardObjs.get(`0-${id}`))
.filter(Boolean);
for (const s of outgoingSprites) s.setDepth(D.ui + 5);
playSound(this, SFX.CARD_DEAL);
await Promise.all(outgoingSprites.map((s) =>
this.tweenTo(s, targetLay.portrait.x, targetLay.portrait.y, 380)
));
// Apply state and sort the final hand.
this.gs = selectPass(this.gs, 0, outgoingIds);
const SUIT_ORDER = { c: 0, d: 1, s: 2, h: 3 };
this.gs.players[0].hand.sort((a, b) => {
const sd = SUIT_ORDER[a.suit] - SUIT_ORDER[b.suit];
return sd !== 0 ? sd : a.value - b.value;
});
for (const s of outgoingSprites) s.destroy();
this.renderHands();
await this.delay(900);
this.hideBanner();
// Keep incoming card positions reserved but invisible until they animate in.
for (const card of incomingCards) {
this.handCardObjs.get(`0-${card.id}`)?.setAlpha(0);
}
// ── Phase 2: Face-down cards fly in from source opponent ─────────────────
const stagingY = slotLayout('bottom').handCenter.y - 180;
const stagingSprites = incomingCards.map((card) => {
const s = this.makeCardSprite(card, sourceLay.portrait.x, sourceLay.portrait.y, { faceUp: false });
s.setDepth(D.ui + 5);
return s;
});
playSound(this, SFX.CARD_DEAL);
await Promise.all(stagingSprites.map((s, i) =>
new Promise((resolve) => {
this.tweens.add({
targets: s,
x: CX + (i - 1) * (HAND_SPREAD + 12),
y: stagingY,
delay: i * 90,
duration: 380,
ease: 'Cubic.easeOut',
onComplete: resolve,
});
})
));
// ── Phase 3: Flip each card to reveal face-up ─────────────────────────────
for (let i = 0; i < stagingSprites.length; i++) {
const sprite = stagingSprites[i];
const card = incomingCards[i];
await new Promise((resolve) => {
this.tweens.add({
targets: sprite, scaleX: 0, duration: 140, ease: 'Linear',
onComplete: () => {
this.renderCardFace(sprite, card, true);
this.tweens.add({ targets: sprite, scaleX: 1, duration: 140, ease: 'Linear', onComplete: resolve });
},
});
});
playSound(this, SFX.CARD_PLACE);
if (i < stagingSprites.length - 1) await this.delay(70);
}
await this.delay(420);
// ── Phase 4: Cards slide into their sorted positions in the hand ──────────
await Promise.all(stagingSprites.map((sprite, i) => {
const target = this.handCardObjs.get(`0-${incomingCards[i].id}`);
if (!target) { sprite.destroy(); return Promise.resolve(); }
return new Promise((resolve) => {
this.tweens.add({
targets: sprite, x: target.x, y: target.y, duration: 320, ease: 'Cubic.easeOut',
onComplete: () => {
target.setAlpha(1);
sprite.destroy();
resolve();
},
});
});
}));
this.busy = false;
await this.advance();
}
@ -423,6 +528,7 @@ export default class HeartsGame extends Phaser.Scene {
}
renderSeatHand(seat) {
if (seat === 0) this.localHandCards = [];
const player = this.gs.players[seat];
const lay = slotLayout(SLOTS[seat]);
const n = player.hand.length;
@ -447,13 +553,21 @@ export default class HeartsGame extends Phaser.Scene {
const interactive = selecting || (playable && this.legalIds.has(card.id));
const dimmed = playable && !this.legalIds.has(card.id);
if (dimmed) sprite.setAlpha(0.45);
if (interactive) {
sprite.setInteractive(new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
sprite.input.cursor = 'pointer';
sprite.on('pointerover', () => { if (!this.busy) { sprite.y -= 10; } });
sprite.on('pointerout', () => { if (!this.busy) { sprite.y += 10; } });
sprite.on('pointerdown', () => this.onCardClick(card.id));
}
sprite.setInteractive(new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
sprite.input.cursor = interactive ? 'pointer' : 'default';
sprite.on('pointerover', () => { if (!this.busy && !this.dragState) sprite.y -= 10; });
sprite.on('pointerout', () => { if (!this.busy && !this.dragState) sprite.y += 10; });
sprite.on('pointerdown', (pointer) => {
if (this.busy || this.dragState) return;
this.potentialDrag = {
handIdx: i, cardId: card.id, isInteractive: interactive,
startX: pointer.x, startY: pointer.y,
offsetX: pointer.x - sprite.x, offsetY: pointer.y - sprite.y,
};
});
this.localHandCards.push(sprite);
if (selected) this.ringCard(x, y);
}
}
@ -516,7 +630,15 @@ export default class HeartsGame extends Phaser.Scene {
}
updateHeartsIndicator() {
this.heartsText?.setText(this.gs.heartsBroken ? '♥ Hearts broken' : '');
const broken = this.gs.heartsBroken;
this.heartsText?.setText(broken ? '♥ Hearts broken' : '');
this.heartsBg?.clear();
if (broken) {
const t = this.heartsText;
const pad = 8;
this.heartsBg.fillStyle(0x000000, 0.55);
this.heartsBg.fillRoundedRect(t.x - t.width / 2 - pad, t.y - t.height / 2 - pad, t.width + pad * 2, t.height + pad * 2, 6);
}
}
updateTurnIndicator() {
@ -592,6 +714,9 @@ export default class HeartsGame extends Phaser.Scene {
// ── Cleanup helpers ─────────────────────────────────────────────────────────────
clearHandObjs() {
this.dragState = null;
this.potentialDrag = null;
this.localHandCards = [];
for (const c of this.handCardObjs.values()) c.destroy();
this.handCardObjs.clear();
}
@ -606,6 +731,204 @@ export default class HeartsGame extends Phaser.Scene {
this.trickSprites.clear();
}
// ── 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, isInteractive } = this.potentialDrag;
this.potentialDrag = null;
if (isInteractive) this.onCardClick(cardId);
return;
}
if (this.dragState) this.endCardDrag();
});
}
startCardDrag(handIdx, offsetX, offsetY) {
const card = this.localHandCards[handIdx];
if (!card) return;
const hand = this.gs.players[0].hand;
const n = hand.length;
const lay = slotLayout('bottom');
const handStartX = lay.handCenter.x - (n - 1) / 2 * HAND_SPREAD;
const handCenterY = lay.handCenter.y;
const insertIdx = Phaser.Math.Clamp(Math.round((card.x - handStartX) / HAND_SPREAD), 0, n - 1);
const isLegal = this.gs.phase === 'playing' && this.awaitingHuman && this.legalIds.has(hand[handIdx].id);
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.transient.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 * HAND_SPREAD, handCenterY);
slotIndicator.setDepth(D.card - 1);
this.transient.push(slotIndicator);
let playZoneHighlight = null;
if (isLegal) {
const pt = slotLayout('bottom').trick;
playZoneHighlight = this.add.graphics();
playZoneHighlight.lineStyle(4, COLORS.accent, 0.9);
playZoneHighlight.strokeRoundedRect(-CARD_W / 2 - 6, -CARD_H / 2 - 6, CARD_W + 12, CARD_H + 12, 12);
playZoneHighlight.fillStyle(COLORS.accent, 0.18);
playZoneHighlight.fillRoundedRect(-CARD_W / 2 - 6, -CARD_H / 2 - 6, CARD_W + 12, CARD_H + 12, 12);
playZoneHighlight.setPosition(pt.x, pt.y);
playZoneHighlight.setDepth(D.card + 1);
playZoneHighlight.setAlpha(0);
this.transient.push(playZoneHighlight);
}
this.dragState = { cardIdx: handIdx, offsetX, offsetY, shadow, slotIndicator, insertIdx, handStartX, handCenterY, isLegal, playZoneHighlight, overPlayZone: false };
card.setDepth(D.card + 10);
this.tweens.add({ targets: card, scaleX: 1.08, scaleY: 1.08, duration: 120, ease: 'Cubic.easeOut' });
}
_isPlayZone(x, y) {
const pt = slotLayout('bottom').trick;
return Math.abs(x - pt.x) < CARD_W * 1.5 && Math.abs(y - pt.y) < CARD_H * 1.5;
}
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 overPlayZone = ds.isLegal && this._isPlayZone(card.x, card.y);
if (overPlayZone !== ds.overPlayZone) {
ds.overPlayZone = overPlayZone;
if (overPlayZone) {
this.tweens.killTweensOf(ds.slotIndicator);
ds.slotIndicator.setAlpha(0);
this._settleNonDraggedCards();
this.tweens.add({ targets: ds.playZoneHighlight, alpha: 1, duration: 150 });
} else {
ds.slotIndicator.setAlpha(1);
this._updateNonDraggedCards();
this.tweens.add({ targets: ds.playZoneHighlight, alpha: 0, duration: 150 });
}
}
if (!overPlayZone) {
const newInsertIdx = Phaser.Math.Clamp(Math.round((card.x - ds.handStartX) / HAND_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 * 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 = ds.handStartX + 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' });
}
}
_settleNonDraggedCards() {
const ds = this.dragState;
const hand = this.gs.players[0].hand;
const n = hand.length;
const remaining = n - 1;
const settleStartX = slotLayout('bottom').handCenter.x - (remaining - 1) / 2 * HAND_SPREAD;
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: settleStartX + k * HAND_SPREAD, rotation: 0, scaleX: 1, scaleY: 1, duration: 150, ease: 'Cubic.easeOut' });
k++;
}
}
endCardDrag() {
const ds = this.dragState;
const card = this.localHandCards[ds.cardIdx];
const hand = this.gs.players[0].hand;
const n = hand.length;
// ── Play path: dropped on the play zone ───────────────────────────────────
if (ds.overPlayZone) {
const handCard = hand[ds.cardIdx];
this.tweens.killTweensOf(card);
card.setScale(1);
card.setRotation(0);
this.dragState = null;
this.playHuman(handCard.id);
return;
}
// ── Reorder path: dropped back in hand ────────────────────────────────────
const movedCardId = hand[ds.cardIdx].id;
const finalIdx = Phaser.Math.Clamp(Math.round((card.x - ds.handStartX) / HAND_SPREAD), 0, n - 1);
const isSelected = this.gs.phase === 'passing' && this.passSelection.has(movedCardId);
const finalY = ds.handCenterY - (isSelected ? 26 : 0);
this.tweens.killTweensOf(card);
this.tweens.add({ targets: card, x: ds.handStartX + finalIdx * HAND_SPREAD, y: finalY, 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 otherIsSelected = this.gs.phase === 'passing' && this.passSelection.has(hand[j].id);
const cardObj = this.localHandCards[j];
this.tweens.killTweensOf(cardObj);
this.tweens.add({ targets: cardObj, x: ds.handStartX + finalPos * HAND_SPREAD, y: ds.handCenterY - (otherIsSelected ? 26 : 0), rotation: 0, scaleX: 1, scaleY: 1, duration: 120, ease: 'Cubic.easeOut' });
}
const [moved] = hand.splice(ds.cardIdx, 1);
hand.splice(finalIdx, 0, moved);
const [movedCard] = this.localHandCards.splice(ds.cardIdx, 1);
this.localHandCards.splice(finalIdx, 0, movedCard);
this.dragState = null;
this.time.delayedCall(240, () => { if (!this.dragState) this.renderHands(); });
}
tweenTo(target, x, y, duration) {
return new Promise((resolve) => this.tweens.add({ targets: target, x, y, duration, ease: 'Cubic.easeOut', onComplete: resolve }));
}