generated from nhcarrigan/template
29c817230d
## 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>
142 lines
4.4 KiB
TypeScript
142 lines
4.4 KiB
TypeScript
/**
|
|
* @file Daily challenge panel component showing today's challenges.
|
|
* @copyright nhcarrigan
|
|
* @license Naomi's Public License
|
|
* @author Naomi Carrigan
|
|
*/
|
|
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
|
import { useGame } from "../../context/gameContext.js";
|
|
import type { JSX } from "react";
|
|
|
|
/**
|
|
* Formats the time remaining until the daily reset.
|
|
* @returns The formatted time string.
|
|
*/
|
|
const formatTimeUntilReset = (): string => {
|
|
const now = new Date();
|
|
const nowAsPst = new Date(
|
|
now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" }),
|
|
);
|
|
const tomorrowMidnightPst = new Date(nowAsPst);
|
|
tomorrowMidnightPst.setDate(tomorrowMidnightPst.getDate() + 1);
|
|
tomorrowMidnightPst.setHours(0, 0, 0, 0);
|
|
const pstOffset = nowAsPst.getTime() - now.getTime();
|
|
const resetAt = new Date(tomorrowMidnightPst.getTime() - pstOffset);
|
|
const msRemaining = resetAt.getTime() - now.getTime();
|
|
const msPerHour = 1000 * 60 * 60;
|
|
const msPerMinute = 1000 * 60;
|
|
const hoursRemaining = Math.floor(msRemaining / msPerHour);
|
|
const msAfterHours = msRemaining % msPerHour;
|
|
const minutesRemaining = Math.floor(msAfterHours / msPerMinute);
|
|
return `${String(hoursRemaining)}h ${String(minutesRemaining)}m`;
|
|
};
|
|
|
|
/**
|
|
* Renders the daily challenge panel with progress tracking.
|
|
* @returns The JSX element.
|
|
*/
|
|
const DailyChallengePanel = (): JSX.Element => {
|
|
const { state, formatNumber } = useGame();
|
|
|
|
if (state === null) {
|
|
return (
|
|
<section className="panel">
|
|
<p>{"Loading..."}</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const { dailyChallenges } = state;
|
|
|
|
if (dailyChallenges === undefined) {
|
|
return (
|
|
<section className="panel daily-challenge-panel">
|
|
<h2>{"📅 Daily Challenges"}</h2>
|
|
<p className="daily-challenge-subtitle">
|
|
{"Load the game to generate today's challenges!"}
|
|
</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const completedCount = dailyChallenges.challenges.filter((challenge) => {
|
|
return challenge.completed;
|
|
}).length;
|
|
|
|
return (
|
|
<section className="panel daily-challenge-panel">
|
|
<h2>{"📅 Daily Challenges"}</h2>
|
|
<div className="daily-challenge-header">
|
|
<p className="daily-challenge-subtitle">
|
|
{"Complete challenges for bonus 💎 crystals! Resets in "}
|
|
<strong>{formatTimeUntilReset()}</strong>
|
|
{" (PST midnight)."}
|
|
</p>
|
|
<p className="daily-challenge-progress">
|
|
{completedCount}
|
|
{" / "}
|
|
{dailyChallenges.challenges.length}
|
|
{" completed"}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="daily-challenge-list">
|
|
{dailyChallenges.challenges.map((challenge) => {
|
|
const progressScaled = challenge.progress * 100;
|
|
const progressPercent = Math.min(
|
|
100,
|
|
Math.floor(progressScaled / challenge.target),
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={`daily-challenge-card ${
|
|
challenge.completed
|
|
? "completed"
|
|
: ""
|
|
}`}
|
|
key={challenge.id}
|
|
>
|
|
<div className="daily-challenge-info">
|
|
<h3 className="daily-challenge-label">{challenge.label}</h3>
|
|
<p className="daily-challenge-reward">
|
|
{"Reward: "}
|
|
<strong>
|
|
{"💎 "}
|
|
{formatNumber(challenge.rewardCrystals)}
|
|
{" crystals"}
|
|
</strong>
|
|
</p>
|
|
</div>
|
|
|
|
<div className="daily-challenge-right">
|
|
{challenge.completed
|
|
? <span className="daily-challenge-done">
|
|
{"✅ Complete!"}
|
|
</span>
|
|
|
|
: <>
|
|
<p className="daily-challenge-count">
|
|
{formatNumber(challenge.progress)}
|
|
{" / "}
|
|
{formatNumber(challenge.target)}
|
|
</p>
|
|
<div className="daily-challenge-bar-track">
|
|
<div
|
|
className="daily-challenge-bar-fill"
|
|
style={{ width: `${String(progressPercent)}%` }}
|
|
/>
|
|
</div>
|
|
</>
|
|
}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export { DailyChallengePanel };
|