Skill tree: subtrees, loadout slots, label fix
Skill trees now render the way the source does: each class has three named subtrees (e.g. Swordmaster: The Blade / The Will / The Way), each with its own 3-col or 5-col grid, sized in 72px cells. Extractor parses subtrees separately so the per-tree row/col coordinates are correct (previously all 22 nodes were stacked on one combined grid and overlapped). Connector edges are mapped per-subtree too. Loadout: new global 3-ability + 3-technique slot row at the bottom of the Skill Trees panel. The cap is global across all 5 classes (matches the source HTML which has `id=active-Ability-N` / `id=active-Technique-N` without per-tree scope). Click a slot to pick from any allocated Ability or Spice (for Ability slots) or any allocated Perk (for Technique slots); right-click clears. Slot backgrounds use the local ability.png / technique.png artwork copied into /icons. Label overlap fix: constrained the name label under each node to the node width (72px) and bumped the vertical gap from 44 to 60px so 2-3 line names have room without bleeding into the row below. Existing saved builds migrate cleanly — loadout normalizes to length-3 slot arrays if absent or malformed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5b3ccf630d
commit
f142725dd8
15 changed files with 2802 additions and 1835 deletions
|
|
@ -16,36 +16,61 @@
|
|||
"id": "benegesserit",
|
||||
"name": "Bene Gesserit",
|
||||
"file": "skills-benegesserit.json",
|
||||
"subtrees": [
|
||||
"Weirding Way",
|
||||
"The Voice",
|
||||
"Body Control"
|
||||
],
|
||||
"nodes": 22,
|
||||
"edges": 23
|
||||
"edges": 29
|
||||
},
|
||||
{
|
||||
"id": "mentat",
|
||||
"name": "Mentat",
|
||||
"file": "skills-mentat.json",
|
||||
"subtrees": [
|
||||
"Mental Calculus",
|
||||
"Assassination",
|
||||
"Tactician"
|
||||
],
|
||||
"nodes": 22,
|
||||
"edges": 22
|
||||
"edges": 28
|
||||
},
|
||||
{
|
||||
"id": "planetologist",
|
||||
"name": "Planetologist",
|
||||
"file": "skills-planetologist.json",
|
||||
"subtrees": [
|
||||
"Scientist",
|
||||
"Explorer",
|
||||
"Mechanic"
|
||||
],
|
||||
"nodes": 20,
|
||||
"edges": 10
|
||||
"edges": 26
|
||||
},
|
||||
{
|
||||
"id": "swordmaster",
|
||||
"name": "Swordmaster",
|
||||
"file": "skills-swordmaster.json",
|
||||
"subtrees": [
|
||||
"The Blade",
|
||||
"The Will",
|
||||
"The Way"
|
||||
],
|
||||
"nodes": 22,
|
||||
"edges": 22
|
||||
"edges": 28
|
||||
},
|
||||
{
|
||||
"id": "trooper",
|
||||
"name": "Trooper",
|
||||
"file": "skills-trooper.json",
|
||||
"subtrees": [
|
||||
"Gunnery",
|
||||
"Suspensor Training",
|
||||
"Tactical Tech"
|
||||
],
|
||||
"nodes": 22,
|
||||
"edges": 22
|
||||
"edges": 28
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
{
|
||||
"id": "benegesserit",
|
||||
"name": "Bene Gesserit",
|
||||
"subtrees": [
|
||||
{
|
||||
"name": "Weirding Way",
|
||||
"cols": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"tag": "Skills.Spice.BinduDodge",
|
||||
|
|
@ -78,7 +82,59 @@
|
|||
"maxPoints": 3,
|
||||
"icon": "t_ui_iconabilitydash_d.webp",
|
||||
"url": "https://dune.gaming.tools/skills/skills-ability-hypersprint"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "Skills.Ability.BinduNerveStrike",
|
||||
"to": "Skills.Spice.BinduDodge"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.WeirdingStep",
|
||||
"to": "Skills.Spice.BinduDodge"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.BinduNerveStrike",
|
||||
"to": "Skills.Attribute.WeirdingWay2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.BinduNerveStrike",
|
||||
"to": "Skills.Perk.Backstabber"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.WeirdingStep",
|
||||
"to": "Skills.Attribute.WeirdingWay2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.WeirdingStep",
|
||||
"to": "Skills.Attribute.WeirdingWay1"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.WeirdingWay2",
|
||||
"to": "Skills.Perk.Backstabber"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.WeirdingWay1",
|
||||
"to": "Skills.Attribute.WeirdingWay2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.Hypersprint",
|
||||
"to": "Skills.Perk.Backstabber"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.WeirdingWay1",
|
||||
"to": "Skills.Perk.Backstabber"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.Hypersprint",
|
||||
"to": "Skills.Attribute.WeirdingWay1"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "The Voice",
|
||||
"cols": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"tag": "Skills.Spice.VoiceSplash",
|
||||
"id": "VoiceSplash",
|
||||
|
|
@ -144,7 +200,39 @@
|
|||
"maxPoints": 1,
|
||||
"icon": "t_ui_iconabilitythevoicecompel_d.webp",
|
||||
"url": "https://dune.gaming.tools/skills/skills-ability-voicecompel"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "Skills.Perk.VoiceAnalysis",
|
||||
"to": "Skills.Spice.VoiceSplash"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.VoiceStop",
|
||||
"to": "Skills.Spice.VoiceSplash"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.Blindspot",
|
||||
"to": "Skills.Perk.VoiceAnalysis"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.VoiceStop",
|
||||
"to": "Skills.Attribute.Manipulation1"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.Blindspot",
|
||||
"to": "Skills.Ability.VoiceCompel"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.VoiceCompel",
|
||||
"to": "Skills.Attribute.Manipulation1"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Body Control",
|
||||
"cols": 5,
|
||||
"nodes": [
|
||||
{
|
||||
"tag": "Skills.Ability.LitanyAgainstFear",
|
||||
"id": "LitanyAgainstFear",
|
||||
|
|
@ -246,50 +334,6 @@
|
|||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "Skills.Ability.BinduNerveStrike",
|
||||
"to": "Skills.Spice.BinduDodge"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.WeirdingStep",
|
||||
"to": "Skills.Spice.BinduDodge"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.BinduNerveStrike",
|
||||
"to": "Skills.Attribute.WeirdingWay2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.BinduNerveStrike",
|
||||
"to": "Skills.Perk.Backstabber"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.WeirdingStep",
|
||||
"to": "Skills.Attribute.WeirdingWay2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.WeirdingStep",
|
||||
"to": "Skills.Attribute.WeirdingWay1"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.WeirdingWay2",
|
||||
"to": "Skills.Perk.Backstabber"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.WeirdingWay1",
|
||||
"to": "Skills.Attribute.WeirdingWay2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.Hypersprint",
|
||||
"to": "Skills.Perk.Backstabber"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.WeirdingWay1",
|
||||
"to": "Skills.Perk.Backstabber"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.Hypersprint",
|
||||
"to": "Skills.Attribute.WeirdingWay1"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.LitanyAgainstFear",
|
||||
"to": "Skills.Perk.BinduStability"
|
||||
|
|
@ -339,4 +383,6 @@
|
|||
"to": "Skills.Attribute.SelfControl2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
{
|
||||
"id": "mentat",
|
||||
"name": "Mentat",
|
||||
"subtrees": [
|
||||
{
|
||||
"name": "Mental Calculus",
|
||||
"cols": 5,
|
||||
"nodes": [
|
||||
{
|
||||
"tag": "Skills.Perk.ShieldWeakpoint",
|
||||
|
|
@ -100,7 +104,63 @@
|
|||
"maxPoints": 3,
|
||||
"icon": "t_ui_iconabilityturretseeker_d.webp",
|
||||
"url": "https://dune.gaming.tools/skills/skills-ability-turretseeker"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "Skills.Perk.ExploitWeakness",
|
||||
"to": "Skills.Perk.ShieldWeakpoint"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.MentalCalculus5",
|
||||
"to": "Skills.Perk.ShieldWeakpoint"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.MentalCalculus3",
|
||||
"to": "Skills.Perk.ExploitWeakness"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Perk.ExploitWeakness",
|
||||
"to": "Skills.Perk.HeadShots"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.MentalCalculus5",
|
||||
"to": "Skills.Perk.HeadShots"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.MentalCalculus4",
|
||||
"to": "Skills.Attribute.MentalCalculus5"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.MentalCalculus1",
|
||||
"to": "Skills.Attribute.MentalCalculus3"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.MentalCalculus1",
|
||||
"to": "Skills.Perk.HeadShots"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.MentalCalculus2",
|
||||
"to": "Skills.Perk.HeadShots"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.MentalCalculus2",
|
||||
"to": "Skills.Attribute.MentalCalculus4"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.TurretSeeker",
|
||||
"to": "Skills.Attribute.MentalCalculus1"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.TurretSeeker",
|
||||
"to": "Skills.Attribute.MentalCalculus2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Assassination",
|
||||
"cols": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"tag": "Skills.Ability.HunterSeeker",
|
||||
"id": "HunterSeeker",
|
||||
|
|
@ -177,7 +237,55 @@
|
|||
"maxPoints": 3,
|
||||
"icon": "t_ui_icongadgetpoisoncapsulelauncher_d.webp",
|
||||
"url": "https://dune.gaming.tools/skills/skills-ability-poisoncapsulelauncher"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "Skills.Ability.HunterSeeker",
|
||||
"to": "Skills.Perk.PoisonTooth"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.HunterSeeker",
|
||||
"to": "Skills.Ability.StunDart"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Assassination2",
|
||||
"to": "Skills.Perk.PoisonTooth"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.PoisonMine",
|
||||
"to": "Skills.Perk.PoisonTooth"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.StunDart",
|
||||
"to": "Skills.Attribute.Assassination2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.StunDart",
|
||||
"to": "Skills.Attribute.Assassination1"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.PoisonMine",
|
||||
"to": "Skills.Attribute.Assassination2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Assassination1",
|
||||
"to": "Skills.Attribute.Assassination2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.PoisonCapsuleLauncher",
|
||||
"to": "Skills.Ability.PoisonMine"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.PoisonCapsuleLauncher",
|
||||
"to": "Skills.Attribute.Assassination1"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Tactician",
|
||||
"cols": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"tag": "Skills.Ability.PortableGenerator",
|
||||
"id": "PortableGenerator",
|
||||
|
|
@ -247,92 +355,30 @@
|
|||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "Skills.Perk.ExploitWeakness",
|
||||
"to": "Skills.Perk.ShieldWeakpoint"
|
||||
"from": "Skills.Ability.PortableGenerator",
|
||||
"to": "Skills.Ability.SuspensorMine_Reduction"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.MentalCalculus5",
|
||||
"to": "Skills.Perk.ShieldWeakpoint"
|
||||
"from": "Skills.Ability.PortableGenerator",
|
||||
"to": "Skills.Perk.IronWill"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.MentalCalculus3",
|
||||
"to": "Skills.Perk.ExploitWeakness"
|
||||
"from": "Skills.Ability.SuspensorMine_Amplification",
|
||||
"to": "Skills.Ability.SuspensorMine_Reduction"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Perk.ExploitWeakness",
|
||||
"to": "Skills.Perk.HeadShots"
|
||||
"from": "Skills.Ability.SolidoDecoy",
|
||||
"to": "Skills.Perk.IronWill"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.MentalCalculus5",
|
||||
"to": "Skills.Perk.HeadShots"
|
||||
"from": "Skills.Ability.SuspensorMine_Amplification",
|
||||
"to": "Skills.Ability.SuspensorWall"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.MentalCalculus4",
|
||||
"to": "Skills.Attribute.MentalCalculus5"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.MentalCalculus1",
|
||||
"to": "Skills.Attribute.MentalCalculus3"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.MentalCalculus1",
|
||||
"to": "Skills.Perk.HeadShots"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.MentalCalculus2",
|
||||
"to": "Skills.Perk.HeadShots"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.MentalCalculus2",
|
||||
"to": "Skills.Attribute.MentalCalculus4"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.TurretSeeker",
|
||||
"to": "Skills.Attribute.MentalCalculus1"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.TurretSeeker",
|
||||
"to": "Skills.Attribute.MentalCalculus2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.HunterSeeker",
|
||||
"to": "Skills.Perk.PoisonTooth"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.HunterSeeker",
|
||||
"to": "Skills.Ability.StunDart"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Assassination2",
|
||||
"to": "Skills.Perk.PoisonTooth"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.PoisonMine",
|
||||
"to": "Skills.Perk.PoisonTooth"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.StunDart",
|
||||
"to": "Skills.Attribute.Assassination2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.StunDart",
|
||||
"to": "Skills.Attribute.Assassination1"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.PoisonMine",
|
||||
"to": "Skills.Attribute.Assassination2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Assassination1",
|
||||
"to": "Skills.Attribute.Assassination2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.PoisonCapsuleLauncher",
|
||||
"to": "Skills.Ability.PoisonMine"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.PoisonCapsuleLauncher",
|
||||
"to": "Skills.Attribute.Assassination1"
|
||||
"from": "Skills.Ability.SolidoDecoy",
|
||||
"to": "Skills.Ability.SuspensorWall"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
{
|
||||
"id": "planetologist",
|
||||
"name": "Planetologist",
|
||||
"subtrees": [
|
||||
{
|
||||
"name": "Scientist",
|
||||
"cols": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"tag": "Skills.Perk.BatteryExpert",
|
||||
|
|
@ -78,7 +82,55 @@
|
|||
"maxPoints": 3,
|
||||
"icon": "t_ui_iconskilltreeattributemineralyield_d.webp",
|
||||
"url": "https://dune.gaming.tools/skills/skills-attribute-scientist1"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist5",
|
||||
"to": "Skills.Perk.BatteryExpert"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Perk.BatteryExpert",
|
||||
"to": "Skills.Science.m_PowerMax"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist4",
|
||||
"to": "Skills.Attribute.Scientist5"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist2",
|
||||
"to": "Skills.Attribute.Scientist5"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist4",
|
||||
"to": "Skills.Science.m_PowerMax"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist3",
|
||||
"to": "Skills.Science.m_PowerMax"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist2",
|
||||
"to": "Skills.Attribute.Scientist4"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist3",
|
||||
"to": "Skills.Attribute.Scientist4"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist1",
|
||||
"to": "Skills.Attribute.Scientist2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist1",
|
||||
"to": "Skills.Attribute.Scientist3"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Explorer",
|
||||
"cols": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"tag": "Skills.Attribute.Explorer5",
|
||||
"id": "Explorer5",
|
||||
|
|
@ -144,7 +196,39 @@
|
|||
"maxPoints": 1,
|
||||
"icon": "t_ui_iconabilitysuspensorpad_d.webp",
|
||||
"url": "https://dune.gaming.tools/skills/skills-ability-suspensorpad"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "Skills.Attribute.Explorer3",
|
||||
"to": "Skills.Attribute.Explorer5"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Explorer4",
|
||||
"to": "Skills.Attribute.Explorer5"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Explorer1",
|
||||
"to": "Skills.Attribute.Explorer3"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Explorer2",
|
||||
"to": "Skills.Attribute.Explorer4"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.SuspensorPad",
|
||||
"to": "Skills.Attribute.Explorer1"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.SuspensorPad",
|
||||
"to": "Skills.Attribute.Explorer2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Mechanic",
|
||||
"cols": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"tag": "Skills.Spice.VehicleHeat",
|
||||
"id": "VehicleHeat",
|
||||
|
|
@ -225,44 +309,46 @@
|
|||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist5",
|
||||
"to": "Skills.Perk.BatteryExpert"
|
||||
"from": "Skills.Attribute.Driver5",
|
||||
"to": "Skills.Spice.VehicleHeat"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Perk.BatteryExpert",
|
||||
"to": "Skills.Science.m_PowerMax"
|
||||
"from": "Skills.Attribute.Driver6",
|
||||
"to": "Skills.Spice.VehicleHeat"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist4",
|
||||
"to": "Skills.Attribute.Scientist5"
|
||||
"from": "Skills.Attribute.Driver4",
|
||||
"to": "Skills.Attribute.Driver5"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist2",
|
||||
"to": "Skills.Attribute.Scientist5"
|
||||
"from": "Skills.Attribute.Driver2",
|
||||
"to": "Skills.Attribute.Driver5"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist4",
|
||||
"to": "Skills.Science.m_PowerMax"
|
||||
"from": "Skills.Attribute.Driver4",
|
||||
"to": "Skills.Attribute.Driver6"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist3",
|
||||
"to": "Skills.Science.m_PowerMax"
|
||||
"from": "Skills.Attribute.Driver3",
|
||||
"to": "Skills.Attribute.Driver6"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist2",
|
||||
"to": "Skills.Attribute.Scientist4"
|
||||
"from": "Skills.Attribute.Driver2",
|
||||
"to": "Skills.Attribute.Driver4"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist3",
|
||||
"to": "Skills.Attribute.Scientist4"
|
||||
"from": "Skills.Attribute.Driver3",
|
||||
"to": "Skills.Attribute.Driver4"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist1",
|
||||
"to": "Skills.Attribute.Scientist2"
|
||||
"from": "Skills.Attribute.Driver1",
|
||||
"to": "Skills.Attribute.Driver2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Scientist1",
|
||||
"to": "Skills.Attribute.Scientist3"
|
||||
"from": "Skills.Attribute.Driver1",
|
||||
"to": "Skills.Attribute.Driver3"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
{
|
||||
"id": "swordmaster",
|
||||
"name": "Swordmaster",
|
||||
"subtrees": [
|
||||
{
|
||||
"name": "The Blade",
|
||||
"cols": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"tag": "Skills.Spice.ParryBoost",
|
||||
|
|
@ -78,7 +82,55 @@
|
|||
"maxPoints": 3,
|
||||
"icon": "t_ui_iconskilltreeskillbrawler_d.webp",
|
||||
"url": "https://dune.gaming.tools/skills/skills-attribute-blade1"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "Skills.Ability.Whirlwind",
|
||||
"to": "Skills.Spice.ParryBoost"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.RiposteBreak",
|
||||
"to": "Skills.Spice.ParryBoost"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.Whirlwind",
|
||||
"to": "Skills.Attribute.Blade2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.Whirlwind",
|
||||
"to": "Skills.Perk.MeleeChain"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.RiposteBreak",
|
||||
"to": "Skills.Attribute.Blade2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.RiposteBreak",
|
||||
"to": "Skills.Ability.RiposteInjure"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Blade2",
|
||||
"to": "Skills.Perk.MeleeChain"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.RiposteInjure",
|
||||
"to": "Skills.Attribute.Blade2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Blade1",
|
||||
"to": "Skills.Perk.MeleeChain"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.RiposteInjure",
|
||||
"to": "Skills.Attribute.Blade1"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "The Will",
|
||||
"cols": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"tag": "Skills.Perk.ThriveOnDanger",
|
||||
"id": "ThriveOnDanger",
|
||||
|
|
@ -144,7 +196,39 @@
|
|||
"maxPoints": 1,
|
||||
"icon": "t_ui_iconabilitydeflection_d.webp",
|
||||
"url": "https://dune.gaming.tools/skills/skills-ability-deflectionslow"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "Skills.Attribute.Resolve2",
|
||||
"to": "Skills.Perk.ThriveOnDanger"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.UnstoppableAttacks",
|
||||
"to": "Skills.Perk.ThriveOnDanger"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Resolve1",
|
||||
"to": "Skills.Attribute.Resolve2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.UnstoppableAttacks",
|
||||
"to": "Skills.Perk.ToughLunge"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.DeflectionSlow",
|
||||
"to": "Skills.Attribute.Resolve1"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.DeflectionSlow",
|
||||
"to": "Skills.Perk.ToughLunge"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "The Way",
|
||||
"cols": 5,
|
||||
"nodes": [
|
||||
{
|
||||
"tag": "Skills.Spice.ShadowStrike",
|
||||
"id": "ShadowStrike",
|
||||
|
|
@ -246,46 +330,6 @@
|
|||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "Skills.Ability.Whirlwind",
|
||||
"to": "Skills.Spice.ParryBoost"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.RiposteBreak",
|
||||
"to": "Skills.Spice.ParryBoost"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.Whirlwind",
|
||||
"to": "Skills.Attribute.Blade2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.Whirlwind",
|
||||
"to": "Skills.Perk.MeleeChain"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.RiposteBreak",
|
||||
"to": "Skills.Attribute.Blade2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.RiposteBreak",
|
||||
"to": "Skills.Ability.RiposteInjure"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Blade2",
|
||||
"to": "Skills.Perk.MeleeChain"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.RiposteInjure",
|
||||
"to": "Skills.Attribute.Blade2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Blade1",
|
||||
"to": "Skills.Perk.MeleeChain"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.RiposteInjure",
|
||||
"to": "Skills.Attribute.Blade1"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Aggression3",
|
||||
"to": "Skills.Spice.ShadowStrike"
|
||||
|
|
@ -335,4 +379,6 @@
|
|||
"to": "Skills.Attribute.Aggression2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
{
|
||||
"id": "trooper",
|
||||
"name": "Trooper",
|
||||
"subtrees": [
|
||||
{
|
||||
"name": "Gunnery",
|
||||
"cols": 5,
|
||||
"nodes": [
|
||||
{
|
||||
"tag": "Skills.Ability.EnergyCapsule",
|
||||
|
|
@ -100,7 +104,63 @@
|
|||
"maxPoints": 3,
|
||||
"icon": "t_ui_iconskilltreeattributedamage_d.webp",
|
||||
"url": "https://dune.gaming.tools/skills/skills-attribute-weaponry1"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "Skills.Ability.EnergyCapsule",
|
||||
"to": "Skills.Attribute.Weaponry5"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.EnergyCapsule",
|
||||
"to": "Skills.Attribute.Weaponry6"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry5",
|
||||
"to": "Skills.Perk.HeavyWeaponNaib"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry3",
|
||||
"to": "Skills.Attribute.Weaponry5"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry3",
|
||||
"to": "Skills.Attribute.Weaponry6"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry4",
|
||||
"to": "Skills.Attribute.Weaponry6"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry2",
|
||||
"to": "Skills.Perk.HeavyWeaponNaib"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry2",
|
||||
"to": "Skills.Attribute.Weaponry3"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry3",
|
||||
"to": "Skills.Perk.BodyShots"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry4",
|
||||
"to": "Skills.Perk.BodyShots"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry1",
|
||||
"to": "Skills.Attribute.Weaponry2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry1",
|
||||
"to": "Skills.Perk.BodyShots"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Suspensor Training",
|
||||
"cols": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"tag": "Skills.Ability.SuspensorBlast",
|
||||
"id": "SuspensorBlast",
|
||||
|
|
@ -177,7 +237,55 @@
|
|||
"maxPoints": 1,
|
||||
"icon": "t_ui_icongadgetreductionsuspensorgrenade_d.webp",
|
||||
"url": "https://dune.gaming.tools/skills/skills-ability-suspensorgrenade_reduction"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "Skills.Ability.SuspensorBlast",
|
||||
"to": "Skills.Perk.DeathFromAbove"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.CollapseGrenade",
|
||||
"to": "Skills.Ability.SuspensorBlast"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.SuspensorTech1",
|
||||
"to": "Skills.Perk.DeathFromAbove"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Perk.DeathFromAbove",
|
||||
"to": "Skills.Perk.SuspensorDash"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.CollapseGrenade",
|
||||
"to": "Skills.Attribute.SuspensorTech1"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.CollapseGrenade",
|
||||
"to": "Skills.Ability.SuspensorGrenade_Amplification"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.SuspensorTech1",
|
||||
"to": "Skills.Perk.SuspensorDash"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.SuspensorGrenade_Amplification",
|
||||
"to": "Skills.Attribute.SuspensorTech1"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.SuspensorGrenade_Reduction",
|
||||
"to": "Skills.Perk.SuspensorDash"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.SuspensorGrenade_Amplification",
|
||||
"to": "Skills.Ability.SuspensorGrenade_Reduction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Tactical Tech",
|
||||
"cols": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"tag": "Skills.Spice.GadgetReload",
|
||||
"id": "GadgetReload",
|
||||
|
|
@ -247,92 +355,30 @@
|
|||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "Skills.Ability.EnergyCapsule",
|
||||
"to": "Skills.Attribute.Weaponry5"
|
||||
"from": "Skills.Ability.AssaultSeeker",
|
||||
"to": "Skills.Spice.GadgetReload"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.EnergyCapsule",
|
||||
"to": "Skills.Attribute.Weaponry6"
|
||||
"from": "Skills.Ability.MagneticAttractor",
|
||||
"to": "Skills.Spice.GadgetReload"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry5",
|
||||
"to": "Skills.Perk.HeavyWeaponNaib"
|
||||
"from": "Skills.Ability.AssaultSeeker",
|
||||
"to": "Skills.Ability.FragGrenade"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry3",
|
||||
"to": "Skills.Attribute.Weaponry5"
|
||||
"from": "Skills.Ability.MagneticAttractor",
|
||||
"to": "Skills.Perk.TrooperCooldowns"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry3",
|
||||
"to": "Skills.Attribute.Weaponry6"
|
||||
"from": "Skills.Ability.CablePull",
|
||||
"to": "Skills.Ability.FragGrenade"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry4",
|
||||
"to": "Skills.Attribute.Weaponry6"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry2",
|
||||
"to": "Skills.Perk.HeavyWeaponNaib"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry2",
|
||||
"to": "Skills.Attribute.Weaponry3"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry3",
|
||||
"to": "Skills.Perk.BodyShots"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry4",
|
||||
"to": "Skills.Perk.BodyShots"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry1",
|
||||
"to": "Skills.Attribute.Weaponry2"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.Weaponry1",
|
||||
"to": "Skills.Perk.BodyShots"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.SuspensorBlast",
|
||||
"to": "Skills.Perk.DeathFromAbove"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.CollapseGrenade",
|
||||
"to": "Skills.Ability.SuspensorBlast"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.SuspensorTech1",
|
||||
"to": "Skills.Perk.DeathFromAbove"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Perk.DeathFromAbove",
|
||||
"to": "Skills.Perk.SuspensorDash"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.CollapseGrenade",
|
||||
"to": "Skills.Attribute.SuspensorTech1"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.CollapseGrenade",
|
||||
"to": "Skills.Ability.SuspensorGrenade_Amplification"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Attribute.SuspensorTech1",
|
||||
"to": "Skills.Perk.SuspensorDash"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.SuspensorGrenade_Amplification",
|
||||
"to": "Skills.Attribute.SuspensorTech1"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.SuspensorGrenade_Reduction",
|
||||
"to": "Skills.Perk.SuspensorDash"
|
||||
},
|
||||
{
|
||||
"from": "Skills.Ability.SuspensorGrenade_Amplification",
|
||||
"to": "Skills.Ability.SuspensorGrenade_Reduction"
|
||||
"from": "Skills.Ability.CablePull",
|
||||
"to": "Skills.Perk.TrooperCooldowns"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
character-builder/frontend/public/icons/slot-ability.png
Normal file
BIN
character-builder/frontend/public/icons/slot-ability.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
character-builder/frontend/public/icons/slot-technique.png
Normal file
BIN
character-builder/frontend/public/icons/slot-technique.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3 KiB |
|
|
@ -4,6 +4,7 @@ import XpProgressCard from './components/XpProgressCard.vue';
|
|||
import FactionTrack from './components/FactionTrack.vue';
|
||||
import SkillTree from './components/SkillTree.vue';
|
||||
import CharacterSummary from './components/CharacterSummary.vue';
|
||||
import LoadoutSlots from './components/LoadoutSlots.vue';
|
||||
import {
|
||||
applyBuild,
|
||||
build,
|
||||
|
|
@ -143,10 +144,12 @@ const spentByClass = computed<Record<ClassId, number>>(() => {
|
|||
for (const c of CLASSES) {
|
||||
const tree = skillTrees.value[c.id];
|
||||
if (!tree) continue;
|
||||
for (const node of tree.nodes) {
|
||||
for (const st of tree.subtrees) {
|
||||
for (const node of st.nodes) {
|
||||
out[c.id] += build.skills[node.tag] || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
|
|
@ -433,6 +436,14 @@ const specMeta: Record<SpecId, { name: string; sym: string }> = {
|
|||
@update:allocations="(a) => (build.skills = a)"
|
||||
@reset="resetAllSkills"
|
||||
/>
|
||||
|
||||
<LoadoutSlots
|
||||
:loadout="build.loadout"
|
||||
:skill-trees="skillTrees"
|
||||
:allocations="build.skills"
|
||||
:classes="CLASSES"
|
||||
@update:loadout="(l) => (build.loadout = l)"
|
||||
/>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -104,7 +104,8 @@ const skillsByClass = computed<ClassSkillSummary[]>(() => {
|
|||
const allocations: ClassSkillSummary['allocations'] = [];
|
||||
let spent = 0;
|
||||
if (tree) {
|
||||
for (const node of tree.nodes) {
|
||||
for (const st of tree.subtrees) {
|
||||
for (const node of st.nodes) {
|
||||
const pts = props.build.skills[node.tag] || 0;
|
||||
if (pts > 0) {
|
||||
spent += pts;
|
||||
|
|
@ -118,6 +119,7 @@ const skillsByClass = computed<ClassSkillSummary[]>(() => {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (spent > 0 || tree)
|
||||
out.push({ id: c.id, name: c.name, spent, allocations });
|
||||
}
|
||||
|
|
|
|||
424
character-builder/frontend/src/components/LoadoutSlots.vue
Normal file
424
character-builder/frontend/src/components/LoadoutSlots.vue
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { ClassId, Loadout, SkillNode, SkillTree } from '../types';
|
||||
|
||||
const props = defineProps<{
|
||||
loadout: Loadout;
|
||||
skillTrees: Record<ClassId, SkillTree | null>;
|
||||
allocations: Record<string, number>;
|
||||
classes: { id: ClassId; name: string }[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:loadout': [next: Loadout];
|
||||
}>();
|
||||
|
||||
type SlotKind = 'ability' | 'technique';
|
||||
|
||||
interface SkillOption {
|
||||
tag: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
kind: string;
|
||||
classId: ClassId;
|
||||
className: string;
|
||||
}
|
||||
|
||||
// Index every allocated node across all 5 trees so we can both render the
|
||||
// chosen slot icon AND show a picker of eligible options.
|
||||
const allAllocatedByTag = computed(() => {
|
||||
const out = new Map<string, SkillOption>();
|
||||
for (const c of props.classes) {
|
||||
const tree = props.skillTrees[c.id];
|
||||
if (!tree) continue;
|
||||
for (const st of tree.subtrees) {
|
||||
for (const node of st.nodes) {
|
||||
if ((props.allocations[node.tag] || 0) > 0) {
|
||||
out.set(node.tag, {
|
||||
tag: node.tag,
|
||||
name: node.name,
|
||||
icon: node.icon,
|
||||
kind: node.kind,
|
||||
classId: c.id,
|
||||
className: c.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function eligibleFor(kind: SlotKind): SkillOption[] {
|
||||
const result: SkillOption[] = [];
|
||||
for (const opt of allAllocatedByTag.value.values()) {
|
||||
if (kind === 'ability') {
|
||||
if (opt.kind === 'Ability' || opt.kind === 'Spice') result.push(opt);
|
||||
} else {
|
||||
if (opt.kind === 'Perk') result.push(opt);
|
||||
}
|
||||
}
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
a.className.localeCompare(b.className) || a.name.localeCompare(b.name),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
const pickerOpenFor = ref<{ kind: SlotKind; index: number } | null>(null);
|
||||
|
||||
function openPicker(kind: SlotKind, index: number) {
|
||||
pickerOpenFor.value = { kind, index };
|
||||
}
|
||||
function closePicker() {
|
||||
pickerOpenFor.value = null;
|
||||
}
|
||||
|
||||
function assign(tag: string | null) {
|
||||
const sel = pickerOpenFor.value;
|
||||
if (!sel) return;
|
||||
const next: Loadout = {
|
||||
abilities: [...props.loadout.abilities],
|
||||
techniques: [...props.loadout.techniques],
|
||||
};
|
||||
const arr = sel.kind === 'ability' ? next.abilities : next.techniques;
|
||||
// If the same tag is already in another slot of the same kind, clear it.
|
||||
if (tag) {
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (arr[i] === tag) arr[i] = null;
|
||||
}
|
||||
}
|
||||
arr[sel.index] = tag;
|
||||
emit('update:loadout', next);
|
||||
closePicker();
|
||||
}
|
||||
|
||||
function clearSlot(kind: SlotKind, index: number) {
|
||||
const next: Loadout = {
|
||||
abilities: [...props.loadout.abilities],
|
||||
techniques: [...props.loadout.techniques],
|
||||
};
|
||||
(kind === 'ability' ? next.abilities : next.techniques)[index] = null;
|
||||
emit('update:loadout', next);
|
||||
}
|
||||
|
||||
function lookup(tag: string | null): SkillOption | null {
|
||||
if (!tag) return null;
|
||||
return allAllocatedByTag.value.get(tag) || null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="loadout">
|
||||
<div class="loadout-head">
|
||||
<h3>Loadout</h3>
|
||||
<div class="hint">
|
||||
3 Abilities + 3 Techniques · drawn from your allocated skills across
|
||||
all classes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="loadout-row">
|
||||
<div class="row-label">Abilities</div>
|
||||
<div class="slots">
|
||||
<div
|
||||
v-for="(tag, i) in loadout.abilities"
|
||||
:key="`ab-${i}`"
|
||||
class="slot slot-ability"
|
||||
:class="{ filled: !!tag }"
|
||||
@click="openPicker('ability', i)"
|
||||
@contextmenu.prevent="clearSlot('ability', i)"
|
||||
:title="lookup(tag)?.name || `Empty ability slot ${i + 1}`"
|
||||
>
|
||||
<img
|
||||
v-if="lookup(tag)?.icon"
|
||||
:src="`/icons/${lookup(tag)!.icon}`"
|
||||
:alt="lookup(tag)?.name"
|
||||
class="slot-icon"
|
||||
draggable="false"
|
||||
/>
|
||||
<span v-else class="slot-placeholder">+</span>
|
||||
<span v-if="lookup(tag)" class="slot-label">
|
||||
{{ lookup(tag)!.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="loadout-row">
|
||||
<div class="row-label">Techniques</div>
|
||||
<div class="slots">
|
||||
<div
|
||||
v-for="(tag, i) in loadout.techniques"
|
||||
:key="`te-${i}`"
|
||||
class="slot slot-technique"
|
||||
:class="{ filled: !!tag }"
|
||||
@click="openPicker('technique', i)"
|
||||
@contextmenu.prevent="clearSlot('technique', i)"
|
||||
:title="lookup(tag)?.name || `Empty technique slot ${i + 1}`"
|
||||
>
|
||||
<img
|
||||
v-if="lookup(tag)?.icon"
|
||||
:src="`/icons/${lookup(tag)!.icon}`"
|
||||
:alt="lookup(tag)?.name"
|
||||
class="slot-icon"
|
||||
draggable="false"
|
||||
/>
|
||||
<span v-else class="slot-placeholder">+</span>
|
||||
<span v-if="lookup(tag)" class="slot-label">
|
||||
{{ lookup(tag)!.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Picker modal -->
|
||||
<div v-if="pickerOpenFor" class="picker-overlay" @click="closePicker">
|
||||
<div class="picker" @click.stop>
|
||||
<div class="picker-head">
|
||||
<h4>
|
||||
Pick {{ pickerOpenFor.kind === 'ability' ? 'Ability' : 'Technique' }}
|
||||
for Slot {{ pickerOpenFor.index + 1 }}
|
||||
</h4>
|
||||
<button @click="closePicker">Cancel</button>
|
||||
</div>
|
||||
<div class="picker-options" v-if="eligibleFor(pickerOpenFor.kind).length > 0">
|
||||
<button class="picker-clear" @click="assign(null)">
|
||||
Clear slot
|
||||
</button>
|
||||
<button
|
||||
v-for="opt in eligibleFor(pickerOpenFor.kind)"
|
||||
:key="opt.tag"
|
||||
class="picker-option"
|
||||
:class="{
|
||||
selected:
|
||||
(pickerOpenFor.kind === 'ability'
|
||||
? loadout.abilities
|
||||
: loadout.techniques
|
||||
).includes(opt.tag),
|
||||
}"
|
||||
@click="assign(opt.tag)"
|
||||
>
|
||||
<img
|
||||
v-if="opt.icon"
|
||||
:src="`/icons/${opt.icon}`"
|
||||
:alt="opt.name"
|
||||
class="picker-icon"
|
||||
draggable="false"
|
||||
/>
|
||||
<span class="picker-cls">{{ opt.className }}</span>
|
||||
<span class="picker-name">{{ opt.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="picker-empty">
|
||||
No allocated
|
||||
{{ pickerOpenFor.kind === 'ability' ? 'Abilities or Spice skills' : 'Perks' }}
|
||||
yet. Spend points in a skill tree first.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loadout {
|
||||
margin-top: 22px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px dashed var(--line-soft);
|
||||
}
|
||||
.loadout-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.loadout-head h3 {
|
||||
font-family: 'Cormorant Garamond', serif;
|
||||
font-size: 22px;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
color: var(--sand);
|
||||
}
|
||||
.loadout-head .hint {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10.5px;
|
||||
color: var(--ink-muted);
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.loadout-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.row-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-muted);
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.slots {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.slot {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border: 2px dashed var(--line);
|
||||
border-radius: 6px;
|
||||
background-color: var(--bg-2);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.1s;
|
||||
position: relative;
|
||||
}
|
||||
.slot:hover { border-color: var(--sand-2); transform: translateY(-1px); }
|
||||
.slot.filled {
|
||||
border-style: solid;
|
||||
border-color: var(--spice);
|
||||
background-color: rgba(224, 138, 60, 0.08);
|
||||
}
|
||||
.slot-ability { background-image: url('/icons/slot-ability.png'); }
|
||||
.slot-technique { background-image: url('/icons/slot-technique.png'); }
|
||||
.slot.filled.slot-ability,
|
||||
.slot.filled.slot-technique {
|
||||
/* dim the background tile so the icon stands out */
|
||||
background-blend-mode: multiply;
|
||||
background-color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.slot-icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.6));
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
.slot-placeholder {
|
||||
font-family: 'Cormorant Garamond', serif;
|
||||
font-size: 32px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.slot-label {
|
||||
position: absolute;
|
||||
bottom: -22px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--ink-dim);
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 0 4px var(--bg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Picker */
|
||||
.picker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 24px;
|
||||
}
|
||||
.picker {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 22px;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.picker-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px dashed var(--line-soft);
|
||||
}
|
||||
.picker-head h4 {
|
||||
font-family: 'Cormorant Garamond', serif;
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
color: var(--sand);
|
||||
}
|
||||
.picker-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.picker-option,
|
||||
.picker-clear {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 6px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-2);
|
||||
color: var(--ink-dim);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.picker-option:hover,
|
||||
.picker-clear:hover {
|
||||
border-color: var(--sand-2);
|
||||
color: var(--sand);
|
||||
}
|
||||
.picker-option.selected {
|
||||
border-color: var(--spice);
|
||||
background: rgba(224, 138, 60, 0.08);
|
||||
}
|
||||
.picker-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.picker-cls {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--ink-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.picker-name {
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.picker-clear {
|
||||
font-style: italic;
|
||||
color: var(--ember);
|
||||
}
|
||||
.picker-empty {
|
||||
padding: 16px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { SkillTree } from '../types';
|
||||
import type { SkillSubtree, SkillTree } from '../types';
|
||||
|
||||
const props = defineProps<{
|
||||
tree: SkillTree | null;
|
||||
|
|
@ -13,87 +13,36 @@ const emit = defineEmits<{
|
|||
reset: [];
|
||||
}>();
|
||||
|
||||
// Cell size + gap in pixels. Match CSS .tree-cell.
|
||||
const CELL = 96;
|
||||
const GAP = 36; // visual spacing including label area
|
||||
const CELL = 72; // cell size in px — matches the source HTML (72px tracks)
|
||||
const GAP_X = 32; // horizontal gap between cells in a subtree
|
||||
const GAP_Y = 60; // vertical gap (leaves space for a multi-line name label below each node)
|
||||
|
||||
const gridSize = computed(() => {
|
||||
if (!props.tree) return { rows: 0, cols: 0 };
|
||||
let maxRow = 0;
|
||||
let maxCol = 0;
|
||||
for (const n of props.tree.nodes) {
|
||||
if (n.row > maxRow) maxRow = n.row;
|
||||
if (n.col > maxCol) maxCol = n.col;
|
||||
}
|
||||
return { rows: maxRow, cols: maxCol };
|
||||
});
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(${gridSize.value.cols}, ${CELL}px)`,
|
||||
gridTemplateRows: `repeat(${gridSize.value.rows}, ${CELL}px)`,
|
||||
width: `${gridSize.value.cols * CELL + (gridSize.value.cols - 1) * GAP}px`,
|
||||
columnGap: `${GAP}px`,
|
||||
rowGap: `${GAP}px`,
|
||||
}));
|
||||
|
||||
// Total points spent in the entire build (across all 5 trees). Used for the
|
||||
// global budget check.
|
||||
const totalAllocated = computed(() =>
|
||||
Object.values(props.allocations).reduce((a, b) => a + (b || 0), 0),
|
||||
);
|
||||
|
||||
// Points spent inside THIS tree only.
|
||||
const thisTreeAllocated = computed(() => {
|
||||
if (!props.tree) return 0;
|
||||
let n = 0;
|
||||
for (const node of props.tree.nodes) {
|
||||
for (const st of props.tree.subtrees) {
|
||||
for (const node of st.nodes) {
|
||||
n += props.allocations[node.tag] || 0;
|
||||
}
|
||||
return n;
|
||||
});
|
||||
|
||||
function centerForNode(row: number, col: number) {
|
||||
const x = (col - 1) * (CELL + GAP) + CELL / 2;
|
||||
const y = (row - 1) * (CELL + GAP) + CELL / 2;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
const edgeLines = computed(() => {
|
||||
if (!props.tree) return [];
|
||||
const byTag = new Map(props.tree.nodes.map((n) => [n.tag, n] as const));
|
||||
const lines: { x1: number; y1: number; x2: number; y2: number }[] = [];
|
||||
for (const e of props.tree.edges) {
|
||||
const a = byTag.get(e.from);
|
||||
const b = byTag.get(e.to);
|
||||
if (!a || !b) continue;
|
||||
const pa = centerForNode(a.row, a.col);
|
||||
const pb = centerForNode(b.row, b.col);
|
||||
lines.push({ x1: pa.x, y1: pa.y, x2: pb.x, y2: pb.y });
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
|
||||
const svgSize = computed(() => {
|
||||
const w = gridSize.value.cols * CELL + (gridSize.value.cols - 1) * GAP;
|
||||
const h = gridSize.value.rows * CELL + (gridSize.value.rows - 1) * GAP;
|
||||
return { w, h };
|
||||
return n;
|
||||
});
|
||||
|
||||
function pointsFor(tag: string): number {
|
||||
return props.allocations[tag] || 0;
|
||||
}
|
||||
|
||||
function add(tag: string, delta: number) {
|
||||
if (!props.tree) return;
|
||||
const node = props.tree.nodes.find((n) => n.tag === tag);
|
||||
if (!node) return;
|
||||
function add(tag: string, delta: number, maxPoints: number) {
|
||||
const cur = pointsFor(tag);
|
||||
const max = node.maxPoints;
|
||||
let next = cur + delta;
|
||||
if (next < 0) next = 0;
|
||||
if (next > max) next = max;
|
||||
if (next > maxPoints) next = maxPoints;
|
||||
if (delta > 0 && totalAllocated.value + (next - cur) > props.availablePoints) {
|
||||
return; // not enough points
|
||||
return;
|
||||
}
|
||||
const out = { ...props.allocations };
|
||||
if (next === 0) delete out[tag];
|
||||
|
|
@ -101,69 +50,135 @@ function add(tag: string, delta: number) {
|
|||
emit('update:allocations', out);
|
||||
}
|
||||
|
||||
function nodeClick(tag: string, e: MouseEvent) {
|
||||
if (e.shiftKey || e.button === 2) add(tag, -1);
|
||||
else add(tag, 1);
|
||||
function nodeClick(tag: string, maxPoints: number, e: MouseEvent) {
|
||||
if (e.shiftKey || e.button === 2) add(tag, -1, maxPoints);
|
||||
else add(tag, 1, maxPoints);
|
||||
}
|
||||
function onContext(tag: string, e: MouseEvent) {
|
||||
function onContext(tag: string, maxPoints: number, e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
add(tag, -1);
|
||||
add(tag, -1, maxPoints);
|
||||
}
|
||||
|
||||
// Build per-subtree layout metadata.
|
||||
interface LaidOutSubtree {
|
||||
name: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
width: number;
|
||||
height: number;
|
||||
nodes: SubtreeNode[];
|
||||
edges: EdgeLine[];
|
||||
}
|
||||
interface SubtreeNode {
|
||||
tag: string;
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
row: number;
|
||||
col: number;
|
||||
maxPoints: number;
|
||||
icon: string | null;
|
||||
cx: number;
|
||||
cy: number;
|
||||
}
|
||||
interface EdgeLine {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
function layoutSubtree(st: SkillSubtree): LaidOutSubtree {
|
||||
const cols = st.cols;
|
||||
let rows = 0;
|
||||
for (const n of st.nodes) {
|
||||
if (n.row > rows) rows = n.row;
|
||||
}
|
||||
// pixel center for grid (row,col) where (1,1) is the top-left cell center
|
||||
const centerFor = (row: number, col: number) => ({
|
||||
cx: (col - 1) * (CELL + GAP_X) + CELL / 2,
|
||||
cy: (row - 1) * (CELL + GAP_Y) + CELL / 2,
|
||||
});
|
||||
const nodes: SubtreeNode[] = st.nodes.map((n) => {
|
||||
const c = centerFor(n.row, n.col);
|
||||
return { ...n, cx: c.cx, cy: c.cy };
|
||||
});
|
||||
const byTag = new Map(nodes.map((n) => [n.tag, n] as const));
|
||||
const edges: EdgeLine[] = [];
|
||||
for (const e of st.edges) {
|
||||
const a = byTag.get(e.from);
|
||||
const b = byTag.get(e.to);
|
||||
if (!a || !b) continue;
|
||||
edges.push({ x1: a.cx, y1: a.cy, x2: b.cx, y2: b.cy });
|
||||
}
|
||||
const width = cols * CELL + (cols - 1) * GAP_X;
|
||||
const height = rows * CELL + (rows - 1) * GAP_Y;
|
||||
return {
|
||||
name: st.name,
|
||||
cols,
|
||||
rows,
|
||||
width,
|
||||
height,
|
||||
nodes,
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
const subtrees = computed<LaidOutSubtree[]>(() => {
|
||||
if (!props.tree) return [];
|
||||
return props.tree.subtrees.map(layoutSubtree);
|
||||
});
|
||||
|
||||
function subtreeSpent(st: LaidOutSubtree): number {
|
||||
let n = 0;
|
||||
for (const node of st.nodes) {
|
||||
n += props.allocations[node.tag] || 0;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-dim);
|
||||
"
|
||||
>
|
||||
<span style="color: var(--sand)">{{ thisTreeAllocated }}</span> in this
|
||||
tree
|
||||
<span style="color: var(--ink-muted); margin: 0 8px">·</span>
|
||||
<span :style="{ color: totalAllocated > availablePoints ? 'var(--ember)' : 'var(--ink-dim)' }">
|
||||
<div class="tree-toolbar">
|
||||
<div class="tree-stats">
|
||||
<span class="sand">{{ thisTreeAllocated }}</span> in this tree
|
||||
<span class="dot">·</span>
|
||||
<span :class="{ over: totalAllocated > availablePoints }">
|
||||
{{ totalAllocated }}
|
||||
</span>
|
||||
/ {{ availablePoints }} total
|
||||
</div>
|
||||
<div
|
||||
style="
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--ink-muted);
|
||||
letter-spacing: 0.16em;
|
||||
"
|
||||
>
|
||||
<div class="tree-hint">
|
||||
Click +1 · Shift-click / right-click −1
|
||||
</div>
|
||||
<button @click="emit('reset')">Reset All Trees</button>
|
||||
</div>
|
||||
|
||||
<div class="tree-wrap" v-if="tree">
|
||||
<div class="tree-grid" :style="gridStyle">
|
||||
<div class="subtree-row" v-if="tree">
|
||||
<div v-for="st in subtrees" :key="st.name" class="subtree">
|
||||
<div class="subtree-head">
|
||||
<h3>{{ st.name }}</h3>
|
||||
<span class="subtree-pts" v-if="subtreeSpent(st) > 0">
|
||||
{{ subtreeSpent(st) }} pts
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="subtree-canvas"
|
||||
:style="{
|
||||
width: st.width + 'px',
|
||||
height: st.height + 'px',
|
||||
}"
|
||||
>
|
||||
<svg
|
||||
class="tree-edges"
|
||||
:viewBox="`0 0 ${svgSize.w} ${svgSize.h}`"
|
||||
:width="svgSize.w"
|
||||
:height="svgSize.h"
|
||||
:viewBox="`0 0 ${st.width} ${st.height}`"
|
||||
:width="st.width"
|
||||
:height="st.height"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<line
|
||||
v-for="(ln, i) in edgeLines"
|
||||
v-for="(ln, i) in st.edges"
|
||||
:key="i"
|
||||
:x1="ln.x1"
|
||||
:y1="ln.y1"
|
||||
|
|
@ -174,12 +189,12 @@ function onContext(tag: string, e: MouseEvent) {
|
|||
/>
|
||||
</svg>
|
||||
<div
|
||||
v-for="n in tree.nodes"
|
||||
v-for="n in st.nodes"
|
||||
:key="n.tag"
|
||||
class="tree-node-wrap"
|
||||
class="abs-node-wrap"
|
||||
:style="{
|
||||
gridColumn: n.col,
|
||||
gridRow: n.row,
|
||||
left: n.cx - 36 + 'px',
|
||||
top: n.cy - 36 + 'px',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
|
|
@ -189,8 +204,8 @@ function onContext(tag: string, e: MouseEvent) {
|
|||
pointsFor(n.tag) > 0 ? 'allocated' : '',
|
||||
pointsFor(n.tag) >= n.maxPoints ? 'maxed' : '',
|
||||
]"
|
||||
@click="(e) => nodeClick(n.tag, e)"
|
||||
@contextmenu="(e) => onContext(n.tag, e)"
|
||||
@click="(e) => nodeClick(n.tag, n.maxPoints, e)"
|
||||
@contextmenu="(e) => onContext(n.tag, n.maxPoints, e)"
|
||||
:title="`${n.name} (${n.kind})`"
|
||||
>
|
||||
<img
|
||||
|
|
@ -208,8 +223,168 @@ function onContext(tag: string, e: MouseEvent) {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="padding: 32px; color: var(--ink-muted)">
|
||||
Loading skill tree…
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tree-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.tree-stats {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.tree-stats .sand { color: var(--sand); }
|
||||
.tree-stats .over { color: var(--ember); }
|
||||
.tree-stats .dot { color: var(--ink-muted); margin: 0 8px; }
|
||||
.tree-hint {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--ink-muted);
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.subtree-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 48px 56px;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 4px;
|
||||
padding: 36px 32px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.subtree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.subtree-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.subtree-head h3 {
|
||||
font-family: 'Cormorant Garamond', serif;
|
||||
font-size: 22px;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.subtree-pts {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--sand);
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.subtree-canvas {
|
||||
position: relative;
|
||||
}
|
||||
.tree-edges {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.abs-node-wrap {
|
||||
position: absolute;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
z-index: 1;
|
||||
}
|
||||
.abs-node-wrap .label {
|
||||
position: absolute;
|
||||
top: 76px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ink-dim);
|
||||
text-align: center;
|
||||
/* Constrain to node width so adjacent labels can't overlap horizontally.
|
||||
Long names wrap to multiple lines, but the row gap (44px) gives them
|
||||
room. */
|
||||
width: 72px;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 0 4px var(--bg);
|
||||
line-height: 1.15;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.tree-node {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: border-color 0.15s, background 0.15s, transform 0.1s;
|
||||
}
|
||||
.tree-node:hover { border-color: var(--sand-2); }
|
||||
.tree-node.allocated {
|
||||
border-color: var(--spice);
|
||||
background: rgba(224, 138, 60, 0.1);
|
||||
}
|
||||
.tree-node.maxed {
|
||||
border-color: var(--sand);
|
||||
background: rgba(230, 201, 138, 0.14);
|
||||
}
|
||||
.tree-node.kind-ability { border-style: solid; }
|
||||
.tree-node.kind-attribute { border-style: dashed; }
|
||||
.tree-node.kind-perk { border-radius: 50%; }
|
||||
.tree-node.kind-spice { border-style: double; }
|
||||
.tree-node .node-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: contain;
|
||||
filter: brightness(0.7) saturate(0.85);
|
||||
transition: filter 0.15s;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
.tree-node:hover .node-icon { filter: brightness(0.95); }
|
||||
.tree-node.allocated .node-icon { filter: brightness(1) saturate(1.1); }
|
||||
.tree-node.maxed .node-icon {
|
||||
filter: brightness(1.1) saturate(1.2) drop-shadow(0 0 6px var(--sand-2));
|
||||
}
|
||||
.tree-node .name {
|
||||
font-family: 'Cormorant Garamond', serif;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
line-height: 1.1;
|
||||
padding: 2px;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.tree-node .pts {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
color: var(--sand);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -19,9 +19,20 @@ export function defaultBuild(): BuildState {
|
|||
specs,
|
||||
faction: { tier: 0, standingInto: 0 },
|
||||
skills: {},
|
||||
loadout: { abilities: [null, null, null], techniques: [null, null, null] },
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSlots(raw: unknown, len: number): (string | null)[] {
|
||||
const arr = Array.isArray(raw) ? raw : [];
|
||||
const out: (string | null)[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
const v = arr[i];
|
||||
out.push(typeof v === 'string' && v.length > 0 ? v : null);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function migrate(raw: any): BuildState {
|
||||
const def = defaultBuild();
|
||||
if (!raw || typeof raw !== 'object') return def;
|
||||
|
|
@ -33,6 +44,10 @@ function migrate(raw: any): BuildState {
|
|||
specs: { ...def.specs },
|
||||
faction: { ...def.faction, ...(raw.faction || {}) },
|
||||
skills: { ...(raw.skills || {}) },
|
||||
loadout: {
|
||||
abilities: normalizeSlots(raw.loadout?.abilities, 3),
|
||||
techniques: normalizeSlots(raw.loadout?.techniques, 3),
|
||||
},
|
||||
};
|
||||
if (raw.specs && typeof raw.specs === 'object') {
|
||||
for (const k of Object.keys(out.specs) as SpecId[]) {
|
||||
|
|
|
|||
|
|
@ -66,11 +66,17 @@ export interface SkillEdge {
|
|||
to: string;
|
||||
}
|
||||
|
||||
export interface SkillSubtree {
|
||||
name: string;
|
||||
cols: number;
|
||||
nodes: SkillNode[];
|
||||
edges: SkillEdge[];
|
||||
}
|
||||
|
||||
export interface SkillTree {
|
||||
id: ClassId;
|
||||
name: string;
|
||||
nodes: SkillNode[];
|
||||
edges: SkillEdge[];
|
||||
subtrees: SkillSubtree[];
|
||||
}
|
||||
|
||||
export interface SpecProgress {
|
||||
|
|
@ -78,6 +84,14 @@ export interface SpecProgress {
|
|||
xpInto: number;
|
||||
}
|
||||
|
||||
// Loadout: 3 active Ability slots + 3 active Technique slots, both pulled
|
||||
// from the union of all allocated skills across all 5 trees (the cap is
|
||||
// global, not per-tree).
|
||||
export interface Loadout {
|
||||
abilities: (string | null)[]; // length 3 — element is a skill tag, or null
|
||||
techniques: (string | null)[]; // length 3
|
||||
}
|
||||
|
||||
export interface BuildState {
|
||||
v: 1; // schema version
|
||||
house: House;
|
||||
|
|
@ -95,4 +109,5 @@ export interface BuildState {
|
|||
// Allocated skill points keyed by full tag (e.g. Skills.Ability.BattleCry).
|
||||
// Tags are globally unique across classes, so one flat map works for all 5.
|
||||
skills: Record<string, number>;
|
||||
loadout: Loadout;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,28 +216,23 @@ LINE_RE = re.compile(
|
|||
)
|
||||
|
||||
|
||||
def extract_skill_tree(path: Path, class_id: str, class_name: str) -> dict:
|
||||
html = path.read_text()
|
||||
nodes = []
|
||||
# We need to also find the alt text + href + icon WITHIN each node's HTML.
|
||||
# Strategy: walk through all data-tag="Skills..." occurrences, slice from
|
||||
# opening of the node <div> to a balanced close. Simple slice: take 2000 chars
|
||||
# after the tag and parse first alt/href/icon/max within it.
|
||||
for m in re.finditer(r'data-tag="(Skills\.[^"]+)"', html):
|
||||
tag = m.group(1)
|
||||
start = m.start()
|
||||
chunk = html[start : start + 2500]
|
||||
SUBTREE_H3_RE = re.compile(
|
||||
r'<h3[^>]*class="[^"]*text-xl[^"]*"[^>]*>([^<]+)</h3>'
|
||||
)
|
||||
GRID_COLS_RE = re.compile(r"grid-template-columns:\s*repeat\((\d+),\s*72px\)")
|
||||
|
||||
|
||||
def _extract_node(chunk: str, tag: str) -> dict | None:
|
||||
gm = GRID_RE.search(chunk)
|
||||
if not gm:
|
||||
continue
|
||||
return None
|
||||
row, col = int(gm.group(1)), int(gm.group(2))
|
||||
alt = ALT_RE.search(chunk)
|
||||
href = HREF_RE.search(chunk)
|
||||
icon = ICON_RE.search(chunk)
|
||||
max_pts = MAX_PTS_RE.search(chunk)
|
||||
kind = tag.split(".")[1] if "." in tag else "Unknown"
|
||||
nodes.append(
|
||||
{
|
||||
return {
|
||||
"tag": tag,
|
||||
"id": tag.split(".")[-1],
|
||||
"name": alt.group(1) if alt else tag.split(".")[-1],
|
||||
|
|
@ -248,77 +243,96 @@ def extract_skill_tree(path: Path, class_id: str, class_name: str) -> dict:
|
|||
"icon": icon.group(1) if icon else None,
|
||||
"url": href.group(1) if href else None,
|
||||
}
|
||||
)
|
||||
|
||||
# de-duplicate nodes by tag (the regex can match twice if the same tag appears in
|
||||
# a connector tooltip etc.)
|
||||
seen = {}
|
||||
for n in nodes:
|
||||
if n["tag"] not in seen:
|
||||
seen[n["tag"]] = n
|
||||
nodes = list(seen.values())
|
||||
|
||||
# Build a position->node lookup. Grid is roughly square with ~73px cells based
|
||||
# on observed example (grid 3/5 -> center 364,220 means col*~73, row*~73 with offset).
|
||||
# We'll learn cell size from the data: if there are connectors, we map each (x,y) to
|
||||
# the nearest node by Euclidean distance.
|
||||
# First compute approximate node centers via grid math, calibrated from any node
|
||||
# we can pin: actually a more reliable approach is to use the connector geometry.
|
||||
edges = []
|
||||
lines = list(LINE_RE.finditer(html))
|
||||
if lines and nodes:
|
||||
# Calibrate: find scale by looking at min/max grid coords vs min/max line coords.
|
||||
def _map_edges(html_slice: str, nodes: list[dict]) -> list[dict]:
|
||||
"""Pixel-match connector <line> endpoints to the nearest nodes in this
|
||||
subtree. Returns deduped edge list."""
|
||||
lines = list(LINE_RE.finditer(html_slice))
|
||||
if not lines or not nodes:
|
||||
return []
|
||||
all_x = [int(x) for ln in lines for x in (ln.group("x1"), ln.group("x2"))]
|
||||
all_y = [int(y) for ln in lines for y in (ln.group("y1"), ln.group("y2"))]
|
||||
min_x, max_x = min(all_x), max(all_x)
|
||||
min_y, max_y = min(all_y), max(all_y)
|
||||
cols = [n["col"] for n in nodes]
|
||||
rows = [n["row"] for n in nodes]
|
||||
rows_ = [n["row"] for n in nodes]
|
||||
min_c, max_c = min(cols), max(cols)
|
||||
min_r, max_r = min(rows), max(rows)
|
||||
# avoid div by zero
|
||||
min_r, max_r = min(rows_), max(rows_)
|
||||
sx = (max_x - min_x) / max(1, (max_c - min_c))
|
||||
sy = (max_y - min_y) / max(1, (max_r - min_r))
|
||||
|
||||
def center(n):
|
||||
return (
|
||||
min_x + (n["col"] - min_c) * sx,
|
||||
min_y + (n["row"] - min_r) * sy,
|
||||
)
|
||||
centers = {
|
||||
n["tag"]: (min_x + (n["col"] - min_c) * sx, min_y + (n["row"] - min_r) * sy)
|
||||
for n in nodes
|
||||
}
|
||||
|
||||
centers = {n["tag"]: center(n) for n in nodes}
|
||||
|
||||
def nearest(x, y):
|
||||
best_tag, best_d = None, float("inf")
|
||||
for t, (cx, cy) in centers.items():
|
||||
def nearest(x: int, y: int) -> str | None:
|
||||
best, best_d = None, float("inf")
|
||||
for tag, (cx, cy) in centers.items():
|
||||
d = (cx - x) ** 2 + (cy - y) ** 2
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best_tag = t
|
||||
return best_tag
|
||||
best_d, best = d, tag
|
||||
return best
|
||||
|
||||
seen_edges = set()
|
||||
seen, edges = set(), []
|
||||
for ln in lines:
|
||||
x1, y1, x2, y2 = (
|
||||
int(ln.group("x1")),
|
||||
int(ln.group("y1")),
|
||||
int(ln.group("x2")),
|
||||
int(ln.group("y2")),
|
||||
)
|
||||
a = nearest(x1, y1)
|
||||
b = nearest(x2, y2)
|
||||
x1, y1, x2, y2 = (int(ln.group(k)) for k in ("x1", "y1", "x2", "y2"))
|
||||
a, b = nearest(x1, y1), nearest(x2, y2)
|
||||
if a and b and a != b:
|
||||
key = tuple(sorted((a, b)))
|
||||
if key not in seen_edges:
|
||||
seen_edges.add(key)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
edges.append({"from": key[0], "to": key[1]})
|
||||
return edges
|
||||
|
||||
return {
|
||||
"id": class_id,
|
||||
"name": class_name,
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
}
|
||||
|
||||
def extract_skill_tree(path: Path, class_id: str, class_name: str) -> dict:
|
||||
"""Parse a class skill tree into its named subtrees.
|
||||
|
||||
Each class is composed of 3 subtrees (e.g. Swordmaster has "The Blade",
|
||||
"The Will", "The Way"). Each subtree is its own CSS grid with its own
|
||||
column count, node positions, and connectors. Treating them as one big
|
||||
grid (the prior behavior) collapsed all 22 nodes on top of each other.
|
||||
"""
|
||||
html = path.read_text()
|
||||
# Split on subtree H3 headers; first chunk is preamble.
|
||||
chunks = SUBTREE_H3_RE.split(html)
|
||||
preamble, pairs = chunks[0], chunks[1:]
|
||||
|
||||
subtrees: list[dict] = []
|
||||
for i in range(0, len(pairs), 2):
|
||||
name = pairs[i].strip()
|
||||
body = pairs[i + 1] if i + 1 < len(pairs) else ""
|
||||
# Slice off anything that belongs to the next subtree (already handled
|
||||
# by split) or to trailing page chrome — search for the closing of the
|
||||
# graph div by counting from the start of the graph element.
|
||||
graph_start = body.find('<div class="graph svelte-1dvag2h"')
|
||||
if graph_start < 0:
|
||||
continue
|
||||
body = body[graph_start:]
|
||||
cols_m = GRID_COLS_RE.search(body)
|
||||
cols = int(cols_m.group(1)) if cols_m else 3
|
||||
|
||||
# Parse nodes inside this subtree.
|
||||
nodes: list[dict] = []
|
||||
seen_tags: set[str] = set()
|
||||
for m in re.finditer(r'data-tag="(Skills\.[^"]+)"', body):
|
||||
tag = m.group(1)
|
||||
if tag in seen_tags:
|
||||
continue
|
||||
chunk = body[m.start() : m.start() + 2500]
|
||||
node = _extract_node(chunk, tag)
|
||||
if node:
|
||||
nodes.append(node)
|
||||
seen_tags.add(tag)
|
||||
|
||||
edges = _map_edges(body, nodes)
|
||||
subtrees.append(
|
||||
{"name": name, "cols": cols, "nodes": nodes, "edges": edges}
|
||||
)
|
||||
|
||||
return {"id": class_id, "name": class_name, "subtrees": subtrees}
|
||||
|
||||
|
||||
# ---------- main ----------
|
||||
|
|
@ -413,13 +427,16 @@ def main():
|
|||
continue
|
||||
tree = extract_skill_tree(path, cls_id, cls_name)
|
||||
(OUT / f"skills-{cls_id}.json").write_text(json.dumps(tree, indent=2))
|
||||
total_nodes = sum(len(st["nodes"]) for st in tree["subtrees"])
|
||||
total_edges = sum(len(st["edges"]) for st in tree["subtrees"])
|
||||
manifest["skills"].append(
|
||||
{
|
||||
"id": cls_id,
|
||||
"name": cls_name,
|
||||
"file": f"skills-{cls_id}.json",
|
||||
"nodes": len(tree["nodes"]),
|
||||
"edges": len(tree["edges"]),
|
||||
"subtrees": [st["name"] for st in tree["subtrees"]],
|
||||
"nodes": total_nodes,
|
||||
"edges": total_edges,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -429,7 +446,8 @@ def main():
|
|||
path = OUT / f"skills-{cls_id}.json"
|
||||
if path.exists():
|
||||
tree = json.loads(path.read_text())
|
||||
for n in tree["nodes"]:
|
||||
for st in tree["subtrees"]:
|
||||
for n in st["nodes"]:
|
||||
if n.get("icon"):
|
||||
icon_names.add(n["icon"])
|
||||
for spec in specs:
|
||||
|
|
@ -440,6 +458,18 @@ def main():
|
|||
if p.get("icon"):
|
||||
icon_names.add(p["icon"])
|
||||
copied, missing = copy_icons(icon_names)
|
||||
|
||||
# Slot background images for the global Abilities + Techniques loadout —
|
||||
# the source HTML references them from a CDN, but local copies live in
|
||||
# the per-class _files directories.
|
||||
for src_name, dst_name in [
|
||||
("ability.png", "slot-ability.png"),
|
||||
("technique.png", "slot-technique.png"),
|
||||
]:
|
||||
src = find_icon_source(src_name)
|
||||
if src:
|
||||
shutil.copy2(src, ICONS_OUT / dst_name)
|
||||
|
||||
manifest["icons"] = {
|
||||
"directory": "frontend/public/icons",
|
||||
"served_at": "/icons/",
|
||||
|
|
|
|||
Loading…
Reference in a new issue