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
This commit is contained in:
Brian Fertig 2026-06-06 18:44:25 -06:00
parent 97edebab55
commit 6aa331cf97
2 changed files with 92 additions and 22 deletions

View File

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

View File

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