diff --git a/assets/fx/sw-dark-1.mp3 b/assets/fx/sw-dark-1.mp3 new file mode 100644 index 0000000..2fff334 Binary files /dev/null and b/assets/fx/sw-dark-1.mp3 differ diff --git a/assets/fx/sw-dark-2.mp3 b/assets/fx/sw-dark-2.mp3 new file mode 100644 index 0000000..6f0e24a Binary files /dev/null and b/assets/fx/sw-dark-2.mp3 differ diff --git a/assets/fx/sw-dark-3.mp3 b/assets/fx/sw-dark-3.mp3 new file mode 100644 index 0000000..6ae7d29 Binary files /dev/null and b/assets/fx/sw-dark-3.mp3 differ diff --git a/assets/fx/sw-light-1.mp3 b/assets/fx/sw-light-1.mp3 new file mode 100644 index 0000000..0378c4c Binary files /dev/null and b/assets/fx/sw-light-1.mp3 differ diff --git a/assets/fx/sw-light-2.mp3 b/assets/fx/sw-light-2.mp3 new file mode 100644 index 0000000..0e19f40 Binary files /dev/null and b/assets/fx/sw-light-2.mp3 differ diff --git a/assets/fx/sw-light-3.mp3 b/assets/fx/sw-light-3.mp3 new file mode 100644 index 0000000..57b33cf Binary files /dev/null and b/assets/fx/sw-light-3.mp3 differ diff --git a/data/swdbg-cards.json b/data/swdbg-cards.json index adee222..3fbc728 100644 --- a/data/swdbg-cards.json +++ b/data/swdbg-cards.json @@ -25,7 +25,7 @@ " ability machine-readable effect (see OPS below) or null", " text display-only rules text — keep in sync with `ability`", "", - "ABILITY SHAPE: { timing, op, ...params, cost?, requiresForce? }", + "ABILITY SHAPE: { timing, op, ...params, cost?, requiresForce?, requiresCapital? }", " timing 'action' = use once per turn while in play (the default)", " 'constant' = always on while in play ('While ...')", " 'onPlay' = triggers when the card is played", @@ -33,6 +33,7 @@ " 'onReveal' = bases only: when the base is revealed", " cost { exileSelf: true } and/or { exileFromHand: 1 } paid before the op", " requiresForce: true = usable only while the Force is with you", + " requiresCapital: true = usable only while you have a capital ship in play", " nForce / amountForce = upgraded number while the Force is with you", "", "SUPPORTED OPS (the engine understands exactly these):", @@ -111,9 +112,9 @@ }, "cards": [ - { "id": "tiefighter", "name": "TIE Fighter", "faction": "empire", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 1, "attack": 1, "resources": 0, "force": 0, "count": 5, "target": 2, "reward": { "resources": 1 }, - "ability": { "timing": "constant", "op": "selfBoostWithTrait", "trait": "Fighter", "amount": 1 }, - "text": "TIE Fighter gains 1 attack while you have another Fighter in play." }, + { "id": "tiefighter", "name": "TIE Fighter", "faction": "empire", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 2, "attack": 2, "resources": 0, "force": 0, "count": 5, "target": 1, "reward": { "resources": 1 }, + "ability": { "timing": "action", "op": "draw", "n": 1, "requiresCapital": true }, + "text": "If you have a capital ship in play, draw 1 card." }, { "id": "scouttrooper", "name": "Scout Trooper", "faction": "empire", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 2, "attack": 0, "resources": 2, "force": 0, "count": 3, "target": 3, "reward": { "resources": 1 }, "ability": { "timing": "action", "op": "revealTop" }, "text": "Reveal the top card of the galaxy deck. If it is an Empire card, gain 1 resource. If it is a Rebel card, you may discard it." }, @@ -142,8 +143,8 @@ "ability": { "timing": "action", "op": "draw", "n": 1, "nBase": { "base": "deathstar", "n": 2 } }, "text": "Draw 1 card (2 cards instead if your base is the Death Star)." }, { "id": "darthvader", "name": "Darth Vader", "faction": "empire", "type": "unit", "unique": true, "traits": ["Sith"], "cost": 8, "attack": 6, "resources": 0, "force": 2, "count": 1, "target": 9, "reward": { "resources": 3, "force": 3 }, - "ability": { "timing": "action", "op": "damageBase", "n": 2, "requiresForce": true }, - "text": "If the Force is with you, deal 2 damage to the enemy base." }, + "ability": { "timing": "constant", "op": "selfBoostWhileForce", "amount": 4 }, + "text": "Darth Vader gains 4 attack while the Force is with you." }, { "id": "emperorpalpatine", "name": "Emperor Palpatine", "faction": "empire", "type": "unit", "unique": true, "traits": ["Sith"], "cost": 8, "attack": 3, "resources": 0, "force": 3, "count": 1, "target": 9, "reward": { "resources": 3, "force": 3 }, "ability": { "timing": "action", "op": "choose", "options": [ { "op": "draw", "n": 2 }, { "op": "oppDiscard", "n": 1, "randomForce": true } ] }, "text": "Choose one: draw 2 cards, or your opponent discards a card from their hand (at random while the Force is with you)." }, @@ -175,9 +176,9 @@ { "id": "rebelcommando", "name": "Rebel Commando", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 3, "attack": 3, "resources": 0, "force": 0, "count": 3, "target": 3, "reward": { "resources": 2 }, "ability": { "timing": "action", "op": "oppDiscard", "n": 1, "randomForce": true }, "text": "Your opponent discards a card from their hand (at random while the Force is with you)." }, - { "id": "cassianandor", "name": "Cassian Andor", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Trooper", "Spy"], "cost": 4, "attack": 3, "resources": 1, "force": 0, "count": 1, "target": 5, "reward": { "resources": 2, "force": 1 }, - "ability": { "timing": "action", "op": "lookTop", "mayDiscard": true }, - "text": "Look at the top card of the galaxy deck. You may discard it." }, + { "id": "cassianandor", "name": "Cassian Andor", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Trooper", "Spy"], "cost": 5, "attack": 5, "resources": 0, "force": 0, "count": 1, "target": 5, "reward": { "resources": 3, "force": 2 }, + "ability": { "timing": "onBounty", "op": "oppDiscard", "n": 1 }, + "text": "When Cassian Andor defeats a target in the galaxy row, your opponent discards 1 card from their hand." }, { "id": "k2so", "name": "K-2SO", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Droid"], "cost": 4, "attack": 3, "resources": 0, "force": 0, "count": 1, "target": 5, "reward": { "resources": 2, "force": 1 }, "ability": { "timing": "action", "op": "oppDiscard", "n": 1, "randomForce": true }, "text": "Your opponent discards a card from their hand (at random while the Force is with you)." }, @@ -197,8 +198,8 @@ "ability": { "timing": "action", "op": "freePurchase", "faction": "rebel", "forceTopdeck": true }, "text": "Purchase a Rebel card from the galaxy row for free. If the Force is with you, place that card on top of your deck." }, { "id": "lukeskywalker", "name": "Luke Skywalker", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Jedi"], "cost": 8, "attack": 6, "resources": 0, "force": 2, "count": 1, "target": 8, "reward": { "resources": 3, "force": 3 }, - "ability": { "timing": "action", "op": "gainAttack", "n": 2, "requiresForce": true }, - "text": "If the Force is with you, Luke Skywalker gains 2 attack." }, + "ability": { "timing": "onPlay", "op": "destroyCapital", "requiresForce": true }, + "text": "If the Force is with you, destroy an enemy capital ship." }, { "id": "blockaderunner", "name": "Blockade Runner", "faction": "rebel", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 4, "attack": 1, "resources": 1, "force": 0, "hp": 4, "count": 2, "target": null, "reward": null, "ability": null, "text": "Though designed as a diplomatic courier, the Corellian CR90 corvette is often used to run planetary blockades." }, diff --git a/src/games/swdbg/SWDBGGame.js b/src/games/swdbg/SWDBGGame.js index b82caa8..be96828 100644 --- a/src/games/swdbg/SWDBGGame.js +++ b/src/games/swdbg/SWDBGGame.js @@ -13,7 +13,7 @@ import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; import { Button } from '../../ui/Button.js'; -import { playSound, SFX } from '../../ui/Sounds.js'; +import { playSound, playForceMove, SFX } from '../../ui/Sounds.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { api } from '../../services/api.js'; import { createPlayerPortrait, createOpponentPortrait } from '../../ui/Portrait.js'; @@ -2106,7 +2106,7 @@ export default class SWDBGGame extends Phaser.Scene { this.showBanner(`${this.seatName(e.seat)} regroups at ${baseDef(this.gs.players[e.seat].faction, e.id).name}`.toUpperCase()); break; case 'forceMove': { - this.sfx(SFX.SCIFI_PLINK ?? SFX.CARD_SHOW); + playForceMove(this, e.to > e.from); if (this._forceMarkerPos) this.shakeAt(this._forceMarkerPos.x, this._forceMarkerPos.y); break; } diff --git a/src/games/swdbg/SWDBGLogic.js b/src/games/swdbg/SWDBGLogic.js index 24e96b4..8706195 100644 --- a/src/games/swdbg/SWDBGLogic.js +++ b/src/games/swdbg/SWDBGLogic.js @@ -97,6 +97,12 @@ export function forcePegged(state, seat) { return state.force * forceSign(p.faction) >= FORCE_MAX; } +function abilityGatePasses(state, seat, ab) { + if (ab.requiresForce && !forceWith(state, seat)) return false; + if (ab.requiresCapital && P(state, seat).capitals.length === 0) return false; + return true; +} + function moveForce(state, seat, n) { if (!n) return; const dir = forceSign(P(state, seat).faction); @@ -255,7 +261,7 @@ function abilityCandidates(state, seat, ab, ctx = {}) { function abilityUsable(state, seat, ab, { costPoolSize = null, ctx = {} } = {}) { const p = P(state, seat); const o = P(state, opp(seat)); - if (ab.requiresForce && !forceWith(state, seat)) return false; + if (!abilityGatePasses(state, seat, ab)) return false; if (ab.cost?.exileFromHand) { const spare = costPoolSize != null ? costPoolSize : p.hand.length; if (spare < ab.cost.exileFromHand) return false; @@ -519,7 +525,7 @@ function fireOnBounty(state, seat) { const p = P(state, seat); for (const e of [...p.inPlay, ...p.capitals]) { const ab = cardDef(e.card).ability; - if (ab && ab.timing === 'onBounty') queueOp(state, seat, ab, { entry: e }); + if (ab && ab.timing === 'onBounty' && abilityGatePasses(state, seat, ab)) queueOp(state, seat, ab, { entry: e }); } } @@ -565,7 +571,7 @@ function playCard(state, seat, card) { emit(state, { type: 'attackBoost', seat, uid: card.uid, n: b.ability.amount, from: 'base' }); } } - if (def.ability?.timing === 'onPlay') queueOp(state, seat, def.ability, { entry }); + if (def.ability?.timing === 'onPlay' && abilityGatePasses(state, seat, def.ability)) queueOp(state, seat, def.ability, { entry }); } function useAbility(state, seat, action) { @@ -728,7 +734,7 @@ export function actChooseBase(state, seat, baseId) { state.effectQueue.shift(); emit(state, { type: 'baseChosen', seat, id: baseId }); const b = baseDef(p.faction, baseId); - if (b.ability?.timing === 'onReveal') queueOp(state, seat, b.ability, {}); + if (b.ability?.timing === 'onReveal' && abilityGatePasses(state, seat, b.ability)) queueOp(state, seat, b.ability, {}); } export function actChooseTarget(state, seat, ref) { diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 87e9fcb..d5cd232 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -149,6 +149,12 @@ export default class PreloadScene extends Phaser.Scene { this.load.audio('sfx-gem-drop', 'assets/fx/gem-drop.mp3'); this.load.audio('sfx-gem-big-drop','assets/fx/gem-big-drop.mp3'); this.load.audio('sfx-firework', 'assets/fx/firework.mp3'); + this.load.audio('sfx-sw-light-1', 'assets/fx/sw-light-1.mp3'); + this.load.audio('sfx-sw-light-2', 'assets/fx/sw-light-2.mp3'); + this.load.audio('sfx-sw-light-3', 'assets/fx/sw-light-3.mp3'); + this.load.audio('sfx-sw-dark-1', 'assets/fx/sw-dark-1.mp3'); + this.load.audio('sfx-sw-dark-2', 'assets/fx/sw-dark-2.mp3'); + this.load.audio('sfx-sw-dark-3', 'assets/fx/sw-dark-3.mp3'); this.load.spritesheet('catan-special-cards', 'assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 }); diff --git a/src/ui/Sounds.js b/src/ui/Sounds.js index 82263c0..038c653 100644 --- a/src/ui/Sounds.js +++ b/src/ui/Sounds.js @@ -63,12 +63,31 @@ export const SFX = { GEM_DROP: 'sfx-gem-drop', GEM_BIG_DROP: 'sfx-gem-big-drop', FIREWORK: 'sfx-firework', + SW_LIGHT_1: 'sfx-sw-light-1', + SW_LIGHT_2: 'sfx-sw-light-2', + SW_LIGHT_3: 'sfx-sw-light-3', + SW_DARK_1: 'sfx-sw-dark-1', + SW_DARK_2: 'sfx-sw-dark-2', + SW_DARK_3: 'sfx-sw-dark-3', }; export function playSound(scene, key) { try { scene.sound.play(key); } catch (_) {} } +// Picks and plays one of several keys at random (e.g. variations of the same cue). +export function playRandomSound(scene, keys) { + playSound(scene, keys[Math.floor(Math.random() * keys.length)]); +} + +const SW_LIGHT_KEYS = [SFX.SW_LIGHT_1, SFX.SW_LIGHT_2, SFX.SW_LIGHT_3]; +const SW_DARK_KEYS = [SFX.SW_DARK_1, SFX.SW_DARK_2, SFX.SW_DARK_3]; + +// SWDBG: Force track shifting toward the Rebel (light) or Empire (dark) side. +export function playForceMove(scene, towardLight) { + playRandomSound(scene, towardLight ? SW_LIGHT_KEYS : SW_DARK_KEYS); +} + // chip-bet is limited to one simultaneous instance. let _chipBetSound = null; export function playChipBet(scene) { diff --git a/tools/verifySWDBG.js b/tools/verifySWDBG.js index 2f98692..7dbd4b7 100644 --- a/tools/verifySWDBG.js +++ b/tools/verifySWDBG.js @@ -325,19 +325,49 @@ function give(st, seat, id) { } { - // Constant boosts: Imperial Carrier aura + TIE swarm + Rodian row bonus. + // Constant boosts: Imperial Carrier aura (Fighter trait) + Rodian row bonus. const st = freshGame(505); const emp = seatOf(st, 'empire'); give(st, emp, 'imperialcarrier'); const tie1 = give(st, emp, 'tiefighter'); - const tie2 = give(st, emp, 'tiefighter'); - // TIE: 1 base + 1 swarm (another Fighter) + 1 carrier aura = 3 - ok(entryAttack(st, emp, tie1) === 3, `TIE Fighter attack with swarm+carrier is 3 (got ${entryAttack(st, emp, tie1)})`); + // TIE Fighter: 2 base + 1 carrier aura = 3 + ok(entryAttack(st, emp, tie1) === 3, `TIE Fighter attack with carrier aura is 3 (got ${entryAttack(st, emp, tie1)})`); const rodian = give(st, emp, 'rodiangunslinger'); ok(entryAttack(st, emp, rodian) === 2, 'Rodian is 2 attack vs bases'); ok(entryAttack(st, emp, rodian, { vsRow: true }) === 4, 'Rodian is 4 attack vs the galaxy row'); } +{ + // TIE Fighter: action ability gated on having a capital ship in play. + const st = freshGame(508); + const emp = seatOf(st, 'empire'); + const tie = give(st, emp, 'tiefighter'); + let threw = false; + try { actTurn(st, emp, { type: 'ability', zone: 'play', uid: tie.card.uid }); } catch { threw = true; } + ok(threw, 'TIE Fighter draw ability unavailable without a capital ship in play'); + give(st, emp, 'gozanticruiser'); + const handBefore = st.players[emp].hand.length; + actTurn(st, emp, { type: 'ability', zone: 'play', uid: tie.card.uid }); + ok(st.players[emp].hand.length === handBefore + 1, 'TIE Fighter draws a card once a capital ship is in play'); +} + +{ + // Cassian Andor: onBounty trigger forces an opponent discard. + const st = freshGame(509); + const reb = seatOf(st, 'rebel'); + const emp = 1 - reb; + st.turnSeat = reb; + const cassian = give(st, reb, 'cassianandor'); // 5 attack + const target = { uid: 888002, id: 'tiefighter' }; // target 1 + st.galaxy.row[0] = target; + st.players[emp].hand = [{ uid: 'h1', id: 'stormtrooper' }]; + actTurn(st, reb, { type: 'attackRow', targetUid: target.uid, uids: [cassian.card.uid] }); + const d = pendingDecision(st); + ok(d.kind === 'oppDiscard' && d.seat === emp, "Cassian Andor's onBounty queues a forced opponent discard"); + actOppDiscard(st, emp, 'h1'); + ok(st.players[emp].hand.length === 0, 'the forced discard resolves'); +} + { // Force mechanics: pegged bonus resource, spy choice restrictions. const st = freshGame(606);