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,212 @@
|
||||
/**
|
||||
* @file Statistics panel component showing player progress and all-time stats.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable react/require-default-props -- TypeScript optional props with default parameters are sufficient */
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { PRESTIGE_UPGRADES } from "../../data/prestigeUpgrades.js";
|
||||
import type { JSX } from "react";
|
||||
|
||||
const formatDate = (timestamp: number): string => {
|
||||
return new Date(timestamp).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
interface StatCardProperties {
|
||||
readonly icon: string;
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
readonly sub?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single statistic card.
|
||||
* @param props - The stat card properties.
|
||||
* @param props.icon - The icon to display.
|
||||
* @param props.label - The label for the stat.
|
||||
* @param props.value - The value to display.
|
||||
* @param props.sub - Optional sub-label.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const StatCard = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
sub = undefined,
|
||||
}: StatCardProperties): JSX.Element => {
|
||||
return (
|
||||
<div className="profile-stat">
|
||||
<span className="profile-stat-icon">{icon}</span>
|
||||
<span className="profile-stat-value">{value}</span>
|
||||
<span className="profile-stat-label">{label}</span>
|
||||
{sub === undefined
|
||||
? null
|
||||
: <span className="profile-stat-date">{sub}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the statistics panel with player progress and all-time stats.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const StatisticsPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
player,
|
||||
resources,
|
||||
prestige,
|
||||
bosses,
|
||||
quests,
|
||||
zones,
|
||||
adventurers,
|
||||
upgrades,
|
||||
equipment,
|
||||
achievements,
|
||||
} = state;
|
||||
|
||||
const bossesDefeated = bosses.filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).length;
|
||||
const questsCompleted = quests.filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).length;
|
||||
const zonesUnlocked = zones.filter((zone) => {
|
||||
return zone.status === "unlocked";
|
||||
}).length;
|
||||
const adventurersRecruited = adventurers.reduce((sum, adventurer) => {
|
||||
return sum + adventurer.count;
|
||||
}, 0);
|
||||
const equipmentOwned = equipment.filter((item) => {
|
||||
return item.owned;
|
||||
}).length;
|
||||
const upgradesPurchased = upgrades.filter((upgrade) => {
|
||||
return upgrade.purchased;
|
||||
}).length;
|
||||
const achievementsUnlocked = achievements.filter((achievement) => {
|
||||
return achievement.unlockedAt !== null;
|
||||
}).length;
|
||||
const prestigeUpgradesPurchased = prestige.purchasedUpgradeIds.length;
|
||||
|
||||
return (
|
||||
<section className="panel statistics-panel">
|
||||
<h2>{"📊 Statistics"}</h2>
|
||||
|
||||
<h3 className="stats-section-header">{"All-Time"}</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard
|
||||
icon="🪙"
|
||||
label="Total Gold Earned"
|
||||
sub="across all runs"
|
||||
value={formatNumber(player.totalGoldEarned)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="👆"
|
||||
label="Total Clicks"
|
||||
value={formatNumber(player.totalClicks)}
|
||||
/>
|
||||
<StatCard icon="⭐" label="Prestiges" value={String(prestige.count)} />
|
||||
<StatCard
|
||||
icon="📅"
|
||||
label="Guild Founded"
|
||||
value={formatDate(player.createdAt)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="☁️"
|
||||
label="Last Cloud Save"
|
||||
value={formatDate(player.lastSavedAt)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="✖️"
|
||||
label="Production Multiplier"
|
||||
sub="from prestige"
|
||||
value={`×${prestige.productionMultiplier.toFixed(2)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="stats-section-header">{"Current Run"}</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard icon="🪙" label="Gold" value={formatNumber(resources.gold)} />
|
||||
<StatCard
|
||||
icon="✨"
|
||||
label="Essence"
|
||||
value={formatNumber(resources.essence)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="💎"
|
||||
label="Crystals"
|
||||
value={formatNumber(resources.crystals)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔮"
|
||||
label="Runestones"
|
||||
sub="permanent currency"
|
||||
value={formatNumber(prestige.runestones)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="stats-section-header">{"Progress"}</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard
|
||||
icon="👹"
|
||||
label="Bosses Defeated"
|
||||
value={`${String(bossesDefeated)} / ${String(bosses.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="📜"
|
||||
label="Quests Completed"
|
||||
value={`${String(questsCompleted)} / ${String(quests.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🗺️"
|
||||
label="Zones Unlocked"
|
||||
value={`${String(zonesUnlocked)} / ${String(zones.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="⚔️"
|
||||
label="Adventurers Recruited"
|
||||
value={formatNumber(adventurersRecruited)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🗡️"
|
||||
label="Equipment Owned"
|
||||
value={`${String(equipmentOwned)} / ${String(equipment.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔧"
|
||||
label="Upgrades Purchased"
|
||||
value={`${String(upgradesPurchased)} / ${String(upgrades.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🏆"
|
||||
label="Achievements"
|
||||
value={`${String(achievementsUnlocked)} / ${String(achievements.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔮"
|
||||
label="Prestige Upgrades"
|
||||
value={`${String(prestigeUpgradesPurchased)} / ${String(PRESTIGE_UPGRADES.length)}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { StatisticsPanel };
|
||||
Reference in New Issue
Block a user