/** * @file Resource bar component displaying player resources and profile actions. * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan */ /* eslint-disable max-lines -- Resource bar has many resource and action elements */ /* eslint-disable max-lines-per-function -- Large header with many resource and action elements */ /* eslint-disable max-statements -- Resource bar requires many local computations and handlers */ /* eslint-disable complexity -- Many conditional resource and badge render paths */ import { useState, type FocusEvent, type JSX } from "react"; import { useGame } from "../../context/gameContext.js"; import { RESOURCE_CAP, computeEssencePerSecond, computeGoldPerSecond, computePartyCombatPower, computeProjectedRunestones, } from "../../engine/tick.js"; import type { Resource } from "@elysium/types"; interface ResourceBarProperties { readonly resources: Resource; readonly runestones: number; readonly prestigeCount: number; readonly transcendenceCount: number; readonly apotheosisCount: number; readonly onEditProfile: ()=> void; readonly lastSavedAt: number | null; readonly isSyncing: boolean; readonly onForceSync: ()=> Promise; } /** * Formats a timestamp as a human-readable relative time string. * @param timestamp - The Unix timestamp in milliseconds. * @returns The relative time string. */ const formatRelativeTime = (timestamp: number): string => { const seconds = Math.floor((Date.now() - timestamp) / 1000); if (seconds < 10) { return "just now"; } if (seconds < 60) { return `${String(seconds)}s ago`; } const minutes = Math.floor(seconds / 60); if (minutes < 60) { return `${String(minutes)}m ago`; } const hours = Math.floor(minutes / 60); return `${String(hours)}h ago`; }; const resourceFullTooltip = [ "This resource is full!", " Consider spending some or prestiging to keep earning.", ].join(""); /** * Renders the resource bar with player resources and profile actions. * @param props - The resource bar properties. * @param props.resources - The current player resources. * @param props.runestones - The current runestone count. * @param props.prestigeCount - The number of prestiges completed. * @param props.transcendenceCount - The number of transcendences completed. * @param props.apotheosisCount - The number of apotheoses completed. * @param props.onEditProfile - Callback to open the edit profile modal. * @param props.lastSavedAt - Timestamp of the last cloud save. * @param props.isSyncing - Whether a sync is currently in progress. * @param props.onForceSync - Callback to trigger a forced cloud sync. * @returns The JSX element. */ const ResourceBar = ({ resources, runestones, prestigeCount, transcendenceCount, apotheosisCount, onEditProfile, lastSavedAt, isSyncing, onForceSync, }: ResourceBarProperties): JSX.Element => { const { formatInteger, formatNumber, syncError, state } = useGame(); const [ isProfileOpen, setIsProfileOpen ] = useState(false); const [ isResourcesOpen, setIsResourcesOpen ] = useState(false); const { gold, essence, crystals } = resources; let partyCombatPower = 0; let goldPerSecond = 0; let essencePerSecond = 0; let projectedRunestones = 0; if (state !== null) { partyCombatPower = computePartyCombatPower(state); goldPerSecond = computeGoldPerSecond(state); essencePerSecond = computeEssencePerSecond(state); projectedRunestones = computeProjectedRunestones(state); } let avatarUrl: string | null = null; if (state !== null) { avatarUrl = state.player.avatar === null ? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(state.player.discordId, 10) % 5)}.png` : `https://cdn.discordapp.com/avatars/${state.player.discordId}/${state.player.avatar}.png?size=64`; } const profileUrl = state === null ? "#" : `/profile/${state.player.discordId}`; const goldFull = gold >= RESOURCE_CAP; const essenceFull = essence >= RESOURCE_CAP; const crystalsFull = crystals >= RESOURCE_CAP; const anyFull = goldFull || essenceFull || crystalsFull; const hiddenResourcesFull = essenceFull || crystalsFull; function handleForceSync(): void { void onForceSync(); } function handleToggleResources(): void { setIsResourcesOpen((previous) => { return !previous; }); } function handleResourceBlur(event: FocusEvent): void { if (!event.currentTarget.contains(event.relatedTarget)) { setIsResourcesOpen(false); } } function handleToggleProfile(): void { setIsProfileOpen((previous) => { return !previous; }); } function handleProfileBlur(event: FocusEvent): void { if (!event.currentTarget.contains(event.relatedTarget)) { setIsProfileOpen(false); } } function handleEditProfile(): void { setIsProfileOpen(false); onEditProfile(); } return ( <>
{isResourcesOpen ?
{"📈"} {formatNumber(goldPerSecond)} {"Gold/s"}
{"⚡"} {formatNumber(essencePerSecond)} {"Essence/s"}
{"✨"} {formatNumber(essence)} {"Essence"} {essenceFull ? {"FULL"} : null}
{"💎"} {formatInteger(crystals)} {"Crystals"} {crystalsFull ? {"FULL"} : null}
{"🔮"} {formatInteger(runestones)} {"Runestones"}
{"⭐"} {`+${formatInteger(projectedRunestones)}`} {"On Prestige"}
{"⚔️"} {formatNumber(partyCombatPower)} {"Combat Power"}
: null}
{apotheosisCount > 0 &&
{"✨ Apotheosis "} {apotheosisCount}
} {transcendenceCount > 0 &&
{"🌌 Transcendence "} {transcendenceCount}
} {prestigeCount > 0 &&
{"⭐ Prestige "} {prestigeCount}
}
{syncError === null ? null : {"❌ Save failed"} } {syncError === null && lastSavedAt !== null ? {"☁️ "} {formatRelativeTime(lastSavedAt)} : null} {avatarUrl === null ? null : }
{anyFull ?
{"⚠️ One or more resources are full! Consider spending some or" + " prestiging to keep earning."}
: null} ); }; export { ResourceBar };