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:
Vantz Stockwell 2026-05-23 08:21:09 -04:00
parent 5b3ccf630d
commit f142725dd8
15 changed files with 2802 additions and 1835 deletions

View file

@ -16,36 +16,61 @@
"id": "benegesserit", "id": "benegesserit",
"name": "Bene Gesserit", "name": "Bene Gesserit",
"file": "skills-benegesserit.json", "file": "skills-benegesserit.json",
"subtrees": [
"Weirding Way",
"The Voice",
"Body Control"
],
"nodes": 22, "nodes": 22,
"edges": 23 "edges": 29
}, },
{ {
"id": "mentat", "id": "mentat",
"name": "Mentat", "name": "Mentat",
"file": "skills-mentat.json", "file": "skills-mentat.json",
"subtrees": [
"Mental Calculus",
"Assassination",
"Tactician"
],
"nodes": 22, "nodes": 22,
"edges": 22 "edges": 28
}, },
{ {
"id": "planetologist", "id": "planetologist",
"name": "Planetologist", "name": "Planetologist",
"file": "skills-planetologist.json", "file": "skills-planetologist.json",
"subtrees": [
"Scientist",
"Explorer",
"Mechanic"
],
"nodes": 20, "nodes": 20,
"edges": 10 "edges": 26
}, },
{ {
"id": "swordmaster", "id": "swordmaster",
"name": "Swordmaster", "name": "Swordmaster",
"file": "skills-swordmaster.json", "file": "skills-swordmaster.json",
"subtrees": [
"The Blade",
"The Will",
"The Way"
],
"nodes": 22, "nodes": 22,
"edges": 22 "edges": 28
}, },
{ {
"id": "trooper", "id": "trooper",
"name": "Trooper", "name": "Trooper",
"file": "skills-trooper.json", "file": "skills-trooper.json",
"subtrees": [
"Gunnery",
"Suspensor Training",
"Tactical Tech"
],
"nodes": 22, "nodes": 22,
"edges": 22 "edges": 28
} }
], ],
"icons": { "icons": {

View file

@ -1,6 +1,10 @@
{ {
"id": "benegesserit", "id": "benegesserit",
"name": "Bene Gesserit", "name": "Bene Gesserit",
"subtrees": [
{
"name": "Weirding Way",
"cols": 3,
"nodes": [ "nodes": [
{ {
"tag": "Skills.Spice.BinduDodge", "tag": "Skills.Spice.BinduDodge",
@ -78,7 +82,59 @@
"maxPoints": 3, "maxPoints": 3,
"icon": "t_ui_iconabilitydash_d.webp", "icon": "t_ui_iconabilitydash_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-hypersprint" "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", "tag": "Skills.Spice.VoiceSplash",
"id": "VoiceSplash", "id": "VoiceSplash",
@ -144,7 +200,39 @@
"maxPoints": 1, "maxPoints": 1,
"icon": "t_ui_iconabilitythevoicecompel_d.webp", "icon": "t_ui_iconabilitythevoicecompel_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-voicecompel" "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", "tag": "Skills.Ability.LitanyAgainstFear",
"id": "LitanyAgainstFear", "id": "LitanyAgainstFear",
@ -246,50 +334,6 @@
} }
], ],
"edges": [ "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", "from": "Skills.Ability.LitanyAgainstFear",
"to": "Skills.Perk.BinduStability" "to": "Skills.Perk.BinduStability"
@ -339,4 +383,6 @@
"to": "Skills.Attribute.SelfControl2" "to": "Skills.Attribute.SelfControl2"
} }
] ]
}
]
} }

View file

@ -1,6 +1,10 @@
{ {
"id": "mentat", "id": "mentat",
"name": "Mentat", "name": "Mentat",
"subtrees": [
{
"name": "Mental Calculus",
"cols": 5,
"nodes": [ "nodes": [
{ {
"tag": "Skills.Perk.ShieldWeakpoint", "tag": "Skills.Perk.ShieldWeakpoint",
@ -100,7 +104,63 @@
"maxPoints": 3, "maxPoints": 3,
"icon": "t_ui_iconabilityturretseeker_d.webp", "icon": "t_ui_iconabilityturretseeker_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-turretseeker" "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", "tag": "Skills.Ability.HunterSeeker",
"id": "HunterSeeker", "id": "HunterSeeker",
@ -177,7 +237,55 @@
"maxPoints": 3, "maxPoints": 3,
"icon": "t_ui_icongadgetpoisoncapsulelauncher_d.webp", "icon": "t_ui_icongadgetpoisoncapsulelauncher_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-poisoncapsulelauncher" "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", "tag": "Skills.Ability.PortableGenerator",
"id": "PortableGenerator", "id": "PortableGenerator",
@ -247,92 +355,30 @@
], ],
"edges": [ "edges": [
{ {
"from": "Skills.Perk.ExploitWeakness", "from": "Skills.Ability.PortableGenerator",
"to": "Skills.Perk.ShieldWeakpoint" "to": "Skills.Ability.SuspensorMine_Reduction"
}, },
{ {
"from": "Skills.Attribute.MentalCalculus5", "from": "Skills.Ability.PortableGenerator",
"to": "Skills.Perk.ShieldWeakpoint" "to": "Skills.Perk.IronWill"
}, },
{ {
"from": "Skills.Attribute.MentalCalculus3", "from": "Skills.Ability.SuspensorMine_Amplification",
"to": "Skills.Perk.ExploitWeakness" "to": "Skills.Ability.SuspensorMine_Reduction"
}, },
{ {
"from": "Skills.Perk.ExploitWeakness", "from": "Skills.Ability.SolidoDecoy",
"to": "Skills.Perk.HeadShots" "to": "Skills.Perk.IronWill"
}, },
{ {
"from": "Skills.Attribute.MentalCalculus5", "from": "Skills.Ability.SuspensorMine_Amplification",
"to": "Skills.Perk.HeadShots" "to": "Skills.Ability.SuspensorWall"
}, },
{ {
"from": "Skills.Attribute.MentalCalculus4", "from": "Skills.Ability.SolidoDecoy",
"to": "Skills.Attribute.MentalCalculus5" "to": "Skills.Ability.SuspensorWall"
}, }
{ ]
"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"
} }
] ]
} }

View file

@ -1,6 +1,10 @@
{ {
"id": "planetologist", "id": "planetologist",
"name": "Planetologist", "name": "Planetologist",
"subtrees": [
{
"name": "Scientist",
"cols": 3,
"nodes": [ "nodes": [
{ {
"tag": "Skills.Perk.BatteryExpert", "tag": "Skills.Perk.BatteryExpert",
@ -78,7 +82,55 @@
"maxPoints": 3, "maxPoints": 3,
"icon": "t_ui_iconskilltreeattributemineralyield_d.webp", "icon": "t_ui_iconskilltreeattributemineralyield_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-scientist1" "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", "tag": "Skills.Attribute.Explorer5",
"id": "Explorer5", "id": "Explorer5",
@ -144,7 +196,39 @@
"maxPoints": 1, "maxPoints": 1,
"icon": "t_ui_iconabilitysuspensorpad_d.webp", "icon": "t_ui_iconabilitysuspensorpad_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-suspensorpad" "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", "tag": "Skills.Spice.VehicleHeat",
"id": "VehicleHeat", "id": "VehicleHeat",
@ -225,44 +309,46 @@
], ],
"edges": [ "edges": [
{ {
"from": "Skills.Attribute.Scientist5", "from": "Skills.Attribute.Driver5",
"to": "Skills.Perk.BatteryExpert" "to": "Skills.Spice.VehicleHeat"
}, },
{ {
"from": "Skills.Perk.BatteryExpert", "from": "Skills.Attribute.Driver6",
"to": "Skills.Science.m_PowerMax" "to": "Skills.Spice.VehicleHeat"
}, },
{ {
"from": "Skills.Attribute.Scientist4", "from": "Skills.Attribute.Driver4",
"to": "Skills.Attribute.Scientist5" "to": "Skills.Attribute.Driver5"
}, },
{ {
"from": "Skills.Attribute.Scientist2", "from": "Skills.Attribute.Driver2",
"to": "Skills.Attribute.Scientist5" "to": "Skills.Attribute.Driver5"
}, },
{ {
"from": "Skills.Attribute.Scientist4", "from": "Skills.Attribute.Driver4",
"to": "Skills.Science.m_PowerMax" "to": "Skills.Attribute.Driver6"
}, },
{ {
"from": "Skills.Attribute.Scientist3", "from": "Skills.Attribute.Driver3",
"to": "Skills.Science.m_PowerMax" "to": "Skills.Attribute.Driver6"
}, },
{ {
"from": "Skills.Attribute.Scientist2", "from": "Skills.Attribute.Driver2",
"to": "Skills.Attribute.Scientist4" "to": "Skills.Attribute.Driver4"
}, },
{ {
"from": "Skills.Attribute.Scientist3", "from": "Skills.Attribute.Driver3",
"to": "Skills.Attribute.Scientist4" "to": "Skills.Attribute.Driver4"
}, },
{ {
"from": "Skills.Attribute.Scientist1", "from": "Skills.Attribute.Driver1",
"to": "Skills.Attribute.Scientist2" "to": "Skills.Attribute.Driver2"
}, },
{ {
"from": "Skills.Attribute.Scientist1", "from": "Skills.Attribute.Driver1",
"to": "Skills.Attribute.Scientist3" "to": "Skills.Attribute.Driver3"
}
]
} }
] ]
} }

View file

@ -1,6 +1,10 @@
{ {
"id": "swordmaster", "id": "swordmaster",
"name": "Swordmaster", "name": "Swordmaster",
"subtrees": [
{
"name": "The Blade",
"cols": 3,
"nodes": [ "nodes": [
{ {
"tag": "Skills.Spice.ParryBoost", "tag": "Skills.Spice.ParryBoost",
@ -78,7 +82,55 @@
"maxPoints": 3, "maxPoints": 3,
"icon": "t_ui_iconskilltreeskillbrawler_d.webp", "icon": "t_ui_iconskilltreeskillbrawler_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-blade1" "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", "tag": "Skills.Perk.ThriveOnDanger",
"id": "ThriveOnDanger", "id": "ThriveOnDanger",
@ -144,7 +196,39 @@
"maxPoints": 1, "maxPoints": 1,
"icon": "t_ui_iconabilitydeflection_d.webp", "icon": "t_ui_iconabilitydeflection_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-deflectionslow" "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", "tag": "Skills.Spice.ShadowStrike",
"id": "ShadowStrike", "id": "ShadowStrike",
@ -246,46 +330,6 @@
} }
], ],
"edges": [ "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", "from": "Skills.Attribute.Aggression3",
"to": "Skills.Spice.ShadowStrike" "to": "Skills.Spice.ShadowStrike"
@ -335,4 +379,6 @@
"to": "Skills.Attribute.Aggression2" "to": "Skills.Attribute.Aggression2"
} }
] ]
}
]
} }

View file

@ -1,6 +1,10 @@
{ {
"id": "trooper", "id": "trooper",
"name": "Trooper", "name": "Trooper",
"subtrees": [
{
"name": "Gunnery",
"cols": 5,
"nodes": [ "nodes": [
{ {
"tag": "Skills.Ability.EnergyCapsule", "tag": "Skills.Ability.EnergyCapsule",
@ -100,7 +104,63 @@
"maxPoints": 3, "maxPoints": 3,
"icon": "t_ui_iconskilltreeattributedamage_d.webp", "icon": "t_ui_iconskilltreeattributedamage_d.webp",
"url": "https://dune.gaming.tools/skills/skills-attribute-weaponry1" "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", "tag": "Skills.Ability.SuspensorBlast",
"id": "SuspensorBlast", "id": "SuspensorBlast",
@ -177,7 +237,55 @@
"maxPoints": 1, "maxPoints": 1,
"icon": "t_ui_icongadgetreductionsuspensorgrenade_d.webp", "icon": "t_ui_icongadgetreductionsuspensorgrenade_d.webp",
"url": "https://dune.gaming.tools/skills/skills-ability-suspensorgrenade_reduction" "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", "tag": "Skills.Spice.GadgetReload",
"id": "GadgetReload", "id": "GadgetReload",
@ -247,92 +355,30 @@
], ],
"edges": [ "edges": [
{ {
"from": "Skills.Ability.EnergyCapsule", "from": "Skills.Ability.AssaultSeeker",
"to": "Skills.Attribute.Weaponry5" "to": "Skills.Spice.GadgetReload"
}, },
{ {
"from": "Skills.Ability.EnergyCapsule", "from": "Skills.Ability.MagneticAttractor",
"to": "Skills.Attribute.Weaponry6" "to": "Skills.Spice.GadgetReload"
}, },
{ {
"from": "Skills.Attribute.Weaponry5", "from": "Skills.Ability.AssaultSeeker",
"to": "Skills.Perk.HeavyWeaponNaib" "to": "Skills.Ability.FragGrenade"
}, },
{ {
"from": "Skills.Attribute.Weaponry3", "from": "Skills.Ability.MagneticAttractor",
"to": "Skills.Attribute.Weaponry5" "to": "Skills.Perk.TrooperCooldowns"
}, },
{ {
"from": "Skills.Attribute.Weaponry3", "from": "Skills.Ability.CablePull",
"to": "Skills.Attribute.Weaponry6" "to": "Skills.Ability.FragGrenade"
}, },
{ {
"from": "Skills.Attribute.Weaponry4", "from": "Skills.Ability.CablePull",
"to": "Skills.Attribute.Weaponry6" "to": "Skills.Perk.TrooperCooldowns"
}, }
{ ]
"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"
} }
] ]
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

View file

@ -4,6 +4,7 @@ import XpProgressCard from './components/XpProgressCard.vue';
import FactionTrack from './components/FactionTrack.vue'; import FactionTrack from './components/FactionTrack.vue';
import SkillTree from './components/SkillTree.vue'; import SkillTree from './components/SkillTree.vue';
import CharacterSummary from './components/CharacterSummary.vue'; import CharacterSummary from './components/CharacterSummary.vue';
import LoadoutSlots from './components/LoadoutSlots.vue';
import { import {
applyBuild, applyBuild,
build, build,
@ -143,10 +144,12 @@ const spentByClass = computed<Record<ClassId, number>>(() => {
for (const c of CLASSES) { for (const c of CLASSES) {
const tree = skillTrees.value[c.id]; const tree = skillTrees.value[c.id];
if (!tree) continue; 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; out[c.id] += build.skills[node.tag] || 0;
} }
} }
}
return out; return out;
}); });
@ -433,6 +436,14 @@ const specMeta: Record<SpecId, { name: string; sym: string }> = {
@update:allocations="(a) => (build.skills = a)" @update:allocations="(a) => (build.skills = a)"
@reset="resetAllSkills" @reset="resetAllSkills"
/> />
<LoadoutSlots
:loadout="build.loadout"
:skill-trees="skillTrees"
:allocations="build.skills"
:classes="CLASSES"
@update:loadout="(l) => (build.loadout = l)"
/>
</section> </section>
</div> </div>

View file

@ -104,7 +104,8 @@ const skillsByClass = computed<ClassSkillSummary[]>(() => {
const allocations: ClassSkillSummary['allocations'] = []; const allocations: ClassSkillSummary['allocations'] = [];
let spent = 0; let spent = 0;
if (tree) { 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; const pts = props.build.skills[node.tag] || 0;
if (pts > 0) { if (pts > 0) {
spent += pts; spent += pts;
@ -118,6 +119,7 @@ const skillsByClass = computed<ClassSkillSummary[]>(() => {
} }
} }
} }
}
if (spent > 0 || tree) if (spent > 0 || tree)
out.push({ id: c.id, name: c.name, spent, allocations }); out.push({ id: c.id, name: c.name, spent, allocations });
} }

View 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>

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import type { SkillTree } from '../types'; import type { SkillSubtree, SkillTree } from '../types';
const props = defineProps<{ const props = defineProps<{
tree: SkillTree | null; tree: SkillTree | null;
@ -13,87 +13,36 @@ const emit = defineEmits<{
reset: []; reset: [];
}>(); }>();
// Cell size + gap in pixels. Match CSS .tree-cell. const CELL = 72; // cell size in px matches the source HTML (72px tracks)
const CELL = 96; const GAP_X = 32; // horizontal gap between cells in a subtree
const GAP = 36; // visual spacing including label area 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(() => const totalAllocated = computed(() =>
Object.values(props.allocations).reduce((a, b) => a + (b || 0), 0), Object.values(props.allocations).reduce((a, b) => a + (b || 0), 0),
); );
// Points spent inside THIS tree only.
const thisTreeAllocated = computed(() => { const thisTreeAllocated = computed(() => {
if (!props.tree) return 0; if (!props.tree) return 0;
let n = 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; 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; return n;
});
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 };
}); });
function pointsFor(tag: string): number { function pointsFor(tag: string): number {
return props.allocations[tag] || 0; return props.allocations[tag] || 0;
} }
function add(tag: string, delta: number) { function add(tag: string, delta: number, maxPoints: number) {
if (!props.tree) return;
const node = props.tree.nodes.find((n) => n.tag === tag);
if (!node) return;
const cur = pointsFor(tag); const cur = pointsFor(tag);
const max = node.maxPoints;
let next = cur + delta; let next = cur + delta;
if (next < 0) next = 0; if (next < 0) next = 0;
if (next > max) next = max; if (next > maxPoints) next = maxPoints;
if (delta > 0 && totalAllocated.value + (next - cur) > props.availablePoints) { if (delta > 0 && totalAllocated.value + (next - cur) > props.availablePoints) {
return; // not enough points return;
} }
const out = { ...props.allocations }; const out = { ...props.allocations };
if (next === 0) delete out[tag]; if (next === 0) delete out[tag];
@ -101,69 +50,135 @@ function add(tag: string, delta: number) {
emit('update:allocations', out); emit('update:allocations', out);
} }
function nodeClick(tag: string, e: MouseEvent) { function nodeClick(tag: string, maxPoints: number, e: MouseEvent) {
if (e.shiftKey || e.button === 2) add(tag, -1); if (e.shiftKey || e.button === 2) add(tag, -1, maxPoints);
else add(tag, 1); else add(tag, 1, maxPoints);
} }
function onContext(tag: string, e: MouseEvent) { function onContext(tag: string, maxPoints: number, e: MouseEvent) {
e.preventDefault(); 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> </script>
<template> <template>
<div> <div>
<div <div class="tree-toolbar">
style=" <div class="tree-stats">
display: flex; <span class="sand">{{ thisTreeAllocated }}</span> in this tree
align-items: center; <span class="dot">·</span>
justify-content: space-between; <span :class="{ over: totalAllocated > availablePoints }">
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)' }">
{{ totalAllocated }} {{ totalAllocated }}
</span> </span>
/ {{ availablePoints }} total / {{ availablePoints }} total
</div> </div>
<div <div class="tree-hint">
style="
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--ink-muted);
letter-spacing: 0.16em;
"
>
Click +1 · Shift-click / right-click 1 Click +1 · Shift-click / right-click 1
</div> </div>
<button @click="emit('reset')">Reset All Trees</button> <button @click="emit('reset')">Reset All Trees</button>
</div> </div>
<div class="tree-wrap" v-if="tree"> <div class="subtree-row" v-if="tree">
<div class="tree-grid" :style="gridStyle"> <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 <svg
class="tree-edges" class="tree-edges"
:viewBox="`0 0 ${svgSize.w} ${svgSize.h}`" :viewBox="`0 0 ${st.width} ${st.height}`"
:width="svgSize.w" :width="st.width"
:height="svgSize.h" :height="st.height"
preserveAspectRatio="none" preserveAspectRatio="none"
> >
<line <line
v-for="(ln, i) in edgeLines" v-for="(ln, i) in st.edges"
:key="i" :key="i"
:x1="ln.x1" :x1="ln.x1"
:y1="ln.y1" :y1="ln.y1"
@ -174,12 +189,12 @@ function onContext(tag: string, e: MouseEvent) {
/> />
</svg> </svg>
<div <div
v-for="n in tree.nodes" v-for="n in st.nodes"
:key="n.tag" :key="n.tag"
class="tree-node-wrap" class="abs-node-wrap"
:style="{ :style="{
gridColumn: n.col, left: n.cx - 36 + 'px',
gridRow: n.row, top: n.cy - 36 + 'px',
}" }"
> >
<div <div
@ -189,8 +204,8 @@ function onContext(tag: string, e: MouseEvent) {
pointsFor(n.tag) > 0 ? 'allocated' : '', pointsFor(n.tag) > 0 ? 'allocated' : '',
pointsFor(n.tag) >= n.maxPoints ? 'maxed' : '', pointsFor(n.tag) >= n.maxPoints ? 'maxed' : '',
]" ]"
@click="(e) => nodeClick(n.tag, e)" @click="(e) => nodeClick(n.tag, n.maxPoints, e)"
@contextmenu="(e) => onContext(n.tag, e)" @contextmenu="(e) => onContext(n.tag, n.maxPoints, e)"
:title="`${n.name} (${n.kind})`" :title="`${n.name} (${n.kind})`"
> >
<img <img
@ -208,8 +223,168 @@ function onContext(tag: string, e: MouseEvent) {
</div> </div>
</div> </div>
</div> </div>
</div>
<div v-else style="padding: 32px; color: var(--ink-muted)"> <div v-else style="padding: 32px; color: var(--ink-muted)">
Loading skill tree Loading skill tree
</div> </div>
</div> </div>
</template> </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>

View file

@ -19,9 +19,20 @@ export function defaultBuild(): BuildState {
specs, specs,
faction: { tier: 0, standingInto: 0 }, faction: { tier: 0, standingInto: 0 },
skills: {}, 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 { function migrate(raw: any): BuildState {
const def = defaultBuild(); const def = defaultBuild();
if (!raw || typeof raw !== 'object') return def; if (!raw || typeof raw !== 'object') return def;
@ -33,6 +44,10 @@ function migrate(raw: any): BuildState {
specs: { ...def.specs }, specs: { ...def.specs },
faction: { ...def.faction, ...(raw.faction || {}) }, faction: { ...def.faction, ...(raw.faction || {}) },
skills: { ...(raw.skills || {}) }, skills: { ...(raw.skills || {}) },
loadout: {
abilities: normalizeSlots(raw.loadout?.abilities, 3),
techniques: normalizeSlots(raw.loadout?.techniques, 3),
},
}; };
if (raw.specs && typeof raw.specs === 'object') { if (raw.specs && typeof raw.specs === 'object') {
for (const k of Object.keys(out.specs) as SpecId[]) { for (const k of Object.keys(out.specs) as SpecId[]) {

View file

@ -66,11 +66,17 @@ export interface SkillEdge {
to: string; to: string;
} }
export interface SkillSubtree {
name: string;
cols: number;
nodes: SkillNode[];
edges: SkillEdge[];
}
export interface SkillTree { export interface SkillTree {
id: ClassId; id: ClassId;
name: string; name: string;
nodes: SkillNode[]; subtrees: SkillSubtree[];
edges: SkillEdge[];
} }
export interface SpecProgress { export interface SpecProgress {
@ -78,6 +84,14 @@ export interface SpecProgress {
xpInto: number; 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 { export interface BuildState {
v: 1; // schema version v: 1; // schema version
house: House; house: House;
@ -95,4 +109,5 @@ export interface BuildState {
// Allocated skill points keyed by full tag (e.g. Skills.Ability.BattleCry). // 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. // Tags are globally unique across classes, so one flat map works for all 5.
skills: Record<string, number>; skills: Record<string, number>;
loadout: Loadout;
} }

View file

@ -216,28 +216,23 @@ LINE_RE = re.compile(
) )
def extract_skill_tree(path: Path, class_id: str, class_name: str) -> dict: SUBTREE_H3_RE = re.compile(
html = path.read_text() r'<h3[^>]*class="[^"]*text-xl[^"]*"[^>]*>([^<]+)</h3>'
nodes = [] )
# We need to also find the alt text + href + icon WITHIN each node's HTML. GRID_COLS_RE = re.compile(r"grid-template-columns:\s*repeat\((\d+),\s*72px\)")
# 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. def _extract_node(chunk: str, tag: str) -> dict | None:
for m in re.finditer(r'data-tag="(Skills\.[^"]+)"', html):
tag = m.group(1)
start = m.start()
chunk = html[start : start + 2500]
gm = GRID_RE.search(chunk) gm = GRID_RE.search(chunk)
if not gm: if not gm:
continue return None
row, col = int(gm.group(1)), int(gm.group(2)) row, col = int(gm.group(1)), int(gm.group(2))
alt = ALT_RE.search(chunk) alt = ALT_RE.search(chunk)
href = HREF_RE.search(chunk) href = HREF_RE.search(chunk)
icon = ICON_RE.search(chunk) icon = ICON_RE.search(chunk)
max_pts = MAX_PTS_RE.search(chunk) max_pts = MAX_PTS_RE.search(chunk)
kind = tag.split(".")[1] if "." in tag else "Unknown" kind = tag.split(".")[1] if "." in tag else "Unknown"
nodes.append( return {
{
"tag": tag, "tag": tag,
"id": tag.split(".")[-1], "id": tag.split(".")[-1],
"name": alt.group(1) if alt else 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, "icon": icon.group(1) if icon else None,
"url": href.group(1) if href 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 def _map_edges(html_slice: str, nodes: list[dict]) -> list[dict]:
# on observed example (grid 3/5 -> center 364,220 means col*~73, row*~73 with offset). """Pixel-match connector <line> endpoints to the nearest nodes in this
# We'll learn cell size from the data: if there are connectors, we map each (x,y) to subtree. Returns deduped edge list."""
# the nearest node by Euclidean distance. lines = list(LINE_RE.finditer(html_slice))
# First compute approximate node centers via grid math, calibrated from any node if not lines or not nodes:
# we can pin: actually a more reliable approach is to use the connector geometry. return []
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.
all_x = [int(x) for ln in lines for x in (ln.group("x1"), ln.group("x2"))] 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"))] 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_x, max_x = min(all_x), max(all_x)
min_y, max_y = min(all_y), max(all_y) min_y, max_y = min(all_y), max(all_y)
cols = [n["col"] for n in nodes] 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_c, max_c = min(cols), max(cols)
min_r, max_r = min(rows), max(rows) min_r, max_r = min(rows_), max(rows_)
# avoid div by zero
sx = (max_x - min_x) / max(1, (max_c - min_c)) sx = (max_x - min_x) / max(1, (max_c - min_c))
sy = (max_y - min_y) / max(1, (max_r - min_r)) sy = (max_y - min_y) / max(1, (max_r - min_r))
def center(n): centers = {
return ( n["tag"]: (min_x + (n["col"] - min_c) * sx, min_y + (n["row"] - min_r) * sy)
min_x + (n["col"] - min_c) * sx, for n in nodes
min_y + (n["row"] - min_r) * sy, }
)
centers = {n["tag"]: center(n) for n in nodes} def nearest(x: int, y: int) -> str | None:
best, best_d = None, float("inf")
def nearest(x, y): for tag, (cx, cy) in centers.items():
best_tag, best_d = None, float("inf")
for t, (cx, cy) in centers.items():
d = (cx - x) ** 2 + (cy - y) ** 2 d = (cx - x) ** 2 + (cy - y) ** 2
if d < best_d: if d < best_d:
best_d = d best_d, best = d, tag
best_tag = t return best
return best_tag
seen_edges = set() seen, edges = set(), []
for ln in lines: for ln in lines:
x1, y1, x2, y2 = ( x1, y1, x2, y2 = (int(ln.group(k)) for k in ("x1", "y1", "x2", "y2"))
int(ln.group("x1")), a, b = nearest(x1, y1), nearest(x2, y2)
int(ln.group("y1")),
int(ln.group("x2")),
int(ln.group("y2")),
)
a = nearest(x1, y1)
b = nearest(x2, y2)
if a and b and a != b: if a and b and a != b:
key = tuple(sorted((a, b))) key = tuple(sorted((a, b)))
if key not in seen_edges: if key not in seen:
seen_edges.add(key) seen.add(key)
edges.append({"from": key[0], "to": key[1]}) edges.append({"from": key[0], "to": key[1]})
return edges
return {
"id": class_id, def extract_skill_tree(path: Path, class_id: str, class_name: str) -> dict:
"name": class_name, """Parse a class skill tree into its named subtrees.
"nodes": nodes,
"edges": edges, 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 ---------- # ---------- main ----------
@ -413,13 +427,16 @@ def main():
continue continue
tree = extract_skill_tree(path, cls_id, cls_name) tree = extract_skill_tree(path, cls_id, cls_name)
(OUT / f"skills-{cls_id}.json").write_text(json.dumps(tree, indent=2)) (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( manifest["skills"].append(
{ {
"id": cls_id, "id": cls_id,
"name": cls_name, "name": cls_name,
"file": f"skills-{cls_id}.json", "file": f"skills-{cls_id}.json",
"nodes": len(tree["nodes"]), "subtrees": [st["name"] for st in tree["subtrees"]],
"edges": len(tree["edges"]), "nodes": total_nodes,
"edges": total_edges,
} }
) )
@ -429,7 +446,8 @@ def main():
path = OUT / f"skills-{cls_id}.json" path = OUT / f"skills-{cls_id}.json"
if path.exists(): if path.exists():
tree = json.loads(path.read_text()) 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"): if n.get("icon"):
icon_names.add(n["icon"]) icon_names.add(n["icon"])
for spec in specs: for spec in specs:
@ -440,6 +458,18 @@ def main():
if p.get("icon"): if p.get("icon"):
icon_names.add(p["icon"]) icon_names.add(p["icon"])
copied, missing = copy_icons(icon_names) 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"] = { manifest["icons"] = {
"directory": "frontend/public/icons", "directory": "frontend/public/icons",
"served_at": "/icons/", "served_at": "/icons/",