feat(catan): add animated special cards and opponent dev card reveals

- Implement visual tracking for Longest Road and Largest Army cards with
  smooth transfer animations between players and the central display.
- Add audio feedback for special card ownership changes and opponent dev card plays.
- Display opponent development card details with a zoom-in animation when played.
- Update opponent panels to remove special card badges in favor of the new visual system.
- Add necessary spritesheet and audio assets to the preload scene.
- Minor fix to restore dice state in Parchisi logic after three-doubles penalty.
This commit is contained in:
Brian Fertig 2026-05-24 12:53:38 -06:00
parent c2fb49706f
commit 4198dd5757
12 changed files with 145 additions and 6 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -395,6 +395,89 @@ export default class CatanGame extends Phaser.Scene {
this.buildDevCardTooltip(); this.buildDevCardTooltip();
this._buildTurnTriangle(); this._buildTurnTriangle();
this._buildSpecialCards();
}
_buildSpecialCards() {
const makeCard = (x, frameIdx, borderColor) => {
const W = 64, H = 90, R = 6, BW = 3;
const bg = this.add.graphics();
bg.fillStyle(0x111111, 0.85);
bg.fillRoundedRect(-W / 2, -H / 2, W, H, R);
const img = this.add.image(0, 0, 'catan-special-cards', frameIdx).setDisplaySize(W - 6, H - 6);
const border = this.add.graphics();
border.lineStyle(BW, borderColor, 1);
border.strokeRoundedRect(-W / 2, -H / 2, W, H, R);
const c = this.add.container(x, 760).setDepth(D.hud + 3);
c.add([bg, img, border]);
return c;
};
this._lrCard = makeCard(1783, 0, 0xdaa520);
this._laCard = makeCard(1847, 1, 0xb03030);
this._prevSpecialCardOwners = { longestRoad: null, largestArmy: null };
this._specialCardAnimating = { longestRoad: false, largestArmy: false };
}
_getSpecialCardPos(cardType, owner) {
if (owner === null) {
return cardType === 'longestRoad'
? { x: 1783, y: 760, scale: 1 }
: { x: 1847, y: 760, scale: 1 };
}
if (owner === 0) {
return cardType === 'longestRoad'
? { x: 1390, y: 1015, scale: 1 }
: { x: 1462, y: 1015, scale: 1 };
}
const panel = this.oppPanels?.find((p) => p.seat === owner);
if (!panel) return { x: 0, y: 0, scale: 0.25 };
const bx = panel.x;
const by = panel.y - 74;
return cardType === 'longestRoad'
? { x: bx - 28, y: by, scale: 0.25 }
: { x: bx + 28, y: by, scale: 0.25 };
}
updateSpecialCards() {
if (!this._lrCard || !this.gs) return;
for (const cardType of ['longestRoad', 'largestArmy']) {
const newOwner = this.gs[cardType].owner;
const prevOwner = this._prevSpecialCardOwners[cardType];
const card = cardType === 'longestRoad' ? this._lrCard : this._laCard;
if (newOwner !== prevOwner) {
const fromPos = this._getSpecialCardPos(cardType, prevOwner);
this._prevSpecialCardOwners[cardType] = newOwner;
this._animateSpecialCardTransfer(cardType, fromPos, newOwner);
} else if (!this._specialCardAnimating[cardType]) {
const pos = this._getSpecialCardPos(cardType, newOwner);
card.setPosition(pos.x, pos.y).setScale(pos.scale);
}
}
}
_animateSpecialCardTransfer(cardType, fromPos, newOwner) {
const toPos = this._getSpecialCardPos(cardType, newOwner);
const card = cardType === 'longestRoad' ? this._lrCard : this._laCard;
card.setPosition(fromPos.x, fromPos.y).setScale(fromPos.scale);
this._specialCardAnimating[cardType] = true;
const peakY = Math.min(fromPos.y, toPos.y) - 150;
const midX = (fromPos.x + toPos.x) / 2;
const midScale = (fromPos.scale + toPos.scale) / 2;
const half = 380;
this.tweens.chain({ targets: card, tweens: [
{ x: midX, y: peakY, scale: midScale, duration: half, ease: 'Quad.Out' },
{ x: toPos.x, y: toPos.y, scale: toPos.scale,
duration: half, ease: 'Quad.In',
onComplete: () => { this._specialCardAnimating[cardType] = false; },
},
]});
enqueueSpeech(cardType === 'longestRoad' ? 'catan-card-road' : 'catan-card-army');
} }
_buildTurnTriangle() { _buildTurnTriangle() {
@ -723,9 +806,11 @@ export default class CatanGame extends Phaser.Scene {
this.add.text(x, y + 70, this.pname(seat), { this.add.text(x, y + 70, this.pname(seat), {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex, fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
wordWrap: { width: 180 }, align: 'center', wordWrap: { width: 180 }, align: 'center',
backgroundColor: 'rgba(0,0,0,0.55)', padding: { x: 6, y: 3 },
}).setOrigin(0.5, 0).setDepth(D.hud); }).setOrigin(0.5, 0).setDepth(D.hud);
const info = this.add.text(x, y + 96, '', { const info = this.add.text(x, y + 96, '', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, align: 'center', fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, align: 'center',
backgroundColor: 'rgba(0,0,0,0.55)', padding: { x: 6, y: 3 },
}).setOrigin(0.5, 0).setDepth(D.hud); }).setOrigin(0.5, 0).setDepth(D.hud);
this.oppPanels.push({ seat, info, x, y, cardFan: [], vpBadge: null }); this.oppPanels.push({ seat, info, x, y, cardFan: [], vpBadge: null });
}); });
@ -737,12 +822,7 @@ export default class CatanGame extends Phaser.Scene {
const p = this.gs.players[panel.seat]; const p = this.gs.players[panel.seat];
const cards = L.handSize(p); const cards = L.handSize(p);
const dev = p.devCards.length + p.newDevCards.length; const dev = p.devCards.length + p.newDevCards.length;
const badges = []; panel.info.setText(`${dev} dev ${p.knightsPlayed} knights`);
if (this.gs.longestRoad.owner === panel.seat) badges.push('LR');
if (this.gs.largestArmy.owner === panel.seat) badges.push('LA');
panel.info.setText(
`${dev} dev ${p.knightsPlayed} knights` + (badges.length ? `\n[${badges.join(' ')}]` : '')
);
// VP badge circle above the portrait // VP badge circle above the portrait
if (panel.vpBadge) { panel.vpBadge.destroy(); panel.vpBadge = null; } if (panel.vpBadge) { panel.vpBadge.destroy(); panel.vpBadge = null; }
@ -818,6 +898,7 @@ export default class CatanGame extends Phaser.Scene {
this.renderOpponentPanels(); this.renderOpponentPanels();
this.updateButtons(); this.updateButtons();
this.updateStatus(); this.updateStatus();
this.updateSpecialCards();
} }
renderPieces() { renderPieces() {
@ -1494,6 +1575,9 @@ export default class CatanGame extends Phaser.Scene {
await this.animateDevCardFromBank(seat); await this.animateDevCardFromBank(seat);
} }
this.gs = this.applyAction(seat, a); this.gs = this.applyAction(seat, a);
if (a.type === 'playDev' && a.card !== 'vp') {
await this.animateOppDevCardPlay(seat, a.card);
}
if (this.gs.phase === 'moveRobber') { if (this.gs.phase === 'moveRobber') {
const m = AI.chooseRobberMove(this.gs, seat); const m = AI.chooseRobberMove(this.gs, seat);
const preRobberHex = this.gs.robberHex; const preRobberHex = this.gs.robberHex;
@ -1533,6 +1617,58 @@ export default class CatanGame extends Phaser.Scene {
} }
} }
// ── opponent dev card reveal ──────────────────────────────────────────────────
async animateOppDevCardPlay(seat, cardType) {
const VISUAL = {
knight: { frame: 5, border: 0xb03030 },
roadBuilding: { frame: 6, border: 0x8b5a2b },
monopoly: { frame: 9, border: 0x7b2d8b },
yearOfPlenty: { frame: 10, border: 0x2d8b57 },
};
const SPEECH = {
knight: 'catan-dev-knight',
roadBuilding: 'catan-dev-road',
monopoly: 'catan-dev-monopoly',
yearOfPlenty: 'catan-dev-year',
};
const visual = VISUAL[cardType];
if (!visual) return;
const from = this.portraitPos(seat);
const toX = 1380, toY = 240;
const W = 270, H = 390, R = 12, BW = 8;
const bg = this.add.graphics();
bg.fillStyle(0x111111, 0.92);
bg.fillRoundedRect(-W / 2, -H / 2, W, H, R);
const img = this.add.image(0, 0, 'catan-cards', visual.frame).setDisplaySize(W - 16, H - 16);
const border = this.add.graphics();
border.lineStyle(BW, visual.border, 1);
border.strokeRoundedRect(-W / 2, -H / 2, W, H, R);
const card = this.add.container(from.x, from.y).setDepth(D.banner + 5).setScale(30 / W);
card.add([bg, img, border]);
await new Promise(resolve =>
this.tweens.add({ targets: card, x: toX, y: toY, scale: 1, duration: 500, ease: 'Back.easeOut', onComplete: resolve })
);
const speechFile = SPEECH[cardType];
await new Promise(resolve => {
if (!speechFile) { this.time.delayedCall(800, resolve); return; }
const audio = new Audio(`/assets/speech/${speechFile}.mp3`);
audio.onended = resolve;
audio.onerror = resolve;
audio.play().catch(resolve);
});
await new Promise(resolve =>
this.tweens.add({ targets: card, alpha: 0, duration: 400, ease: 'Quad.In', onComplete: resolve })
);
card.destroy();
}
// ── human: roll ─────────────────────────────────────────────────────────────── // ── human: roll ───────────────────────────────────────────────────────────────
async onRoll() { async onRoll() {
if (this.busy || this.gs.phase !== 'rollPhase' || this.gs.currentPlayer !== 0) return; if (this.busy || this.gs.phase !== 'rollPhase' || this.gs.currentPlayer !== 0) return;

View File

@ -81,6 +81,7 @@ export function rollSpecificDice(state, d1, d2) {
// Three-doubles penalty: furthest-from-home pawn of current player // Three-doubles penalty: furthest-from-home pawn of current player
// goes back to the nest; turn ends. No moves played. // goes back to the nest; turn ends. No moves played.
applyThreeDoublesPenalty(s); applyThreeDoublesPenalty(s);
s.dice = [d1, d2]; // endTurn nulled dice; restore for animation/display
return s; return s;
} }
const allOut = pawnsInNest(s, s.currentPlayer) === 0; const allOut = pawnsInNest(s, s.currentPlayer) === 0;

View File

@ -59,6 +59,8 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-pencil-write', '/assets/fx/pencil-write.mp3'); this.load.audio('sfx-pencil-write', '/assets/fx/pencil-write.mp3');
this.load.audio('sfx-piece-click', '/assets/fx/piece-click.mp3'); this.load.audio('sfx-piece-click', '/assets/fx/piece-click.mp3');
this.load.audio('sfx-roulette', '/assets/fx/roulette.mp3'); this.load.audio('sfx-roulette', '/assets/fx/roulette.mp3');
this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 });
} }
async create() { async create() {