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",
|
"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": {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
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 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>
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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">
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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[]) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue