From 6ff16660a459afab7c0f9e0b9b20c36e8798e4aa Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Sat, 14 Feb 2026 10:39:26 -0800 Subject: [PATCH] hackdays: Add Time Controls --- package-lock.json | 56 +++++ package.json | 2 + .../Factory/Sidebar/GameSidebar.tsx | 2 +- src/components/Factory/Sidebar/Time.tsx | 195 ++++++++++++++++-- src/components/ui/toggle-group.tsx | 61 ++++++ src/components/ui/toggle.tsx | 43 ++++ 6 files changed, 346 insertions(+), 13 deletions(-) create mode 100644 src/components/ui/toggle-group.tsx create mode 100644 src/components/ui/toggle.tsx diff --git a/package-lock.json b/package-lock.json index aecaf6337..66394dca1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,8 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.18", "@tanstack/history": "1.154.14", @@ -3294,6 +3296,60 @@ } } }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", diff --git a/package.json b/package.json index 4348a4c13..d517527e0 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,8 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.18", "@tanstack/history": "1.154.14", diff --git a/src/components/Factory/Sidebar/GameSidebar.tsx b/src/components/Factory/Sidebar/GameSidebar.tsx index 3abfe6d34..972d79380 100644 --- a/src/components/Factory/Sidebar/GameSidebar.tsx +++ b/src/components/Factory/Sidebar/GameSidebar.tsx @@ -2,7 +2,7 @@ import { Sidebar, SidebarContent } from "@/components/ui/sidebar"; import Buildings from "./Buildings"; import GlobalResources from "./GlobalResources"; -import Time from "./Time"; +import { Time } from "./Time"; interface GameSidebarProps { day: number; diff --git a/src/components/Factory/Sidebar/Time.tsx b/src/components/Factory/Sidebar/Time.tsx index 5e0a26b6f..e55425ca8 100644 --- a/src/components/Factory/Sidebar/Time.tsx +++ b/src/components/Factory/Sidebar/Time.tsx @@ -1,23 +1,194 @@ -import { Button } from "@/components/ui/button"; -import { InlineStack } from "@/components/ui/layout"; -import { SidebarGroup, SidebarGroupLabel } from "@/components/ui/sidebar"; +import { useEffect, useRef, useState } from "react"; + +import TooltipButton from "@/components/shared/Buttons/TooltipButton"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Progress } from "@/components/ui/progress"; +import { SidebarGroup } from "@/components/ui/sidebar"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; + +const TICK_INTERVAL = 40; // ~25fps +const MAX_PROGRESS = 1000; +const BASE_DAY_DURATION = 5000; // ms +const BASE_INCREMENT = MAX_PROGRESS / (BASE_DAY_DURATION / TICK_INTERVAL); +const DAY_TRANSITION_PAUSE = 200; // ms pause between days + +const GAME_SPEED_MULTIPLIER = { + slow: 1, + medium: 2, + fast: 5, +}; + +type GameSpeed = keyof typeof GAME_SPEED_MULTIPLIER; interface TimeProps { day: number; onAdvanceDay: () => void; } -const Time = ({ day, onAdvanceDay }: TimeProps) => { +export const Time = ({ day, onAdvanceDay }: TimeProps) => { + const [progress, setProgress] = useState(0); + const [isPaused, setIsPaused] = useState(true); + const [gameSpeed, setGameSpeed] = useState("slow"); + const [isTransitioning, setIsTransitioning] = useState(false); + const intervalRef = useRef(null); + + const handleTogglePlay = () => { + setIsPaused((prev) => !prev); + }; + + const handleDaySkip = () => { + if (isTransitioning) return; + + setIsTransitioning(true); + setProgress(0); + + setTimeout(() => { + onAdvanceDay(); + setIsTransitioning(false); + }, DAY_TRANSITION_PAUSE); + }; + + useEffect(() => { + const handleKeyPress = (event: KeyboardEvent) => { + if (event.code === "Space") { + event.preventDefault(); + handleTogglePlay(); + } + + if (event.key === "1") { + setGameSpeed("slow"); + } else if (event.key === "2") { + setGameSpeed("medium"); + } else if (event.key === "3") { + setGameSpeed("fast"); + } + }; + + window.addEventListener("keydown", handleKeyPress); + return () => window.removeEventListener("keydown", handleKeyPress); + }, []); + + useEffect(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + if (isPaused || isTransitioning) { + return; + } + + intervalRef.current = setInterval(() => { + setProgress((prev) => { + const speedMultiplier = GAME_SPEED_MULTIPLIER[gameSpeed]; + const increment = BASE_INCREMENT * speedMultiplier; + const newProgress = prev + increment; + + if (newProgress >= MAX_PROGRESS) { + setIsTransitioning(true); + + setProgress(0); + + setTimeout(() => { + onAdvanceDay(); + setIsTransitioning(false); + }, DAY_TRANSITION_PAUSE); + + return 0; + } + + return newProgress; + }); + }, TICK_INTERVAL); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [isPaused, gameSpeed, onAdvanceDay, isTransitioning]); + + const progressPercent = (progress / MAX_PROGRESS) * 100; + return ( - Day {day} - - - + +
+ + Day {day} + + +
+ + + + + + + { + if (value) setGameSpeed(value as GameSpeed); + }} + > + + + + + + + + + + + +
); }; - -export default Time; diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx new file mode 100644 index 000000000..1c876bbee --- /dev/null +++ b/src/components/ui/toggle-group.tsx @@ -0,0 +1,61 @@ +"use client" + +import * as React from "react" +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" +import { type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { toggleVariants } from "@/components/ui/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps +>({ + size: "default", + variant: "default", +}) + +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + + {children} + + +)) + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName + +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +}) + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName + +export { ToggleGroup, ToggleGroupItem } diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx new file mode 100644 index 000000000..ab0a30fb3 --- /dev/null +++ b/src/components/ui/toggle.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import * as TogglePrimitive from "@radix-ui/react-toggle" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const toggleVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-9 px-2 min-w-9", + sm: "h-8 px-1.5 min-w-8", + lg: "h-10 px-2.5 min-w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Toggle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, ...props }, ref) => ( + +)) + +Toggle.displayName = TogglePrimitive.Root.displayName + +export { Toggle, toggleVariants }