generated from nhcarrigan/template
feat: add equipment, achievements, and visual polish
- Equipment system: 12 items across weapon/armour/trinket slots with common/rare/epic/legendary rarities; starter commons auto-equipped, higher tiers drop from boss victories - Achievement system: 15 milestones with typed conditions; checked each tick and crystal rewards applied automatically - Achievement toast: slide-in notification, auto-dismisses after 4s - Floating click text: +X gold floats on each manual click - Expanded quests (9 total) and upgrades (12 total) - Upgrade panel now shows locked upgrades so players can see their progression path - formatNumber utility (K/M/B/T) used consistently across all panels - Backfill logic for existing saves to add new content gracefully - types package now emits .d.ts declarations
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { GameState } from "@elysium/types";
|
||||
import type { Achievement, BossChallengeResponse, GameState } from "@elysium/types";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -7,9 +7,14 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { dealBossDamage, loadGame, saveGame } from "../api/client.js";
|
||||
import { challengeBoss as challengeBossApi, loadGame, saveGame } from "../api/client.js";
|
||||
import { applyTick, calculateClickPower } from "../engine/tick.js";
|
||||
|
||||
export interface BattleResult {
|
||||
bossName: string;
|
||||
result: BossChallengeResponse;
|
||||
}
|
||||
|
||||
interface GameContextValue {
|
||||
state: GameState | null;
|
||||
isLoading: boolean;
|
||||
@@ -22,14 +27,24 @@ interface GameContextValue {
|
||||
buyUpgrade: (upgradeId: string) => void;
|
||||
/** Start a quest */
|
||||
startQuest: (questId: string) => void;
|
||||
/** Attack the active boss */
|
||||
attackBoss: (bossId: string) => void;
|
||||
/** Challenge a boss — runs full server-side simulation */
|
||||
challengeBoss: (bossId: string) => Promise<void>;
|
||||
/** Equip an owned equipment item (auto-unequips the same slot) */
|
||||
equipItem: (equipmentId: string) => void;
|
||||
/** Reload state from the server */
|
||||
reload: () => Promise<void>;
|
||||
/** Offline gold earned on login */
|
||||
offlineGold: number;
|
||||
/** Dismiss the offline gold notification */
|
||||
dismissOfflineGold: () => void;
|
||||
/** Battle result to display in the modal (null when no battle pending) */
|
||||
battleResult: BattleResult | null;
|
||||
/** Dismiss the battle result modal */
|
||||
dismissBattle: () => void;
|
||||
/** Queue of newly unlocked achievements (for toasts) */
|
||||
newAchievements: Achievement[];
|
||||
/** Remove an achievement from the toast queue */
|
||||
dismissAchievement: (id: string) => void;
|
||||
}
|
||||
|
||||
const GameContext = createContext<GameContextValue | null>(null);
|
||||
@@ -41,9 +56,12 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [offlineGold, setOfflineGold] = useState(0);
|
||||
const [battleResult, setBattleResult] = useState<BattleResult | null>(null);
|
||||
const [newAchievements, setNewAchievements] = useState<Achievement[]>([]);
|
||||
const stateRef = useRef<GameState | null>(null);
|
||||
const lastSaveRef = useRef<number>(Date.now());
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const newlyUnlockedRef = useRef<Achievement[]>([]);
|
||||
|
||||
stateRef.current = state;
|
||||
|
||||
@@ -79,9 +97,22 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
return applyTick(prev, deltaSeconds);
|
||||
const next = applyTick(prev, deltaSeconds);
|
||||
|
||||
// Detect newly unlocked achievements
|
||||
newlyUnlockedRef.current = next.achievements.filter((a, i) => {
|
||||
const wasLocked = (prev.achievements ?? [])[i]?.unlockedAt === null;
|
||||
return wasLocked && a.unlockedAt !== null;
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
|
||||
if (newlyUnlockedRef.current.length > 0) {
|
||||
setNewAchievements((prev) => [...prev, ...newlyUnlockedRef.current]);
|
||||
newlyUnlockedRef.current = [];
|
||||
}
|
||||
|
||||
// Auto-save every 30 seconds
|
||||
if (Date.now() - lastSaveRef.current >= AUTO_SAVE_INTERVAL_MS) {
|
||||
lastSaveRef.current = Date.now();
|
||||
@@ -176,50 +207,107 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
});
|
||||
}, []);
|
||||
|
||||
const attackBoss = useCallback(async (bossId: string) => {
|
||||
const equipItem = useCallback((equipmentId: string) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
const item = (prev.equipment ?? []).find((e) => e.id === equipmentId);
|
||||
if (!item || !item.owned) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
equipment: (prev.equipment ?? []).map((e) => {
|
||||
if (e.id === equipmentId) return { ...e, equipped: true };
|
||||
// Unequip the previously-equipped item in the same slot
|
||||
if (e.type === item.type && e.equipped) return { ...e, equipped: false };
|
||||
return e;
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const challengeBoss = useCallback(async (bossId: string) => {
|
||||
if (!stateRef.current) return;
|
||||
const clickPower = calculateClickPower(stateRef.current);
|
||||
const boss = stateRef.current.bosses.find((b) => b.id === bossId);
|
||||
if (!boss) return;
|
||||
|
||||
try {
|
||||
const result = await dealBossDamage({ bossId, damage: clickPower });
|
||||
const result = await challengeBossApi({ bossId });
|
||||
|
||||
// Update local state to match server result
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
bosses: prev.bosses.map((b) =>
|
||||
b.id === bossId
|
||||
|
||||
if (result.won) {
|
||||
const bossIndex = prev.bosses.findIndex((b) => b.id === bossId);
|
||||
return {
|
||||
...prev,
|
||||
bosses: prev.bosses.map((b, idx) => {
|
||||
if (b.id === bossId) {
|
||||
return { ...b, status: "defeated" as const, currentHp: 0 };
|
||||
}
|
||||
if (
|
||||
idx === bossIndex + 1 &&
|
||||
b.prestigeRequirement <= prev.prestige.count
|
||||
) {
|
||||
return { ...b, status: "available" as const };
|
||||
}
|
||||
return b;
|
||||
}),
|
||||
resources: result.rewards
|
||||
? {
|
||||
...b,
|
||||
status: result.defeated ? ("defeated" as const) : ("in_progress" as const),
|
||||
currentHp: result.currentHp,
|
||||
}
|
||||
: b,
|
||||
),
|
||||
...(result.defeated && result.rewards
|
||||
? {
|
||||
resources: {
|
||||
...prev.resources,
|
||||
gold: prev.resources.gold + result.rewards.gold,
|
||||
essence: prev.resources.essence + result.rewards.essence,
|
||||
crystals: prev.resources.crystals + result.rewards.crystals,
|
||||
},
|
||||
player: {
|
||||
}
|
||||
: prev.resources,
|
||||
player: result.rewards
|
||||
? {
|
||||
...prev.player,
|
||||
totalGoldEarned:
|
||||
prev.player.totalGoldEarned + result.rewards.gold,
|
||||
},
|
||||
upgrades: prev.upgrades.map((u) =>
|
||||
}
|
||||
: prev.player,
|
||||
upgrades: result.rewards
|
||||
? prev.upgrades.map((u) =>
|
||||
result.rewards!.upgradeIds.includes(u.id)
|
||||
? { ...u, unlocked: true }
|
||||
: u,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
)
|
||||
: prev.upgrades,
|
||||
equipment: result.rewards
|
||||
? (prev.equipment ?? []).map((e) => {
|
||||
if (!result.rewards!.equipmentIds.includes(e.id)) return e;
|
||||
const slotEmpty = !(prev.equipment ?? []).some(
|
||||
(other) => other.type === e.type && other.equipped,
|
||||
);
|
||||
return { ...e, owned: true, equipped: slotEmpty || e.equipped };
|
||||
})
|
||||
: prev.equipment ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// Loss: reset boss HP and apply casualties
|
||||
return {
|
||||
...prev,
|
||||
bosses: prev.bosses.map((b) =>
|
||||
b.id === bossId
|
||||
? { ...b, status: "available" as const, currentHp: b.maxHp }
|
||||
: b,
|
||||
),
|
||||
adventurers: prev.adventurers.map((a) => {
|
||||
const casualty = result.casualties?.find(
|
||||
(c) => c.adventurerId === a.id,
|
||||
);
|
||||
if (!casualty) return a;
|
||||
return { ...a, count: Math.max(0, a.count - casualty.killed) };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
setBattleResult({ bossName: boss.name, result });
|
||||
} catch {
|
||||
// Rate limited or other error — silently ignore
|
||||
// Silently ignore — server errors shouldn't crash the UI
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -227,6 +315,14 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
setOfflineGold(0);
|
||||
}, []);
|
||||
|
||||
const dismissBattle = useCallback(() => {
|
||||
setBattleResult(null);
|
||||
}, []);
|
||||
|
||||
const dismissAchievement = useCallback((id: string) => {
|
||||
setNewAchievements((prev) => prev.filter((a) => a.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<GameContext.Provider
|
||||
value={{
|
||||
@@ -237,10 +333,15 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
buyAdventurer,
|
||||
buyUpgrade,
|
||||
startQuest,
|
||||
attackBoss,
|
||||
challengeBoss,
|
||||
equipItem,
|
||||
reload,
|
||||
offlineGold,
|
||||
dismissOfflineGold,
|
||||
battleResult,
|
||||
dismissBattle,
|
||||
newAchievements,
|
||||
dismissAchievement,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user