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,306 @@
|
||||
/**
|
||||
* @file Quest panel component for managing and completing quests.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths */
|
||||
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
||||
import { useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
import type { Quest } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Formats a duration in seconds to a human-readable string.
|
||||
* @param seconds - The total number of seconds to format.
|
||||
* @returns The formatted duration string.
|
||||
*/
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const secondsPerHour = 3600;
|
||||
const secondsPerMinute = 60;
|
||||
if (seconds >= secondsPerHour) {
|
||||
const hours = Math.floor(seconds / secondsPerHour);
|
||||
const remainderSeconds = seconds % secondsPerHour;
|
||||
const minutes = Math.floor(remainderSeconds / secondsPerMinute);
|
||||
return `${String(hours)}h ${String(minutes)}m`;
|
||||
}
|
||||
if (seconds >= secondsPerMinute) {
|
||||
const minutes = Math.floor(seconds / secondsPerMinute);
|
||||
const secs = seconds % secondsPerMinute;
|
||||
return `${String(minutes)}m ${String(secs)}s`;
|
||||
}
|
||||
return `${String(seconds)}s`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the time remaining for an active quest.
|
||||
* @param quest - The quest to check.
|
||||
* @returns The remaining seconds.
|
||||
*/
|
||||
const questTimeRemaining = (quest: Quest): number => {
|
||||
if (quest.status !== "active" || quest.startedAt === undefined) {
|
||||
return 0;
|
||||
}
|
||||
const elapsed = (Date.now() - quest.startedAt) / 1000;
|
||||
return Math.max(0, quest.durationSeconds - elapsed);
|
||||
};
|
||||
|
||||
interface QuestCardProperties {
|
||||
readonly quest: Quest;
|
||||
readonly partyCombatPower: number;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly zoneHint: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single quest card.
|
||||
* @param props - The quest card properties.
|
||||
* @param props.quest - The quest to display.
|
||||
* @param props.partyCombatPower - The current party's combat power.
|
||||
* @param props.unlockHint - Optional hint for how to unlock this quest.
|
||||
* @param props.zoneHint - Optional hint for which zone to unlock.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const QuestCard = ({
|
||||
quest,
|
||||
partyCombatPower,
|
||||
unlockHint,
|
||||
zoneHint,
|
||||
}: QuestCardProperties): JSX.Element => {
|
||||
const { startQuest, formatNumber } = useGame();
|
||||
const cpRequired = quest.combatPowerRequired ?? 0;
|
||||
const meetsCP = partyCombatPower >= cpRequired;
|
||||
|
||||
function handleStartQuest(): void {
|
||||
startQuest(quest.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`quest-card quest-${quest.status}`}>
|
||||
<div className="quest-info">
|
||||
<h3>{quest.name}</h3>
|
||||
<p>{quest.description}</p>
|
||||
{cpRequired > 0
|
||||
&& <p
|
||||
className={`quest-cp-requirement ${
|
||||
meetsCP
|
||||
? "cp-met"
|
||||
: "cp-unmet"
|
||||
}`}
|
||||
>
|
||||
{"⚔️ Requires "}
|
||||
{formatNumber(cpRequired)}
|
||||
{" Combat Power"}
|
||||
{quest.status === "available"
|
||||
&& (meetsCP
|
||||
? " ✓"
|
||||
: ` (you have ${formatNumber(partyCombatPower)})`)}
|
||||
</p>
|
||||
}
|
||||
<div className="quest-rewards">
|
||||
{quest.rewards.map((reward) => {
|
||||
return (
|
||||
<span className="reward-tag" key={`${reward.type}-${String(reward.amount ?? "")}`}>
|
||||
{reward.type === "gold"
|
||||
&& `🪙 ${formatNumber(reward.amount ?? 0)}`}
|
||||
{reward.type === "essence"
|
||||
&& `✨ ${formatNumber(reward.amount ?? 0)}`}
|
||||
{reward.type === "crystals"
|
||||
&& `💎 ${formatNumber(reward.amount ?? 0)}`}
|
||||
{reward.type === "upgrade" && "🔓 Upgrade"}
|
||||
{reward.type === "adventurer" && "👥 New Adventurer"}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="quest-action">
|
||||
{quest.status === "locked"
|
||||
&& <>
|
||||
<span className="quest-badge locked">{"🔒 Locked"}</span>
|
||||
{zoneHint === undefined
|
||||
? null
|
||||
: <p className="unlock-hint">
|
||||
{"🗺️ Unlock zone: "}
|
||||
{zoneHint}
|
||||
</p>
|
||||
}
|
||||
{zoneHint === undefined && unlockHint !== undefined
|
||||
? <p className="unlock-hint">
|
||||
{"📜 Complete: "}
|
||||
{unlockHint}
|
||||
</p>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
{quest.status === "available" && quest.lastFailedAt !== undefined
|
||||
&& <p className="quest-failed-hint">{"⚠️ Last attempt failed"}</p>
|
||||
}
|
||||
{quest.status === "available"
|
||||
&& <button
|
||||
className="start-quest-button"
|
||||
disabled={!meetsCP}
|
||||
onClick={handleStartQuest}
|
||||
title={
|
||||
meetsCP
|
||||
? undefined
|
||||
: `Need ${formatNumber(cpRequired)} combat power`
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{"Send Party ("}
|
||||
{formatDuration(quest.durationSeconds)}
|
||||
{")"}
|
||||
</button>
|
||||
}
|
||||
{quest.status === "active"
|
||||
&& <span className="quest-badge active">
|
||||
{"⏳ "}
|
||||
{formatDuration(Math.ceil(questTimeRemaining(quest)))}
|
||||
{" remaining"}
|
||||
</span>
|
||||
}
|
||||
{quest.status === "completed"
|
||||
&& <span className="quest-badge completed">{"✅ Complete"}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the quest panel with zone selection and quest list.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const QuestPanel = (): JSX.Element => {
|
||||
const { state, toggleAutoQuest } = useGame();
|
||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { adventurers, autoQuest, quests, zones } = state;
|
||||
// eslint-disable-next-line unicorn/no-array-reduce -- Need the total!
|
||||
const partyCombatPower = adventurers.reduce((total, adventurer) => {
|
||||
const power = total + adventurer.combatPower;
|
||||
return power * adventurer.count;
|
||||
}, 0);
|
||||
const zoneQuests = quests.filter(({ zoneId }) => {
|
||||
return zoneId === activeZoneId;
|
||||
});
|
||||
const lockedCount = zoneQuests.filter(({ status }) => {
|
||||
return status === "locked";
|
||||
}).length;
|
||||
const visibleQuests = showLocked
|
||||
? zoneQuests
|
||||
: zoneQuests.filter(({ status }) => {
|
||||
return status !== "locked";
|
||||
});
|
||||
|
||||
const questNameById = new Map(
|
||||
quests.map(({ id, name }) => {
|
||||
return [ id, name ];
|
||||
}),
|
||||
);
|
||||
const zoneById = new Map(
|
||||
zones.map((zone) => {
|
||||
return [ zone.id, zone ];
|
||||
}),
|
||||
);
|
||||
const questUnlockHints = new Map<string, string>();
|
||||
const questZoneHints = new Map<string, string>();
|
||||
for (const { id: questId, status, zoneId, prerequisiteIds } of quests) {
|
||||
if (status !== "locked") {
|
||||
continue;
|
||||
}
|
||||
const zone = zoneById.get(zoneId);
|
||||
if (zone?.status === "locked") {
|
||||
questZoneHints.set(questId, zone.name);
|
||||
} else if (prerequisiteIds.length > 0) {
|
||||
const [ prereqId ] = prerequisiteIds;
|
||||
if (prereqId !== undefined) {
|
||||
const prereqName = questNameById.get(prereqId);
|
||||
if (prereqName !== undefined) {
|
||||
questUnlockHints.set(questId, prereqName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
function handleAutoQuest(): void {
|
||||
toggleAutoQuest();
|
||||
}
|
||||
|
||||
const autoQuestOn = autoQuest === true;
|
||||
|
||||
return (
|
||||
<section className="panel quest-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Quests"}</h2>
|
||||
<div className="panel-header-controls">
|
||||
<button
|
||||
className={`auto-toggle-btn ${
|
||||
autoQuestOn
|
||||
? "auto-toggle-on"
|
||||
: "auto-toggle-off"
|
||||
}`}
|
||||
onClick={handleAutoQuest}
|
||||
title="Automatically send the party on the highest available quest"
|
||||
type="button"
|
||||
>
|
||||
{"🤖 Auto: "}
|
||||
{autoQuestOn
|
||||
? "ON"
|
||||
: "OFF"}
|
||||
</button>
|
||||
<LockToggle
|
||||
lockedCount={lockedCount}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
onSelectZone={setActiveZoneId}
|
||||
zones={zones}
|
||||
/>
|
||||
|
||||
<div className="quest-list">
|
||||
{visibleQuests.map((quest) => {
|
||||
return (
|
||||
<QuestCard
|
||||
key={quest.id}
|
||||
partyCombatPower={partyCombatPower}
|
||||
quest={quest}
|
||||
unlockHint={questUnlockHints.get(quest.id)}
|
||||
zoneHint={questZoneHints.get(quest.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{visibleQuests.length === 0
|
||||
&& <p className="empty-zone">{"No quests to show in this zone."}</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { QuestPanel };
|
||||
Reference in New Issue
Block a user