generated from nhcarrigan/template
feat: major content expansion with essence and crystal sinks
- 6 zones (up from 3): Shadow Marshes, Volcanic Depths, Astral Void - 18 bosses (up from 4): 3 per zone with zone-based sequential unlock - 24 quests (up from 9): 3-4 per zone, reorganised by zone - 15 adventurer tiers (up from 10): Shadow Assassin through Divine Champion - 28 equipment pieces (up from 12): boss drops + purchasable with essence/crystals - 24 upgrades (up from 13): crystal-cost upgrades + new adventurer upgrades - 22 achievements (up from 14): new milestones for bosses, quests, gold, armies - Purchasable equipment system: buy items directly with essence or crystals - Crystal-cost upgrades: spend crystals on global and click power boosts - Zone-based boss progression: defeating a zone's last boss unlocks the next zone
This commit is contained in:
@@ -32,10 +32,25 @@ const bonusDescription = (item: Equipment): string => {
|
||||
|
||||
interface EquipmentCardProps {
|
||||
item: Equipment;
|
||||
gold: number;
|
||||
essence: number;
|
||||
crystals: number;
|
||||
}
|
||||
|
||||
const EquipmentCard = ({ item }: EquipmentCardProps): React.JSX.Element => {
|
||||
const { equipItem } = useGame();
|
||||
const costLabel = (cost: { gold: number; essence: number; crystals: number }): string => {
|
||||
const parts: string[] = [];
|
||||
if (cost.gold > 0) parts.push(`🪙 ${cost.gold.toLocaleString()}`);
|
||||
if (cost.essence > 0) parts.push(`✨ ${cost.essence.toLocaleString()}`);
|
||||
if (cost.crystals > 0) parts.push(`💎 ${cost.crystals.toLocaleString()}`);
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
const EquipmentCard = ({ item, gold, essence, crystals }: EquipmentCardProps): React.JSX.Element => {
|
||||
const { equipItem, buyEquipment } = useGame();
|
||||
|
||||
const canAfford = item.cost
|
||||
? gold >= item.cost.gold && essence >= item.cost.essence && crystals >= item.cost.crystals
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className={`equipment-card rarity-${item.rarity} ${item.equipped ? "equipped" : ""} ${!item.owned ? "not-owned" : ""}`}>
|
||||
@@ -47,9 +62,22 @@ const EquipmentCard = ({ item }: EquipmentCardProps): React.JSX.Element => {
|
||||
</div>
|
||||
<p className="equipment-description">{item.description}</p>
|
||||
<p className="equipment-bonus">{bonusDescription(item)}</p>
|
||||
{!item.owned && item.cost && (
|
||||
<p className="equipment-cost">{costLabel(item.cost)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="equipment-action">
|
||||
{!item.owned && <span className="equipment-locked">🔒 Not yet obtained</span>}
|
||||
{!item.owned && !item.cost && <span className="equipment-locked">🔒 Boss drop</span>}
|
||||
{!item.owned && item.cost && (
|
||||
<button
|
||||
className="equip-button"
|
||||
disabled={!canAfford}
|
||||
onClick={() => { buyEquipment(item.id); }}
|
||||
type="button"
|
||||
>
|
||||
{canAfford ? "Purchase" : "Can't afford"}
|
||||
</button>
|
||||
)}
|
||||
{item.owned && item.equipped && <span className="equipment-equipped-badge">✓ Equipped</span>}
|
||||
{item.owned && !item.equipped && (
|
||||
<button
|
||||
@@ -104,7 +132,13 @@ export const EquipmentPanel = (): React.JSX.Element => {
|
||||
<h3 className="slot-heading">{SLOT_LABEL[slotType]}</h3>
|
||||
<div className="equipment-list">
|
||||
{items.map((item) => (
|
||||
<EquipmentCard key={item.id} item={item} />
|
||||
<EquipmentCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
gold={state.resources.gold}
|
||||
essence={state.resources.essence}
|
||||
crystals={state.resources.crystals}
|
||||
/>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="empty-zone">No items to show in this slot.</p>
|
||||
|
||||
@@ -7,12 +7,15 @@ interface UpgradeCardProps {
|
||||
upgrade: Upgrade;
|
||||
currentGold: number;
|
||||
currentEssence: number;
|
||||
currentCrystals: number;
|
||||
}
|
||||
|
||||
const UpgradeCard = ({ upgrade, currentGold, currentEssence }: UpgradeCardProps): React.JSX.Element => {
|
||||
const UpgradeCard = ({ upgrade, currentGold, currentEssence, currentCrystals }: UpgradeCardProps): React.JSX.Element => {
|
||||
const { buyUpgrade } = useGame();
|
||||
const canAfford =
|
||||
currentGold >= upgrade.costGold && currentEssence >= upgrade.costEssence;
|
||||
currentGold >= upgrade.costGold &&
|
||||
currentEssence >= upgrade.costEssence &&
|
||||
currentCrystals >= (upgrade.costCrystals ?? 0);
|
||||
|
||||
if (!upgrade.unlocked) {
|
||||
return (
|
||||
@@ -25,6 +28,7 @@ const UpgradeCard = ({ upgrade, currentGold, currentEssence }: UpgradeCardProps)
|
||||
<div className="upgrade-cost">
|
||||
{upgrade.costGold > 0 && <span>🪙 {upgrade.costGold.toLocaleString()}</span>}
|
||||
{upgrade.costEssence > 0 && <span>✨ {upgrade.costEssence.toLocaleString()}</span>}
|
||||
{(upgrade.costCrystals ?? 0) > 0 && <span>💎 {upgrade.costCrystals?.toLocaleString()}</span>}
|
||||
</div>
|
||||
<span className="upgrade-locked-label">Locked</span>
|
||||
</div>
|
||||
@@ -50,6 +54,7 @@ const UpgradeCard = ({ upgrade, currentGold, currentEssence }: UpgradeCardProps)
|
||||
<div className="upgrade-cost">
|
||||
{upgrade.costGold > 0 && <span>🪙 {upgrade.costGold.toLocaleString()}</span>}
|
||||
{upgrade.costEssence > 0 && <span>✨ {upgrade.costEssence.toLocaleString()}</span>}
|
||||
{(upgrade.costCrystals ?? 0) > 0 && <span>💎 {upgrade.costCrystals?.toLocaleString()}</span>}
|
||||
</div>
|
||||
<button
|
||||
className="buy-button"
|
||||
@@ -94,6 +99,7 @@ export const UpgradePanel = (): React.JSX.Element => {
|
||||
upgrade={upgrade}
|
||||
currentGold={state.resources.gold}
|
||||
currentEssence={state.resources.essence}
|
||||
currentCrystals={state.resources.crystals}
|
||||
/>
|
||||
))}
|
||||
{purchased.map((upgrade) => (
|
||||
@@ -102,6 +108,7 @@ export const UpgradePanel = (): React.JSX.Element => {
|
||||
upgrade={upgrade}
|
||||
currentGold={state.resources.gold}
|
||||
currentEssence={state.resources.essence}
|
||||
currentCrystals={state.resources.crystals}
|
||||
/>
|
||||
))}
|
||||
{showLocked && locked.map((upgrade) => (
|
||||
@@ -110,6 +117,7 @@ export const UpgradePanel = (): React.JSX.Element => {
|
||||
upgrade={upgrade}
|
||||
currentGold={state.resources.gold}
|
||||
currentEssence={state.resources.essence}
|
||||
currentCrystals={state.resources.crystals}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,8 @@ interface GameContextValue {
|
||||
buyAdventurer: (adventurerId: string) => void;
|
||||
/** Buy an upgrade */
|
||||
buyUpgrade: (upgradeId: string) => void;
|
||||
/** Purchase a buyable equipment item */
|
||||
buyEquipment: (equipmentId: string) => void;
|
||||
/** Start a quest */
|
||||
startQuest: (questId: string) => void;
|
||||
/** Challenge a boss — runs full server-side simulation */
|
||||
@@ -175,6 +177,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
if (!upgrade || !upgrade.unlocked || upgrade.purchased) return prev;
|
||||
if (prev.resources.gold < upgrade.costGold) return prev;
|
||||
if (prev.resources.essence < upgrade.costEssence) return prev;
|
||||
if (prev.resources.crystals < (upgrade.costCrystals ?? 0)) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
@@ -182,6 +185,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
...prev.resources,
|
||||
gold: prev.resources.gold - upgrade.costGold,
|
||||
essence: prev.resources.essence - upgrade.costEssence,
|
||||
crystals: prev.resources.crystals - (upgrade.costCrystals ?? 0),
|
||||
},
|
||||
upgrades: prev.upgrades.map((u) =>
|
||||
u.id === upgradeId ? { ...u, purchased: true } : u,
|
||||
@@ -225,6 +229,37 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
});
|
||||
}, []);
|
||||
|
||||
const buyEquipment = useCallback((equipmentId: string) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
const item = (prev.equipment ?? []).find((e) => e.id === equipmentId);
|
||||
if (!item || item.owned || !item.cost) return prev;
|
||||
|
||||
const { gold, essence, crystals } = item.cost;
|
||||
if (prev.resources.gold < gold) return prev;
|
||||
if (prev.resources.essence < essence) return prev;
|
||||
if (prev.resources.crystals < crystals) return prev;
|
||||
|
||||
const slotAlreadyEquipped = (prev.equipment ?? []).some(
|
||||
(e) => e.type === item.type && e.equipped,
|
||||
);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
resources: {
|
||||
...prev.resources,
|
||||
gold: prev.resources.gold - gold,
|
||||
essence: prev.resources.essence - essence,
|
||||
crystals: prev.resources.crystals - crystals,
|
||||
},
|
||||
equipment: (prev.equipment ?? []).map((e) => {
|
||||
if (e.id === equipmentId) return { ...e, owned: true, equipped: !slotAlreadyEquipped };
|
||||
return e;
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const challengeBoss = useCallback(async (bossId: string) => {
|
||||
if (!stateRef.current) return;
|
||||
const boss = stateRef.current.bosses.find((b) => b.id === bossId);
|
||||
@@ -238,21 +273,33 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
if (!prev) return prev;
|
||||
|
||||
if (result.won) {
|
||||
const bossIndex = prev.bosses.findIndex((b) => b.id === bossId);
|
||||
const defeatedBoss = prev.bosses.find((b) => b.id === bossId);
|
||||
const zoneBosses = prev.bosses.filter((b) => b.zoneId === defeatedBoss?.zoneId);
|
||||
const zoneIdx = zoneBosses.findIndex((b) => b.id === bossId);
|
||||
const nextZoneBossId = zoneBosses[zoneIdx + 1]?.id;
|
||||
|
||||
// Find newly unlocked zones and their first bosses
|
||||
const newlyUnlockedZones = (prev.zones ?? []).filter((z) => z.unlockBossId === bossId && z.status === "locked");
|
||||
const newZoneFirstBossIds = newlyUnlockedZones.map((z) => {
|
||||
const firstBoss = prev.bosses.find((b) => b.zoneId === z.id);
|
||||
return firstBoss?.id;
|
||||
}).filter(Boolean);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
bosses: prev.bosses.map((b, idx) => {
|
||||
if (b.id === bossId) {
|
||||
return { ...b, status: "defeated" as const, currentHp: 0 };
|
||||
bosses: prev.bosses.map((b) => {
|
||||
if (b.id === bossId) return { ...b, status: "defeated" as const, currentHp: 0 };
|
||||
if (b.id === nextZoneBossId && b.prestigeRequirement <= prev.prestige.count) {
|
||||
return { ...b, status: "available" as const };
|
||||
}
|
||||
if (
|
||||
idx === bossIndex + 1 &&
|
||||
b.prestigeRequirement <= prev.prestige.count
|
||||
) {
|
||||
if (newZoneFirstBossIds.includes(b.id) && b.prestigeRequirement <= prev.prestige.count) {
|
||||
return { ...b, status: "available" as const };
|
||||
}
|
||||
return b;
|
||||
}),
|
||||
zones: (prev.zones ?? []).map((z) =>
|
||||
z.unlockBossId === bossId ? { ...z, status: "unlocked" as const } : z,
|
||||
),
|
||||
resources: result.rewards
|
||||
? {
|
||||
...prev.resources,
|
||||
@@ -332,6 +379,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
handleClick,
|
||||
buyAdventurer,
|
||||
buyUpgrade,
|
||||
buyEquipment,
|
||||
startQuest,
|
||||
challengeBoss,
|
||||
equipItem,
|
||||
|
||||
@@ -937,6 +937,12 @@ body {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.equipment-cost {
|
||||
color: var(--colour-essence);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.equipment-equipped-badge {
|
||||
color: var(--colour-success);
|
||||
font-size: 0.85rem;
|
||||
|
||||
Reference in New Issue
Block a user