generated from nhcarrigan/template
3ac1d566cb
## Summary Addresses all community feedback tickets from the last deploy, plus several UI improvements made during the same session. ### Bug fixes & balance - **#97** — Fix auto-adventurer tier priority: sort by combat power instead of current cost so the highest-tier affordable unit is always purchased - **#98** — Add Dark Templar adventurer (80k CP) to bridge the Volcanic Depths progression wall; rewire upgrade and quest rewards accordingly - **#99** — Reorder and buff Shadow Assassin (55k CP, level 12) so Witch Coven feels rewarding rather than a regression - **#100** — Display effective Gold/s (all multipliers applied) in the resource bar - **#101** — Add Peasant tier 2 (10x, essence) and tier 3 (50x, crystals) upgrades for meaningful late-game scaling ### Other fixes - Sync game state to server before auto-boss challenges (matching manual challenge behaviour) - Refresh Discord avatar hash on every game load via bot token so stale CDN URLs are corrected automatically ### UI improvements - Replace Donate / Discord / Support / View Profile / Edit Profile buttons with a single avatar dropdown menu - Collapse all resources except Gold into a click-to-toggle dropdown; orange alert dot appears when a hidden resource is capped ## Closes Closes #97 Closes #98 Closes #99 Closes #100 Closes #101 Reviewed-on: #102 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
/**
|
|
* @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, computeGoldPerSecond } 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<void>;
|
|
}
|
|
|
|
/**
|
|
* 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 { 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;
|
|
if (state !== null) {
|
|
for (const adventurer of state.adventurers) {
|
|
const contribution = adventurer.combatPower * adventurer.count;
|
|
partyCombatPower = partyCombatPower + contribution;
|
|
}
|
|
goldPerSecond = computeGoldPerSecond(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<HTMLDivElement>): void {
|
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
|
setIsResourcesOpen(false);
|
|
}
|
|
}
|
|
|
|
function handleToggleProfile(): void {
|
|
setIsProfileOpen((previous) => {
|
|
return !previous;
|
|
});
|
|
}
|
|
|
|
function handleProfileBlur(event: FocusEvent<HTMLDivElement>): void {
|
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
|
setIsProfileOpen(false);
|
|
}
|
|
}
|
|
|
|
function handleEditProfile(): void {
|
|
setIsProfileOpen(false);
|
|
onEditProfile();
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<header className="resource-bar">
|
|
<div
|
|
className="resource-menu"
|
|
onBlur={handleResourceBlur}
|
|
>
|
|
<button
|
|
className={`resource resource-toggle${goldFull
|
|
? " resource-full"
|
|
: ""}`}
|
|
onClick={handleToggleResources}
|
|
title="Click to see all resources"
|
|
type="button"
|
|
>
|
|
<span className="resource-icon">{"🪙"}</span>
|
|
<span className="resource-value">{formatNumber(gold)}</span>
|
|
<span className="resource-label">{"Gold"}</span>
|
|
{goldFull
|
|
? <span
|
|
className="resource-cap-badge"
|
|
title={resourceFullTooltip}
|
|
>
|
|
{"FULL"}
|
|
</span>
|
|
: null}
|
|
{hiddenResourcesFull
|
|
? <span
|
|
className="resource-alert-dot"
|
|
title={"One or more resources are full!"}
|
|
/>
|
|
: null}
|
|
</button>
|
|
{isResourcesOpen
|
|
? <div className="resources-dropdown">
|
|
<div className="resource">
|
|
<span className="resource-icon">{"📈"}</span>
|
|
<span className="resource-value">
|
|
{formatNumber(goldPerSecond)}
|
|
</span>
|
|
<span className="resource-label">{"Gold/s"}</span>
|
|
</div>
|
|
<div className={`resource${essenceFull
|
|
? " resource-full"
|
|
: ""}`}>
|
|
<span className="resource-icon">{"✨"}</span>
|
|
<span className="resource-value">
|
|
{formatNumber(essence)}
|
|
</span>
|
|
<span className="resource-label">{"Essence"}</span>
|
|
{essenceFull
|
|
? <span
|
|
className="resource-cap-badge"
|
|
title={resourceFullTooltip}
|
|
>
|
|
{"FULL"}
|
|
</span>
|
|
: null}
|
|
</div>
|
|
<div className={`resource${crystalsFull
|
|
? " resource-full"
|
|
: ""}`}>
|
|
<span className="resource-icon">{"💎"}</span>
|
|
<span className="resource-value">
|
|
{formatNumber(crystals)}
|
|
</span>
|
|
<span className="resource-label">{"Crystals"}</span>
|
|
{crystalsFull
|
|
? <span
|
|
className="resource-cap-badge"
|
|
title={resourceFullTooltip}
|
|
>
|
|
{"FULL"}
|
|
</span>
|
|
: null}
|
|
</div>
|
|
<div className="resource">
|
|
<span className="resource-icon">{"🔮"}</span>
|
|
<span className="resource-value">
|
|
{formatNumber(runestones)}
|
|
</span>
|
|
<span className="resource-label">{"Runestones"}</span>
|
|
</div>
|
|
<div className="resource">
|
|
<span className="resource-icon">{"⚔️"}</span>
|
|
<span className="resource-value">
|
|
{formatNumber(partyCombatPower)}
|
|
</span>
|
|
<span className="resource-label">{"Combat Power"}</span>
|
|
</div>
|
|
</div>
|
|
: null}
|
|
</div>
|
|
{apotheosisCount > 0
|
|
&& <div className="apotheosis-badge">
|
|
{"✨ Apotheosis "}
|
|
{apotheosisCount}
|
|
</div>
|
|
}
|
|
{transcendenceCount > 0
|
|
&& <div className="transcendence-badge">
|
|
{"🌌 Transcendence "}
|
|
{transcendenceCount}
|
|
</div>
|
|
}
|
|
{prestigeCount > 0
|
|
&& <div className="prestige-badge">
|
|
{"⭐ Prestige "}
|
|
{prestigeCount}
|
|
</div>
|
|
}
|
|
<div className="resource-bar-actions">
|
|
{syncError === null
|
|
? null
|
|
: <span className="save-status save-error" title={syncError}>
|
|
{"❌ Save failed"}
|
|
</span>
|
|
}
|
|
{syncError === null && lastSavedAt !== null
|
|
? <span
|
|
className="save-status"
|
|
title={new Date(lastSavedAt).toLocaleString()}
|
|
>
|
|
{"☁️ "}
|
|
{formatRelativeTime(lastSavedAt)}
|
|
</span>
|
|
: null}
|
|
<button
|
|
className="force-save-button"
|
|
disabled={isSyncing}
|
|
onClick={handleForceSync}
|
|
title="Force cloud save"
|
|
type="button"
|
|
>
|
|
{isSyncing
|
|
? "⏳"
|
|
: "💾"}
|
|
</button>
|
|
{avatarUrl === null
|
|
? null
|
|
: <div
|
|
className="profile-menu"
|
|
onBlur={handleProfileBlur}
|
|
>
|
|
<button
|
|
className="profile-avatar-button"
|
|
onClick={handleToggleProfile}
|
|
title="Account"
|
|
type="button"
|
|
>
|
|
<img
|
|
alt="Profile"
|
|
className="profile-avatar-img"
|
|
src={avatarUrl}
|
|
/>
|
|
</button>
|
|
{isProfileOpen
|
|
? <div className="profile-dropdown">
|
|
<a
|
|
className="profile-dropdown-item"
|
|
href={profileUrl}
|
|
rel="noreferrer"
|
|
target="_blank"
|
|
>
|
|
{"👤 View Profile"}
|
|
</a>
|
|
<button
|
|
className="profile-dropdown-item"
|
|
onClick={handleEditProfile}
|
|
type="button"
|
|
>
|
|
{"✏️ Edit Profile"}
|
|
</button>
|
|
<hr className="profile-dropdown-divider" />
|
|
<a
|
|
className="profile-dropdown-item"
|
|
href="https://donate.nhcarrigan.com"
|
|
rel="noreferrer"
|
|
target="_blank"
|
|
>
|
|
{"💜 Donate"}
|
|
</a>
|
|
<a
|
|
className="profile-dropdown-item"
|
|
href="https://chat.nhcarrigan.com"
|
|
rel="noreferrer"
|
|
target="_blank"
|
|
>
|
|
{"💬 Discord"}
|
|
</a>
|
|
<a
|
|
className="profile-dropdown-item"
|
|
href="https://support.nhcarrigan.com"
|
|
rel="noreferrer"
|
|
target="_blank"
|
|
>
|
|
{"🆘 Support"}
|
|
</a>
|
|
</div>
|
|
: null}
|
|
</div>}
|
|
</div>
|
|
</header>
|
|
{anyFull
|
|
? <div className="resource-cap-notice">
|
|
{"⚠️ One or more resources are full! Consider spending some or"
|
|
+ " prestiging to keep earning."}
|
|
</div>
|
|
: null}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export { ResourceBar };
|