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>
217 lines
7.5 KiB
TypeScript
217 lines
7.5 KiB
TypeScript
/**
|
||
* @file Crafting panel component for crafting items from 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 max-nested-callbacks -- Nested recipe/material maps require nesting */
|
||
import { type JSX, useState } from "react";
|
||
import { useGame } from "../../context/gameContext.js";
|
||
import { MATERIALS } from "../../data/materials.js";
|
||
import { RECIPES } from "../../data/recipes.js";
|
||
import { ZoneSelector } from "./zoneSelector.js";
|
||
|
||
const bonusLabel: Record<string, string> = {
|
||
click_power: "👆 Click Power",
|
||
combat_power: "⚔️ Combat Power",
|
||
essence_income: "✨ Essence Income",
|
||
gold_income: "🪙 Gold Income",
|
||
};
|
||
|
||
/**
|
||
* Renders the crafting panel for crafting recipes from gathered materials.
|
||
* @returns The JSX element.
|
||
*/
|
||
const CraftingPanel = (): JSX.Element => {
|
||
const { state, craftRecipe, formatNumber } = useGame();
|
||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
||
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
|
||
|
||
if (state === null) {
|
||
return (
|
||
<section className="panel">
|
||
<p>{"Loading..."}</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const { zones, exploration: explorationState } = state;
|
||
const playerMaterials = explorationState?.materials ?? [];
|
||
const craftedIds = explorationState?.craftedRecipeIds ?? [];
|
||
|
||
const zoneRecipes = RECIPES.filter((recipe) => {
|
||
return recipe.zoneId === activeZoneId;
|
||
});
|
||
const zoneMaterials = MATERIALS.filter((material) => {
|
||
return material.zoneId === activeZoneId;
|
||
});
|
||
|
||
function getQuantity(materialId: string): number {
|
||
return (
|
||
playerMaterials.find((playerMaterial) => {
|
||
return playerMaterial.materialId === materialId;
|
||
})?.quantity ?? 0
|
||
);
|
||
}
|
||
|
||
function canAffordRecipe(recipeId: string): boolean {
|
||
const recipe = RECIPES.find((candidateRecipe) => {
|
||
return candidateRecipe.id === recipeId;
|
||
});
|
||
if (recipe === undefined) {
|
||
return false;
|
||
}
|
||
return recipe.requiredMaterials.every((request) => {
|
||
return getQuantity(request.materialId) >= request.quantity;
|
||
});
|
||
}
|
||
|
||
async function handleCraft(recipeId: string): Promise<void> {
|
||
setPendingRecipeId(recipeId);
|
||
try {
|
||
await craftRecipe(recipeId);
|
||
} finally {
|
||
setPendingRecipeId(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="panel crafting-panel">
|
||
<div className="panel-header">
|
||
<h2>{"⚗️ Crafting"}</h2>
|
||
</div>
|
||
|
||
<ZoneSelector
|
||
activeZoneId={activeZoneId}
|
||
onSelectZone={setActiveZoneId}
|
||
zones={zones}
|
||
/>
|
||
|
||
<div className="crafting-content">
|
||
<div className="materials-section">
|
||
<h3>{"📦 Materials"}</h3>
|
||
{zoneMaterials.length === 0
|
||
? <p className="empty-zone">{"No materials in this zone."}</p>
|
||
: <div className="materials-list">
|
||
{zoneMaterials.map((material) => {
|
||
const qty = getQuantity(material.id);
|
||
return (
|
||
<div
|
||
className={`material-card rarity-${material.rarity} ${
|
||
qty === 0
|
||
? "material-empty"
|
||
: ""
|
||
}`}
|
||
key={material.id}
|
||
>
|
||
<div className="material-info">
|
||
<span className="material-name">{material.name}</span>
|
||
<span className="material-rarity">{material.rarity}</span>
|
||
</div>
|
||
<span className="material-quantity">
|
||
{formatNumber(qty)}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<div className="recipes-section">
|
||
<h3>{"📜 Recipes"}</h3>
|
||
{zoneRecipes.length === 0
|
||
? <p className="empty-zone">{"No recipes in this zone."}</p>
|
||
: <div className="recipes-list">
|
||
{zoneRecipes.map((recipe) => {
|
||
const crafted = craftedIds.includes(recipe.id);
|
||
const affordable = canAffordRecipe(recipe.id);
|
||
const isPending = pendingRecipeId === recipe.id;
|
||
|
||
function handleCraftClick(): void {
|
||
void handleCraft(recipe.id);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={`recipe-card ${
|
||
crafted
|
||
? "recipe-crafted"
|
||
: ""
|
||
} ${!affordable && !crafted
|
||
? "recipe-unaffordable"
|
||
: ""}`}
|
||
key={recipe.id}
|
||
>
|
||
<div className="recipe-info">
|
||
<h4>{recipe.name}</h4>
|
||
<p className="recipe-description">{recipe.description}</p>
|
||
<div className="recipe-bonus">
|
||
<span className="bonus-label">
|
||
{bonusLabel[recipe.bonus.type] ?? recipe.bonus.type}
|
||
</span>
|
||
<span className="bonus-value">
|
||
{"×"}
|
||
{recipe.bonus.value.toFixed(2)}
|
||
</span>
|
||
</div>
|
||
<div className="recipe-requirements">
|
||
{recipe.requiredMaterials.map((request) => {
|
||
const have = getQuantity(request.materialId);
|
||
const enough = have >= request.quantity;
|
||
const matName
|
||
= MATERIALS.find((mat) => {
|
||
return mat.id === request.materialId;
|
||
})?.name ?? request.materialId;
|
||
return (
|
||
<span
|
||
className={`req-tag ${
|
||
enough
|
||
? "req-met"
|
||
: "req-missing"
|
||
}`}
|
||
key={request.materialId}
|
||
>
|
||
{matName}
|
||
{": "}
|
||
{formatNumber(have)}
|
||
{"/"}
|
||
{formatNumber(request.quantity)}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div className="recipe-action">
|
||
{crafted
|
||
? <span className="quest-badge active">
|
||
{"✅ Crafted"}
|
||
</span>
|
||
: <button
|
||
className="craft-button"
|
||
disabled={
|
||
!affordable || isPending || pendingRecipeId !== null
|
||
}
|
||
onClick={handleCraftClick}
|
||
type="button"
|
||
>
|
||
{isPending
|
||
? "Crafting..."
|
||
: "⚗️ Craft"}
|
||
</button>
|
||
}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
};
|
||
|
||
export { CraftingPanel };
|