generated from nhcarrigan/template
feat: vampire boss panel and thralls panel with combat simulation
Implements VampireBossPanel (zone filtering, HP bar, battle modal with rewards/casualties) and VampireThrallsPanel (batch buy with geometric cost scaling). Wires challengeVampireBoss, dismissVampireBattle, and buyVampireThrall into GameContext with correct sort-key ordering.
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* @file Thralls panel component for purchasing vampire thralls.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable react/no-multi-comp -- ThrallCard sub-component is tightly coupled */
|
||||
/* eslint-disable complexity -- ThrallCard has inherent branching for batch/afford logic */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { VampireThrall } from "@elysium/types";
|
||||
|
||||
type BatchSize = 1 | 10 | "max";
|
||||
const batchOptions: Array<BatchSize> = [ 1, 10, "max" ];
|
||||
|
||||
const growthRate = 1.15;
|
||||
|
||||
/**
|
||||
* Computes the total blood cost to buy a batch of thralls.
|
||||
* @param thrall - The thrall tier to purchase.
|
||||
* @param quantity - The number to buy.
|
||||
* @returns The total blood cost.
|
||||
*/
|
||||
const computeBatchCost = (
|
||||
thrall: VampireThrall,
|
||||
quantity: number,
|
||||
): number => {
|
||||
let total = 0;
|
||||
for (let index = 0; index < quantity; index = index + 1) {
|
||||
const exponent = thrall.count + index;
|
||||
const cost = thrall.baseCost * Math.pow(growthRate, exponent);
|
||||
total = total + cost;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the maximum number of thralls affordable with the available blood.
|
||||
* @param thrall - The thrall tier.
|
||||
* @param blood - The available blood balance.
|
||||
* @returns The maximum affordable quantity.
|
||||
*/
|
||||
const computeMaxAffordable = (
|
||||
thrall: VampireThrall,
|
||||
blood: number,
|
||||
): number => {
|
||||
let total = 0;
|
||||
let quantity = 0;
|
||||
for (let index = 0; index < 100_000; index = index + 1) {
|
||||
const exponent = thrall.count + index;
|
||||
const cost = thrall.baseCost * Math.pow(growthRate, exponent);
|
||||
if (total + cost > blood) {
|
||||
break;
|
||||
}
|
||||
total = total + cost;
|
||||
quantity = quantity + 1;
|
||||
}
|
||||
return quantity;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a localStorage string back into a valid BatchSize, defaulting to 1.
|
||||
* @param stored - The raw string from localStorage (or null if absent).
|
||||
* @returns A valid BatchSize value.
|
||||
*/
|
||||
const parseBatchSize = (stored: string | null): BatchSize => {
|
||||
if (stored === "max") {
|
||||
return "max";
|
||||
}
|
||||
if (stored === "10") {
|
||||
return 10;
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
interface ThrallCardProperties {
|
||||
readonly thrall: VampireThrall;
|
||||
readonly blood: number;
|
||||
readonly selectedBatch: BatchSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single thrall purchase card.
|
||||
* @param props - The component properties.
|
||||
* @param props.thrall - The thrall tier to display.
|
||||
* @param props.blood - The player's current blood balance.
|
||||
* @param props.selectedBatch - The active batch size selection.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ThrallCard = ({
|
||||
thrall,
|
||||
blood,
|
||||
selectedBatch,
|
||||
}: ThrallCardProperties): JSX.Element => {
|
||||
const { buyVampireThrall, formatNumber } = useGame();
|
||||
|
||||
const maxAffordable = computeMaxAffordable(thrall, blood);
|
||||
const effectiveBatch = selectedBatch === "max"
|
||||
? maxAffordable
|
||||
: selectedBatch;
|
||||
const batchCost = computeBatchCost(thrall, effectiveBatch);
|
||||
const canAffordBatch = blood >= batchCost && effectiveBatch > 0;
|
||||
|
||||
const singleCost = computeBatchCost(thrall, 1);
|
||||
|
||||
function handleBuy(): void {
|
||||
if (effectiveBatch > 0) {
|
||||
buyVampireThrall(thrall.id, effectiveBatch);
|
||||
}
|
||||
}
|
||||
|
||||
function getBuyButtonLabel(): string {
|
||||
if (selectedBatch === "max") {
|
||||
if (maxAffordable === 0) {
|
||||
return "Can't Afford";
|
||||
}
|
||||
return `Buy Max (×${String(maxAffordable)})`;
|
||||
}
|
||||
return `Buy ×${String(effectiveBatch)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`disciple-card ${thrall.unlocked
|
||||
? ""
|
||||
: "disciple-locked"}`}>
|
||||
<div className="disciple-header">
|
||||
<div className="disciple-title">
|
||||
<h3>{thrall.name}</h3>
|
||||
<span className="disciple-class">{thrall.class}</span>
|
||||
</div>
|
||||
<span className="disciple-count">
|
||||
{"×"}
|
||||
{formatNumber(thrall.count)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="disciple-income">
|
||||
{thrall.bloodPerSecond > 0
|
||||
&& <span className="income-tag">
|
||||
{"🩸 "}
|
||||
{formatNumber(thrall.bloodPerSecond)}
|
||||
{"/s blood"}
|
||||
</span>
|
||||
}
|
||||
{thrall.ichorPerSecond > 0
|
||||
&& <span className="income-tag">
|
||||
{"💧 "}
|
||||
{formatNumber(thrall.ichorPerSecond)}
|
||||
{"/s ichor"}
|
||||
</span>
|
||||
}
|
||||
<span className="combat-power-tag">
|
||||
{"⚔️ "}
|
||||
{formatNumber(thrall.combatPower)}
|
||||
{" combat power each"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="disciple-cost">
|
||||
<span className="cost-label">
|
||||
{"Next: 🩸 "}
|
||||
{formatNumber(singleCost)}
|
||||
</span>
|
||||
{selectedBatch !== 1
|
||||
&& effectiveBatch > 0
|
||||
&& <span className="cost-label">
|
||||
{selectedBatch === "max"
|
||||
? "Max"
|
||||
: String(selectedBatch)}
|
||||
{" (×"}
|
||||
{String(effectiveBatch)}
|
||||
{"): 🩸 "}
|
||||
{formatNumber(batchCost)}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
{thrall.unlocked
|
||||
? <button
|
||||
className="buy-disciple-button"
|
||||
disabled={!canAffordBatch}
|
||||
onClick={handleBuy}
|
||||
title={
|
||||
canAffordBatch
|
||||
? undefined
|
||||
: `Need 🩸 ${formatNumber(batchCost)} blood`
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{getBuyButtonLabel()}
|
||||
</button>
|
||||
: <span className="disciple-badge locked">{"🔒 Locked"}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the thralls panel for purchasing vampire thralls.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireThrallsPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [ selectedBatch, setSelectedBatch ] = useState<BatchSize>(() => {
|
||||
return parseBatchSize(localStorage.getItem("elysium_thrall_batch"));
|
||||
});
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const vampireState = state.vampire;
|
||||
if (vampireState === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Vampire expansion not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const blood = state.resources.blood ?? 0;
|
||||
const { thralls } = vampireState;
|
||||
|
||||
function handleBatchSelect(batch: BatchSize): void {
|
||||
setSelectedBatch(batch);
|
||||
localStorage.setItem("elysium_thrall_batch", String(batch));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel disciples-panel">
|
||||
<h2>{"Thralls"}</h2>
|
||||
<div className="disciples-balance">
|
||||
<span>
|
||||
{"🩸 Blood: "}
|
||||
<strong>{formatNumber(blood)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className="batch-selector">
|
||||
{batchOptions.map((batch) => {
|
||||
function handleClick(): void {
|
||||
handleBatchSelect(batch);
|
||||
}
|
||||
return <button
|
||||
className={`batch-button ${selectedBatch === batch
|
||||
? "active"
|
||||
: ""}`}
|
||||
key={String(batch)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
>
|
||||
{batch === "max"
|
||||
? "Max"
|
||||
: `×${String(batch)}`}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
<div className="disciples-list">
|
||||
{thralls.map((thrall: VampireThrall) => {
|
||||
return <ThrallCard
|
||||
blood={blood}
|
||||
key={thrall.id}
|
||||
selectedBatch={selectedBatch}
|
||||
thrall={thrall}
|
||||
/>;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { VampireThrallsPanel };
|
||||
Reference in New Issue
Block a user