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.
This commit is contained in:
Brian Fertig 2026-05-18 23:40:26 -06:00
parent 55ff4a3e00
commit b63bf1b651
1 changed files with 120 additions and 60 deletions

View File

@ -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);