diff --git a/src/ui/React/CharacterOverview.tsx b/src/ui/React/CharacterOverview.tsx index 3bf700569..9018f57d8 100644 --- a/src/ui/React/CharacterOverview.tsx +++ b/src/ui/React/CharacterOverview.tsx @@ -17,8 +17,7 @@ import { convertTimeMsToTimeElapsedString } from "../../utils/StringHelperFuncti import { Settings } from "../../Settings/Settings"; import { Router } from "../GameRoot"; import { Page } from "../Router"; -import { StatsProgressOverviewCell } from "./StatsProgressBar"; -import { currentNodeMults } from "../../BitNode/BitNodeMultipliers"; +import { StatsProgressBar } from "./StatsProgressBar"; import { isClassWork } from "../../Work/ClassWork"; import { CONSTANTS } from "../../Constants"; @@ -28,17 +27,13 @@ import { isFactionWork } from "../../Work/FactionWork"; import { ReputationRate } from "./ReputationRate"; import { isCompanyWork } from "../../Work/CompanyWork"; import { isCrimeWork } from "../../Work/CrimeWork"; -import { Skills } from "../../PersonObjects/Skills"; -import { calculateSkillProgress } from "../../PersonObjects/formulas/skill"; import { EventEmitter } from "../../utils/EventEmitter"; import { useRerender } from "./hooks"; -type SkillRowName = "Hack" | "Str" | "Def" | "Dex" | "Agi" | "Cha" | "Int"; -type RowName = SkillRowName | "HP" | "Money"; -const OverviewEventEmitter = new EventEmitter(); +export const OverviewEventEmitter = new EventEmitter(); // These values aren't displayed, they're just used for comparison to check if state has changed -const valUpdaters: Record unknown> = { +const valUpdaters = { HP: () => Player.hp.current + "|" + Player.hp.max, // This isn't displayed, it's just compared for updates. Money: () => Player.money, Hack: () => Player.skills.hacking, @@ -48,10 +43,10 @@ const valUpdaters: Record unknown> = { Agi: () => Player.skills.agility, Cha: () => Player.skills.charisma, Int: () => Player.skills.intelligence, -}; +} as const; //These formattedVals functions don't take in a value because of the weirdness around HP. -const formattedVals: Record string> = { +const formattedVals = { HP: () => `${formatHp(Player.hp.current)} / ${formatHp(Player.hp.max)}`, Money: () => formatMoney(Player.money), Hack: () => formatSkill(Player.skills.hacking), @@ -61,53 +56,10 @@ const formattedVals: Record string> = { Agi: () => formatSkill(Player.skills.agility), Cha: () => formatSkill(Player.skills.charisma), Int: () => formatSkill(Player.skills.intelligence), -}; - -const skillMultUpdaters: Record number> = { - //Used by skill bars to calculate the mult - Hack: () => Player.mults.hacking * currentNodeMults.HackingLevelMultiplier, - Str: () => Player.mults.strength * currentNodeMults.StrengthLevelMultiplier, - Def: () => Player.mults.defense * currentNodeMults.DefenseLevelMultiplier, - Dex: () => Player.mults.dexterity * currentNodeMults.DexterityLevelMultiplier, - Agi: () => Player.mults.agility * currentNodeMults.AgilityLevelMultiplier, - Cha: () => Player.mults.charisma * currentNodeMults.CharismaLevelMultiplier, - Int: () => 1, -}; - -const skillNameMap: Record = { - Hack: "hacking", - Str: "strength", - Def: "defense", - Dex: "dexterity", - Agi: "agility", - Cha: "charisma", - Int: "intelligence", -}; - -interface SkillBarProps { - name: SkillRowName; - color?: string; -} -function SkillBar({ name, color }: SkillBarProps): React.ReactElement { - const [progress, setProgress] = useState(calculateSkillProgress(0)); - useEffect(() => { - const clearSubscription = OverviewEventEmitter.subscribe(() => { - const mult = skillMultUpdaters[name](); - setProgress(calculateSkillProgress(Player.exp[skillNameMap[name]], mult)); - }); - - return clearSubscription; - }, [name]); - - return ( - - - - ); -} +} as const; interface ValProps { - name: RowName; + name: keyof typeof valUpdaters; color?: string; } export function Val({ name, color }: ValProps): React.ReactElement { @@ -136,15 +88,14 @@ export function Val({ name, color }: ValProps): React.ReactElement { } interface DataRowProps { - name: RowName; //name for UI display + name: keyof typeof formattedVals; //name for UI display showBar: boolean; color?: string; cellType: "cellNone" | "cell"; } export function DataRow({ name, showBar, color, cellType }: DataRowProps): React.ReactElement { const { classes } = useStyles(); - const isSkill = name in skillNameMap; - const skillBar = showBar && isSkill ? : <>; + const skillBar = showBar && ; return ( <> diff --git a/src/ui/React/StatsProgressBar.tsx b/src/ui/React/StatsProgressBar.tsx index 5c743e2ec..b3d9cedaf 100644 --- a/src/ui/React/StatsProgressBar.tsx +++ b/src/ui/React/StatsProgressBar.tsx @@ -1,47 +1,105 @@ -import * as React from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import LinearProgress from "@mui/material/LinearProgress"; -import { TableCell, Tooltip, Typography } from "@mui/material"; -import { useStyles } from "./CharacterOverview"; -import { ISkillProgress } from "../../PersonObjects/formulas/skill"; +import { TableRow, TableCell, Tooltip, Typography } from "@mui/material"; +import { OverviewEventEmitter, useStyles } from "./CharacterOverview"; +import { Player } from "@player"; +import { currentNodeMults } from "../../BitNode/BitNodeMultipliers"; +import { calculateSkillProgress } from "../../PersonObjects/formulas/skill"; import { formatExp } from "../formatNumber"; -interface IProgressProps { - min: number; - max: number; - current: number; - remaining: number; - progress: number; +interface IProps { + name: string; color?: React.CSSProperties["color"]; } -interface IStatsOverviewCellProps { - progress: ISkillProgress; +interface InnerProps { + name: keyof typeof skillNameMap; color?: React.CSSProperties["color"]; } -export function StatsProgressBar({ - min, - max, - current, - remaining, - progress, - color, -}: IProgressProps): React.ReactElement { +const skillMultUpdaters = { + //Used by skill bars to calculate the mult + Hack: () => Player.mults.hacking * currentNodeMults.HackingLevelMultiplier, + Str: () => Player.mults.strength * currentNodeMults.StrengthLevelMultiplier, + Def: () => Player.mults.defense * currentNodeMults.DefenseLevelMultiplier, + Dex: () => Player.mults.dexterity * currentNodeMults.DexterityLevelMultiplier, + Agi: () => Player.mults.agility * currentNodeMults.AgilityLevelMultiplier, + Cha: () => Player.mults.charisma * currentNodeMults.CharismaLevelMultiplier, + Int: () => 1, +} as const; + +const skillNameMap = { + Hack: "hacking", + Str: "strength", + Def: "defense", + Dex: "dexterity", + Agi: "agility", + Cha: "charisma", + Int: "intelligence", +} as const; + +function isSkill(name: string): name is keyof typeof skillNameMap { + return name in skillNameMap; +} + +// This part is extracted so that the outer table parts don't need to get +// rerendered on every refresh. +function StatsProgressBarInner({ name, color }: InnerProps): React.ReactElement { + const domRef: React.Ref = useRef(null); + const [progress, setProgress] = useState(calculateSkillProgress(0)); + useEffect(() => { + const clearSubscription = OverviewEventEmitter.subscribe(() => { + const mult = skillMultUpdaters[name](); + // Since this creates a new object every time, it normally causes a rerender every time. + const newProgress = calculateSkillProgress(Player.exp[skillNameMap[name]], mult); + setProgress((progress) => { + if (progress.progress === newProgress.progress) { + // Nothing has changed, return the original object for no rerender. + return progress; + } + // This takes place in the state updater for progress. + const ele = domRef.current?.firstElementChild; + if (!ele) return newProgress; + + const isWrapping = + newProgress.currentSkill === progress.currentSkill + 1 && newProgress.progress < progress.progress; + const sameLevel = + newProgress.currentSkill === progress.currentSkill && newProgress.progress > progress.progress; + const keyframes = [ + { transform: `translateX(${progress.progress - 100}%)`, offset: 0 }, + { transform: `translateX(${newProgress.progress - 100}%)`, offset: 1 }, + ]; + if (isWrapping) { + const offset = (100 - progress.progress) / (100 + newProgress.progress - progress.progress); + keyframes.splice(1, 0, { transform: "translateX(0%)", offset }, { transform: "translateX(-100%)", offset }); + } + // Use an instant animation for large or backward jumps, which is the + // same as no animation at all. + ele.animate(keyframes, { fill: "forwards", duration: isWrapping || sameLevel ? 400 : 0 }); + return newProgress; + }); + }); + + return clearSubscription; + }, [name]); + const tooltip = ( Progress:  - {formatExp(current)} ({progress.toFixed(2)}%) + {formatExp(progress.currentExperience)} ({progress.progress.toFixed(2)}%)
Remaining:  - {formatExp(remaining)} / {formatExp(max - min)} + {formatExp(progress.remainingExperience)} / {formatExp(progress.nextExperience - progress.baseExperience)}
); - - return ( - + // We keep this component fixed (never rerender it) and manipulate it + // strictly through the animate() API. + const bar = useMemo( + () => ( - + ), + [color], ); + return {bar}; } -export function StatsProgressOverviewCell({ progress: skill, color }: IStatsOverviewCellProps): React.ReactElement { +export function StatsProgressBar({ name, color }: IProps): React.ReactElement { const { classes } = useStyles(); + + if (!isSkill(name)) { + return <>; + } + return ( - - - + + + + + ); }