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

more tooltip work

besaid.zone 3a59f88f e6cf6420

verified
Changed files
+402 -82
apps
skillulator
src
routes
c
$class
+28 -22
apps/skillulator/src/index.css
··· 9 @import "tailwindcss"; 10 11 @layer base { 12 - html, 13 - body { 14 - @apply bg-gray-50; 15 - height: 100%; 16 - } 17 18 - .a11y-focus { 19 - @apply focus:border-transparent focus:outline-transparent focus:ring-4 focus:ring-offset-2 focus:ring-indigo-500; 20 - } 21 22 - .skill-level { 23 - position: absolute; 24 - bottom: 1px; 25 - right: 5px; 26 - font-size: 16px; 27 - font-weight: bold; 28 - color: white; 29 - text-shadow: -1px -1px 0 #f00, 1px -1px 0 #f00, -1px 1px 0 #f00, 1px 1px 0 30 - #f00; 31 - } 32 33 - #root { 34 - height: 100%; 35 - @apply flex flex-col; 36 - } 37 }
··· 9 @import "tailwindcss"; 10 11 @layer base { 12 13 + html, 14 + body { 15 + @apply bg-gray-50; 16 + height: 100%; 17 + } 18 19 + .a11y-focus { 20 + @apply focus:border-transparent focus:outline-transparent focus:ring-4 focus:ring-offset-2 focus:ring-indigo-500; 21 + } 22 23 + .skill-level { 24 + position: absolute; 25 + bottom: 1px; 26 + right: 5px; 27 + font-size: 16px; 28 + font-weight: bold; 29 + color: white; 30 + text-shadow: -1px -1px 0 #f00, 1px -1px 0 #f00, -1px 1px 0 #f00, 1px 1px 0 #f00; 31 + } 32 + 33 + #attributesPopover { 34 + position-anchor: --attributes; 35 + top: anchor(bottom); 36 + right: anchor(right); 37 + } 38 + 39 + #root { 40 + height: 100%; 41 + @apply flex flex-col; 42 + } 43 }
+298
apps/skillulator/src/routes/c/$class/components/Tooltip.tsx
···
··· 1 + import type { Language, Skill } from "@/types"; 2 + import type { RefObject } from "react"; 3 + 4 + type TooltipProps = { 5 + skill: Skill; 6 + popoverRef: RefObject<HTMLDivElement | null>; 7 + locale: Language; 8 + }; 9 + 10 + // https://github.com/Frostiae/Flyffulator/blob/main/src/flyff/flyfftooltip.jsx#L535 11 + function renderSkillAttributes(skill: Skill, locale: Language) { 12 + const out = []; 13 + const skillLevel = skill.skillLevel ?? skill.levels; 14 + const skillAttribute = 15 + skill.skillLevel !== undefined 16 + ? skill.skillStats[skillLevel] 17 + : skill.skillStats[0]; 18 + 19 + if (skill.element !== "none") { 20 + out.push(`\nElement: ${skill.element}`); 21 + } 22 + 23 + if (skillAttribute.consumedMP !== undefined) { 24 + out.push(`\nMP: ${skillAttribute.consumedMP}`); 25 + } 26 + 27 + if (skillAttribute.consumedFP !== undefined) { 28 + out.push(`\nFP: ${skillAttribute.consumedFP}`); 29 + } 30 + 31 + // Attack 32 + if (skillAttribute.maxAttack !== undefined && skillAttribute.maxAttack > 0) { 33 + out.push( 34 + <span> 35 + Base Damage: {skillAttribute.minAttack} ~ {skillAttribute.maxAttack} 36 + </span>, 37 + ); 38 + } 39 + 40 + if (skillAttribute.scalingParameters !== undefined) { 41 + for (const scale of skillAttribute.scalingParameters) { 42 + if (scale.parameter === "attack" && scale.maximum === undefined) { 43 + out.push( 44 + <span> 45 + <br /> 46 + Attack Scaling: {scale.stat} x {scale.scale} 47 + </span>, 48 + ); 49 + } 50 + } 51 + } 52 + 53 + // Heal 54 + if (skillAttribute.abilities !== undefined) { 55 + for (const ability of skillAttribute.abilities) { 56 + if (ability.parameter === "hp") { 57 + out.push( 58 + <span> 59 + <br /> 60 + Base Heal: {ability.add} 61 + </span>, 62 + ); 63 + break; 64 + } 65 + } 66 + } 67 + 68 + if (skillAttribute.scalingParameters !== undefined) { 69 + for (const scale of skillAttribute.scalingParameters) { 70 + if (scale.parameter === "hp") { 71 + out.push( 72 + <span> 73 + <br /> 74 + Heal Scaling: {scale.stat} x {scale.scale} 75 + </span>, 76 + ); 77 + } 78 + } 79 + } 80 + 81 + // Time 82 + if (skillAttribute.duration !== undefined) { 83 + const secs = skillAttribute.duration % 60; 84 + const mins = Math.floor(skillAttribute.duration / 60); 85 + out.push( 86 + <span> 87 + <br /> 88 + Base Time: {String(mins).padStart(2, "0")}: 89 + {String(secs).padStart(2, "0")} 90 + </span>, 91 + ); 92 + } 93 + 94 + if (skillAttribute.scalingParameters !== undefined) { 95 + for (const scale of skillAttribute.scalingParameters) { 96 + if (scale.parameter === "duration") { 97 + out.push( 98 + <span> 99 + <br /> 100 + Time Scaling: {scale.stat} x {scale.scale} 101 + </span>, 102 + ); 103 + } 104 + } 105 + } 106 + 107 + // Casting time 108 + if (skillAttribute.casting !== undefined && skillAttribute.casting >= 1) { 109 + const secs = skillAttribute.casting % 60; 110 + const mins = Math.floor(skillAttribute.casting / 60); 111 + out.push( 112 + <span> 113 + <br /> 114 + Casting Time: {String(mins).padStart(2, "0")}: 115 + {String(secs).padStart(2, "0")} 116 + </span>, 117 + ); 118 + } 119 + 120 + // Cooldown 121 + // TODO: PvP cooldown seems to be missing from the API, check Holycross for example 122 + if (skillAttribute.cooldown !== undefined) { 123 + const secs = Math.ceil(skillAttribute.cooldown) % 60; 124 + const mins = Math.floor(Math.ceil(skillAttribute.cooldown) / 60); 125 + out.push( 126 + <span> 127 + <br /> 128 + Cooldown: {String(mins).padStart(2, "0")}: 129 + {String(secs).padStart(2, "0")} 130 + </span>, 131 + ); 132 + } 133 + 134 + // Range 135 + if (skillAttribute.spellRange !== undefined) { 136 + out.push( 137 + <span> 138 + <br /> 139 + Spell Range: {skillAttribute.spellRange} 140 + </span>, 141 + ); 142 + 143 + if (skill.target === "party") { 144 + out.push(<span> (Party)</span>); 145 + } else if (skill.target === "area") { 146 + out.push(<span> (Around)</span>); 147 + } 148 + } 149 + 150 + // Probability 151 + if (skillAttribute.probability !== undefined) { 152 + out.push( 153 + <span> 154 + <br /> 155 + Probability: {skillAttribute.probability}% 156 + </span>, 157 + ); 158 + 159 + if ( 160 + skillAttribute.probabilityPVP !== undefined && 161 + skillAttribute.probabilityPVP !== skillAttribute.probability 162 + ) { 163 + out.push(<span> / {skillAttribute.probabilityPVP}% (PVP & Giants)</span>); 164 + } 165 + } 166 + 167 + // TODO: wallLives missing from elementor skill 168 + if (skillAttribute.wallLives !== undefined) { 169 + out.push( 170 + <span> 171 + <br /> 172 + Number of Lives: {skillAttribute.wallLives} 173 + </span>, 174 + ); 175 + } 176 + 177 + // Reflex hit 178 + if ( 179 + skillAttribute.reflectedDamagePVE !== undefined && 180 + skillAttribute.reflectedDamagePVP !== undefined 181 + ) { 182 + out.push( 183 + <span> 184 + <br /> 185 + Reflected Damage: {skillAttribute.reflectedDamagePVE}% /{" "} 186 + {skillAttribute.reflectedDamagePVP}% (PVP) 187 + </span>, 188 + ); 189 + } 190 + 191 + // Damage over time 192 + if (skillAttribute.dotTick !== undefined) { 193 + out.push( 194 + <span> 195 + <br /> 196 + DoT Tick: {skillAttribute.dotTick} Seconds 197 + </span>, 198 + ); 199 + } 200 + 201 + // Stats 202 + // if (skillAttribute.abilities !== undefined) { 203 + // for (const ability of skillAttribute.abilities) { 204 + // const abilityStyle = { color: "#6161ff" }; 205 + // const add = ability.add; 206 + // let extra = 0; 207 + 208 + // if (skillAttribute.scalingParameters !== undefined) { 209 + // for (const scale of skillAttribute.scalingParameters) { 210 + // if ( 211 + // scale.parameter === ability.parameter && 212 + // scale.maximum !== undefined 213 + // ) { 214 + // let bufferStat = 0; 215 + // switch (scale.stat) { 216 + // case "int": 217 + // bufferStat = Context.player.bufferInt; 218 + // break; 219 + // case "str": 220 + // bufferStat = Context.player.bufferStr; 221 + // break; 222 + // case "dex": 223 + // bufferStat = Context.player.bufferDex; 224 + // break; 225 + // default: 226 + // bufferStat = Context.player.bufferSta; 227 + // break; 228 + // } 229 + 230 + // extra = Math.floor( 231 + // Math.min(scale.scale * bufferStat, scale.maximum), 232 + // ); 233 + // } 234 + // } 235 + // } 236 + 237 + // out.push( 238 + // <span style={abilityStyle}> 239 + // <br /> 240 + // {Utils.getStatNameByIdOrDefault(ability.parameter, i18n)} 241 + // {ability.set != undefined ? "=" : "+"} 242 + // {ability.set != undefined ? ability.set : add + extra} 243 + // {ability.rate && "%"} 244 + // </span>, 245 + // ); 246 + // if (extra > 0) { 247 + // out.push( 248 + // <span style={{ color: "#ffaa00" }}> 249 + // {" "} 250 + // ({add}+{extra}) 251 + // </span>, 252 + // ); 253 + // } 254 + // } 255 + 256 + // if (skillAttribute.scalingParameters !== undefined) { 257 + // for (const ability of skillAttribute.abilities) { 258 + // for (const scale of skillAttribute.scalingParameters) { 259 + // if ( 260 + // scale.parameter === ability.parameter && 261 + // scale.maximum !== undefined 262 + // ) { 263 + // out.push( 264 + // <span style={{ color: "#ffaa00" }}> 265 + // <br /> 266 + // {scale.parameter} Scaling: +{scale.scale * 25} 267 + // {ability.rate && "%"} per 25 {scale.stat} (max {scale.maximum} 268 + // {ability.rate && "%"}) 269 + // </span>, 270 + // ); 271 + // } 272 + // } 273 + // } 274 + // } 275 + // } 276 + 277 + // out.push(`\n${skill.description[locale] ?? skill.description.en}`); 278 + 279 + return out.map((result) => result); 280 + } 281 + 282 + export function Tooltip(props: TooltipProps) { 283 + return ( 284 + <div 285 + id="attributesPopover" 286 + popover="auto" 287 + ref={props.popoverRef} 288 + className="absolute m-0 inset-auto h-[300px] w-[300px] shadow-sm border border-gray-100 rounded-sm p-4" 289 + > 290 + <div className="flex gap-1"> 291 + <h2 className="font-bold">{props.skill.name[props.locale]}</h2> 292 + &middot; 293 + <span>Lv. {props.skill.level}</span> 294 + </div> 295 + <div>{renderSkillAttributes(props.skill, props.locale)}</div> 296 + </div> 297 + ); 298 + }
+74 -60
apps/skillulator/src/routes/c/$class/components/action-buttons.tsx
··· 1 import clsx from "clsx"; 2 import { useTreeStore } from "@/zustand/treeStore"; 3 - import type { Skill } from "@/types"; 4 import { t } from "i18next"; 5 6 export function DecreaseSkillPointButton(props: { 7 skill: Skill; ··· 78 skill: Skill; 79 hasMinLevelRequirements: boolean; 80 isMaxed: boolean; 81 - locale: string; 82 masterVariationSkillId?: number; 83 isSelectedMasterVariation?: boolean; 84 }) { 85 const { decreaseSkillPoint, increaseSkillPoint } = useTreeStore(); 86 87 return ( 88 - <button 89 - type="button" 90 - onKeyDown={(event) => { 91 - if (["ArrowDown", "ArrowUp"].includes(event.key)) { 92 if (!props.jobId) return; 93 event.preventDefault(); 94 - if (event.key === "ArrowDown") { 95 - decreaseSkillPoint({ 96 - jobId: props.jobId, 97 - skillId: props.skill.id, 98 - masterVariationSkillId: props.masterVariationSkillId, 99 - }); 100 - } else if (event.key === "ArrowUp") { 101 increaseSkillPoint({ 102 jobId: props.jobId, 103 skillId: props.skill.id, 104 masterVariationSkillId: props.masterVariationSkillId, 105 }); 106 } 107 - } 108 - }} 109 - onClick={(event) => { 110 - if (!props.jobId) return; 111 - event.preventDefault(); 112 - if (event.type === "click") { 113 - increaseSkillPoint({ 114 jobId: props.jobId, 115 skillId: props.skill.id, 116 masterVariationSkillId: props.masterVariationSkillId, 117 }); 118 } 119 - }} 120 - onContextMenu={(event) => { 121 - if (!props.jobId) return; 122 - event.preventDefault(); 123 - decreaseSkillPoint({ 124 - jobId: props.jobId, 125 - skillId: props.skill.id, 126 - masterVariationSkillId: props.masterVariationSkillId, 127 - }); 128 - }} 129 - // disable also if any other master variation skill has been picked (isSelected === true) 130 - disabled={ 131 - !props.hasMinLevelRequirements || props.isSelectedMasterVariation 132 - } 133 - className="flex flex-col items-center disabled:cursor-not-allowed" 134 - > 135 - <div className="relative"> 136 - <img 137 - alt="" 138 - className={clsx( 139 - "h-12 w-12", 140 - props.hasMinLevelRequirements && !props.isSelectedMasterVariation 141 - ? "grayscale-0" 142 - : "grayscale", 143 - )} 144 - src={`https://api.flyff.com/image/skill/colored/${props.skill.icon}`} 145 - /> 146 <span 147 className={clsx( 148 - "skill-level", 149 - props.isMaxed ? "font-bold uppercase" : undefined, 150 )} 151 > 152 - {props.isMaxed ? "max" : `${props.skill.skillLevel}`} 153 </span> 154 - </div> 155 - <span 156 - className={clsx( 157 - "inline-block font-bold", 158 - props.hasMinLevelRequirements ? "text-blue-500" : "text-gray-300", 159 - )} 160 - > 161 - {props.skill.name[props.locale]} 162 - </span> 163 - </button> 164 ); 165 } 166
··· 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; ··· 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" }}> 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
+2
apps/skillulator/src/types.ts
··· 7 maxLevel: number; 8 pointsPerLevel: number; 9 };
··· 7 maxLevel: number; 8 pointsPerLevel: number; 9 }; 10 + 11 + export type Language = keyof State["jobTree"][number]["skills"][number]["name"];