Files
elysium/apps/web/src/components/ui/resourceBar.tsx
T
hikari 29c817230d
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
CI / Lint, Build & Test (push) Successful in 1m6s
feat: initial prototype — core game systems (#30)
## Summary

This PR represents the full v1 prototype, implementing the core game systems for Elysium.

- Full idle/clicker RPG loop: resource collection, crafting, boss fights, exploration, and quests
- Adventurer hiring with batch size selector and progressive tier cost scaling
- Prestige, transcendence, and apotheosis systems with auto-prestige support
- Character sheet, titles, leaderboards, companion system, and daily login bonuses
- Auto-quest and auto-boss toggles
- Discord webhook notifications on prestige/transcendence/apotheosis
- Discord role awarded on apotheosis
- Responsive design and overarching story/lore system
- In-game sound effects and browser notifications for key events
- Support link button in the resource bar
- Full test coverage (100% on `apps/api` and `packages/types`)
- CI pipeline: lint → build → test

## Closes

Closes #1
Closes #2
Closes #3
Closes #4
Closes #5
Closes #6
Closes #7
Closes #8
Closes #9
Closes #10
Closes #11
Closes #12
Closes #13
Closes #14
Closes #16
Closes #19
Closes #20
Closes #21
Closes #22
Closes #23
Closes #24
Closes #25
Closes #26
Closes #27
Closes #29

 This issue was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #30
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-08 15:53:39 -07:00

240 lines
7.8 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-per-function -- Large header with many resource and action elements */
/* eslint-disable complexity -- Many conditional resource and badge render paths */
import { useGame } from "../../context/gameContext.js";
import { RESOURCE_CAP } from "../../engine/tick.js";
import type { Resource } from "@elysium/types";
import type { JSX } from "react";
interface ResourceBarProperties {
readonly resources: Resource;
readonly runestones: number;
readonly prestigeCount: number;
readonly transcendenceCount: number;
readonly apotheosisCount: number;
readonly profileUrl: string;
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.profileUrl - The URL of the player's public profile.
* @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,
profileUrl,
onEditProfile,
lastSavedAt,
isSyncing,
onForceSync,
}: ResourceBarProperties): JSX.Element => {
const { formatNumber, syncError } = useGame();
const { gold, essence, crystals } = resources;
const resourceValues = [ gold, essence, crystals ];
const anyFull = resourceValues.some((v) => {
return v >= RESOURCE_CAP;
});
const goldFull = gold >= RESOURCE_CAP;
const essenceFull = essence >= RESOURCE_CAP;
const crystalsFull = crystals >= RESOURCE_CAP;
function handleForceSync(): void {
void onForceSync();
}
return (
<>
<header className="resource-bar">
<div className={`resource${goldFull
? " resource-full"
: ""}`}>
<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}
</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>
{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="profile-buttons">
<a
className="profile-link-button"
href="https://donate.nhcarrigan.com"
rel="noreferrer"
target="_blank"
title="Support the developer"
>
{"💜"} <span className="btn-label">{"Donate"}</span>
</a>
<a
className="profile-link-button"
href="https://chat.nhcarrigan.com"
rel="noreferrer"
target="_blank"
title="Join our Discord"
>
{"💬"} <span className="btn-label">{"Discord"}</span>
</a>
<a
className="profile-link-button"
href="https://support.nhcarrigan.com"
rel="noreferrer"
target="_blank"
title="Get support on our forum"
>
{"🆘"} <span className="btn-label">{"Support"}</span>
</a>
{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>
<a
className="profile-link-button"
href={profileUrl}
rel="noreferrer"
target="_blank"
title="View your public profile"
>
{"👤"} <span className="btn-label">{"Profile"}</span>
</a>
<button
className="profile-edit-button"
onClick={onEditProfile}
title="Edit your profile"
type="button"
>
{"✏️"}
</button>
</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 };