mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2026-04-16 06:18:42 +02:00
UI: Better status bar animations (#2317)
* UI: Better status bar animations This is an alternate implementation of #2286. It does the same wrap-around behaviour for when the progressbar crosses into the next level, but it skips animation entirely if the effective skill level goes down, or if more than one level is gained at a time. The implementation uses the animate() DOM api instead of manipulating styles, which completely avoids the issues of having CSS style buildup. This API is designed for exactly what we're trying to do. I also pushed rerender handling down from CharacterOverview to StatsProgressBar, which simplifies things and is helpful for doing the animation implementation.
This commit is contained in:
@@ -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<RowName, () => 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<RowName, () => 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<RowName, () => 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<RowName, () => string> = {
|
||||
Agi: () => formatSkill(Player.skills.agility),
|
||||
Cha: () => formatSkill(Player.skills.charisma),
|
||||
Int: () => formatSkill(Player.skills.intelligence),
|
||||
};
|
||||
|
||||
const skillMultUpdaters: Record<SkillRowName, () => 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<SkillRowName, keyof Skills> = {
|
||||
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 (
|
||||
<TableRow>
|
||||
<StatsProgressOverviewCell progress={progress} color={color} />
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
} 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 ? <SkillBar name={name as SkillRowName} color={color} /> : <></>;
|
||||
const skillBar = showBar && <StatsProgressBar name={name} color={color} />;
|
||||
return (
|
||||
<>
|
||||
<TableRow>
|
||||
|
||||
@@ -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<HTMLElement> = 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 = (
|
||||
<Typography sx={{ textAlign: "right" }}>
|
||||
<strong>Progress:</strong>
|
||||
{formatExp(current)} ({progress.toFixed(2)}%)
|
||||
{formatExp(progress.currentExperience)} ({progress.progress.toFixed(2)}%)
|
||||
<br />
|
||||
<strong>Remaining:</strong>
|
||||
{formatExp(remaining)} / {formatExp(max - min)}
|
||||
{formatExp(progress.remainingExperience)} / {formatExp(progress.nextExperience - progress.baseExperience)}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
// We keep this component fixed (never rerender it) and manipulate it
|
||||
// strictly through the animate() API.
|
||||
const bar = useMemo(
|
||||
() => (
|
||||
<LinearProgress
|
||||
ref={domRef}
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
value={0}
|
||||
sx={{
|
||||
backgroundColor: "#111111",
|
||||
"& .MuiLinearProgress-bar1Determinate": {
|
||||
@@ -49,28 +107,30 @@ export function StatsProgressBar({
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
[color],
|
||||
);
|
||||
return <Tooltip title={tooltip}>{bar}</Tooltip>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
colSpan={2}
|
||||
classes={{ root: classes.cellNone }}
|
||||
style={{ paddingBottom: "2px", position: "relative", top: "-3px" }}
|
||||
>
|
||||
<StatsProgressBar
|
||||
min={skill.baseExperience}
|
||||
max={skill.nextExperience}
|
||||
current={skill.currentExperience}
|
||||
remaining={skill.remainingExperience}
|
||||
progress={skill.progress}
|
||||
color={color}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
colSpan={2}
|
||||
classes={{ root: classes.cellNone }}
|
||||
style={{ paddingBottom: "2px", position: "relative", top: "-3px" }}
|
||||
>
|
||||
<StatsProgressBarInner name={name} color={color} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user