generated from nhcarrigan/template
feat: v1 prototype — core game systems #30
@@ -12,20 +12,48 @@ const CLASS_ICONS: Record<string, string> = {
|
||||
paladin: "🛡️",
|
||||
};
|
||||
|
||||
const adventurerCost = (adventurer: Adventurer): number =>
|
||||
Math.ceil(10 * Math.pow(1.15, adventurer.count));
|
||||
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
|
||||
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 {
|
||||
adventurer: Adventurer;
|
||||
currentGold: number;
|
||||
batchSize: BatchSize;
|
||||
unlockHint?: string | undefined;
|
||||
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 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 (
|
||||
<div className={`adventurer-card ${!adventurer.unlocked ? "locked" : ""}`}>
|
||||
@@ -41,10 +69,12 @@ const AdventurerCard = ({ adventurer, currentGold, unlockHint, formatNumber }: A
|
||||
<button
|
||||
className="buy-button"
|
||||
disabled={!canAfford || !adventurer.unlocked}
|
||||
onClick={() => { buyAdventurer(adventurer.id); }}
|
||||
onClick={handleBuy}
|
||||
type="button"
|
||||
>
|
||||
{adventurer.unlocked ? `🪙 ${formatNumber(cost)}` : "🔒 Locked"}
|
||||
{adventurer.unlocked
|
||||
? `🪙 ${formatNumber(Math.ceil(cost))}${batchSize === "max" && resolvedQuantity > 0 ? ` (×${resolvedQuantity})` : ""}`
|
||||
: "🔒 Locked"}
|
||||
</button>
|
||||
{!adventurer.unlocked && unlockHint && (
|
||||
<p className="unlock-hint">📜 Complete: {unlockHint}</p>
|
||||
@@ -56,6 +86,7 @@ const AdventurerCard = ({ adventurer, currentGold, unlockHint, formatNumber }: A
|
||||
export const AdventurerPanel = (): React.JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [showLocked, setShowLocked] = useState(true);
|
||||
const [batchSize, setBatchSize] = useState<BatchSize>(1);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
@@ -81,11 +112,24 @@ export const AdventurerPanel = (): React.JSX.Element => {
|
||||
onToggle={() => { setShowLocked((v) => !v); }}
|
||||
/>
|
||||
</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">
|
||||
{visible.map((adventurer) => (
|
||||
<AdventurerCard
|
||||
key={adventurer.id}
|
||||
adventurer={adventurer}
|
||||
batchSize={batchSize}
|
||||
currentGold={state.resources.gold}
|
||||
unlockHint={adventurerUnlockHints.get(adventurer.id)}
|
||||
formatNumber={formatNumber}
|
||||
|
||||
@@ -39,8 +39,8 @@ interface GameContextValue {
|
||||
error: string | null;
|
||||
/** Click the crystal to earn gold */
|
||||
handleClick: () => void;
|
||||
/** Buy an adventurer */
|
||||
buyAdventurer: (adventurerId: string) => void;
|
||||
/** Buy one or more of an adventurer tier; quantity > 1 buys in batch */
|
||||
buyAdventurer: (adventurerId: string, quantity: number) => void;
|
||||
/** Buy an upgrade */
|
||||
buyUpgrade: (upgradeId: string) => void;
|
||||
/** 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) => {
|
||||
if (!prev) return prev;
|
||||
const adventurer = prev.adventurers.find((a) => a.id === adventurerId);
|
||||
if (!adventurer || !adventurer.unlocked) return prev;
|
||||
|
||||
const cost = 10 * Math.pow(1.15, adventurer.count);
|
||||
if (prev.resources.gold < cost) return prev;
|
||||
let gold = prev.resources.gold;
|
||||
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 {
|
||||
...prev,
|
||||
resources: { ...prev.resources, gold: prev.resources.gold - cost },
|
||||
resources: { ...prev.resources, gold },
|
||||
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 ===================== */
|
||||
.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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user