From b63bf1b651b2e999c7bca4b0ce66c5328f8bba70 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 18 May 2026 23:40:26 -0600 Subject: [PATCH] refactor: enhance GoFish ask animation with fishing line effect Refactor the ask sequence in GoFishGame.js to introduce a visual fishing line animation between the asker and target before revealing the result. Key changes: - Extract target slot/layout calculations outside conditional blocks for clarity. - Add `animateFishingLine` method to draw a dynamic hook arc with barb that extends from asker to target over 1 second. - Reorganize animation timing: reveal asker's card first, then cast fishing line, then handle catch/fail outcomes. - Simplify catch/fail logic by unifying post-line animation flows. - Improve visual feedback with staggered card appearances and smoother transitions. --- public/src/games/gofish/GoFishGame.js | 180 +++++++++++++++++--------- 1 file changed, 120 insertions(+), 60 deletions(-) diff --git a/public/src/games/gofish/GoFishGame.js b/public/src/games/gofish/GoFishGame.js index 13d587c..8136e7b 100644 --- a/public/src/games/gofish/GoFishGame.js +++ b/public/src/games/gofish/GoFishGame.js @@ -705,6 +705,9 @@ export default class GoFishGame extends Phaser.Scene { const askerSlot = this.slotForSeat[askerSeat]; const askerLayout = slotLayout(askerSlot); const askerShow = this.slotShowPos(askerSlot, askerLayout); + const targetSlot = this.slotForSeat[targetSeat]; + const targetLayout = slotLayout(targetSlot); + const targetShow = this.slotShowPos(targetSlot, targetLayout); const askerCard = beforeState.players[askerSeat].hand.find(c => c.rank === rank); if (!askerCard) { onComplete(); return; } @@ -715,6 +718,7 @@ export default class GoFishGame extends Phaser.Scene { askerSprite.setDepth(D.banner - 4); this.transientObjs.push(askerSprite); + // Move asker card to show zone then flip it face-up. this.tweens.add({ targets: askerSprite, x: askerShow.x, y: askerShow.y, @@ -723,72 +727,67 @@ export default class GoFishGame extends Phaser.Scene { onComplete: () => this.flipCardFaceUp(askerSprite, askerCard), }); - if (last.result === 'catch') { - const targetSlot = this.slotForSeat[targetSeat]; - const targetLayout = slotLayout(targetSlot); - const targetShow = this.slotShowPos(targetSlot, targetLayout); - const n = last.cardsTransferred.length; - - this.time.delayedCall(640, () => { - const isTargetVert = targetSlot === 'left' || targetSlot === 'right'; - const targetSprites = last.cardsTransferred.map((card, i) => { - const tx = isTargetVert ? targetShow.x - : targetShow.x + (i - (n - 1) / 2) * (CARD_W + 8); - const ty = isTargetVert ? targetShow.y + (i - (n - 1) / 2) * (CARD_H + 8) - : targetShow.y; - - const sprite = this.makeCardSprite(card, targetLayout.handCenter.x, targetLayout.handCenter.y, { - faceUp: false, rotation: 0, - }); - sprite.setDepth(D.banner - 4); - this.transientObjs.push(sprite); - - this.tweens.add({ - targets: sprite, - x: tx, y: ty, - duration: 250, - delay: i * 80, - ease: 'Cubic.easeOut', - onComplete: () => this.flipCardFaceUp(sprite, card), - }); - - return { sprite, tx, ty }; - }); - - const waitMs = 250 + (n - 1) * 80 + 380; - this.time.delayedCall(waitMs, () => { - if (askerSprite.active) { - this.tweens.add({ - targets: askerSprite, alpha: 0, duration: 200, - onComplete: () => { if (askerSprite.active) askerSprite.destroy(); }, + // After card is revealed, cast fishing line toward the target, then show result. + this.time.delayedCall(640, () => { + this.animateFishingLine(askerShow.x, askerShow.y, targetShow.x, targetShow.y, () => { + if (last.result === 'catch') { + const n = last.cardsTransferred.length; + const isTargetVert = targetSlot === 'left' || targetSlot === 'right'; + const targetSprites = last.cardsTransferred.map((card, i) => { + const tx = isTargetVert ? targetShow.x + : targetShow.x + (i - (n - 1) / 2) * (CARD_W + 8); + const ty = isTargetVert ? targetShow.y + (i - (n - 1) / 2) * (CARD_H + 8) + : targetShow.y; + const sprite = this.makeCardSprite(card, targetLayout.handCenter.x, targetLayout.handCenter.y, { + faceUp: false, rotation: 0, }); - } - for (const { sprite } of targetSprites) { - if (!sprite.active) continue; + sprite.setDepth(D.banner - 4); + this.transientObjs.push(sprite); this.tweens.add({ targets: sprite, - x: askerLayout.handCenter.x, y: askerLayout.handCenter.y, - alpha: 0, duration: 380, ease: 'Cubic.easeIn', - onComplete: () => { if (sprite.active) sprite.destroy(); }, + x: tx, y: ty, + duration: 250, + delay: i * 80, + ease: 'Cubic.easeOut', + onComplete: () => this.flipCardFaceUp(sprite, card), }); - } - this.time.delayedCall(420, onComplete); - }); - }); - } else { - this.time.delayedCall(880, () => { - this.flipCardFaceDown(askerSprite); - this.time.delayedCall(340, () => { - this.tweens.add({ - targets: askerSprite, - x: askerLayout.handCenter.x, y: askerLayout.handCenter.y, - duration: 280, ease: 'Cubic.easeIn', - onComplete: () => { if (askerSprite.active) askerSprite.destroy(); }, + return { sprite, tx, ty }; }); - this.time.delayedCall(320, onComplete); - }); + + const waitMs = 250 + (n - 1) * 80 + 380; + this.time.delayedCall(waitMs, () => { + if (askerSprite.active) { + this.tweens.add({ + targets: askerSprite, alpha: 0, duration: 200, + onComplete: () => { if (askerSprite.active) askerSprite.destroy(); }, + }); + } + for (const { sprite } of targetSprites) { + if (!sprite.active) continue; + this.tweens.add({ + targets: sprite, + x: askerLayout.handCenter.x, y: askerLayout.handCenter.y, + alpha: 0, duration: 380, ease: 'Cubic.easeIn', + onComplete: () => { if (sprite.active) sprite.destroy(); }, + }); + } + this.time.delayedCall(420, onComplete); + }); + } else { + // Fish — flip card back face-down and return it to hand. + this.flipCardFaceDown(askerSprite); + this.time.delayedCall(340, () => { + this.tweens.add({ + targets: askerSprite, + x: askerLayout.handCenter.x, y: askerLayout.handCenter.y, + duration: 280, ease: 'Cubic.easeIn', + onComplete: () => { if (askerSprite.active) askerSprite.destroy(); }, + }); + this.time.delayedCall(320, onComplete); + }); + } }); - } + }); } slotShowPos(slot, layout) { @@ -1013,6 +1012,67 @@ export default class GoFishGame extends Phaser.Scene { } } + animateFishingLine(fromX, fromY, toX, toY, onComplete) { + const gfx = this.add.graphics().setDepth(D.banner - 3); + const totalDx = toX - fromX, totalDy = toY - fromY; + const totalLen = Math.sqrt(totalDx * totalDx + totalDy * totalDy) || 1; + const ndx = totalDx / totalLen, ndy = totalDy / totalLen; + const px = -ndy, py = ndx; // left-perpendicular to line direction + const HOOK_R = 10; + const BARB_LEN = 7; + const SWEEP = (4 * Math.PI) / 3; // 240° + + const drawFrame = (t) => { + gfx.clear(); + gfx.lineStyle(2, 0xffe066, 1); + const tipX = fromX + t * totalDx; + const tipY = fromY + t * totalDy; + + gfx.beginPath(); + gfx.moveTo(fromX, fromY); + gfx.lineTo(tipX, tipY); + gfx.strokePath(); + + // Hook arc offset perpendicular from tip + const hookCx = tipX + px * HOOK_R; + const hookCy = tipY + py * HOOK_R; + const startAngle = Math.atan2(tipY - hookCy, tipX - hookCx); + const endAngle = startAngle + SWEEP; + gfx.beginPath(); + gfx.arc(hookCx, hookCy, HOOK_R, startAngle, endAngle, false); + gfx.strokePath(); + + // Barb at hook tip + const barbX = hookCx + HOOK_R * Math.cos(endAngle); + const barbY = hookCy + HOOK_R * Math.sin(endAngle); + gfx.beginPath(); + gfx.moveTo(barbX, barbY); + gfx.lineTo(barbX - Math.sin(endAngle) * BARB_LEN, barbY + Math.cos(endAngle) * BARB_LEN); + gfx.strokePath(); + }; + + const progress = { t: 0 }; + this.tweens.add({ + targets: progress, + t: 1, + duration: 1000, + ease: 'Cubic.easeOut', + onUpdate: () => drawFrame(progress.t), + onComplete: () => { + drawFrame(1); + this.time.delayedCall(150, () => { + this.tweens.add({ + targets: gfx, + alpha: 0, + duration: 250, + ease: 'Linear', + onComplete: () => { gfx.destroy(); onComplete(); }, + }); + }); + }, + }); + } + formatAskBanner(last) { const asker = this.opponentName(last.askerSeat); const target = this.opponentName(last.targetSeat);