generated from nhcarrigan/template
91c9f52daf
- Add 11 goddess panels (zones, bosses, quests, disciples, equipment, upgrades, consecration, enlightenment, crafting, exploration, achievements) - Wire all panels into gameLayout via mode/tab routing - Add goddess passive income, disciple tick, quest timers, zone/quest unlock logic, and achievement checking to the tick engine - Add goddess CSS variables, .goddess-mode overrides, 300ms fade transition, and full panel stylesheet coverage - Add 13 Goddess expansion entries to the How to Play guide - Add web-side data files for crafting recipes, exploration areas, materials
264 lines
9.0 KiB
TypeScript
264 lines
9.0 KiB
TypeScript
/**
|
||
* @file Goddess crafting panel component for crafting recipes from sacred 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 { GODDESS_RECIPES } from "../../data/goddessCraftingRecipes.js";
|
||
import { GODDESS_MATERIALS } from "../../data/goddessMaterials.js";
|
||
import { cdnImage } from "../../utils/cdn.js";
|
||
|
||
const bonusLabel: Record<string, string> = {
|
||
click_power: "👆 Click Power",
|
||
combat_power: "⚔️ Combat Power",
|
||
essence_income: "✨ Essence Income",
|
||
gold_income: "🪙 Gold Income",
|
||
};
|
||
|
||
/**
|
||
* Renders the goddess crafting panel for crafting recipes from sacred materials.
|
||
* @returns The JSX element.
|
||
*/
|
||
const GoddessCraftingPanel = (): JSX.Element => {
|
||
const { state, craftGoddessRecipe, formatNumber } = useGame();
|
||
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||
return (
|
||
sessionStorage.getItem("elysium_goddess_craft_zone")
|
||
?? "goddess_celestial_garden"
|
||
);
|
||
});
|
||
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
|
||
|
||
if (state === null) {
|
||
return (
|
||
<section className="panel">
|
||
<p>{"Loading..."}</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const { goddess } = state;
|
||
const playerMaterials = goddess?.exploration.materials ?? [];
|
||
const craftedIds = goddess?.exploration.craftedRecipeIds ?? [];
|
||
|
||
const goddessZones = goddess?.zones ?? [];
|
||
const zoneRecipes = GODDESS_RECIPES.filter((recipe) => {
|
||
return recipe.zoneId === activeZoneId;
|
||
});
|
||
const zoneMaterials = GODDESS_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 = GODDESS_RECIPES.find((candidateRecipe) => {
|
||
return candidateRecipe.id === recipeId;
|
||
});
|
||
if (recipe === undefined) {
|
||
return false;
|
||
}
|
||
return recipe.requiredMaterials.every((request) => {
|
||
return getQuantity(request.materialId) >= request.quantity;
|
||
});
|
||
}
|
||
|
||
function handleZoneSelect(zoneId: string): void {
|
||
setActiveZoneId(zoneId);
|
||
sessionStorage.setItem("elysium_goddess_craft_zone", zoneId);
|
||
}
|
||
|
||
async function handleCraft(recipeId: string): Promise<void> {
|
||
setPendingRecipeId(recipeId);
|
||
try {
|
||
await craftGoddessRecipe(recipeId);
|
||
} finally {
|
||
setPendingRecipeId(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="panel crafting-panel">
|
||
<div className="panel-header">
|
||
<h2>{"⚗️ Sacred Crafting"}</h2>
|
||
</div>
|
||
|
||
<div className="zone-selector">
|
||
{goddessZones.map((zone) => {
|
||
const isLocked = zone.status === "locked";
|
||
|
||
function handleZoneClick(): void {
|
||
handleZoneSelect(zone.id);
|
||
}
|
||
|
||
return (
|
||
<button
|
||
className={`zone-tab ${
|
||
activeZoneId === zone.id
|
||
? "zone-tab-active"
|
||
: ""
|
||
} ${isLocked
|
||
? "zone-tab-locked"
|
||
: ""}`}
|
||
disabled={isLocked}
|
||
key={zone.id}
|
||
onClick={handleZoneClick}
|
||
title={isLocked
|
||
? "Zone locked"
|
||
: zone.name}
|
||
type="button"
|
||
>
|
||
{zone.name}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="crafting-content">
|
||
<div className="materials-section">
|
||
<h3>{"📦 Sacred 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}
|
||
>
|
||
<img
|
||
alt={material.name}
|
||
className="card-thumbnail"
|
||
src={cdnImage("materials", 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>{"📜 Sacred 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}
|
||
>
|
||
<img
|
||
alt={recipe.name}
|
||
className="card-thumbnail"
|
||
src={cdnImage("recipes", 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
|
||
= GODDESS_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 { GoddessCraftingPanel };
|