feat: enhance SWDBG animations and Dungeon Boss mid-walk casting
- SWDBG: add animated buy sequence with funding arcs, center-screen reveal, and face-down stow to discard/deck - SWDBG: introduce stable row slot tracking so bought/lost cards don't shift neighbors; add ghost cards for pending animations - SWDBG: add end-of-turn discard animations preserving old-hand positions; emit endTurnDiscard events from logic - SWDBG: add deep-dive Buy/Attack buttons for actionable cards; refactor dealCardAnimated with startAs/flipTo/keepAlive support - SWDBG: add drawArcArrow/drawArcArrows for converging attack visuals on bounty kills and base attacks - Dungeon Boss: add mid-walk advRoom casting window after room events, letting players react to damage/kills during hero crawl - Dungeon Boss: track walking hero in publicView and entranceHeroesOf so spells and AI heuristics can target mid-crawl heroes - Dungeon Boss: replace pre-bait token snapshot with overlay system — pending bait heroes stay in town with fly-in tokens; pending draw cards show card backs until animations land - Dungeon Boss: keep heroes in battle zone after roomHit/pass instead of returning them immediately - Update images, registry (defaultPlayfield), and verifier (advRoomCasts)
This commit is contained in:
parent
ee60d7fa1c
commit
a1503d19fe
Binary file not shown.
|
Before Width: | Height: | Size: 967 KiB After Width: | Height: | Size: 981 KiB |
Binary file not shown.
|
|
@ -104,5 +104,5 @@ registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGa
|
|||
registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 74 });
|
||||
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: 'dungeonboss', name: 'Dungeon Boss', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 77, defaultPlayfield: 'fantasy' });
|
||||
registerGame({ slug: 'swdbg', name: 'Star Wars', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 78, defaultPlayfield: 'stars' });
|
||||
|
|
|
|||
|
|
@ -149,13 +149,25 @@ function chooseWindow(view, seat, skill, rnd, decision) {
|
|||
const acts = windowActions(view, seat);
|
||||
if ((!acts.spells.length && !acts.rooms.length) || rnd() > prof.actChance) return { pass: true };
|
||||
const p = me(view, seat);
|
||||
const isAdv = decision.window === 'advStart';
|
||||
const isAdv = decision.window === 'advStart' || decision.window === 'advRoom';
|
||||
const myTurn = isAdv && decision.advSeat === seat;
|
||||
const leader = soulLeader(view, seat);
|
||||
const cands = [{ choice: { pass: true }, s: 1 }];
|
||||
|
||||
const survivor = (who) => who.entrance.find((h) => h.hp > dungeonDamage(view, who.seat));
|
||||
const dying = (who) => who.entrance.find((h) => h.hp <= dungeonDamage(view, who.seat));
|
||||
// The hero currently mid-crawl (see publicView's `adv` field) isn't in any
|
||||
// entrance array — surface it as a fallback so these heuristics can still
|
||||
// reason about "the hero at risk" during a mid-walk advRoom window.
|
||||
const walkingHeroOf = (s) => (view.adv && view.turnOrder[view.adv.orderIdx] === s ? view.adv.walking?.hero : null);
|
||||
const survivor = (who) => {
|
||||
const w = walkingHeroOf(who.seat);
|
||||
if (w && w.hp > dungeonDamage(view, who.seat)) return w;
|
||||
return who.entrance.find((h) => h.hp > dungeonDamage(view, who.seat));
|
||||
};
|
||||
const dying = (who) => {
|
||||
const w = walkingHeroOf(who.seat);
|
||||
if (w && w.hp <= dungeonDamage(view, who.seat)) return w;
|
||||
return who.entrance.find((h) => h.hp <= dungeonDamage(view, who.seat));
|
||||
};
|
||||
|
||||
for (const sp of acts.spells) {
|
||||
const def = spellDef({ id: sp.id });
|
||||
|
|
@ -169,9 +181,13 @@ function chooseWindow(view, seat, skill, rnd, decision) {
|
|||
else if (best) push(best, 3);
|
||||
} else if (op === 'healHero' && isAdv && !myTurn && leader) {
|
||||
const t = (sp.targets || []).find((t2) => {
|
||||
const owner = view.players.find((q) => q.entrance.some((h) => h.uid === t2.uid));
|
||||
let owner = view.players.find((q) => q.entrance.some((h) => h.uid === t2.uid));
|
||||
let hero = owner && owner.entrance.find((h) => h.uid === t2.uid);
|
||||
if (!hero && view.adv?.walking?.hero.uid === t2.uid) {
|
||||
owner = view.players[view.turnOrder[view.adv.orderIdx]];
|
||||
hero = view.adv.walking.hero;
|
||||
}
|
||||
if (!owner || owner.seat === seat) return false;
|
||||
const hero = owner.entrance.find((h) => h.uid === t2.uid);
|
||||
return hero.hp <= dungeonDamage(view, owner.seat) && hero.hp + def.op.amount > dungeonDamage(view, owner.seat);
|
||||
});
|
||||
if (t) push(t, 11);
|
||||
|
|
|
|||
|
|
@ -271,6 +271,17 @@ export default class DungeonBossGame extends Phaser.Scene {
|
|||
this._hoverSuppressCont = null; // card container currently mid-drag — its own hover popup stays disabled until dropped
|
||||
this._advBattle = null; // adventure battle modal session state (see openAdventureModal) while open
|
||||
this._advDungeonSnapshot = null; // per-seat pre-mutation dungeon snapshot, refreshed every applyDecision (see there)
|
||||
// Bait-walk overlay (town -> entrance), refreshed every playEvents() batch
|
||||
// that contains heroWalks events — see renderTown/renderHumanBoard/
|
||||
// renderOpponents and eventFx's 'heroWalks' case.
|
||||
this._pendingBaitUids = new Set(); // uids to hide from the real entrance render until the whole batch finishes
|
||||
this._baitStillInTownUids = new Set(); // uids still drawn in town (not yet walked this batch)
|
||||
this._baitHeroData = new Map(); // uid -> {id} for uids no longer in gs.town but still shown there
|
||||
this._baitFlightTokens = []; // every flying/landed token created this batch, cleaned up by finishBaitOverlay()
|
||||
// Drawn-card overlay: uids in the human hand whose fly-in-from-deck
|
||||
// animation hasn't landed yet — see renderHand's placeholder and
|
||||
// eventFx's 'draw' case / renderHandOnly.
|
||||
this._pendingDrawUids = new Set();
|
||||
}
|
||||
|
||||
create() {
|
||||
|
|
@ -1284,8 +1295,14 @@ export default class DungeonBossGame extends Phaser.Scene {
|
|||
}
|
||||
// pending secret build
|
||||
if (p.pendingBuild) this.makeCardBack(x + 128 - p.dungeon.length * pitch, y + 62, mw * 0.8, mh * 0.9, this.boardLayer);
|
||||
// entrance heroes — mini cards, fanned with a slight overlap
|
||||
// entrance heroes — mini cards, fanned with a slight overlap. Any hero
|
||||
// still mid-bait-walk (see playEvents/eventFx's 'heroWalks' case) is
|
||||
// skipped here — its resting flying token stands in for it, at this
|
||||
// SAME slot position, until the whole bait batch finishes — but hi
|
||||
// still counts every entry (skipped or not) so later heroes' slots
|
||||
// don't shift.
|
||||
p.entrance.forEach((hero, hi) => {
|
||||
if (this._pendingBaitUids.has(hero.uid)) return;
|
||||
const hx = x - pw / 2 + 30 + hi * OPP_ENTRANCE_PITCH;
|
||||
const hy = y + 62;
|
||||
this.miniHeroCard(hx, hy, hero, OPP_ENTRANCE_W, {
|
||||
|
|
@ -1298,14 +1315,20 @@ export default class DungeonBossGame extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
// Heroes queued to walk to an entrance this bait batch stay drawn here
|
||||
// (via _baitStillInTownUids) until their own walk step actually begins —
|
||||
// see eventFx's 'heroWalks' case, which deletes a hero from that set and
|
||||
// calls renderTownOnly() right as its flight starts.
|
||||
renderTown() {
|
||||
const gs = this.gs;
|
||||
const n = gs.town.length;
|
||||
const extraUids = [...this._baitStillInTownUids].filter((uid) => !gs.town.some((h) => h.uid === uid));
|
||||
const roster = [...gs.town, ...extraUids.map((uid) => ({ uid, ...(this._baitHeroData.get(uid) || {}) }))];
|
||||
const n = roster.length;
|
||||
const label = this.add.text(GAME_WIDTH / 2, 368, `— TOWN ${n ? '' : '(empty)'} —`, {
|
||||
fontFamily: 'Righteous', fontSize: '18px', color: '#9e9080',
|
||||
}).setOrigin(0.5);
|
||||
this.townLayer.add(label);
|
||||
gs.town.forEach((hero, i) => {
|
||||
roster.forEach((hero, i) => {
|
||||
const { x, y } = this.townPos(i, n);
|
||||
this._heroTokens.set(hero.uid, { x, y });
|
||||
this.makeHeroCard(x, y, hero, TOWN_HERO_W, TOWN_HERO_W / CARD_ASPECT.hero, {
|
||||
|
|
@ -1316,6 +1339,29 @@ export default class DungeonBossGame extends Phaser.Scene {
|
|||
});
|
||||
}
|
||||
|
||||
// Light-weight re-render of just the town row (see renderTown's overlay) —
|
||||
// used mid-bait-playback so pulling one hero out of town doesn't disturb
|
||||
// the rest of the board, which must keep showing this batch's other
|
||||
// not-yet-walked heroes exactly where they are.
|
||||
renderTownOnly() {
|
||||
this.townLayer.removeAll(true);
|
||||
this.renderTown();
|
||||
}
|
||||
|
||||
// Ends a bait-walk overlay session: kills + drops every flight token
|
||||
// created this batch, whether it already landed or is still mid-tween
|
||||
// (its own queue-step delay and its tween duration are both 1500ms by
|
||||
// design, so completion order isn't guaranteed) — the authoritative
|
||||
// renderAll() about to run replaces it with the real entrance mini card
|
||||
// either way — then clears the hide/keep-in-town sets.
|
||||
finishBaitOverlay() {
|
||||
this._baitFlightTokens.forEach((t) => { this.tweens.killTweensOf(t); t.destroy(); });
|
||||
this._baitFlightTokens = [];
|
||||
this._pendingBaitUids.clear();
|
||||
this._baitStillInTownUids.clear();
|
||||
this._baitHeroData.clear();
|
||||
}
|
||||
|
||||
renderHumanBoard() {
|
||||
const gs = this.gs;
|
||||
const p = gs.players[this.humanSeat];
|
||||
|
|
@ -1389,9 +1435,12 @@ export default class DungeonBossGame extends Phaser.Scene {
|
|||
this.boardLayer.add(g);
|
||||
}
|
||||
}
|
||||
// queued heroes at entrance
|
||||
// queued heroes at entrance — any hero still mid-bait-walk is skipped
|
||||
// (see renderOpponents' matching comment); hi still counts every entry
|
||||
// so later heroes' slots don't shift.
|
||||
const entR = this.humanSlotRect(Math.max(p.dungeon.length, 1) - 0.35);
|
||||
p.entrance.forEach((hero, hi) => {
|
||||
if (this._pendingBaitUids.has(hero.uid)) return;
|
||||
const hx = entR.x - 130 - hi * HUMAN_ENTRANCE_PITCH;
|
||||
this.miniHeroCard(hx, 640, hero, HUMAN_ENTRANCE_W, {
|
||||
parent: this.boardLayer, hover: false,
|
||||
|
|
@ -1520,6 +1569,15 @@ export default class DungeonBossGame extends Phaser.Scene {
|
|||
let x = GAME_WIDTH / 2 - width / 2;
|
||||
const y = 985;
|
||||
for (const inst of rooms) {
|
||||
// A just-drawn card reserves its slot (so the rest of the hand doesn't
|
||||
// reflow again once revealed) but shows only a card back until its
|
||||
// fly-in animation lands — see eventFx's 'draw' case / renderHandOnly.
|
||||
if (this._pendingDrawUids.has(inst.uid)) {
|
||||
const sp = this.makeCardBack(x, y, roomW * 0.85, roomH * 0.9, this.handLayer).setAlpha(0.9);
|
||||
this._handSprites.set(inst.uid, sp);
|
||||
x += pitch;
|
||||
continue;
|
||||
}
|
||||
const selectable = this.isHandSelectable(inst);
|
||||
const draggable = (zoneActive || buildActive) && selectable;
|
||||
const sp = this.makeRoomCard(x, y, inst, roomW, roomH, {
|
||||
|
|
@ -1538,6 +1596,12 @@ export default class DungeonBossGame extends Phaser.Scene {
|
|||
x += pitch;
|
||||
}
|
||||
for (const inst of spells) {
|
||||
if (this._pendingDrawUids.has(inst.uid)) {
|
||||
const sp = this.makeCardBack(x, y - 16, spellW * 0.85, spellH * 0.9, this.handLayer).setAlpha(0.9);
|
||||
this._handSprites.set(inst.uid, sp);
|
||||
x += pitch;
|
||||
continue;
|
||||
}
|
||||
const castable = this.castableUids?.has(inst.uid);
|
||||
const selectable = this.isHandSelectable(inst);
|
||||
const draggable = zoneActive && selectable;
|
||||
|
|
@ -1554,6 +1618,14 @@ export default class DungeonBossGame extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
// Light-weight re-render of just the hand row (see renderHand's
|
||||
// _pendingDrawUids placeholder) — used when a drawn card's fly-in
|
||||
// animation lands, revealing its real face without touching anything else.
|
||||
renderHandOnly() {
|
||||
this.handLayer.removeAll(true);
|
||||
this.renderHand();
|
||||
}
|
||||
|
||||
// ── discard drop zone ────────────────────────────────────────────────────────
|
||||
// Draws a slot for each card still owed during a discard/setupDiscard
|
||||
// decision, centered on screen. Slots already claimed (mode.selected) show
|
||||
|
|
@ -1705,13 +1777,6 @@ export default class DungeonBossGame extends Phaser.Scene {
|
|||
this._advDungeonSnapshot = this.gs.players.map((p) => p.dungeon.map((s) => ({
|
||||
room: { id: s.room.id, uid: s.room.uid }, deactivated: s.deactivated, armed: s.armed,
|
||||
})));
|
||||
// Same problem, different spot: runBait() removes every baited hero from
|
||||
// gs.town before emitting heroWalks, all within this SAME mutator call —
|
||||
// so by the time playEvents()'s own renderAll() runs and heroWalks' fx
|
||||
// reads this._heroTokens for a "from" (town) position, that hero is
|
||||
// already gone from gs.town and renderTown() never gave it an entry.
|
||||
// Snapshot the current (pre-mutation, still-accurate) town positions now.
|
||||
this._preBaitHeroTokens = new Map(this._heroTokens);
|
||||
try {
|
||||
switch (d.kind) {
|
||||
case 'setupDiscard': actSetupDiscard(this.gs, d.seat, choice); break;
|
||||
|
|
@ -2675,21 +2740,18 @@ export default class DungeonBossGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
// Return-to-top / dim / advance, per the spec: a survived room hit sends
|
||||
// both cards back up then dims the room just passed; a trap kill sends
|
||||
// just the room back up as a destroyed placeholder (the hero already faded
|
||||
// away in playTrapKillStep); bossWound and pass need no further settling
|
||||
// (already fully resolved inline in their own play functions).
|
||||
// just the room back up and dims it — the hero stays put in the battle
|
||||
// zone for its whole run, only ever leaving once it's defeated (removeHeroCard,
|
||||
// already run inside playRoomHitStep) or wounds the boss (playBossWoundStep);
|
||||
// a trap kill sends just the room back up as a destroyed placeholder (the
|
||||
// hero already faded away in playTrapKillStep); bossWound and pass need no
|
||||
// further settling (already fully resolved inline in their own play functions).
|
||||
settleAdventureStepIntoTopRows(step, onDone) {
|
||||
if (!step) { onDone(); return; } // the opening Continue click — nothing has played yet to settle
|
||||
switch (step.kind) {
|
||||
case 'roomHit': {
|
||||
// The hero (if it died here) is already gone from the stage by now
|
||||
// (removeHeroCard ran inside playRoomHitStep) — only the room needs
|
||||
// to travel back, either way.
|
||||
const uid = step.heroDies ? null : step.uid;
|
||||
this.returnHome(uid, step.displayKey, () => { this.dimRoomCard(step.displayKey); onDone(); });
|
||||
case 'roomHit':
|
||||
this.returnHome(null, step.displayKey, () => { this.dimRoomCard(step.displayKey); onDone(); });
|
||||
break;
|
||||
}
|
||||
case 'trapKill':
|
||||
this.returnHome(null, step.displayKey, () => { this.markRoomDestroyed(step.displayKey); onDone(); });
|
||||
break;
|
||||
|
|
@ -2943,7 +3005,9 @@ export default class DungeonBossGame extends Phaser.Scene {
|
|||
playPassStep(step, onDone) {
|
||||
this.moveToStage(step.uid, step.displayKey, { duration: 200 }, () => {
|
||||
this.time.delayedCall(140, () => {
|
||||
this.returnHome(step.uid, step.displayKey, () => { this.dimRoomCard(step.displayKey); onDone(); });
|
||||
// Only the room travels back — the hero stays in the battle zone
|
||||
// until it's defeated or wounds the boss (see settleAdventureStepIntoTopRows).
|
||||
this.returnHome(null, step.displayKey, () => { this.dimRoomCard(step.displayKey); onDone(); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -3074,6 +3138,29 @@ export default class DungeonBossGame extends Phaser.Scene {
|
|||
this._humanDrawBatch = { room: 0, spell: 0 };
|
||||
this._humanDrawSeen = { room: 0, spell: 0 };
|
||||
for (const e of events) if (e.type === 'draw' && e.seat === this.humanSeat) this._humanDrawBatch[e.card]++;
|
||||
// These freshly-drawn cards (already appended to the tail of the hand
|
||||
// arrays by the mutator) reserve their hand slot right away but stay a
|
||||
// card back until their own fly-in-from-deck animation lands — see
|
||||
// renderHand's placeholder and eventFx's 'draw' case / renderHandOnly.
|
||||
this._pendingDrawUids = new Set();
|
||||
const humanHand = this.gs.players[this.humanSeat];
|
||||
if (this._humanDrawBatch.room) {
|
||||
const list = humanHand.hand.rooms;
|
||||
for (let i = list.length - this._humanDrawBatch.room; i < list.length; i++) this._pendingDrawUids.add(list[i].uid);
|
||||
}
|
||||
if (this._humanDrawBatch.spell) {
|
||||
const list = humanHand.hand.spells;
|
||||
for (let i = list.length - this._humanDrawBatch.spell; i < list.length; i++) this._pendingDrawUids.add(list[i].uid);
|
||||
}
|
||||
// Heroes baited to walk to an entrance this batch: keep each one's town
|
||||
// card in place and hide it from the (already-mutated) real entrance
|
||||
// render until its own walk step actually fires — see renderTown's
|
||||
// overlay and eventFx's 'heroWalks' case, which peels one uid at a time
|
||||
// off _baitStillInTownUids as each hero's own flight begins.
|
||||
const walkers = events.filter((e) => e.type === 'heroWalks');
|
||||
this._pendingBaitUids = new Set(walkers.map((e) => e.uid));
|
||||
this._baitStillInTownUids = new Set(this._pendingBaitUids);
|
||||
this._baitHeroData = new Map(walkers.map((e) => [e.uid, { id: e.id, hp: heroDef({ id: e.id }).hp }]));
|
||||
// `before` (e.g. the windowPass event(s) that close the pre-adventure
|
||||
// advStart window — confirmed via engine trace to share the SAME batch
|
||||
// as the heroEnters it unblocks) plays through the normal fixed-delay
|
||||
|
|
@ -3084,6 +3171,7 @@ export default class DungeonBossGame extends Phaser.Scene {
|
|||
const step = () => {
|
||||
const e = queue.shift();
|
||||
if (!e) {
|
||||
this.finishBaitOverlay();
|
||||
if (walk.length) { this.renderAll(); this.runAdventureWalk(walk, rest); return; }
|
||||
this.busy = false;
|
||||
this.renderAll();
|
||||
|
|
@ -3152,16 +3240,26 @@ export default class DungeonBossGame extends Phaser.Scene {
|
|||
const inst = list[list.length - total + seen];
|
||||
const sprite = inst && this._handSprites.get(inst.uid);
|
||||
if (inst && sprite) {
|
||||
this.flyCardFromDeck(e.card, inst, sprite.x, sprite.y, e.card === 'room' ? 125 : 104, (cont) => cont.destroy());
|
||||
this.flyCardFromDeck(e.card, inst, sprite.x, sprite.y, e.card === 'room' ? 125 : 104, (cont) => {
|
||||
// Reveal the real card in the hand (see renderHand's
|
||||
// _pendingDrawUids placeholder) in the same tick as destroying
|
||||
// the flying stand-in, so there's no flicker.
|
||||
this._pendingDrawUids.delete(inst.uid);
|
||||
this.renderHandOnly();
|
||||
cont.destroy();
|
||||
});
|
||||
} else this.sfx(SFX.CARD_DEAL);
|
||||
break;
|
||||
}
|
||||
case 'heroWalks': {
|
||||
// Use the pre-mutation snapshot (see applyDecision) — by now
|
||||
// renderAll() has already redrawn the town without this hero (bait
|
||||
// already moved it into an entrance), so the live _heroTokens has no
|
||||
// entry for it any more.
|
||||
const from = this._preBaitHeroTokens?.get(e.uid) || this._heroTokens.get(e.uid);
|
||||
// This hero's town card has sat in place (see renderTown's
|
||||
// _baitStillInTownUids overlay, set up in playEvents) until right
|
||||
// now — pull it out of town as its own walk begins, so the batch's
|
||||
// heroes leave town one at a time instead of all vanishing together
|
||||
// the instant the batch starts.
|
||||
const from = this._heroTokens.get(e.uid);
|
||||
this._baitStillInTownUids.delete(e.uid);
|
||||
this.renderTownOnly();
|
||||
const hero = this.findHero(e.uid) || { uid: e.uid, id: e.id, hp: 0 };
|
||||
if (from) {
|
||||
// Built at full town-card width, then tweened down (via scale) to
|
||||
|
|
@ -3171,11 +3269,16 @@ export default class DungeonBossGame extends Phaser.Scene {
|
|||
const tok = this.miniHeroCard(from.x, from.y, hero, TOWN_HERO_W, {
|
||||
parent: this.fxLayer, noHoverPreview: true,
|
||||
});
|
||||
const to = this.entranceApprox(e.seat);
|
||||
// Tracked from creation (not on tween completion — see
|
||||
// finishBaitOverlay) so it's left resting at the entrance once
|
||||
// landed (the real entrance render stays suppressed for this uid
|
||||
// via _pendingBaitUids) until the whole bait batch finishes.
|
||||
this._baitFlightTokens.push(tok);
|
||||
const to = this.entranceSlotFor(e.seat, e.uid);
|
||||
const scale = entW / TOWN_HERO_W;
|
||||
this.tweens.add({
|
||||
targets: tok, x: to.x, y: to.y, scaleX: scale, scaleY: scale,
|
||||
duration: 1500, ease: 'Cubic.easeInOut', onComplete: () => tok.destroy(),
|
||||
duration: 1500, ease: 'Cubic.easeInOut',
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
|
@ -3279,6 +3382,25 @@ export default class DungeonBossGame extends Phaser.Scene {
|
|||
const c = centers[seat - 1];
|
||||
return { x: c.x - 250, y: c.y + 62 };
|
||||
}
|
||||
// A baited hero's EXACT resting slot — matching renderHumanBoard/
|
||||
// renderOpponents' own fan-out formulas exactly — so its heroWalks flying
|
||||
// token (see eventFx) lands precisely where the real entrance mini card
|
||||
// will eventually render, with zero visual jump once the bait batch's
|
||||
// closing renderAll() swaps it in. Falls back to entranceApprox if the
|
||||
// engine hasn't actually placed this hero in the entrance yet (shouldn't
|
||||
// happen — heroWalks fires after runBait's own state mutation).
|
||||
entranceSlotFor(seat, uid) {
|
||||
const p = this.gs.players[seat];
|
||||
const hi = p.entrance.findIndex((h) => h.uid === uid);
|
||||
if (hi < 0) return this.entranceApprox(seat);
|
||||
if (seat === this.humanSeat) {
|
||||
const entR = this.humanSlotRect(Math.max(p.dungeon.length, 1) - 0.35);
|
||||
return { x: entR.x - 130 - hi * HUMAN_ENTRANCE_PITCH, y: 640 };
|
||||
}
|
||||
const centers = this.oppPanelCenters();
|
||||
const c = centers[seat - 1];
|
||||
return { x: c.x - 560 / 2 + 30 + hi * OPP_ENTRANCE_PITCH, y: c.y + 62 };
|
||||
}
|
||||
oppBossPos(seat) {
|
||||
const c = this.oppPanelCenters()[seat - 1];
|
||||
return { x: c.x + 228, y: c.y - 20 };
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@
|
|||
// state.events; the scene/verify drain them with takeEvents().
|
||||
//
|
||||
// Deliberate simplifications from the tabletop game (noted where they apply):
|
||||
// - Spells resolve at three cast windows (build start, post flip, pre-walk)
|
||||
// instead of true any-time interrupts; Pit/Ramp/Cave-In "arm" a room and
|
||||
// resolve when a hero enters it, which is where they'd be used anyway.
|
||||
// - Spells resolve at discrete cast windows (build start, post flip, pre-walk,
|
||||
// and — matching the tabletop's true any-time-during-Adventure-Phase
|
||||
// casting more closely — a fresh window after every room a hero resolves
|
||||
// during the walk itself) rather than true continuous interrupts; Pit/Ramp/
|
||||
// Cave-In "arm" a room and resolve when a hero enters it, which is where
|
||||
// they'd be used anyway.
|
||||
// - Counterspell chains don't recurse (a reaction can't itself be countered).
|
||||
// - Vampire Bordello heals a wound without flipping it to a soul.
|
||||
// - Trepidation keeps blocked heroes in town rather than parked at an entrance.
|
||||
|
|
@ -224,7 +227,7 @@ function windowKindOk(effWindow, windowId) {
|
|||
function spellPhaseOk(def, windowId) {
|
||||
if (def.phase === 'both') return true;
|
||||
if (def.phase === 'build') return windowId === 'buildStart' || windowId === 'postFlip';
|
||||
return windowId === 'advStart';
|
||||
return windowId === 'advStart' || windowId === 'advRoom';
|
||||
}
|
||||
|
||||
// All actions a seat could take in the current window.
|
||||
|
|
@ -269,6 +272,18 @@ function costPayable(state, seat, cost, slotIdx) {
|
|||
return true;
|
||||
}
|
||||
|
||||
// A seat's entrance queue, PLUS its currently-walking hero (if any) — the
|
||||
// walking hero lives only in state.adv.walking (shifted out of p.entrance by
|
||||
// stepAdventure the moment it starts resolving), so hero-targeting ops during
|
||||
// a mid-walk advRoom window would otherwise be unable to target the very
|
||||
// hero the window paused for. See also findEntranceHero.
|
||||
function entranceHeroesOf(state, seat) {
|
||||
const list = P(state, seat).entrance.slice();
|
||||
const a = state.adv;
|
||||
if (a && a.walking && state.turnOrder[a.orderIdx] === seat) list.push(a.walking.hero);
|
||||
return list;
|
||||
}
|
||||
|
||||
// ── Op target candidates ────────────────────────────────────────────────────
|
||||
// Returns null when the op needs no target, else an array of targetRefs
|
||||
// (possibly empty = not castable). targetRef kinds:
|
||||
|
|
@ -349,15 +364,15 @@ export function opCandidates(state, seat, eff, ctx = {}) {
|
|||
return out;
|
||||
}
|
||||
case 'damageHero': case 'teleportHero':
|
||||
return p.entrance.map((h) => ({ kind: 'hero', uid: h.uid }));
|
||||
return entranceHeroesOf(state, seat).map((h) => ({ kind: 'hero', uid: h.uid }));
|
||||
case 'healHero': {
|
||||
const out = [];
|
||||
for (const s of opps) for (const h of P(state, s).entrance) out.push({ kind: 'hero', uid: h.uid });
|
||||
for (const s of opps) for (const h of entranceHeroesOf(state, s)) out.push({ kind: 'hero', uid: h.uid });
|
||||
return out;
|
||||
}
|
||||
case 'fearHero': {
|
||||
const out = [];
|
||||
for (const s of aliveSeats(state)) for (const h of P(state, s).entrance) out.push({ kind: 'hero', uid: h.uid });
|
||||
for (const s of aliveSeats(state)) for (const h of entranceHeroesOf(state, s)) out.push({ kind: 'hero', uid: h.uid });
|
||||
return out;
|
||||
}
|
||||
case 'killHeroTown':
|
||||
|
|
@ -818,7 +833,13 @@ function applyOp(state, seat, eff, targetRef, ctx = {}) {
|
|||
const amount = eff.amount === 'dungeonSize' ? p.dungeon.length : eff.amount;
|
||||
found.hero.hp -= amount;
|
||||
emit(state, { type: 'heroHurt', uid: found.hero.uid, amount, seat: found.seat });
|
||||
if (found.hero.hp <= 0) scoreHeroKill(state, seat, found.hero, { removeFromSeat: found.seat, noRoom: true });
|
||||
if (found.hero.hp <= 0) {
|
||||
// A kill on the currently-walking hero can't be spliced out of
|
||||
// p.entrance (it isn't there — see findEntranceHero) and stepAdventure
|
||||
// must stop treating it as still walking.
|
||||
if (found.walking) state.adv.walking = null;
|
||||
scoreHeroKill(state, seat, found.hero, { removeFromSeat: found.walking ? null : found.seat, noRoom: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'healHero': {
|
||||
|
|
@ -829,8 +850,14 @@ function applyOp(state, seat, eff, targetRef, ctx = {}) {
|
|||
case 'fearHero': {
|
||||
const found = findEntranceHero(state, targetRef.uid);
|
||||
if (found) {
|
||||
const q = P(state, found.seat).entrance;
|
||||
q.splice(q.findIndex((h) => h.uid === found.hero.uid), 1);
|
||||
if (found.walking) {
|
||||
// Splicing p.entrance here would remove the WRONG hero (the
|
||||
// walking hero isn't in that array) — just end its walk instead.
|
||||
state.adv.walking = null;
|
||||
} else {
|
||||
const q = P(state, found.seat).entrance;
|
||||
q.splice(q.findIndex((h) => h.uid === found.hero.uid), 1);
|
||||
}
|
||||
state.town.push(found.hero);
|
||||
emit(state, { type: 'heroFeared', uid: found.hero.uid, from: found.seat });
|
||||
}
|
||||
|
|
@ -920,7 +947,12 @@ function applyOp(state, seat, eff, targetRef, ctx = {}) {
|
|||
function findEntranceHero(state, uid) {
|
||||
for (const seat of aliveSeats(state)) {
|
||||
const hero = P(state, seat).entrance.find((h) => h.uid === uid);
|
||||
if (hero) return { seat, hero };
|
||||
if (hero) return { seat, hero, walking: false };
|
||||
}
|
||||
const a = state.adv;
|
||||
if (a && a.walking && a.walking.hero.uid === uid) {
|
||||
const seat = state.turnOrder[a.orderIdx];
|
||||
if (P(state, seat).alive) return { seat, hero: a.walking.hero, walking: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1073,7 +1105,26 @@ function nextAdventureSeat(state) {
|
|||
}
|
||||
}
|
||||
|
||||
// Walk heroes one room-step at a time; pauses whenever effectQueue gains a decision.
|
||||
// Opens a fresh casting window after a real per-room event during the walk
|
||||
// (any seat may act, exactly like advStart — see nextWindowSeat), letting
|
||||
// adventure-phase spells react to what just happened mid-crawl rather than
|
||||
// only before the walk started. If the event that triggered this ALSO queued
|
||||
// a real decision (e.g. an onHeroDieHere room effect), that decision takes
|
||||
// priority — advance()'s effectQueue check runs before window handling — so
|
||||
// this is a harmless no-op in that case; the resume-after-decision path in
|
||||
// advance() already re-enters stepAdventure() once the queue drains.
|
||||
function openAdvRoomWindow(state, seat) {
|
||||
if (state.gameOver || state.effectQueue.length) return;
|
||||
state.window = { id: 'advRoom', advSeat: seat, passes: [], casts: 0 };
|
||||
state.adv.windowOpened = true;
|
||||
emit(state, { type: 'adventurePause', seat }); // observability only — UI must not gate control flow on this
|
||||
}
|
||||
|
||||
// Walk heroes one room-step at a time; pauses whenever effectQueue gains a
|
||||
// decision, OR after every real per-room event (a kill, or any survived room
|
||||
// with actual damage/effects) to open a fresh advRoom casting window — matching
|
||||
// the granularity the battle modal already shows via its Continue button
|
||||
// (roomHit/trapKill/bossWound steps), not every silent pass-through.
|
||||
function stepAdventure(state) {
|
||||
const a = state.adv;
|
||||
const seat = state.turnOrder[a.orderIdx];
|
||||
|
|
@ -1103,12 +1154,14 @@ function stepAdventure(state) {
|
|||
emit(state, { type: 'bossWounded', seat, uid: hero.uid, id: hero.id, wounds, total: p.wounds });
|
||||
a.walking = null;
|
||||
if (p.wounds >= WOUNDS_TO_DIE) { eliminate(state, seat); nextAdventureSeat(state); return; }
|
||||
continue;
|
||||
openAdvRoomWindow(state, seat);
|
||||
return;
|
||||
}
|
||||
const slot = p.dungeon[w.roomIdx];
|
||||
if (!slot) { w.roomIdx = Math.min(w.roomIdx - 1, p.dungeon.length - 1); continue; }
|
||||
if (slot.deactivated) { w.roomIdx--; continue; }
|
||||
const def = roomDef(slot.room);
|
||||
let hadEvent = false;
|
||||
// Armed traps resolve on entry.
|
||||
if (slot.armed) {
|
||||
const armed = slot.armed;
|
||||
|
|
@ -1118,12 +1171,19 @@ function stepAdventure(state) {
|
|||
destroySlot(state, seat, idx);
|
||||
scoreHeroKill(state, seat, hero, { slot: null });
|
||||
a.walking = null;
|
||||
continue;
|
||||
openAdvRoomWindow(state, seat);
|
||||
return;
|
||||
}
|
||||
if (armed.type === 'ramp') {
|
||||
hero.hp -= armed.bonus;
|
||||
hadEvent = true;
|
||||
emit(state, { type: 'heroHurt', uid: hero.uid, amount: armed.bonus, seat });
|
||||
if (hero.hp <= 0) { scoreHeroKill(state, seat, hero, { slot }); a.walking = null; continue; }
|
||||
if (hero.hp <= 0) {
|
||||
scoreHeroKill(state, seat, hero, { slot });
|
||||
a.walking = null;
|
||||
openAdvRoomWindow(state, seat);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Minotaur's Maze: the first hero through each round re-enters the room it
|
||||
|
|
@ -1135,17 +1195,30 @@ function stepAdventure(state) {
|
|||
const dmg = roomDamage(state, seat, backIdx);
|
||||
if (dmg > 0) {
|
||||
hero.hp -= dmg;
|
||||
hadEvent = true;
|
||||
emit(state, { type: 'roomHits', seat, slotIdx: backIdx, uid: hero.uid, amount: dmg, bounce: true });
|
||||
if (hero.hp <= 0) { scoreHeroKill(state, seat, hero, { slot: p.dungeon[backIdx] }); a.walking = null; continue; }
|
||||
if (hero.hp <= 0) {
|
||||
scoreHeroKill(state, seat, hero, { slot: p.dungeon[backIdx] });
|
||||
a.walking = null;
|
||||
openAdvRoomWindow(state, seat);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const dmg = roomDamage(state, seat, w.roomIdx);
|
||||
if (dmg > 0) {
|
||||
hero.hp -= dmg;
|
||||
hadEvent = true;
|
||||
emit(state, { type: 'roomHits', seat, slotIdx: w.roomIdx, uid: hero.uid, amount: dmg });
|
||||
if (hero.hp <= 0) { scoreHeroKill(state, seat, hero, { slot }); a.walking = null; continue; }
|
||||
if (hero.hp <= 0) {
|
||||
scoreHeroKill(state, seat, hero, { slot });
|
||||
a.walking = null;
|
||||
openAdvRoomWindow(state, seat);
|
||||
return;
|
||||
}
|
||||
}
|
||||
w.roomIdx--;
|
||||
if (hadEvent) { openAdvRoomWindow(state, seat); return; }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1289,7 +1362,7 @@ function advance(state) {
|
|||
state.window = null;
|
||||
if (closed.id === 'buildStart') { state.phase = 'build'; continue; }
|
||||
if (closed.id === 'postFlip') { runBait(state); startAdventure(state); continue; }
|
||||
if (closed.id === 'advStart') { stepAdventure(state); continue; }
|
||||
if (closed.id === 'advStart' || closed.id === 'advRoom') { stepAdventure(state); continue; }
|
||||
}
|
||||
if (state.adv && state.adv.windowOpened && !state.window) {
|
||||
// resumed mid-walk after an effect decision
|
||||
|
|
@ -1350,6 +1423,10 @@ export function publicView(state, seat) {
|
|||
return {
|
||||
seed: state.seed, nPlayers: state.nPlayers, round: state.round,
|
||||
phase: state.phase, window: state.window, turnOrder: state.turnOrder,
|
||||
// The currently-walking hero (mid-crawl, not yet back in an entrance
|
||||
// array — see stepAdventure/entranceHeroesOf) so AI heuristics can
|
||||
// reason about it during a mid-walk advRoom casting window.
|
||||
adv: state.adv && { orderIdx: state.adv.orderIdx, walking: state.adv.walking && { hero: state.adv.walking.hero, roomIdx: state.adv.walking.roomIdx } },
|
||||
epicsActive: state.epicsActive,
|
||||
// Deck contents are hidden (stubs keep lengths honest); discards are public.
|
||||
decks: {
|
||||
|
|
|
|||
|
|
@ -136,6 +136,14 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
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._pendingDiscardUids = new Set(); // uids counted in a discard pile but not yet visually landed there
|
||||
this._pendingDeckUids = new Set(); // same, for the rare "buy, topdeck it" ability
|
||||
this._pendingAttackFrom = []; // {x,y}[] snapshot of the attacking squad's board positions, captured just before dispatch
|
||||
this._rowSlots = new Map(); // uid → stable screen-slot index — a card keeps its slot for as long as it's in the row, so buying/losing one never shifts its neighbors
|
||||
this._rowGhosts = new Map(); // uid → id — a row card (bought or bountied) still shown in its vacated slot until its own animation actually reaches/removes it
|
||||
this._endTurnFromPos = new Map(); // uid → {x,y}, snapshot of an end-of-turn discard's in-play position, taken before it's cleared
|
||||
this._endTurnHandLayout = new Map(); // uid → {idx,total}, virtual old-hand-slot layout for a human's end-of-turn hand discard
|
||||
this._endTurnGhosts = new Map(); // uid → {seat,id,zone} — an end-of-turn discard stays visible in its old spot until its own fly-away animation begins
|
||||
this._dealingInitial = false; // true only during the game-start deal sequence
|
||||
this._actionButtons = [];
|
||||
this.hoverTimer = null;
|
||||
|
|
@ -456,6 +464,12 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
cont.add(hl);
|
||||
this.tweens.add({ targets: hl, alpha: 0.35, duration: 480, yoyo: true, repeat: -1 });
|
||||
}
|
||||
if (opts.highlightDiscard) {
|
||||
const ov = this.add.graphics();
|
||||
ov.fillStyle(C.bad, 0.4); ov.fillRoundedRect(-w / 2, -h / 2, w, h, 7);
|
||||
cont.add(ov);
|
||||
this.tweens.add({ targets: ov, alpha: 0.12, duration: 460, yoyo: true, repeat: -1 });
|
||||
}
|
||||
const wantHoverPreview = opts._hoverBuild && !opts.noHoverPreview;
|
||||
if (opts.onClick || wantHoverPreview) {
|
||||
cont.setSize(w, h);
|
||||
|
|
@ -552,10 +566,28 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
const zone = this.add.zone(0, 0, w, h).setInteractive();
|
||||
cardHolder.add(zone);
|
||||
|
||||
const closeBtn = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + h / 2 + 70, 'Close',
|
||||
// offer Buy/Attack right alongside Close when this card is legally
|
||||
// actionable right now — mutually exclusive (buy requires no committed
|
||||
// squad, attack requires one), so at most one ever joins Close
|
||||
const buyInfo = deepDiveInfo.kind === 'card' ? this.deepDiveBuyInfo(deepDiveInfo.inst) : null;
|
||||
const canAttack = !buyInfo && deepDiveInfo.kind === 'card' && this.deepDiveAttackable(deepDiveInfo.inst);
|
||||
const hasExtra = !!buyInfo || canAttack;
|
||||
const btnY = GAME_HEIGHT / 2 + h / 2 + 70;
|
||||
const closeBtn = new Button(this, hasExtra ? GAME_WIDTH / 2 + 90 : GAME_WIDTH / 2, btnY, 'Close',
|
||||
() => this.closeDeepDive(), { width: 160, fontSize: 20 });
|
||||
closeBtn.setDepth(DEPTH.overlay + 1);
|
||||
root.add(closeBtn);
|
||||
if (buyInfo) {
|
||||
const buyBtn = new Button(this, GAME_WIDTH / 2 - 90, btnY, 'Buy',
|
||||
() => this.deepDiveBuy(buyInfo), { width: 160, fontSize: 20 });
|
||||
buyBtn.setDepth(DEPTH.overlay + 1);
|
||||
root.add(buyBtn);
|
||||
} else if (canAttack) {
|
||||
const atkBtn = new Button(this, GAME_WIDTH / 2 - 90, btnY, 'Attack',
|
||||
() => this.deepDiveAttack(deepDiveInfo.inst), { width: 160, fontSize: 20 });
|
||||
atkBtn.setDepth(DEPTH.overlay + 1);
|
||||
root.add(atkBtn);
|
||||
}
|
||||
|
||||
this._deepDive = {
|
||||
modal: root, cardHolder, zoneTimers: [], zoneNodes: [],
|
||||
|
|
@ -645,7 +677,10 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
this._deepDive.zoneNodes.push(box, line, pill);
|
||||
}
|
||||
|
||||
closeDeepDive() {
|
||||
// afterClose (optional) fires once the card has fully flown back to its
|
||||
// original spot and the modal is gone — used by Buy/Attack so the actual
|
||||
// action + its animations only start once the card is visually "returned"
|
||||
closeDeepDive(afterClose) {
|
||||
if (!this._deepDive) return;
|
||||
const { modal, cardHolder, zoneTimers, zoneNodes, returnX, returnY } = this._deepDive;
|
||||
zoneTimers.forEach((t) => t.remove());
|
||||
|
|
@ -654,7 +689,47 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
this.tweens.add({
|
||||
targets: cardHolder, x: returnX, y: returnY,
|
||||
duration: 280, ease: 'Cubic.easeIn',
|
||||
onComplete: () => { modal.destroy(); this._deepDive = null; },
|
||||
onComplete: () => { modal.destroy(); this._deepDive = null; afterClose?.(); },
|
||||
});
|
||||
}
|
||||
|
||||
// is this deep-dived card buyable by the human right now? Handles the
|
||||
// Outer Rim Pilot specially — legalActions() keys its buy entry on the
|
||||
// literal string 'outerrim', not the actual pilot card's own uid.
|
||||
deepDiveBuyInfo(inst) {
|
||||
if (this.squad.size) return null;
|
||||
const legal = this.humanTurnLegal();
|
||||
if (!legal) return null;
|
||||
if (legal.buys.some((b) => b.uid === inst.uid)) return { uid: inst.uid };
|
||||
const orp = this.gs.outerRim[this.gs.outerRim.length - 1];
|
||||
if (orp && orp.uid === inst.uid && legal.buys.some((b) => b.uid === 'outerrim')) return { uid: 'outerrim' };
|
||||
return null;
|
||||
}
|
||||
|
||||
// is this deep-dived card a legal bounty target for the currently
|
||||
// committed squad? rowTargetReachable() alone doesn't confirm the card is
|
||||
// actually IN the row (it would also pass for a same-faction unit shown
|
||||
// from elsewhere), so check row membership first.
|
||||
deepDiveAttackable(inst) {
|
||||
if (!this.squad.size || !this.gs.galaxy.row.some((c) => c.uid === inst.uid)) return false;
|
||||
return this.rowTargetReachable(inst);
|
||||
}
|
||||
|
||||
deepDiveBuy(buyInfo) {
|
||||
const d = this.mode.decision;
|
||||
this.closeDeepDive(() => {
|
||||
this.sfx(SFX.PURCHASE);
|
||||
this.applyDecision(d, { type: 'buy', uid: buyInfo.uid });
|
||||
});
|
||||
}
|
||||
|
||||
deepDiveAttack(inst) {
|
||||
const d = this.mode.decision;
|
||||
this.closeDeepDive(() => {
|
||||
const uids = [...this.squad];
|
||||
this.squad.clear();
|
||||
this.sfx(SFX.SCIFI_LAUNCH);
|
||||
this.applyDecision(d, { type: 'attackRow', targetUid: inst.uid, uids });
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -751,6 +826,13 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
onClick: () => this.showInspect(e.card),
|
||||
});
|
||||
});
|
||||
// end-of-turn discard ghosts: a discarded in-play card stays put right up
|
||||
// until its own fly-to-discard animation begins
|
||||
for (const [uid, ghost] of this._endTurnGhosts) {
|
||||
if (ghost.zone !== 'inPlay' || ghost.seat !== seat) continue;
|
||||
const pos = this._endTurnFromPos.get(uid);
|
||||
if (pos) this.makeCard(pos.x, pos.y, { uid, id: ghost.id }, 84, 118, { parent: this.boardLayer, hover: false });
|
||||
}
|
||||
// counters
|
||||
const cx = GAME_WIDTH - 220;
|
||||
const lines = [
|
||||
|
|
@ -774,8 +856,13 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
// portrait so pile sizes are visible at a glance without opening any panel
|
||||
renderOpponentPiles(x, p) {
|
||||
const pw = 30, ph = 42;
|
||||
// while the old hand is still discarding (each ghost clears the instant
|
||||
// its own fly-away animation begins), show that shrinking count instead
|
||||
// of the new hand's — which only starts counting up once every old ghost
|
||||
// is gone
|
||||
const oldHandGhosts = [...this._endTurnGhosts.values()].filter((g) => g.zone === 'hand' && g.seat === p.seat).length;
|
||||
const pendingInHand = p.hand.filter((c) => this._pendingCardUids.has(c.uid)).length;
|
||||
const visibleCount = p.hand.length - pendingInHand;
|
||||
const visibleCount = oldHandGhosts > 0 ? oldHandGhosts : p.hand.length - pendingInHand;
|
||||
const shown = Math.min(visibleCount, 4);
|
||||
const fanY = 72;
|
||||
for (let i = 0; i < shown; i++) {
|
||||
|
|
@ -798,8 +885,23 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
fontFamily: '"Julius Sans One"', fontSize: '11px', color: C.muted,
|
||||
}).setOrigin(0.5));
|
||||
};
|
||||
pile(132, p.deck.length, 'DRAW');
|
||||
pile(192, p.discard.length, 'DISCARD');
|
||||
pile(132, this.visibleDeckCount(p), 'DRAW');
|
||||
pile(192, this.visibleDiscardCount(p), 'DISCARD');
|
||||
}
|
||||
|
||||
// a bought card is already in p.discard state-wise the instant it's
|
||||
// purchased, but its reveal-then-stow animation hasn't landed yet — lag the
|
||||
// displayed discard count by however many of its cards are still pending
|
||||
visibleDiscardCount(p) {
|
||||
if (!this._pendingDiscardUids.size) return p.discard.length;
|
||||
return p.discard.length - p.discard.filter((c) => this._pendingDiscardUids.has(c.uid)).length;
|
||||
}
|
||||
|
||||
// same idea for the draw pile — only relevant for the rare "buy it and
|
||||
// topdeck it" ability, which sends a bought card to the deck instead
|
||||
visibleDeckCount(p) {
|
||||
if (!this._pendingDeckUids.size) return p.deck.length;
|
||||
return p.deck.length - p.deck.filter((c) => this._pendingDeckUids.has(c.uid)).length;
|
||||
}
|
||||
|
||||
renderBasePips(x, y, p, info) {
|
||||
|
|
@ -846,7 +948,29 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
const rowX0 = 510;
|
||||
const legal = this.humanTurnLegal();
|
||||
const humanFaction = gs.players[this.humanSeat].faction;
|
||||
gs.galaxy.row.forEach((card, i) => {
|
||||
// stable per-card slot assignment: a card keeps its screen slot for as
|
||||
// long as it's in the row; when it leaves, that slot frees up for
|
||||
// whatever card claims it next — so buying (or losing) a row card never
|
||||
// shifts its neighbors, and a refill naturally lands in the same slot
|
||||
// its predecessor vacated. A bought/bountied card's slot is held open
|
||||
// (via _rowGhosts) until its own animation actually reaches/removes it,
|
||||
// so nothing else can claim it while it's still visible.
|
||||
const liveUids = new Set(gs.galaxy.row.map((c) => c.uid));
|
||||
for (const uid of [...this._rowSlots.keys()]) {
|
||||
if (!liveUids.has(uid) && !this._rowGhosts.has(uid)) this._rowSlots.delete(uid);
|
||||
}
|
||||
const usedSlots = new Set(this._rowSlots.values());
|
||||
for (const card of gs.galaxy.row) {
|
||||
if (this._rowSlots.has(card.uid)) continue;
|
||||
let slot = 0;
|
||||
while (usedSlots.has(slot)) slot++;
|
||||
if (slot >= this.gs.meta.galaxyRowSize) continue; // no free slot yet
|
||||
this._rowSlots.set(card.uid, slot);
|
||||
usedSlots.add(slot);
|
||||
}
|
||||
gs.galaxy.row.forEach((card) => {
|
||||
const i = this._rowSlots.get(card.uid);
|
||||
if (i == null) return;
|
||||
const def = cardDef(card);
|
||||
const slot = this.galaxyRowSlot(i, def.faction, humanFaction);
|
||||
this._rowPos.set(card.uid, { x: slot.x, y: slot.y });
|
||||
|
|
@ -861,6 +985,16 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
onClick: () => this.onRowClicked(card),
|
||||
});
|
||||
});
|
||||
// ghosts: a just-bought or just-bountied card still occupies its vacated
|
||||
// slot until its own animation actually reaches/removes it
|
||||
for (const [uid, id] of this._rowGhosts) {
|
||||
const gi = this._rowSlots.get(uid);
|
||||
if (gi == null) continue;
|
||||
const def = cardDef(id);
|
||||
const slot = this.galaxyRowSlot(gi, def.faction, humanFaction);
|
||||
this._rowPos.set(uid, { x: slot.x, y: slot.y });
|
||||
this.makeCard(slot.x, slot.y, { uid, id }, slot.w, slot.h, { parent: this.rowLayer, hover: false, showText: true });
|
||||
}
|
||||
// Outer Rim Pilot stack
|
||||
if (gs.outerRim.length) {
|
||||
const orp = getData().json.outerRimPilot;
|
||||
|
|
@ -987,6 +1121,13 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
});
|
||||
if (canUse) this.addUsePill(slot.x, this.usePillY(slot.y, slot.h), () => this.beginAbility({ zone: 'play', uid: e.card.uid }));
|
||||
});
|
||||
// end-of-turn discard ghosts: a discarded in-play card stays put right up
|
||||
// until its own fly-to-discard animation begins
|
||||
for (const [uid, ghost] of this._endTurnGhosts) {
|
||||
if (ghost.zone !== 'inPlay' || ghost.seat !== seat) continue;
|
||||
const pos = this._endTurnFromPos.get(uid);
|
||||
if (pos) this.makeCard(pos.x, pos.y, { uid, id: ghost.id }, 122, 170, { parent: this.boardLayer, hover: false });
|
||||
}
|
||||
if (!units.length && !p.capitals.length) {
|
||||
this.boardLayer.add(this.add.text(1180, playRowY, 'cards you play land here', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#2a3c66',
|
||||
|
|
@ -1030,8 +1171,16 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
const p = gs.players[this.humanSeat];
|
||||
const y = 985;
|
||||
// draw pile sits just under the human's base card (base is 392,730 size 300x190)
|
||||
this.renderHandPile(392, 911, p.deck.length, 'DRAW');
|
||||
this.renderHandPile(GAME_WIDTH - 205, y, p.discard.length, 'DISCARD');
|
||||
this.renderHandPile(392, 911, this.visibleDeckCount(p), 'DRAW');
|
||||
this.renderHandPile(GAME_WIDTH - 205, y, this.visibleDiscardCount(p), 'DISCARD');
|
||||
// end-of-turn discard ghosts: a discarded hand card stays put in its
|
||||
// virtual old-hand slot right up until its own fly-to-discard animation
|
||||
// begins (the real hand has already been replaced by then)
|
||||
for (const [uid, ghost] of this._endTurnGhosts) {
|
||||
if (ghost.zone !== 'hand' || ghost.seat !== this.humanSeat) continue;
|
||||
const layout = this._endTurnHandLayout.get(uid);
|
||||
if (layout) this.makeCard(this.humanHandSlotX(layout.idx, layout.total), y, { uid, id: ghost.id }, 148, 206, { parent: this.handLayer, hover: false, showText: true });
|
||||
}
|
||||
if (!p.hand.length) return;
|
||||
const cw = 148, chh = 206;
|
||||
const pitch = Math.min(cw + 8, 1250 / Math.max(1, p.hand.length));
|
||||
|
|
@ -1041,11 +1190,17 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
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));
|
||||
// forced discard (an opponent's effect) reads as something being done
|
||||
// TO the player, so it gets its own pulsing red overlay rather than the
|
||||
// gold "you get to pick" cue used for a voluntary exile-as-cost choice
|
||||
const forcedDiscard = this.mode.type === 'oppDiscard';
|
||||
const exileCost = this.mode.type === 'target' && this.mode.decision?.op === 'exileCards'
|
||||
&& (this.mode.candidates || []).some((c) => c.zone === 'hand' && c.uid === inst.uid);
|
||||
const discardable = forcedDiscard || exileCost;
|
||||
this.makeCard(cardX, y, inst, cw, chh, {
|
||||
parent: this.handLayer, showText: true,
|
||||
highlightGold: discardable,
|
||||
highlightGold: exileCost,
|
||||
highlightDiscard: forcedDiscard,
|
||||
onClick: () => this.onHandClicked(inst),
|
||||
});
|
||||
if (!playable && !discardable) this.handLayer.list[this.handLayer.list.length - 1].setAlpha(0.9);
|
||||
|
|
@ -1220,6 +1375,15 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
applyDecision(d, choice) {
|
||||
// snapshot the attacking squad's board positions here (not just in the
|
||||
// human click handlers) since AI-driven attackRow/attackBase choices are
|
||||
// built directly in SWDBGAI.js and dispatched straight into this method,
|
||||
// bypassing onRowClicked()/launchBaseAttack() entirely — _playPos is still
|
||||
// valid here, as nothing re-renders between a decision being made and
|
||||
// applyDecision running
|
||||
if (choice?.type === 'attackRow' || choice?.type === 'attackBase') {
|
||||
this._pendingAttackFrom = (choice.uids || []).map((uid) => this._playPos.get(uid)).filter(Boolean);
|
||||
}
|
||||
try {
|
||||
switch (d.kind) {
|
||||
case 'turn': actTurn(this.gs, d.seat, choice); break;
|
||||
|
|
@ -1546,8 +1710,46 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
playEvents(events, opts = {}) {
|
||||
this.busy = true;
|
||||
this.setPrompt('');
|
||||
// a purchase or a bounty kill always emits its rowRefill just before its
|
||||
// own buy/bounty event (SWDBGLogic's removeRowCard refills before
|
||||
// buyCard/attackRow emit) — swap them so the bought/defeated card's full
|
||||
// animation plays out (arc + reveal/explosion) before the replacement
|
||||
// card animates into the vacated row slot
|
||||
events = events.slice();
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
if ((events[i].type === 'buy' || events[i].type === 'bounty') && events[i - 1].type === 'rowRefill') {
|
||||
[events[i - 1], events[i]] = [events[i], events[i - 1]];
|
||||
}
|
||||
}
|
||||
// an end-of-turn discard batch has no stored position for the old hand's
|
||||
// cards (by the time these events are read, endTurn() has already drawn
|
||||
// the NEW hand) — reconstruct the human's virtual old-hand layout purely
|
||||
// from the emitted events' own order (one 'endTurnDiscard' zone:'hand'
|
||||
// event per old card, in original hand order)
|
||||
const handDiscardsBySeat = new Map();
|
||||
for (const e of events) {
|
||||
if (e.type === 'endTurnDiscard' && e.zone === 'hand') {
|
||||
if (!handDiscardsBySeat.has(e.seat)) handDiscardsBySeat.set(e.seat, []);
|
||||
handDiscardsBySeat.get(e.seat).push(e.uid);
|
||||
}
|
||||
}
|
||||
for (const uids of handDiscardsBySeat.values()) {
|
||||
uids.forEach((uid, idx) => this._endTurnHandLayout.set(uid, { idx, total: uids.length }));
|
||||
}
|
||||
for (const e of events) {
|
||||
if ((e.type === 'draw' || e.type === 'rowRefill' || e.type === 'play') && e.uid != null) this._pendingCardUids.add(e.uid);
|
||||
if (e.type === 'buy' && e.uid != null) {
|
||||
if (this._rowPos.has(e.uid)) this._rowGhosts.set(e.uid, e.id);
|
||||
if (e.topdeck) this._pendingDeckUids.add(e.uid); else this._pendingDiscardUids.add(e.uid);
|
||||
}
|
||||
if (e.type === 'bounty' && e.uid != null && this._rowPos.has(e.uid)) {
|
||||
this._rowGhosts.set(e.uid, e.id);
|
||||
}
|
||||
if (e.type === 'endTurnDiscard' && e.uid != null) {
|
||||
this._pendingDiscardUids.add(e.uid);
|
||||
this._endTurnGhosts.set(e.uid, { seat: e.seat, id: e.id, zone: e.zone });
|
||||
if (e.zone === 'inPlay' && this._playPos.has(e.uid)) this._endTurnFromPos.set(e.uid, this._playPos.get(e.uid));
|
||||
}
|
||||
}
|
||||
const queue = events.filter((e) => this.eventDelay(e) > 0);
|
||||
const step = () => {
|
||||
|
|
@ -1571,9 +1773,9 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
switch (e.type) {
|
||||
case 'turnStart': return 460;
|
||||
case 'play': return aiEvent ? 300 : 220;
|
||||
case 'buy': return 420;
|
||||
case 'bounty': return 640;
|
||||
case 'attackBase': return 380;
|
||||
case 'buy': return 2300; // 500 funding arc + 420 reveal + 1000 hold + 380 flip-to-discard
|
||||
case 'bounty': return 1140; // 500 attack arc + 640 existing popText/banner hold
|
||||
case 'attackBase': return 600; // 500 attack arc + 100 buffer before capitalDamage/baseDamage resolve
|
||||
case 'capitalDamage': return 300;
|
||||
case 'capitalDestroyed': return 520;
|
||||
case 'baseDamage': return 460;
|
||||
|
|
@ -1590,23 +1792,29 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
case 'galaxyReshuffle': return 300;
|
||||
case 'draw': return this._dealingInitial ? 300 : 260;
|
||||
case 'rowRefill': return this._dealingInitial ? 320 : 280;
|
||||
case 'endTurnDiscard': return 200;
|
||||
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.
|
||||
// pile (draw pile / galaxy deck / center-screen reveal) to its final slot.
|
||||
// startAs picks the initial visual ('back' or 'face'); flipTo, if set,
|
||||
// swaps it mid-flight to 'face' or 'back' — covering reveals (back->face)
|
||||
// and cards going into a hidden pile (face->back). 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)
|
||||
startAs = 'back', // 'back' | 'face' — visual at the start of the flight
|
||||
flipTo = null, // 'face' | 'back' | null — what it flips into mid-flight, if anything
|
||||
id = null, // card def id, needed whenever a face is shown (start and/or flip target)
|
||||
duration = 280,
|
||||
keepAlive = false, // if true, don't destroy the token on arrival — caller owns its lifecycle via onLand(token)
|
||||
onLand,
|
||||
} = opts;
|
||||
const outer = this.add.container(fromX, fromY);
|
||||
|
|
@ -1614,25 +1822,24 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
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 })
|
||||
const buildSide = (side) => side === 'face'
|
||||
? this.makeCard(0, 0, { uid: -1, id }, toW, toH, { parent: flipHost, hover: false, showText: true, noHoverPreview: true })
|
||||
: this.makeCardBack(0, 0, toW, toH, flipHost);
|
||||
let side = buildSide(startAs);
|
||||
|
||||
this.tweens.add({
|
||||
targets: outer, x: toX, y: toY, scaleX: 1, scaleY: 1, angle: toAngle,
|
||||
duration, ease: 'Cubic.easeInOut',
|
||||
onComplete: () => { outer.destroy(); onLand?.(); },
|
||||
onComplete: () => { if (!keepAlive) outer.destroy(); onLand?.(outer); },
|
||||
});
|
||||
|
||||
if (flipToId != null) {
|
||||
if (flipTo != 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,
|
||||
});
|
||||
side.destroy();
|
||||
side = buildSide(flipTo);
|
||||
this.tweens.add({ targets: flipHost, scaleX: 1, duration: duration * 0.35, ease: 'Cubic.easeOut' });
|
||||
},
|
||||
});
|
||||
|
|
@ -1648,7 +1855,7 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
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,
|
||||
startAs: 'back', flipTo: 'face', id: e.id, duration: dur,
|
||||
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
|
||||
});
|
||||
} else {
|
||||
|
|
@ -1659,12 +1866,56 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
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,
|
||||
startAs: 'back', flipTo: null, duration: dur,
|
||||
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// end-of-turn cleanup: every discarded in-play unit and every discarded
|
||||
// hand card stays visible in its original spot (via _endTurnGhosts) right
|
||||
// up until this exact moment, then flies individually to the owner's
|
||||
// discard pile. In-play cards fly from their pre-mutation board position
|
||||
// (_endTurnFromPos, snapshotted in playEvents); hand cards fly from a
|
||||
// virtual old-hand layout reconstructed purely from event order
|
||||
// (_endTurnHandLayout), since the real hand has already been replaced with
|
||||
// the new one by the time this event is processed. Own cards flip
|
||||
// face-down (the discard pile is shown as a back); the opponent's hand
|
||||
// cards were already hidden, so no flip.
|
||||
animateEndTurnDiscard(e) {
|
||||
const isHuman = e.seat === this.humanSeat;
|
||||
const dest = isHuman
|
||||
? { x: GAME_WIDTH - 205, y: 985, w: 90, h: 124 }
|
||||
: { x: 570, y: 192, w: 30, h: 42 };
|
||||
let fromX, fromY, fromW, fromH, startAs, flipTo;
|
||||
if (e.zone === 'inPlay') {
|
||||
const pos = this._endTurnFromPos.get(e.uid) || dest;
|
||||
this._endTurnFromPos.delete(e.uid);
|
||||
fromX = pos.x; fromY = pos.y;
|
||||
fromW = isHuman ? 122 : 84; fromH = isHuman ? 170 : 118;
|
||||
startAs = 'face'; flipTo = 'back';
|
||||
} else if (isHuman) {
|
||||
const layout = this._endTurnHandLayout.get(e.uid) || { idx: 0, total: 1 };
|
||||
this._endTurnHandLayout.delete(e.uid);
|
||||
fromX = this.humanHandSlotX(layout.idx, layout.total); fromY = 985;
|
||||
fromW = 148; fromH = 206;
|
||||
startAs = 'face'; flipTo = 'back';
|
||||
} else {
|
||||
this._endTurnHandLayout.delete(e.uid);
|
||||
fromX = 570; fromY = 72;
|
||||
fromW = 30; fromH = 42;
|
||||
startAs = 'back'; flipTo = null;
|
||||
}
|
||||
this._endTurnGhosts.delete(e.uid);
|
||||
this.renderAll();
|
||||
this.dealCardAnimated({
|
||||
fromX, fromY, fromW, fromH,
|
||||
toX: dest.x, toY: dest.y, toW: dest.w, toH: dest.h,
|
||||
startAs, flipTo, id: e.id, duration: 200,
|
||||
onLand: () => { this._pendingDiscardUids.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
|
||||
|
|
@ -1677,7 +1928,7 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
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,
|
||||
startAs: 'back', flipTo: 'face', id: e.id, duration: 300,
|
||||
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
|
||||
});
|
||||
}
|
||||
|
|
@ -1694,25 +1945,89 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
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,
|
||||
startAs: 'face', flipTo: null, id: 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; }
|
||||
// the stable slot map (assigned in renderGalaxy()) already places this
|
||||
// card in whatever slot its predecessor vacated, so it always lands
|
||||
// exactly where the purchased/lost card used to sit
|
||||
const idx = this._rowSlots.get(e.uid);
|
||||
if (idx == null) { 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,
|
||||
startAs: 'back', flipTo: 'face', id: e.id, duration: this._dealingInitial ? 320 : 280,
|
||||
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
|
||||
});
|
||||
}
|
||||
|
||||
// buying a card is a four-beat moment: funding arcs converge on it while it
|
||||
// still sits in its row slot (rendered via _rowGhosts), then it flies up to
|
||||
// a big center-screen reveal (already face-up, no flip) — leaving its row
|
||||
// slot empty from that instant on — holds there so it can be read, then
|
||||
// flips face-down and shrinks into the buyer's discard pile (or, for the
|
||||
// rare "topdeck it" ability, the draw pile instead). The row's own
|
||||
// replacement card (a separate 'rowRefill' event, reordered to play right
|
||||
// after this one in playEvents) lands in that same now-empty slot only
|
||||
// once this whole sequence completes.
|
||||
animateBuy(e) {
|
||||
const src = this._rowPos.get(e.uid) || this._orpPos || { x: GAME_WIDTH / 2, y: GAME_HEIGHT / 2 };
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
const bigW = 316, bigH = 444; // 2x the on-board row/outer-rim card size
|
||||
const dest = e.topdeck
|
||||
? (e.seat === this.humanSeat ? { x: 392, y: 911, w: 90, h: 124 } : { x: 570, y: 132, w: 30, h: 42 })
|
||||
: (e.seat === this.humanSeat ? { x: GAME_WIDTH - 205, y: 985, w: 90, h: 124 } : { x: 570, y: 192, w: 30, h: 42 });
|
||||
const land = () => {
|
||||
this._pendingCardUids.delete(e.uid);
|
||||
this._pendingDiscardUids.delete(e.uid);
|
||||
this._pendingDeckUids.delete(e.uid);
|
||||
this.renderAll();
|
||||
};
|
||||
const startReveal = () => {
|
||||
// the card leaves its row slot the instant it starts flying to the
|
||||
// center-screen reveal — clearing the ghost here (rather than at the
|
||||
// very end) is what frees the slot up for the eventual rowRefill card
|
||||
if (this._rowGhosts.get(e.uid) === e.id) { this._rowGhosts.delete(e.uid); this.renderAll(); }
|
||||
this.dealCardAnimated({
|
||||
fromX: src.x, fromY: src.y, fromW: 158, fromH: 222,
|
||||
toX: cx, toY: cy, toW: bigW, toH: bigH,
|
||||
startAs: 'face', flipTo: null, id: e.id, duration: 420,
|
||||
keepAlive: true,
|
||||
onLand: (token) => {
|
||||
this.time.delayedCall(1000, () => {
|
||||
token.destroy();
|
||||
this.dealCardAnimated({
|
||||
fromX: cx, fromY: cy, fromW: bigW, fromH: bigH,
|
||||
toX: dest.x, toY: dest.y, toW: dest.w, toH: dest.h,
|
||||
startAs: 'face', flipTo: 'back', id: e.id, duration: 380,
|
||||
onLand: land,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
// Funding arcs: gold arcs converge from every one of the buyer's currently
|
||||
// in-play cards (units + capitals — unaffected by the purchase itself) onto
|
||||
// the row/outer-rim slot being bought, before the reveal-hold-stow sequence
|
||||
// starts. _playPos is read live since the buyer's board is untouched by a
|
||||
// purchase; _rowPos for the bought card itself is kept fresh by the
|
||||
// _rowGhosts render in renderGalaxy() until startReveal() clears it.
|
||||
const buyer = this.gs.players[e.seat];
|
||||
const sources = [...buyer.inPlay, ...buyer.capitals]
|
||||
.map((entry) => this._playPos.get(entry.card.uid))
|
||||
.filter(Boolean);
|
||||
if (!sources.length) {
|
||||
const basePos = e.seat === this.humanSeat ? this._myBasePos : this._oppBasePos;
|
||||
if (basePos) sources.push(basePos);
|
||||
}
|
||||
this.drawArcArrows(sources, src.x, src.y, C.gold, {}, startReveal);
|
||||
}
|
||||
|
||||
eventFx(e) {
|
||||
switch (e.type) {
|
||||
case 'turnStart':
|
||||
|
|
@ -1725,31 +2040,40 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
if (e.seat !== this.humanSeat) this.animatePlay(e);
|
||||
else this.animateHumanPlay(e);
|
||||
break;
|
||||
case 'buy': {
|
||||
case 'buy':
|
||||
this.sfx(SFX.PURCHASE);
|
||||
const from = this._rowPos.get(e.uid) || this._orpPos;
|
||||
if (from) {
|
||||
const tok = this.makeCardBack(from.x, from.y, 110, 154, this.fxLayer);
|
||||
const to = e.seat === this.humanSeat ? { x: 140, y: 940 } : { x: GAME_WIDTH - 220, y: 90 };
|
||||
this.tweens.add({ targets: tok, x: to.x, y: to.y, scale: 0.3, alpha: 0.4, duration: 380, ease: 'Cubic.easeIn', onComplete: () => tok.destroy() });
|
||||
}
|
||||
this.animateBuy(e);
|
||||
break;
|
||||
}
|
||||
case 'bounty': {
|
||||
this.sfx(SFX.SCIFI_EXPLODE);
|
||||
// the target stays visible in its row slot (via _rowGhosts, held
|
||||
// open the same way a bought card is) until the arrow actually
|
||||
// reaches it
|
||||
const at = this._rowPos.get(e.uid);
|
||||
if (at) {
|
||||
this.popText(at.x, at.y, '💥', '#ffffff', 44);
|
||||
this.popText(at.x, at.y - 56, `${cardDef(e.id).name} defeated!`, C.goldHex, 20);
|
||||
}
|
||||
const from = this._pendingAttackFrom;
|
||||
this._pendingAttackFrom = [];
|
||||
const impact = () => {
|
||||
if (this._rowGhosts.get(e.uid) === e.id) { this._rowGhosts.delete(e.uid); this.renderAll(); }
|
||||
this.sfx(SFX.SCIFI_EXPLODE);
|
||||
if (at) {
|
||||
this.popText(at.x, at.y, '💥', '#ffffff', 44);
|
||||
this.popText(at.x, at.y - 56, `${cardDef(e.id).name} defeated!`, C.goldHex, 20);
|
||||
}
|
||||
};
|
||||
if (at && from.length) this.drawArcArrows(from, at.x, at.y, C.bad, {}, impact);
|
||||
else impact();
|
||||
break;
|
||||
}
|
||||
case 'attackBase':
|
||||
case 'attackBase': {
|
||||
this.sfx(SFX.SCIFI_LAUNCH);
|
||||
const dest = e.seat === this.humanSeat ? this._oppBasePos : this._myBasePos;
|
||||
const from = this._pendingAttackFrom;
|
||||
this._pendingAttackFrom = [];
|
||||
if (dest && from.length) this.drawArcArrows(from, dest.x, dest.y, C.bad, {});
|
||||
break;
|
||||
}
|
||||
case 'capitalDamage': {
|
||||
const at = this._playPos.get(e.uid);
|
||||
if (at) { this.popText(at.x, at.y - 20, `-${e.n}`, '#ff6b5e', 26); this.shakeAt(at.x, at.y); }
|
||||
if (at) { this.popText(at.x, at.y - 20, `-${e.n}`, '#ff6b5e', 36); this.shakeAt(at.x, at.y); }
|
||||
break;
|
||||
}
|
||||
case 'capitalDestroyed': {
|
||||
|
|
@ -1762,7 +2086,7 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
case 'baseDamage': {
|
||||
const pos = e.seat === this.humanSeat ? this._myBasePos : this._oppBasePos;
|
||||
if (pos) {
|
||||
this.popText(pos.x, pos.y - 40, `-${e.n}`, '#ff5348', 34);
|
||||
this.popText(pos.x, pos.y - 40, `-${e.n}`, '#ff5348', 44);
|
||||
const flash = this.add.rectangle(pos.x, pos.y, 310, 200, 0xd6604d, 0.35).setDepth(DEPTH.fx);
|
||||
this.tweens.add({ targets: flash, alpha: 0, duration: 380, onComplete: () => flash.destroy() });
|
||||
this.sfx(SFX.BATTLESHIP_HIT);
|
||||
|
|
@ -1823,6 +2147,10 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
this.sfx(SFX.CARD_DEAL);
|
||||
this.animateRowRefill(e);
|
||||
break;
|
||||
case 'endTurnDiscard':
|
||||
this.sfx(SFX.CARD_PLACE);
|
||||
this.animateEndTurnDiscard(e);
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1841,6 +2169,98 @@ export default class SWDBGGame extends Phaser.Scene {
|
|||
this.tweens.add({ targets: g, scale: 1.6, alpha: 0, duration: 300, onComplete: () => g.destroy() });
|
||||
}
|
||||
|
||||
// Risk-style converging attack arc: manual quadratic-bezier re-rasterized every
|
||||
// tween tick (not Phaser's Curves.QuadraticBezier), with a white-outline pass
|
||||
// under the colored pass and an arrowhead aligned to the curve's tangent at the
|
||||
// tip. Callback-style (onDone) to match this file's other animation helpers.
|
||||
drawArcArrow(fromX, fromY, toX, toY, color, opts = {}) {
|
||||
const { duration = 500, archCap = 90, linger = 120, onDone } = opts;
|
||||
const dx = toX - fromX, dy = toY - fromY;
|
||||
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
let perpX = -dy / len, perpY = dx / len;
|
||||
if (perpY > 0) { perpX = -perpX; perpY = -perpY; } // always arch toward top of screen
|
||||
const archH = Math.min(len * 0.35, archCap);
|
||||
const cpx = (fromX + toX) / 2 + perpX * archH;
|
||||
const cpy = (fromY + toY) / 2 + perpY * archH;
|
||||
|
||||
const g = this.add.graphics().setDepth(DEPTH.fx);
|
||||
const LINE_W = 5, STROKE_W = 2, ARROW_LEN = 20, ARROW_WID = 9;
|
||||
|
||||
const drawArc = (t) => {
|
||||
g.clear();
|
||||
const steps = Math.max(2, Math.ceil(t * 48));
|
||||
const pts = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const tt = (i / steps) * t;
|
||||
pts.push(
|
||||
(1 - tt) * (1 - tt) * fromX + 2 * (1 - tt) * tt * cpx + tt * tt * toX,
|
||||
(1 - tt) * (1 - tt) * fromY + 2 * (1 - tt) * tt * cpy + tt * tt * toY,
|
||||
);
|
||||
}
|
||||
const drawPath = (w, col, alpha) => {
|
||||
g.lineStyle(w, col, alpha);
|
||||
g.beginPath();
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
if (i === 0) g.moveTo(pts[i * 2], pts[i * 2 + 1]);
|
||||
else g.lineTo(pts[i * 2], pts[i * 2 + 1]);
|
||||
}
|
||||
g.strokePath();
|
||||
};
|
||||
drawPath(LINE_W + STROKE_W * 2, 0xffffff, 0.85);
|
||||
drawPath(LINE_W, color, 0.92);
|
||||
|
||||
if (t > 0.05) {
|
||||
const prevT = Math.max(0, t - 0.04);
|
||||
const tx = (1 - t) * (1 - t) * fromX + 2 * (1 - t) * t * cpx + t * t * toX;
|
||||
const ty = (1 - t) * (1 - t) * fromY + 2 * (1 - t) * t * cpy + t * t * toY;
|
||||
const px = (1 - prevT) * (1 - prevT) * fromX + 2 * (1 - prevT) * prevT * cpx + prevT * prevT * toX;
|
||||
const py = (1 - prevT) * (1 - prevT) * fromY + 2 * (1 - prevT) * prevT * cpy + prevT * prevT * toY;
|
||||
const adx = tx - px, ady = ty - py;
|
||||
const aLen = Math.sqrt(adx * adx + ady * ady) || 1;
|
||||
const ax = adx / aLen, ay = ady / aLen;
|
||||
const s = STROKE_W;
|
||||
g.fillStyle(0xffffff, 0.85);
|
||||
g.fillTriangle(
|
||||
tx + ax * s, ty + ay * s,
|
||||
tx - ax * (ARROW_LEN + s) + ay * (ARROW_WID + s), ty - ay * (ARROW_LEN + s) - ax * (ARROW_WID + s),
|
||||
tx - ax * (ARROW_LEN + s) - ay * (ARROW_WID + s), ty - ay * (ARROW_LEN + s) + ax * (ARROW_WID + s),
|
||||
);
|
||||
g.fillStyle(color, 0.95);
|
||||
g.fillTriangle(
|
||||
tx, ty,
|
||||
tx - ax * ARROW_LEN + ay * ARROW_WID, ty - ay * ARROW_LEN - ax * ARROW_WID,
|
||||
tx - ax * ARROW_LEN - ay * ARROW_WID, ty - ay * ARROW_LEN + ax * ARROW_WID,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const progress = { t: 0 };
|
||||
this.tweens.add({
|
||||
targets: progress, t: 1, duration, ease: 'Sine.easeInOut',
|
||||
onUpdate: () => drawArc(progress.t),
|
||||
onComplete: () => {
|
||||
drawArc(1);
|
||||
onDone?.();
|
||||
this.time.delayedCall(linger, () => g.destroy());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fan-in wrapper: fires one drawArcArrow per source point simultaneously,
|
||||
// calling onAllDone once every arc has individually finished. Used both for
|
||||
// the buy funding arc (many sources converge on one purchase) and the attack
|
||||
// arc (many attacking squad members converge on one target).
|
||||
drawArcArrows(points, toX, toY, color, opts = {}, onAllDone) {
|
||||
if (!points.length) { onAllDone?.(); return; }
|
||||
let remaining = points.length;
|
||||
for (const p of points) {
|
||||
this.drawArcArrow(p.x, p.y, toX, toY, color, {
|
||||
...opts,
|
||||
onDone: () => { if (--remaining === 0) onAllDone?.(); },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
showBanner(text) {
|
||||
const banner = this.add.text(GAME_WIDTH / 2, 300, text, {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: C.text,
|
||||
|
|
|
|||
|
|
@ -700,9 +700,15 @@ function attackBase(state, seat, action) {
|
|||
|
||||
function endTurn(state, seat) {
|
||||
const p = P(state, seat);
|
||||
for (const e of p.inPlay) p.discard.push(e.card);
|
||||
for (const e of p.inPlay) {
|
||||
p.discard.push(e.card);
|
||||
emit(state, { type: 'endTurnDiscard', seat, uid: e.card.uid, id: e.card.id, zone: 'inPlay' });
|
||||
}
|
||||
p.inPlay = [];
|
||||
for (const c of p.hand) p.discard.push(c);
|
||||
for (const c of p.hand) {
|
||||
p.discard.push(c);
|
||||
emit(state, { type: 'endTurnDiscard', seat, uid: c.uid, id: c.id, zone: 'hand' });
|
||||
}
|
||||
p.hand = [];
|
||||
p.resources = 0;
|
||||
drawCards(state, seat, state.meta.handSize);
|
||||
|
|
|
|||
|
|
@ -108,7 +108,15 @@ function driveDecision(st, d, skill, rnd, tally) {
|
|||
actBuild(st, d.seat, choice);
|
||||
break;
|
||||
}
|
||||
case 'window': actWindow(st, d.seat, choice); if (tally && choice && !choice.pass) tally.casts++; break;
|
||||
case 'window': {
|
||||
const windowId = d.window;
|
||||
actWindow(st, d.seat, choice);
|
||||
if (tally && choice && !choice.pass) {
|
||||
tally.casts++;
|
||||
if (windowId === 'advRoom') tally.advRoomCasts++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'react': actReact(st, d.seat, choice); break;
|
||||
case 'target': actChooseTarget(st, d.seat, choice); break;
|
||||
case 'discard': actDiscard(st, d.seat, choice); break;
|
||||
|
|
@ -120,7 +128,7 @@ function driveDecision(st, d, skill, rnd, tally) {
|
|||
function playGame(nPlayers, seed, skills, collect = null) {
|
||||
const st = newGame(nPlayers, seed);
|
||||
const rnd = makeRng(seed ^ 0x5eed);
|
||||
const tally = { casts: 0 };
|
||||
const tally = { casts: 0, advRoomCasts: 0 };
|
||||
let steps = 0;
|
||||
const eventLog = [];
|
||||
while (!isOver(st)) {
|
||||
|
|
@ -163,6 +171,7 @@ console.log('Self-play soak (320 games)…');
|
|||
{
|
||||
let totalRounds = 0;
|
||||
let totalCasts = 0;
|
||||
let totalAdvRoomCasts = 0;
|
||||
let eliminations = 0;
|
||||
const winReasons = {};
|
||||
const winsBySeat = {};
|
||||
|
|
@ -174,6 +183,7 @@ console.log('Self-play soak (320 games)…');
|
|||
games++;
|
||||
totalRounds += st.round;
|
||||
totalCasts += tally.casts;
|
||||
totalAdvRoomCasts += tally.advRoomCasts;
|
||||
eliminations += st.players.filter((p) => !p.alive).length;
|
||||
const over = st.events; // drained already; use final state
|
||||
ok(st.winner != null, `game ${g}: has a winner`);
|
||||
|
|
@ -192,11 +202,12 @@ console.log('Self-play soak (320 games)…');
|
|||
ok(ranking[0] === st.winner || !w.alive === false, `game ${g}: winner ranks first`);
|
||||
}
|
||||
console.log(` ${games} games · avg rounds ${(totalRounds / games).toFixed(1)}`
|
||||
+ ` · spell/ability activations ${totalCasts}`
|
||||
+ ` · spell/ability activations ${totalCasts} (${totalAdvRoomCasts} mid-walk)`
|
||||
+ ` · eliminations ${eliminations}`
|
||||
+ ` · endings ${JSON.stringify(winReasons)}`
|
||||
+ ` · wins by seat ${JSON.stringify(winsBySeat)}`);
|
||||
ok(totalCasts > games, 'AI actually casts spells / activates rooms');
|
||||
ok(totalAdvRoomCasts > 0, 'AI actually uses the mid-walk advRoom casting window');
|
||||
ok((winReasons.souls || 0) > games * 0.25, 'a healthy share of mixed-skill games end on 10 souls');
|
||||
}
|
||||
|
||||
|
|
@ -306,10 +317,16 @@ function driveTo(st, predicate, maxSteps = 5000) {
|
|||
const tough = Object.keys(HEROES).find((id) => HEROES[id].epic);
|
||||
p.entrance = [{ uid: 777777, id: tough, hp: HEROES[tough].hp, hpMax: HEROES[tough].hp }];
|
||||
const before = p.wounds;
|
||||
// Run just the adventure by driving until the entrance empties.
|
||||
// Run just the adventure by driving until this hero's walk (queued, or mid-
|
||||
// crawl and paused at a mid-walk advRoom window) finishes — entrance alone
|
||||
// isn't enough any more: it empties the instant the hero starts walking,
|
||||
// but the walk itself can now pause (for an advRoom casting window) partway
|
||||
// through, before the boss-wound step that's actually being asserted here.
|
||||
const rnd = makeRng(5);
|
||||
let steps = 0;
|
||||
while (p.entrance.length && !isOver(st) && steps < 5000) {
|
||||
const stillWalking = () => p.entrance.some((h) => h.uid === 777777)
|
||||
|| (st.adv && st.adv.walking && st.adv.walking.hero.uid === 777777);
|
||||
while (stillWalking() && !isOver(st) && steps < 5000) {
|
||||
steps++;
|
||||
const d = pendingDecision(st);
|
||||
driveDecision(st, d, 1, rnd, null);
|
||||
|
|
|
|||
Loading…
Reference in New Issue