Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/components/Factory/Sidebar/GameSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
195 changes: 183 additions & 12 deletions src/components/Factory/Sidebar/Time.tsx
Original file line number Diff line number Diff line change
@@ -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<GameSpeed>("slow");
const [isTransitioning, setIsTransitioning] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(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 (
<SidebarGroup>
<SidebarGroupLabel>Day {day}</SidebarGroupLabel>
<InlineStack className="px-2">
<Button onClick={onAdvanceDay} className="w-full" size="sm">
Next Day
</Button>
</InlineStack>
<BlockStack gap="2">
<div
className="w-full hover:bg-accent rounded-lg p-2 cursor-pointer"
onClick={handleDaySkip}
>
<Text weight="semibold" size="sm">
Day {day}
</Text>
<Progress value={progressPercent} className="h-2" />
</div>

<InlineStack gap="2" blockAlign="center" className="w-full px-2">
<TooltipButton
value="pause"
className={cn(
"h-8 w-8",
isPaused
? "hover:bg-accent hover:text-foreground hover:border hover:border-foreground cursor-pointer"
: "bg-accent hover:text-background border-foreground border text-foreground",
)}
onClick={handleTogglePlay}
tooltip={isPaused ? "Play (Space)" : "Pause (Space)"}
>
<Icon name="Pause" size="sm" />
</TooltipButton>

<ToggleGroup
type="single"
value={gameSpeed}
onValueChange={(value) => {
if (value) setGameSpeed(value as GameSpeed);
}}
>
<ToggleGroupItem
value="slow"
className={cn(
"h-8 w-8",
isPaused && gameSpeed === "slow" && "border border-foreground",
!isPaused &&
gameSpeed === "slow" &&
"bg-foreground! text-background!",
)}
>
<Icon name="Play" size="sm" />
</ToggleGroupItem>
<ToggleGroupItem
value="medium"
className={cn(
"h-8 w-8",
isPaused &&
gameSpeed === "medium" &&
"border border-foreground",
!isPaused &&
gameSpeed === "medium" &&
"bg-foreground! text-background!",
)}
>
<Icon name="FastForward" size="sm" />
</ToggleGroupItem>
<ToggleGroupItem
value="fast"
className={cn(
"h-8 w-8",
isPaused && gameSpeed === "fast" && "border border-foreground",
!isPaused &&
gameSpeed === "fast" &&
"bg-foreground! text-background!",
)}
>
<Icon name="SkipForward" size="sm" />
</ToggleGroupItem>
</ToggleGroup>
</InlineStack>
</BlockStack>
</SidebarGroup>
);
};

export default Time;
61 changes: 61 additions & 0 deletions src/components/ui/toggle-group.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof toggleVariants>
>({
size: "default",
variant: "default",
})

const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))

ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName

const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)

return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})

ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName

export { ToggleGroup, ToggleGroupItem }
Loading