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:
Brian Fertig 2026-07-05 14:44:03 -06:00
parent 4dcc8211b8
commit ee60d7fa1c
7 changed files with 277 additions and 45 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -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",

View File

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

View File

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

View File

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

View File

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