generated from nhcarrigan/template
feat: initial elysium idle game prototype
Sets up the full monorepo with pnpm workspaces. Includes shared types package, Hono API with Discord OAuth/JWT auth, Prisma v6 + MongoDB Atlas, and React + Vite frontend with game loop, five tabs, and Discord-linked save/load.
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
import type { GameState } from "@elysium/types";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { dealBossDamage, loadGame, saveGame } from "../api/client.js";
|
||||
import { applyTick, calculateClickPower } from "../engine/tick.js";
|
||||
|
||||
interface GameContextValue {
|
||||
state: GameState | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
/** Click the crystal to earn gold */
|
||||
handleClick: () => void;
|
||||
/** Buy an adventurer */
|
||||
buyAdventurer: (adventurerId: string) => void;
|
||||
/** Buy an upgrade */
|
||||
buyUpgrade: (upgradeId: string) => void;
|
||||
/** Start a quest */
|
||||
startQuest: (questId: string) => void;
|
||||
/** Attack the active boss */
|
||||
attackBoss: (bossId: string) => void;
|
||||
/** Reload state from the server */
|
||||
reload: () => Promise<void>;
|
||||
/** Offline gold earned on login */
|
||||
offlineGold: number;
|
||||
/** Dismiss the offline gold notification */
|
||||
dismissOfflineGold: () => void;
|
||||
}
|
||||
|
||||
const GameContext = createContext<GameContextValue | null>(null);
|
||||
|
||||
const AUTO_SAVE_INTERVAL_MS = 30_000;
|
||||
|
||||
export const GameProvider = ({ children }: { children: React.ReactNode }): React.JSX.Element => {
|
||||
const [state, setState] = useState<GameState | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [offlineGold, setOfflineGold] = useState(0);
|
||||
const stateRef = useRef<GameState | null>(null);
|
||||
const lastSaveRef = useRef<number>(Date.now());
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
stateRef.current = state;
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await loadGame();
|
||||
setState(data.state);
|
||||
if (data.offlineGold > 0) {
|
||||
setOfflineGold(data.offlineGold);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load game");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
// Game loop via requestAnimationFrame
|
||||
useEffect(() => {
|
||||
if (!state) return;
|
||||
|
||||
let lastTime = performance.now();
|
||||
|
||||
const tick = (now: number): void => {
|
||||
const deltaSeconds = (now - lastTime) / 1000;
|
||||
lastTime = now;
|
||||
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
return applyTick(prev, deltaSeconds);
|
||||
});
|
||||
|
||||
// Auto-save every 30 seconds
|
||||
if (Date.now() - lastSaveRef.current >= AUTO_SAVE_INTERVAL_MS) {
|
||||
lastSaveRef.current = Date.now();
|
||||
if (stateRef.current) {
|
||||
void saveGame({ state: stateRef.current });
|
||||
}
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run when state becomes available
|
||||
}, [state !== null]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
const clickPower = calculateClickPower(prev);
|
||||
return {
|
||||
...prev,
|
||||
resources: { ...prev.resources, gold: prev.resources.gold + clickPower },
|
||||
player: {
|
||||
...prev.player,
|
||||
totalGoldEarned: prev.player.totalGoldEarned + clickPower,
|
||||
totalClicks: prev.player.totalClicks + 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const buyAdventurer = useCallback((adventurerId: string) => {
|
||||
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;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
resources: { ...prev.resources, gold: prev.resources.gold - cost },
|
||||
adventurers: prev.adventurers.map((a) =>
|
||||
a.id === adventurerId ? { ...a, count: a.count + 1 } : a,
|
||||
),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const buyUpgrade = useCallback((upgradeId: string) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
const upgrade = prev.upgrades.find((u) => u.id === upgradeId);
|
||||
if (!upgrade || !upgrade.unlocked || upgrade.purchased) return prev;
|
||||
if (prev.resources.gold < upgrade.costGold) return prev;
|
||||
if (prev.resources.essence < upgrade.costEssence) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
resources: {
|
||||
...prev.resources,
|
||||
gold: prev.resources.gold - upgrade.costGold,
|
||||
essence: prev.resources.essence - upgrade.costEssence,
|
||||
},
|
||||
upgrades: prev.upgrades.map((u) =>
|
||||
u.id === upgradeId ? { ...u, purchased: true } : u,
|
||||
),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const startQuest = useCallback((questId: string) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
const quest = prev.quests.find((q) => q.id === questId);
|
||||
if (!quest || quest.status !== "available") return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
quests: prev.quests.map((q) =>
|
||||
q.id === questId
|
||||
? { ...q, status: "active" as const, startedAt: Date.now() }
|
||||
: q,
|
||||
),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const attackBoss = useCallback(async (bossId: string) => {
|
||||
if (!stateRef.current) return;
|
||||
const clickPower = calculateClickPower(stateRef.current);
|
||||
|
||||
try {
|
||||
const result = await dealBossDamage({ bossId, damage: clickPower });
|
||||
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
bosses: prev.bosses.map((b) =>
|
||||
b.id === bossId
|
||||
? {
|
||||
...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.player,
|
||||
totalGoldEarned:
|
||||
prev.player.totalGoldEarned + result.rewards.gold,
|
||||
},
|
||||
upgrades: prev.upgrades.map((u) =>
|
||||
result.rewards!.upgradeIds.includes(u.id)
|
||||
? { ...u, unlocked: true }
|
||||
: u,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// Rate limited or other error — silently ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismissOfflineGold = useCallback(() => {
|
||||
setOfflineGold(0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<GameContext.Provider
|
||||
value={{
|
||||
state,
|
||||
isLoading,
|
||||
error,
|
||||
handleClick,
|
||||
buyAdventurer,
|
||||
buyUpgrade,
|
||||
startQuest,
|
||||
attackBoss,
|
||||
reload,
|
||||
offlineGold,
|
||||
dismissOfflineGold,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</GameContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useGame = (): GameContextValue => {
|
||||
const context = useContext(GameContext);
|
||||
if (!context) {
|
||||
throw new Error("useGame must be used within a GameProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
Reference in New Issue
Block a user