feat: Add Bloodthirsty faction campaign with new cards, skills, and animations

- Added 13 new Bloodthirsty cards (common to legendary) including Countess Vireska commander
- Implemented 4 new skills: siphon, drain, bloodpact, and bloodrage with full combat engine support
- Created Chapter 2 campaign (8 missions) with progressive difficulty and unique lore entries
- Added corresponding audio effects (bloodrage.mp3, drain.mp3, siphon.mp3) and card art assets
- Implemented visual animations for all new skills:
  - Drain: projectile to primary target + splash damage to neighbors + green heal return particles
  - Siphon: blood-red projectile with glow effect that heals attacker
  - Bloodpact: red flash for HP loss followed by ATK gain animation (handles self-destruction)
  - Bloodrage: red pulse and ATK boost display when below 50% HP
- Updated CombatEngine to handle preBattle death checks (bloodpact can kill cards immediately)
- Modified BattleScene to sequence new skill animations within the combat flow
This commit is contained in:
Brian Fertig 2026-03-14 21:56:19 -06:00
parent 390d55c7b2
commit d6682baa66
29 changed files with 966 additions and 33 deletions

View File

@ -3,7 +3,10 @@
"allow": [
"Bash(node:*)",
"Bash(wc -l /home/brianfertig/git/tyrants-edge/src/**/*.js)",
"Bash(grep:*)"
"Bash(grep:*)",
"Bash(ls:*)",
"Bash(cd:*)",
"Bash(python3:*)"
]
}
}

Binary file not shown.

BIN
assets/audio/fx/drain.mp3 Normal file

Binary file not shown.

BIN
assets/audio/fx/siphon.mp3 Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 4.4 MiB

View File

@ -22,9 +22,13 @@
"level": 2,
"requiredLevel": 2,
"unlockCondition": "campaign_raider",
"missions": [],
"missions": ["mission_006", "mission_007", "mission_008", "mission_009", "mission_010", "mission_011", "mission_012", "mission_013"],
"starterReward": {
"cards": []
"cards": [
"bt_thrall_1", "bt_thrall_1", "bt_thrall_1",
"bt_leech_1", "bt_leech_1", "bt_leech_1",
"bt_initiate_1", "bt_initiate_1", "bt_initiate_1"
]
}
},
{

View File

@ -1643,5 +1643,213 @@
],
"flavorText": "The Overlord doesn't negotiate. He dictates terms from the rubble.",
"artKey": "raider_overlord_1"
},
{
"id": "bt_cmd_1",
"name": "Countess Vireska",
"faction": "bloodthirsty",
"type": "commander",
"rarity": "legendary",
"levels": [
{ "attack": 5, "health": 50, "armor": 1, "delay": 0, "skills": [{ "name": "drain", "value": 3, "trigger": "preAttack" }] },
{ "attack": 6, "health": 60, "armor": 1, "delay": 0, "skills": [{ "name": "drain", "value": 4, "trigger": "preAttack" }] },
{ "attack": 7, "health": 70, "armor": 2, "delay": 0, "skills": [{ "name": "drain", "value": 4, "trigger": "preAttack" }] },
{ "attack": 8, "health": 85, "armor": 2, "delay": 0, "skills": [{ "name": "drain", "value": 5, "trigger": "preAttack" }] },
{ "attack": 10, "health": 100, "armor": 3, "delay": 0, "skills": [{ "name": "drain", "value": 6, "trigger": "preAttack" }] }
],
"flavorText": "She drinks deeply, and the battlefield withers.",
"artKey": "bt_cmd_1"
},
{
"id": "bt_thrall_1",
"name": "Blood Thrall",
"faction": "bloodthirsty",
"type": "assault",
"rarity": "common",
"levels": [
{ "attack": 4, "health": 10, "armor": 0, "delay": 1, "skills": [{ "name": "siphon", "value": 2, "trigger": "preAttack" }] },
{ "attack": 5, "health": 12, "armor": 0, "delay": 1, "skills": [{ "name": "siphon", "value": 2, "trigger": "preAttack" }] },
{ "attack": 6, "health": 14, "armor": 0, "delay": 1, "skills": [{ "name": "siphon", "value": 3, "trigger": "preAttack" }] },
{ "attack": 7, "health": 17, "armor": 1, "delay": 1, "skills": [{ "name": "siphon", "value": 3, "trigger": "preAttack" }] },
{ "attack": 8, "health": 20, "armor": 1, "delay": 1, "skills": [{ "name": "siphon", "value": 4, "trigger": "preAttack" }] }
],
"flavorText": "Every wound it inflicts feeds its hunger.",
"artKey": "bt_thrall_1"
},
{
"id": "bt_leech_1",
"name": "Crimson Leech",
"faction": "bloodthirsty",
"type": "assault",
"rarity": "common",
"levels": [
{ "attack": 3, "health": 11, "armor": 0, "delay": 0, "skills": [{ "name": "drain", "value": 2, "trigger": "preAttack" }] },
{ "attack": 4, "health": 13, "armor": 0, "delay": 0, "skills": [{ "name": "drain", "value": 2, "trigger": "preAttack" }] },
{ "attack": 5, "health": 15, "armor": 0, "delay": 0, "skills": [{ "name": "drain", "value": 3, "trigger": "preAttack" }] },
{ "attack": 5, "health": 18, "armor": 1, "delay": 0, "skills": [{ "name": "drain", "value": 3, "trigger": "preAttack" }] },
{ "attack": 6, "health": 21, "armor": 1, "delay": 0, "skills": [{ "name": "drain", "value": 4, "trigger": "preAttack" }] }
],
"flavorText": "It takes what you cannot spare.",
"artKey": "bt_leech_1"
},
{
"id": "bt_initiate_1",
"name": "Blood Initiate",
"faction": "bloodthirsty",
"type": "assault",
"rarity": "common",
"levels": [
{ "attack": 3, "health": 13, "armor": 0, "delay": 1, "skills": [{ "name": "bloodpact", "value": 2, "trigger": "preBattle" }] },
{ "attack": 4, "health": 15, "armor": 0, "delay": 1, "skills": [{ "name": "bloodpact", "value": 2, "trigger": "preBattle" }] },
{ "attack": 5, "health": 18, "armor": 0, "delay": 1, "skills": [{ "name": "bloodpact", "value": 3, "trigger": "preBattle" }] },
{ "attack": 5, "health": 21, "armor": 1, "delay": 1, "skills": [{ "name": "bloodpact", "value": 3, "trigger": "preBattle" }] },
{ "attack": 6, "health": 25, "armor": 1, "delay": 1, "skills": [{ "name": "bloodpact", "value": 4, "trigger": "preBattle" }] }
],
"flavorText": "Pain is the price of transcendence.",
"artKey": "bt_initiate_1"
},
{
"id": "bt_ghoul_1",
"name": "Famished Ghoul",
"faction": "bloodthirsty",
"type": "assault",
"rarity": "common",
"levels": [
{ "attack": 2, "health": 14, "armor": 1, "delay": 1, "skills": [{ "name": "bloodrage", "value": 2, "trigger": "passive" }] },
{ "attack": 3, "health": 16, "armor": 1, "delay": 1, "skills": [{ "name": "bloodrage", "value": 2, "trigger": "passive" }] },
{ "attack": 4, "health": 19, "armor": 1, "delay": 1, "skills": [{ "name": "bloodrage", "value": 3, "trigger": "passive" }] },
{ "attack": 4, "health": 22, "armor": 2, "delay": 1, "skills": [{ "name": "bloodrage", "value": 3, "trigger": "passive" }] },
{ "attack": 5, "health": 26, "armor": 2, "delay": 1, "skills": [{ "name": "bloodrage", "value": 4, "trigger": "passive" }] }
],
"flavorText": "Hunger sharpens every claw.",
"artKey": "bt_ghoul_1"
},
{
"id": "bt_revenant_1",
"name": "Revenant",
"faction": "bloodthirsty",
"type": "assault",
"rarity": "rare",
"levels": [
{ "attack": 5, "health": 14, "armor": 1, "delay": 1, "skills": [{ "name": "siphon", "value": 3, "trigger": "preAttack" }, { "name": "strike", "value": 1, "trigger": "preAttack" }] },
{ "attack": 6, "health": 17, "armor": 1, "delay": 1, "skills": [{ "name": "siphon", "value": 3, "trigger": "preAttack" }, { "name": "strike", "value": 2, "trigger": "preAttack" }] },
{ "attack": 7, "health": 20, "armor": 1, "delay": 1, "skills": [{ "name": "siphon", "value": 4, "trigger": "preAttack" }, { "name": "strike", "value": 2, "trigger": "preAttack" }] },
{ "attack": 8, "health": 23, "armor": 2, "delay": 1, "skills": [{ "name": "siphon", "value": 4, "trigger": "preAttack" }, { "name": "strike", "value": 3, "trigger": "preAttack" }] },
{ "attack": 9, "health": 27, "armor": 2, "delay": 1, "skills": [{ "name": "siphon", "value": 5, "trigger": "preAttack" }, { "name": "strike", "value": 3, "trigger": "preAttack" }] }
],
"flavorText": "Death could not keep it. Neither can you.",
"artKey": "bt_revenant_1"
},
{
"id": "bt_bloodmage_1",
"name": "Blood Mage",
"faction": "bloodthirsty",
"type": "assault",
"rarity": "rare",
"levels": [
{ "attack": 4, "health": 10, "armor": 2, "delay": 2, "skills": [{ "name": "drain", "value": 3, "trigger": "preAttack" }, { "name": "siphon", "value": 2, "trigger": "preAttack" }] },
{ "attack": 5, "health": 12, "armor": 2, "delay": 2, "skills": [{ "name": "drain", "value": 3, "trigger": "preAttack" }, { "name": "siphon", "value": 2, "trigger": "preAttack" }] },
{ "attack": 5, "health": 14, "armor": 3, "delay": 2, "skills": [{ "name": "drain", "value": 4, "trigger": "preAttack" }, { "name": "siphon", "value": 3, "trigger": "preAttack" }] },
{ "attack": 6, "health": 17, "armor": 3, "delay": 2, "skills": [{ "name": "drain", "value": 4, "trigger": "preAttack" }, { "name": "siphon", "value": 3, "trigger": "preAttack" }] },
{ "attack": 7, "health": 20, "armor": 4, "delay": 2, "skills": [{ "name": "drain", "value": 5, "trigger": "preAttack" }, { "name": "siphon", "value": 4, "trigger": "preAttack" }] }
],
"flavorText": "The arcane arts demand a crimson tithe.",
"artKey": "bt_bloodmage_1"
},
{
"id": "bt_harbinger_1",
"name": "Harbinger",
"faction": "bloodthirsty",
"type": "assault",
"rarity": "rare",
"levels": [
{ "attack": 5, "health": 18, "armor": 1, "delay": 2, "skills": [{ "name": "bloodpact", "value": 3, "trigger": "preBattle" }, { "name": "bloodrage", "value": 2, "trigger": "passive" }] },
{ "attack": 6, "health": 21, "armor": 1, "delay": 2, "skills": [{ "name": "bloodpact", "value": 3, "trigger": "preBattle" }, { "name": "bloodrage", "value": 2, "trigger": "passive" }] },
{ "attack": 7, "health": 25, "armor": 1, "delay": 2, "skills": [{ "name": "bloodpact", "value": 4, "trigger": "preBattle" }, { "name": "bloodrage", "value": 3, "trigger": "passive" }] },
{ "attack": 8, "health": 29, "armor": 2, "delay": 2, "skills": [{ "name": "bloodpact", "value": 4, "trigger": "preBattle" }, { "name": "bloodrage", "value": 3, "trigger": "passive" }] },
{ "attack": 9, "health": 33, "armor": 2, "delay": 2, "skills": [{ "name": "bloodpact", "value": 5, "trigger": "preBattle" }, { "name": "bloodrage", "value": 4, "trigger": "passive" }] }
],
"flavorText": "Where the blood flows, the harbinger follows.",
"artKey": "bt_harbinger_1"
},
{
"id": "bt_vampire_1",
"name": "Nightborn Vampire",
"faction": "bloodthirsty",
"type": "assault",
"rarity": "rare",
"levels": [
{ "attack": 6, "health": 12, "armor": 2, "delay": 2, "skills": [{ "name": "drain", "value": 4, "trigger": "preAttack" }, { "name": "enfeeble", "value": 2, "trigger": "preBattle" }] },
{ "attack": 7, "health": 14, "armor": 2, "delay": 2, "skills": [{ "name": "drain", "value": 4, "trigger": "preAttack" }, { "name": "enfeeble", "value": 2, "trigger": "preBattle" }] },
{ "attack": 7, "health": 17, "armor": 2, "delay": 2, "skills": [{ "name": "drain", "value": 5, "trigger": "preAttack" }, { "name": "enfeeble", "value": 3, "trigger": "preBattle" }] },
{ "attack": 8, "health": 20, "armor": 3, "delay": 2, "skills": [{ "name": "drain", "value": 5, "trigger": "preAttack" }, { "name": "enfeeble", "value": 3, "trigger": "preBattle" }] },
{ "attack": 9, "health": 23, "armor": 3, "delay": 2, "skills": [{ "name": "drain", "value": 6, "trigger": "preAttack" }, { "name": "enfeeble", "value": 4, "trigger": "preBattle" }] }
],
"flavorText": "It does not kill. It diminishes.",
"artKey": "bt_vampire_1"
},
{
"id": "bt_bloodlord_1",
"name": "Blood Lord",
"faction": "bloodthirsty",
"type": "assault",
"rarity": "epic",
"levels": [
{ "attack": 6, "health": 18, "armor": 2, "delay": 2, "skills": [{ "name": "bloodpact", "value": 4, "trigger": "preBattle" }, { "name": "siphon", "value": 4, "trigger": "preAttack" }] },
{ "attack": 7, "health": 21, "armor": 2, "delay": 2, "skills": [{ "name": "bloodpact", "value": 4, "trigger": "preBattle" }, { "name": "siphon", "value": 4, "trigger": "preAttack" }] },
{ "attack": 8, "health": 25, "armor": 3, "delay": 2, "skills": [{ "name": "bloodpact", "value": 5, "trigger": "preBattle" }, { "name": "siphon", "value": 5, "trigger": "preAttack" }] },
{ "attack": 9, "health": 29, "armor": 3, "delay": 2, "skills": [{ "name": "bloodpact", "value": 5, "trigger": "preBattle" }, { "name": "siphon", "value": 5, "trigger": "preAttack" }] },
{ "attack": 11, "health": 34, "armor": 4, "delay": 2, "skills": [{ "name": "bloodpact", "value": 6, "trigger": "preBattle" }, { "name": "siphon", "value": 6, "trigger": "preAttack" }] }
],
"flavorText": "He demands a tithe of blood from all — even himself.",
"artKey": "bt_bloodlord_1"
},
{
"id": "bt_hemomancer_1",
"name": "Hemomancer",
"faction": "bloodthirsty",
"type": "assault",
"rarity": "epic",
"levels": [
{ "attack": 7, "health": 16, "armor": 2, "delay": 3, "skills": [{ "name": "drain", "value": 5, "trigger": "preAttack" }, { "name": "bloodrage", "value": 3, "trigger": "passive" }] },
{ "attack": 8, "health": 19, "armor": 2, "delay": 3, "skills": [{ "name": "drain", "value": 5, "trigger": "preAttack" }, { "name": "bloodrage", "value": 3, "trigger": "passive" }] },
{ "attack": 9, "health": 22, "armor": 3, "delay": 3, "skills": [{ "name": "drain", "value": 6, "trigger": "preAttack" }, { "name": "bloodrage", "value": 4, "trigger": "passive" }] },
{ "attack": 10, "health": 26, "armor": 3, "delay": 3, "skills": [{ "name": "drain", "value": 6, "trigger": "preAttack" }, { "name": "bloodrage", "value": 4, "trigger": "passive" }] },
{ "attack": 12, "health": 30, "armor": 4, "delay": 3, "skills": [{ "name": "drain", "value": 7, "trigger": "preAttack" }, { "name": "bloodrage", "value": 5, "trigger": "passive" }] }
],
"flavorText": "The old blood remembers what the new blood forgets.",
"artKey": "bt_hemomancer_1"
},
{
"id": "bt_crimsonguard_1",
"name": "Crimson Guard",
"faction": "bloodthirsty",
"type": "assault",
"rarity": "epic",
"levels": [
{ "attack": 6, "health": 20, "armor": 2, "delay": 2, "skills": [{ "name": "siphon", "value": 3, "trigger": "preAttack" }, { "name": "counter", "value": 3, "trigger": "on_defend" }] },
{ "attack": 7, "health": 24, "armor": 2, "delay": 2, "skills": [{ "name": "siphon", "value": 3, "trigger": "preAttack" }, { "name": "counter", "value": 3, "trigger": "on_defend" }] },
{ "attack": 8, "health": 28, "armor": 3, "delay": 2, "skills": [{ "name": "siphon", "value": 4, "trigger": "preAttack" }, { "name": "counter", "value": 4, "trigger": "on_defend" }] },
{ "attack": 9, "health": 32, "armor": 3, "delay": 2, "skills": [{ "name": "siphon", "value": 4, "trigger": "preAttack" }, { "name": "counter", "value": 4, "trigger": "on_defend" }] },
{ "attack": 11, "health": 36, "armor": 4, "delay": 2, "skills": [{ "name": "siphon", "value": 5, "trigger": "preAttack" }, { "name": "counter", "value": 5, "trigger": "on_defend" }] }
],
"flavorText": "Its shield is painted red. So is everything behind it.",
"artKey": "bt_crimsonguard_1"
},
{
"id": "bt_archon_1",
"name": "Archon of the Blood Tide",
"faction": "bloodthirsty",
"type": "assault",
"rarity": "legendary",
"levels": [
{ "attack": 8, "health": 20, "armor": 3, "delay": 3, "skills": [{ "name": "bloodpact", "value": 5, "trigger": "preBattle" }, { "name": "drain", "value": 5, "trigger": "preAttack" }] },
{ "attack": 9, "health": 24, "armor": 3, "delay": 3, "skills": [{ "name": "bloodpact", "value": 5, "trigger": "preBattle" }, { "name": "drain", "value": 5, "trigger": "preAttack" }] },
{ "attack": 10, "health": 28, "armor": 4, "delay": 3, "skills": [{ "name": "bloodpact", "value": 6, "trigger": "preBattle" }, { "name": "drain", "value": 6, "trigger": "preAttack" }] },
{ "attack": 12, "health": 33, "armor": 4, "delay": 3, "skills": [{ "name": "bloodpact", "value": 7, "trigger": "preBattle" }, { "name": "drain", "value": 7, "trigger": "preAttack" }] },
{ "attack": 14, "health": 38, "armor": 5, "delay": 3, "skills": [{ "name": "bloodpact", "value": 8, "trigger": "preBattle" }, { "name": "drain", "value": 8, "trigger": "preAttack" }] }
],
"flavorText": "The tide rises. The tide claims all.",
"artKey": "bt_archon_1"
}
]

View File

@ -134,5 +134,230 @@
]
},
"lore": "The Reaver is here. The Khan is sending a message written in Imperial blood."
},
{
"id": "mission_006",
"name": "Awakening",
"chapter": 2,
"campaignId": "campaign_bloodthirsty",
"enemyLevel": 1,
"description": "The first blood thralls emerged at dusk, mindless but ravenous. Something ancient calls to them from below.",
"unlockCondition": null,
"opponent": {
"commander": "bt_cmd_1",
"cards": [
"bt_thrall_1",
"bt_thrall_1",
"bt_leech_1",
"bt_leech_1",
"bt_initiate_1",
"bt_ghoul_1",
"bt_harbinger_1",
"bt_revenant_1"
]
},
"rewards": {
"gold": 150,
"cards": [
"bt_thrall_1"
]
},
"lore": "The first blood thralls emerged at dusk, mindless but ravenous. Something ancient calls to them from below."
},
{
"id": "mission_007",
"name": "The Crimson Well",
"chapter": 2,
"campaignId": "campaign_bloodthirsty",
"enemyLevel": 1,
"description": "Villagers speak of figures in crimson robes drawing water at midnight. The water tastes of iron.",
"unlockCondition": "mission_006",
"opponent": {
"commander": "bt_cmd_1",
"cards": [
"bt_thrall_1",
"bt_thrall_1",
"bt_leech_1",
"bt_leech_1",
"bt_initiate_1",
"bt_initiate_1",
"bt_ghoul_1"
]
},
"rewards": {
"gold": 200,
"cards": [
"bt_leech_1"
]
},
"lore": "Villagers speak of figures in crimson robes drawing water at midnight. The water tastes of iron."
},
{
"id": "mission_008",
"name": "Night Raid",
"chapter": 2,
"campaignId": "campaign_bloodthirsty",
"enemyLevel": 1,
"description": "They came with the fog, silent as death. Our sentries never raised the alarm.",
"unlockCondition": "mission_007",
"opponent": {
"commander": "bt_cmd_1",
"cards": [
"bt_thrall_1",
"bt_thrall_1",
"bt_initiate_1",
"bt_initiate_1",
"bt_ghoul_1",
"bt_ghoul_1",
"bt_revenant_1"
]
},
"rewards": {
"gold": 250,
"cards": [
"bt_initiate_1"
]
},
"lore": "They came with the fog, silent as death. Our sentries never raised the alarm."
},
{
"id": "mission_009",
"name": "The Binding",
"chapter": 2,
"campaignId": "campaign_bloodthirsty",
"enemyLevel": 1,
"description": "The ritual circles were drawn in fresh blood. Whatever they're summoning, it hungers.",
"unlockCondition": "mission_008",
"opponent": {
"commander": "bt_cmd_1",
"cards": [
"bt_leech_1",
"bt_leech_1",
"bt_initiate_1",
"bt_initiate_1",
"bt_revenant_1",
"bt_bloodmage_1",
"bt_harbinger_1"
]
},
"rewards": {
"gold": 300,
"cards": [
"bt_ghoul_1"
]
},
"lore": "The ritual circles were drawn in fresh blood. Whatever they're summoning, it hungers."
},
{
"id": "mission_010",
"name": "Crimson Fortress",
"chapter": 2,
"campaignId": "campaign_bloodthirsty",
"enemyLevel": 1,
"description": "The walls drip crimson. The moat runs red. Every stone pulses with dark vitality.",
"unlockCondition": "mission_009",
"opponent": {
"commander": "bt_cmd_1",
"cards": [
"bt_thrall_1",
"bt_ghoul_1",
"bt_ghoul_1",
"bt_revenant_1",
"bt_bloodmage_1",
"bt_harbinger_1",
"bt_vampire_1"
]
},
"rewards": {
"gold": 350,
"cards": [
"bt_revenant_1"
]
},
"lore": "The walls drip crimson. The moat runs red. Every stone pulses with dark vitality."
},
{
"id": "mission_011",
"name": "Blood Moon Rising",
"chapter": 2,
"campaignId": "campaign_bloodthirsty",
"enemyLevel": 1,
"description": "Under the crimson moon, the blood magic intensifies tenfold. Even the dead seem to stir.",
"unlockCondition": "mission_010",
"opponent": {
"commander": "bt_cmd_1",
"cards": [
"bt_initiate_1",
"bt_ghoul_1",
"bt_revenant_1",
"bt_harbinger_1",
"bt_vampire_1",
"bt_bloodlord_1",
"bt_crimsonguard_1"
]
},
"rewards": {
"gold": 400,
"cards": [
"bt_bloodmage_1"
]
},
"lore": "Under the crimson moon, the blood magic intensifies tenfold. Even the dead seem to stir."
},
{
"id": "mission_012",
"name": "The Hemomancer's Sanctum",
"chapter": 2,
"campaignId": "campaign_bloodthirsty",
"enemyLevel": 1,
"description": "The sanctum walls breathe. The floor is slick with ancient blood. The Hemomancer has waited centuries for this moment.",
"unlockCondition": "mission_011",
"opponent": {
"commander": "bt_cmd_1",
"cards": [
"bt_ghoul_1",
"bt_revenant_1",
"bt_bloodmage_1",
"bt_vampire_1",
"bt_bloodlord_1",
"bt_hemomancer_1",
"bt_crimsonguard_1"
]
},
"rewards": {
"gold": 450,
"cards": [
"bt_harbinger_1"
]
},
"lore": "The sanctum walls breathe. The floor is slick with ancient blood. The Hemomancer has waited centuries for this moment."
},
{
"id": "mission_013",
"name": "The Blood Tide",
"chapter": 2,
"campaignId": "campaign_bloodthirsty",
"enemyLevel": 1,
"description": "The tide rises. Every drop of blood spilled feeds the Countess. She is ancient, she is patient, and she has had enough of slumber.",
"unlockCondition": "mission_012",
"opponent": {
"commander": "bt_cmd_1",
"cards": [
"bt_revenant_1",
"bt_harbinger_1",
"bt_vampire_1",
"bt_bloodlord_1",
"bt_hemomancer_1",
"bt_crimsonguard_1",
"bt_archon_1"
]
},
"rewards": {
"gold": 500,
"cards": [
"bt_bloodlord_1"
]
},
"lore": "The tide rises. Every drop of blood spilled feeds the Countess. She is ancient, she is patient, and she has had enough of slumber."
}
]

View File

@ -140,5 +140,29 @@
"description": "Gain +value attack for each other ally of the same faction.",
"trigger": "passive",
"category": "offense"
},
{
"name": "siphon",
"description": "Before attacking: heal self for value HP (capped at max HP).",
"trigger": "preAttack",
"category": "support"
},
{
"name": "drain",
"description": "Before attacking: deal value damage to the opposing card or commander, ignoring armor. Heal self for the same amount.",
"trigger": "preAttack",
"category": "offense"
},
{
"name": "bloodpact",
"description": "Before battle: lose value HP, permanently gain +value ATK.",
"trigger": "preBattle",
"category": "offense"
},
{
"name": "bloodrage",
"description": "When below 50% HP, gain +value ATK (recalculated each attack).",
"trigger": "passive",
"category": "offense"
}
]

View File

@ -155,11 +155,16 @@ export class CombatEngine {
// Pre-attack passive/on_attack skills (flurry, valor, legion — pierce now fires in preAttack phase)
for (const s of attacker.skills) {
if (['flurry', 'valor', 'legion'].includes(s.name) &&
if (['flurry', 'valor', 'legion', 'bloodrage'].includes(s.name) &&
['on_attack', 'passive'].includes(s.trigger)) {
const passiveCtx = { ...context };
this.skillProcessor.process(s, attacker, target,
alliedLanes.filter(c => c.currentHP > 0),
enemyLanes.filter(c => c.currentHP > 0), context);
enemyLanes.filter(c => c.currentHP > 0), passiveCtx);
if (s.name === 'bloodrage' && passiveCtx.bloodrageGain) {
this.events.push({ type: 'bloodrage', card: attacker, gain: passiveCtx.bloodrageGain });
}
if (passiveCtx.extraAttacks) context.extraAttacks = passiveCtx.extraAttacks;
}
}
@ -349,6 +354,7 @@ export class CombatEngine {
this._rupturePhase();
this._jamPhase();
this._preBattlePhase();
this._deathCheck(); // bloodpact can self-kill cards
this._buildPendingAttacks();
this.playerGoesFirst = !this.playerGoesFirst; // flip initiative for next turn
return [...this.events];
@ -361,6 +367,7 @@ export class CombatEngine {
const protectFires = [];
const enfeebeFires = [];
const jamFires = [];
const bloodpactFires = [];
const liveAllies = allies.filter(c => c.currentHP > 0);
const liveEnemies = enemies.filter(c => c.currentHP > 0);
for (const card of cards) {
@ -401,9 +408,13 @@ export class CombatEngine {
this._log(`${card.name} jam suppresses ${t.card.name} skills by ${t.reductions.map(r => r.amount).join('/')}`);
}
}
if (s.name === 'bloodpact' && ctx.bloodpactCard) {
bloodpactFires.push({ skill: 'bloodpact', source: card, value: ctx.bloodpactValue });
this._log(`${card.name} bloodpact: -${ctx.bloodpactValue} HP, +${ctx.bloodpactValue} ATK`);
}
}
}
return { buffs, siegeFires, protectFires, enfeebeFires, jamFires };
return { buffs, siegeFires, protectFires, enfeebeFires, jamFires, bloodpactFires };
}
// Emit the 8-step pre-battle sequence and process preBattle skills.
@ -424,8 +435,8 @@ export class CombatEngine {
// Steps 3–4: commander offensive buffs
const firstCmdFires = this._collectPreBattleFires([firstCmd], firstAllies, otherAllies, otherCmd, [otherCmd]);
const otherCmdFires = this._collectPreBattleFires([otherCmd], otherAllies, firstAllies, firstCmd, [firstCmd]);
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, buffs: firstCmdFires.buffs, siegeFires: firstCmdFires.siegeFires, protectFires: firstCmdFires.protectFires, enfeebeFires: firstCmdFires.enfeebeFires, jamFires: firstCmdFires.jamFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, buffs: otherCmdFires.buffs, siegeFires: otherCmdFires.siegeFires, protectFires: otherCmdFires.protectFires, enfeebeFires: otherCmdFires.enfeebeFires, jamFires: otherCmdFires.jamFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, buffs: firstCmdFires.buffs, siegeFires: firstCmdFires.siegeFires, protectFires: firstCmdFires.protectFires, enfeebeFires: firstCmdFires.enfeebeFires, jamFires: firstCmdFires.jamFires, bloodpactFires: firstCmdFires.bloodpactFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, buffs: otherCmdFires.buffs, siegeFires: otherCmdFires.siegeFires, protectFires: otherCmdFires.protectFires, enfeebeFires: otherCmdFires.enfeebeFires, jamFires: otherCmdFires.jamFires, bloodpactFires: otherCmdFires.bloodpactFires });
// Steps 5–6: lane defensive buffs (placeholder)
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [] });
@ -434,8 +445,8 @@ export class CombatEngine {
// Steps 7–8: lane offensive buffs + siege fires + protect fires + enfeeble fires + jam fires
const firstLaneFires = this._collectPreBattleFires(firstLanes, firstAllies, otherAllies, otherCmd, otherLanes);
const otherLaneFires = this._collectPreBattleFires(otherLanes, otherAllies, firstAllies, firstCmd, firstLanes);
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: firstLaneFires.buffs, siegeFires: firstLaneFires.siegeFires, protectFires: firstLaneFires.protectFires, enfeebeFires: firstLaneFires.enfeebeFires, jamFires: firstLaneFires.jamFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: otherLaneFires.buffs, siegeFires: otherLaneFires.siegeFires, protectFires: otherLaneFires.protectFires, enfeebeFires: otherLaneFires.enfeebeFires, jamFires: otherLaneFires.jamFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: firstLaneFires.buffs, siegeFires: firstLaneFires.siegeFires, protectFires: firstLaneFires.protectFires, enfeebeFires: firstLaneFires.enfeebeFires, jamFires: firstLaneFires.jamFires, bloodpactFires: firstLaneFires.bloodpactFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: otherLaneFires.buffs, siegeFires: otherLaneFires.siegeFires, protectFires: otherLaneFires.protectFires, enfeebeFires: otherLaneFires.enfeebeFires, jamFires: otherLaneFires.jamFires, bloodpactFires: otherLaneFires.bloodpactFires });
}
// Build the ordered list of attacks for this turn (called by beginCommit).
@ -480,7 +491,7 @@ export class CombatEngine {
const liveAllies = pending.alliedLanes.filter(c => c.currentHP > 0);
for (const s of pending.attacker.skills) {
if (s.trigger !== 'preAttack') continue;
const ctx = { rng: this.rng, enemyCommander: pending.enemyCommander };
const ctx = { rng: this.rng, enemyCommander: pending.enemyCommander, enemyLaneCards: pending.enemyLanes };
this.skillProcessor.process(s, pending.attacker, pending.target, liveAllies, liveEnemies, ctx);
if (s.name === 'mortar' && ctx.mortarAllTargets) {
for (const t of ctx.mortarAllTargets) {
@ -539,6 +550,37 @@ export class CombatEngine {
preAttackFires.push({ skill: 'pierce', target: ctx.pierceTarget, amount: ctx.pierceAmount, armBefore, targetIsCommander });
this._log(`${pending.attacker.name} pierce reduces ${ctx.pierceTarget.name} ARM by ${ctx.pierceAmount}`);
}
if (s.name === 'drain' && ctx.drainTarget) {
const hpBefore = ctx.drainTarget.currentHP + ctx.drainDamage;
const targetIsCommander = ctx.drainTarget === pending.enemyCommander;
const secondaries = (ctx.drainSecondaries || []).map(sec => ({
target: sec.target,
damage: sec.damage,
hpBefore: sec.target.currentHP + sec.damage,
laneOffset: sec.laneOffset
}));
preAttackFires.push({
skill: 'drain', target: ctx.drainTarget, damage: ctx.drainDamage,
hpBefore, targetIsCommander, heal: ctx.drainHeal,
attacker: pending.attacker, secondaries
});
this._log(`${pending.attacker.name} drains ${ctx.drainTarget.name} for ${ctx.drainDamage}`);
if (!targetIsCommander && ctx.drainTarget.currentHP <= 0) {
this._log(`${ctx.drainTarget.name} is destroyed by drain`);
this.events.push({ type: 'death', card: ctx.drainTarget, side: pending.side === 'player' ? 'opponent' : 'player' });
}
for (const sec of secondaries) {
this._log(`${pending.attacker.name} drain splashes ${sec.target.name} for ${sec.damage}`);
if (sec.target.currentHP <= 0) {
this._log(`${sec.target.name} is destroyed by drain splash`);
this.events.push({ type: 'death', card: sec.target, side: pending.side === 'player' ? 'opponent' : 'player' });
}
}
}
if (s.name === 'siphon' && ctx.siphonHeal != null) {
preAttackFires.push({ skill: 'siphon', attacker: pending.attacker, target: pending.target, heal: ctx.siphonHeal });
this._log(`${pending.attacker.name} siphons ${ctx.siphonHeal} HP`);
}
}
// Emit preAttack event (inserted before any death events from above)

View File

@ -355,4 +355,66 @@ export class SkillProcessor {
const sameFaction = allies.filter(a => a.faction === attacker.faction && a !== attacker && a.currentHP > 0);
attacker.currentAttack += skill.value * sameFaction.length;
}
siphon(skill, attacker, defender, allies, enemies, context) {
const healAmount = Math.min(Math.ceil(skill.value * 1.5), attacker.health - attacker.currentHP);
attacker.currentHP += healAmount;
if (context) context.siphonHeal = healAmount;
}
drain(skill, attacker, defender, allies, enemies, context) {
const target = defender || context?.enemyCommander;
if (!target) return;
const primaryDmg = skill.value;
const secondaryDmg = Math.ceil(skill.value / 2);
let totalDrained = 0;
// Primary: deal full value ignoring armor
target.currentHP -= primaryDmg;
totalDrained += primaryDmg;
// Secondaries: left/right neighbors of the primary target in enemy lanes
const enemyLanes = context?.enemyLaneCards || [];
const idx = enemyLanes.indexOf(target);
const secondaries = [];
if (idx >= 0) {
for (const offset of [-1, 1]) {
const sIdx = idx + offset;
if (sIdx < 0 || sIdx >= enemyLanes.length) continue;
const sec = enemyLanes[sIdx];
if (!sec || sec.currentHP <= 0) continue;
sec.currentHP -= secondaryDmg;
totalDrained += secondaryDmg;
secondaries.push({ target: sec, damage: secondaryDmg, laneOffset: offset });
}
}
// Heal self for total drained (capped at max HP)
const healAmount = Math.min(totalDrained, attacker.health - attacker.currentHP);
if (healAmount > 0) attacker.currentHP += healAmount;
if (context) {
context.drainTarget = target;
context.drainDamage = primaryDmg;
context.drainSecondaries = secondaries;
context.drainHeal = healAmount > 0 ? healAmount : 0;
context.drainTotalDrained = totalDrained;
}
}
bloodpact(skill, attacker, defender, allies, enemies, context) {
attacker.currentHP -= skill.value;
attacker.currentAttack += skill.value;
if (context) {
context.bloodpactCard = attacker;
context.bloodpactValue = skill.value;
}
}
bloodrage(skill, attacker, defender, allies, enemies, context) {
if (attacker.currentHP <= Math.floor(attacker.health / 2)) {
attacker.currentAttack += skill.value;
if (context) context.bloodrageGain = skill.value;
}
}
}

View File

@ -174,15 +174,6 @@ export class CardObject extends Phaser.GameObjects.Container {
).setOrigin(1, 0.5);
this.add(dlyLabel);
// ── Rarity gem (top-right of image area) ──────────────────────────────────
const gemR = Math.max(3, Math.round(3.5 * scale));
const gem = this.scene.add.circle(
w / 2 - gemR - Math.round(3 * scale),
-h / 2 + bannerH + gemR + Math.round(3 * scale),
gemR, rarityColor
);
this.add(gem);
// ── Level badge (top-left of image area) ────────────────────────────────
if (this.cardData.level && this.cardData.level > 1) {
const lvBadge = this.scene.add.text(

View File

@ -385,6 +385,7 @@ export class BattleScene extends Phaser.Scene {
const events = this.engine.processNextAttack();
const preAttackEvent = events.find(e => e.type === 'preAttack');
const postAttackEvent = events.find(e => e.type === 'postAttack');
const bloodrageEvent = events.find(e => e.type === 'bloodrage');
// Group events into attack rounds — flurry produces multiple attack events.
// Each round carries its own attack, berserk, counter, and on-attack-all events.
@ -438,7 +439,13 @@ export class BattleScene extends Phaser.Scene {
});
};
if (roundIdx === 0) {
this._onPreAttackStep(preAttackEvent, runAttack);
this._onPreAttackStep(preAttackEvent, () => {
if (bloodrageEvent) {
this._animateBloodrageGain(bloodrageEvent, runAttack);
} else {
runAttack();
}
});
} else {
runAttack();
}
@ -707,6 +714,8 @@ export class BattleScene extends Phaser.Scene {
if (group.skill === 'mortar') this._animateMortarFire(event.attacker, group, cb);
else if (group.skill === 'strike') this._animateStrikeFire(event.attacker, group, cb);
else if (group.skill === 'pierce') this._animatePierceFire(event.attacker, group, cb);
else if (group.skill === 'drain') this._animateDrainFire(event.attacker, group, cb);
else if (group.skill === 'siphon') this._animateSiphonFire(event.attacker, group, cb);
else cb();
}
};
@ -1500,18 +1509,21 @@ export class BattleScene extends Phaser.Scene {
// Called once per preBattle step. Handles buff animations, siege fires, protect fires, then enfeeble fires.
_onPreBattleStep(event, onComplete) {
const hasBuffs = event.buffs?.length > 0;
const hasSiege = event.siegeFires?.length > 0;
const hasProtect = event.protectFires?.length > 0;
const hasEnfeeble = event.enfeebeFires?.length > 0;
const hasJam = event.jamFires?.length > 0;
const hasBuffs = event.buffs?.length > 0;
const hasSiege = event.siegeFires?.length > 0;
const hasProtect = event.protectFires?.length > 0;
const hasEnfeeble = event.enfeebeFires?.length > 0;
const hasJam = event.jamFires?.length > 0;
const hasBloodpact = event.bloodpactFires?.length > 0;
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam) {
this._processBuffAnimations(event.buffs || [], () => {
this._processSiegeFires(event.siegeFires || [], () => {
this._processProtectFires(event.protectFires || [], () => {
this._processEnfeebeFires(event.enfeebeFires || [], () => {
this._processJamFires(event.jamFires || [], onComplete);
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasBloodpact) {
this._processBloodpactFires(event.bloodpactFires || [], () => {
this._processBuffAnimations(event.buffs || [], () => {
this._processSiegeFires(event.siegeFires || [], () => {
this._processProtectFires(event.protectFires || [], () => {
this._processEnfeebeFires(event.enfeebeFires || [], () => {
this._processJamFires(event.jamFires || [], onComplete);
});
});
});
});
@ -1572,6 +1584,366 @@ export class BattleScene extends Phaser.Scene {
next(0);
}
_processBloodpactFires(fires, onComplete) {
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
this._animateBloodpactFire(fires[idx], () => next(idx + 1));
};
next(0);
}
_animateBloodpactFire(bloodpactFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(bloodpactFire.source.instanceId);
if (!sourceObj?.scene) { onComplete(); return; }
this.statusText.setText(`${bloodpactFire.source.name} blood pact: -${bloodpactFire.value} HP, +${bloodpactFire.value} ATK!`);
this.sound.play('sfx_damage', { volume: 0.6 });
// Red flash for HP loss
sourceObj.flash(0xff0000);
this.time.delayedCall(400, () => {
if (!sourceObj.scene) { onComplete(); return; }
sourceObj.refresh();
// Check if card died from bloodpact
if (bloodpactFire.source.currentHP <= 0) {
this._playCardDeath(sourceObj, bloodpactFire.source, onComplete);
} else {
// Show ATK gain animation
sourceObj.animateBerserkGain(bloodpactFire.value, onComplete);
}
});
}
// Drain animation: reuse strike missile pattern with red/purple tint, then heal self
// Drain animation:
// 1. Sprite 22 flies from attacker to primary target, HP loss on primary
// 2. 2/3-scale copies fly left/right to neighbors, HP loss on each
// 3. Green emitters fly from all affected cards back to attacker, heal + refresh
_animateDrainFire(attacker, drainFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(attacker.instanceId);
const targetObj = _lookup(drainFire.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
this.sound.play('sfx_drain', { volume: 0.8 });
// Correct target HP display to pre-drain value
if (targetObj.hpText && targetObj.scene) {
targetObj.hpText.setText(`${Math.max(0, drainFire.hpBefore)}`);
}
this.statusText.setText(`${attacker.name} drains ${drainFire.target.name}!`);
const BASE_SCALE = 160 / 460;
const SEC_SCALE = BASE_SCALE * (2 / 3);
// Place drain sprite (frame 22) on the attacking card
const drainSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 22)
.setScale(BASE_SCALE)
.setDepth(30);
// Fly to the primary target
this.tweens.add({
targets: drainSprite,
x: targetObj.x,
y: targetObj.y,
duration: 400,
ease: 'Cubic.easeIn',
onComplete: () => {
if (!targetObj.scene) {
if (drainSprite.scene) drainSprite.destroy();
onComplete();
return;
}
// Apply HP loss on primary target
const hpAfter = Math.max(0, drainFire.hpBefore - drainFire.damage);
if (drainFire.damage > 0) {
if (hpAfter > 0) this.sound.play('sfx_damage', { volume: 0.8 });
targetObj.animateHPLoss(drainFire.damage, null, drainFire.hpBefore, hpAfter);
}
// Collect all affected card objects for the heal-return phase
const affectedCards = [{ obj: targetObj, data: drainFire.target, damage: drainFire.damage, hpAfter }];
// Spawn secondary sprites to left/right neighbors
const secondaries = drainFire.secondaries || [];
let secondariesDone = 0;
const totalSecondaries = secondaries.filter(s => _lookup(s.target.instanceId)?.scene).length;
const onAllSecondariesLanded = () => {
// Brief pause, then fade primary sprite
this.time.delayedCall(300, () => {
if (drainSprite.scene) {
this.tweens.add({
targets: drainSprite,
alpha: 0, duration: 200, ease: 'Power2',
onComplete: () => { if (drainSprite.scene) drainSprite.destroy(); }
});
}
// Handle deaths, then send green emitters back
this._drainProcessDeaths(drainFire, affectedCards, () => {
this._drainHealReturn(sourceObj, attacker, affectedCards, drainFire.heal, onComplete);
});
});
};
if (totalSecondaries === 0) {
onAllSecondariesLanded();
} else {
for (const sec of secondaries) {
const secObj = _lookup(sec.target.instanceId);
if (!secObj?.scene) continue;
// Correct secondary HP display
if (secObj.hpText && secObj.scene) {
secObj.hpText.setText(`${Math.max(0, sec.hpBefore)}`);
}
// Spawn 2/3-scale copy at primary target position, fly to neighbor
const secSprite = this.add.sprite(targetObj.x, targetObj.y, 'attacks', 22)
.setScale(SEC_SCALE)
.setDepth(30);
this.tweens.add({
targets: secSprite,
x: secObj.x,
y: secObj.y,
duration: 300,
ease: 'Cubic.easeIn',
onComplete: () => {
// Fade out secondary sprite
this.tweens.add({
targets: secSprite,
alpha: 0, duration: 200, ease: 'Power2',
onComplete: () => { if (secSprite.scene) secSprite.destroy(); }
});
// HP loss on secondary
const secHpAfter = Math.max(0, sec.hpBefore - sec.damage);
if (sec.damage > 0 && secObj.scene) {
if (secHpAfter > 0) this.sound.play('sfx_damage', { volume: 0.6 });
secObj.animateHPLoss(sec.damage, null, sec.hpBefore, secHpAfter);
}
affectedCards.push({ obj: secObj, data: sec.target, damage: sec.damage, hpAfter: secHpAfter });
if (++secondariesDone >= totalSecondaries) {
onAllSecondariesLanded();
}
}
});
}
}
}
});
}
// Handle deaths for all cards hit by drain before the heal-return phase.
_drainProcessDeaths(drainFire, affectedCards, onComplete) {
let pending = 0;
let anyDeath = false;
for (const ac of affectedCards) {
if (ac.data.currentHP <= 0 && ac.obj.scene && !ac.obj.isCommander) {
anyDeath = true;
pending++;
this._playCardDeath(ac.obj, ac.data, () => {
if (--pending === 0) onComplete();
});
}
}
if (!anyDeath) onComplete();
}
// Send bright green emitters from each affected card back to the drain source, then heal.
_drainHealReturn(sourceObj, attacker, affectedCards, healAmount, onComplete) {
if (healAmount <= 0 || !sourceObj?.scene) { onComplete(); return; }
// Filter to cards still on scene (alive cards that were drained)
const sources = affectedCards.filter(ac => ac.obj?.scene && ac.damage > 0);
if (sources.length === 0) {
// Just refresh and finish
if (sourceObj.scene) sourceObj.refresh();
onComplete();
return;
}
let emittersReturned = 0;
const onAllReturned = () => {
// Green flash + HP refresh on the drain card
if (sourceObj.scene) {
sourceObj.flash(0x00ff00);
this.time.delayedCall(200, () => {
if (sourceObj.scene) sourceObj.refresh();
onComplete();
});
} else {
onComplete();
}
};
for (const ac of sources) {
const startX = ac.obj.x;
const startY = ac.obj.y;
const endX = sourceObj.x;
const endY = sourceObj.y;
// Green particle emitter that flies back to source
const emitter = this.add.particles(startX, startY, 'particle_dot', {
speed: { min: 10, max: 40 },
scale: { start: 0.7, end: 0 },
alpha: { start: 1, end: 0 },
lifespan: 300,
tint: [0x00ff00, 0x44ff44, 0x88ff88, 0x00cc00],
blendMode: 'ADD',
frequency: 20
}).setDepth(29);
const progress = { t: 0 };
this.tweens.add({
targets: progress,
t: 1,
duration: 500,
ease: 'Sine.inOut',
onUpdate: () => {
const sin = Math.sin(progress.t * Math.PI);
const px = startX + (endX - startX) * progress.t;
const py = startY + (endY - startY) * progress.t + (-120) * sin;
if (emitter.scene) emitter.setPosition(px, py);
},
onComplete: () => {
emitter.stop();
this.time.delayedCall(300, () => { if (emitter.scene) emitter.destroy(); });
if (++emittersReturned >= sources.length) {
onAllReturned();
}
}
});
}
}
// Bloodrage animation: red pulse on card + ATK gain text
// Siphon animation: sprite 21 flies from attacker to card across,
// stays ~500ms with glow + particles, then fades out. Heals attacker after.
_animateSiphonFire(attacker, siphonFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(attacker.instanceId);
const targetObj = siphonFire.target ? _lookup(siphonFire.target.instanceId) : null;
if (!sourceObj?.scene) { onComplete(); return; }
// If no target across, skip animation but still refresh HP (heal already applied in engine)
if (!targetObj?.scene) {
if (siphonFire.heal > 0) {
sourceObj.flash(0x00ff00);
this.time.delayedCall(200, () => {
if (sourceObj.scene) sourceObj.refresh();
onComplete();
});
} else {
onComplete();
}
return;
}
this.sound.play('sfx_siphon', { volume: 0.8 });
this.statusText.setText(`${attacker.name} siphons +${siphonFire.heal} HP!`);
const BASE_SCALE = 160 / 460;
const siphonSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 21)
.setScale(BASE_SCALE * 0.6)
.setDepth(30);
// Blood-red glow
const glow = siphonSprite.postFX.addGlow(0xcc0000, 6, 0);
this.tweens.add({
targets: glow,
outerStrength: 14,
duration: 250,
yoyo: true,
repeat: -1,
ease: 'Sine.inOut'
});
// Bright red particle emitter tracking the sprite
const emitter = this.add.particles(siphonSprite.x, siphonSprite.y, 'particle_dot', {
speed: { min: 20, max: 70 },
scale: { start: 0.6, end: 0 },
alpha: { start: 1, end: 0 },
lifespan: 350,
tint: [0xff2222, 0xff4444, 0xff0000, 0xff6666],
blendMode: 'ADD',
frequency: 30
}).setDepth(29);
// Fly from attacker to the card directly across
this.tweens.add({
targets: siphonSprite,
x: targetObj.x,
y: targetObj.y,
scaleX: BASE_SCALE,
scaleY: BASE_SCALE,
duration: 320,
ease: 'Cubic.easeIn',
onUpdate: () => {
if (emitter.scene) emitter.setPosition(siphonSprite.x, siphonSprite.y);
},
onComplete: () => {
if (!siphonSprite.scene) {
if (emitter.scene) emitter.destroy();
onComplete();
return;
}
// Hold on target for ~500ms with pulsing glow
emitter.stop();
this.time.delayedCall(300, () => { if (emitter.scene) emitter.destroy(); });
this.time.delayedCall(500, () => {
if (!siphonSprite.scene) { onComplete(); return; }
// Fade out the sprite
this.tweens.add({
targets: siphonSprite,
alpha: 0,
scaleX: BASE_SCALE * 1.3,
scaleY: BASE_SCALE * 1.3,
duration: 300,
ease: 'Power2',
onComplete: () => {
if (siphonSprite.scene) siphonSprite.destroy();
// Refresh HP on the attacker (heal already applied)
if (sourceObj.scene) {
sourceObj.flash(0x00ff00);
this.time.delayedCall(200, () => {
if (sourceObj.scene) sourceObj.refresh();
onComplete();
});
} else {
onComplete();
}
}
});
});
}
});
}
_animateBloodrageGain(bloodrageEvent, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const cardObj = _lookup(bloodrageEvent.card.instanceId);
if (!cardObj?.scene) { onComplete(); return; }
this.statusText.setText(`${bloodrageEvent.card.name} enters a blood rage! +${bloodrageEvent.gain} ATK`);
cardObj.flash(0xff0000);
// Show ATK gain using berserk animation
cardObj.atkText.setText(`${bloodrageEvent.card.currentAttack - bloodrageEvent.gain}`);
cardObj.animateBerserkGain(bloodrageEvent.gain, () => {
onComplete();
});
}
_processBuffAnimations(buffs, onComplete) {
// Group consecutive "all" rally buffs for parallel animation
const groups = [];

View File

@ -30,6 +30,8 @@ export class BootScene extends Phaser.Scene {
this.load.audio('sfx_counter', 'assets/audio/fx/counter.mp3');
this.load.audio('sfx_enfeeble', 'assets/audio/fx/enfeeble.mp3');
this.load.audio('sfx_jam', 'assets/audio/fx/jam.mp3');
this.load.audio('sfx_siphon', 'assets/audio/fx/siphon.mp3');
this.load.audio('sfx_drain', 'assets/audio/fx/drain.mp3');
this.load.audio('sfx_swipe_01', 'assets/audio/fx/swipe_01.mp3');
this.load.audio('sfx_swipe_02', 'assets/audio/fx/swipe_02.mp3');
this.load.audio('sfx_menu_select', 'assets/audio/fx/menu_select.mp3');

View File

@ -100,7 +100,7 @@ export class CampaignScene extends Phaser.Scene {
mission,
deck: save.decks[0],
playerLevel: save.level || 1,
enemyLevel: mission.enemyLevel || 1,
enemyLevel: save.level || 1,
campaignId: this.campaignId
});
}