UI: Sync UI updates to game updates. (#1512)

There are a bunch of React components that update at the same rate
that the game engine processes cycles. Rather than have each place
that does so start its own timer to update that often, add a new
react hook that triggers an update shortly after the engine completes
a cycle.
This commit is contained in:
Tom Prince
2024-08-02 00:57:43 -06:00
committed by GitHub
parent 6483b5e7fe
commit 2f95d21503
22 changed files with 71 additions and 55 deletions

View File

@ -26,7 +26,7 @@ import { formatNumberNoSuffix } from "../../ui/formatNumber";
import { Info } from "@mui/icons-material";
import { Link } from "@mui/material";
import { AlertEvents } from "../../ui/React/AlertManager";
import { useRerender } from "../../ui/React/hooks";
import { useCycleRerender } from "../../ui/React/hooks";
const NeuroFluxDisplay = (): React.ReactElement => {
const level = Player.augmentations.find((e) => e.name === AugmentationName.NeuroFluxGovernor)?.level ?? 0;
@ -85,7 +85,7 @@ interface IProps {
export function AugmentationsRoot(props: IProps): React.ReactElement {
const [installOpen, setInstallOpen] = useState(false);
const rerender = useRerender(200);
const rerender = useCycleRerender();
function doExport(): void {
props.exportGameFn();

View File

@ -5,10 +5,10 @@ import { AllPages } from "./AllPages";
import { Player } from "@player";
import { Box } from "@mui/material";
import { useRerender } from "../../ui/React/hooks";
import { useCycleRerender } from "../../ui/React/hooks";
export function BladeburnerRoot(): React.ReactElement {
useRerender(200);
useCycleRerender();
const bladeburner = Player.bladeburner;
if (!bladeburner) return <></>;
return (

View File

@ -10,10 +10,10 @@ import { Overview } from "./Overview";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import { useRerender } from "../../ui/React/hooks";
import { useCycleRerender } from "../../ui/React/hooks";
export function CorporationRoot(): React.ReactElement {
const rerender = useRerender(200);
const rerender = useCycleRerender();
const [divisionName, setDivisionName] = useState<string | number>("Overview");
const corporation = Player.corporation;

View File

@ -20,7 +20,7 @@ import { CovenantPurchasesRoot } from "../../PersonObjects/Sleeve/ui/CovenantPur
import { FactionName, FactionWorkType } from "@enums";
import { GangButton } from "./GangButton";
import { FactionWork } from "../../Work/FactionWork";
import { useRerender } from "../../ui/React/hooks";
import { useCycleRerender } from "../../ui/React/hooks";
import { repNeededToDonate } from "../formulas/donation";
type FactionRootProps = {
@ -147,7 +147,7 @@ function MainPage({ faction, rerender, onAugmentations }: IMainProps): React.Rea
}
export function FactionRoot({ faction }: FactionRootProps): React.ReactElement {
const rerender = useRerender(200);
const rerender = useCycleRerender();
if (!Player.factions.includes(faction.name)) {
return (

View File

@ -9,7 +9,7 @@ import { Settings } from "../../Settings/Settings";
import { formatFavor, formatReputation } from "../../ui/formatNumber";
import { Router } from "../../ui/GameRoot";
import { Page } from "../../ui/Router";
import { useRerender } from "../../ui/React/hooks";
import { useCycleRerender } from "../../ui/React/hooks";
import { CorruptableText } from "../../ui/React/CorruptableText";
import { Requirement } from "../../ui/Components/Requirement";
@ -205,7 +205,7 @@ const FactionElement = (props: FactionElementProps): React.ReactElement => {
export function FactionsRoot(): React.ReactElement {
const theme = useTheme();
const rerender = useRerender(200);
const rerender = useCycleRerender();
useEffect(() => {
Player.factionInvitations.forEach((factionName) => {
InvitationsSeen.add(factionName);

View File

@ -15,7 +15,7 @@ import { makeStyles } from "tss-react/mui";
import Typography from "@mui/material/Typography";
import Tooltip from "@mui/material/Tooltip";
import Box from "@mui/material/Box";
import { useRerender } from "../../ui/React/hooks";
import { useCycleRerender } from "../../ui/React/hooks";
import { calculateFavorAfterResetting } from "../formulas/favor";
interface IProps {
@ -42,7 +42,7 @@ function DefaultAssignment(): React.ReactElement {
}
export function Info(props: IProps): React.ReactElement {
useRerender(200);
useCycleRerender();
const { classes } = useStyles();
const Assignment = props.factionInfo.assignment ?? DefaultAssignment;

View File

@ -8,7 +8,7 @@ import { Context } from "./Context";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import { useRerender } from "../../ui/React/hooks";
import { useCycleRerender } from "../../ui/React/hooks";
/** React Component for all the gang stuff. */
export function GangRoot(): React.ReactElement {
@ -22,7 +22,7 @@ export function GangRoot(): React.ReactElement {
setValue(tab);
}
useRerender(200);
useCycleRerender();
return (
<Context.Gang.Provider value={gang}>

View File

@ -25,12 +25,12 @@ import Typography from "@mui/material/Typography";
import Grid from "@mui/material/Grid";
import Button from "@mui/material/Button";
import { Box } from "@mui/material";
import { useRerender } from "../../ui/React/hooks";
import { useCycleRerender } from "../../ui/React/hooks";
/** Root React Component for the Hacknet Node UI */
export function HacknetRoot(): React.ReactElement {
const [open, setOpen] = useState(false);
const rerender = useRerender(200);
const rerender = useCycleRerender();
const [purchaseMultiplier, setPurchaseMultiplier] = useState<number | "MAX">(PurchaseMultipliers.x1);
let totalProduction = 0;

View File

@ -7,7 +7,7 @@ import { HacknetUpgradeElem } from "./HacknetUpgradeElem";
import { Modal } from "../../ui/React/Modal";
import { Player } from "@player";
import Typography from "@mui/material/Typography";
import { useRerender } from "../../ui/React/hooks";
import { useCycleRerender } from "../../ui/React/hooks";
interface IProps {
open: boolean;
@ -16,7 +16,7 @@ interface IProps {
/** Create the pop-up for purchasing upgrades with hashes */
export function HashUpgradeModal(props: IProps): React.ReactElement {
const rerender = useRerender(200);
const rerender = useCycleRerender();
const hashManager = Player.hashManager;
if (!hashManager) {

View File

@ -19,7 +19,7 @@ import { Page } from "../../ui/Router";
import { Player } from "@player";
import { QuitJobModal } from "../../Company/ui/QuitJobModal";
import { CompanyWork } from "../../Work/CompanyWork";
import { useRerender } from "../../ui/React/hooks";
import { useCycleRerender } from "../../ui/React/hooks";
import { companyNameAsLocationName } from "../../Company/utils";
import { JobSummary } from "../../Company/ui/JobSummary";
import { StatsTable } from "../../ui/React/StatsTable";
@ -32,7 +32,7 @@ interface IProps {
export function CompanyLocation(props: IProps): React.ReactElement {
const [quitOpen, setQuitOpen] = useState(false);
const rerender = useRerender(200);
const rerender = useCycleRerender();
/**
* We'll keep a reference to the Company that this component is being rendered for,

View File

@ -12,12 +12,12 @@ import { getHospitalizationCost } from "../../Hospital/Hospital";
import { Money } from "../../ui/React/Money";
import { dialogBoxCreate } from "../../ui/React/DialogBox";
import { useRerender } from "../../ui/React/hooks";
import { useCycleRerender } from "../../ui/React/hooks";
export function HospitalLocation(): React.ReactElement {
/** Stores button styling that sets them all to block display */
const btnStyle = { display: "block" };
const rerender = useRerender(200);
const rerender = useCycleRerender();
function getHealed(e: React.MouseEvent<HTMLElement>): void {
if (!e.isTrusted) {

View File

@ -21,7 +21,7 @@ import { formatNumberNoSuffix } from "../../../ui/formatNumber";
import { convertTimeMsToTimeElapsedString } from "../../../utils/StringHelperFunctions";
import { GraftableAugmentation } from "../GraftableAugmentation";
import { calculateGraftingTimeWithBonus, getGraftingAvailableAugs } from "../GraftingHelpers";
import { useRerender } from "../../../ui/React/hooks";
import { useCycleRerender } from "../../../ui/React/hooks";
export const GraftableAugmentations = (): Record<string, GraftableAugmentation> => {
const gAugs: Record<string, GraftableAugmentation> = {};
@ -67,7 +67,7 @@ export const GraftingRoot = (): React.ReactElement => {
const [selectedAug, setSelectedAug] = useState(getGraftingAvailableAugs()[0]);
const [graftOpen, setGraftOpen] = useState(false);
const selectedAugmentation = Augmentations[selectedAug];
const rerender = useRerender(200);
const rerender = useCycleRerender();
const getAugsSorted = (): AugmentationName[] => {
const augs = getGraftingAvailableAugs();

View File

@ -6,11 +6,11 @@ import { Player } from "@player";
import { SleeveElem } from "./SleeveElem";
import { FAQModal } from "./FAQModal";
import { useRerender } from "../../../ui/React/hooks";
import { useCycleRerender } from "../../../ui/React/hooks";
export function SleeveRoot(): React.ReactElement {
const [FAQOpen, setFAQOpen] = useState(false);
const rerender = useRerender(200);
const rerender = useCycleRerender();
return (
<>

View File

@ -11,12 +11,12 @@ import { Settings } from "../../Settings/Settings";
import { Programs } from "../Programs";
import { CreateProgramWork, isCreateProgramWork } from "../../Work/CreateProgramWork";
import { useRerender } from "../../ui/React/hooks";
import { useCycleRerender } from "../../ui/React/hooks";
export const ProgramsSeen = new Set<string>();
export function ProgramsRoot(): React.ReactElement {
useRerender(200);
useCycleRerender();
const programs = [...Object.values(Programs)]
.filter((prog) => {

View File

@ -54,7 +54,7 @@ import { ProgramsSeen } from "../../Programs/ui/ProgramsRoot";
import { InvitationsSeen } from "../../Faction/ui/FactionsRoot";
import { hash } from "../../hash/hash";
import { Locations } from "../../Locations/Locations";
import { useRerender } from "../../ui/React/hooks";
import { useCycleRerender } from "../../ui/React/hooks";
import { playerHasDiscoveredGo } from "../../Go/effects/effect";
import { knowAboutBitverse } from "../../BitNode/BitNodeUtils";
@ -108,7 +108,7 @@ const useStyles = makeStyles()((theme: Theme) => ({
}));
export function SidebarRoot(props: { page: Page }): React.ReactElement {
useRerender(200);
useCycleRerender();
let flash: Page | null = null;
switch (ITutorial.currStep) {

View File

@ -6,7 +6,7 @@ import { StockTickers } from "./StockTickers";
import { IStockMarket } from "../IStockMarket";
import { Player } from "@player";
import { useRerender } from "../../ui/React/hooks";
import { useCycleRerender } from "../../ui/React/hooks";
interface IProps {
stockMarket: IStockMarket;
@ -14,7 +14,7 @@ interface IProps {
/** Root React component for the Stock Market UI */
export function StockMarketRoot(props: IProps): React.ReactElement {
const rerender = useRerender(200);
const rerender = useCycleRerender();
return (
<>
<InfoAndPurchases rerender={rerender} />

View File

@ -1,11 +1,11 @@
import React from "react";
import Typography from "@mui/material/Typography";
import { useRerender } from "../../ui/React/hooks";
import { useCycleRerender } from "../../ui/React/hooks";
import { Terminal } from "../../Terminal";
export function TerminalActionTimer(): React.ReactElement {
useRerender(200);
useCycleRerender();
return <Typography color="primary">{Terminal.action && Terminal.getProgressText()}</Typography>;
}

View File

@ -39,11 +39,13 @@ import { startExploits } from "./Exploits/loops";
import { calculateAchievements } from "./Achievements/Achievements";
import React from "react";
import ReactDOM from "react-dom";
import { setupUncaughtPromiseHandler } from "./UncaughtPromiseHandler";
import { Button, Typography } from "@mui/material";
import { SnackbarEvents } from "./ui/React/Snackbar";
import { SaveData } from "./types";
import { Go } from "./Go/Go";
import { EventEmitter } from "./utils/EventEmitter";
// Only show warning if the time diff is greater than this value.
const thresholdOfTimeDiffForShowingWarningAboutSystemClock = CONSTANTS.MillisecondsPerFiveMinutes;
@ -54,6 +56,8 @@ function showWarningAboutSystemClock(timeDiff: number) {
);
}
export const GameCycleEvents = new EventEmitter<[]>();
/** Game engine. Handles the main game loop. */
const Engine: {
_lastUpdate: number;
@ -428,6 +432,11 @@ const Engine: {
Engine._lastUpdate = _thisUpdate - offset;
Player.lastUpdate = _thisUpdate - offset;
Engine.updateGame(diff);
if (GameCycleEvents.hasSubscibers()) {
ReactDOM.unstable_batchedUpdates(() => {
GameCycleEvents.emit();
});
}
}
window.setTimeout(Engine.start, CONSTANTS.MilliPerCycle - offset);
},

View File

@ -15,7 +15,7 @@ import { Modal } from "./React/Modal";
import { Money } from "./React/Money";
import { StatsRow } from "./React/StatsRow";
import { StatsTable } from "./React/StatsTable";
import { useRerender } from "./React/hooks";
import { useCycleRerender } from "./React/hooks";
import { getMaxFavor } from "../Go/effects/effect";
import { canAccessBitNodeFeature, knowAboutBitverse } from "../BitNode/BitNodeUtils";
@ -220,7 +220,7 @@ function MoneyModal({ open, onClose }: IMoneyModalProps): React.ReactElement {
export function CharacterStats(): React.ReactElement {
const [moneyOpen, setMoneyOpen] = useState(false);
const [employersOpen, setEmployersOpen] = useState(false);
useRerender(200);
useCycleRerender();
const timeRows = [
["Since last Augmentation installation", convertTimeMsToTimeElapsedString(Player.playtimeSinceLastAug)],

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { GameCycleEvents } from "../../engine";
/** Hook that returns a function for the component. Optionally set an interval to rerender the component.
* @param autoRerenderTime: Optional. If provided and nonzero, used as the ms interval to automatically call the rerender function.
@ -17,6 +18,19 @@ export function useRerender(autoRerenderTime?: number) {
return rerender;
}
/** Hook that rerenders the component shortly after the game engine processes a cycle.
* @returns a function that will trigger a rerender.
*/
export function useCycleRerender(): () => void {
const rerender = useRerender();
useEffect(() => {
const unsubscribe = GameCycleEvents.subscribe(rerender);
return unsubscribe;
}, [rerender]);
return rerender;
}
export function useBoolean(initialValue = false) {
const [value, setValue] = useState(initialValue);

View File

@ -10,7 +10,7 @@ import { ProgressBar } from "./React/Progress";
import { Reputation } from "./React/Reputation";
import { ReputationRate } from "./React/ReputationRate";
import { StatsRow } from "./React/StatsRow";
import { useRerender } from "./React/hooks";
import { useCycleRerender } from "./React/hooks";
import { Companies } from "../Company/Companies";
import { CONSTANTS } from "../Constants";
@ -183,7 +183,7 @@ function CrimeExpRows(rate: WorkStats): React.ReactElement[] {
}
export function WorkInProgressRoot(): React.ReactElement {
useRerender(CONSTANTS.MilliPerCycle);
useCycleRerender();
let workInfo: IWorkInfo = {
buttons: {

View File

@ -1,31 +1,24 @@
function uuidv4(): string {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0,
v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/** Generic Event Emitter class following a subscribe/publish paradigm. */
export class EventEmitter<T extends any[]> {
subscribers: Record<string, (...args: [...T]) => void | undefined> = {};
private subscribers: Set<(...args: [...T]) => void | undefined> = new Set();
constructor() {}
subscribe(s: (...args: [...T]) => void): () => void {
let uuid = uuidv4();
while (this.subscribers[uuid] !== undefined) uuid = uuidv4();
this.subscribers[uuid] = s;
this.subscribers.add(s);
return () => {
delete this.subscribers[uuid];
this.subscribers.delete(s);
};
}
emit(...args: [...T]): void {
for (const s in this.subscribers) {
const sub = this.subscribers[s];
if (sub === undefined) continue;
for (const sub of this.subscribers) {
sub(...args);
}
}
hasSubscibers(): boolean {
return this.subscribers.size > 0;
}
}