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
|
|
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 651 KiB |
|
After Width: | Height: | Size: 598 KiB |
|
After Width: | Height: | Size: 647 KiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 626 KiB |
|
After Width: | Height: | Size: 559 KiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 709 KiB |
|
After Width: | Height: | Size: 552 KiB |
|
After Width: | Height: | Size: 604 KiB |
|
After Width: | Height: | Size: 611 KiB |
|
After Width: | Height: | Size: 526 KiB |
|
After Width: | Height: | Size: 590 KiB |
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 4.4 MiB |
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
208
data/cards.json
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
|
|||