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:
parent
c2fb49706f
commit
4198dd5757
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.
Binary file not shown.
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue