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:
parent
97edebab55
commit
6aa331cf97
|
|
@ -84,7 +84,7 @@ export default class FarkelGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
this.gs = createInitialState({ playerCount, names, skills });
|
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,
|
fontFamily: 'Righteous', fontSize: '60px', color: COLORS.textHex,
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
this.statusText = this.add.text(TRAY_CX, 128, '', {
|
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 x = DICE_LEFT + i * (DIE + DIE_GAP);
|
||||||
const y = TRAY_CY;
|
const y = TRAY_CY;
|
||||||
const g = this.add.graphics().setDepth(DEPTH.die);
|
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);
|
const hit = this.add.zone(x, y, DIE, DIE).setOrigin(0.5).setDepth(DEPTH.dieSel);
|
||||||
hit.setInteractive({ useHandCursor: true });
|
hit.setInteractive({ useHandCursor: true });
|
||||||
hit.on('pointerdown', () => this.onDieClick(i));
|
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() {
|
renderDice() {
|
||||||
const rolled = this.gs.turn.rolled;
|
const rolled = this.gs.turn.rolled;
|
||||||
for (let i = 0; i < DICE; i++) {
|
for (let i = 0; i < DICE; i++) {
|
||||||
const el = this.dieEls[i];
|
const el = this.dieEls[i];
|
||||||
if (i < rolled.length) {
|
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 });
|
el.hit.setInteractive({ useHandCursor: true });
|
||||||
} else {
|
} else {
|
||||||
el.g.clear();
|
el.g.clear();
|
||||||
|
|
@ -436,29 +522,13 @@ export default class FarkelGame extends Phaser.Scene {
|
||||||
async rollAnimated() {
|
async rollAnimated() {
|
||||||
this.busy = true;
|
this.busy = true;
|
||||||
this.updateControls();
|
this.updateControls();
|
||||||
playSound(this, SFX.DICE_ROLL);
|
|
||||||
rollDice(this.gs);
|
rollDice(this.gs);
|
||||||
await this.tumble();
|
const values = this.gs.turn.rolled;
|
||||||
|
await this.animateDice(values);
|
||||||
this.render();
|
this.render();
|
||||||
this.busy = false;
|
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() {
|
async afterRoll() {
|
||||||
if (this.gs.phase === 'farkled') {
|
if (this.gs.phase === 'farkled') {
|
||||||
await this.farkleFx();
|
await this.farkleFx();
|
||||||
|
|
|
||||||
|
|
@ -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: '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: '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: '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 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue