feat: implement card leveling system and campaign progression

Adds a 5-level scaling system to all cards with progressively increasing stats and skill values. Introduces a campaign structure with unlock conditions, level requirements, and completion rewards that grant new cards and player level-ups.

Key changes:
- Refactored `data/cards.json` to use a `levels` array for stat progression
- Added `data/campaigns.json` defining 5 faction campaigns with mission lists and rewards
- Updated `CardManager.createInstance()` to accept a level parameter and resolve stats accordingly
- Implemented save migration (v1→v2) to track player level and unlocked campaigns
- Added `CampaignSelectScene` for browsing unlocked campaigns by faction/level
- Added `SkirmishSetupScene` allowing players to choose card levels for skirmishes
- Enhanced combat engine to pass level data to both player and AI decks
- Implemented visual animations for new skills: `enfeeble` (ATK reduction) and `jam` (skill suppression) with floating text feedback
- Updated UI components (Collection, Deck Builder, Store, Fusion) to display Level 1 stats by default
This commit is contained in:
Brian Fertig 2026-03-14 13:00:16 -06:00
parent 850a32c33f
commit 313d2548d9
26 changed files with 1145 additions and 317 deletions

View File

@ -2,7 +2,8 @@
"permissions": {
"allow": [
"Bash(node:*)",
"Bash(wc -l /home/brianfertig/git/tyrants-edge/src/**/*.js)"
"Bash(wc -l /home/brianfertig/git/tyrants-edge/src/**/*.js)",
"Bash(grep:*)"
]
}
}

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 3.6 MiB

66
data/campaigns.json Normal file
View File

@ -0,0 +1,66 @@
[
{
"id": "campaign_raider",
"name": "Raider Uprising",
"faction": "raider",
"level": 1,
"requiredLevel": 1,
"unlockCondition": null,
"missions": ["mission_001", "mission_002", "mission_003", "mission_004", "mission_005"],
"starterReward": {
"cards": [
"raider_grunt_1", "raider_grunt_1", "raider_grunt_1",
"raider_scout_1", "raider_scout_1", "raider_scout_1",
"raider_pillager_1", "raider_pillager_1", "raider_pillager_1"
]
}
},
{
"id": "campaign_bloodthirsty",
"name": "Blood Tide",
"faction": "bloodthirsty",
"level": 2,
"requiredLevel": 2,
"unlockCondition": "campaign_raider",
"missions": [],
"starterReward": {
"cards": []
}
},
{
"id": "campaign_xeno",
"name": "Xeno Incursion",
"faction": "xeno",
"level": 3,
"requiredLevel": 3,
"unlockCondition": "campaign_bloodthirsty",
"missions": [],
"starterReward": {
"cards": []
}
},
{
"id": "campaign_righteous",
"name": "Righteous Crusade",
"faction": "righteous",
"level": 4,
"requiredLevel": 4,
"unlockCondition": "campaign_xeno",
"missions": [],
"starterReward": {
"cards": []
}
},
{
"id": "campaign_imperial",
"name": "Imperial Reckoning",
"faction": "imperial",
"level": 5,
"requiredLevel": 5,
"unlockCondition": "campaign_righteous",
"missions": [],
"starterReward": {
"cards": []
}
}
]

View File

@ -5,16 +5,12 @@
"faction": "imperial",
"type": "commander",
"rarity": "legendary",
"attack": 5,
"health": 60,
"armor": 2,
"delay": 0,
"skills": [
{
"name": "rally",
"value": 2,
"trigger": "preBattle"
}
"levels": [
{ "attack": 5, "health": 60, "armor": 2, "delay": 0, "skills": [{ "name": "rally", "value": 2, "trigger": "preBattle" }] },
{ "attack": 6, "health": 70, "armor": 2, "delay": 0, "skills": [{ "name": "rally", "value": 3, "trigger": "preBattle" }] },
{ "attack": 7, "health": 80, "armor": 3, "delay": 0, "skills": [{ "name": "rally", "value": 3, "trigger": "preBattle" }] },
{ "attack": 8, "health": 95, "armor": 3, "delay": 0, "skills": [{ "name": "rally", "value": 4, "trigger": "preBattle" }] },
{ "attack": 10, "health": 110, "armor": 4, "delay": 0, "skills": [{ "name": "rally", "value": 5, "trigger": "preBattle" }] }
],
"flavorText": "Iron discipline forged an empire from ash.",
"artKey": "imp_cmd_1"
@ -25,16 +21,12 @@
"faction": "imperial",
"type": "assault",
"rarity": "common",
"attack": 3,
"health": 13,
"armor": 0,
"delay": 1,
"skills": [
{
"name": "strike",
"value": 1,
"trigger": "preAttack"
}
"levels": [
{ "attack": 3, "health": 13, "armor": 0, "delay": 1, "skills": [{ "name": "strike", "value": 1, "trigger": "preAttack" }] },
{ "attack": 4, "health": 15, "armor": 0, "delay": 1, "skills": [{ "name": "strike", "value": 1, "trigger": "preAttack" }] },
{ "attack": 5, "health": 18, "armor": 0, "delay": 1, "skills": [{ "name": "strike", "value": 2, "trigger": "preAttack" }] },
{ "attack": 6, "health": 21, "armor": 1, "delay": 1, "skills": [{ "name": "strike", "value": 2, "trigger": "preAttack" }] },
{ "attack": 7, "health": 25, "armor": 1, "delay": 1, "skills": [{ "name": "strike", "value": 3, "trigger": "preAttack" }] }
],
"flavorText": "The backbone of the Imperial war machine.",
"artKey": "imp_trooper_1"
@ -45,16 +37,12 @@
"faction": "imperial",
"type": "assault",
"rarity": "common",
"attack": 4,
"health": 10,
"armor": 0,
"delay": 1,
"skills": [
{
"name": "pierce",
"value": 1,
"trigger": "preAttack"
}
"levels": [
{ "attack": 4, "health": 10, "armor": 0, "delay": 1, "skills": [{ "name": "pierce", "value": 1, "trigger": "preAttack" }] },
{ "attack": 5, "health": 12, "armor": 0, "delay": 1, "skills": [{ "name": "pierce", "value": 2, "trigger": "preAttack" }] },
{ "attack": 6, "health": 14, "armor": 0, "delay": 1, "skills": [{ "name": "pierce", "value": 2, "trigger": "preAttack" }] },
{ "attack": 7, "health": 16, "armor": 1, "delay": 1, "skills": [{ "name": "pierce", "value": 3, "trigger": "preAttack" }] },
{ "attack": 8, "health": 19, "armor": 1, "delay": 1, "skills": [{ "name": "pierce", "value": 3, "trigger": "preAttack" }] }
],
"flavorText": "Precision fire, no mercy.",
"artKey": "imp_gunner_1"
@ -65,16 +53,12 @@
"faction": "imperial",
"type": "assault",
"rarity": "rare",
"attack": 2,
"health": 20,
"armor": 3,
"delay": 1,
"skills": [
{
"name": "protect",
"value": 2,
"trigger": "preBattle"
}
"levels": [
{ "attack": 2, "health": 20, "armor": 3, "delay": 1, "skills": [{ "name": "protect", "value": 2, "trigger": "preBattle" }] },
{ "attack": 3, "health": 24, "armor": 3, "delay": 1, "skills": [{ "name": "protect", "value": 2, "trigger": "preBattle" }] },
{ "attack": 3, "health": 28, "armor": 4, "delay": 1, "skills": [{ "name": "protect", "value": 3, "trigger": "preBattle" }] },
{ "attack": 4, "health": 33, "armor": 4, "delay": 1, "skills": [{ "name": "protect", "value": 3, "trigger": "preBattle" }] },
{ "attack": 5, "health": 38, "armor": 5, "delay": 1, "skills": [{ "name": "protect", "value": 4, "trigger": "preBattle" }] }
],
"flavorText": "Their shields have never broken. Never.",
"artKey": "imp_shieldwall_1"
@ -85,16 +69,12 @@
"faction": "imperial",
"type": "assault",
"rarity": "rare",
"attack": 7,
"health": 12,
"armor": 0,
"delay": 2,
"skills": [
{
"name": "pierce",
"value": 3,
"trigger": "preAttack"
}
"levels": [
{ "attack": 7, "health": 12, "armor": 0, "delay": 2, "skills": [{ "name": "pierce", "value": 3, "trigger": "preAttack" }] },
{ "attack": 8, "health": 14, "armor": 0, "delay": 2, "skills": [{ "name": "pierce", "value": 3, "trigger": "preAttack" }] },
{ "attack": 9, "health": 16, "armor": 0, "delay": 2, "skills": [{ "name": "pierce", "value": 4, "trigger": "preAttack" }] },
{ "attack": 10, "health": 19, "armor": 1, "delay": 2, "skills": [{ "name": "pierce", "value": 4, "trigger": "preAttack" }] },
{ "attack": 12, "health": 22, "armor": 1, "delay": 2, "skills": [{ "name": "pierce", "value": 5, "trigger": "preAttack" }] }
],
"flavorText": "One shot. One less problem.",
"artKey": "imp_sniper_1"
@ -105,21 +85,12 @@
"faction": "imperial",
"type": "assault",
"rarity": "epic",
"attack": 6,
"health": 16,
"armor": 2,
"delay": 2,
"skills": [
{
"name": "berserk",
"value": 1,
"trigger": "on_attack"
},
{
"name": "strike",
"value": 2,
"trigger": "preAttack"
}
"levels": [
{ "attack": 6, "health": 16, "armor": 2, "delay": 2, "skills": [{ "name": "berserk", "value": 1, "trigger": "on_attack" }, { "name": "strike", "value": 2, "trigger": "preAttack" }] },
{ "attack": 7, "health": 19, "armor": 2, "delay": 2, "skills": [{ "name": "berserk", "value": 1, "trigger": "on_attack" }, { "name": "strike", "value": 2, "trigger": "preAttack" }] },
{ "attack": 8, "health": 22, "armor": 3, "delay": 2, "skills": [{ "name": "berserk", "value": 2, "trigger": "on_attack" }, { "name": "strike", "value": 3, "trigger": "preAttack" }] },
{ "attack": 9, "health": 26, "armor": 3, "delay": 2, "skills": [{ "name": "berserk", "value": 2, "trigger": "on_attack" }, { "name": "strike", "value": 3, "trigger": "preAttack" }] },
{ "attack": 11, "health": 30, "armor": 4, "delay": 2, "skills": [{ "name": "berserk", "value": 2, "trigger": "on_attack" }, { "name": "strike", "value": 4, "trigger": "preAttack" }] }
],
"flavorText": "Built for war. Perfected through slaughter.",
"artKey": "imp_mech_1"
@ -130,16 +101,12 @@
"faction": "imperial",
"type": "assault",
"rarity": "rare",
"attack": 2,
"health": 6,
"armor": 0,
"delay": 0,
"skills": [
{
"name": "mortar",
"value": 6,
"trigger": "preAttack"
}
"levels": [
{ "attack": 2, "health": 6, "armor": 0, "delay": 0, "skills": [{ "name": "mortar", "value": 6, "trigger": "preAttack" }] },
{ "attack": 3, "health": 8, "armor": 0, "delay": 0, "skills": [{ "name": "mortar", "value": 7, "trigger": "preAttack" }] },
{ "attack": 3, "health": 10, "armor": 0, "delay": 0, "skills": [{ "name": "mortar", "value": 8, "trigger": "preAttack" }] },
{ "attack": 4, "health": 12, "armor": 1, "delay": 0, "skills": [{ "name": "mortar", "value": 10, "trigger": "preAttack" }] },
{ "attack": 5, "health": 15, "armor": 1, "delay": 0, "skills": [{ "name": "mortar", "value": 12, "trigger": "preAttack" }] }
],
"flavorText": "Indirect fire is still very much on fire.",
"artKey": "imp_mortar_1"
@ -150,16 +117,12 @@
"faction": "imperial",
"type": "assault",
"rarity": "common",
"attack": 3,
"health": 15,
"armor": 1,
"delay": 1,
"skills": [
{
"name": "counter",
"value": 2,
"trigger": "on_defend"
}
"levels": [
{ "attack": 3, "health": 15, "armor": 1, "delay": 1, "skills": [{ "name": "counter", "value": 2, "trigger": "on_defend" }] },
{ "attack": 4, "health": 18, "armor": 1, "delay": 1, "skills": [{ "name": "counter", "value": 2, "trigger": "on_defend" }] },
{ "attack": 4, "health": 21, "armor": 2, "delay": 1, "skills": [{ "name": "counter", "value": 3, "trigger": "on_defend" }] },
{ "attack": 5, "health": 25, "armor": 2, "delay": 1, "skills": [{ "name": "counter", "value": 3, "trigger": "on_defend" }] },
{ "attack": 6, "health": 29, "armor": 3, "delay": 1, "skills": [{ "name": "counter", "value": 4, "trigger": "on_defend" }] }
],
"flavorText": "They who stand firm turn every blade aside.",
"artKey": "imp_guardian_1"
@ -170,16 +133,12 @@
"faction": "imperial",
"type": "assault",
"rarity": "epic",
"attack": 7,
"health": 20,
"armor": 3,
"delay": 3,
"skills": [
{
"name": "swipe",
"value": 2,
"trigger": "on_attack"
}
"levels": [
{ "attack": 7, "health": 20, "armor": 3, "delay": 3, "skills": [{ "name": "swipe", "value": 2, "trigger": "on_attack" }] },
{ "attack": 8, "health": 24, "armor": 3, "delay": 3, "skills": [{ "name": "swipe", "value": 2, "trigger": "on_attack" }] },
{ "attack": 9, "health": 28, "armor": 4, "delay": 3, "skills": [{ "name": "swipe", "value": 3, "trigger": "on_attack" }] },
{ "attack": 10, "health": 33, "armor": 4, "delay": 3, "skills": [{ "name": "swipe", "value": 3, "trigger": "on_attack" }] },
{ "attack": 12, "health": 38, "armor": 5, "delay": 3, "skills": [{ "name": "swipe", "value": 4, "trigger": "on_attack" }] }
],
"flavorText": "Nothing stops it. Nothing.",
"artKey": "imp_juggernaut_1"
@ -190,21 +149,12 @@
"faction": "imperial",
"type": "assault",
"rarity": "legendary",
"attack": 8,
"health": 22,
"armor": 4,
"delay": 3,
"skills": [
{
"name": "strike",
"value": 4,
"trigger": "preAttack"
},
{
"name": "armored",
"value": 2,
"trigger": "passive"
}
"levels": [
{ "attack": 8, "health": 22, "armor": 4, "delay": 3, "skills": [{ "name": "strike", "value": 4, "trigger": "preAttack" }, { "name": "armored", "value": 2, "trigger": "passive" }] },
{ "attack": 9, "health": 26, "armor": 4, "delay": 3, "skills": [{ "name": "strike", "value": 4, "trigger": "preAttack" }, { "name": "armored", "value": 2, "trigger": "passive" }] },
{ "attack": 10, "health": 30, "armor": 5, "delay": 3, "skills": [{ "name": "strike", "value": 5, "trigger": "preAttack" }, { "name": "armored", "value": 3, "trigger": "passive" }] },
{ "attack": 12, "health": 35, "armor": 5, "delay": 3, "skills": [{ "name": "strike", "value": 6, "trigger": "preAttack" }, { "name": "armored", "value": 3, "trigger": "passive" }] },
{ "attack": 14, "health": 40, "armor": 6, "delay": 3, "skills": [{ "name": "strike", "value": 7, "trigger": "preAttack" }, { "name": "armored", "value": 4, "trigger": "passive" }] }
],
"flavorText": "The last line of defense. And they have never fallen.",
"artKey": "imp_sentinel_1"
@ -215,16 +165,12 @@
"faction": "raider",
"type": "commander",
"rarity": "legendary",
"attack": 6,
"health": 50,
"armor": 1,
"delay": 0,
"skills": [
{
"name": "siege",
"value": 3,
"trigger": "preBattle"
}
"levels": [
{ "attack": 6, "health": 50, "armor": 1, "delay": 0, "skills": [{ "name": "siege", "value": 3, "trigger": "preBattle" }] },
{ "attack": 7, "health": 60, "armor": 1, "delay": 0, "skills": [{ "name": "siege", "value": 4, "trigger": "preBattle" }] },
{ "attack": 8, "health": 70, "armor": 2, "delay": 0, "skills": [{ "name": "siege", "value": 4, "trigger": "preBattle" }] },
{ "attack": 9, "health": 85, "armor": 2, "delay": 0, "skills": [{ "name": "siege", "value": 5, "trigger": "preBattle" }] },
{ "attack": 11, "health": 100, "armor": 3, "delay": 0, "skills": [{ "name": "siege", "value": 6, "trigger": "preBattle" }] }
],
"flavorText": "The Khan takes what he wants. Everything else burns.",
"artKey": "raider_cmd_1"
@ -235,16 +181,12 @@
"faction": "raider",
"type": "assault",
"rarity": "common",
"attack": 4,
"health": 12,
"armor": 0,
"delay": 1,
"skills": [
{
"name": "berserk",
"value": 1,
"trigger": "on_attack"
}
"levels": [
{ "attack": 4, "health": 12, "armor": 0, "delay": 1, "skills": [{ "name": "berserk", "value": 1, "trigger": "on_attack" }] },
{ "attack": 5, "health": 14, "armor": 0, "delay": 1, "skills": [{ "name": "berserk", "value": 1, "trigger": "on_attack" }] },
{ "attack": 6, "health": 17, "armor": 0, "delay": 1, "skills": [{ "name": "berserk", "value": 1, "trigger": "on_attack" }] },
{ "attack": 7, "health": 20, "armor": 1, "delay": 1, "skills": [{ "name": "berserk", "value": 2, "trigger": "on_attack" }] },
{ "attack": 8, "health": 23, "armor": 1, "delay": 1, "skills": [{ "name": "berserk", "value": 2, "trigger": "on_attack" }] }
],
"flavorText": "Cheap to field. Costly to face.",
"artKey": "raider_grunt_1"
@ -255,16 +197,12 @@
"faction": "raider",
"type": "assault",
"rarity": "common",
"attack": 3,
"health": 11,
"armor": 0,
"delay": 0,
"skills": [
{
"name": "jam",
"value": 1,
"trigger": "on_attack"
}
"levels": [
{ "attack": 3, "health": 11, "armor": 0, "delay": 0, "skills": [{ "name": "jam", "value": 1, "trigger": "preBattle" }] },
{ "attack": 4, "health": 13, "armor": 0, "delay": 0, "skills": [{ "name": "jam", "value": 1, "trigger": "preBattle" }] },
{ "attack": 5, "health": 15, "armor": 0, "delay": 0, "skills": [{ "name": "jam", "value": 1, "trigger": "preBattle" }] },
{ "attack": 5, "health": 18, "armor": 1, "delay": 0, "skills": [{ "name": "jam", "value": 1, "trigger": "preBattle" }] },
{ "attack": 6, "health": 21, "armor": 1, "delay": 0, "skills": [{ "name": "jam", "value": 2, "trigger": "preBattle" }] }
],
"flavorText": "Fast, quiet, deadly. Usually in that order.",
"artKey": "raider_scout_1"
@ -275,21 +213,12 @@
"faction": "raider",
"type": "assault",
"rarity": "rare",
"attack": 6,
"health": 14,
"armor": 1,
"delay": 1,
"skills": [
{
"name": "berserk",
"value": 2,
"trigger": "on_attack"
},
{
"name": "strike",
"value": 2,
"trigger": "preAttack"
}
"levels": [
{ "attack": 6, "health": 14, "armor": 1, "delay": 1, "skills": [{ "name": "berserk", "value": 2, "trigger": "on_attack" }, { "name": "strike", "value": 2, "trigger": "preAttack" }] },
{ "attack": 7, "health": 17, "armor": 1, "delay": 1, "skills": [{ "name": "berserk", "value": 2, "trigger": "on_attack" }, { "name": "strike", "value": 2, "trigger": "preAttack" }] },
{ "attack": 8, "health": 20, "armor": 1, "delay": 1, "skills": [{ "name": "berserk", "value": 2, "trigger": "on_attack" }, { "name": "strike", "value": 3, "trigger": "preAttack" }] },
{ "attack": 9, "health": 23, "armor": 2, "delay": 1, "skills": [{ "name": "berserk", "value": 3, "trigger": "on_attack" }, { "name": "strike", "value": 3, "trigger": "preAttack" }] },
{ "attack": 11, "health": 27, "armor": 2, "delay": 1, "skills": [{ "name": "berserk", "value": 3, "trigger": "on_attack" }, { "name": "strike", "value": 4, "trigger": "preAttack" }] }
],
"flavorText": "Pain is just another fuel source.",
"artKey": "raider_berserker_1"
@ -300,16 +229,12 @@
"faction": "raider",
"type": "assault",
"rarity": "rare",
"attack": 4,
"health": 10,
"armor": 2,
"delay": 1,
"skills": [
{
"name": "enfeeble",
"value": 2,
"trigger": "on_attack"
}
"levels": [
{ "attack": 4, "health": 10, "armor": 2, "delay": 1, "skills": [{ "name": "enfeeble", "value": 2, "trigger": "preBattle" }] },
{ "attack": 5, "health": 12, "armor": 2, "delay": 1, "skills": [{ "name": "enfeeble", "value": 2, "trigger": "preBattle" }] },
{ "attack": 5, "health": 14, "armor": 3, "delay": 1, "skills": [{ "name": "enfeeble", "value": 3, "trigger": "preBattle" }] },
{ "attack": 6, "health": 17, "armor": 3, "delay": 1, "skills": [{ "name": "enfeeble", "value": 3, "trigger": "preBattle" }] },
{ "attack": 7, "health": 20, "armor": 4, "delay": 1, "skills": [{ "name": "enfeeble", "value": 4, "trigger": "preBattle" }] }
],
"flavorText": "They don't kill you. They just make sure you can't fight back.",
"artKey": "raider_cutthroat_1"
@ -320,16 +245,12 @@
"faction": "raider",
"type": "assault",
"rarity": "epic",
"attack": 6,
"health": 16,
"armor": 3,
"delay": 2,
"skills": [
{
"name": "rally",
"value": 3,
"trigger": "preBattle"
}
"levels": [
{ "attack": 6, "health": 16, "armor": 3, "delay": 2, "skills": [{ "name": "rally", "value": 3, "trigger": "preBattle" }] },
{ "attack": 7, "health": 19, "armor": 3, "delay": 2, "skills": [{ "name": "rally", "value": 3, "trigger": "preBattle" }] },
{ "attack": 8, "health": 22, "armor": 4, "delay": 2, "skills": [{ "name": "rally", "value": 4, "trigger": "preBattle" }] },
{ "attack": 9, "health": 26, "armor": 4, "delay": 2, "skills": [{ "name": "rally", "value": 4, "trigger": "preBattle" }] },
{ "attack": 11, "health": 30, "armor": 5, "delay": 2, "skills": [{ "name": "rally", "value": 5, "trigger": "preBattle" }] }
],
"flavorText": "Where he goes, the pack follows.",
"artKey": "raider_warlord_1"
@ -340,16 +261,12 @@
"faction": "raider",
"type": "assault",
"rarity": "common",
"attack": 3,
"health": 13,
"armor": 0,
"delay": 1,
"skills": [
{
"name": "siege",
"value": 2,
"trigger": "preBattle"
}
"levels": [
{ "attack": 3, "health": 13, "armor": 0, "delay": 1, "skills": [{ "name": "siege", "value": 2, "trigger": "preBattle" }] },
{ "attack": 4, "health": 15, "armor": 0, "delay": 1, "skills": [{ "name": "siege", "value": 2, "trigger": "preBattle" }] },
{ "attack": 5, "health": 18, "armor": 0, "delay": 1, "skills": [{ "name": "siege", "value": 3, "trigger": "preBattle" }] },
{ "attack": 5, "health": 21, "armor": 1, "delay": 1, "skills": [{ "name": "siege", "value": 3, "trigger": "preBattle" }] },
{ "attack": 6, "health": 25, "armor": 1, "delay": 1, "skills": [{ "name": "siege", "value": 4, "trigger": "preBattle" }] }
],
"flavorText": "Leave nothing standing. Take everything else.",
"artKey": "raider_pillager_1"
@ -360,16 +277,12 @@
"faction": "raider",
"type": "assault",
"rarity": "rare",
"attack": 5,
"health": 12,
"armor": 2,
"delay": 2,
"skills": [
{
"name": "swipe",
"value": 1,
"trigger": "on_attack"
}
"levels": [
{ "attack": 5, "health": 12, "armor": 2, "delay": 2, "skills": [{ "name": "swipe", "value": 1, "trigger": "on_attack" }] },
{ "attack": 6, "health": 14, "armor": 2, "delay": 2, "skills": [{ "name": "swipe", "value": 1, "trigger": "on_attack" }] },
{ "attack": 7, "health": 17, "armor": 2, "delay": 2, "skills": [{ "name": "swipe", "value": 2, "trigger": "on_attack" }] },
{ "attack": 8, "health": 20, "armor": 3, "delay": 2, "skills": [{ "name": "swipe", "value": 2, "trigger": "on_attack" }] },
{ "attack": 9, "health": 23, "armor": 3, "delay": 2, "skills": [{ "name": "swipe", "value": 3, "trigger": "on_attack" }] }
],
"flavorText": "Swift violence is an art form.",
"artKey": "raider_marauder_1"
@ -380,21 +293,12 @@
"faction": "raider",
"type": "assault",
"rarity": "epic",
"attack": 7,
"health": 16,
"armor": 2,
"delay": 2,
"skills": [
{
"name": "flurry",
"value": 1,
"trigger": "passive"
},
{
"name": "berserk",
"value": 2,
"trigger": "on_attack"
}
"levels": [
{ "attack": 7, "health": 16, "armor": 2, "delay": 2, "skills": [{ "name": "flurry", "value": 1, "trigger": "passive" }, { "name": "berserk", "value": 2, "trigger": "on_attack" }] },
{ "attack": 8, "health": 19, "armor": 2, "delay": 2, "skills": [{ "name": "flurry", "value": 1, "trigger": "passive" }, { "name": "berserk", "value": 2, "trigger": "on_attack" }] },
{ "attack": 9, "health": 22, "armor": 3, "delay": 2, "skills": [{ "name": "flurry", "value": 1, "trigger": "passive" }, { "name": "berserk", "value": 3, "trigger": "on_attack" }] },
{ "attack": 10, "health": 26, "armor": 3, "delay": 2, "skills": [{ "name": "flurry", "value": 1, "trigger": "passive" }, { "name": "berserk", "value": 3, "trigger": "on_attack" }] },
{ "attack": 12, "health": 30, "armor": 4, "delay": 2, "skills": [{ "name": "flurry", "value": 1, "trigger": "passive" }, { "name": "berserk", "value": 4, "trigger": "on_attack" }] }
],
"flavorText": "Every kill feeds the frenzy.",
"artKey": "raider_reaver_1"
@ -405,21 +309,12 @@
"faction": "raider",
"type": "assault",
"rarity": "legendary",
"attack": 8,
"health": 18,
"armor": 3,
"delay": 3,
"skills": [
{
"name": "siege",
"value": 5,
"trigger": "preBattle"
},
{
"name": "strike",
"value": 3,
"trigger": "preAttack"
}
"levels": [
{ "attack": 8, "health": 18, "armor": 3, "delay": 3, "skills": [{ "name": "siege", "value": 5, "trigger": "preBattle" }, { "name": "strike", "value": 3, "trigger": "preAttack" }] },
{ "attack": 9, "health": 21, "armor": 3, "delay": 3, "skills": [{ "name": "siege", "value": 5, "trigger": "preBattle" }, { "name": "strike", "value": 3, "trigger": "preAttack" }] },
{ "attack": 10, "health": 25, "armor": 4, "delay": 3, "skills": [{ "name": "siege", "value": 6, "trigger": "preBattle" }, { "name": "strike", "value": 4, "trigger": "preAttack" }] },
{ "attack": 12, "health": 29, "armor": 4, "delay": 3, "skills": [{ "name": "siege", "value": 7, "trigger": "preBattle" }, { "name": "strike", "value": 5, "trigger": "preAttack" }] },
{ "attack": 14, "health": 34, "armor": 5, "delay": 3, "skills": [{ "name": "siege", "value": 8, "trigger": "preBattle" }, { "name": "strike", "value": 6, "trigger": "preAttack" }] }
],
"flavorText": "The Overlord doesn't negotiate. He dictates terms from the rubble.",
"artKey": "raider_overlord_1"

View File

@ -3,6 +3,8 @@
"id": "mission_001",
"name": "First Contact",
"chapter": 1,
"campaignId": "campaign_raider",
"enemyLevel": 1,
"description": "Raiders have been spotted near the frontier. Drive them back.",
"unlockCondition": null,
"opponent": {
@ -17,7 +19,9 @@
},
"rewards": {
"gold": 100,
"cards": ["raider_grunt_1"]
"cards": [
"raider_grunt_1"
]
},
"lore": "The first skirmish on the eastern frontier. The Raiders test our resolve."
},
@ -25,6 +29,8 @@
"id": "mission_002",
"name": "Frontier Skirmish",
"chapter": 1,
"campaignId": "campaign_raider",
"enemyLevel": 1,
"description": "The Raiders return in greater numbers. Hold the line!",
"unlockCondition": "mission_001",
"opponent": {
@ -34,12 +40,16 @@
"raider_grunt_1",
"raider_scout_1",
"raider_scout_1",
"raider_berserker_1"
"raider_pillager_1",
"raider_pillager_1",
"raider_cutthroat_1"
]
},
"rewards": {
"gold": 150,
"cards": ["imp_trooper_1"]
"cards": [
"imp_trooper_1"
]
},
"lore": "A berserker leads the charge this time. Our lines bend but do not break."
},
@ -47,6 +57,8 @@
"id": "mission_003",
"name": "The Breach",
"chapter": 1,
"campaignId": "campaign_raider",
"enemyLevel": 1,
"description": "Raiders have breached the outer wall. Repel the invasion!",
"unlockCondition": "mission_002",
"opponent": {
@ -54,14 +66,18 @@
"cards": [
"raider_grunt_1",
"raider_grunt_1",
"raider_berserker_1",
"raider_pillager_1",
"raider_pillager_1",
"raider_scout_1",
"raider_cutthroat_1",
"raider_warlord_1"
"raider_berserker_1"
]
},
"rewards": {
"gold": 200,
"cards": ["imp_gunner_1"]
"cards": [
"imp_gunner_1"
]
},
"lore": "A Raider Warlord coordinates the assault. This is no mere raid."
},
@ -69,22 +85,26 @@
"id": "mission_004",
"name": "Ambush!",
"chapter": 1,
"campaignId": "campaign_raider",
"enemyLevel": 1,
"description": "An Imperial patrol walks into a deadly trap. Fight your way out!",
"unlockCondition": "mission_003",
"opponent": {
"commander": "raider_cmd_1",
"cards": [
"raider_grunt_1",
"raider_berserker_1",
"raider_pillager_1",
"raider_berserker_1",
"raider_cutthroat_1",
"raider_marauder_1",
"raider_pillager_1"
"raider_scout_1"
]
},
"rewards": {
"gold": 250,
"cards": ["imp_shieldwall_1"]
"cards": [
"imp_shieldwall_1"
]
},
"lore": "They knew we were coming. Someone talked. Survive first, investigate later."
},
@ -92,6 +112,8 @@
"id": "mission_005",
"name": "Khan's Vanguard",
"chapter": 1,
"campaignId": "campaign_raider",
"enemyLevel": 1,
"description": "The Khan's elite vanguard pushes deep into Imperial territory. Stop them at all costs!",
"unlockCondition": "mission_004",
"opponent": {
@ -107,8 +129,10 @@
},
"rewards": {
"gold": 300,
"cards": ["raider_berserker_1"]
"cards": [
"raider_berserker_1"
]
},
"lore": "The Reaver is here. The Khan is sending a message written in Imperial blood."
}
]
]

View File

@ -1,12 +1,12 @@
import { RNG } from '../utils/RNG.js';
export class CombatAI {
constructor(deck, cardManager, seed) {
constructor(deck, cardManager, seed, level = 1) {
this.cardManager = cardManager;
this.rng = new RNG(seed || Date.now());
// Create instances
this.commander = cardManager.createInstance(deck.commander);
this.deckCards = deck.cards.map(id => cardManager.createInstance(id));
this.commander = cardManager.createInstance(deck.commander, level);
this.deckCards = deck.cards.map(id => cardManager.createInstance(id, level));
this.rng.shuffle(this.deckCards);
this.deckIndex = 0;
this.hand = [];

View File

@ -6,21 +6,21 @@ const MAX_LANES = 4;
const MAX_HAND = 3;
export class CombatEngine {
constructor(playerDeck, opponentDeck, cardManager, seed) {
constructor(playerDeck, opponentDeck, cardManager, seed, playerLevel = 1, enemyLevel = 1) {
this.cardManager = cardManager;
this.skillProcessor = new SkillProcessor();
this.rng = new RNG(seed || Date.now());
// Player side
this.playerCommander = cardManager.createInstance(playerDeck.commander);
this.playerDeck = playerDeck.cards.map(id => cardManager.createInstance(id));
this.playerCommander = cardManager.createInstance(playerDeck.commander, playerLevel);
this.playerDeck = playerDeck.cards.map(id => cardManager.createInstance(id, playerLevel));
this.rng.shuffle(this.playerDeck);
this.playerDeckIdx = 0;
this.playerHand = [];
this.playerLanes = []; // active cards in lanes
// Opponent side (AI)
this.ai = new CombatAI(opponentDeck, cardManager, seed ? seed + 1 : Date.now() + 1);
this.ai = new CombatAI(opponentDeck, cardManager, seed ? seed + 1 : Date.now() + 1, enemyLevel);
this.opponentCommander = this.ai.commander;
this.opponentLanes = [];
@ -344,19 +344,21 @@ export class CombatEngine {
return [...this.events];
}
// Process preBattle skills for a set of cards and return buff, siege fire, and protect fire descriptors.
_collectPreBattleFires(cards, allies, enemies, enemyCommander) {
// Process preBattle skills for a set of cards and return buff, siege fire, protect fire, and enfeeble fire descriptors.
_collectPreBattleFires(cards, allies, enemies, enemyCommander, enemyLanes = []) {
const buffs = [];
const siegeFires = [];
const protectFires = [];
const enfeebeFires = [];
const jamFires = [];
const liveAllies = allies.filter(c => c.currentHP > 0);
const liveEnemies = enemies.filter(c => c.currentHP > 0);
for (const card of cards) {
if (card.currentHP <= 0) continue;
for (const s of card.skills) {
if (s.trigger !== 'preBattle') continue;
// Pass laneCards so protect can find positional neighbors
const ctx = { rng: this.rng, enemyCommander, laneCards: cards };
// Pass laneCards + enemyLaneCards so positional skills (protect, enfeeble) work
const ctx = { rng: this.rng, enemyCommander, laneCards: cards, enemyLaneCards: enemyLanes };
this.skillProcessor.process(s, card, null, liveAllies, liveEnemies, ctx);
if (s.name === 'rally' && ctx.rallyTarget) {
buffs.push({ skill: 'rally', source: card, target: ctx.rallyTarget, amount: s.value });
@ -370,9 +372,19 @@ export class CombatEngine {
protectFires.push({ skill: 'protect', source: card, targets: ctx.protectTargets });
this._log(`${card.name} protect buffs ${ctx.protectTargets.map(t => t.card.name).join(', ')} +${s.value} ARM`);
}
if (s.name === 'enfeeble' && ctx.enfeebleTarget) {
enfeebeFires.push({ skill: 'enfeeble', source: card, target: ctx.enfeebleTarget, amount: ctx.enfeebleAmount });
this._log(`${card.name} enfeeble reduces ${ctx.enfeebleTarget.name} ATK by ${ctx.enfeebleAmount}`);
}
if (s.name === 'jam' && ctx.jamTargets?.length) {
jamFires.push({ skill: 'jam', source: card, targets: ctx.jamTargets });
for (const t of ctx.jamTargets) {
this._log(`${card.name} jam suppresses ${t.card.name} skills by ${t.reductions.map(r => r.amount).join('/')}`);
}
}
}
}
return { buffs, siegeFires, protectFires };
return { buffs, siegeFires, protectFires, enfeebeFires, jamFires };
}
// Emit the 8-step pre-battle sequence and process preBattle skills.
@ -391,20 +403,20 @@ export class CombatEngine {
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: otherSide, card: otherCmd, buffs: [], siegeFires: [], protectFires: [] });
// Steps 34: commander offensive buffs
const firstCmdFires = this._collectPreBattleFires([firstCmd], firstAllies, otherAllies, otherCmd);
const otherCmdFires = this._collectPreBattleFires([otherCmd], otherAllies, firstAllies, firstCmd);
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, buffs: firstCmdFires.buffs, siegeFires: firstCmdFires.siegeFires, protectFires: firstCmdFires.protectFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, buffs: otherCmdFires.buffs, siegeFires: otherCmdFires.siegeFires, protectFires: otherCmdFires.protectFires });
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 });
// Steps 56: lane defensive buffs (placeholder)
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: [], siegeFires: [], protectFires: [] });
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: [], siegeFires: [], protectFires: [] });
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [] });
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [] });
// Steps 78: lane offensive buffs + siege fires + protect fires
const firstLaneFires = this._collectPreBattleFires(firstLanes, firstAllies, otherAllies, otherCmd);
const otherLaneFires = this._collectPreBattleFires(otherLanes, otherAllies, firstAllies, firstCmd);
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: firstLaneFires.buffs, siegeFires: firstLaneFires.siegeFires, protectFires: firstLaneFires.protectFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: otherLaneFires.buffs, siegeFires: otherLaneFires.siegeFires, protectFires: otherLaneFires.protectFires });
// Steps 78: 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 });
}
// Build the ordered list of attacks for this turn (called by beginCommit).
@ -529,7 +541,9 @@ export class CombatEngine {
return [...this.events];
}
// Remove all temp buffs from a set of cards and return debuff descriptors.
// Remove all temp buffs/debuffs from a set of cards and return descriptors.
// Positive amounts were additions (rally, protect) — subtract to reverse.
// Negative amounts were subtractions (enfeeble) — subtract negative = add back.
_removeAndCollectDebuffs(cards) {
const debuffs = [];
for (const card of cards) {
@ -537,6 +551,11 @@ export class CombatEngine {
for (const buff of card._tempBuffs) {
if (buff.stat === 'currentAttack') card.currentAttack = Math.max(0, card.currentAttack - buff.amount);
if (buff.stat === 'currentArmor') card.currentArmor = Math.max(0, card.currentArmor - buff.amount);
if (buff.stat === 'skillValues') {
for (const { index, amount } of buff.amounts) {
if (card.skills[index]) card.skills[index].value = (card.skills[index].value || 0) + amount;
}
}
debuffs.push({ skill: buff.source, target: card, amount: buff.amount });
}
card._tempBuffs = [];

View File

@ -104,14 +104,79 @@ export class SkillProcessor {
if (context) context.protectTargets = protectTargets;
}
jam(skill, attacker, defender) {
if (!defender) return;
defender.jamTurns = (defender.jamTurns || 0) + skill.value;
jam(skill, attacker, defender, allies, enemies, context) {
// preBattle: suppress skill values on the card directly across and its neighbors
const laneCards = context?.laneCards || [];
const enemyLaneCards = context?.enemyLaneCards || [];
const idx = laneCards.indexOf(attacker);
if (idx < 0 || idx >= enemyLaneCards.length) return;
const primaryAmount = skill.value;
const secondaryAmount = Math.ceil(skill.value / 2);
const jamTargets = [];
// Primary: directly opposite card
const primary = enemyLaneCards[idx];
if (primary?.currentHP > 0) {
const reductions = this._applyJamReductions(primary, primaryAmount);
if (reductions.length > 0) {
primary._tempBuffs = primary._tempBuffs || [];
primary._tempBuffs.push({ stat: 'skillValues', amounts: reductions, source: 'jam' });
jamTargets.push({ card: primary, reductions, laneOffset: 0 });
}
}
// Secondaries: left and right neighbours of the primary
for (const offset of [-1, 1]) {
const sIdx = idx + offset;
if (sIdx < 0 || sIdx >= enemyLaneCards.length) continue;
const secondary = enemyLaneCards[sIdx];
if (!secondary?.currentHP > 0) continue;
const reductions = this._applyJamReductions(secondary, secondaryAmount);
if (reductions.length > 0) {
secondary._tempBuffs = secondary._tempBuffs || [];
secondary._tempBuffs.push({ stat: 'skillValues', amounts: reductions, source: 'jam' });
jamTargets.push({ card: secondary, reductions, laneOffset: offset });
}
}
if (context) context.jamTargets = jamTargets;
}
enfeeble(skill, attacker, defender) {
if (!defender) return;
defender.currentAttack = Math.max(0, defender.currentAttack - skill.value);
// Reduce each skill's value on a card by up to `amount`, floor at 0.
// Returns an array of { index, amount } representing actual reductions applied.
_applyJamReductions(card, amount) {
const reductions = [];
for (let i = 0; i < card.skills.length; i++) {
const s = card.skills[i];
if (s.value == null || s.value <= 0) continue;
const reduced = Math.min(s.value, amount);
if (reduced > 0) {
s.value -= reduced;
reductions.push({ index: i, amount: reduced });
}
}
return reductions;
}
enfeeble(skill, attacker, defender, allies, enemies, context) {
// preBattle: target the enemy card directly across from this card's lane position
const laneCards = context?.laneCards || [];
const enemyLaneCards = context?.enemyLaneCards || [];
const idx = laneCards.indexOf(attacker);
if (idx < 0 || idx >= enemyLaneCards.length) return;
const target = enemyLaneCards[idx];
if (!target || target.currentHP <= 0) return;
const amount = Math.min(skill.value, target.currentAttack);
target.currentAttack = Math.max(0, target.currentAttack - amount);
// Store negative amount so _removeAndCollectDebuffs reverses it by adding back
target._tempBuffs = target._tempBuffs || [];
target._tempBuffs.push({ stat: 'currentAttack', amount: -amount, source: 'enfeeble' });
if (context) {
context.enfeebleTarget = target;
context.enfeebleAmount = amount;
}
}
weaken(skill, attacker, defender) {

View File

@ -1,6 +1,8 @@
import { BootScene } from './scenes/BootScene.js';
import { MainMenuScene } from './scenes/MainMenuScene.js';
import { CampaignSelectScene } from './scenes/CampaignSelectScene.js';
import { CampaignScene } from './scenes/CampaignScene.js';
import { SkirmishSetupScene } from './scenes/SkirmishSetupScene.js';
import { BattleScene } from './scenes/BattleScene.js';
import { DeckBuilderScene } from './scenes/DeckBuilderScene.js';
import { CollectionScene } from './scenes/CollectionScene.js';
@ -20,7 +22,9 @@ const config = {
scene: [
BootScene,
MainMenuScene,
CampaignSelectScene,
CampaignScene,
SkirmishSetupScene,
BattleScene,
DeckBuilderScene,
CollectionScene,

View File

@ -23,16 +23,30 @@ export class CardManager {
return this.getAllCards().filter(c => c.rarity === rarity);
}
createInstance(id) {
getStatsAtLevel(id, level = 1) {
const base = this.getCard(id);
if (!base) return null;
const lvl = Math.max(1, Math.min(level, base.levels.length));
return { ...base.levels[lvl - 1] };
}
createInstance(id, level = 1) {
const base = this.getCard(id);
if (!base) return null;
const lvl = Math.max(1, Math.min(level, base.levels.length));
const stats = base.levels[lvl - 1];
return {
...base,
skills: base.skills.map(s => ({ ...s })),
currentHP: base.health,
currentAttack: base.attack,
currentArmor: base.armor,
currentDelay: base.delay,
level: lvl,
attack: stats.attack,
health: stats.health,
armor: stats.armor,
delay: stats.delay,
skills: stats.skills.map(s => ({ ...s })),
currentHP: stats.health,
currentAttack: stats.attack,
currentArmor: stats.armor,
currentDelay: stats.delay,
ruptureStacks: 0,
jamTurns: 0,
berserkBonus: 0,

View File

@ -1,14 +1,16 @@
const SAVE_KEY = 'tyrants-edge-save';
const CURRENT_VERSION = 1;
const CURRENT_VERSION = 2;
export class SaveManager {
static defaultSave() {
return {
version: CURRENT_VERSION,
gold: 400,
level: 1,
collection: {},
decks: [],
campaignProgress: { completedMissions: [], currentChapter: 1 },
campaignProgress: { completedMissions: [], currentCampaign: 'campaign_raider' },
unlockedCampaigns: ['campaign_raider'],
starterDeckReceived: false
};
}
@ -30,7 +32,16 @@ export class SaveManager {
static migrate(data) {
if (data.version === CURRENT_VERSION) return data;
// future migrations here
// v1 → v2: add level, unlockedCampaigns, update campaignProgress
if (!data.version || data.version < 2) {
data.level = data.level || 1;
data.unlockedCampaigns = data.unlockedCampaigns || ['campaign_raider'];
if (data.campaignProgress) {
data.campaignProgress.currentCampaign = data.campaignProgress.currentCampaign || 'campaign_raider';
} else {
data.campaignProgress = { completedMissions: [], currentCampaign: 'campaign_raider' };
}
}
data.version = CURRENT_VERSION;
return data;
}

View File

@ -179,6 +179,21 @@ export class CardObject extends Phaser.GameObjects.Container {
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(
-w / 2 + Math.round(4 * scale),
-h / 2 + bannerH + Math.round(3 * scale),
`Lv.${this.cardData.level}`,
{
fontSize: fs(6), color: '#ffffff', fontStyle: 'bold',
backgroundColor: '#000000aa',
padding: { x: Math.round(2 * scale), y: Math.round(1 * scale) }
}
).setOrigin(0, 0);
this.add(lvBadge);
}
}
refresh() {
@ -189,6 +204,12 @@ export class CardObject extends Phaser.GameObjects.Container {
if (this.delayOverlay) {
this.delayOverlay.setVisible(this.cardData.currentDelay > 0);
}
if (this.skillText && this.cardData.skills?.length) {
const skillStr = this.cardData.skills
.map(s => s.value != null ? `${s.name} ${s.value}` : s.name)
.join(' · ');
this.skillText.setText(skillStr);
}
}
animateArmorLoss(amount, onComplete, fromARM = null, toARM = null) {

View File

@ -10,6 +10,9 @@ export class BattleScene extends Phaser.Scene {
this.missionData = data.mission || null;
this.playerDeckData = data.deck || null;
this.isSkirmish = data.skirmish || false;
this.playerLevel = data.playerLevel || 1;
this.enemyLevel = data.enemyLevel || 1;
this.campaignId = data.campaignId || null;
}
create() {
@ -106,7 +109,7 @@ export class BattleScene extends Phaser.Scene {
return;
}
this.engine = new CombatEngine(this.playerDeckData, opponentDeck, cardManager);
this.engine = new CombatEngine(this.playerDeckData, opponentDeck, cardManager, undefined, this.playerLevel, this.enemyLevel);
this.cardObjects = new Map(); // instanceId -> CardObject
// Commander visuals
@ -254,8 +257,13 @@ export class BattleScene extends Phaser.Scene {
const commitEvents = this.engine.beginCommit(chosenCard);
const preBattleEvents = commitEvents.filter(e => e.type === 'preBattle');
// Temporarily restore any jam-reduced skill values so _renderState() builds
// CardObjects with the original (pre-jam) values displayed. They are re-applied
// immediately after so combat math is unaffected.
this._restoreJamSkillsForDisplay(preBattleEvents);
// Render the field with all newly deployed cards but pre-combat HP
this._renderState();
this._reapplyJamSkillReductions(preBattleEvents);
const newPlayerCard = chosenCard
? this.engine.getState().player.lanes.find(c => !oldPlayerIds.has(c.instanceId))
@ -732,8 +740,7 @@ export class BattleScene extends Phaser.Scene {
.setScale(BASE_SCALE)
.setDepth(30);
const angle = Phaser.Math.Angle.Between(sourceObj.x, sourceObj.y, targetObj.x, targetObj.y);
pierceSprite.rotation = angle + Math.PI / 2;
// No rotation — reticle stays upright throughout
// Fly to the defender
this.tweens.add({
@ -743,21 +750,36 @@ export class BattleScene extends Phaser.Scene {
duration: 320,
ease: 'Cubic.easeIn',
onComplete: () => {
// Fade out sprite while armor loss animates
this.tweens.add({
targets: pierceSprite,
alpha: 0,
duration: 250,
ease: 'Power2',
onComplete: () => { if (pierceSprite.scene) pierceSprite.destroy(); }
});
if (!targetObj.scene) { onComplete(); return; }
// Set ARM display to pre-pierce value so animation starts correctly
if (targetObj.armText) targetObj.armText.setText(`${pierceFire.armBefore}`);
const armAfter = pierceFire.armBefore - pierceFire.amount;
targetObj.animateArmorLoss(pierceFire.amount, onComplete, pierceFire.armBefore, armAfter);
// Phase 1: reticle locks on — slow pulse for 1000ms
this.tweens.add({
targets: pierceSprite,
scaleX: BASE_SCALE * 1.25,
scaleY: BASE_SCALE * 1.25,
duration: 200,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: 2,
onComplete: () => {
if (!pierceSprite.scene) { onComplete(); return; }
// Phase 2: fade out the reticle
this.tweens.add({
targets: pierceSprite,
alpha: 0,
duration: 300,
ease: 'Power2',
onComplete: () => { if (pierceSprite.scene) pierceSprite.destroy(); }
});
// Trigger armor loss at the moment the reticle starts fading
if (!targetObj.scene) { onComplete(); return; }
if (targetObj.armText) targetObj.armText.setText(`${pierceFire.armBefore}`);
const armAfter = pierceFire.armBefore - pierceFire.amount;
targetObj.animateArmorLoss(pierceFire.amount, onComplete, pierceFire.armBefore, armAfter);
}
});
}
});
}
@ -998,16 +1020,22 @@ export class BattleScene extends Phaser.Scene {
processStep(0);
}
// Called once per preBattle step. Handles buff animations, siege fires, then protect fires.
// 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 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;
if (hasBuffs || hasSiege || hasProtect) {
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam) {
this._processBuffAnimations(event.buffs || [], () => {
this._processSiegeFires(event.siegeFires || [], () => {
this._processProtectFires(event.protectFires || [], onComplete);
this._processProtectFires(event.protectFires || [], () => {
this._processEnfeebeFires(event.enfeebeFires || [], () => {
this._processJamFires(event.jamFires || [], onComplete);
});
});
});
});
} else {
@ -1031,6 +1059,22 @@ export class BattleScene extends Phaser.Scene {
next(0);
}
_processEnfeebeFires(fires, onComplete) {
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
this._animateEnfeebeFire(fires[idx], () => next(idx + 1));
};
next(0);
}
_processJamFires(fires, onComplete) {
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
this._animateJamFire(fires[idx], () => next(idx + 1));
};
next(0);
}
_processBuffAnimations(buffs, onComplete) {
const next = (idx) => {
if (idx >= buffs.length) { onComplete(); return; }
@ -1321,6 +1365,294 @@ export class BattleScene extends Phaser.Scene {
});
}
// Animate enfeeble: sprite 18 flies from source card to target card (like pierce), then
// the reticle locks on and pulses before fading, then ATK loss animates on the target.
_animateEnfeebeFire(enfeebeFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(enfeebeFire.source.instanceId);
const targetObj = _lookup(enfeebeFire.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene || enfeebeFire.amount <= 0) { onComplete(); return; }
this.sound.play('sfx_enfeeble', { volume: 0.8 });
this.statusText.setText(`${enfeebeFire.source.name} enfeebles ${enfeebeFire.target.name}!`);
const BASE_SCALE = 160 / 460;
const enfeebleSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 18)
.setScale(BASE_SCALE)
.setDepth(30);
// Fly to the target
this.tweens.add({
targets: enfeebleSprite,
x: targetObj.x,
y: targetObj.y,
duration: 320,
ease: 'Cubic.easeIn',
onComplete: () => {
if (!targetObj.scene) { onComplete(); return; }
// Lock on — slow pulse for ~800ms
this.tweens.add({
targets: enfeebleSprite,
scaleX: BASE_SCALE * 1.25,
scaleY: BASE_SCALE * 1.25,
duration: 200,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: 2,
onComplete: () => {
if (!enfeebleSprite.scene) { onComplete(); return; }
// Fade out the sprite
this.tweens.add({
targets: enfeebleSprite,
alpha: 0,
duration: 300,
ease: 'Power2',
onComplete: () => { if (enfeebleSprite.scene) enfeebleSprite.destroy(); }
});
// ATK loss animation on target
if (!targetObj.scene) { onComplete(); return; }
const atkBefore = enfeebeFire.target.currentAttack + enfeebeFire.amount;
if (targetObj.atkText) targetObj.atkText.setText(`${atkBefore}`);
this._animateAttackLoss(targetObj, enfeebeFire.amount, atkBefore, enfeebeFire.target.currentAttack, onComplete);
}
});
}
});
}
// Animate an ATK reduction on a CardObject (mirrors animateArmorLoss / animateHPLoss pattern).
_animateAttackLoss(cardObj, amount, fromATK, toATK, onComplete) {
if (!cardObj.atkText || !cardObj.scene) { if (onComplete) onComplete(); return; }
const w = cardObj.options?.width || 80;
const s = w / 80;
const h = cardObj.options?.height || 110;
const bannerH = Math.round(h * 0.12);
const topBannerCY = -h / 2 + bannerH / 2;
cardObj.atkText.setText(`${Math.max(0, fromATK)}`);
this.tweens.add({
targets: cardObj.atkText,
scaleX: 2, scaleY: 2,
duration: 200,
ease: 'Back.Out',
onComplete: () => {
if (!cardObj.scene) { if (onComplete) onComplete(); return; }
const lossText = this.add.text(
cardObj.x + cardObj.atkText.x * cardObj.scaleX - Math.round(4 * s),
cardObj.y + topBannerCY * cardObj.scaleY,
`-${amount}`,
{
fontSize: `${Math.round(11 * s)}px`,
color: '#ff8877',
fontStyle: 'bold',
stroke: '#000000',
strokeThickness: Math.max(1, Math.round(2 * s))
}
).setOrigin(1, 0.5).setDepth(50);
this.time.delayedCall(500, () => {
if (!cardObj.scene) { if (onComplete) onComplete(); return; }
cardObj.atkText.setText(`${Math.max(0, toATK)}`);
this.tweens.add({ targets: cardObj.atkText, scaleX: 1, scaleY: 1, duration: 200, ease: 'Power2' });
this.tweens.add({
targets: lossText,
alpha: 0,
y: lossText.y - Math.round(14 * s),
duration: 300,
ease: 'Power2',
onComplete: () => {
if (lossText.scene) lossText.destroy();
if (onComplete) onComplete();
}
});
});
}
});
}
// Animate jam: sprite 19 flies source → primary (~900ms), pulses, then half-sized copies fan
// to secondaries (~700ms). Floating skill-reduction text appears on each hit card and fades
// over 1200ms. Cards only refresh (showing reduced skill values) after all floats finish.
// Total duration: ~3 seconds.
_animateJamFire(jamFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(jamFire.source.instanceId);
const primaryTarget = jamFire.targets.find(t => t.laneOffset === 0);
if (!sourceObj?.scene || !primaryTarget) { onComplete(); return; }
const primaryObj = _lookup(primaryTarget.card.instanceId);
if (!primaryObj?.scene) { onComplete(); return; }
this.sound.play('sfx_jam', { volume: 0.8 });
this.statusText.setText(`${jamFire.source.name} jams the enemy signals!`);
const BASE_SCALE = 160 / 460;
const HALF_SCALE = BASE_SCALE * 0.5;
const FLY_MS = 900;
const PULSE_MS = 200; // one yoyo = 400ms
const FAN_MS = 700;
const FADE_MS = 400;
const FLOAT_MS = 1200;
const secondaries = jamFire.targets.filter(t => t.laneOffset !== 0);
// Count how many float animations we're waiting on before finishing
const validTargets = [primaryTarget, ...secondaries].filter(
t => _lookup(t.card.instanceId)?.scene
);
if (validTargets.length === 0) { onComplete(); return; }
let floatsRemaining = validTargets.length;
const onFloatDone = () => {
if (--floatsRemaining > 0) return;
// All floats finished — now refresh every affected card to show reduced values
for (const t of validTargets) _lookup(t.card.instanceId)?.refresh();
onComplete();
};
// Main sprite: source → primary
const mainSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 19)
.setScale(BASE_SCALE)
.setDepth(30);
this.tweens.add({
targets: mainSprite,
x: primaryObj.x,
y: primaryObj.y,
duration: FLY_MS,
ease: 'Cubic.easeIn',
onComplete: () => {
if (!primaryObj.scene) { onFloatDone(); if (mainSprite.scene) mainSprite.destroy(); return; }
// Floating skill-reduction text on primary (drives completion)
this._showJamReductionFloat(primaryObj, primaryTarget.reductions, FLOAT_MS, onFloatDone);
// Pulse main sprite once, then fade
this.tweens.add({
targets: mainSprite,
scaleX: BASE_SCALE * 1.35,
scaleY: BASE_SCALE * 1.35,
duration: PULSE_MS,
ease: 'Sine.easeInOut',
yoyo: true,
onComplete: () => {
this.tweens.add({
targets: mainSprite,
alpha: 0,
duration: FADE_MS,
ease: 'Power2',
onComplete: () => { if (mainSprite.scene) mainSprite.destroy(); }
});
}
});
// Half-sized copies fan from primary to each secondary
for (const sec of secondaries) {
const secObj = _lookup(sec.card.instanceId);
if (!secObj?.scene) { onFloatDone(); continue; }
const secSprite = this.add.sprite(primaryObj.x, primaryObj.y, 'attacks', 19)
.setScale(HALF_SCALE)
.setDepth(30);
this.tweens.add({
targets: secSprite,
x: secObj.x,
y: secObj.y,
duration: FAN_MS,
ease: 'Cubic.easeIn',
onComplete: () => {
if (!secObj.scene) { onFloatDone(); if (secSprite.scene) secSprite.destroy(); return; }
// Floating text on secondary (drives completion)
this._showJamReductionFloat(secObj, sec.reductions, FLOAT_MS, onFloatDone);
this.tweens.add({
targets: secSprite,
alpha: 0,
duration: FADE_MS,
ease: 'Power2',
onComplete: () => { if (secSprite.scene) secSprite.destroy(); }
});
}
});
}
}
});
}
// Temporarily add back jam reductions so CardObjects are built with original skill values.
_restoreJamSkillsForDisplay(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.jamFires || []) {
for (const t of fire.targets) {
for (const r of t.reductions) {
if (t.card.skills[r.index]) t.card.skills[r.index].value += r.amount;
}
}
}
}
}
// Re-apply jam reductions after _renderState() so combat math stays correct.
_reapplyJamSkillReductions(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.jamFires || []) {
for (const t of fire.targets) {
for (const r of t.reductions) {
if (t.card.skills[r.index]) t.card.skills[r.index].value = Math.max(0, t.card.skills[r.index].value - r.amount);
}
}
}
}
}
// Show floating "-N skill" reduction text near a card's skill area. Calls onComplete when done.
_showJamReductionFloat(cardObj, reductions, duration, onComplete) {
if (!cardObj?.scene || !reductions?.length) { if (onComplete) onComplete(); return; }
// Place float near the skill text using the cardObj's world position
const skillOffsetY = cardObj.skillText
? cardObj.skillText.y * (cardObj.scaleY || 1)
: cardObj.displayHeight * 0.2;
const worldX = cardObj.x;
const worldY = cardObj.y + skillOffsetY;
const lines = reductions.map(r => {
const skillName = cardObj.cardData.skills[r.index]?.name || 'skill';
return `-${r.amount} ${skillName}`;
}).join('\n');
const floatText = this.add.text(worldX, worldY, lines, {
fontSize: '18px',
color: '#ff9933',
fontStyle: 'bold',
stroke: '#000000',
strokeThickness: 3,
align: 'center'
}).setOrigin(0.5, 0.5).setDepth(55);
this.tweens.add({
targets: floatText,
alpha: 0,
y: worldY - 55,
duration,
ease: 'Power2',
onComplete: () => {
if (floatText.scene) floatText.destroy();
if (onComplete) onComplete();
}
});
}
// Drives the 8-step postBattle sequence after all attacks resolve.
_processPostBattle(events, onComplete) {
const processStep = (idx) => {
@ -1550,7 +1882,13 @@ export class BattleScene extends Phaser.Scene {
this.add.text(width / 2, height / 2 + 160, 'Continue', {
fontSize: '28px', color: '#ffffff'
}).setOrigin(0.5);
backBtn.on('pointerdown', () => this.scene.start(this.missionData ? 'CampaignScene' : 'MainMenuScene'));
backBtn.on('pointerdown', () => {
if (this.missionData) {
this.scene.start('CampaignScene', { campaignId: this.campaignId || 'campaign_raider' });
} else {
this.scene.start('MainMenuScene');
}
});
}
_animateInitiativeHandoff() {

View File

@ -14,6 +14,7 @@ export class BootScene extends Phaser.Scene {
this.load.json('cards', 'data/cards.json');
this.load.json('packs', 'data/packs.json');
this.load.json('missions', 'data/missions.json');
this.load.json('campaigns', 'data/campaigns.json');
this.load.json('skills', 'data/skills.json');
this.load.audio('sfx_attack', 'assets/audio/fx/attack.mp3');
@ -27,6 +28,8 @@ export class BootScene extends Phaser.Scene {
this.load.audio('sfx_rally', 'assets/audio/fx/rally.mp3');
this.load.audio('sfx_siege', 'assets/audio/fx/siege.mp3');
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_menu_select', 'assets/audio/fx/menu_select.mp3');
this.load.audio('sfx_menu_hover', 'assets/audio/fx/menu_hover.mp3');
@ -57,6 +60,7 @@ export class BootScene extends Phaser.Scene {
const cardsData = this.cache.json.get('cards');
const packsData = this.cache.json.get('packs');
const missionsData = this.cache.json.get('missions');
const campaignsData = this.cache.json.get('campaigns');
const cardManager = new CardManager(cardsData);
const packManager = new PackManager(packsData, cardManager);
@ -81,6 +85,7 @@ export class BootScene extends Phaser.Scene {
this.registry.set('cardManager', cardManager);
this.registry.set('packManager', packManager);
this.registry.set('missions', missionsData);
this.registry.set('campaigns', campaignsData);
this.registry.set('save', save);
// Start main menu music looping; store reference so other scenes can stop/resume it

View File

@ -1,18 +1,40 @@
import { SaveManager } from '../managers/SaveManager.js';
export class CampaignScene extends Phaser.Scene {
constructor() { super('CampaignScene'); }
init(data) {
this.campaignId = data.campaignId || 'campaign_raider';
}
create() {
const missions = this.registry.get('missions');
const allMissions = this.registry.get('missions');
const campaigns = this.registry.get('campaigns');
const save = this.registry.get('save');
const { width, height } = this.scale;
const campaign = campaigns.find(c => c.id === this.campaignId);
const missions = allMissions.filter(m => m.campaignId === this.campaignId);
this.add.rectangle(width / 2, height / 2, width, height, 0x0d1b2a);
this.add.text(width / 2, 45, 'Campaign — Chapter 1', {
this.add.text(width / 2, 45, campaign ? campaign.name : 'Campaign', {
fontSize: '42px', color: '#d4af37'
}).setOrigin(0.5);
if (campaign) {
this.add.text(width / 2, 90, `Level ${campaign.level}${campaign.faction.toUpperCase()}`, {
fontSize: '18px', color: '#888888'
}).setOrigin(0.5);
}
const completed = save.campaignProgress.completedMissions;
// Check if campaign is fully complete
const allDone = missions.length > 0 && missions.every(m => completed.includes(m.id));
if (allDone && campaign) {
this._checkCampaignRewards(campaign, save, campaigns);
}
missions.forEach((mission, i) => {
const isUnlocked = !mission.unlockCondition || completed.includes(mission.unlockCondition);
const isDone = completed.includes(mission.id);
@ -29,7 +51,7 @@ export class CampaignScene extends Phaser.Scene {
node.setInteractive({ useHandCursor: true });
node.on('pointerover', () => node.setFillStyle(isDone ? 0x33aa33 : 0x2a5a8c));
node.on('pointerout', () => node.setFillStyle(color));
node.on('pointerdown', () => this._startMission(mission));
node.on('pointerdown', () => this._startMission(mission, campaign));
}
this.add.text(x, y - 70, mission.name, {
@ -37,7 +59,7 @@ export class CampaignScene extends Phaser.Scene {
wordWrap: { width: 270 }, align: 'center'
}).setOrigin(0.5);
this.add.text(x, y - 35, `Chapter ${mission.chapter}`, {
this.add.text(x, y - 35, `Mission ${i + 1}`, {
fontSize: '16px', color: '#888888'
}).setOrigin(0.5);
@ -62,11 +84,10 @@ export class CampaignScene extends Phaser.Scene {
fontSize: '18px', color: '#888888', align: 'center', wordWrap: { width: width - 100 }
}).setOrigin(0.5);
// Back button
this._makeBackButton();
}
_startMission(mission) {
_startMission(mission, campaign) {
const save = this.registry.get('save');
if (!save.decks || save.decks.length === 0) {
this.add.text(960, 940, 'No deck! Create a deck in Deck Builder first.', {
@ -74,7 +95,38 @@ export class CampaignScene extends Phaser.Scene {
}).setOrigin(0.5);
return;
}
this.scene.start('BattleScene', { mission, deck: save.decks[0] });
this.sound.play('sfx_menu_select', { volume: 0.7 });
this.scene.start('BattleScene', {
mission,
deck: save.decks[0],
playerLevel: save.level || 1,
enemyLevel: mission.enemyLevel || 1,
campaignId: this.campaignId
});
}
_checkCampaignRewards(campaign, save, allCampaigns) {
// Award starter reward cards if not already awarded
const rewardKey = `campaignReward_${campaign.id}`;
if (campaign.starterReward?.cards?.length > 0 && !save[rewardKey]) {
for (const cardId of campaign.starterReward.cards) {
save.collection[cardId] = (save.collection[cardId] || 0) + 1;
}
save[rewardKey] = true;
// Level up if completing this campaign grants a new level
if (campaign.level >= save.level && save.level < 5) {
save.level = Math.min(5, campaign.level + 1);
}
// Unlock next campaign
const nextCampaign = allCampaigns.find(c => c.unlockCondition === campaign.id);
if (nextCampaign && !save.unlockedCampaigns.includes(nextCampaign.id)) {
save.unlockedCampaigns.push(nextCampaign.id);
}
SaveManager.save(save);
}
}
_makeBackButton() {
@ -82,6 +134,9 @@ export class CampaignScene extends Phaser.Scene {
.setInteractive({ useHandCursor: true })
.setStrokeStyle(1, 0x888888);
this.add.text(80, 45, 'Back', { fontSize: '18px', color: '#ffffff' }).setOrigin(0.5);
bg.on('pointerdown', () => this.scene.start('MainMenuScene'));
bg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
this.scene.start('CampaignSelectScene');
});
}
}

View File

@ -0,0 +1,135 @@
export class CampaignSelectScene extends Phaser.Scene {
constructor() { super('CampaignSelectScene'); }
create() {
const campaigns = this.registry.get('campaigns');
const save = this.registry.get('save');
const { width, height } = this.scale;
// Resume main menu music if it was stopped by a battle
const menuMusic = this.registry.get('music_main_menu');
if (menuMusic && !menuMusic.isPlaying) menuMusic.play();
this.add.rectangle(width / 2, height / 2, width, height, 0x0d1b2a);
this.add.text(width / 2, 45, 'Select Campaign', {
fontSize: '42px', color: '#d4af37'
}).setOrigin(0.5);
this.add.text(width / 2, 95, `Player Level: ${save.level || 1}`, {
fontSize: '20px', color: '#88aaff'
}).setOrigin(0.5);
const FACTION_COLORS = {
raider: '#cc4444',
bloodthirsty: '#cc4488',
xeno: '#44cc66',
righteous: '#cccc44',
imperial: '#4466cc'
};
const spacing = 340;
const startX = width / 2 - (campaigns.length - 1) * spacing / 2;
campaigns.forEach((campaign, i) => {
const x = startX + i * spacing;
const y = height / 2 + 20;
const isUnlocked = save.unlockedCampaigns?.includes(campaign.id);
const completedMissions = (save.campaignProgress?.completedMissions || [])
.filter(mId => campaign.missions.includes(mId));
const isComplete = campaign.missions.length > 0 && completedMissions.length >= campaign.missions.length;
const bgColor = isComplete ? 0x224422 : isUnlocked ? 0x1a3a5c : 0x222222;
const borderColor = isComplete ? 0x44ff44 : isUnlocked ? 0x4488ff : 0x555555;
const node = this.add.rectangle(x, y, 300, 400, bgColor)
.setStrokeStyle(2, borderColor);
// Campaign name
const nameColor = isUnlocked ? '#ffffff' : '#666666';
this.add.text(x, y - 150, campaign.name, {
fontSize: '22px', color: nameColor, fontStyle: 'bold',
wordWrap: { width: 270 }, align: 'center'
}).setOrigin(0.5);
// Faction label
const fColor = FACTION_COLORS[campaign.faction] || '#888888';
this.add.text(x, y - 115, campaign.faction.toUpperCase(), {
fontSize: '14px', color: fColor
}).setOrigin(0.5);
// Level requirement
this.add.text(x, y - 85, `Level ${campaign.level}`, {
fontSize: '16px', color: '#aaaaaa'
}).setOrigin(0.5);
// Progress or lock status
if (isUnlocked) {
if (campaign.missions.length === 0) {
this.add.text(x, y, 'Coming Soon', {
fontSize: '18px', color: '#888844'
}).setOrigin(0.5);
} else {
const progressStr = isComplete
? 'Complete!'
: `${completedMissions.length} / ${campaign.missions.length} missions`;
const progressColor = isComplete ? '#44ff44' : '#aaffaa';
this.add.text(x, y - 20, progressStr, {
fontSize: '16px', color: progressColor
}).setOrigin(0.5);
// Click to play
node.setInteractive({ useHandCursor: true });
node.on('pointerover', () => node.setFillStyle(isComplete ? 0x336633 : 0x2a5a8c));
node.on('pointerout', () => node.setFillStyle(bgColor));
node.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
this.scene.start('CampaignScene', { campaignId: campaign.id });
});
this.add.text(x, y + 30, 'Click to play', {
fontSize: '14px', color: '#88aaff'
}).setOrigin(0.5);
}
// Starter reward info
if (campaign.starterReward?.cards?.length > 0 && !isComplete) {
this.add.text(x, y + 80, 'Completion Reward:', {
fontSize: '13px', color: '#ffd700'
}).setOrigin(0.5);
this.add.text(x, y + 100, `${campaign.starterReward.cards.length} cards`, {
fontSize: '13px', color: '#ffeeaa'
}).setOrigin(0.5);
}
} else {
// Locked
this.add.text(x, y - 20, 'LOCKED', {
fontSize: '24px', color: '#555555', fontStyle: 'bold'
}).setOrigin(0.5);
// Unlock requirement
const reqText = campaign.unlockCondition
? `Complete ${campaigns.find(c => c.id === campaign.unlockCondition)?.name || 'previous campaign'}`
: `Requires Level ${campaign.requiredLevel}`;
this.add.text(x, y + 20, reqText, {
fontSize: '13px', color: '#666666',
wordWrap: { width: 260 }, align: 'center'
}).setOrigin(0.5);
}
});
this._makeBackButton();
}
_makeBackButton() {
const bg = this.add.rectangle(80, 45, 160, 50, 0x333333)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(1, 0x888888);
this.add.text(80, 45, 'Back', { fontSize: '18px', color: '#ffffff' }).setOrigin(0.5);
bg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
this.scene.start('MainMenuScene');
});
}
}

View File

@ -98,12 +98,13 @@ export class CollectionScene extends Phaser.Scene {
}).setOrigin(0.5);
this.cardGroup.push(typeT);
const statsT = this.add.text(x, y - 8, `ATK:${card.attack} HP:${card.health}${card.armor > 0 ? ' ARM:' + card.armor : ''}`, {
const lv1 = card.levels[0];
const statsT = this.add.text(x, y - 8, `ATK:${lv1.attack} HP:${lv1.health}${lv1.armor > 0 ? ' ARM:' + lv1.armor : ''}`, {
fontSize: '14px', color: owned > 0 ? '#aaaaff' : '#444455'
}).setOrigin(0.5);
this.cardGroup.push(statsT);
const skillsT = this.add.text(x, y + 12, card.skills.map(s => s.name).join(', '), {
const skillsT = this.add.text(x, y + 12, lv1.skills.map(s => s.name).join(', '), {
fontSize: '12px', color: owned > 0 ? '#ffcc44' : '#443300',
wordWrap: { width: cardW - 6 }, align: 'center'
}).setOrigin(0.5);

View File

@ -278,7 +278,8 @@ export class DeckBuilderScene extends Phaser.Scene {
const countTxt = this.add.text(PANEL_X + 300, y, `x${count}`, {
fontSize: '15px', color: '#aaaaaa'
}).setOrigin(0, 0.5);
const statsTxt = this.add.text(PANEL_X + 360, y, `ATK:${card.attack} HP:${card.health}`, {
const lv1 = card.levels[0];
const statsTxt = this.add.text(PANEL_X + 360, y, `ATK:${lv1.attack} HP:${lv1.health}`, {
fontSize: '13px', color: '#8888cc'
}).setOrigin(0, 0.5);

View File

@ -98,7 +98,8 @@ export class FusionScene extends Phaser.Scene {
const rarityT = this.add.text(x, y - 56, card.rarity.toUpperCase(), {
fontSize: '16px', color: '#' + (rarityColors[card.rarity] || 0x888888).toString(16).padStart(6, '0')
}).setOrigin(0.5);
const statsT = this.add.text(x, y - 32, `ATK:${card.attack} HP:${card.health} ARM:${card.armor}`, {
const lv1 = card.levels[0];
const statsT = this.add.text(x, y - 32, `ATK:${lv1.attack} HP:${lv1.health} ARM:${lv1.armor}`, {
fontSize: '16px', color: '#aaaaff'
}).setOrigin(0.5);
const countT = this.add.text(x, y - 8, `You have: ${count} (need ${recipe.count})`, {

View File

@ -27,8 +27,12 @@ export class MainMenuScene extends Phaser.Scene {
fontSize: '24px', color: '#888888'
}).setOrigin(0.5);
// Gold display
this.goldText = this.add.text(width - 30, 30, `Gold: ${save.gold}`, {
// Level & Gold display
this.add.text(width - 30, 30, `Level: ${save.level || 1}`, {
fontSize: '24px', color: '#88aaff'
}).setOrigin(1, 0);
this.goldText = this.add.text(width - 30, 60, `Gold: ${save.gold}`, {
fontSize: '24px', color: '#ffd700'
}).setOrigin(1, 0);
@ -38,8 +42,8 @@ export class MainMenuScene extends Phaser.Scene {
}).setOrigin(0, 1);
const buttons = [
{ label: 'Campaign', scene: 'CampaignScene' },
{ label: 'Skirmish', scene: 'BattleScene', data: { skirmish: true } },
{ label: 'Campaign', scene: 'CampaignSelectScene' },
{ label: 'Skirmish', scene: 'SkirmishSetupScene' },
{ label: 'Deck Builder', scene: 'DeckBuilderScene' },
{ label: 'Collection', scene: 'CollectionScene' },
{ label: 'Store', scene: 'StoreScene' },

View File

@ -0,0 +1,167 @@
export class SkirmishSetupScene extends Phaser.Scene {
constructor() { super('SkirmishSetupScene'); }
create() {
const save = this.registry.get('save');
const { width, height } = this.scale;
// Resume main menu music if it was stopped by a battle
const menuMusic = this.registry.get('music_main_menu');
if (menuMusic && !menuMusic.isPlaying) menuMusic.play();
this.add.rectangle(width / 2, height / 2, width, height, 0x0d1b2a);
this.add.text(width / 2, 60, 'Skirmish Setup', {
fontSize: '42px', color: '#d4af37'
}).setOrigin(0.5);
this.selectedLevel = save.level || 1;
this.selectedDifficulty = 'Medium';
// ── Level selector ────────────────────────────────────────────────
this.add.text(width / 2, 180, 'Card Level', {
fontSize: '24px', color: '#ffffff'
}).setOrigin(0.5);
this.add.text(width / 2, 215, 'Both player and enemy cards will use this level', {
fontSize: '14px', color: '#888888'
}).setOrigin(0.5);
this.levelButtons = [];
const maxLevel = save.level || 1;
for (let lv = 1; lv <= 5; lv++) {
const bx = width / 2 + (lv - 3) * 120;
const by = 280;
const available = lv <= maxLevel;
const isSelected = lv === this.selectedLevel;
const bg = this.add.rectangle(bx, by, 90, 55, isSelected ? 0x2a5a8c : (available ? 0x1a3a5c : 0x222222))
.setStrokeStyle(2, isSelected ? 0x88ccff : (available ? 0x4488ff : 0x555555));
const text = this.add.text(bx, by, `Lv ${lv}`, {
fontSize: '20px', color: available ? '#ffffff' : '#555555'
}).setOrigin(0.5);
if (available) {
bg.setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
this.selectedLevel = lv;
this._refreshSelectors();
});
}
this.levelButtons.push({ bg, text, level: lv, available });
}
// ── Difficulty selector ───────────────────────────────────────────
this.add.text(width / 2, 380, 'Difficulty', {
fontSize: '24px', color: '#ffffff'
}).setOrigin(0.5);
this.add.text(width / 2, 415, 'Label only — no stat changes yet', {
fontSize: '14px', color: '#888888'
}).setOrigin(0.5);
const difficulties = ['Easy', 'Medium', 'Hard', 'Legendary'];
const diffColors = {
Easy: 0x226622, Medium: 0x1a3a5c, Hard: 0x663322, Legendary: 0x664400
};
const diffBorderColors = {
Easy: 0x44aa44, Medium: 0x4488ff, Hard: 0xff6644, Legendary: 0xffaa00
};
this.diffButtons = [];
difficulties.forEach((diff, i) => {
const bx = width / 2 + (i - 1.5) * 200;
const by = 480;
const isSelected = diff === this.selectedDifficulty;
const bg = this.add.rectangle(bx, by, 170, 55, isSelected ? diffColors[diff] : 0x1a2a3a)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, isSelected ? diffBorderColors[diff] : 0x444444);
const text = this.add.text(bx, by, diff, {
fontSize: '20px', color: isSelected ? '#ffffff' : '#aaaaaa'
}).setOrigin(0.5);
bg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
this.selectedDifficulty = diff;
this._refreshSelectors();
});
this.diffButtons.push({ bg, text, diff });
});
// ── Start Battle button ───────────────────────────────────────────
const startBg = this.add.rectangle(width / 2, 620, 300, 70, 0x224488)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x4488ff);
this.add.text(width / 2, 620, 'Start Battle', {
fontSize: '28px', color: '#ffffff', fontStyle: 'bold'
}).setOrigin(0.5);
startBg.on('pointerover', () => startBg.setFillStyle(0x3366aa));
startBg.on('pointerout', () => startBg.setFillStyle(0x224488));
startBg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
const deck = save.decks[0];
if (!deck) {
this._showError('No deck! Create one in Deck Builder first.');
return;
}
this.scene.start('BattleScene', {
skirmish: true,
deck,
playerLevel: this.selectedLevel,
enemyLevel: this.selectedLevel,
difficulty: this.selectedDifficulty
});
});
this._makeBackButton();
}
_refreshSelectors() {
const maxLevel = (this.registry.get('save')?.level) || 1;
const diffColors = {
Easy: 0x226622, Medium: 0x1a3a5c, Hard: 0x663322, Legendary: 0x664400
};
const diffBorderColors = {
Easy: 0x44aa44, Medium: 0x4488ff, Hard: 0xff6644, Legendary: 0xffaa00
};
for (const btn of this.levelButtons) {
const isSelected = btn.level === this.selectedLevel;
btn.bg.setFillStyle(isSelected ? 0x2a5a8c : (btn.available ? 0x1a3a5c : 0x222222));
btn.bg.setStrokeStyle(2, isSelected ? 0x88ccff : (btn.available ? 0x4488ff : 0x555555));
btn.text.setColor(btn.available ? '#ffffff' : '#555555');
}
for (const btn of this.diffButtons) {
const isSelected = btn.diff === this.selectedDifficulty;
btn.bg.setFillStyle(isSelected ? diffColors[btn.diff] : 0x1a2a3a);
btn.bg.setStrokeStyle(2, isSelected ? diffBorderColors[btn.diff] : 0x444444);
btn.text.setColor(isSelected ? '#ffffff' : '#aaaaaa');
}
}
_showError(msg) {
const { width } = this.scale;
this.add.text(width / 2, 720, msg, {
fontSize: '20px', color: '#ff4444'
}).setOrigin(0.5);
}
_makeBackButton() {
const bg = this.add.rectangle(80, 45, 160, 50, 0x333333)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(1, 0x888888);
this.add.text(80, 45, 'Back', { fontSize: '18px', color: '#ffffff' }).setOrigin(0.5);
bg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
this.scene.start('MainMenuScene');
});
}
}

View File

@ -109,10 +109,11 @@ export class StoreScene extends Phaser.Scene {
fontSize: '16px',
color: '#' + (rarityColors[card.rarity] || 0x888888).toString(16).padStart(6, '0')
}).setOrigin(0.5).setAlpha(0);
const statsT = this.add.text(x, y + 4, `ATK:${card.attack} HP:${card.health} ARM:${card.armor}`, {
const lv1 = card.levels[0];
const statsT = this.add.text(x, y + 4, `ATK:${lv1.attack} HP:${lv1.health} ARM:${lv1.armor}`, {
fontSize: '16px', color: '#aaaaff'
}).setOrigin(0.5).setAlpha(0);
const skillT = this.add.text(x, y + 34, card.skills.map(s => s.name).join(', '), {
const skillT = this.add.text(x, y + 34, lv1.skills.map(s => s.name).join(', '), {
fontSize: '14px', color: '#ffcc44'
}).setOrigin(0.5).setAlpha(0);
const newT = this.add.text(x, y + 65, (save.collection[card.id] || 0) === 1 ? 'NEW!' : `x${save.collection[card.id]}`, {