generated from nhcarrigan/template
feat: add exploration and crafting systems
Adds two new game systems: Exploration (scouts collect materials from timed area runs) and Crafting (combine materials into permanent multipliers). Includes 72 exploration areas, 54 materials, 36 recipes, and 108 new Codex lore entries. Removes unused characterName requirement from prestige/transcendence/apotheosis reset flows.
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import type { ExploreCollectResponse } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
||||
import { ZoneSelector } from "./ZoneSelector.js";
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds >= 86400) {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
||||
}
|
||||
if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||
if (seconds >= 60) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const ExplorationPanel = (): React.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) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const zones = state.zones ?? [];
|
||||
const explorationState = state.exploration;
|
||||
|
||||
const zoneAreas = EXPLORATION_AREAS.filter((a) => a.zoneId === activeZoneId);
|
||||
|
||||
const handleStart = async (areaId: string): Promise<void> => {
|
||||
setPendingAreaId(areaId);
|
||||
try {
|
||||
await startExploration(areaId);
|
||||
} finally {
|
||||
setPendingAreaId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCollect = async (areaId: string): Promise<void> => {
|
||||
setPendingAreaId(areaId);
|
||||
try {
|
||||
const result = await collectExploration(areaId);
|
||||
setLastResult({ areaId, response: result });
|
||||
} finally {
|
||||
setPendingAreaId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel exploration-panel">
|
||||
<div className="panel-header">
|
||||
<h2>🗺️ Exploration</h2>
|
||||
</div>
|
||||
|
||||
{lastResult && (
|
||||
<div className="exploration-result">
|
||||
<button
|
||||
className="exploration-result-close"
|
||||
onClick={() => { setLastResult(null); }}
|
||||
type="button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{lastResult.response.foundNothing ? (
|
||||
<p className="exploration-nothing">{lastResult.response.nothingMessage}</p>
|
||||
) : (
|
||||
<>
|
||||
{lastResult.response.event && (
|
||||
<p className="exploration-event-text">{lastResult.response.event.text}</p>
|
||||
)}
|
||||
<div className="exploration-rewards">
|
||||
{(lastResult.response.event?.goldChange ?? 0) !== 0 && (
|
||||
<span className={`reward-tag ${(lastResult.response.event?.goldChange ?? 0) > 0 ? "" : "negative"}`}>
|
||||
🪙 {(lastResult.response.event?.goldChange ?? 0) > 0 ? "+" : ""}{formatNumber(lastResult.response.event?.goldChange ?? 0)} gold
|
||||
</span>
|
||||
)}
|
||||
{(lastResult.response.event?.essenceChange ?? 0) > 0 && (
|
||||
<span className="reward-tag">
|
||||
✨ +{formatNumber(lastResult.response.event?.essenceChange ?? 0)} essence
|
||||
</span>
|
||||
)}
|
||||
{lastResult.response.event?.materialGained && (
|
||||
<span className="reward-tag material-tag">
|
||||
📦 +{lastResult.response.event.materialGained.quantity} {lastResult.response.event.materialGained.materialId.replace(/_/g, " ")} (event)
|
||||
</span>
|
||||
)}
|
||||
{lastResult.response.materialsFound.map((m) => (
|
||||
<span key={m.materialId} className="reward-tag material-tag">
|
||||
📦 +{m.quantity} {m.materialId.replace(/_/g, " ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
zones={zones}
|
||||
onSelectZone={(id) => { setActiveZoneId(id); setLastResult(null); }}
|
||||
/>
|
||||
|
||||
<div className="exploration-list">
|
||||
{zoneAreas.map((area) => {
|
||||
const areaState = explorationState?.areas.find((a) => a.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;
|
||||
|
||||
return (
|
||||
<div key={area.id} className={`exploration-card exploration-${status}`}>
|
||||
<div className="exploration-info">
|
||||
<h3>
|
||||
{area.name}
|
||||
{areaState?.completedOnce && <span className="exploration-discovered"> 📖</span>}
|
||||
</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}
|
||||
onClick={() => { void handleStart(area.id); }}
|
||||
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={() => { void handleCollect(area.id); }}
|
||||
type="button"
|
||||
>
|
||||
{isPending ? "Collecting..." : "📦 Collect Results"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{zoneAreas.length === 0 && (
|
||||
<p className="empty-zone">No exploration areas in this zone.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user