generated from nhcarrigan/template
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>
This commit was merged in pull request #30.
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @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 };
|
||||
Reference in New Issue
Block a user