diff --git a/src/components/Factory/Canvas/GameCanvas.tsx b/src/components/Factory/Canvas/GameCanvas.tsx index c28466592..f66f9c456 100644 --- a/src/components/Factory/Canvas/GameCanvas.tsx +++ b/src/components/Factory/Canvas/GameCanvas.tsx @@ -20,10 +20,12 @@ import { GameOverDialog } from "../components/GameOverDialog"; import { setup } from "../data/setup"; import { createBuildingNode } from "../objects/buildings/createBuildingNode"; import { setupConnections } from "../objects/resources/setupConnections"; +import { useGameActions } from "../providers/GameActionsProvider"; import { useGlobalResources } from "../providers/GlobalResourcesProvider"; import { useStatistics } from "../providers/StatisticsProvider"; import { useTime } from "../providers/TimeProvider"; import { processDay } from "../simulation/processDay"; +import { loadGameState, saveGameState } from "../utils/saveGame"; import { createIsValidConnection } from "./callbacks/isValidConnection"; import { createOnConnect } from "./callbacks/onConnect"; import { createOnDrop } from "./callbacks/onDrop"; @@ -47,13 +49,28 @@ const GameCanvas = ({ children, ...rest }: GameCanvasProps) => { const [reactFlowInstance, setReactFlowInstance] = useState(); - const { addDayStatistics, getLatestDayStats, resetStatistics, currentDay } = - useStatistics(); - const { resources, updateResources, resetResources, setResource } = - useGlobalResources(); + const [hasLoadedGame, setHasLoadedGame] = useState(false); + const [pendingSetup, setPendingSetup] = useState(false); + + const { + addDayStatistics, + getLatestDayStats, + resetStatistics, + currentDay, + history, + setStatisticsHistory, + } = useStatistics(); + const { + resources, + updateResources, + resetResources, + setResource, + setAllResources, + } = useGlobalResources(); const { pause, dayAdvanceTrigger } = useTime(); + const { registerRestartHandler } = useGameActions(); - const { fitView } = useReactFlow(); + const { fitView, getViewport, setViewport } = useReactFlow(); const { clearContent } = useContextPanel(); const [gameOverOpen, setGameOverOpen] = useState(false); @@ -78,9 +95,20 @@ const GameCanvas = ({ children, ...rest }: GameCanvasProps) => { ); setNodes(updatedNodes); - updateResources(statistics.global.earned); + const updatedResources = updateResources(statistics.global.earned); addDayStatistics(statistics); + const viewport = getViewport(); + saveGameState( + updatedNodes, + edges, + updatedResources, + [...history, statistics], + viewport, + ).catch((error) => { + console.error("Failed to auto-save game:", error); + }); + if (statistics.global.foodDeficit > 0) { if (!hasContinuedGame.current) { setGameOverOpen(true); @@ -91,11 +119,13 @@ const GameCanvas = ({ children, ...rest }: GameCanvasProps) => { dayAdvanceTrigger, currentDay, resources, - hasContinuedGame, + history, setNodes, updateResources, getLatestDayStats, addDayStatistics, + getViewport, + pause, ]); const onInit: OnInit = (instance) => { @@ -115,11 +145,15 @@ const GameCanvas = ({ children, ...rest }: GameCanvasProps) => { }; const runSetup = () => { + if (!reactFlowInstance) return; + setNodes([]); setEdges([]); resetResources(); resetStatistics(); clearContent(); + hasContinuedGame.current = false; + prevTriggerRef.current = 0; const newNodes = setup.buildings?.map((building) => createBuildingNode(building.type, building.position), @@ -131,33 +165,34 @@ const GameCanvas = ({ children, ...rest }: GameCanvasProps) => { if (newNodes) { setNodes(newNodes); + setPendingSetup(true); } + }; + + useEffect(() => { + if (!pendingSetup || !reactFlowInstance || nodes.length === 0) return; // Double RAF is needed to ensure nodes are rendered before we try to create edges between them requestAnimationFrame(() => { requestAnimationFrame(() => { - if ( - setup.connections && - setup.buildings && - newNodes && - reactFlowInstance - ) { + if (setup.connections && setup.buildings) { const newEdges = setupConnections( setup.connections, setup.buildings, - newNodes, + nodes, reactFlowInstance, ); - if (newEdges) { - setEdges(newEdges.filter((edge): edge is Edge => edge !== null)); + if (newEdges && newEdges.length > 0) { + setEdges(newEdges); } } + + fitView({ maxZoom: 1, padding: 0.2 }); + setPendingSetup(false); }); }); - - fitView({ maxZoom: 1, padding: 0.2 }); - }; + }, [pendingSetup, reactFlowInstance, nodes, fitView]); const handleContinuePlaying = () => { setGameOverOpen(false); @@ -166,13 +201,46 @@ const GameCanvas = ({ children, ...rest }: GameCanvasProps) => { const handleRestart = () => { setGameOverOpen(false); - hasContinuedGame.current = false; runSetup(); }; useEffect(() => { - runSetup(); - }, [runSetup]); + registerRestartHandler(handleRestart); + }, [handleRestart, registerRestartHandler]); + + useEffect(() => { + if (!reactFlowInstance || hasLoadedGame) return; + + const loadSavedGame = async () => { + try { + const savedGame = await loadGameState(); + + if (savedGame) { + setNodes(savedGame.nodes); + setEdges(savedGame.edges); + setAllResources(savedGame.globalResources); + setStatisticsHistory(savedGame.statisticsHistory); + + if (savedGame.viewport) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setViewport(savedGame.viewport!, { duration: 0 }); + }); + }); + } + } else { + runSetup(); + } + } catch (error) { + console.error("Error loading saved game:", error); + runSetup(); + } finally { + setHasLoadedGame(true); + } + }; + + loadSavedGame(); + }, [reactFlowInstance, hasLoadedGame]); return ( <> diff --git a/src/components/Factory/Context/FactoryContext.tsx b/src/components/Factory/Context/FactoryContext.tsx new file mode 100644 index 000000000..49e54b608 --- /dev/null +++ b/src/components/Factory/Context/FactoryContext.tsx @@ -0,0 +1,29 @@ +import { Button } from "@/components/ui/button"; +import { BlockStack } from "@/components/ui/layout"; + +import { useGameActions } from "../providers/GameActionsProvider"; + +export const FactoryContext = () => { + const { restartGame } = useGameActions(); + + return ( + +

Factory Game

+

How long can you survive?

+

+ Every day that passes an increasing amount of food will be required for + survival. +

+

+ Produce, refine and sell resources to earn money and expand your + production lines. +

+

The game is autosaved at the end of every day.

+

Happy Hack Days! - The factory must grow!

+ + +
+ ); +}; diff --git a/src/components/Factory/FactoryGameApp.tsx b/src/components/Factory/FactoryGameApp.tsx index efe226938..1523b3827 100644 --- a/src/components/Factory/FactoryGameApp.tsx +++ b/src/components/Factory/FactoryGameApp.tsx @@ -9,8 +9,10 @@ import { ContextPanelProvider } from "@/providers/ContextPanelProvider"; import GameCanvas from "./Canvas/GameCanvas"; import { AnchoredToastContainer } from "./components/AnchoredToast"; +import { FactoryContext } from "./Context/FactoryContext"; import GameControls from "./Controls/GameControls"; import { AnchoredToastProvider } from "./providers/AnchoredToastProvider"; +import { GameActionsProvider } from "./providers/GameActionsProvider"; import { TimeProvider } from "./providers/TimeProvider"; import GameSidebar from "./Sidebar/GameSidebar"; @@ -33,30 +35,32 @@ const FactoryGameApp = () => { }; return ( - - - - - Factory Game

}> - - - - - - - - - - - -
-
-
+ + + + + + }> + + + + + + + + + + + + + + + ); }; diff --git a/src/components/Factory/data/buildings.ts b/src/components/Factory/data/buildings.ts index 3ce1930b2..1877f7891 100644 --- a/src/components/Factory/data/buildings.ts +++ b/src/components/Factory/data/buildings.ts @@ -210,7 +210,7 @@ export const BUILDINGS: Record = { description: "Turns wood into planks", cost: 0, color: "#D2691E", - category: "refining", + category: "manufacturing", productionMethods: [ { name: "Sandpaper and Saw", @@ -229,7 +229,7 @@ export const BUILDINGS: Record = { description: "Turns wood into paper", cost: 0, color: "#6A5ACD", - category: "refining", + category: "manufacturing", productionMethods: [ { name: "Pulp and Press", @@ -273,7 +273,7 @@ export const BUILDINGS: Record = { description: "Processes livestock", cost: 0, color: "#8B0000", - category: "refining", + category: "manufacturing", productionMethods: [ { name: "Carving Knives", @@ -292,7 +292,7 @@ export const BUILDINGS: Record = { description: "Produces books", cost: 0, color: "#4B0082", - category: "refining", + category: "manufacturing", productionMethods: [ { name: "Bookbinding", @@ -311,7 +311,7 @@ export const BUILDINGS: Record = { description: "Grind wheat into flour", cost: 0, color: "#DAA520", - category: "refining", + category: "manufacturing", productionMethods: [ { name: "Grinding Stones", @@ -327,7 +327,7 @@ export const BUILDINGS: Record = { description: "Burns wood into coal", cost: 0, color: "#36454F", - category: "refining", + category: "manufacturing", productionMethods: [ { name: "Charcoal Burning", @@ -343,7 +343,7 @@ export const BUILDINGS: Record = { description: "Bakes bread and other treats!", cost: 0, color: "#F5CEB3", - category: "refining", + category: "manufacturing", productionMethods: [ { name: "Oven Baking", @@ -461,7 +461,7 @@ export const BUILDINGS: Record = { description: "Smelts ores into metal", cost: 0, color: "#B22222", - category: "refining", + category: "manufacturing", productionMethods: [ { name: "Bronze", @@ -489,7 +489,7 @@ export const BUILDINGS: Record = { description: "Crafts tools for various purposes", cost: 0, color: "#8B4513", - category: "refining", + category: "manufacturing", productionMethods: [ { name: "Stone Tools", @@ -569,7 +569,7 @@ export const BUILDINGS: Record = { description: "Produces coins from metal", cost: 0, color: "#FFD700", - category: "refining", + category: "manufacturing", productionMethods: [ { name: "Copper Coins", diff --git a/src/components/Factory/data/setup.ts b/src/components/Factory/data/setup.ts index 111b69e7d..557de4b45 100644 --- a/src/components/Factory/data/setup.ts +++ b/src/components/Factory/data/setup.ts @@ -44,7 +44,7 @@ const buildings: BuildingSetup[] = [ const resources: ResourceSetup[] = [ { type: "money", amount: 1000 }, - { type: "food", amount: 200 }, + { type: "food", amount: 100 }, ]; const connections: ConnectionSetup[] = [ diff --git a/src/components/Factory/providers/GameActionsProvider.tsx b/src/components/Factory/providers/GameActionsProvider.tsx new file mode 100644 index 000000000..e2b2526cf --- /dev/null +++ b/src/components/Factory/providers/GameActionsProvider.tsx @@ -0,0 +1,47 @@ +import { createContext, type ReactNode, useContext, useRef } from "react"; + +interface GameActionsContextType { + restartGame: () => void; + registerRestartHandler: (handler: () => void) => void; +} + +const GameActionsContext = createContext( + undefined, +); + +interface GameActionsProviderProps { + children: ReactNode; +} + +export const GameActionsProvider = ({ children }: GameActionsProviderProps) => { + const restartHandlerRef = useRef<(() => void) | null>(null); + + const registerRestartHandler = (handler: () => void) => { + restartHandlerRef.current = handler; + }; + + const restartGame = () => { + if (restartHandlerRef.current) { + restartHandlerRef.current(); + } + }; + + return ( + + {children} + + ); +}; + +export const useGameActions = () => { + const context = useContext(GameActionsContext); + if (!context) { + throw new Error("useGameActions must be used within GameActionsProvider"); + } + return context; +}; diff --git a/src/components/Factory/providers/GlobalResourcesProvider.tsx b/src/components/Factory/providers/GlobalResourcesProvider.tsx index d413d1092..3c81673e9 100644 --- a/src/components/Factory/providers/GlobalResourcesProvider.tsx +++ b/src/components/Factory/providers/GlobalResourcesProvider.tsx @@ -8,11 +8,12 @@ import { interface GlobalResourcesContextType { resources: GlobalResources; - updateResources: (updates: Partial) => void; + updateResources: (updates: Partial) => GlobalResources; setResource: (resourceType: GlobalResourceType, amount: number) => void; addResource: (resourceType: GlobalResourceType, amount: number) => void; getResource: (resourceType: GlobalResourceType) => number; resetResources: () => void; + setAllResources: (resources: GlobalResources) => void; } const GlobalResourcesContext = createContext< @@ -30,11 +31,13 @@ const INITIAL_RESOURCES: GlobalResources = Object.fromEntries( export const GlobalResourcesProvider = ({ children, }: GlobalResourcesProviderProps) => { - // Initialize all global resources to 0 + // Initialize all global resources const [resources, setResources] = useState(INITIAL_RESOURCES); const updateResources = (updates: Partial) => { + let updatedResources: GlobalResources = INITIAL_RESOURCES; + setResources((prev) => { const updated = { ...prev }; @@ -45,8 +48,11 @@ export const GlobalResourcesProvider = ({ } }); + updatedResources = updated; return updated; }); + + return updatedResources; }; const setResource = (resourceType: GlobalResourceType, amount: number) => { @@ -71,6 +77,10 @@ export const GlobalResourcesProvider = ({ setResources(INITIAL_RESOURCES); }; + const setAllResources = (newResources: GlobalResources) => { + setResources(newResources); + }; + return ( {children} diff --git a/src/components/Factory/providers/StatisticsProvider.tsx b/src/components/Factory/providers/StatisticsProvider.tsx index 1b30bcde8..418b81ba1 100644 --- a/src/components/Factory/providers/StatisticsProvider.tsx +++ b/src/components/Factory/providers/StatisticsProvider.tsx @@ -17,6 +17,7 @@ interface StatisticsContextType { includeBreakdown?: boolean, ) => EdgeStatistics[]; resetStatistics: () => void; + setStatisticsHistory: (history: DayStatistics[]) => void; } const StatisticsContext = createContext( @@ -78,6 +79,10 @@ export const StatisticsProvider: React.FC<{ children: React.ReactNode }> = ({ setHistory([]); }; + const setStatisticsHistory = (newHistory: DayStatistics[]) => { + setHistory(newHistory); + }; + return ( = ({ getLatestDayStats, getLatestEdgeStats, resetStatistics, + setStatisticsHistory, }} > {children} diff --git a/src/components/Factory/types/buildings.ts b/src/components/Factory/types/buildings.ts index 42f3b0752..f3478be7d 100644 --- a/src/components/Factory/types/buildings.ts +++ b/src/components/Factory/types/buildings.ts @@ -62,7 +62,7 @@ export const BUILDING_CATEGORIES: BuildingCategoryDefinition[] = [ { type: "special", label: "Special", icon: "Star" }, { type: "logistics", label: "Logistics", icon: "Truck" }, { type: "production", label: "Production", icon: "Hammer" }, - { type: "refining", label: "Refining", icon: "Factory" }, + { type: "manufacturing", label: "Manufacturing", icon: "Factory" }, { type: "services", label: "Services", icon: "Store" }, { type: "storage", label: "Storage", icon: "Package" }, ]; diff --git a/src/components/Factory/utils/saveGame.ts b/src/components/Factory/utils/saveGame.ts new file mode 100644 index 000000000..4867b29d2 --- /dev/null +++ b/src/components/Factory/utils/saveGame.ts @@ -0,0 +1,301 @@ +import type { Edge, Node } from "@xyflow/react"; +import localforage from "localforage"; + +import type { GlobalResources } from "../data/resources"; +import type { DayStatistics } from "../types/statistics"; + +const factoryGameStore = localforage.createInstance({ + name: "tangle-factory", + storeName: "game_saves", + description: "Store for Factory Game save data", +}); + +export interface GameSaveData { + version: string; + savedAt: number; + day: number; + nodes: Node[]; + edges: Edge[]; + globalResources: GlobalResources; + statisticsHistory: DayStatistics[]; + viewport?: { + x: number; + y: number; + zoom: number; + }; +} + +const SAVE_KEY = "autosave"; +const CURRENT_VERSION = "1.0.0"; + +/** + * Serialize and save the current game state to IndexedDB + */ +export async function saveGameState( + nodes: Node[], + edges: Edge[], + globalResources: GlobalResources, + statisticsHistory: DayStatistics[], + viewport?: { x: number; y: number; zoom: number }, +): Promise { + const saveData: GameSaveData = { + version: CURRENT_VERSION, + savedAt: Date.now(), + day: + statisticsHistory.length > 0 + ? statisticsHistory[statisticsHistory.length - 1].global.day + : 0, + nodes: serializeNodes(nodes), + edges: serializeEdges(edges), + globalResources: { ...globalResources }, + statisticsHistory: serializeStatistics(statisticsHistory), + viewport, + }; + + await factoryGameStore.setItem(SAVE_KEY, saveData); +} + +/** + * Load the game state from IndexedDB + */ +export async function loadGameState(): Promise { + try { + const saveData = await factoryGameStore.getItem(SAVE_KEY); + + if (!saveData) { + return null; + } + + // Deserialize the statistics history to restore Maps + const deserializedSaveData: GameSaveData = { + ...saveData, + statisticsHistory: deserializeStatistics(saveData.statisticsHistory), + nodes: saveData.nodes.map(deserializeNode), + edges: saveData.edges.map(deserializeEdge), + }; + + return deserializedSaveData; + } catch (error) { + console.error("Error loading game state:", error); + return null; + } +} + +/** + * Check if a save exists + */ +export async function hasSaveData(): Promise { + const saveData = await factoryGameStore.getItem(SAVE_KEY); + return saveData !== null; +} + +/** + * Delete the save data + */ +export async function deleteSaveData(): Promise { + await factoryGameStore.removeItem(SAVE_KEY); +} + +/** + * Get save metadata without loading full data + */ +export async function getSaveMetadata(): Promise<{ + day: number; + savedAt: number; + version: string; +} | null> { + const saveData = await factoryGameStore.getItem(SAVE_KEY); + + if (!saveData) return null; + + return { + day: saveData.day, + savedAt: saveData.savedAt, + version: saveData.version, + }; +} + +/** + * Serialize nodes for storage (remove functions, clean data) + */ +function serializeNodes(nodes: Node[]): Node[] { + return nodes.map((node) => ({ + ...node, + data: { + ...node.data, + buildingInstance: node.data.buildingInstance + ? serializeBuildingInstance(node.data.buildingInstance) + : undefined, + }, + })); +} + +/** + * Deserialize a node, restoring Map objects in building instances + */ +function deserializeNode(node: Node): Node { + return { + ...node, + data: { + ...node.data, + buildingInstance: node.data.buildingInstance + ? deserializeBuildingInstance(node.data.buildingInstance) + : undefined, + }, + }; +} + +/** + * Serialize building instance, converting Maps to objects with markers + */ +function serializeBuildingInstance(instance: any): any { + return { + ...instance, + stockpile: instance.stockpile?.map((stock: any) => ({ + ...stock, + breakdown: stock.breakdown ? serializeValue(stock.breakdown) : undefined, + })), + }; +} + +/** + * Deserialize building instance, restoring Map in stockpile breakdown + */ +function deserializeBuildingInstance(instance: any): any { + return { + ...instance, + stockpile: instance.stockpile?.map((stock: any) => { + const deserialized: any = { + ...stock, + }; + + if (stock.breakdown) { + deserialized.breakdown = deserializeValue(stock.breakdown); + } + + return deserialized; + }), + }; +} + +/** + * Serialize edges for storage + */ +function serializeEdges(edges: Edge[]): Edge[] { + return edges.map((edge) => ({ + ...edge, + data: { + ...edge.data, + }, + })); +} + +/** + * Deserialize an edge, restoring any complex data structures + */ +function deserializeEdge(edge: Edge): Edge { + return { + ...edge, + data: edge.data ? deserializeValue(edge.data) : undefined, + }; +} + +/** + * Serialize statistics history + * Note: Maps need to be converted to objects for JSON serialization + */ +function serializeStatistics(history: DayStatistics[]): DayStatistics[] { + return history.map((dayStat) => ({ + ...dayStat, + buildings: mapToObject(dayStat.buildings), + edges: mapToObject(dayStat.edges), + })) as any; +} + +/** + * Deserialize statistics history + * Convert objects back to Maps + */ +export function deserializeStatistics(serialized: any[]): DayStatistics[] { + return serialized.map((dayStat) => ({ + ...dayStat, + buildings: objectToMap(dayStat.buildings), + edges: objectToMap(dayStat.edges), + })); +} + +/** + * Convert Map to plain object for JSON serialization + */ +function mapToObject(map: Map): Record { + const obj: Record = {}; + + map.forEach((value, key) => { + obj[key] = serializeValue(value); + }); + + return obj; +} + +/** + * Convert plain object back to Map + */ +function objectToMap(obj: Record): Map { + const map = new Map(); + + Object.entries(obj).forEach(([key, value]) => { + map.set(key, deserializeValue(value) as V); + }); + + return map; +} + +/** + * Recursively serialize values that might contain Maps + */ +function serializeValue(value: any): any { + if (value instanceof Map) { + return { + __isMap: true, + data: mapToObject(value), + }; + } + + if (Array.isArray(value)) { + return value.map(serializeValue); + } + + if (value && typeof value === "object") { + const serialized: any = {}; + Object.entries(value).forEach(([k, v]) => { + serialized[k] = serializeValue(v); + }); + return serialized; + } + + return value; +} + +/** + * Recursively deserialize values that might have been Maps + */ +function deserializeValue(value: any): any { + if (value && typeof value === "object" && value.__isMap) { + const deserializedData = objectToMap(value.data); + return deserializedData; + } + + if (Array.isArray(value)) { + return value.map(deserializeValue); + } + + if (value && typeof value === "object") { + const deserialized: any = {}; + Object.entries(value).forEach(([k, v]) => { + deserialized[k] = deserializeValue(v); + }); + return deserialized; + } + + return value; +}