Creating hexagonal skill tree
1/7/2025
Martin Bozhilov
In this tutorial, we will explore how to create a visually appealing hexagonal skill tree inspired by the Batman: Arkham Knight game.
This guide will walk you through the process of designing and implementing a skill tree using SVGs, CSS, and JavaScript. By the end of this tutorial, you’ll have a dynamic and interactive skill tree for your game.
Source location
You can find the complete sample source within the ${Gameface package}/Samples/uiresources/UITutorials/HexagonalSkillTree
directory.
Preparing the assets
Before we start coding, we need to prepare the assets for our skill tree. The easiest and most performant way to create the hexagons is by using SVGs.
Hexagon SVGs
To create the hexagons, we will head to Figma and design a hexagon shape. We will then export the hexagon as an SVG file.
The simplest way to create a hexagon in Figma is by selecting the polygon
option in the creation tool and setting the edges count to 6:
And then exporting it as an SVG file from the bottom right section.
SkillTree background SVG
We will also need a background SVG for the skill tree. This SVG will serve as the base for our skill tree on which we will place the elements.
We will use the hexagon we just created in Figma and duplicate it as many times as we want and after that connecting them with the pen tool.
After you are done with the design, export the background SVG file. Feel free to experiment with the design and create a unique background for your skill tree.
Setting up the project
Let’s begin by setting up the project structure, which includes adding the SVG skill tree we just created and some basic styles.
1<div class="container">2 <div class="skill-container">3 </div>4 <svg class="svg-paths" width="100%" height="100%" viewBox="0 0 1466 1380" xmlns="http://www.w3.org/2000/svg">41 collapsed lines
5 <path d="M820.385 630.74L733.448 582.566L646.063 630.74V729.104L733.448 777.053L820.385 729.104V630.74Z" stroke="white" stroke-width="5"/>6 <path d="M820.385 1186.42L733.448 1138.24L646.063 1186.42V1284.78L733.448 1332.73L820.385 1284.78V1186.42Z" stroke="white" stroke-width="5"/>7 <path d="M819.04 70.5802L732.104 22.4065L644.719 70.5802V168.944L732.104 216.894L819.04 168.944V70.5802Z" stroke="white" stroke-width="5"/>8 <path d="M1033.69 631.188L946.757 583.014L859.372 631.188V729.552L946.757 777.502L1033.69 729.552V631.188Z" stroke="white" stroke-width="5"/>9 <path d="M1248.35 630.291L1161.41 582.118L1074.03 630.291V728.655L1161.41 776.605L1248.35 728.655V630.291Z" stroke="white" stroke-width="5"/>10 <path d="M1463 630.291L1376.06 582.118L1288.68 630.291V728.655L1376.06 776.605L1463 728.655V630.291Z" stroke="white" stroke-width="5"/>11 <path d="M819.041 681.154H858.924" stroke="white" stroke-width="5"/>12 <path d="M1033.69 680.257H1073.58" stroke="white" stroke-width="5"/>13 <path d="M1248.35 680.257H1288.23" stroke="white" stroke-width="5"/>14 <path d="M542.142 445.103L543.891 544.479L629.303 596.07L714.489 546.888L712.322 447.235L627.328 395.921L542.142 445.103Z" stroke="white" stroke-width="5"/>15 <path d="M431.902 259.13L433.651 358.506L519.063 410.097L604.248 360.915L602.082 261.263L517.088 209.948L431.902 259.13Z" stroke="white" stroke-width="5"/>16 <path d="M324.575 73.1568L326.324 172.533L411.736 224.124L496.922 174.942L494.755 75.2894L409.761 23.9748L324.575 73.1568Z" stroke="white" stroke-width="5"/>17 <path d="M692.74 606.015L672.799 571.475" stroke="white" stroke-width="5"/>18 <path d="M582.5 420.042L562.559 385.502" stroke="white" stroke-width="5"/>19 <path d="M475.174 234.069L455.232 199.529" stroke="white" stroke-width="5"/>20 <path d="M843.059 398.273L757.871 449.476L755.898 549.24L841.084 598.422L926.302 546.72L928.245 447.455L843.059 398.273Z" stroke="white" stroke-width="5"/>21 <path d="M950.162 212.301L864.974 263.503L863.001 363.268L948.187 412.45L1033.4 360.747L1035.35 261.483L950.162 212.301Z" stroke="white" stroke-width="5"/>22 <path d="M1064.88 26.3274L979.694 77.53L977.721 177.294L1062.91 226.476L1148.13 174.774L1150.07 75.5094L1064.88 26.3274Z" stroke="white" stroke-width="5"/>23 <path d="M779.004 609.151L798.946 574.611" stroke="white" stroke-width="5"/>24 <path d="M886.107 423.179L906.049 388.639" stroke="white" stroke-width="5"/>25 <path d="M1000.83 237.206L1020.77 202.666" stroke="white" stroke-width="5"/>26 <path d="M432.397 729.328L519.334 777.502L606.719 729.328L606.719 630.964L519.334 583.014L432.397 630.964L432.397 729.328Z" stroke="white" stroke-width="5"/>27 <path d="M217.653 730.224L304.59 778.398L391.975 730.224L391.975 631.86L304.59 583.91L217.653 631.86L217.653 730.224Z" stroke="white" stroke-width="5"/>28 <path d="M2.99986 730.224L89.9366 778.398L177.322 730.224L177.322 631.86L89.9366 583.91L2.99986 631.86L2.99986 730.224Z" stroke="white" stroke-width="5"/>29 <path d="M647.05 679.362H607.167" stroke="white" stroke-width="5"/>30 <path d="M432.306 680.257H392.423" stroke="white" stroke-width="5"/>31 <path d="M217.653 680.257H177.769" stroke="white" stroke-width="5"/>32 <path d="M623.917 963.548L709.105 912.345L711.077 812.581L625.892 763.399L540.674 815.102L538.731 914.366L623.917 963.548Z" stroke="white" stroke-width="5"/>33 <path d="M516.894 1149.48L602.082 1098.28L604.055 998.515L518.869 949.333L433.651 1001.04L431.708 1100.3L516.894 1149.48Z" stroke="white" stroke-width="5"/>34 <path d="M409.632 1335.46L494.82 1284.25L496.793 1184.49L411.607 1135.31L326.389 1187.01L324.446 1286.27L409.632 1335.46Z" stroke="white" stroke-width="5"/>35 <path d="M687.971 752.67L668.03 787.21" stroke="white" stroke-width="5"/>36 <path d="M580.948 938.603L561.007 973.143" stroke="white" stroke-width="5"/>37 <path d="M473.687 1124.58L453.745 1159.12" stroke="white" stroke-width="5"/>38 <path d="M928.116 914.439L926.367 815.062L840.955 763.472L755.769 812.654L757.936 912.306L842.93 963.621L928.116 914.439Z" stroke="white" stroke-width="5"/>39 <path d="M1038.74 1100.41L1036.99 1001.04L951.578 949.445L866.392 998.627L868.559 1098.28L953.553 1149.59L1038.74 1100.41Z" stroke="white" stroke-width="5"/>40 <path d="M1149.94 1286.38L1148.19 1187.01L1062.78 1135.42L977.593 1184.6L979.759 1284.25L1064.75 1335.57L1149.94 1286.38Z" stroke="white" stroke-width="5"/>41 <path d="M777.518 753.526L797.459 788.066" stroke="white" stroke-width="5"/>42 <path d="M888.14 939.5L908.081 974.04" stroke="white" stroke-width="5"/>43 <path d="M999.34 1125.47L1019.28 1160.01" stroke="white" stroke-width="5"/>44 <path d="M499.525 1244.9H643.15M822.85 1244.9H978.798" stroke="white" stroke-width="5"/>45 <path d="M498.181 129.061H641.806M821.505 129.061H977.454" stroke="white" stroke-width="5"/>46 </svg>47</div>
1@font-face {2 font-family: 'Nunito';3 src: url(./assets/Nunito-VariableFont_wght.ttf);4}5
6body{7 margin: 0;8 width: 100vw;9 height: 100vh;10 background: linear-gradient(to bottom, #141e30, #243b55);11 overflow: hidden;12 font-family: 'Nunito';13 color: white;14}15
16.container {17 position: absolute;18 right: 0;19 width: 75%;20 height: 140%;21 /* This transition will control the camera movement */22 transition: transform 0.2s linear;23 z-index: -1;24}
Skill Tree movement
Since skill trees can be quite big we want to have the skill tree follow the mouse movement.
To achieve this we will add an event listener for the mousemove
event and update the transform
property of the container element.
We will calculate the percentage of the mouse position relative to the window size and then map it to the desired movement range.
In our case we want the maxium movement to be 10% of the window width and 30% of the window height. So the maximum translateX
value will be -10% and the maximum translateY
value will be -30%.
1const skillTree = document.querySelector('.container');2const treePaths = document.querySelector('.svg-paths');3
4function translateSkillTreeIntoView(x, y) {5 const percentageX = (x / window.innerWidth) * 100;6 const percentageY = (y / window.innerHeight) * 100;7
8 const mappedX = percentageX * -0.10;9 const mappedY = percentageY * -0.30;10
11 skillTree.style.transform = `translateX(${mappedX.toFixed(2)}%) translateY(${mappedY.toFixed(2)}%)`;12}13
14document.addEventListener('mousemove', (event) => {15 const { clientX, clientY } = event;16
17 translateSkillTreeIntoView(clientX, clientY);18})
You SVG should now follow the mouse movement.
Creating the hexagons
Now that we have the basic structure set up, we can start creating the hexagons that will represent the skills in our skill tree.
1<div class="skill-container">2 <div class="hex">3 <div class="hex-content"></div>4 <svg class="svg svg-main" viewBox="0 0 395 441" fill="none" xmlns="http://www.w3.org/2000/svg">5 <!-- Side 1 -->6 <path d="M198 3.5L392 111" />7 <!-- Side 2 -->8 <path d="M392 111L392 330.5" />9 <!-- Side 3 -->10 <path class="strike-dash" d="M392 330.5L198 437.5" />11 <!-- Side 4 (with stroke-dasharray) -->12 <path d="M198 437.5L3 330.5" />13 <!-- Side 5 -->14 <path d="M3 330.5L3 111" />15 <!-- Side 6 -->16 <path class="strike-dash" d="M3 111L198 3.5" />17 </svg>18 </div>19</div>
One thing we changed is the separation of the svg from one path
to six path
elements for each side and the addition of the strike-dash
class to the paths that we want to have a dashed stroke.
We will use this class to apply a dashed stroke to the hexagon sides, creating a unique hexagon shape.
Note: You can easily separate your hexagon svg into six different path elements for each side by provding it to an AI language model and asking it to do so.
1.hex {2 width: 9vw;3 height: 19vh;4 position: absolute;5 transition: all 0.3s ease-in-out;6 display: flex;7 justify-content: center;8 align-items: center;9 cursor: pointer;10}11
12.hex-content {13 width: 70%;14 height: 70%;15 mask-size: 100%;16 mask-repeat: no-repeat;17 mask-position: center 60%;18 background-color: crimson;19 mask-image: url(./assets/strength/strength-plus.png);20}21
22.hex path {23 stroke: crimson;24}25
26.svg {27 width: 9vw;28 height: 19vh;29 position: absolute;30}31
32.svg-main path {33 stroke-width: 10;34}35
36.strike-dash {37 stroke-dasharray: 6;38}
Our hexagon elements will consist of a hexagon SVG as the outline shape of the element and a content div that will hold the skill icon, which will be placed as a mask image. We will leverage the power of CSS masks to create different color icons for the skills while keeping the background transparent.
Categorizing the skills
We are going to categorize the skills by their type (strength, durability, health, etc) and apply different colors to the hexagons. We can easily do that by applying different classes to the hexagon elements. And use the classes with the help of CSS variables to apply different colors to the hexagons.
1/* Category colors */2.hex-strength {3 --shadow-color: crimson;4}20 collapsed lines
5
6.hex-durability {7 --shadow-color: #FF9F43;8}9
10.hex-health {11 --shadow-color: #4CAF50;12}13
14.hex-mana {15 --shadow-color: #42A5F5;16}17
18.hex-stamina {19 --shadow-color: #FFEB3B;20}21
22.hex-misc {23 --shadow-color: #AB47BC;24}25
26/* Combined skills */27.hex-strength-durability .path-left-side {28 --shadow-color: crimson;29}30
31.hex-strength-durability .path-right-side {32 --shadow-color: #FF9F43;33}8 collapsed lines
34
35.hex-health-mana .path-left-side {36 --shadow-color: #42A5F5;37}38
39.hex-health-mana .path-right-side {40 --shadow-color: #4CAF50;41}42
43/* LOCKED STATE */44.hex-locked,45.hex-locked .path-left-side,46.hex-locked .path-right-side {47 --shadow-color: #ffffff;48}49
50.hex-locked {51 opacity: 0.5;52}
And now simply apply the variable to the svg path
element.
1.hex-skill path {2 stroke: var(--shadow-color);3}
With this setup we can easily change the color of the hexagons based on the skill type as the --shadow-color
variable will take the color from the category class placed on the parent.
Arrange the hexagons on the skill tree
Now that we have the hexagons created, we can start placing them on the skill tree. One way to do it is to manually position each hexagon on the skill tree background SVG
by setting the left
and top
properties of the hexagon elements untill they fit perfectly.
Mocking game data with a model object
Since we don’t have a game to provide us with the back-end data, we are going to mock some game data that will represent the skills and their relationships. We will create a mock model object that will hold all the information about the skills we need.
1const SkillsModel = {2 points: 24,3 skills: [4 {5 name: 'Health +',6 id: 1,7 description: 'Increase your max health',8 unlocked: false,9 skillPoints: 1,10 image: 'url(./assets/health/health-plus.png)',11 x: '51.5%',12 y: '77vh',13 parents: null,14 type: 'health',15 },207 collapsed lines
16 {17 name: 'Health ++',18 id: 2,19 description: 'Increase your max health',20 unlocked: false,21 skillPoints: 2,22 image: 'url(./assets/health/health-plus-plus.png)',23 x: '59%',24 y: '95.25vh',25 parents: 1,26 type: 'health',27 },28 {29 name: 'Health +++',30 id: 3,31 description: 'Increase your max health',32 unlocked: false,33 skillPoints: 3,34 image: 'url(./assets/health/health-plus-plus-plus.png)',35 x: '66.5%',36 y: '113vh',37 parents: 2,38 type: 'health',39 },40 {41 name: 'Mana +',42 id: 4,43 description: 'Increase your max mana',44 unlocked: false,45 skillPoints: 1,46 image: 'url(./assets/mana/mana-plus.png)',47 x: '36.5%',48 y: '77vh',49 parents: null,50 type: 'mana',51 },52 {53 name: 'Mana ++',54 id: 5,55 description: 'Increase your max mana',56 unlocked: false,57 skillPoints: 2,58 image: 'url(./assets/mana/mana-plus-plus.png)',59 x: '29.5%',60 y: '95.25vh',61 parents: 4,62 type: 'mana',63 },64 {65 name: 'Mana +++',66 id: 6,67 description: 'Increase your max mana',68 unlocked: false,69 skillPoints: 3,70 image: 'url(./assets/mana/mana-plus-plus-plus.png)',71 x: '22%',72 y: '113vh',73 parents: 5,74 type: 'mana',75 },76 {77 name: 'Durability +',78 id: 7,79 description: 'Increase your max durability',80 unlocked: false,81 skillPoints: 1,82 image: 'url(./assets/durability/durability-plus.png)',83 x: '51.5%',84 y: '41.75vh',85 parents: null,86 type: 'durability',87 },88 {89 name: 'Durability ++',90 id: 8,91 description: 'Increase your max durability',92 unlocked: false,93 skillPoints: 2,94 image: 'url(./assets/durability/durability-plus-plus.png)',95 x: '59%',96 y: '24.25vh',97 parents: 7,98 type: 'durability',99 },100 {101 name: 'Durability +++',102 id: 9,103 description: 'Increase your max durability',104 unlocked: false,105 skillPoints: 3,106 image: 'url(./assets/durability/durability-plus-plus-plus.png)',107 x: '66.5%',108 y: '6vh',109 parents: 8,110 type: 'durability',111 },112 {113 name: 'Stamina +',114 id: 10,115 description: 'Increase your max stamina',116 unlocked: false,117 skillPoints: 1,118 image: 'url(./assets/stamina/stamina-plus.png)',119 x: '58.75%',120 y: '59.5vh',121 parents: null,122 type: 'stamina',123 },124 {125 name: 'Stamina ++',126 id: 11,127 description: 'Increase your max stamina',128 unlocked: false,129 skillPoints: 2,130 image: 'url(./assets/stamina/stamina-plus-plus.png)',131 x: '73.25%',132 y: '59.5vh',133 parents: 10,134 type: 'stamina',135 },136 {137 name: 'Stamina +++',138 id: 12,139 description: 'Increase your max stamina',140 unlocked: false,141 skillPoints: 3,142 image: 'url(./assets/stamina/stamina-plus-plus-plus.png)',143 x: '87.75%',144 y: '59.5vh',145 parents: 11,146 type: 'stamina',147 },148 {149 name: 'Strength +',150 id: 13,151 description: 'Increase your max strength',152 unlocked: false,153 skillPoints: 1,154 image: 'url(./assets/strength/strength-plus.png)',155 x: '37%',156 y: '41.75vh',157 parents: null,158 type: 'strength',159 },160 {161 name: 'Strength ++',162 id: 14,163 description: 'Increase your max strength',164 unlocked: false,165 skillPoints: 2,166 image: 'url(./assets/strength/strength-plus-plus.png)',167 x: '29.5%',168 y: '23.75vh',169 parents: 13,170 type: 'strength',171 },172 {173 name: 'Strength +++',174 id: 15,175 description: 'Increase your max strength',176 unlocked: false,177 skillPoints: 3,178 image: 'url(./assets/strength/strength-plus-plus-plus.png)',179 x: '22%',180 y: '6vh',181 parents: 14,182 type: 'strength',183 },184 {185 name: 'Dodge',186 id: 16,187 description: 'Learn dodge ability',188 unlocked: false,189 skillPoints: 1,190 image: 'url(./assets/misc/dodge.png)',191 x: '29.5%',192 y: '59.5vh',193 parents: null,194 type: 'misc',195 },196 {197 name: 'Sixth sense',198 id: 17,199 description: 'Learn the sixth sense ability and increase your awareness',200 unlocked: false,201 skillPoints: 2,202 image: 'url(./assets/misc/sixth-sense.png)',203 x: '15%',204 y: '59.5vh',205 parents: 16,206 type: 'misc',207 },208 {209 name: 'Teleportation',210 id: 18,211 description: 'Learn the teleportation ability and unlock fast travel',212 unlocked: false,213 skillPoints: 3,214 image: 'url(./assets/misc/teleport.png)',215 x: '0.3%',216 y: '59.5vh',217 parents: 17,218 type: 'misc',219 },220 ],221 specialSkills: [222 {223 name: 'Iron Will',224 description: 'Tremendously increases strength and durability',225 unlocked: false,226 skillPoints: 5,227 image: 'url(./assets/combined/iron-will.png)',228 x: '43.75%',229 y: '6vh',230 parents: [15, 9],231 type: 'strength-durability',232 },233 {234 name: 'Arcane Vitality',235 description: 'Tremendously boosts mana regeneration and health recovery',236 unlocked: false,237 skillPoints: 5,238 image: 'url(./assets/combined/arcane-vitality.png)',239 x: '44%',240 y: '113vh',241 parents: [3, 6],242 type: 'health-mana',243 },244 ],245 starterSkill: {246 name: 'Journey Begins',247 description: 'Gain access to the skill tree',248 unlocked: true,249 skillPoints: 0,250 image: 'url(./assets/misc/rubber-man.png)',251 x: '44%',252 y: '59.5vh',253 type: 'starter',254 },255};
And we should not forget to initialize the model in our index.js
file.
1function updateModel(modelName) {2 engine.updateWholeModel(modelName);3 engine.synchronizeModels();4}5
6engine.on("Ready", () => {7 engine.createJSModel("SkillsModel", SkillsModel);8 engine.synchronizeModels();9});
The model we created has a skills
array with objects that contain the skill data, specialSkills
array because we have special skills that require multiple skills to be unlocked,
and a starterSkill
object that represents the starting skill of the tree which will be unlocked by default.
Since the url for the mask image icons will come from our mocked model, we can remove the placeholder CSS we used previously.
1.hex-content {2 width: 70%;3 height: 70%;4 mask-size: 100%;5 mask-repeat: no-repeat;6 mask-position: center 60%;7 background-color: crimson;8 mask-image: url(./assets/strength/strength-plus.png);9 background-color: var(--shadow-color);10}11
12.hex path {13 stroke: crimson;14}
Now for every category we will create a new class with different color. And also we will add a class for when the skill is locked.
Connecting model data to the html with HTML data-binding
Now that we have the model setup, we can start connecting the data to the HTML elements. We will loop through the skills
array and create a hexagon element for each skill
by utilizing the power of data-binding .
Since we have 3 types of skills - starter, normal and combined we will need to handle them separately.
Starter skill
For the starter skill, we will need to extract from the model the x
and y
coordinates and the image url for the mask image.
1<div class="skill-container">2 <div3 class="hex hex-starter"4 data-bind-style-top='{{SkillsModel.starterSkill.y}}'5 data-bind-style-left='{{SkillsModel.starterSkill.x}}'>6 <div class="hex-content" data-bind-style-mask-image='{{SkillsModel.starterSkill.image}}'></div>7 <!-- Main -->8 <svg class="svg svg-main" viewBox="0 0 395 441" fill="none" xmlns="http://www.w3.org/2000/svg">9 <!-- Side 1 -->10 <path d="M198 3.5L392 111" stroke="#FF9F43" />11 <!-- Side 2 -->12 <path d="M392 111L392 330.5" stroke="#FFEB3B" />13 <!-- Side 3 -->14 <path class="strike-dash" d="M392 330.5L198 437.5" stroke="#4CAF50"/>15 <!-- Side 4 (with stroke-dasharray) -->16 <path d="M198 437.5L3 330.5" stroke="#42A5F5"/>17 <!-- Side 5 -->18 <path d="M3 330.5L3 111" stroke="#AB47BC" />19 <!-- Side 6 -->20 <path class="strike-dash" d="M3 111L198 3.5" stroke="crimson" />21 </svg>22 </div>23</div>
To make our starter skill stand out we will apply a different color to each side of the hexagon. We will use the stroke
attribute to apply the color to the sides directly.
1.hex-starter > .hex-content {2 background: conic-gradient(3 #FF9F43 0%,4 #FFEB3B 20%,5 #4CAF50 40%,6 #42A5F5 60%,7 #AB47BC 80%,8 crimson 100%9 );10}
And apply a conic gradient
as the background, making it all seamlessly integrate with the stroke colors.
With that, we can now see the starter skill placed in the center of our skill tree!
Regular skills
Since we will be looping through the skills
array we will need to create a new hexagon element for each skill. We can easily do that with the help of the data-bind-for
attribute.
The ohter notable difference from the starter skill logic is that here we will dynamically create the class name based on the skill type, which with the help of the CSS we set up earlier will apply different colors based on the categories of the skills.
And finally, we will use data-bind-class-toggle
to conditionally apply the hex-locked
class to the hexagon element if the skill is not unlocked.
This will make the hexagon appear grayed out if they are locked.
1<div2 class="hex hex-skill"3 data-bind-for="index, skill:{{SkillsModel.skills}}"4 data-bind-style-top='{{skill.y}}'5 data-bind-style-left='{{skill.x}}'6 data-bind-class="'hex-'+{{skill.type}};'"7 data-bind-class-toggle="hex-locked:{{skill.unlocked}} === false">8 <div class="hex-content" data-bind-style-mask-image='{{skill.image}}'></div>9 <svg class="svg svg-main" viewBox="0 0 395 441" fill="none" xmlns="http://www.w3.org/2000/svg">10 <!-- Side 1 -->11 <path d="M198 3.5L392 111" />12 <!-- Side 2 -->13 <path d="M392 111L392 330.5" />14 <!-- Side 3 -->15 <path class="strike-dash" d="M392 330.5L198 437.5" />16 <!-- Side 4 (with stroke-dasharray) -->17 <path d="M198 437.5L3 330.5" />18 <!-- Side 5 -->19 <path d="M3 330.5L3 111" />20 <!-- Side 6 -->21 <path class="strike-dash" d="M3 111L198 3.5" />22 </svg>23</div>
Combined skills
Lastly, we will create a hexagon element for each combined skill. The combined skills will have a different color on both the left and right sides based on the category of the parent skill of each side.
We will achieve that by grouping the left and right sides of the hexagon svg path
elements and applying a class to it.
1<div2 class="hex hex-skill"3 data-bind-for="index, skill:{{SkillsModel.specialSkills}}"4 data-bind-style-top='{{skill.y}}'5 data-bind-style-left='{{skill.x}}'6 data-bind-class="'hex-'+{{skill.type}};'"7 data-bind-class-toggle="hex-locked:{{skill.unlocked}} === false">8 <div class="hex-content" data-bind-style-mask-image='{{skill.image}}'></div>9 <!-- Main -->10 <svg class="svg svg-main" data-bind-class="'hex-'+{{skill.type}};'" viewBox="0 0 395 441" fill="none" xmlns="http://www.w3.org/2000/svg">11 <g class="path-right-side">12 <!-- Side 1 -->13 <path d="M198 3.5L392 111" />14 <!-- Side 2 -->15 <path d="M392 111L392 330.5" />16 <!-- Side 3 -->17 <path class="strike-dash" d="M392 330.5L198 437.5" />18 </g>19 <g class="path-left-side">20 <path d="M198 437.5L3 330.5" />21 <!-- Side 5 -->22 <path d="M3 330.5L3 111" />23 <!-- Side 6 -->24 <path class="strike-dash" d="M3 111L198 3.5" />25 </g>26 </svg>27</div>
And just as we did with the starter skill, because there will be more than one color for the background, we will add separate styles for the background of the combined skills.
1/* Combined skills colors */2.hex-strength-durability .hex-content {3 background: linear-gradient(to right, crimson 0%, #FF9F43 80%);4}5
6.hex-health-mana .hex-content {7 background: linear-gradient(to right, #42A5F5 0%, #4CAF50 80%);8}9
10/* Locked background */11.hex-locked .hex-content{12 background: #ffffff;13}
And with that we have all the skills placed on the skill tree!
If you comment out the data-bind-class-toggle
attribute you will be able to see the color categories
Replacing the skill tree svg
Now that we have all the skills placed on the skill tree, we can remove the hexagons that we placed our elements upon and preserve only the lines connecting them.
Achieving this is very simple, we just need to head to Figma again and export the skill tree without the hexagons.
Adding effects and animations
To make our colorful skill tree even more engaging we can add some effects and animations to the hexagons.
Glow effect
To enhance the visual appeal of the skill tree we can add a glow effect to the hexagons making them more futuristic.
Doing that is very simple, we just need to add a filter
property to the svg hexagon element.
1.svg-main {2 filter: drop-shadow(0 0 5px var(--shadow-color)) drop-shadow(0 0 20px var(--shadow-color));3}
Each hexagon will now have a glow effect that will change color based on the category of the skill.
We also need to handle the combined skills and the starter skill separately.
1/* Combined skills glow */2.svg-main.hex-strength-durability {3 filter: drop-shadow(0 0 5px #FF9F43) drop-shadow(0 0 5px crimson)4}5
6.svg-main.hex-health-mana {7 filter: drop-shadow(0 0 5px #42A5F5) drop-shadow(0 0 5px #4CAF50)8}9
10/* Only for specificity */11.hex-locked .hex-strength-durability.svg-main,12.hex-locked .hex-health-mana.svg-main {13 filter: drop-shadow(0 0 5px #ffffff) drop-shadow(0 0 5px #ffffff)14}
The starter skill will have a different glow effect. We want each side of the hexagon to have a different glow color. Unfortunately,
we can’t apply filter to a path element, so the approach we will go for is to put 2 of the same SVGs in the hex-content
element and blur them, making the effect of a glow.
In the hex-starter
element, inside hex-content
we will add the following html:
1<div>2 <svg class="svg svg-main svg-starter-glow outer" viewBox="0 0 395 441" fill="none" xmlns="http://www.w3.org/2000/svg">3 <!-- Side 1 -->4 <path d="M198 3.5L392 111" stroke="#FF9F43" />5 <!-- Side 2 -->6 <path d="M392 111L392 330.5" stroke="#FFEB3B" />7 <!-- Side 3 -->8 <path class="strike-dash" d="M392 330.5L198 437.5" stroke="#4CAF50"/>9 <!-- Side 4 (with stroke-dasharray) -->10 <path d="M198 437.5L3 330.5" stroke="#42A5F5"/>11 <!-- Side 5 -->12 <path d="M3 330.5L3 111" stroke="#AB47BC" />13 <!-- Side 6 -->14 <path class="strike-dash" d="M3 111L198 3.5" stroke="crimson" />15 </svg>16 <svg class="svg svg-main svg-starter-glow inner" viewBox="0 0 395 441" fill="none" xmlns="http://www.w3.org/2000/svg">17 <!-- Side 1 -->18 <path d="M198 3.5L392 111" stroke="#FF9F43" />19 <!-- Side 2 -->20 <path d="M392 111L392 330.5" stroke="#FFEB3B" />21 <!-- Side 3 -->22 <path class="strike-dash" d="M392 330.5L198 437.5" stroke="#4CAF50"/>23 <!-- Side 4 (with stroke-dasharray) -->24 <path d="M198 437.5L3 330.5" stroke="#42A5F5"/>25 <!-- Side 5 -->26 <path d="M3 330.5L3 111" stroke="#AB47BC" />27 <!-- Side 6 -->28 <path class="strike-dash" d="M3 111L198 3.5" stroke="crimson" />29 </svg>30</div>
And the following CSS:
1.svg-starter-glow {2 filter: blur(10px);3}4
5.svg-starter-glow.outer {6 transform: scale(1.02);7}8
9.svg-starter-glow.inner {10 transform: scale(0.98);11}
Active skill effect
When a skill is hovered or selected we can add a unique animation making it stand out from the rest:
Let’s begin by firstly modifying the hex elements by adding a couple of SVGs again inside the hex-content
element.
To decrease the amount of code, we are going to once again use data-bind-for
to render the inner svgs that will be used for the animation.
1engine.on("Ready", () => {2engine.createJSModel("SkillsModel", SkillsModel);3engine.createJSModel("SvgModel", SvgModel);
And we should also create the model like so:
1const SvgModel = {2 innerSvgs: [1,2,3],3}
Starter hex:
1<!-- Outer -->2<svg class="svg svg-outer" viewBox="0 0 395 441" fill="none" xmlns="http://www.w3.org/2000/svg">12 collapsed lines
3 <!-- Side 1 -->4 <path d="M198 3.5L392 111" stroke="#FF9F43" />5 <!-- Side 2 -->6 <path d="M392 111L392 330.5" stroke="#FFEB3B" />7 <!-- Side 3 -->8 <path d="M392 330.5L198 437.5" stroke="#4CAF50"/>9 <!-- Side 4 (with stroke-dasharray) -->10 <path d="M198 437.5L3 330.5" stroke="#42A5F5"/>11 <!-- Side 5 -->12 <path d="M3 330.5L3 111" stroke="#AB47BC" />13 <!-- Side 6 -->14 <path d="M3 111L198 3.5" stroke="crimson" />15</svg>16
17<!-- Inner -->18<div data-bind-for="svg:{{SvgModel.innerSvgs}}">19 <svg class="svg svg-inner" data-bind-class="'svg-inner-'+{{svg}}" data-bind-class-toggle="selected: {{SkillsModel.starterSkill}} === {{activeState.activeSkill}}" viewBox="0 0 395 441" fill="none" xmlns="http://www.w3.org/2000/svg">11 collapsed lines
20 <path d="M198 3.5L392 111" stroke="#FF9F43" />21 <!-- Side 2 -->22 <path d="M392 111L392 330.5" stroke="#FFEB3B" />23 <!-- Side 3 -->24 <path d="M392 330.5L198 437.5" stroke="#4CAF50"/>25 <!-- Side 4 (with stroke-dasharray) -->26 <path d="M198 437.5L3 330.5" stroke="#42A5F5"/>27 <!-- Side 5 -->28 <path d="M3 330.5L3 111" stroke="#AB47BC" />29 <!-- Side 6 -->30 <path d="M3 111L198 3.5" stroke="crimson" />31 </svg>32</div>
Regular skills:
1<svg class="svg svg-outer" viewBox="0 0 395 441" fill="none" xmlns="http://www.w3.org/2000/svg">2 <path d="M392 111L198 3.5L3 111V330.5L198 437.5L392 330.5V111Z" />3</svg>4
5<!-- Inner -->6<div data-bind-for="svg:{{SvgModel.innerSvgs}}">7 <svg class="svg svg-inner" data-bind-class="'svg-inner-'+{{svg}}" data-bind-class-toggle="selected: {{skill}} === {{activeState.activeSkill}}" viewBox="0 0 395 441" fill="none" xmlns="http://www.w3.org/2000/svg">8 <path d="M392 111L198 3.5L3 111V330.5L198 437.5L392 330.5V111Z" />9 </svg>10</div>
Combined skills:
1<!-- Outer -->2<svg class="svg svg-outer" viewBox="0 0 395 441" fill="none" xmlns="http://www.w3.org/2000/svg">15 collapsed lines
3 <g class="path-right-side">4 <!-- Side 1 -->5 <path d="M198 3.5L392 111" />6 <!-- Side 2 -->7 <path d="M392 111L392 330.5" />8 <!-- Side 3 -->9 <path d="M392 330.5L198 437.5" />10 </g>11 <g class="path-left-side">12 <path d="M198 437.5L3 330.5" />13 <!-- Side 5 -->14 <path d="M3 330.5L3 111" />15 <!-- Side 6 -->16 <path d="M3 111L198 3.5" />17 </g>18</svg>19
20<!-- Inner -->21<div data-bind-for="svg:{{SvgModel.innerSvgs}}">22 <svg class="svg svg-inner" data-bind-class="'svg-inner-'+{{svg}}" data-bind-class-toggle="selected: {{skill}} === {{activeState.activeSkill}}" viewBox="0 0 395 441" fill="none" xmlns="http://www.w3.org/2000/svg">23 <g class="path-right-side">24 <!-- Side 1 -->25 <path d="M198 3.5L392 111" />26 <!-- Side 2 -->27 <path d="M392 111L392 330.5" />28 <!-- Side 3 -->29 <path d="M392 330.5L198 437.5" />30 </g>31 <g class="path-left-side">32 <path d="M198 437.5L3 330.5" />33 <!-- Side 5 -->34 <path d="M3 330.5L3 111" />35 <!-- Side 6 -->36 <path d="M3 111L198 3.5" />37 </g>38 </svg>39</div>
We will now use the svg-outer
and svg-inner
classes to apply different kinds of animations for each SVG.
Let’s create a second CSS file to put our animations in as to not polute the global one.
1@keyframes inner-shrink-1 {2 0% {3 transform: scale(1);4 }5 100% {6 transform: scale(0.9);7 }8}9
10@keyframes inner-shrink-2 {11 0% {12 transform: scale(0.95);13 }14 100% {15 transform: scale(0.85);16 }17}18
19@keyframes inner-shrink-3 {20 0% {21 transform: scale(0.95);22 }23 100% {24 transform: scale(0.8);25 }26}27
28@keyframes outer-glow {29 0% {30 filter: drop-shadow(0 0 5px rgba(0, 0, 0, 0.51)) blur(5px);31 stroke-width: 10;32 opacity: 1;33 }34
35 25% {36 filter: drop-shadow(0 0 30px rgba(0, 0, 0, 0.51)) blur(15px);37 stroke-width: 50;38 }39
40 90% {41 transform: scale(1.25);42 opacity: 0.25;43 stroke-width: 20;44 }45
46 100% {47 opacity: 0;48 stroke-width: 0;49 transform: scale(1.3);50 }51}
Unfortunately, CSS variables can’t be used in keyframes, so we will have to set some default colors for the animations.
Now let’s add the animations to the hexagons:
1/* Outer shadow */2.selected {3 transform: scale(1.1);4}5
6.svg-outer {7 position: absolute;8 top: 0;9 left: 0;10 display: none;11}12
13.selected .svg-outer {14 animation: outer-glow 1.5s infinite ease-in;15 display: block;16}17
18/* Inner animation svg */19.svg-inner {20 position: absolute;21 top: 0;22 left: 0;23 stroke-width: 4;24 opacity: 0.5;25 display: none;26}27
28.selected.svg-inner {29 display: block;30}31
32.svg-inner-1 {33 animation: inner-shrink-1 0.75s infinite alternate ease-in-out;34}35
36.svg-inner-2 {37 stroke-width: 7;38 opacity: 0.75;39 animation: inner-shrink-2 0.75s infinite alternate ease-in-out;40}41
42.svg-inner-3 {43 animation: inner-shrink-3 0.75s infinite alternate ease-in-out;44}
We added a selected
class to apply and run the animations only on the currently selected element.
We can now add the selected
class to the hexagon element and its SVGs when it is hovered or clicked. We will again utilize the power of data-binding
to easily attach events to our skill tree items.
For more seаmless state management, we are going to create an observable model which will automatically update when its state changes.
After initialzing it we are going to set its value as our starter skill.
1engine.on("Ready", () => {2 collapsed lines
2 engine.createJSModel("SkillsModel", SkillsModel);3 engine.createJSModel("SvgModel", SvgModel);4 engine.createObservableModel("activeState");5 engine.addSynchronizationDependency(SkillsModel, activeState);6
7 activeState.activeSkill = SkillsModel.starterSkill;
Starter skill:
1<div2 class="hex hex-starter"3 data-bind-style-top='{{SkillsModel.starterSkill.y}}'4 data-bind-style-left='{{SkillsModel.starterSkill.x}}'5 data-bind-focus="makeActive(this, {{SkillsModel.starterSkill}})"6 data-bind-class-toggle="selected: {{SkillsModel.starterSkill}} === {{activeState.activeSkill}}"7 data-bind-mouseenter="focusElement(this)"8>
Other skills:
1<div2 class="hex hex-skill"3 data-bind-for="index, skill:{{SkillsModel.skills}}"4 data-bind-style-top='{{skill.y}}'5 data-bind-style-left='{{skill.x}}'6 data-bind-focus="makeActive(this, {{skill}})"7 data-bind-mouseenter="focusElement(this)"8 data-bind-class="'hex-'+{{skill.type}};'"9 data-bind-class-toggle="hex-locked:{{skill.unlocked}} === false"10 data-bind-class-toggle="selected: {{skill}} === {{activeState.activeSkill}};hex-locked:{{skill.unlocked}} === false">11>
As you can see, we’ve added a data-bind-focus
attribute to the hexagon element. This attribute will call the makeActive
function when the element is focused.
The makeActive
function will expect the DOM element and the skill object from the model as arguments and there we will handle which element is active.
1function focusElement(element) {2 element.focus();3}4
5function makeActive(skillElement, skill) {6 if(skillElement.classList.contains('selected')) return7
8 activeState.activeSkill = skill;9 engine.synchronizeModels();10}
Like that, the code will almost work, what’s left is to make each of our hexagons focusable.
Adding keyboard navigation
We are going to use Gameface’s Interaction manager library and more specifically the Spatial navigation to make our hexagons focusable and to easily extend our sample with keyboard navigation.
After installing it and putting it in our project, what’s left is to initialize it in our index.js
file.
1function initSpatialNavigation() {2 interactionManager.spatialNavigation.init(['.hex'], 0.3);3 interactionManager.spatialNavigation.focusFirst();4}
Here we say that we want to make all elements with the class hex
focusable and that we want to allow a maximum of 30% overlap
between the current item and the others when deciding which element to focus next.
You can find out more about this parameter in the documentation .
The only thing left is to call this function after the model has loaded in the Ready
event.
1engine.on("Ready", () => {2 engine.createJSModel("SkillsModel", SkillsModel);3 engine.synchronizeModels();4 initSpatialNavigation();5});
And that’s it! Now you can navigate through the hexagons using the keyboard arrows and see the active element animation respond.
Making it all interactive
Now that we have our skill tree set up, we can start making everything interactive and input responsive.
Adding tooltip with skill information
We are going to add a tooltip to display the skill’s information when the user hovers over a skill and a skill points counter to keep track of the available skill points the player has.
The tooltip will consist of the following elements:
- Skill name
- Skill cost
- Skill description
- Unlocked status that will show dynamically based on the skill’s unlocked state.
We are once again going to make use of the observable model that keeps track of the active element. Having this information we can use data-binding
to fill out the textContent
of the elements we need with the properties of the currently active element.
1<body>2 <div class="skill-info" data-bind-class-toggle="hex-locked:{{activeState.activeSkill.unlocked}} === false" data-bind-class="'hex-'+{{activeState.activeSkill.type}}">3 <h1 class="skill-name" data-bind-value="{{activeState.activeSkill.name}}" ></h1>4 <div class="skill-price" data-bind-value="'Skill cost'+' '+{{activeState.activeSkill.skillPoints}}" data-bind-class-toggle="hidden:!{{activeState.activeSkill.skillPoints}}"></div>5 <div class="skill-description" data-bind-value="{{activeState.activeSkill.description}}"></div>6 <div style="overflow: hidden;">7 <div class="skill-status" data-bind-class-toggle="unlocked:{{activeState.activeSkill.unlocked}}">unlocked</div>8 </div>9 </div>10 <div class="container">
Now to add the styles
1.skill-info {2 position: absolute;3 top: 2.5%;4 left: 1%;5 padding: 1.5vmax 1.25vmax;6 width: 30%;7 background: rgba(0, 0, 0, 0.8);8 color: white;9 border-radius: 10px;10 box-shadow: 0 0 20px 3px var(--shadow-color);11 transition: box-shadow 0.5s;12}13
14.hex-locked.skill-info {15 opacity: 0.8;122 collapsed lines
16}17
18.hex-starter.skill-info,19.hex-strength-durability.skill-info,20.hex-health-mana.skill-info {21 background-color: black;22}23
24.hex-strength-durability.skill-info {25 --shadow-color: crimson;26 --shadow-color-2: #FF9F43;27}28
29.hex-health-mana.skill-info {30 --shadow-color: #42A5F5;31 --shadow-color-2: #4CAF50;32}33
34/* Reset for locked state */35.hex-locked.skill-info {36 --shadow-color: #FFF;37}38
39.hex-starter.skill-info::before,40.hex-strength-durability.skill-info::before,41.hex-health-mana.skill-info::before {42 content: '';43 position: absolute;44 top: -5px;45 left: -5px;46 right: -5px;47 bottom: -5px;48 z-index: -1;49 border-radius: 10px;50 filter: blur(10px);51 background-image: linear-gradient(to right, var(--shadow-color), var(--shadow-color-2));52}53
54.hex-starter.skill-info::before {55 background: linear-gradient(to right, #FF9F43, #FFEB3B, #4CAF50, #42A5F5, #AB47BC, crimson);56}57
58/* Reset for locked state */59.hex-locked.skill-info::before {60 background-image: none;61}62
63.skill-name {64 border-bottom: 2px solid rgba(255, 255, 255, 0.5);65 font-size: 1.6vmax;66 margin: 0;67 margin-bottom: 1vmax;68 padding-bottom: 1vmax;69 text-transform: uppercase;70}71
72.skill-price {73 text-transform: uppercase;74 font-size: 1.7vmax;75 margin-bottom: 1vmax;76 color: var(--shadow-color);77 filter: drop-shadow(0 0 10px var(--shadow-color));78 transition: filter 0.5s, color 0.5s;79}80
81.skill-price.hidden {82 display: none;83}84
85.skill-description {86 font-size: 0.9vmax;87}88
89.skill-points {90 position: absolute;91 bottom: 2.5%;92 left: 1%;93 display: flex;94 font-size: 3vmax;95}96
97.skill-status {98 color: white;99 letter-spacing: 10px;100 font-size: 1.2vmax;101 text-transform: uppercase;102 text-align: center;103 margin-top: 2vmax;104 position: relative;105 transform: translateX(-100%);106 opacity: 0;107 transition: transform 0.3s, opacity 0.3s;108}109
110.skill-status.unlocked {111 transform: translate(0);112 opacity: 1;113}114
115.skill-status::before {116 content: '';117 position: absolute;118 top: 0;119 left: 0;120 width: 100%;121 height: 100%;122 background-color: var(--shadow-color);123 opacity: 0.5;124 z-index: -1;125 border: 1px solid white;126 transition: background-color 0.5s;127}128
129/* starter skill status */130.hex-starter .skill-status::before {131 background: linear-gradient(to right, #FF9F43, #FFEB3B, #4CAF50, #42A5F5, #AB47BC, crimson);132}133
134/* Combined skills status */135.hex-strength-durability .skill-status::before,136.hex-health-mana .skill-status::before {137 background-image: linear-gradient(to right, var(--shadow-color), var(--shadow-color-2));138}
The idea is to have the tooltip correspond to the skill category and also have a glow effect. We achieved this by using box shadows
for the regular skills and linear gradients
for the starter and combined skills.
Now when you hover over a skill, you will see the tooltip with the skill’s information dynamically updating upon interaction.
Adding skill unlock functionality
In order to make the interaction with the skill tree more engaging, we will make the paths connecting the skills fill with the corresponding color of the skill’s category when a skill is unlocked.
We will slightly modify the SVG paths by giving them category classes, as well as a class that will help us keep track of which path corresponds to which skill of the category, essentially making each path have a unique class that will help us identify when to color its stroke.
1<svg class="svg-paths" width="100%" height="100%" viewBox="0 0 1466 1380" fill="none" xmlns="http://www.w3.org/2000/svg">2 <path class="hex-path hex-locked hex-stamina stamina-1" d="M816.041 681.154H855.924" stroke-width="5"/>3 <path class="hex-path hex-locked hex-stamina stamina-2" d="M1030.69 680.257H1070.58" stroke-width="5"/>4 <path class="hex-path hex-locked hex-stamina stamina-3" d="M1245.35 680.257H1285.23" stroke-width="5"/>5 <path class="hex-path hex-locked hex-strength strength-1" d="M689.74 606.015L669.799 571.475" stroke-width="5"/>6 <path class="hex-path hex-locked hex-strength strength-2" d="M579.5 420.042L559.559 385.502" stroke-width="5"/>7 <path class="hex-path hex-locked hex-strength strength-3" d="M472.174 234.069L452.232 199.529" stroke-width="5"/>8 <path class="hex-path hex-locked hex-durability durability-1" d="M776.004 609.151L795.946 574.611" stroke-width="5"/>9 <path class="hex-path hex-locked hex-durability durability-2" d="M883.107 423.179L903.049 388.639" stroke-width="5"/>10 <path class="hex-path hex-locked hex-durability durability-3" d="M997.828 237.206L1017.77 202.666" stroke-width="5"/>11 <path class="hex-path hex-locked hex-misc misc-1" d="M644.05 679.362H604.167" stroke-width="5"/>12 <path class="hex-path hex-locked hex-misc misc-2" d="M429.306 680.257H389.423" stroke-width="5"/>13 <path class="hex-path hex-locked hex-misc misc-3" d="M214.653 680.257H174.769" stroke-width="5"/>14 <path class="hex-path hex-locked hex-mana mana-1" d="M684.971 752.67L665.03 787.21" stroke-width="5"/>15 <path class="hex-path hex-locked hex-mana mana-2" d="M577.948 938.603L558.007 973.143" stroke-width="5"/>16 <path class="hex-path hex-locked hex-mana mana-3" d="M470.687 1124.58L450.745 1159.12" stroke-width="5"/>17 <path class="hex-path hex-locked hex-health health-1" d="M774.518 753.526L794.459 788.066" stroke-width="5"/>18 <path class="hex-path hex-locked hex-health health-2" d="M885.14 939.5L905.081 974.04" stroke-width="5"/>19 <path class="hex-path hex-locked hex-health health-3" d="M996.34 1125.47L1016.28 1160.01" stroke-width="5"/>20 <path class="hex-path hex-locked hex-mana health-mana" d="M496.525 1244.9H640.15" stroke-width="5"/>21 <path class="hex-path hex-locked hex-health health-mana" d="M819.85 1244.9H975.798" stroke-width="5"/>22 <path class="hex-path hex-locked hex-strength strength-durability" d="M495.181 129.061H638.806" stroke-width="5"/>23 <path class="hex-path hex-locked hex-durability strength-durability" d="M818.505 129.061H974.454" stroke-width="5"/>24</svg>
Let’s apply the styles to the paths, as well as handle the transition for when the stroke is filled.
1.hex-path {2 stroke: var(--shadow-color);3 transition: all 0.3s ease-in;4}
With that out of the way we are now ready to combine the skill unlock functionality with the skill tree.
In the index.js
file we will add an unlockSkill
function that will handle all the logic.
1function unlockSkill(skillElement, skill, combined = false) {2 const {type, skillPoints, parents, unlocked} = skill;3
4 skillElement.focus();5
6 if (unlocked) return;7
8 if(skillPoints > SkillsModel.points || !isSkillValid(parents, skill)) {9 return;10 };11
12 skillElement.classList.remove('hex-locked');13 SkillsModel.points -= skillPoints;14 skill.unlocked = true;15
16 updateModel(SkillsModel);17
18 if(combined) {19 const paths = skillTree.querySelectorAll(`.${type}`);20 if (paths) paths.forEach((path) => path.classList.remove('hex-locked'));21 } else {22 const path = skillTree.querySelector(`.${type}-${skillPoints}`);23 if (path) path.classList.remove('hex-locked');24 }25}26
27
28function isSkillValid(parents, skill) {29 if(parents === null) return true;30
31 if (Array.isArray(parents)) return skill.parents.every((parentId) => SkillsModel.skills[parentId - 1].unlocked);32
33 return SkillsModel.skills[skill.parents - 1].unlocked;34}
In the unlockSkill
function we do the following:
- Check if the skill is already unlocked
- Check if the player has enough skill points to unlock the skill
- Check if the skill’s parent is unlocked
- If all the conditions are met, we set the unlocked field in the model to true, update the model, and remove the
hex-locked
class from the skill element and the corresponding path to fill the svg’s path with color.
We also added the isSkillValid
helper function that will help us determine if the skill’s is valid for unlocking.
- Normal skill is considered valid if the parent is unlocked
- Combined skill is considered valid if both parents are unlocked
Lastly we need to connect the unlockSkill
function with our hexagon elements. The logic for unlocking will trigger when the element is clicked or the key ‘Enter’ is pressed.
5 collapsed lines
1<div2 class="hex hex-skill"3 data-bind-for="index, skill:{{SkillsModel.skills}}"4 data-bind-style-top='{{skill.y}}'5 data-bind-style-left='{{skill.x}}'6 data-bind-click="unlockSkill(this, {{skill}})"7 data-bind-keypress="handleKeyPress(this, event, {{skill}})"4 collapsed lines
8 data-bind-focus="makeActive(this, {{skill}})"9 data-bind-mouseenter="focusElement(this)"10 data-bind-class="'hex-'+{{skill.type}};'"11 data-bind-class-toggle="hex-locked:{{skill.unlocked}} === false">
For the combined skills, it’s the same as the regular skills, but we need to pass an additional argument to the unlockSkill
function to indicate that the skill is combined.
5 collapsed lines
1<div2 class="hex hex-skill"3 data-bind-for="index, skill:{{SkillsModel.skills}}"4 data-bind-style-top='{{skill.y}}'5 data-bind-style-left='{{skill.x}}'6 data-bind-click="unlockSkill(this, {{skill}}, true)"7 data-bind-keypress="handleKeyPress(this, event, {{skill}}, true)"4 collapsed lines
8 data-bind-focus="makeActive(this, {{skill}})"9 data-bind-mouseenter="focusElement(this)"10 data-bind-class="'hex-'+{{skill.type}};'"11 data-bind-class-toggle="hex-locked:{{skill.unlocked}} === false">
Lastly, we need to handle the key press event for the ‘Enter’ key within the handleKeyPress
function. Its logic is simple - if the ‘Enter’ key is pressed, we call the unlockSkill
function.
1function handleKeyPress(skillElement, event, skill, combined = false) {2 if(event.charCode !== 13) return3
4 unlockSkill(skillElement, skill, combined)5}
And that’s it! Now you can interact with the skill tree by unlocking the skills and seeing the paths, skills and the tooltip fill with color.
Adding error Handling
As you have probably noticed, clicking on a skill that is locked and doesn’t meet the requirements will not show a visual cue.
We should notify the player that the skill can’t be unlocked and why. We will add a subtle locked animation to the skill element and display an error message.
For displaying the error message, we will use Gameface’s toast component.
Click if unsure how to add it to your project
First, we need to install it.
1npm i coherent-gameface-toast
After installing it, we need to add the toast component to our project. Let’s first add the toast’s styles.
We will add them before our custom styles, so they can be easily overwritten.
1<link rel="stylesheet" href="./node_modules/coherent-gameface-toast/coherent-gameface-components-theme.css">2<link rel="stylesheet" href="./node_modules/coherent-gameface-toast/style.css">3<link rel="stylesheet" href="styles.css">4<link rel="stylesheet" href="animations.css">
And the toast’s script
1<script src="cohtml.js"></script>2<script src="model.js"></script>3<script src="node_modules/coherent-gameface-interaction-manager/dist/interaction-manager.min.js"></script>4<script src="./node_modules/coherent-gameface-toast/dist/toast.production.min.js"></script>5<script src="index.js"></script>
Let’s add the toast to our index.html
file.
1<body>2<gameface-toast class="toast-slide-in" position="top-right" timeout="3000">3 <div slot="message">4 <div class="error-message-header">Can't unlock</div>5 <div class="error-message"></div>6 </div>7</gameface-toast>
Now let’s customize the toast’s styles to fit the theme of our project.
1/* Error message */2.guic-toast-container {3 overflow: visible;4}5
6.toast-slide-in {7 animation-name: slide-in;8 animation-duration: 0.5s;9}10
11.toast-slide-in-retrigger {12 animation-name: slide-in-retrigger;13 animation-duration: 0.5s;14}15
16.guic-toast-hide {17 animation-name: guic-toast-fade-out;18}19
20.guic-toast {21 background: rgba(0, 0, 0, 0.8);22 color: white;23 border-radius: 10px;24 box-shadow: 0 0 20px 3px crimson;25 padding: 0.2vmax;26}27
28.guic-toast-message {29 padding: 0.5vmax;30}31
32.error-message-header {33 background-color: rgba(220, 20, 60, 0.5);34 padding: 0.3vmax 0.5vmax;35 margin-bottom: 0.3vmax;36 text-transform: uppercase;37 border: 1px solid white;38}39
40/* disable since we are not using it */41.guic-toast-close-btn {42 display: none;43}
And add a custom slide-in
animation for the toast, overwritting the default one.
1@keyframes slide-in {2 0% {3 transform: translateX(50vmax);4 }5 50% {6 transform: translateX(-5vmax);7 }8 100% {9 transform: translateX(0%);10 }11}12
13@keyframes slide-in-retrigger {14 0% {15 transform: translateX(50vmax);16 }17 50% {18 transform: translateX(-5vmax);19 }20 100% {21 transform: translateX(0%);22 }23}
The reason we added 2 animation that do the same thing is because we want to be able to retrigger the animation if the toast needs to be shown again before the previous one has finished.
Now let’s add the logic for showing the error message.
1const toast = document.querySelector('gameface-toast');2const toastMessage = toast.querySelector('.error-message');3
4function showErrorMessage(skillPoints) {5 toastMessage.textContent = skillPoints > SkillsModel.points ? 'Not enough skill points' : 'Parent skill not unlocked';6 if(toast.visible) {7 toast.classList.toggle('toast-slide-in');8 toast.classList.toggle('toast-slide-in-retrigger');9 }10
11 toast.show();12}
The other visual indicator for invalid operation is the locked animation on the skill element. We will add a simple animation that will make the skill element shake when the player tries to unlock a locked skill.
1@keyframes shake {2 25% {3 transform: translateX(2.5%);4 }5
6 50% {7 transform: translateX(-2.5%);8 }9}
Apply it to the hex elements
1.hex.shake{2 animation: shake 0.5s ease-in-out;3}
What’s left is to add a function to trigger the animation and combine it with the toast error message in the unlockSkill
function.
1if (skillPoints > SkillsModel.points || !isSkillValid(parents, skill)) {2 showErrorMessage(skillPoints);3 triggerLockedAnimation(skillElement);4 return;5};6
7function triggerLockedAnimation(skillElement) {8 skillElement.classList.add('shake');9 setTimeout(() => skillElement.classList.remove('shake'), 500);10}
Now we have a fully interactive skill tree with error handling and visual cues for the player!
Quality of life improvements
Our skill tree is almost complete, but there is one quality of life improvement we can add to make the experience even better.
Currenly, if the player decided to navigate the tree using only the keyboard, the screen won’t follow them like it would if they were using the mouse.
We can fix this by enhancing the makeActive
function by calling the translateSkillTreeIntoView
function and providing it with the coordinates of the active skill.
1function makeActive(skillElement, skill) {2 if(skillElement.classList.contains('selected')) return3
4 activeState.activeSkill = skill;5 engine.synchronizeModels();6
7 const skillRect = skillElement.getBoundingClientRect();8 const skillTreeRect = skillTree.getBoundingClientRect();9
10 // Coordinates relative to the skill tree11 const x = skillRect.x - skillTreeRect.x;12 const y = skillRect.y - skillTreeRect.y;13
14 translateSkillTreeIntoView(x, y);15}
In order to properly calculate the correct x and y coordinates, we have to subtract the position of the skill element from the position of the skill tree. That way we get the correct coordinates relative to the viewport and not the tree SVG.
And with that our skill tree is complete!