feat: add batch size selector for hiring adventurers

Closes #12
This commit is contained in:
2026-03-07 13:35:18 -08:00
committed by Naomi Carrigan
parent cddf4fa692
commit 3856dda734
3 changed files with 99 additions and 14 deletions
@@ -12,20 +12,48 @@ const CLASS_ICONS: Record<string, string> = {
paladin: "🛡️", paladin: "🛡️",
}; };
const adventurerCost = (adventurer: Adventurer): number => type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
Math.ceil(10 * Math.pow(1.15, adventurer.count)); const BATCH_OPTIONS: BatchSize[] = [1, 5, 10, 25, 100, "max"];
const computeBatchCost = (adventurer: Adventurer, quantity: number): number => {
let total = 0;
for (let i = 0; i < quantity; i++) {
total += 10 * Math.pow(1.15, adventurer.count + i);
}
return total;
};
const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => {
let total = 0;
let quantity = 0;
for (let i = 0; i < 100_000; i++) {
const cost = 10 * Math.pow(1.15, adventurer.count + i);
if (total + cost > gold) break;
total += cost;
quantity++;
}
return quantity;
};
interface AdventurerCardProps { interface AdventurerCardProps {
adventurer: Adventurer; adventurer: Adventurer;
currentGold: number; currentGold: number;
batchSize: BatchSize;
unlockHint?: string | undefined; unlockHint?: string | undefined;
formatNumber: (n: number) => string; formatNumber: (n: number) => string;
} }
const AdventurerCard = ({ adventurer, currentGold, unlockHint, formatNumber }: AdventurerCardProps): React.JSX.Element => { const AdventurerCard = ({ adventurer, currentGold, batchSize, unlockHint, formatNumber }: AdventurerCardProps): React.JSX.Element => {
const { buyAdventurer } = useGame(); const { buyAdventurer } = useGame();
const cost = adventurerCost(adventurer);
const canAfford = currentGold >= cost; const resolvedQuantity =
batchSize === "max" ? computeMaxAffordable(adventurer, currentGold) : batchSize;
const cost = computeBatchCost(adventurer, resolvedQuantity);
const canAfford = resolvedQuantity > 0 && currentGold >= cost;
const handleBuy = (): void => {
buyAdventurer(adventurer.id, resolvedQuantity);
};
return ( return (
<div className={`adventurer-card ${!adventurer.unlocked ? "locked" : ""}`}> <div className={`adventurer-card ${!adventurer.unlocked ? "locked" : ""}`}>
@@ -41,10 +69,12 @@ const AdventurerCard = ({ adventurer, currentGold, unlockHint, formatNumber }: A
<button <button
className="buy-button" className="buy-button"
disabled={!canAfford || !adventurer.unlocked} disabled={!canAfford || !adventurer.unlocked}
onClick={() => { buyAdventurer(adventurer.id); }} onClick={handleBuy}
type="button" type="button"
> >
{adventurer.unlocked ? `🪙 ${formatNumber(cost)}` : "🔒 Locked"} {adventurer.unlocked
? `🪙 ${formatNumber(Math.ceil(cost))}${batchSize === "max" && resolvedQuantity > 0 ? ` (×${resolvedQuantity})` : ""}`
: "🔒 Locked"}
</button> </button>
{!adventurer.unlocked && unlockHint && ( {!adventurer.unlocked && unlockHint && (
<p className="unlock-hint">📜 Complete: {unlockHint}</p> <p className="unlock-hint">📜 Complete: {unlockHint}</p>
@@ -56,6 +86,7 @@ const AdventurerCard = ({ adventurer, currentGold, unlockHint, formatNumber }: A
export const AdventurerPanel = (): React.JSX.Element => { export const AdventurerPanel = (): React.JSX.Element => {
const { state, formatNumber } = useGame(); const { state, formatNumber } = useGame();
const [showLocked, setShowLocked] = useState(true); const [showLocked, setShowLocked] = useState(true);
const [batchSize, setBatchSize] = useState<BatchSize>(1);
if (!state) return <section className="panel"><p>Loading...</p></section>; if (!state) return <section className="panel"><p>Loading...</p></section>;
@@ -81,11 +112,24 @@ export const AdventurerPanel = (): React.JSX.Element => {
onToggle={() => { setShowLocked((v) => !v); }} onToggle={() => { setShowLocked((v) => !v); }}
/> />
</div> </div>
<div className="batch-selector">
{BATCH_OPTIONS.map((option) => (
<button
key={option}
className={`batch-button ${batchSize === option ? "active" : ""}`}
onClick={() => { setBatchSize(option); }}
type="button"
>
{option === "max" ? "xMax" : `x${option}`}
</button>
))}
</div>
<div className="adventurer-list"> <div className="adventurer-list">
{visible.map((adventurer) => ( {visible.map((adventurer) => (
<AdventurerCard <AdventurerCard
key={adventurer.id} key={adventurer.id}
adventurer={adventurer} adventurer={adventurer}
batchSize={batchSize}
currentGold={state.resources.gold} currentGold={state.resources.gold}
unlockHint={adventurerUnlockHints.get(adventurer.id)} unlockHint={adventurerUnlockHints.get(adventurer.id)}
formatNumber={formatNumber} formatNumber={formatNumber}
+18 -7
View File
@@ -39,8 +39,8 @@ interface GameContextValue {
error: string | null; error: string | null;
/** Click the crystal to earn gold */ /** Click the crystal to earn gold */
handleClick: () => void; handleClick: () => void;
/** Buy an adventurer */ /** Buy one or more of an adventurer tier; quantity > 1 buys in batch */
buyAdventurer: (adventurerId: string) => void; buyAdventurer: (adventurerId: string, quantity: number) => void;
/** Buy an upgrade */ /** Buy an upgrade */
buyUpgrade: (upgradeId: string) => void; buyUpgrade: (upgradeId: string) => void;
/** Purchase a buyable equipment item */ /** Purchase a buyable equipment item */
@@ -439,20 +439,31 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
}); });
}, []); }, []);
const buyAdventurer = useCallback((adventurerId: string) => { const buyAdventurer = useCallback((adventurerId: string, quantity: number) => {
setState((prev) => { setState((prev) => {
if (!prev) return prev; if (!prev) return prev;
const adventurer = prev.adventurers.find((a) => a.id === adventurerId); const adventurer = prev.adventurers.find((a) => a.id === adventurerId);
if (!adventurer || !adventurer.unlocked) return prev; if (!adventurer || !adventurer.unlocked) return prev;
const cost = 10 * Math.pow(1.15, adventurer.count); let gold = prev.resources.gold;
if (prev.resources.gold < cost) return prev; let count = adventurer.count;
let purchased = 0;
for (let i = 0; i < quantity; i++) {
const cost = 10 * Math.pow(1.15, count);
if (gold < cost) break;
gold -= cost;
count++;
purchased++;
}
if (purchased === 0) return prev;
return { return {
...prev, ...prev,
resources: { ...prev.resources, gold: prev.resources.gold - cost }, resources: { ...prev.resources, gold },
adventurers: prev.adventurers.map((a) => adventurers: prev.adventurers.map((a) =>
a.id === adventurerId ? { ...a, count: a.count + 1 } : a, a.id === adventurerId ? { ...a, count } : a,
), ),
}; };
}); });
+30
View File
@@ -248,6 +248,36 @@ body {
} }
/* ===================== ADVENTURERS ===================== */ /* ===================== ADVENTURERS ===================== */
.batch-selector {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.batch-button {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
color: var(--colour-text);
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
padding: 0.3rem 0.6rem;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.batch-button:hover {
border-color: var(--colour-accent);
color: var(--colour-accent);
}
.batch-button.active {
background: var(--colour-accent);
border-color: var(--colour-accent);
color: #fff;
}
.adventurer-list { .adventurer-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;