From 6aa331cf97304b94b20c6e384ff3ae9c046e52a4 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 6 Jun 2026 18:44:25 -0600 Subject: [PATCH] fix: correct game spelling to "Farkle" and add Catan-style dice animation - Rename "Farkel" to "Farkle" in UI title and server game registry - Replace tumble animation with new animateDice method featuring: - Dice thrown from elevated position with arc trajectory - Random face cycling while airborne - Bounce landing with per-die staggered timing - Spin and scale animations - Squash-bounce effect on final die - Refactor die rendering to use containers for transform animations --- public/src/games/farkel/FarkelGame.js | 112 +++++++++++++++++++++----- server/games/registry.js | 2 +- 2 files changed, 92 insertions(+), 22 deletions(-) diff --git a/public/src/games/farkel/FarkelGame.js b/public/src/games/farkel/FarkelGame.js index ac19b58..9ec36e7 100644 --- a/public/src/games/farkel/FarkelGame.js +++ b/public/src/games/farkel/FarkelGame.js @@ -84,7 +84,7 @@ export default class FarkelGame extends Phaser.Scene { } this.gs = createInitialState({ playerCount, names, skills }); - this.add.text(TRAY_CX, 56, 'Farkel', { + this.add.text(TRAY_CX, 56, 'Farkle', { fontFamily: 'Righteous', fontSize: '60px', color: COLORS.textHex, }).setOrigin(0.5); this.statusText = this.add.text(TRAY_CX, 128, '', { @@ -134,10 +134,11 @@ export default class FarkelGame extends Phaser.Scene { const x = DICE_LEFT + i * (DIE + DIE_GAP); const y = TRAY_CY; const g = this.add.graphics().setDepth(DEPTH.die); + const c = this.add.container(x, y, [g]).setDepth(DEPTH.die); const hit = this.add.zone(x, y, DIE, DIE).setOrigin(0.5).setDepth(DEPTH.dieSel); hit.setInteractive({ useHandCursor: true }); hit.on('pointerdown', () => this.onDieClick(i)); - this.dieEls.push({ g, hit, cx: x, cy: y }); + this.dieEls.push({ g, hit, cx: x, cy: y, c }); } } @@ -276,12 +277,97 @@ export default class FarkelGame extends Phaser.Scene { } } + // Catan-style dice throw animation + animateDice(values) { + return new Promise((resolve) => { + playSound(this, SFX.DICE_ROLL); + + const n = values.length; // only animate the dice actually being rolled + const landX = this.dieEls.slice(0, n).map((el) => el.cx); + const landY = TRAY_CY; + const startX = TRAY_CX; + const startY = TRAY_CY + 120; + const arcY = TRAY_CY - 200; + + // Hide unused dice containers + for (let i = n; i < DICE; i++) this.dieEls[i].c.setVisible(false); + + // Move rolling dice to throw origin, small, random angle + for (let i = 0; i < n; i++) { + const el = this.dieEls[i]; + el.c.setVisible(true).setAlpha(0.25).setScale(0.35).setAngle(Phaser.Math.Between(0, 359)) + .setPosition(startX + (i - (n - 1) / 2) * 18, startY); + this.drawDie(el.g, 0, 0, DIE, Phaser.Math.Between(1, 6)); + } + + // Cycle random faces while airborne + let cyclerStopped = false; + const cycler = this.time.addEvent({ + delay: 55, loop: true, + callback: () => { + for (let i = 0; i < n; i++) { + this.drawDie(this.dieEls[i].g, 0, 0, DIE, Phaser.Math.Between(1, 6)); + } + }, + }); + const stopCycler = () => { if (!cyclerStopped) { cyclerStopped = true; cycler.remove(); } }; + + let settled = 0; + for (let i = 0; i < n; i++) { + const el = this.dieEls[i]; + const lx = landX[i] + (Math.random() * 10 - 5); + const ly = landY + (Math.random() * 10 - 5); + const outMs = 320 + i * 28; + const backMs = 460 + i * 40; + const totalMs = outMs + backMs; + + // X flies straight to landing; Y arcs up then bounces down + this.tweens.add({ targets: el.c, x: lx, duration: totalMs, ease: 'Quad.Out' }); + this.tweens.chain({ + targets: el.c, tweens: [ + { y: arcY, duration: outMs, ease: 'Quad.Out' }, + { y: ly, duration: backMs, ease: 'Bounce.Out' }, + ] + }); + // Scale up as die approaches + this.tweens.add({ targets: el.c, scale: 1, duration: outMs + backMs * 0.55, ease: 'Quad.Out' }); + // Spin + this.tweens.add({ + targets: el.c, + angle: el.c.angle + 540 + Math.random() * 180, + duration: totalMs, + ease: 'Quad.Out', + }); + + this.time.delayedCall(totalMs, () => { + stopCycler(); + this.drawDie(el.g, 0, 0, DIE, values[i]); + // Snap to nearest upright angle with a small wiggle + const upright = Math.round(el.c.angle / 90) * 90 + (Math.random() * 10 - 5); + this.tweens.add({ + targets: el.c, angle: upright, duration: 120, ease: 'Back.Out', + onComplete: () => { + el.c.setAlpha(1); + settled++; + if (settled === n) { + // Little squash-bounce on last die + for (let j = 0; j < n; j++) + this.tweens.add({ targets: this.dieEls[j].c, scaleX: 1.12, scaleY: 0.88, duration: 80, yoyo: true }); + this.time.delayedCall(160, resolve); + } + }, + }); + }); + } + }); + } + renderDice() { const rolled = this.gs.turn.rolled; for (let i = 0; i < DICE; i++) { const el = this.dieEls[i]; if (i < rolled.length) { - this.drawDie(el.g, el.cx, el.cy, DIE, rolled[i], { selected: this.selected.has(i) }); + this.drawDie(el.g, 0, 0, DIE, rolled[i], { selected: this.selected.has(i) }); el.hit.setInteractive({ useHandCursor: true }); } else { el.g.clear(); @@ -436,29 +522,13 @@ export default class FarkelGame extends Phaser.Scene { async rollAnimated() { this.busy = true; this.updateControls(); - playSound(this, SFX.DICE_ROLL); rollDice(this.gs); - await this.tumble(); + const values = this.gs.turn.rolled; + await this.animateDice(values); this.render(); this.busy = false; } - tumble() { - const n = this.gs.turn.rolled.length; - return new Promise((resolve) => { - this.tweens.addCounter({ - from: 0, to: 1, duration: 520, ease: 'Quad.Out', - onUpdate: () => { - for (let i = 0; i < n; i++) { - const el = this.dieEls[i]; - this.drawDie(el.g, el.cx, el.cy, DIE, 1 + Math.floor(Math.random() * 6), {}); - } - }, - onComplete: resolve, - }); - }); - } - async afterRoll() { if (this.gs.phase === 'farkled') { await this.farkleFx(); diff --git a/server/games/registry.js b/server/games/registry.js index 3e4373d..a2dace4 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -70,4 +70,4 @@ registerGame({ slug: 'solitairetour', name: 'Solitaire Tour', category: 'c registerGame({ slug: 'splendor', name: 'Splendor', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 41 }); registerGame({ slug: 'labyrinth', name: 'Labyrinth', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 43 }); registerGame({ slug: 'videopoker', name: 'Video Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 44 }); -registerGame({ slug: 'farkel', name: 'Farkel', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 45 }); +registerGame({ slug: 'farkel', name: 'Farkle', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 45 });