generated from nhcarrigan/template
11e97325cb
## Summary - Adds `apps/web/src/utils/cdn.ts` with a `cdnImage(folder, id)` helper that builds URLs from `https://cdn.nhcarrigan.com/elysium/` - Wires CDN art into all 13 game panels (bosses, quests, adventurers, companions, equipment, upgrades, prestige, transcendence, achievements, explorations, crafting, story, codex) - Zone selector tabs now display 16:9 zone art thumbnails in place of emoji icons - Adds a fixed background image at 15% opacity via `body::before` - Documents the art generation and CDN upload process in `CLAUDE.md` for future expansions Resolves #15 ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #43 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
/**
|
|
* @file Exploration panel component for exploring areas and collecting materials.
|
|
* @copyright nhcarrigan
|
|
* @license Naomi's Public License
|
|
* @author Naomi Carrigan
|
|
*/
|
|
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
|
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
|
import { type JSX, useState } from "react";
|
|
import { useGame } from "../../context/gameContext.js";
|
|
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
|
import { cdnImage } from "../../utils/cdn.js";
|
|
import { ZoneSelector } from "./zoneSelector.js";
|
|
import type { ExploreCollectResponse } 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 secondsPerDay = 86_400;
|
|
const secondsPerHour = 3600;
|
|
const secondsPerMinute = 60;
|
|
if (seconds >= secondsPerDay) {
|
|
const days = Math.floor(seconds / secondsPerDay);
|
|
const remainingAfterDays = seconds % secondsPerDay;
|
|
const hours = Math.floor(remainingAfterDays / secondsPerHour);
|
|
return hours > 0
|
|
? `${String(days)}d ${String(hours)}h`
|
|
: `${String(days)}d`;
|
|
}
|
|
if (seconds >= secondsPerHour) {
|
|
const hours = Math.floor(seconds / secondsPerHour);
|
|
const remainingAfterHours = seconds % secondsPerHour;
|
|
const minutes = Math.floor(remainingAfterHours / 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 exploration in progress.
|
|
* @param startedAt - The timestamp when exploration started.
|
|
* @param durationSeconds - The total duration in seconds.
|
|
* @returns The remaining seconds.
|
|
*/
|
|
const timeRemaining = (startedAt: number, durationSeconds: number): number => {
|
|
const elapsed = (Date.now() - startedAt) / 1000;
|
|
return Math.max(0, durationSeconds - elapsed);
|
|
};
|
|
|
|
interface CollectResult {
|
|
areaId: string;
|
|
response: ExploreCollectResponse;
|
|
}
|
|
|
|
/**
|
|
* Renders the exploration panel for managing area explorations.
|
|
* @returns The JSX element.
|
|
*/
|
|
const ExplorationPanel = (): JSX.Element => {
|
|
const { state, startExploration, collectExploration, formatNumber }
|
|
= useGame();
|
|
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
|
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
|
|
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
|
|
|
|
if (state === null) {
|
|
return (
|
|
<section className="panel">
|
|
<p>{"Loading..."}</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const { zones, exploration: explorationState } = state;
|
|
|
|
const zoneAreas = EXPLORATION_AREAS.filter((area) => {
|
|
return area.zoneId === activeZoneId;
|
|
});
|
|
|
|
const hasActiveExploration
|
|
= explorationState?.areas.some((area) => {
|
|
return area.status === "in_progress";
|
|
}) ?? false;
|
|
|
|
async function handleStart(areaId: string): Promise<void> {
|
|
setPendingAreaId(areaId);
|
|
try {
|
|
await startExploration(areaId);
|
|
} finally {
|
|
setPendingAreaId(null);
|
|
}
|
|
}
|
|
|
|
async function handleCollect(areaId: string): Promise<void> {
|
|
setPendingAreaId(areaId);
|
|
try {
|
|
const result = await collectExploration(areaId);
|
|
setLastResult({ areaId: areaId, response: result });
|
|
} finally {
|
|
setPendingAreaId(null);
|
|
}
|
|
}
|
|
|
|
function handleDismissResult(): void {
|
|
setLastResult(null);
|
|
}
|
|
|
|
function handleZoneSelect(id: string): void {
|
|
setActiveZoneId(id);
|
|
setLastResult(null);
|
|
}
|
|
|
|
const goldChange = lastResult?.response.event?.goldChange ?? 0;
|
|
const essenceChange = lastResult?.response.event?.essenceChange ?? 0;
|
|
|
|
return (
|
|
<section className="panel exploration-panel">
|
|
<div className="panel-header">
|
|
<h2>{"🗺️ Exploration"}</h2>
|
|
</div>
|
|
|
|
{lastResult === null
|
|
? null
|
|
: <div className="exploration-result">
|
|
<button
|
|
className="exploration-result-close"
|
|
onClick={handleDismissResult}
|
|
type="button"
|
|
>
|
|
{"✕"}
|
|
</button>
|
|
{lastResult.response.foundNothing
|
|
? <p className="exploration-nothing">
|
|
{lastResult.response.nothingMessage}
|
|
</p>
|
|
: <>
|
|
{lastResult.response.event === null
|
|
? null
|
|
: <p className="exploration-event-text">
|
|
{lastResult.response.event.text}
|
|
</p>
|
|
}
|
|
<div className="exploration-rewards">
|
|
{goldChange !== 0
|
|
&& <span
|
|
className={`reward-tag ${goldChange > 0
|
|
? ""
|
|
: "negative"}`}
|
|
>
|
|
{"🪙 "}
|
|
{goldChange > 0
|
|
? "+"
|
|
: ""}
|
|
{formatNumber(goldChange)}
|
|
{" gold"}
|
|
</span>
|
|
}
|
|
{essenceChange > 0
|
|
&& <span className="reward-tag">
|
|
{"✨ +"}
|
|
{formatNumber(essenceChange)}
|
|
{" essence"}
|
|
</span>
|
|
}
|
|
{lastResult.response.event?.materialGained !== null
|
|
&& lastResult.response.event?.materialGained !== undefined
|
|
? <span className="reward-tag material-tag">
|
|
{"📦 +"}
|
|
{lastResult.response.event.materialGained.quantity}{" "}
|
|
{/* eslint-disable-next-line stylistic/max-len -- long property chain cannot be shortened */}
|
|
{lastResult.response.event.materialGained.materialId.replaceAll(
|
|
"_",
|
|
" ",
|
|
)}
|
|
{" (event)"}
|
|
</span>
|
|
: null}
|
|
{lastResult.response.materialsFound.map((foundMaterial) => {
|
|
return (
|
|
<span
|
|
className="reward-tag material-tag"
|
|
key={foundMaterial.materialId}
|
|
>
|
|
{"📦 +"}
|
|
{foundMaterial.quantity}{" "}
|
|
{foundMaterial.materialId.replaceAll("_", " ")}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
<ZoneSelector
|
|
activeZoneId={activeZoneId}
|
|
onSelectZone={handleZoneSelect}
|
|
zones={zones}
|
|
/>
|
|
|
|
<div className="exploration-list">
|
|
{zoneAreas.map((area) => {
|
|
const areaState = explorationState?.areas.find((explorationArea) => {
|
|
return explorationArea.id === area.id;
|
|
});
|
|
const status = areaState?.status ?? "locked";
|
|
const startedAt = areaState?.startedAt ?? 0;
|
|
const isReady
|
|
= status === "in_progress"
|
|
&& timeRemaining(startedAt, area.durationSeconds) <= 0;
|
|
const isPending = pendingAreaId === area.id;
|
|
|
|
function handleStartClick(): void {
|
|
void handleStart(area.id);
|
|
}
|
|
function handleCollectClick(): void {
|
|
void handleCollect(area.id);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`exploration-card exploration-${status}`}
|
|
key={area.id}
|
|
>
|
|
<img
|
|
alt={area.name}
|
|
className="card-thumbnail"
|
|
src={cdnImage("explorations", area.id)}
|
|
/>
|
|
<div className="exploration-info">
|
|
<h3>
|
|
{area.name}
|
|
{areaState?.completedOnce === true
|
|
? <span className="exploration-discovered">{" 📖"}</span>
|
|
: null}
|
|
</h3>
|
|
<p>{area.description}</p>
|
|
<span className="exploration-duration">
|
|
{"⏱️ "}
|
|
{formatDuration(area.durationSeconds)}
|
|
</span>
|
|
</div>
|
|
<div className="exploration-action">
|
|
{status === "locked"
|
|
&& <span className="quest-badge locked">{"🔒 Locked"}</span>
|
|
}
|
|
{status === "available"
|
|
&& <button
|
|
className="start-quest-button"
|
|
disabled={isPending || hasActiveExploration}
|
|
onClick={handleStartClick}
|
|
title={
|
|
hasActiveExploration
|
|
? "An exploration is already in progress"
|
|
: undefined
|
|
}
|
|
type="button"
|
|
>
|
|
{isPending
|
|
? "Departing..."
|
|
: `Explore (${formatDuration(area.durationSeconds)})`}
|
|
</button>
|
|
}
|
|
{status === "in_progress" && !isReady
|
|
&& <span className="quest-badge active">
|
|
{"⏳ "}
|
|
{formatDuration(
|
|
Math.ceil(timeRemaining(startedAt, area.durationSeconds)),
|
|
)}
|
|
{" remaining"}
|
|
</span>
|
|
}
|
|
{status === "in_progress" && isReady
|
|
? <button
|
|
className="collect-button"
|
|
disabled={isPending}
|
|
onClick={handleCollectClick}
|
|
type="button"
|
|
>
|
|
{isPending
|
|
? "Collecting..."
|
|
: "📦 Collect Results"}
|
|
</button>
|
|
: null}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{zoneAreas.length === 0
|
|
&& <p className="empty-zone">
|
|
{"No exploration areas in this zone."}
|
|
</p>
|
|
}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export { ExplorationPanel };
|