diff --git a/public/assets/music/track06.mp3 b/public/assets/music/track06.mp3 new file mode 100644 index 0000000..5571a24 Binary files /dev/null and b/public/assets/music/track06.mp3 differ diff --git a/public/assets/music/track07.mp3 b/public/assets/music/track07.mp3 new file mode 100644 index 0000000..085ec14 Binary files /dev/null and b/public/assets/music/track07.mp3 differ diff --git a/public/data/music.json b/public/data/music.json index fee4b2b..52efa39 100644 --- a/public/data/music.json +++ b/public/data/music.json @@ -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" } ] } \ No newline at end of file diff --git a/public/src/games/hearts/HeartsGame.js b/public/src/games/hearts/HeartsGame.js index b5eccda..a982355 100644 --- a/public/src/games/hearts/HeartsGame.js +++ b/public/src/games/hearts/HeartsGame.js @@ -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 })); }