a collection of tools for fly for fun universe skillulator.lol

Compare changes

Choose any two refs to compare.

+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
··· 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
··· 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
··· 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
··· 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
··· 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 + &middot; 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 + }
+210
apps/skillulator/src/components/action-buttons.tsx
··· 1 + import clsx from "clsx"; 2 + import { useTreeStore } from "@/zustand/treeStore"; 3 + import type { Language, Skill } from "@/types"; 4 + import { t } from "i18next"; 5 + import { useRef, type ComponentRef } from "react"; 6 + import { Tooltip } from "./Tooltip"; 7 + 8 + export function DecreaseSkillPointButton(props: { 9 + skill: Skill; 10 + jobId: number | undefined; 11 + masterVariationSkillId?: number; 12 + }) { 13 + const { decreaseSkillPoint } = useTreeStore(); 14 + return ( 15 + <button 16 + type="button" 17 + className={clsx( 18 + "absolute px-4 py-1 text-xs font-bold text-indigo-900 uppercase bg-indigo-100 border border-indigo-200 rounded-sm top-2 left-2 disabled:cursor-not-allowed md:hidden a11y-focus", 19 + props.skill.skillLevel === 0 ? "grayscale" : "grayscale-0", 20 + )} 21 + disabled={props.skill.skillLevel === 0} 22 + onClick={(event) => { 23 + if (!props.jobId) return; 24 + event.preventDefault(); 25 + if (event.type === "click") { 26 + decreaseSkillPoint({ 27 + jobId: props.jobId, 28 + skillId: props.skill.id, 29 + masterVariationSkillId: props.masterVariationSkillId, 30 + }); 31 + } 32 + }} 33 + > 34 + lvl down 35 + </button> 36 + ); 37 + } 38 + 39 + export function IncreaseSkillToMaxButton(props: { 40 + hasMinLevelRequirements: boolean; 41 + isMaxed: boolean; 42 + jobId: number | undefined; 43 + skillId: number; 44 + isSelectedMasterVariation?: boolean; 45 + masterVariationSkillId?: number; 46 + }) { 47 + const { increaseSkillToMax } = useTreeStore(); 48 + return ( 49 + <button 50 + type="button" 51 + disabled={ 52 + !props.hasMinLevelRequirements || 53 + props.isMaxed || 54 + props.isSelectedMasterVariation 55 + } 56 + className={clsx( 57 + "absolute px-4 py-1 text-xs font-bold text-indigo-900 uppercase bg-indigo-100 border border-indigo-200 rounded-sm right-2 top-2 disabled:cursor-not-allowed a11y-focus", 58 + props.hasMinLevelRequirements && 59 + !props.isMaxed && 60 + !props.isSelectedMasterVariation 61 + ? "grayscale-0" 62 + : "grayscale", 63 + )} 64 + onClick={() => { 65 + if (!props.jobId) return; 66 + increaseSkillToMax({ 67 + skillId: props.skillId, 68 + jobId: props.jobId, 69 + masterVariationSkillId: props.masterVariationSkillId, 70 + }); 71 + }} 72 + > 73 + max 74 + </button> 75 + ); 76 + } 77 + 78 + export function SkillIconButton(props: { 79 + jobId: number | undefined; 80 + skill: Skill; 81 + hasMinLevelRequirements: boolean; 82 + isMaxed: boolean; 83 + locale: Language; 84 + masterVariationSkillId?: number; 85 + isSelectedMasterVariation?: boolean; 86 + }) { 87 + const { decreaseSkillPoint, increaseSkillPoint } = useTreeStore(); 88 + 89 + const popoverRef = useRef<ComponentRef<"div">>(null); 90 + 91 + return ( 92 + <div className="skill-icon-anchor"> 93 + {/* biome-ignore lint/a11y/useKeyWithMouseEvents: <explanation> */} 94 + <button 95 + type="button" 96 + onMouseOver={() => popoverRef?.current?.showPopover()} 97 + onMouseOut={() => popoverRef?.current?.hidePopover()} 98 + onKeyDown={(event) => { 99 + if (["ArrowDown", "ArrowUp"].includes(event.key)) { 100 + if (!props.jobId) return; 101 + event.preventDefault(); 102 + if (event.key === "ArrowDown") { 103 + decreaseSkillPoint({ 104 + jobId: props.jobId, 105 + skillId: props.skill.id, 106 + masterVariationSkillId: props.masterVariationSkillId, 107 + }); 108 + } else if (event.key === "ArrowUp") { 109 + increaseSkillPoint({ 110 + jobId: props.jobId, 111 + skillId: props.skill.id, 112 + masterVariationSkillId: props.masterVariationSkillId, 113 + }); 114 + } 115 + } 116 + }} 117 + onClick={(event) => { 118 + if (!props.jobId) return; 119 + event.preventDefault(); 120 + if (event.type === "click") { 121 + increaseSkillPoint({ 122 + jobId: props.jobId, 123 + skillId: props.skill.id, 124 + masterVariationSkillId: props.masterVariationSkillId, 125 + }); 126 + } 127 + }} 128 + onContextMenu={(event) => { 129 + if (!props.jobId) return; 130 + event.preventDefault(); 131 + decreaseSkillPoint({ 132 + jobId: props.jobId, 133 + skillId: props.skill.id, 134 + masterVariationSkillId: props.masterVariationSkillId, 135 + }); 136 + }} 137 + // disable also if any other master variation skill has been picked (isSelected === true) 138 + disabled={ 139 + !props.hasMinLevelRequirements || props.isSelectedMasterVariation 140 + } 141 + className="flex flex-col items-center disabled:cursor-not-allowed" 142 + > 143 + <div className="relative"> 144 + <img 145 + alt="" 146 + className={clsx( 147 + "h-12 w-12", 148 + props.hasMinLevelRequirements && !props.isSelectedMasterVariation 149 + ? "grayscale-0" 150 + : "grayscale", 151 + )} 152 + src={`https://api.flyff.com/image/skill/colored/${props.skill.icon}`} 153 + /> 154 + <span 155 + className={clsx( 156 + "skill-level", 157 + props.isMaxed ? "font-bold uppercase" : undefined, 158 + )} 159 + > 160 + {props.isMaxed ? "max" : `${props.skill.skillLevel}`} 161 + </span> 162 + </div> 163 + <span 164 + className={clsx( 165 + "inline-block font-bold", 166 + props.hasMinLevelRequirements ? "text-blue-500" : "text-gray-300", 167 + )} 168 + > 169 + {props.skill.name[props.locale]} 170 + </span> 171 + </button> 172 + <Tooltip 173 + skill={props.skill} 174 + popoverRef={popoverRef} 175 + locale={props.locale} 176 + /> 177 + </div> 178 + ); 179 + } 180 + 181 + export function CopyButton(props: { 182 + copied: boolean; 183 + copyToClipboard: () => void; 184 + }) { 185 + return ( 186 + <button 187 + type="button" 188 + disabled={props.copied} 189 + onClick={props.copyToClipboard} 190 + className={clsx( 191 + "h-min w-full self-end rounded-md bg-indigo-500 px-4 py-1.5 font-semibold text-white duration-150 hover:bg-indigo-600 md:w-max a11y-focus", 192 + props.copied ? "disabled:bg-green-500" : undefined, 193 + )} 194 + > 195 + {props.copied ? t("copiedText") : t("copyText")} 196 + </button> 197 + ); 198 + } 199 + 200 + export function ResetButton(props: { handleReset: () => void }) { 201 + return ( 202 + <button 203 + type="button" 204 + className="h-min w-full self-end rounded-md border border-red-300 bg-red-100 px-4 py-1.5 text-red-900 duration-150 hover:bg-red-200 md:w-max a11y-focus" 205 + onClick={props.handleReset} 206 + > 207 + {t("resetText")} 208 + </button> 209 + ); 210 + }
+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
··· 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
··· 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
··· 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
··· 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
··· 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 - &middot; 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 - }
-210
apps/skillulator/src/routes/c/$class/components/action-buttons.tsx
··· 1 - import clsx from "clsx"; 2 - import { useTreeStore } from "@/zustand/treeStore"; 3 - import type { Language, Skill } from "@/types"; 4 - import { t } from "i18next"; 5 - import { useRef, type ComponentRef } from "react"; 6 - import { Tooltip } from "./Tooltip"; 7 - 8 - export function DecreaseSkillPointButton(props: { 9 - skill: Skill; 10 - jobId: number | undefined; 11 - masterVariationSkillId?: number; 12 - }) { 13 - const { decreaseSkillPoint } = useTreeStore(); 14 - return ( 15 - <button 16 - type="button" 17 - className={clsx( 18 - "absolute px-4 py-1 text-xs font-bold text-indigo-900 uppercase bg-indigo-100 border border-indigo-200 rounded-sm top-2 left-2 disabled:cursor-not-allowed md:hidden a11y-focus", 19 - props.skill.skillLevel === 0 ? "grayscale" : "grayscale-0", 20 - )} 21 - disabled={props.skill.skillLevel === 0} 22 - onClick={(event) => { 23 - if (!props.jobId) return; 24 - event.preventDefault(); 25 - if (event.type === "click") { 26 - decreaseSkillPoint({ 27 - jobId: props.jobId, 28 - skillId: props.skill.id, 29 - masterVariationSkillId: props.masterVariationSkillId, 30 - }); 31 - } 32 - }} 33 - > 34 - lvl down 35 - </button> 36 - ); 37 - } 38 - 39 - export function IncreaseSkillToMaxButton(props: { 40 - hasMinLevelRequirements: boolean; 41 - isMaxed: boolean; 42 - jobId: number | undefined; 43 - skillId: number; 44 - isSelectedMasterVariation?: boolean; 45 - masterVariationSkillId?: number; 46 - }) { 47 - const { increaseSkillToMax } = useTreeStore(); 48 - return ( 49 - <button 50 - type="button" 51 - disabled={ 52 - !props.hasMinLevelRequirements || 53 - props.isMaxed || 54 - props.isSelectedMasterVariation 55 - } 56 - className={clsx( 57 - "absolute px-4 py-1 text-xs font-bold text-indigo-900 uppercase bg-indigo-100 border border-indigo-200 rounded-sm right-2 top-2 disabled:cursor-not-allowed a11y-focus", 58 - props.hasMinLevelRequirements && 59 - !props.isMaxed && 60 - !props.isSelectedMasterVariation 61 - ? "grayscale-0" 62 - : "grayscale", 63 - )} 64 - onClick={() => { 65 - if (!props.jobId) return; 66 - increaseSkillToMax({ 67 - skillId: props.skillId, 68 - jobId: props.jobId, 69 - masterVariationSkillId: props.masterVariationSkillId, 70 - }); 71 - }} 72 - > 73 - max 74 - </button> 75 - ); 76 - } 77 - 78 - export function SkillIconButton(props: { 79 - jobId: number | undefined; 80 - skill: Skill; 81 - hasMinLevelRequirements: boolean; 82 - isMaxed: boolean; 83 - locale: Language; 84 - masterVariationSkillId?: number; 85 - isSelectedMasterVariation?: boolean; 86 - }) { 87 - const { decreaseSkillPoint, increaseSkillPoint } = useTreeStore(); 88 - 89 - const popoverRef = useRef<ComponentRef<"div">>(null); 90 - 91 - return ( 92 - <div style={{ anchorName: "--attributes-popover" }}> 93 - {/* biome-ignore lint/a11y/useKeyWithMouseEvents: <explanation> */} 94 - <button 95 - type="button" 96 - onMouseOver={() => popoverRef?.current?.showPopover()} 97 - onMouseOut={() => popoverRef?.current?.hidePopover()} 98 - onKeyDown={(event) => { 99 - if (["ArrowDown", "ArrowUp"].includes(event.key)) { 100 - if (!props.jobId) return; 101 - event.preventDefault(); 102 - if (event.key === "ArrowDown") { 103 - decreaseSkillPoint({ 104 - jobId: props.jobId, 105 - skillId: props.skill.id, 106 - masterVariationSkillId: props.masterVariationSkillId, 107 - }); 108 - } else if (event.key === "ArrowUp") { 109 - increaseSkillPoint({ 110 - jobId: props.jobId, 111 - skillId: props.skill.id, 112 - masterVariationSkillId: props.masterVariationSkillId, 113 - }); 114 - } 115 - } 116 - }} 117 - onClick={(event) => { 118 - if (!props.jobId) return; 119 - event.preventDefault(); 120 - if (event.type === "click") { 121 - increaseSkillPoint({ 122 - jobId: props.jobId, 123 - skillId: props.skill.id, 124 - masterVariationSkillId: props.masterVariationSkillId, 125 - }); 126 - } 127 - }} 128 - onContextMenu={(event) => { 129 - if (!props.jobId) return; 130 - event.preventDefault(); 131 - decreaseSkillPoint({ 132 - jobId: props.jobId, 133 - skillId: props.skill.id, 134 - masterVariationSkillId: props.masterVariationSkillId, 135 - }); 136 - }} 137 - // disable also if any other master variation skill has been picked (isSelected === true) 138 - disabled={ 139 - !props.hasMinLevelRequirements || props.isSelectedMasterVariation 140 - } 141 - className="flex flex-col items-center disabled:cursor-not-allowed" 142 - > 143 - <div className="relative"> 144 - <img 145 - alt="" 146 - className={clsx( 147 - "h-12 w-12", 148 - props.hasMinLevelRequirements && !props.isSelectedMasterVariation 149 - ? "grayscale-0" 150 - : "grayscale", 151 - )} 152 - src={`https://api.flyff.com/image/skill/colored/${props.skill.icon}`} 153 - /> 154 - <span 155 - className={clsx( 156 - "skill-level", 157 - props.isMaxed ? "font-bold uppercase" : undefined, 158 - )} 159 - > 160 - {props.isMaxed ? "max" : `${props.skill.skillLevel}`} 161 - </span> 162 - </div> 163 - <span 164 - className={clsx( 165 - "inline-block font-bold", 166 - props.hasMinLevelRequirements ? "text-blue-500" : "text-gray-300", 167 - )} 168 - > 169 - {props.skill.name[props.locale]} 170 - </span> 171 - </button> 172 - <Tooltip 173 - skill={props.skill} 174 - popoverRef={popoverRef} 175 - locale={props.locale} 176 - /> 177 - </div> 178 - ); 179 - } 180 - 181 - export function CopyButton(props: { 182 - copied: boolean; 183 - copyToClipboard: () => void; 184 - }) { 185 - return ( 186 - <button 187 - type="button" 188 - disabled={props.copied} 189 - onClick={props.copyToClipboard} 190 - className={clsx( 191 - "h-min w-full self-end rounded-md bg-indigo-500 px-4 py-1.5 font-semibold text-white duration-150 hover:bg-indigo-600 md:w-max a11y-focus", 192 - props.copied ? "disabled:bg-green-500" : undefined, 193 - )} 194 - > 195 - {props.copied ? t("copiedText") : t("copyText")} 196 - </button> 197 - ); 198 - } 199 - 200 - export function ResetButton(props: { handleReset: () => void }) { 201 - return ( 202 - <button 203 - type="button" 204 - className="h-min w-full self-end rounded-md border border-red-300 bg-red-100 px-4 py-1.5 text-red-900 duration-150 hover:bg-red-200 md:w-max a11y-focus" 205 - onClick={props.handleReset} 206 - > 207 - {t("resetText")} 208 - </button> 209 - ); 210 - }
+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 - &larr; {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 + &larr; {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
··· 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 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
··· 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
··· 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
··· 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;