generated from nhcarrigan/template
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user