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

Configure Feed

Select the types of activity you want to include in your feed.

more tooltip work

besaid.zone 3a59f88f e6cf6420

verified
+402 -82
+28 -22
apps/skillulator/src/index.css
··· 9 9 @import "tailwindcss"; 10 10 11 11 @layer base { 12 - html, 13 - body { 14 - @apply bg-gray-50; 15 - height: 100%; 16 - } 17 12 18 - .a11y-focus { 19 - @apply focus:border-transparent focus:outline-transparent focus:ring-4 focus:ring-offset-2 focus:ring-indigo-500; 20 - } 13 + html, 14 + body { 15 + @apply bg-gray-50; 16 + height: 100%; 17 + } 21 18 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 - } 19 + .a11y-focus { 20 + @apply focus:border-transparent focus:outline-transparent focus:ring-4 focus:ring-offset-2 focus:ring-indigo-500; 21 + } 32 22 33 - #root { 34 - height: 100%; 35 - @apply flex flex-col; 36 - } 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 + } 37 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 1 import clsx from "clsx"; 2 2 import { useTreeStore } from "@/zustand/treeStore"; 3 - import type { Skill } from "@/types"; 3 + import type { Language, Skill } from "@/types"; 4 4 import { t } from "i18next"; 5 + import { useRef, type ComponentRef } from "react"; 6 + import { Tooltip } from "./Tooltip"; 5 7 6 8 export function DecreaseSkillPointButton(props: { 7 9 skill: Skill; ··· 78 80 skill: Skill; 79 81 hasMinLevelRequirements: boolean; 80 82 isMaxed: boolean; 81 - locale: string; 83 + locale: Language; 82 84 masterVariationSkillId?: number; 83 85 isSelectedMasterVariation?: boolean; 84 86 }) { 85 87 const { decreaseSkillPoint, increaseSkillPoint } = useTreeStore(); 88 + 89 + const popoverRef = useRef<ComponentRef<"div">>(null); 86 90 87 91 return ( 88 - <button 89 - type="button" 90 - onKeyDown={(event) => { 91 - if (["ArrowDown", "ArrowUp"].includes(event.key)) { 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) => { 92 118 if (!props.jobId) return; 93 119 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") { 120 + if (event.type === "click") { 101 121 increaseSkillPoint({ 102 122 jobId: props.jobId, 103 123 skillId: props.skill.id, 104 124 masterVariationSkillId: props.masterVariationSkillId, 105 125 }); 106 126 } 107 - } 108 - }} 109 - onClick={(event) => { 110 - if (!props.jobId) return; 111 - event.preventDefault(); 112 - if (event.type === "click") { 113 - increaseSkillPoint({ 127 + }} 128 + onContextMenu={(event) => { 129 + if (!props.jobId) return; 130 + event.preventDefault(); 131 + decreaseSkillPoint({ 114 132 jobId: props.jobId, 115 133 skillId: props.skill.id, 116 134 masterVariationSkillId: props.masterVariationSkillId, 117 135 }); 136 + }} 137 + // disable also if any other master variation skill has been picked (isSelected === true) 138 + disabled={ 139 + !props.hasMinLevelRequirements || props.isSelectedMasterVariation 118 140 } 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 - /> 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> 146 163 <span 147 164 className={clsx( 148 - "skill-level", 149 - props.isMaxed ? "font-bold uppercase" : undefined, 165 + "inline-block font-bold", 166 + props.hasMinLevelRequirements ? "text-blue-500" : "text-gray-300", 150 167 )} 151 168 > 152 - {props.isMaxed ? "max" : `${props.skill.skillLevel}`} 169 + {props.skill.name[props.locale]} 153 170 </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> 171 + </button> 172 + <Tooltip 173 + skill={props.skill} 174 + popoverRef={popoverRef} 175 + locale={props.locale} 176 + /> 177 + </div> 164 178 ); 165 179 } 166 180
+2
apps/skillulator/src/types.ts
··· 7 7 maxLevel: number; 8 8 pointsPerLevel: number; 9 9 }; 10 + 11 + export type Language = keyof State["jobTree"][number]["skills"][number]["name"];