diff --git a/src/components/GeneratorCpsBreakdown.tsx b/src/components/GeneratorCpsBreakdown.tsx new file mode 100644 index 0000000..4fb3157 --- /dev/null +++ b/src/components/GeneratorCpsBreakdown.tsx @@ -0,0 +1,256 @@ +import { Badge, Group, Stack, Text } from "@mantine/core"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { UPGRADES } from "../data/upgrades"; +import { computeAllGeneratorsCps } from "../engine/upgradeEngine"; +import { useGameStore } from "../store"; +import { formatNumber } from "../utils/formatNumber"; + +/** Number of 1-second buckets for the rolling click average. */ +const CLICK_BUFFER_SIZE = 10; + +/** + * Hook that tracks a rolling 10-second average of TD earned from clicks. + * Uses a circular buffer of 1-second buckets updated via Zustand subscription. + */ +function useRollingClickAverage(): number { + const bucketsRef = useRef(new Array(CLICK_BUFFER_SIZE).fill(0)); + const currentIndexRef = useRef(0); + const lastTickRef = useRef(Math.floor(Date.now() / 1000)); + const [average, setAverage] = useState(0); + + const advanceBuckets = useCallback(() => { + const now = Math.floor(Date.now() / 1000); + const elapsed = now - lastTickRef.current; + if (elapsed > 0) { + const steps = Math.min(elapsed, CLICK_BUFFER_SIZE); + for (let i = 0; i < steps; i++) { + currentIndexRef.current = + (currentIndexRef.current + 1) % CLICK_BUFFER_SIZE; + bucketsRef.current[currentIndexRef.current] = 0; + } + lastTickRef.current = now; + } + }, []); + + // Subscribe to totalClicks changes to detect click events + useEffect(() => { + let prevClicks = useGameStore.getState().totalClicks; + let prevTd = useGameStore.getState().trainingData; + + const unsub = useGameStore.subscribe((state) => { + const newClicks = state.totalClicks; + if (newClicks > prevClicks) { + advanceBuckets(); + // Estimate TD earned from this click as the delta in trainingData + const tdDelta = state.trainingData.sub(prevTd).toNumber(); + // Only count positive deltas that coincide with click increments + if (tdDelta > 0) { + bucketsRef.current[currentIndexRef.current] += tdDelta; + } + } + prevClicks = newClicks; + prevTd = state.trainingData; + }); + + return unsub; + }, [advanceBuckets]); + + // Recalculate average every second + useEffect(() => { + const interval = setInterval(() => { + advanceBuckets(); + const sum = bucketsRef.current.reduce((a, b) => a + b, 0); + setAverage(sum / CLICK_BUFFER_SIZE); + }, 1000); + return () => clearInterval(interval); + }, [advanceBuckets]); + + return average; +} + +/** + * Renders a compact per-generator CPS breakdown table. + * Only generators with at least 1 owned unit are shown. + * Values update reactively whenever upgradeOwned changes. + */ +export function GeneratorCpsBreakdown() { + const upgradeOwned = useGameStore((s) => s.upgradeOwned); + + const rows = useMemo( + () => computeAllGeneratorsCps(UPGRADES, upgradeOwned), + [upgradeOwned], + ); + + const activeRows = rows.filter((r) => r.owned > 0); + + const grandTotalCps = useMemo( + () => activeRows.reduce((sum, r) => sum + r.totalCps, 0), + [activeRows], + ); + + const clickAverage = useRollingClickAverage(); + + if (activeRows.length === 0) { + return ( + + No generators owned yet. + + ); + } + + return ( + + {/* Column headers */} + + + Generator + + + CPS + + + Share + + + + {activeRows.map((row) => ( + + + {/* Icon + name + owned badge */} + + + + {row.name} + + + ×{row.owned} + + + + {/* Total CPS */} + + {formatNumber(row.totalCps)}/s + + + {/* Percentage share */} + + {row.percentOfTotal.toFixed(1)}% + + + + {/* Next milestone hint */} + {row.nextMilestone && ( + + Next ×{row.nextMilestone.multiplier} at{" "} + {row.nextMilestone.threshold} owned + + )} + + ))} + + {/* Summary footer */} + + + Total + + + {formatNumber(grandTotalCps)}/s + + + 100% + + + + {/* Rolling click average */} + + + Click avg (10s) + + + {formatNumber(clickAverage)}/s + +
+ + + ); +} diff --git a/src/components/UpgradesSidebar.tsx b/src/components/UpgradesSidebar.tsx index 7128b1d..6aedc53 100644 --- a/src/components/UpgradesSidebar.tsx +++ b/src/components/UpgradesSidebar.tsx @@ -19,6 +19,7 @@ import { useGameStore } from "../store"; import type { BuyMode } from "../store/settingsStore"; import { useSettingsStore } from "../store/settingsStore"; import { D } from "../utils/decimal"; +import { GeneratorCpsBreakdown } from "./GeneratorCpsBreakdown"; import { ClickUpgradeCard } from "./upgrades/ClickUpgradeCard"; import { UpgradeCard } from "./upgrades/UpgradeCard"; @@ -29,18 +30,18 @@ interface TierConfig { } const TIER_CONFIG: readonly TierConfig[] = [ - { tier: "garage-lab", label: "🔬 Garage Lab", unlockStage: 0 }, - { tier: "startup", label: "🚀 Startup", unlockStage: 0 }, - { tier: "scale-up", label: "🏗️ Scale-Up", unlockStage: 2 }, - { tier: "mega-corp", label: "🏢 Mega Corp", unlockStage: 3 }, - { tier: "transcendence", label: "✨ Transcendence", unlockStage: 4 }, + { tier: "garage-lab", label: "\uD83D\uDD2C Garage Lab", unlockStage: 0 }, + { tier: "startup", label: "\uD83D\uDE80 Startup", unlockStage: 0 }, + { tier: "scale-up", label: "\uD83C\uDFD7\uFE0F Scale-Up", unlockStage: 2 }, + { tier: "mega-corp", label: "\uD83C\uDFE2 Mega Corp", unlockStage: 3 }, + { tier: "transcendence", label: "\u2728 Transcendence", unlockStage: 4 }, ]; const BUY_MODES: readonly { mode: BuyMode; label: string; shortcut: string }[] = [ - { mode: 1, label: "×1", shortcut: "1" }, - { mode: 10, label: "×10", shortcut: "2" }, - { mode: 100, label: "×100", shortcut: "3" }, + { mode: 1, label: "\u00d71", shortcut: "1" }, + { mode: 10, label: "\u00d710", shortcut: "2" }, + { mode: 100, label: "\u00d7100", shortcut: "3" }, { mode: "max", label: "Max", shortcut: "4" }, ]; @@ -81,7 +82,7 @@ function CategoryHeader({ transition: "transform 200ms ease", }} > - ▼ + \u25bc @@ -136,6 +137,7 @@ export function UpgradesSidebar() { "scale-up": true, "mega-corp": true, transcendence: true, + "production-breakdown": true, }, ); @@ -172,6 +174,11 @@ export function UpgradesSidebar() { const hasAnyUpgrades = visibleClickUpgrades.length > 0 || visibleTiers.length > 0; + // Count of owned generators for the breakdown header badge + const ownedGeneratorCount = UPGRADES.filter( + (u) => (upgradeOwned[u.id] ?? 0) > 0, + ).length; + return (