feat(swdbg): add card dealing animations and opponent hand visualization
- Animate card draws, plays, and row refills with smooth flight transitions and mid-flight reveal flips during initial deal - Introduce slot helper methods for consistent card positioning across renderers and animations - Show opponent hand as face-down fan with visible draw/discard pile counts - Track pending card UIDs to prevent rendering cards mid-animation - Support game-specific default playfields via gamesRegistry - Add guardFreshButton to prevent accidental clicks from pointer event bubbling - Add Starscape and Fantasy playfields; set Starscape as default for Star Wars
This commit is contained in:
parent
4dcc8211b8
commit
ee60d7fa1c
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
|
|
@ -14,10 +14,16 @@
|
|||
"path": "assets/images/playfield-wood-cherry.png"
|
||||
},
|
||||
{
|
||||
"id": "light",
|
||||
"name": "Bright Wooden Table",
|
||||
"key": "playfield-light",
|
||||
"path": "assets/images/playfield-wood-light.png"
|
||||
"id": "stars",
|
||||
"name": "Starscape",
|
||||
"key": "playfield-starscape",
|
||||
"path": "assets/images/playfield-starscape.png"
|
||||
},
|
||||
{
|
||||
"id": "fantasy",
|
||||
"name": "Fantasy",
|
||||
"key": "playfield-fantasy",
|
||||
"path": "assets/images/playfield-fantasy.png"
|
||||
},
|
||||
{
|
||||
"id": "dark",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export function registerGame(definition) {
|
|||
minOpponents: definition.minOpponents ?? 1,
|
||||
maxOpponents: definition.maxOpponents ?? 1,
|
||||
defaultOpponents: definition.defaultOpponents ?? null,
|
||||
defaultPlayfield: definition.defaultPlayfield ?? null,
|
||||
hasTutorial: definition.hasTutorial ?? false,
|
||||
iconFrame: definition.iconFrame ?? null,
|
||||
});
|
||||
|
|
@ -104,4 +105,4 @@ registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardG
|
|||
registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75 });
|
||||
registerGame({ slug: 'jumble', name: 'Jumble', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 76 });
|
||||
registerGame({ slug: 'dungeonboss', name: 'Dungeon Boss', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 77 });
|
||||
registerGame({ slug: 'swdbg', name: 'Star Wars', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 78 });
|
||||
registerGame({ slug: 'swdbg', name: 'Star Wars', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 78, defaultPlayfield: 'stars' });
|
||||
|
|
|
|||
|
|
@ -135,6 +135,8 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
this._recorded = false;
|
||||
this._rowPos = new Map(); // uid → {x,y}
|
||||
this._playPos = new Map(); // uid → {x,y}
|
||||
this._pendingCardUids = new Set(); // uids whose deal/refill animation hasn't landed yet
|
||||
this._dealingInitial = false; // true only during the game-start deal sequence
|
||||
this._actionButtons = [];
|
||||
this.hoverTimer = null;
|
||||
this.hoverVisible = false;
|
||||
|
|
@ -228,10 +230,19 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
|
||||
startGame(faction) {
|
||||
this.gs = newGame({ humanFaction: faction });
|
||||
takeEvents(this.gs);
|
||||
this.renderAll();
|
||||
this.showBanner(faction === 'empire' ? 'You command the Empire — your turn first' : 'The Empire moves first — hold the line');
|
||||
this.pump();
|
||||
const events = takeEvents(this.gs);
|
||||
const oppSeat = 1 - this.humanSeat;
|
||||
const oppDraws = events.filter((e) => e.type === 'draw' && e.seat === oppSeat);
|
||||
const humanDraws = events.filter((e) => e.type === 'draw' && e.seat === this.humanSeat);
|
||||
const rowRefills = events.filter((e) => e.type === 'rowRefill');
|
||||
const ordered = [...oppDraws, ...humanDraws, ...rowRefills];
|
||||
this._dealingInitial = true;
|
||||
this.playEvents(ordered, {
|
||||
onDone: () => {
|
||||
this._dealingInitial = false;
|
||||
this.showBanner(faction === 'empire' ? 'You command the Empire — your turn first' : 'The Empire moves first — hold the line');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── art lookup ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -679,6 +690,11 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
|
||||
seatName(seat) { return seat === this.humanSeat ? 'you' : (this.opponents[0]?.name || 'Opponent'); }
|
||||
|
||||
// final resting slot for opponent capital index `i` / in-play index `i` —
|
||||
// shared by renderOpponent() and the opponent's card-play deal-in animation
|
||||
oppCapitalSlot(i) { return { x: 660 + i * 122, y: 140, w: 108, h: 152 }; }
|
||||
oppInPlaySlot(i) { return { x: 1210 + (i % 5) * 96, y: 100 + Math.floor(i / 5) * 88, w: 84, h: 118 }; }
|
||||
|
||||
renderOpponent() {
|
||||
const gs = this.gs;
|
||||
const seat = 1 - this.humanSeat;
|
||||
|
|
@ -713,21 +729,23 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
}).setOrigin(0.5).setAngle(-6));
|
||||
}
|
||||
this._oppBasePos = { x: 392, y: 140 };
|
||||
this.renderOpponentPiles(570, p);
|
||||
// capitals
|
||||
p.capitals.forEach((e, i) => {
|
||||
const x = 620 + i * 122;
|
||||
this._playPos.set(e.card.uid, { x, y: 140 });
|
||||
this.makeCard(x, 140, e.card, 108, 152, {
|
||||
const slot = this.oppCapitalSlot(i);
|
||||
this._playPos.set(e.card.uid, { x: slot.x, y: slot.y });
|
||||
if (this._pendingCardUids.has(e.card.uid)) return;
|
||||
this.makeCard(slot.x, slot.y, e.card, slot.w, slot.h, {
|
||||
parent: this.boardLayer, hover: false, damage: e.damage,
|
||||
onClick: () => this.showInspect(e.card),
|
||||
});
|
||||
});
|
||||
// opponent cards in play (their turn)
|
||||
p.inPlay.forEach((e, i) => {
|
||||
const x = 1210 + (i % 5) * 96;
|
||||
const y = 100 + Math.floor(i / 5) * 88;
|
||||
this._playPos.set(e.card.uid, { x, y });
|
||||
this.makeCard(x, y, e.card, 84, 118, {
|
||||
const slot = this.oppInPlaySlot(i);
|
||||
this._playPos.set(e.card.uid, { x: slot.x, y: slot.y });
|
||||
if (this._pendingCardUids.has(e.card.uid)) return;
|
||||
this.makeCard(slot.x, slot.y, e.card, slot.w, slot.h, {
|
||||
parent: this.boardLayer, hover: false, committed: e.committed,
|
||||
attackNow: entryAttack(gs, seat, e),
|
||||
onClick: () => this.showInspect(e.card),
|
||||
|
|
@ -740,11 +758,11 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
`Discard ${p.discard.length}`,
|
||||
`Resources ${p.resources}`,
|
||||
];
|
||||
this.boardLayer.add(this.add.text(cx, 70, lines.join('\n'), {
|
||||
this.boardLayer.add(this.add.text(cx, 95, lines.join('\n'), {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: C.muted, lineSpacing: 7,
|
||||
}));
|
||||
// bases remaining / lost
|
||||
this.renderBasePips(cx, 168, p, info);
|
||||
this.renderBasePips(cx, 193, p, info);
|
||||
if (gs.turnSeat === seat && !gs.over) {
|
||||
this.boardLayer.add(this.add.text(GAME_WIDTH / 2, 48, '— THEIR TURN —', {
|
||||
fontFamily: 'Righteous', fontSize: '15px', color: info.colorHex,
|
||||
|
|
@ -752,6 +770,38 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
// small face-down hand fan + draw/discard pile icons, shown just right of the
|
||||
// portrait so pile sizes are visible at a glance without opening any panel
|
||||
renderOpponentPiles(x, p) {
|
||||
const pw = 30, ph = 42;
|
||||
const pendingInHand = p.hand.filter((c) => this._pendingCardUids.has(c.uid)).length;
|
||||
const visibleCount = p.hand.length - pendingInHand;
|
||||
const shown = Math.min(visibleCount, 4);
|
||||
const fanY = 72;
|
||||
for (let i = 0; i < shown; i++) {
|
||||
const off = i - (shown - 1) / 2;
|
||||
this.makeCardBack(x + off * 7, fanY, pw, ph, this.boardLayer).setAngle(off * 7);
|
||||
}
|
||||
this.boardLayer.add(this.add.text(x, fanY + ph / 2 + 11, `HAND ${visibleCount}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '11px', color: C.muted,
|
||||
}).setOrigin(0.5));
|
||||
const pile = (y, count, label) => {
|
||||
if (count > 0) {
|
||||
this.makeCardBack(x, y, pw, ph, this.boardLayer);
|
||||
} else {
|
||||
const g = this.add.graphics();
|
||||
g.lineStyle(2, 0x2a3c66, 0.6);
|
||||
g.strokeRoundedRect(x - pw / 2, y - ph / 2, pw, ph, 6);
|
||||
this.boardLayer.add(g);
|
||||
}
|
||||
this.boardLayer.add(this.add.text(x, y + ph / 2 + 11, `${label} ${count}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '11px', color: C.muted,
|
||||
}).setOrigin(0.5));
|
||||
};
|
||||
pile(132, p.deck.length, 'DRAW');
|
||||
pile(192, p.discard.length, 'DISCARD');
|
||||
}
|
||||
|
||||
renderBasePips(x, y, p, info) {
|
||||
this.boardLayer.add(this.add.text(x, y - 22, `Bases lost ${p.lostBases}/${this.gs.meta.basesToWin}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: C.muted,
|
||||
|
|
@ -766,6 +816,14 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
// final resting slot for row index `i` given a card's faction — shared by
|
||||
// renderGalaxy() and the row-refill deal-in animation
|
||||
galaxyRowSlot(i, faction, humanFaction) {
|
||||
const rowX0 = 510, cw = 158, ch = 222, y = 430;
|
||||
const rowNudge = faction === 'neutral' ? 0 : faction === humanFaction ? 12 : -12;
|
||||
return { x: rowX0 + i * (cw + 14), y: y + rowNudge, w: cw, h: ch };
|
||||
}
|
||||
|
||||
// ── galaxy band ─────────────────────────────────────────────────────────────
|
||||
renderGalaxy() {
|
||||
const gs = this.gs;
|
||||
|
|
@ -790,17 +848,14 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
const humanFaction = gs.players[this.humanSeat].faction;
|
||||
gs.galaxy.row.forEach((card, i) => {
|
||||
const def = cardDef(card);
|
||||
const x = rowX0 + i * (cw + 14);
|
||||
// nudge cards vertically to hint at ownership: down for the player's own
|
||||
// faction (easier to buy), up for the opponent's faction, untouched if neutral
|
||||
const rowNudge = def.faction === 'neutral' ? 0 : def.faction === humanFaction ? 12 : -12;
|
||||
const cy = y + rowNudge;
|
||||
this._rowPos.set(card.uid, { x, y: cy });
|
||||
const slot = this.galaxyRowSlot(i, def.faction, humanFaction);
|
||||
this._rowPos.set(card.uid, { x: slot.x, y: slot.y });
|
||||
if (this._pendingCardUids.has(card.uid)) return;
|
||||
const buyable = !this.squad.size && legal && legal.buys.some((b) => b.uid === card.uid);
|
||||
const targetable = this.squad.size > 0 && this.rowTargetReachable(card);
|
||||
const freeTarget = this.mode.type === 'target' && ['discardRow', 'freePurchase', 'destroyCapital'].includes(this.mode.decision?.op)
|
||||
&& (this.mode.candidates || []).some((c) => c.uid === card.uid);
|
||||
this.makeCard(x, cy, card, cw, ch, {
|
||||
this.makeCard(slot.x, slot.y, card, slot.w, slot.h, {
|
||||
parent: this.rowLayer, showText: true,
|
||||
highlightBuy: buyable, highlightTarget: targetable, highlightGold: freeTarget,
|
||||
onClick: () => this.onRowClicked(card),
|
||||
|
|
@ -856,6 +911,17 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
// ── human area ──────────────────────────────────────────────────────────────
|
||||
// final resting slot for the human's own capital index `i` / in-play index
|
||||
// `i` of a row with `unitsLength` cards — shared by renderHuman() and the
|
||||
// human's own card-play deal-in animation
|
||||
humanCapitalSlot(i) { return { x: 620 + i * 128, y: 730, w: 116, h: 162 }; }
|
||||
humanInPlaySlot(i, unitsLength) {
|
||||
const uw = 122, uh = 170;
|
||||
const pitch = Math.min(uw + 10, 620 / Math.max(1, unitsLength));
|
||||
const x0 = 1180 - ((unitsLength - 1) * pitch) / 2;
|
||||
return { x: x0 + i * pitch, y: 730, w: uw, h: uh };
|
||||
}
|
||||
|
||||
renderHuman() {
|
||||
const gs = this.gs;
|
||||
const seat = this.humanSeat;
|
||||
|
|
@ -891,38 +957,35 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
|
||||
// capitals
|
||||
p.capitals.forEach((e, i) => {
|
||||
const x = 620 + i * 128;
|
||||
const y = playRowY;
|
||||
this._playPos.set(e.card.uid, { x, y });
|
||||
const slot = this.humanCapitalSlot(i);
|
||||
this._playPos.set(e.card.uid, { x: slot.x, y: slot.y });
|
||||
if (this._pendingCardUids.has(e.card.uid)) return;
|
||||
const canUse = legal && legal.abilities.some((a) => a.uid === e.card.uid);
|
||||
const selectable = this.myTurnMode() && !e.committed && entryAttack(gs, seat, e) > 0;
|
||||
this.makeCard(x, y, e.card, 116, 162, {
|
||||
this.makeCard(slot.x, slot.y, e.card, slot.w, slot.h, {
|
||||
parent: this.boardLayer, hover: false, damage: e.damage,
|
||||
selected: this.squad.has(e.card.uid), committed: e.committed,
|
||||
attackNow: entryAttack(gs, seat, e),
|
||||
onClick: () => selectable ? this.toggleSquad(e.card.uid) : this.showInspect(e.card),
|
||||
});
|
||||
if (canUse) this.addUsePill(x, this.usePillY(y, 162), () => this.beginAbility({ zone: 'capital', uid: e.card.uid }));
|
||||
if (canUse) this.addUsePill(slot.x, this.usePillY(slot.y, slot.h), () => this.beginAbility({ zone: 'capital', uid: e.card.uid }));
|
||||
});
|
||||
|
||||
// units in play
|
||||
const units = p.inPlay;
|
||||
const uw = 122, uh = 170;
|
||||
const pitch = Math.min(uw + 10, 620 / Math.max(1, units.length));
|
||||
const x0 = 1180 - ((units.length - 1) * pitch) / 2;
|
||||
units.forEach((e, i) => {
|
||||
const x = x0 + i * pitch;
|
||||
const y = playRowY;
|
||||
this._playPos.set(e.card.uid, { x, y });
|
||||
const slot = this.humanInPlaySlot(i, units.length);
|
||||
this._playPos.set(e.card.uid, { x: slot.x, y: slot.y });
|
||||
if (this._pendingCardUids.has(e.card.uid)) return;
|
||||
const canUse = legal && legal.abilities.some((a) => a.uid === e.card.uid);
|
||||
const selectable = this.myTurnMode() && !e.committed && entryAttack(gs, seat, e) > 0;
|
||||
this.makeCard(x, y, e.card, uw, uh, {
|
||||
this.makeCard(slot.x, slot.y, e.card, slot.w, slot.h, {
|
||||
parent: this.boardLayer, hover: false,
|
||||
selected: this.squad.has(e.card.uid), committed: e.committed,
|
||||
attackNow: entryAttack(gs, seat, e),
|
||||
onClick: () => selectable ? this.toggleSquad(e.card.uid) : this.showInspect(e.card),
|
||||
});
|
||||
if (canUse) this.addUsePill(x, this.usePillY(y, uh), () => this.beginAbility({ zone: 'play', uid: e.card.uid }));
|
||||
if (canUse) this.addUsePill(slot.x, this.usePillY(slot.y, slot.h), () => this.beginAbility({ zone: 'play', uid: e.card.uid }));
|
||||
});
|
||||
if (!units.length && !p.capitals.length) {
|
||||
this.boardLayer.add(this.add.text(1180, playRowY, 'cards you play land here', {
|
||||
|
|
@ -974,19 +1037,31 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
const pitch = Math.min(cw + 8, 1250 / Math.max(1, p.hand.length));
|
||||
let x = GAME_WIDTH / 2 - ((p.hand.length - 1) * pitch) / 2;
|
||||
for (const inst of p.hand) {
|
||||
const cardX = x;
|
||||
x += pitch;
|
||||
if (this._pendingCardUids.has(inst.uid)) continue;
|
||||
const playable = this.myTurnMode();
|
||||
const discardable = this.mode.type === 'oppDiscard' || (this.mode.type === 'target' && this.mode.decision?.op === 'exileCards'
|
||||
&& (this.mode.candidates || []).some((c) => c.zone === 'hand' && c.uid === inst.uid));
|
||||
this.makeCard(x, y, inst, cw, chh, {
|
||||
this.makeCard(cardX, y, inst, cw, chh, {
|
||||
parent: this.handLayer, showText: true,
|
||||
highlightGold: discardable,
|
||||
onClick: () => this.onHandClicked(inst),
|
||||
});
|
||||
if (!playable && !discardable) this.handLayer.list[this.handLayer.list.length - 1].setAlpha(0.9);
|
||||
x += pitch;
|
||||
}
|
||||
}
|
||||
|
||||
// final resting x for hand slot `index` of a hand with `handLength` cards —
|
||||
// shared by renderHand() and the deal-in animation so a card's flight target
|
||||
// always matches where it will actually land
|
||||
humanHandSlotX(index, handLength) {
|
||||
const cw = 148;
|
||||
const pitch = Math.min(cw + 8, 1250 / Math.max(1, handLength));
|
||||
const x0 = GAME_WIDTH / 2 - ((handLength - 1) * pitch) / 2;
|
||||
return x0 + index * pitch;
|
||||
}
|
||||
|
||||
// small draw/discard pile shown beside the human hand: card back + count when
|
||||
// non-empty, dashed outline placeholder when empty
|
||||
renderHandPile(x, y, count, label) {
|
||||
|
|
@ -1262,9 +1337,22 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
b.setDepth(DEPTH.overlay + 1);
|
||||
this._modalButtons = this._modalButtons || [];
|
||||
this._modalButtons.push(b);
|
||||
this.guardFreshButton(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
// Button's onClick fires on 'pointerup', while everything else in this game
|
||||
// (cards, the USE pill) fires on 'pointerdown'. When a popup opens
|
||||
// synchronously from a pointerdown handler (e.g. clicking "USE") while the
|
||||
// mouse is still physically held down, that same click's eventual release
|
||||
// would otherwise land on a freshly-created popup button and fire it
|
||||
// immediately. Swallow just that one pending release before arming it.
|
||||
guardFreshButton(b) {
|
||||
if (!this.input.activePointer.isDown) return;
|
||||
b.input.enabled = false;
|
||||
this.input.once('pointerup', () => { if (b.input) b.input.enabled = true; });
|
||||
}
|
||||
|
||||
showBaseChoiceModal(d) {
|
||||
const root = this.modalRoot();
|
||||
const p = this.gs.players[this.humanSeat];
|
||||
|
|
@ -1447,6 +1535,7 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
const b = new Button(this, 1700, 700 + i * 76, label, cb, { width: 190, fontSize: 18 });
|
||||
b.setDepth(DEPTH.ui);
|
||||
this._actionButtons.push(b);
|
||||
this.guardFreshButton(b);
|
||||
}
|
||||
clearActionButtons() {
|
||||
for (const b of this._actionButtons) b.destroy();
|
||||
|
|
@ -1454,9 +1543,12 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
// ── event playback ──────────────────────────────────────────────────────────
|
||||
playEvents(events) {
|
||||
playEvents(events, opts = {}) {
|
||||
this.busy = true;
|
||||
this.setPrompt('');
|
||||
for (const e of events) {
|
||||
if ((e.type === 'draw' || e.type === 'rowRefill' || e.type === 'play') && e.uid != null) this._pendingCardUids.add(e.uid);
|
||||
}
|
||||
const queue = events.filter((e) => this.eventDelay(e) > 0);
|
||||
const step = () => {
|
||||
const e = queue.shift();
|
||||
|
|
@ -1464,6 +1556,7 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
this.busy = false;
|
||||
this.renderAll();
|
||||
this.pump();
|
||||
opts.onDone?.();
|
||||
return;
|
||||
}
|
||||
this.eventFx(e);
|
||||
|
|
@ -1477,7 +1570,7 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
const aiEvent = e.seat != null && e.seat !== this.humanSeat;
|
||||
switch (e.type) {
|
||||
case 'turnStart': return 460;
|
||||
case 'play': return aiEvent ? 300 : 140;
|
||||
case 'play': return aiEvent ? 300 : 220;
|
||||
case 'buy': return 420;
|
||||
case 'bounty': return 640;
|
||||
case 'attackBase': return 380;
|
||||
|
|
@ -1495,10 +1588,131 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
case 'repair': return 300;
|
||||
case 'rowDiscard': return 340;
|
||||
case 'galaxyReshuffle': return 300;
|
||||
case 'draw': return this._dealingInitial ? 300 : 260;
|
||||
case 'rowRefill': return this._dealingInitial ? 320 : 280;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// fly + resize + optional mid-flight flip, used for dealing a card from a
|
||||
// pile (draw pile / galaxy deck) to its final hand/row slot. The flip runs
|
||||
// inside a nested container so its scaleX collapse doesn't fight the outer
|
||||
// tween's own position/resize scale, and is timed to finish with margin
|
||||
// before the outer tween's onComplete destroys everything.
|
||||
dealCardAnimated(opts) {
|
||||
const {
|
||||
fromX, fromY, fromW, fromH,
|
||||
toX, toY, toW, toH,
|
||||
toAngle = 0,
|
||||
flipToId = null, // card def id to reveal mid-flight; null = stays a card-back
|
||||
faceId = null, // card def id to show face-up for the whole flight (no back stage at all)
|
||||
duration = 280,
|
||||
onLand,
|
||||
} = opts;
|
||||
const outer = this.add.container(fromX, fromY);
|
||||
outer.setScale(fromW / toW, fromH / toH);
|
||||
this.fxLayer.add(outer);
|
||||
const flipHost = this.add.container(0, 0);
|
||||
outer.add(flipHost);
|
||||
const back = faceId != null
|
||||
? this.makeCard(0, 0, { uid: -1, id: faceId }, toW, toH, { parent: flipHost, hover: false, showText: true, noHoverPreview: true })
|
||||
: this.makeCardBack(0, 0, toW, toH, flipHost);
|
||||
|
||||
this.tweens.add({
|
||||
targets: outer, x: toX, y: toY, scaleX: 1, scaleY: 1, angle: toAngle,
|
||||
duration, ease: 'Cubic.easeInOut',
|
||||
onComplete: () => { outer.destroy(); onLand?.(); },
|
||||
});
|
||||
|
||||
if (flipToId != null) {
|
||||
this.tweens.add({
|
||||
targets: flipHost, scaleX: 0, duration: duration * 0.35, delay: duration * 0.15,
|
||||
ease: 'Cubic.easeIn',
|
||||
onComplete: () => {
|
||||
back.destroy();
|
||||
this.makeCard(0, 0, { uid: -1, id: flipToId }, toW, toH, {
|
||||
parent: flipHost, hover: false, showText: true, noHoverPreview: true,
|
||||
});
|
||||
this.tweens.add({ targets: flipHost, scaleX: 1, duration: duration * 0.35, ease: 'Cubic.easeOut' });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
animateDraw(e) {
|
||||
const p = this.gs.players[e.seat];
|
||||
const idx = p.hand.findIndex((c) => c.uid === e.uid);
|
||||
if (idx < 0) { this._pendingCardUids.delete(e.uid); return; }
|
||||
const dur = this._dealingInitial ? 300 : 260;
|
||||
if (e.seat === this.humanSeat) {
|
||||
this.dealCardAnimated({
|
||||
fromX: 392, fromY: 911, fromW: 90, fromH: 124,
|
||||
toX: this.humanHandSlotX(idx, p.hand.length), toY: 985, toW: 148, toH: 206,
|
||||
flipToId: e.id, duration: dur,
|
||||
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
|
||||
});
|
||||
} else {
|
||||
const remainingPending = p.hand.filter((c) => this._pendingCardUids.has(c.uid) && c.uid !== e.uid).length;
|
||||
const visibleAfter = p.hand.length - remainingPending;
|
||||
const shown = Math.min(visibleAfter, 4);
|
||||
const off = (shown - 1) / 2; // land in the rightmost fan slot — backs are interchangeable
|
||||
this.dealCardAnimated({
|
||||
fromX: 570, fromY: 132, fromW: 30, fromH: 42,
|
||||
toX: 570 + off * 7, toY: 72, toW: 30, toH: 42, toAngle: off * 7,
|
||||
flipToId: null, duration: dur,
|
||||
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// opponent's hand is only ever shown as a generic face-down fan, so a played
|
||||
// card animates out of that fan (reveal-flip included, since playing it is
|
||||
// exactly the moment it stops being secret) into its capital/in-play slot
|
||||
animatePlay(e) {
|
||||
const p = this.gs.players[e.seat];
|
||||
const pool = e.capital ? p.capitals : p.inPlay;
|
||||
const idx = pool.findIndex((entry) => entry.card.uid === e.uid);
|
||||
if (idx < 0) { this._pendingCardUids.delete(e.uid); return; }
|
||||
const slot = e.capital ? this.oppCapitalSlot(idx) : this.oppInPlaySlot(idx);
|
||||
this.dealCardAnimated({
|
||||
fromX: 570, fromY: 72, fromW: 30, fromH: 42,
|
||||
toX: slot.x, toY: slot.y, toW: slot.w, toH: slot.h,
|
||||
flipToId: e.id, duration: 300,
|
||||
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
|
||||
});
|
||||
}
|
||||
|
||||
// the human already knows their own card (they just clicked it), so it
|
||||
// flies already face-up — no back stage, no flip — from the hand strip
|
||||
// into its capital/in-play slot
|
||||
animateHumanPlay(e) {
|
||||
const p = this.gs.players[e.seat];
|
||||
const pool = e.capital ? p.capitals : p.inPlay;
|
||||
const idx = pool.findIndex((entry) => entry.card.uid === e.uid);
|
||||
if (idx < 0) { this._pendingCardUids.delete(e.uid); return; }
|
||||
const slot = e.capital ? this.humanCapitalSlot(idx) : this.humanInPlaySlot(idx, pool.length);
|
||||
this.dealCardAnimated({
|
||||
fromX: GAME_WIDTH / 2, fromY: 985, fromW: 60, fromH: 84,
|
||||
toX: slot.x, toY: slot.y, toW: slot.w, toH: slot.h,
|
||||
faceId: e.id, duration: 220,
|
||||
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
|
||||
});
|
||||
}
|
||||
|
||||
animateRowRefill(e) {
|
||||
const row = this.gs.galaxy.row;
|
||||
const idx = row.findIndex((c) => c.uid === e.uid);
|
||||
if (idx < 0) { this._pendingCardUids.delete(e.uid); return; }
|
||||
const humanFaction = this.gs.players[this.humanSeat].faction;
|
||||
const slot = this.galaxyRowSlot(idx, cardDef(e.id).faction, humanFaction);
|
||||
this.dealCardAnimated({
|
||||
fromX: 330, fromY: 430, fromW: 158 * 0.9, fromH: 222 * 0.9,
|
||||
toX: slot.x, toY: slot.y, toW: slot.w, toH: slot.h,
|
||||
flipToId: e.id, duration: this._dealingInitial ? 320 : 280,
|
||||
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
|
||||
});
|
||||
}
|
||||
|
||||
eventFx(e) {
|
||||
switch (e.type) {
|
||||
case 'turnStart':
|
||||
|
|
@ -1508,6 +1722,8 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
break;
|
||||
case 'play':
|
||||
this.sfx(SFX.CARD_PLACE);
|
||||
if (e.seat !== this.humanSeat) this.animatePlay(e);
|
||||
else this.animateHumanPlay(e);
|
||||
break;
|
||||
case 'buy': {
|
||||
this.sfx(SFX.PURCHASE);
|
||||
|
|
@ -1599,6 +1815,14 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
case 'galaxyReshuffle':
|
||||
this.sfx(SFX.CARD_SHUFFLE);
|
||||
break;
|
||||
case 'draw':
|
||||
this.sfx(SFX.CARD_DEAL);
|
||||
this.animateDraw(e);
|
||||
break;
|
||||
case 'rowRefill':
|
||||
this.sfx(SFX.CARD_DEAL);
|
||||
this.animateRowRefill(e);
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,8 +114,9 @@ function drawCards(state, seat, n) {
|
|||
p.discard = [];
|
||||
emit(state, { type: 'reshuffle', seat });
|
||||
}
|
||||
p.hand.push(p.deck.pop());
|
||||
emit(state, { type: 'draw', seat });
|
||||
const card = p.deck.pop();
|
||||
p.hand.push(card);
|
||||
emit(state, { type: 'draw', seat, uid: card.uid, id: card.id });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
// Apply playfield default; card back is chosen randomly
|
||||
if (!isWordGame && this.gameDef.slug !== 'battleship' && this.gameDef.slug !== 'mastermind') {
|
||||
const pfd = this.cache.json.get('playfields') ?? {};
|
||||
this.applyDefault('playfields', pfd.default, 'selectedPlayfield', 'playfieldTiles');
|
||||
this.applyDefault('playfields', this.gameDef.defaultPlayfield ?? pfd.default, 'selectedPlayfield', 'playfieldTiles');
|
||||
if (this.selectedPlayfield?.type === 'colored' && this._colorDropdownDomEl) {
|
||||
this._colorDropdownDomEl.node.style.display = 'block';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue