+16
-15
apps/skillulator/public/locales/en/translation.json
+16
-15
apps/skillulator/public/locales/en/translation.json
···
1
1
{
2
-
"pageDescription": "Skillulator, optimize and share your FlyFF skill builds",
3
-
"secondaryTitle": "How to use",
4
-
"appInstructions": {
5
-
"inst1": "Use the left and right click to level up or level down a skill",
6
-
"inst2": "The arrow up and arrow down keys can be used as well",
7
-
"inst3": "You can set a level using the character level input",
8
-
"inst4": "Clicking \"copy skill tree\" will create a link for you to share with other people"
9
-
},
10
-
"classSelectionLink": "Back to class selection",
11
-
"copyText": "Copy skill tree",
12
-
"copiedText": "Copied code to clipboard!",
13
-
"resetText": "Reset skill tree",
14
-
"availSkillPoints": "Available skill points",
15
-
"charLevel": "Character Level",
16
-
"requiredText": "is required"
2
+
"pageDescription": "Skillulator, optimize and share your FlyFF skill builds",
3
+
"secondaryTitle": "How to use",
4
+
"appInstructions": {
5
+
"inst1": "Use the left and right click to level up or level down a skill",
6
+
"inst2": "The arrow up and arrow down keys can be used as well",
7
+
"inst3": "You can set a level using the character level input",
8
+
"inst4": "Clicking \"copy skill tree\" will create a link for you to share with other people",
9
+
"inst5": "Clicking on the master icon within a skill opens the master variation dialog"
10
+
},
11
+
"classSelectionLink": "Back to class selection",
12
+
"copyText": "Copy skill tree",
13
+
"copiedText": "Copied code to clipboard!",
14
+
"resetText": "Reset skill tree",
15
+
"availSkillPoints": "Available skill points",
16
+
"charLevel": "Character Level",
17
+
"requiredText": "is required"
17
18
}
+24
apps/skillulator/src/components/LinkCard.tsx
+24
apps/skillulator/src/components/LinkCard.tsx
···
1
+
import { Link } from "@tanstack/react-router";
2
+
3
+
interface Props {
4
+
name: string;
5
+
image: string;
6
+
}
7
+
8
+
export function LinkCard(props: Props) {
9
+
return (
10
+
<Link
11
+
aria-label={`Go to the ${props.name} skill tree`}
12
+
params={{ class: props.name }}
13
+
to="/c/$class"
14
+
className="flex flex-col items-center justify-center px-1 py-2 duration-150 bg-white border border-gray-300 rounded-md hover:bg-gray-100 lg:px-5 a11y-focus"
15
+
>
16
+
<img
17
+
alt=""
18
+
src={`https://skillulator.lol/icons/classes/${props.image}`}
19
+
className="w-10 h-10 md:h-12 md:w-12"
20
+
/>
21
+
<span className="capitalize">{props.name}</span>
22
+
</Link>
23
+
);
24
+
}
+92
apps/skillulator/src/components/MasterSkillVariationDialog.tsx
+92
apps/skillulator/src/components/MasterSkillVariationDialog.tsx
···
1
+
import type { RefObject } from "react";
2
+
import { IncreaseSkillToMaxButton, SkillIconButton } from "./action-buttons";
3
+
import { Requirement } from "./Requirement";
4
+
import type { Language, Skill as SkillType } from "@/types";
5
+
6
+
export function MasterSkillVariationDialog(props: {
7
+
ref: RefObject<HTMLDialogElement | null>;
8
+
masterVariations: SkillType["masterVariations"];
9
+
skill: SkillType;
10
+
jobId: number | undefined;
11
+
skillId: number;
12
+
hasMinLevelRequirements: boolean;
13
+
isMaxed: boolean;
14
+
lang: Language;
15
+
}) {
16
+
const skillName = props.skill.name.en
17
+
.replaceAll(" ", "")
18
+
.replace(/'/, "")
19
+
.replace(/[\[\]()]/g, "")
20
+
.replace(":", "");
21
+
return (
22
+
<dialog
23
+
ref={props.ref}
24
+
className="border border-gray-300 shadow-sm rounded-md p-5 w-1/2 xl:w-[60%] mx-auto my-auto"
25
+
>
26
+
<div className="flex justify-between mb-4">
27
+
<h2 className="font-bold">
28
+
Select a Master Variation (you can only choose one)
29
+
</h2>
30
+
<button
31
+
type="button"
32
+
onClick={() => props?.ref?.current?.close()}
33
+
className="text-sm bg-red-500 text-white py-1 px-2 rounded-md cursor-pointer"
34
+
>
35
+
Close Dialog
36
+
</button>
37
+
</div>
38
+
<div className="flex gap-4">
39
+
{props?.masterVariations?.map((variation, index) => {
40
+
const selectedMasterVariation = props?.masterVariations?.some(
41
+
(s) => s.isSelected === true,
42
+
)
43
+
? props?.masterVariations?.filter((s) => s.isSelected === true)
44
+
: props.masterVariations;
45
+
46
+
const isSelectedMasterVariation =
47
+
!selectedMasterVariation?.every((v) => v.id === variation.id) &&
48
+
selectedMasterVariation?.length === 1;
49
+
50
+
return (
51
+
<div
52
+
key={JSON.stringify({ id: variation.id, index })}
53
+
data-skill={skillName}
54
+
className="relative flex flex-col items-center flex-1 py-2 bg-white border border-gray-300 rounded-md basis-1/2 min-content"
55
+
>
56
+
<SkillIconButton
57
+
locale={props.lang}
58
+
skill={{
59
+
...variation,
60
+
id: props.skill.id,
61
+
masterVariations: props.masterVariations,
62
+
}}
63
+
masterVariationSkillId={variation?.id}
64
+
isSelectedMasterVariation={isSelectedMasterVariation}
65
+
jobId={props.jobId}
66
+
hasMinLevelRequirements={variation?.hasMinLevelRequirements}
67
+
isMaxed={variation?.isMaxed}
68
+
/>
69
+
<div>
70
+
{variation.requirements.map((variation, index: number) => (
71
+
<Requirement
72
+
key={JSON.stringify({ variation, index })}
73
+
hasMinLevelRequirements={variation.hasMinLevel}
74
+
skill={{ level: variation.level, name: variation.name }}
75
+
/>
76
+
))}
77
+
</div>
78
+
<IncreaseSkillToMaxButton
79
+
skillId={props.skill.id}
80
+
jobId={props.jobId}
81
+
isSelectedMasterVariation={isSelectedMasterVariation}
82
+
masterVariationSkillId={variation.id}
83
+
hasMinLevelRequirements={variation?.hasMinLevelRequirements}
84
+
isMaxed={variation?.isMaxed}
85
+
/>
86
+
</div>
87
+
);
88
+
})}
89
+
</div>
90
+
</dialog>
91
+
);
92
+
}
+22
apps/skillulator/src/components/Requirement.tsx
+22
apps/skillulator/src/components/Requirement.tsx
···
1
+
import clsx from "clsx";
2
+
3
+
interface RequirementsProps {
4
+
skill: {
5
+
name: string;
6
+
level: number;
7
+
};
8
+
hasMinLevelRequirements: boolean;
9
+
}
10
+
11
+
export function Requirement(props: RequirementsProps) {
12
+
return (
13
+
<span
14
+
className={clsx(
15
+
"block text-sm text-center",
16
+
props.hasMinLevelRequirements ? "text-green-500" : "text-red-500",
17
+
)}
18
+
>
19
+
{props.skill.name} level {props.skill.level} is required
20
+
</span>
21
+
);
22
+
}
+75
apps/skillulator/src/components/Skill.tsx
+75
apps/skillulator/src/components/Skill.tsx
···
1
+
import type { Language, Skill as SkillType } from "@/types";
2
+
import { languages } from "@/utils/constants";
3
+
import { getLanguageForSkill } from "@/utils/language";
4
+
import { IncreaseSkillToMaxButton, SkillIconButton } from "./action-buttons";
5
+
import { Requirement } from "./Requirement";
6
+
import { useRef, type ComponentRef } from "react";
7
+
import { MasterSkillVariationDialog } from "./MasterSkillVariationDialog";
8
+
9
+
interface SkillProps {
10
+
skill: SkillType;
11
+
jobId: number | undefined;
12
+
skillId: number;
13
+
hasMinLevelRequirements: boolean;
14
+
isMaxed: boolean;
15
+
lang: Language;
16
+
}
17
+
18
+
export default function Skill(props: SkillProps) {
19
+
const locale = getLanguageForSkill(languages, props.lang);
20
+
const skillName = props.skill.name.en
21
+
.replaceAll(" ", "")
22
+
.replace(/'/, "")
23
+
.replace(/[\[\]()]/g, "")
24
+
.replace(":", "");
25
+
26
+
const isMasterVariationSkill =
27
+
props?.skill?.masterVariations?.length &&
28
+
props?.skill?.masterVariations?.length > 1;
29
+
30
+
const masterDialogRef = useRef<ComponentRef<"dialog">>(null);
31
+
32
+
return (
33
+
<div
34
+
data-skill={skillName}
35
+
className="scoped-anchor relative flex flex-col items-center flex-1 pt-3 bg-white border border-gray-300 rounded-md basis-1/2 min-content"
36
+
>
37
+
<SkillIconButton {...props} locale={locale} />
38
+
{props.skill.isPassiveSkill ? (
39
+
<span className="text-sm font-semibold text-purple-600">
40
+
(passive skill)
41
+
</span>
42
+
) : null}
43
+
<div>
44
+
{props.skill.requirements.map((skill, index: number) => (
45
+
<Requirement
46
+
key={JSON.stringify({ skill, index })}
47
+
hasMinLevelRequirements={skill.hasMinLevel}
48
+
skill={{ level: skill.level, name: skill.name }}
49
+
/>
50
+
))}
51
+
</div>
52
+
<div>
53
+
<button
54
+
type="button"
55
+
aria-label="open master variation dialog"
56
+
onClick={() => masterDialogRef?.current?.showModal()}
57
+
>
58
+
{isMasterVariationSkill ? (
59
+
<img
60
+
src="https://api.flyff.com/image/badge/master6.png"
61
+
alt=""
62
+
className="h-6 w-6 absolute top-2 left-3 cursor-pointer"
63
+
/>
64
+
) : null}
65
+
</button>
66
+
<IncreaseSkillToMaxButton {...props} />
67
+
</div>
68
+
<MasterSkillVariationDialog
69
+
ref={masterDialogRef}
70
+
masterVariations={props?.skill?.masterVariations}
71
+
{...props}
72
+
/>
73
+
</div>
74
+
);
75
+
}
+290
apps/skillulator/src/components/Tooltip.tsx
+290
apps/skillulator/src/components/Tooltip.tsx
···
1
+
// @ts-nocheck
2
+
import type { Language, Skill } from "@/types";
3
+
import type { RefObject } from "react";
4
+
5
+
type TooltipProps = {
6
+
skill: Skill;
7
+
popoverRef: RefObject<HTMLDivElement | null>;
8
+
locale: Language;
9
+
};
10
+
11
+
// https://github.com/Frostiae/Flyffulator/blob/main/src/flyff/flyfftooltip.jsx#L535
12
+
function renderSkillAttributes(skill: Skill, locale: Language) {
13
+
const out = [];
14
+
const skillLevel =
15
+
skill.skillLevel >= skill.skillStats.length
16
+
? skill.skillLevel - 1
17
+
: skill.skillLevel;
18
+
const skillAttribute =
19
+
skill.skillLevel !== undefined
20
+
? skill.skillStats[skillLevel]
21
+
: skill.skillStats[0];
22
+
23
+
const attributeClasses = "font-bold";
24
+
25
+
if (skillAttribute.consumedMP !== undefined) {
26
+
out.push(<p>MP: {skillAttribute.consumedMP}</p>);
27
+
}
28
+
29
+
if (skillAttribute.consumedFP !== undefined) {
30
+
out.push(<p>FP: {skillAttribute.consumedFP}</p>);
31
+
}
32
+
33
+
// Attack
34
+
if (skillAttribute.maxAttack !== undefined && skillAttribute.maxAttack > 0) {
35
+
out.push(
36
+
<p className={attributeClasses}>
37
+
Base Damage: {skillAttribute.minAttack} ~ {skillAttribute.maxAttack}
38
+
</p>,
39
+
);
40
+
}
41
+
42
+
if (skillAttribute.scalingParameters !== undefined) {
43
+
for (const scale of skillAttribute.scalingParameters) {
44
+
if (scale.parameter === "attack" && scale.maximum === undefined) {
45
+
out.push(
46
+
<p className={attributeClasses}>
47
+
Attack Scaling: {scale.stat} x {scale.scale}
48
+
</p>,
49
+
);
50
+
}
51
+
}
52
+
}
53
+
54
+
// Heal
55
+
if (skillAttribute.abilities !== undefined) {
56
+
for (const ability of skillAttribute.abilities) {
57
+
if (ability.parameter === "hp") {
58
+
out.push(<p className={attributeClasses}>Base Heal: {ability.add}</p>);
59
+
break;
60
+
}
61
+
}
62
+
}
63
+
64
+
if (skillAttribute.scalingParameters !== undefined) {
65
+
for (const scale of skillAttribute.scalingParameters) {
66
+
if (scale.parameter === "hp") {
67
+
out.push(
68
+
<p className={attributeClasses}>
69
+
Heal Scaling: {scale.stat} x {scale.scale}
70
+
</p>,
71
+
);
72
+
}
73
+
}
74
+
}
75
+
76
+
// Time
77
+
if (skillAttribute.duration !== undefined) {
78
+
const secs = skillAttribute.duration % 60;
79
+
const mins = Math.floor(skillAttribute.duration / 60);
80
+
out.push(
81
+
<p className={attributeClasses}>
82
+
Base Time: {String(mins).padStart(2, "0")}:
83
+
{String(secs).padStart(2, "0")}
84
+
</p>,
85
+
);
86
+
}
87
+
88
+
if (skillAttribute.scalingParameters !== undefined) {
89
+
for (const scale of skillAttribute.scalingParameters) {
90
+
if (scale.parameter === "duration") {
91
+
out.push(
92
+
<p className={attributeClasses}>
93
+
Time Scaling: {scale.stat} x {scale.scale}
94
+
</p>,
95
+
);
96
+
}
97
+
}
98
+
}
99
+
100
+
// Casting time
101
+
if (skillAttribute.casting !== undefined && skillAttribute.casting >= 1) {
102
+
const secs = skillAttribute.casting % 60;
103
+
const mins = Math.floor(skillAttribute.casting / 60);
104
+
out.push(
105
+
<p className={attributeClasses}>
106
+
Casting Time: {String(mins).padStart(2, "0")}:
107
+
{String(secs).padStart(2, "0")}
108
+
</p>,
109
+
);
110
+
}
111
+
112
+
// Cooldown
113
+
// TODO: PvP cooldown seems to be missing from the API, check Holycross for example
114
+
if (skillAttribute.cooldown !== undefined) {
115
+
const secs = Math.ceil(skillAttribute.cooldown) % 60;
116
+
const mins = Math.floor(Math.ceil(skillAttribute.cooldown) / 60);
117
+
out.push(
118
+
<p className={attributeClasses}>
119
+
Cooldown: {String(mins).padStart(2, "0")}:
120
+
{String(secs).padStart(2, "0")}
121
+
</p>,
122
+
);
123
+
}
124
+
125
+
// Range
126
+
if (skillAttribute.spellRange !== undefined) {
127
+
out.push(<p>Spell Range: {skillAttribute.spellRange}</p>);
128
+
129
+
if (skill.target === "party") {
130
+
out.push(<p> (Party)</p>);
131
+
} else if (skill.target === "area") {
132
+
out.push(<p> (Around)</p>);
133
+
}
134
+
}
135
+
136
+
// Probability
137
+
if (skillAttribute.probability !== undefined) {
138
+
out.push(
139
+
<span className={attributeClasses}>
140
+
Probability: {skillAttribute.probability}%
141
+
</span>,
142
+
);
143
+
144
+
if (
145
+
skillAttribute.probabilityPVP !== undefined &&
146
+
skillAttribute.probabilityPVP !== skillAttribute.probability
147
+
) {
148
+
out.push(
149
+
<span className={attributeClasses}>
150
+
{" "}
151
+
/ {skillAttribute.probabilityPVP}% (PVP & Giants)
152
+
</span>,
153
+
);
154
+
}
155
+
}
156
+
157
+
// TODO: wallLives missing from elementor skill
158
+
if (skillAttribute.wallLives !== undefined) {
159
+
out.push(
160
+
<p className={attributeClasses}>
161
+
Number of Lives: {skillAttribute.wallLives}
162
+
</p>,
163
+
);
164
+
}
165
+
166
+
// Reflex hit
167
+
if (
168
+
skillAttribute.reflectedDamagePVE !== undefined &&
169
+
skillAttribute.reflectedDamagePVP !== undefined
170
+
) {
171
+
out.push(
172
+
<p className={attributeClasses}>
173
+
Reflected Damage: {skillAttribute.reflectedDamagePVE}% /{" "}
174
+
{skillAttribute.reflectedDamagePVP}% (PVP)
175
+
</p>,
176
+
);
177
+
}
178
+
179
+
// Damage over time
180
+
if (skillAttribute.dotTick !== undefined) {
181
+
out.push(
182
+
<p className={attributeClasses}>
183
+
DoT Tick: {skillAttribute.dotTick} Seconds
184
+
</p>,
185
+
);
186
+
}
187
+
188
+
// Stats
189
+
// if (skillAttribute.abilities !== undefined) {
190
+
// for (const ability of skillAttribute.abilities) {
191
+
// const abilityStyle = { color: "#6161ff" };
192
+
// const add = ability.add;
193
+
// let extra = 0;
194
+
195
+
// if (skillAttribute.scalingParameters !== undefined) {
196
+
// for (const scale of skillAttribute.scalingParameters) {
197
+
// if (
198
+
// scale.parameter === ability.parameter &&
199
+
// scale.maximum !== undefined
200
+
// ) {
201
+
// let bufferStat = 0;
202
+
// switch (scale.stat) {
203
+
// case "int":
204
+
// bufferStat = Context.player.bufferInt;
205
+
// break;
206
+
// case "str":
207
+
// bufferStat = Context.player.bufferStr;
208
+
// break;
209
+
// case "dex":
210
+
// bufferStat = Context.player.bufferDex;
211
+
// break;
212
+
// default:
213
+
// bufferStat = Context.player.bufferSta;
214
+
// break;
215
+
// }
216
+
217
+
// extra = Math.floor(
218
+
// Math.min(scale.scale * bufferStat, scale.maximum),
219
+
// );
220
+
// }
221
+
// }
222
+
// }
223
+
224
+
// out.push(
225
+
// <span style={abilityStyle}>
226
+
//
227
+
// {Utils.getStatNameByIdOrDefault(ability.parameter, i18n)}
228
+
// {ability.set != undefined ? "=" : "+"}
229
+
// {ability.set != undefined ? ability.set : add + extra}
230
+
// {ability.rate && "%"}
231
+
// </span>,
232
+
// );
233
+
// if (extra > 0) {
234
+
// out.push(
235
+
// <span style={{ color: "#ffaa00" }}>
236
+
// {" "}
237
+
// ({add}+{extra})
238
+
// </span>,
239
+
// );
240
+
// }
241
+
// }
242
+
243
+
// if (skillAttribute.scalingParameters !== undefined) {
244
+
// for (const ability of skillAttribute.abilities) {
245
+
// for (const scale of skillAttribute.scalingParameters) {
246
+
// if (
247
+
// scale.parameter === ability.parameter &&
248
+
// scale.maximum !== undefined
249
+
// ) {
250
+
// out.push(
251
+
// <span style={{ color: "#ffaa00" }}>
252
+
//
253
+
// {scale.parameter} Scaling: +{scale.scale * 25}
254
+
// {ability.rate && "%"} per 25 {scale.stat} (max {scale.maximum}
255
+
// {ability.rate && "%"})
256
+
// </span>,
257
+
// );
258
+
// }
259
+
// }
260
+
// }
261
+
// }
262
+
// }
263
+
264
+
out.push(
265
+
<p className="mt-2">{skill.description[locale] ?? skill.description.en}</p>,
266
+
);
267
+
268
+
return out.map((result) => result);
269
+
}
270
+
271
+
export function Tooltip(props: TooltipProps) {
272
+
return (
273
+
<div
274
+
popover="auto"
275
+
ref={props.popoverRef}
276
+
className="absolute shadow-sm border-2 border-indigo-700 rounded-sm p-4 bg-black/80 text-left attributes-popover w-[350px]"
277
+
>
278
+
<div className="flex gap-1 text-white">
279
+
<h2 className="font-bold text-green-500">
280
+
{props.skill.name[props.locale]}
281
+
</h2>
282
+
·
283
+
<span>Lv. {props.skill.level}</span>
284
+
</div>
285
+
<div className="text-white">
286
+
{renderSkillAttributes(props.skill, props.locale)}
287
+
</div>
288
+
</div>
289
+
);
290
+
}
+61
-1
apps/skillulator/src/css/ranger.css
+61
-1
apps/skillulator/src/css/ranger.css
···
7
7
". . FlameArrow IceArrow PoisonArrow"
8
8
". . PiercingArrow CriticalShot Nature"
9
9
". . Tripleshot Tripleshot SilentArrow"
10
-
". . Boomburst Boomburst .";
10
+
"Boomburst Boomburst Boomburst Boomburst Boomburst"
11
+
"CondorDive HeavyShot . . DevastatingSting"
12
+
". RepellingShot EagleEye SwiftHands Barrage"
13
+
". TrapBinding TrapDoT TrapDebuff ."
14
+
"AdvancedTrap TrapBlast TrapAoE TrapAoE MarkedPrey";
11
15
}
12
16
13
17
[data-skill="Pulling"] {
···
105
109
[data-skill="Boomburst"] {
106
110
grid-area: Boomburst;
107
111
}
112
+
113
+
[data-skill="CondorDive"] {
114
+
grid-area: CondorDive;
115
+
}
116
+
117
+
[data-skill="HeavyShot"] {
118
+
grid-area: HeavyShot;
119
+
}
120
+
121
+
[data-skill="DevastatingSting"] {
122
+
grid-area: DevastatingSting;
123
+
}
124
+
125
+
[data-skill="RepellingShot"] {
126
+
grid-area: RepellingShot;
127
+
}
128
+
129
+
[data-skill="Barrage"] {
130
+
grid-area: Barrage;
131
+
}
132
+
133
+
[data-skill="TrapDoT"] {
134
+
grid-area: TrapDoT;
135
+
}
136
+
137
+
[data-skill="TrapBinding"] {
138
+
grid-area: TrapBinding;
139
+
}
140
+
141
+
[data-skill="TrapDebuff"] {
142
+
grid-area: TrapDebuff;
143
+
}
144
+
145
+
[data-skill="TrapAoE"] {
146
+
grid-area: TrapAoE;
147
+
}
148
+
149
+
[data-skill="TrapBlast"] {
150
+
grid-area: TrapBlast;
151
+
}
152
+
153
+
[data-skill="EagleEye"] {
154
+
grid-area: EagleEye;
155
+
}
156
+
157
+
[data-skill="SwiftHands"] {
158
+
grid-area: SwiftHands;
159
+
}
160
+
161
+
[data-skill="AdvancedTrap"] {
162
+
grid-area: AdvancedTrap;
163
+
}
164
+
165
+
[data-skill="MarkedPrey"] {
166
+
grid-area: MarkedPrey;
167
+
}
+20
-15
apps/skillulator/src/hooks/useSkillTree.ts
+20
-15
apps/skillulator/src/hooks/useSkillTree.ts
···
11
11
import { useCallback, useEffect, useState } from "react";
12
12
import { useTranslation } from "react-i18next";
13
13
import { useTreeStore } from "@/zustand/treeStore";
14
+
import type { Language, Skills } from "@/types";
14
15
15
16
export function useSkillTree() {
16
17
const { i18n } = useTranslation();
···
98
99
]);
99
100
100
101
const sortedSkills = skills
101
-
?.toSorted((a, b) => a.level - b.level)
102
-
?.map((skill) => ({
103
-
...skill,
104
-
hasMinLevelRequirements: checkSkillRequirements(skill),
105
-
isMaxed: isSkillMaxed(skill),
106
-
masterVariations: skill?.masterVariations
107
-
?.toSorted((a, b) => a.level - b.level)
108
-
?.map((variation) => ({
109
-
...variation,
110
-
hasMinLevelRequirements: checkSkillRequirements(variation),
111
-
isMaxed: isSkillMaxed(variation),
112
-
isSelected: isMasterSkillSelected(variation),
113
-
})),
114
-
}));
102
+
? [...skills]
103
+
.sort((a, b) => a.level - b.level)
104
+
.map((skill) => ({
105
+
...skill,
106
+
hasMinLevelRequirements: checkSkillRequirements(skill),
107
+
isMaxed: isSkillMaxed(skill),
108
+
masterVariations: skill?.masterVariations
109
+
? [...skill.masterVariations]
110
+
.sort((a, b) => a.level - b.level)
111
+
.map((variation) => ({
112
+
...variation,
113
+
hasMinLevelRequirements: checkSkillRequirements(variation),
114
+
isMaxed: isSkillMaxed(variation),
115
+
isSelected: isMasterSkillSelected(variation),
116
+
}))
117
+
: [],
118
+
}))
119
+
: [];
115
120
116
121
return {
117
122
job,
···
119
124
level,
120
125
skillPoints,
121
126
copied,
122
-
language: i18n.language,
127
+
language: i18n.language as Language,
123
128
handleLevelChange,
124
129
copyToClipboard,
125
130
handleReset,
+8
-2
apps/skillulator/src/index.css
+8
-2
apps/skillulator/src/index.css
···
28
28
}
29
29
30
30
.attributes-popover {
31
-
inset: auto;
32
-
margin: 0;
33
31
position-anchor: --attributes-popover;
34
32
position-area: bottom right;
35
33
position-try-fallbacks: --top-center, --top-left;
34
+
}
35
+
36
+
body:has(dialog[open]) .skill-icon-anchor {
37
+
anchor-name: none !important;
38
+
}
39
+
40
+
.skill-icon-anchor {
41
+
anchor-name: --attributes-popover;
36
42
}
37
43
38
44
@position-try --top-left {
-22
apps/skillulator/src/routes/c/$class/components/Requirement.tsx
-22
apps/skillulator/src/routes/c/$class/components/Requirement.tsx
···
1
-
import clsx from "clsx";
2
-
3
-
interface RequirementsProps {
4
-
skill: {
5
-
name: string;
6
-
level: number;
7
-
};
8
-
hasMinLevelRequirements: boolean;
9
-
}
10
-
11
-
export function Requirement(props: RequirementsProps) {
12
-
return (
13
-
<span
14
-
className={clsx(
15
-
"block text-sm text-center",
16
-
props.hasMinLevelRequirements ? "text-green-500" : "text-red-500",
17
-
)}
18
-
>
19
-
{props.skill.name} level {props.skill.level} is required
20
-
</span>
21
-
);
22
-
}
-157
apps/skillulator/src/routes/c/$class/components/Skill.tsx
-157
apps/skillulator/src/routes/c/$class/components/Skill.tsx
···
1
-
import type { Skill as SkillType } from "@/types";
2
-
import { languages } from "@/utils/constants";
3
-
import { getLanguageForSkill } from "@/utils/language";
4
-
import {
5
-
// DecreaseSkillPointButton,
6
-
IncreaseSkillToMaxButton,
7
-
SkillIconButton,
8
-
} from "./action-buttons";
9
-
import { Requirement } from "./Requirement";
10
-
import { useRef, type ComponentRef, type RefObject } from "react";
11
-
12
-
interface SkillProps {
13
-
skill: SkillType;
14
-
jobId: number | undefined;
15
-
skillId: number;
16
-
hasMinLevelRequirements: boolean;
17
-
isMaxed: boolean;
18
-
lang: string;
19
-
}
20
-
21
-
export default function Skill(props: SkillProps) {
22
-
const locale = getLanguageForSkill(languages, props.lang);
23
-
const skillName = props.skill.name.en
24
-
.replaceAll(" ", "")
25
-
.replace(/'/, "")
26
-
.replace(/[\[\]()]/g, "");
27
-
28
-
const isMasterVariationSkill =
29
-
typeof props?.skill?.masterVariations?.length !== "undefined";
30
-
31
-
const masterDialogRef = useRef<ComponentRef<"dialog">>(null);
32
-
33
-
return (
34
-
<div
35
-
data-skill={skillName}
36
-
className="scoped-anchor relative flex flex-col items-center flex-1 pt-3 bg-white border border-gray-300 rounded-md basis-1/2 min-content"
37
-
>
38
-
<SkillIconButton {...props} locale={locale} />
39
-
{props.skill.isPassiveSkill ? (
40
-
<span className="text-sm font-semibold text-purple-600">
41
-
(passive skill)
42
-
</span>
43
-
) : null}
44
-
<div>
45
-
{props.skill.requirements.map((skill, index: number) => (
46
-
<Requirement
47
-
key={JSON.stringify({ skill, index })}
48
-
hasMinLevelRequirements={skill.hasMinLevel}
49
-
skill={{ level: skill.level, name: skill.name }}
50
-
/>
51
-
))}
52
-
</div>
53
-
<div>
54
-
<button
55
-
type="button"
56
-
aria-label="open master variation dialog"
57
-
onClick={() => masterDialogRef?.current?.showModal()}
58
-
>
59
-
{isMasterVariationSkill ? (
60
-
<img
61
-
src="https://api.flyff.com/image/badge/master6.png"
62
-
alt=""
63
-
className="h-6 w-6 absolute top-2 left-3 cursor-pointer"
64
-
/>
65
-
) : null}
66
-
</button>
67
-
{/* <DecreaseSkillPointButton {...props} /> */}
68
-
<IncreaseSkillToMaxButton {...props} />
69
-
</div>
70
-
<MasterSkillVariationDialog
71
-
ref={masterDialogRef}
72
-
masterVariations={props?.skill?.masterVariations}
73
-
{...props}
74
-
/>
75
-
</div>
76
-
);
77
-
}
78
-
79
-
function MasterSkillVariationDialog(props: {
80
-
ref: RefObject<HTMLDialogElement | null>;
81
-
masterVariations: SkillType["masterVariations"];
82
-
skill: SkillType;
83
-
jobId: number | undefined;
84
-
skillId: number;
85
-
hasMinLevelRequirements: boolean;
86
-
isMaxed: boolean;
87
-
lang: string;
88
-
}) {
89
-
return (
90
-
<dialog
91
-
ref={props.ref}
92
-
className="border border-gray-300 shadow-sm rounded-md p-5 w-1/2 xl:w-[60%] mx-auto my-auto"
93
-
>
94
-
<div className="flex justify-between mb-4">
95
-
<h2 className="font-bold">
96
-
Select a Master Variation (you can only choose one)
97
-
</h2>
98
-
<button
99
-
type="button"
100
-
onClick={() => props?.ref?.current?.close()}
101
-
className="text-sm bg-red-500 text-white py-1 px-2 rounded-md cursor-pointer"
102
-
>
103
-
Close Dialog
104
-
</button>
105
-
</div>
106
-
<div className="flex gap-4">
107
-
{props?.masterVariations?.map((variation, index) => {
108
-
const selectedMasterVariation = props?.masterVariations?.some(
109
-
(s) => s.isSelected === true,
110
-
)
111
-
? props?.masterVariations?.filter((s) => s.isSelected === true)
112
-
: props.masterVariations;
113
-
114
-
const isSelectedMasterVariation =
115
-
!selectedMasterVariation?.every((v) => v.id === variation.id) &&
116
-
selectedMasterVariation?.length === 1;
117
-
118
-
return (
119
-
<div
120
-
key={JSON.stringify({ id: variation.id, index })}
121
-
data-skill={props.skill.name}
122
-
className="relative flex flex-col items-center flex-1 py-2 bg-white border border-gray-300 rounded-md basis-1/2 min-content"
123
-
>
124
-
<SkillIconButton
125
-
locale={props.lang}
126
-
skill={{ ...variation, id: props.skill.id }}
127
-
masterVariationSkillId={variation?.id}
128
-
isSelectedMasterVariation={isSelectedMasterVariation}
129
-
jobId={props.jobId}
130
-
hasMinLevelRequirements={variation?.hasMinLevelRequirements}
131
-
isMaxed={variation?.isMaxed}
132
-
/>
133
-
<div>
134
-
{variation.requirements.map((variation, index: number) => (
135
-
<Requirement
136
-
key={JSON.stringify({ variation, index })}
137
-
hasMinLevelRequirements={variation.hasMinLevel}
138
-
skill={{ level: variation.level, name: variation.name }}
139
-
/>
140
-
))}
141
-
</div>
142
-
{/* <DecreaseSkillPointButton {...props} /> */}
143
-
<IncreaseSkillToMaxButton
144
-
skillId={props.skill.id}
145
-
jobId={props.jobId}
146
-
isSelectedMasterVariation={isSelectedMasterVariation}
147
-
masterVariationSkillId={variation.id}
148
-
hasMinLevelRequirements={variation?.hasMinLevelRequirements}
149
-
isMaxed={variation?.isMaxed}
150
-
/>
151
-
</div>
152
-
);
153
-
})}
154
-
</div>
155
-
</dialog>
156
-
);
157
-
}
-290
apps/skillulator/src/routes/c/$class/components/Tooltip.tsx
-290
apps/skillulator/src/routes/c/$class/components/Tooltip.tsx
···
1
-
// @ts-nocheck
2
-
import type { Language, Skill } from "@/types";
3
-
import type { RefObject } from "react";
4
-
5
-
type TooltipProps = {
6
-
skill: Skill;
7
-
popoverRef: RefObject<HTMLDivElement | null>;
8
-
locale: Language;
9
-
};
10
-
11
-
// https://github.com/Frostiae/Flyffulator/blob/main/src/flyff/flyfftooltip.jsx#L535
12
-
function renderSkillAttributes(skill: Skill, locale: Language) {
13
-
const out = [];
14
-
const skillLevel =
15
-
skill.skillLevel >= skill.skillStats.length
16
-
? skill.skillLevel - 1
17
-
: skill.skillLevel;
18
-
const skillAttribute =
19
-
skill.skillLevel !== undefined
20
-
? skill.skillStats[skillLevel]
21
-
: skill.skillStats[0];
22
-
23
-
const attributeClasses = "font-bold";
24
-
25
-
if (skillAttribute.consumedMP !== undefined) {
26
-
out.push(<p>MP: {skillAttribute.consumedMP}</p>);
27
-
}
28
-
29
-
if (skillAttribute.consumedFP !== undefined) {
30
-
out.push(<p>FP: {skillAttribute.consumedFP}</p>);
31
-
}
32
-
33
-
// Attack
34
-
if (skillAttribute.maxAttack !== undefined && skillAttribute.maxAttack > 0) {
35
-
out.push(
36
-
<p className={attributeClasses}>
37
-
Base Damage: {skillAttribute.minAttack} ~ {skillAttribute.maxAttack}
38
-
</p>,
39
-
);
40
-
}
41
-
42
-
if (skillAttribute.scalingParameters !== undefined) {
43
-
for (const scale of skillAttribute.scalingParameters) {
44
-
if (scale.parameter === "attack" && scale.maximum === undefined) {
45
-
out.push(
46
-
<p className={attributeClasses}>
47
-
Attack Scaling: {scale.stat} x {scale.scale}
48
-
</p>,
49
-
);
50
-
}
51
-
}
52
-
}
53
-
54
-
// Heal
55
-
if (skillAttribute.abilities !== undefined) {
56
-
for (const ability of skillAttribute.abilities) {
57
-
if (ability.parameter === "hp") {
58
-
out.push(<p className={attributeClasses}>Base Heal: {ability.add}</p>);
59
-
break;
60
-
}
61
-
}
62
-
}
63
-
64
-
if (skillAttribute.scalingParameters !== undefined) {
65
-
for (const scale of skillAttribute.scalingParameters) {
66
-
if (scale.parameter === "hp") {
67
-
out.push(
68
-
<p className={attributeClasses}>
69
-
Heal Scaling: {scale.stat} x {scale.scale}
70
-
</p>,
71
-
);
72
-
}
73
-
}
74
-
}
75
-
76
-
// Time
77
-
if (skillAttribute.duration !== undefined) {
78
-
const secs = skillAttribute.duration % 60;
79
-
const mins = Math.floor(skillAttribute.duration / 60);
80
-
out.push(
81
-
<p className={attributeClasses}>
82
-
Base Time: {String(mins).padStart(2, "0")}:
83
-
{String(secs).padStart(2, "0")}
84
-
</p>,
85
-
);
86
-
}
87
-
88
-
if (skillAttribute.scalingParameters !== undefined) {
89
-
for (const scale of skillAttribute.scalingParameters) {
90
-
if (scale.parameter === "duration") {
91
-
out.push(
92
-
<p className={attributeClasses}>
93
-
Time Scaling: {scale.stat} x {scale.scale}
94
-
</p>,
95
-
);
96
-
}
97
-
}
98
-
}
99
-
100
-
// Casting time
101
-
if (skillAttribute.casting !== undefined && skillAttribute.casting >= 1) {
102
-
const secs = skillAttribute.casting % 60;
103
-
const mins = Math.floor(skillAttribute.casting / 60);
104
-
out.push(
105
-
<p className={attributeClasses}>
106
-
Casting Time: {String(mins).padStart(2, "0")}:
107
-
{String(secs).padStart(2, "0")}
108
-
</p>,
109
-
);
110
-
}
111
-
112
-
// Cooldown
113
-
// TODO: PvP cooldown seems to be missing from the API, check Holycross for example
114
-
if (skillAttribute.cooldown !== undefined) {
115
-
const secs = Math.ceil(skillAttribute.cooldown) % 60;
116
-
const mins = Math.floor(Math.ceil(skillAttribute.cooldown) / 60);
117
-
out.push(
118
-
<p className={attributeClasses}>
119
-
Cooldown: {String(mins).padStart(2, "0")}:
120
-
{String(secs).padStart(2, "0")}
121
-
</p>,
122
-
);
123
-
}
124
-
125
-
// Range
126
-
if (skillAttribute.spellRange !== undefined) {
127
-
out.push(<p>Spell Range: {skillAttribute.spellRange}</p>);
128
-
129
-
if (skill.target === "party") {
130
-
out.push(<p> (Party)</p>);
131
-
} else if (skill.target === "area") {
132
-
out.push(<p> (Around)</p>);
133
-
}
134
-
}
135
-
136
-
// Probability
137
-
if (skillAttribute.probability !== undefined) {
138
-
out.push(
139
-
<span className={attributeClasses}>
140
-
Probability: {skillAttribute.probability}%
141
-
</span>,
142
-
);
143
-
144
-
if (
145
-
skillAttribute.probabilityPVP !== undefined &&
146
-
skillAttribute.probabilityPVP !== skillAttribute.probability
147
-
) {
148
-
out.push(
149
-
<span className={attributeClasses}>
150
-
{" "}
151
-
/ {skillAttribute.probabilityPVP}% (PVP & Giants)
152
-
</span>,
153
-
);
154
-
}
155
-
}
156
-
157
-
// TODO: wallLives missing from elementor skill
158
-
if (skillAttribute.wallLives !== undefined) {
159
-
out.push(
160
-
<p className={attributeClasses}>
161
-
Number of Lives: {skillAttribute.wallLives}
162
-
</p>,
163
-
);
164
-
}
165
-
166
-
// Reflex hit
167
-
if (
168
-
skillAttribute.reflectedDamagePVE !== undefined &&
169
-
skillAttribute.reflectedDamagePVP !== undefined
170
-
) {
171
-
out.push(
172
-
<p className={attributeClasses}>
173
-
Reflected Damage: {skillAttribute.reflectedDamagePVE}% /{" "}
174
-
{skillAttribute.reflectedDamagePVP}% (PVP)
175
-
</p>,
176
-
);
177
-
}
178
-
179
-
// Damage over time
180
-
if (skillAttribute.dotTick !== undefined) {
181
-
out.push(
182
-
<p className={attributeClasses}>
183
-
DoT Tick: {skillAttribute.dotTick} Seconds
184
-
</p>,
185
-
);
186
-
}
187
-
188
-
// Stats
189
-
// if (skillAttribute.abilities !== undefined) {
190
-
// for (const ability of skillAttribute.abilities) {
191
-
// const abilityStyle = { color: "#6161ff" };
192
-
// const add = ability.add;
193
-
// let extra = 0;
194
-
195
-
// if (skillAttribute.scalingParameters !== undefined) {
196
-
// for (const scale of skillAttribute.scalingParameters) {
197
-
// if (
198
-
// scale.parameter === ability.parameter &&
199
-
// scale.maximum !== undefined
200
-
// ) {
201
-
// let bufferStat = 0;
202
-
// switch (scale.stat) {
203
-
// case "int":
204
-
// bufferStat = Context.player.bufferInt;
205
-
// break;
206
-
// case "str":
207
-
// bufferStat = Context.player.bufferStr;
208
-
// break;
209
-
// case "dex":
210
-
// bufferStat = Context.player.bufferDex;
211
-
// break;
212
-
// default:
213
-
// bufferStat = Context.player.bufferSta;
214
-
// break;
215
-
// }
216
-
217
-
// extra = Math.floor(
218
-
// Math.min(scale.scale * bufferStat, scale.maximum),
219
-
// );
220
-
// }
221
-
// }
222
-
// }
223
-
224
-
// out.push(
225
-
// <span style={abilityStyle}>
226
-
//
227
-
// {Utils.getStatNameByIdOrDefault(ability.parameter, i18n)}
228
-
// {ability.set != undefined ? "=" : "+"}
229
-
// {ability.set != undefined ? ability.set : add + extra}
230
-
// {ability.rate && "%"}
231
-
// </span>,
232
-
// );
233
-
// if (extra > 0) {
234
-
// out.push(
235
-
// <span style={{ color: "#ffaa00" }}>
236
-
// {" "}
237
-
// ({add}+{extra})
238
-
// </span>,
239
-
// );
240
-
// }
241
-
// }
242
-
243
-
// if (skillAttribute.scalingParameters !== undefined) {
244
-
// for (const ability of skillAttribute.abilities) {
245
-
// for (const scale of skillAttribute.scalingParameters) {
246
-
// if (
247
-
// scale.parameter === ability.parameter &&
248
-
// scale.maximum !== undefined
249
-
// ) {
250
-
// out.push(
251
-
// <span style={{ color: "#ffaa00" }}>
252
-
//
253
-
// {scale.parameter} Scaling: +{scale.scale * 25}
254
-
// {ability.rate && "%"} per 25 {scale.stat} (max {scale.maximum}
255
-
// {ability.rate && "%"})
256
-
// </span>,
257
-
// );
258
-
// }
259
-
// }
260
-
// }
261
-
// }
262
-
// }
263
-
264
-
out.push(
265
-
<p className="mt-2">{skill.description[locale] ?? skill.description.en}</p>,
266
-
);
267
-
268
-
return out.map((result) => result);
269
-
}
270
-
271
-
export function Tooltip(props: TooltipProps) {
272
-
return (
273
-
<div
274
-
popover="auto"
275
-
ref={props.popoverRef}
276
-
className="absolute shadow-sm border-2 border-indigo-700 rounded-sm p-4 bg-black/80 text-left attributes-popover w-[350px]"
277
-
>
278
-
<div className="flex gap-1 text-white">
279
-
<h2 className="font-bold text-green-500">
280
-
{props.skill.name[props.locale]}
281
-
</h2>
282
-
·
283
-
<span>Lv. {props.skill.level}</span>
284
-
</div>
285
-
<div className="text-white">
286
-
{renderSkillAttributes(props.skill, props.locale)}
287
-
</div>
288
-
</div>
289
-
);
290
-
}
+80
-80
apps/skillulator/src/routes/c/$class/route.tsx
+80
-80
apps/skillulator/src/routes/c/$class/route.tsx
···
2
2
import clsx from "clsx";
3
3
import { t } from "i18next";
4
4
import { Suspense } from "react";
5
-
import Skill from "./components/Skill";
5
+
import Skill from "../../../components/Skill";
6
6
import { useSkillTree } from "@/hooks/useSkillTree";
7
-
import { CopyButton, ResetButton } from "./components/action-buttons";
7
+
import { CopyButton, ResetButton } from "../../../components/action-buttons";
8
8
9
9
export const Route = createFileRoute("/c/$class")({
10
-
component: SkillTree,
10
+
component: SkillTree,
11
11
});
12
12
13
13
function SkillTree() {
14
-
const {
15
-
job,
16
-
skills,
17
-
level,
18
-
skillPoints,
19
-
copied,
20
-
language,
21
-
handleLevelChange,
22
-
copyToClipboard,
23
-
handleReset,
24
-
} = useSkillTree();
14
+
const {
15
+
job,
16
+
skills,
17
+
level,
18
+
skillPoints,
19
+
copied,
20
+
language,
21
+
handleLevelChange,
22
+
copyToClipboard,
23
+
handleReset,
24
+
} = useSkillTree();
25
25
26
-
const params = useParams({ from: "/c/$class" });
26
+
const params = useParams({ from: "/c/$class" });
27
27
28
-
return (
29
-
<>
30
-
<Suspense>
31
-
<div className="px-5 2xl:px-0 my-10">
32
-
<div className="flex flex-col justify-between mb-10 md:flex-row max-w-[1440px] mx-auto">
33
-
<div className="flex flex-col-reverse">
34
-
<h1 className="text-2xl font-bold capitalize">{params.class}</h1>
35
-
<Link
36
-
to="/"
37
-
className="mb-2 text-indigo-600 hover:underline md:mb-4 a11y-focus"
38
-
>
39
-
{" "}
40
-
← {t("classSelectionLink")}
41
-
</Link>
42
-
</div>
43
-
<div className="flex flex-col gap-2 md:flex-row">
44
-
<div className="flex justify-between gap-2">
45
-
<div className="self-end h-min">
46
-
<p>{t("availSkillPoints")}</p>
47
-
<span className="font-bold">{skillPoints}</span>
48
-
</div>
49
-
<div className="flex flex-col self-end h-min">
50
-
<label htmlFor="characterLevel" className="text-sm">
51
-
{t("charLevel")}
52
-
</label>
53
-
<input
54
-
type="number"
55
-
className="rounded-md border border-gray-300 px-4 py-1.5 a11y-focus"
56
-
inputMode="numeric"
57
-
pattern="[0-9]*"
58
-
value={level}
59
-
onChange={handleLevelChange}
60
-
min={15}
61
-
max={190}
62
-
/>
63
-
</div>
64
-
</div>
65
-
<CopyButton copied={copied} copyToClipboard={copyToClipboard} />
66
-
<ResetButton handleReset={handleReset} />
67
-
</div>
68
-
</div>
69
-
<div
70
-
className={clsx(
71
-
"xl:grid max-w-[1440px] mx-auto gap-0.5",
72
-
params.class,
73
-
)}
74
-
>
75
-
{skills?.map((skill, index: number) => {
76
-
return (
77
-
<Skill
78
-
lang={language}
79
-
key={JSON.stringify({ name: skill.name, index })}
80
-
hasMinLevelRequirements={skill.hasMinLevelRequirements}
81
-
isMaxed={skill.isMaxed}
82
-
skill={skill}
83
-
skillId={skill.id}
84
-
jobId={job?.id}
85
-
/>
86
-
);
87
-
})}
88
-
</div>
89
-
</div>
90
-
</Suspense>
91
-
</>
92
-
);
28
+
return (
29
+
<>
30
+
<Suspense>
31
+
<div className="px-5 2xl:px-0 my-10">
32
+
<div className="flex flex-col justify-between mb-10 md:flex-row max-w-[1440px] mx-auto">
33
+
<div className="flex flex-col-reverse">
34
+
<h1 className="text-2xl font-bold capitalize">{params.class}</h1>
35
+
<Link
36
+
to="/"
37
+
className="mb-2 text-indigo-600 hover:underline md:mb-4 a11y-focus"
38
+
>
39
+
{" "}
40
+
← {t("classSelectionLink")}
41
+
</Link>
42
+
</div>
43
+
<div className="flex flex-col gap-2 md:flex-row">
44
+
<div className="flex justify-between gap-2">
45
+
<div className="self-end h-min">
46
+
<p>{t("availSkillPoints")}</p>
47
+
<span className="font-bold">{skillPoints}</span>
48
+
</div>
49
+
<div className="flex flex-col self-end h-min">
50
+
<label htmlFor="characterLevel" className="text-sm">
51
+
{t("charLevel")}
52
+
</label>
53
+
<input
54
+
type="number"
55
+
className="rounded-md border border-gray-300 px-4 py-1.5 a11y-focus"
56
+
inputMode="numeric"
57
+
pattern="[0-9]*"
58
+
value={level}
59
+
onChange={handleLevelChange}
60
+
min={15}
61
+
max={190}
62
+
/>
63
+
</div>
64
+
</div>
65
+
<CopyButton copied={copied} copyToClipboard={copyToClipboard} />
66
+
<ResetButton handleReset={handleReset} />
67
+
</div>
68
+
</div>
69
+
<div
70
+
className={clsx(
71
+
"xl:grid max-w-[1440px] mx-auto gap-0.5",
72
+
params.class,
73
+
)}
74
+
>
75
+
{skills?.map((skill, index: number) => {
76
+
return (
77
+
<Skill
78
+
lang={language}
79
+
key={JSON.stringify({ name: skill.name, index })}
80
+
hasMinLevelRequirements={skill.hasMinLevelRequirements}
81
+
isMaxed={skill.isMaxed}
82
+
skill={skill}
83
+
skillId={skill.id}
84
+
jobId={job?.id}
85
+
/>
86
+
);
87
+
})}
88
+
</div>
89
+
</div>
90
+
</Suspense>
91
+
</>
92
+
);
93
93
}
-24
apps/skillulator/src/routes/index/components/LinkCard.tsx
-24
apps/skillulator/src/routes/index/components/LinkCard.tsx
···
1
-
import { Link } from "@tanstack/react-router";
2
-
3
-
interface Props {
4
-
name: string;
5
-
image: string;
6
-
}
7
-
8
-
export function LinkCard(props: Props) {
9
-
return (
10
-
<Link
11
-
aria-label={`Go to the ${props.name} skill tree`}
12
-
params={{ class: props.name }}
13
-
to="/c/$class"
14
-
className="flex flex-col items-center justify-center px-1 py-2 duration-150 bg-white border border-gray-300 rounded-md hover:bg-gray-100 lg:px-5 a11y-focus"
15
-
>
16
-
<img
17
-
alt=""
18
-
src={`https://skillulator.lol/icons/classes/${props.image}`}
19
-
className="w-10 h-10 md:h-12 md:w-12"
20
-
/>
21
-
<span className="capitalize">{props.name}</span>
22
-
</Link>
23
-
);
24
-
}
+2
-1
apps/skillulator/src/routes/index/route.lazy.tsx
+2
-1
apps/skillulator/src/routes/index/route.lazy.tsx
···
2
2
import { useTranslation } from "react-i18next";
3
3
import { JOBS } from "@/utils/constants";
4
4
import { createLazyFileRoute } from "@tanstack/react-router";
5
-
import { LinkCard } from "./components/LinkCard";
5
+
import { LinkCard } from "@/components/LinkCard";
6
6
7
7
export const Route = createLazyFileRoute("/")({
8
8
component: Index,
···
27
27
<li>{t("appInstructions.inst2")}</li>
28
28
<li>{t("appInstructions.inst3")}</li>
29
29
<li>{t("appInstructions.inst4")}</li>
30
+
<li>{t("appInstructions.inst5")}</li>
30
31
</ul>
31
32
</main>
32
33
</Suspense>
+15
apps/skillulator/src/utils/constants.ts
+15
apps/skillulator/src/utils/constants.ts
···
1
+
import type { SkillBracket } from "@/types";
2
+
1
3
export const JOB_SKILLPOINTS: Record<number, Record<string, number>> = {
2
4
// elementor
3
5
9150: {
···
153
155
image: "jester.png",
154
156
},
155
157
];
158
+
159
+
export const SKILL_BRACKETS: SkillBracket[] = [
160
+
{ maxLevel: 20, pointsPerLevel: 2 },
161
+
{ maxLevel: 40, pointsPerLevel: 3 },
162
+
{ maxLevel: 60, pointsPerLevel: 4 },
163
+
{ maxLevel: 80, pointsPerLevel: 5 },
164
+
{ maxLevel: 100, pointsPerLevel: 6 },
165
+
{ maxLevel: 120, pointsPerLevel: 7 },
166
+
{ maxLevel: 140, pointsPerLevel: 8 },
167
+
{ maxLevel: 150, pointsPerLevel: 1 },
168
+
{ maxLevel: 166, pointsPerLevel: 2 },
169
+
{ maxLevel: 190, pointsPerLevel: 10 },
170
+
];
+3
-1
apps/skillulator/src/utils/language.ts
+3
-1
apps/skillulator/src/utils/language.ts
···
1
+
import type { Language } from "@/types";
1
2
import type { languages } from "./constants";
2
3
3
4
export function getLanguageForSkill(
4
5
langs: typeof languages,
5
6
appLanguage: string,
6
7
) {
7
-
return langs.find((lang) => lang.label === appLanguage)?.value ?? "en";
8
+
return (langs.find((lang) => lang.label === appLanguage)?.value ??
9
+
"en") as Language;
8
10
}
+6
-17
apps/skillulator/src/utils/skill-tree-helpers.ts
+6
-17
apps/skillulator/src/utils/skill-tree-helpers.ts
···
1
-
import type { Jobs, SkillBracket, Skills } from "../types";
2
-
import type { JOB_SKILLPOINTS } from "./constants";
1
+
import type { Jobs, Skills } from "../types";
2
+
import { SKILL_BRACKETS, type JOB_SKILLPOINTS } from "./constants";
3
3
4
4
export function getJobById(jobId: number, jobs: Jobs) {
5
5
return jobs.filter((job) => job.id === jobId).at(0);
···
104
104
throw new Error("Could not decode skill tree");
105
105
}
106
106
107
-
const SKILL_BRACKETS: SkillBracket[] = [
108
-
{ maxLevel: 20, pointsPerLevel: 2 },
109
-
{ maxLevel: 40, pointsPerLevel: 3 },
110
-
{ maxLevel: 60, pointsPerLevel: 4 },
111
-
{ maxLevel: 80, pointsPerLevel: 5 },
112
-
{ maxLevel: 100, pointsPerLevel: 6 },
113
-
{ maxLevel: 120, pointsPerLevel: 7 },
114
-
{ maxLevel: 140, pointsPerLevel: 8 },
115
-
{ maxLevel: 150, pointsPerLevel: 1 },
116
-
{ maxLevel: 160, pointsPerLevel: 2 },
117
-
{ maxLevel: 165, pointsPerLevel: 2 },
118
-
{ maxLevel: 190, pointsPerLevel: 10 },
119
-
];
120
-
121
107
export function getSkillPointsForLevel(characterLevel: number): number {
122
108
if (characterLevel < 15) {
123
109
return 0;
124
110
}
125
111
126
112
let totalPoints = 0;
127
-
let previousMaxLevel = 0;
113
+
// this is because characters start at level one, not zero
114
+
// needed to calculate the correct amount of skillpoints for the first bracket
115
+
let previousMaxLevel = 1;
128
116
129
117
for (const bracket of SKILL_BRACKETS) {
130
118
const effectiveLevel = Math.min(characterLevel, bracket.maxLevel);
131
119
const levelsInBracket = Math.max(0, effectiveLevel - previousMaxLevel);
120
+
console.log(bracket, levelsInBracket, levelsInBracket * bracket.pointsPerLevel)
132
121
133
122
totalPoints += levelsInBracket * bracket.pointsPerLevel;
134
123
previousMaxLevel = bracket.maxLevel;