From 55e9019fa926eb1f63bad1a05ede3be91c762754 Mon Sep 17 00:00:00 2001 From: Aaron Wood Date: Fri, 10 Apr 2026 19:29:00 -0400 Subject: [PATCH] feat: add AtmospherePanel component with toggles and intensity sliders --- client/src/components/AtmospherePanel.tsx | 141 ++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 client/src/components/AtmospherePanel.tsx diff --git a/client/src/components/AtmospherePanel.tsx b/client/src/components/AtmospherePanel.tsx new file mode 100644 index 0000000..6ce70ef --- /dev/null +++ b/client/src/components/AtmospherePanel.tsx @@ -0,0 +1,141 @@ +import { useEffect, useRef, useState } from "react"; +import type { AtmosphereState } from "../lib/atmosphereTypes"; +import styles from "./AtmospherePanel.module.css"; + +type EffectKey = keyof AtmosphereState; + +const EFFECTS: { key: EffectKey; emoji: string; label: string }[] = [ + { key: "fog", emoji: "🌫", label: "Fog" }, + { key: "fire", emoji: "🔥", label: "Fire" }, + { key: "rain", emoji: "🌧", label: "Rain" }, + { key: "embers", emoji: "✨", label: "Embers" }, +]; + +interface AtmospherePanelProps { + atmosphere: AtmosphereState; + onAtmosphereChange: (next: AtmosphereState) => void; +} + +export default function AtmospherePanel({ + atmosphere, + onAtmosphereChange, +}: AtmospherePanelProps) { + const [open, setOpen] = useState(false); + // Local slider values so the label updates while dragging without triggering remounts + const [localIntensity, setLocalIntensity] = useState({ + fog: atmosphere.fog.intensity, + fire: atmosphere.fire.intensity, + rain: atmosphere.rain.intensity, + embers: atmosphere.embers.intensity, + }); + const containerRef = useRef(null); + + // Close panel on outside click + useEffect(() => { + if (!open) return; + function onMouseDown(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", onMouseDown); + return () => document.removeEventListener("mousedown", onMouseDown); + }, [open]); + + // Sync local intensity when atmosphere changes from socket + useEffect(() => { + setLocalIntensity({ + fog: atmosphere.fog.intensity, + fire: atmosphere.fire.intensity, + rain: atmosphere.rain.intensity, + embers: atmosphere.embers.intensity, + }); + }, [atmosphere]); + + const activeEmojis = EFFECTS + .filter(({ key }) => atmosphere[key].active) + .map(({ emoji }) => emoji) + .join(""); + + function toggleEffect(key: EffectKey) { + onAtmosphereChange({ + ...atmosphere, + [key]: { ...atmosphere[key], active: !atmosphere[key].active }, + }); + } + + function handleSliderChange(key: EffectKey, value: number) { + // Update label only — no socket emit yet + setLocalIntensity((prev) => ({ ...prev, [key]: value })); + } + + function handleSliderCommit(key: EffectKey, value: number) { + // Emit on mouse/touch release + onAtmosphereChange({ + ...atmosphere, + [key]: { ...atmosphere[key], intensity: value }, + }); + } + + return ( +
+ + + {open && ( +
+
Atmosphere
+ {EFFECTS.map(({ key, emoji, label }) => ( +
+
+ {emoji} {label} +
+
+ + {localIntensity[key]}% + + + handleSliderChange(key, Number(e.target.value)) + } + onMouseUp={(e) => + handleSliderCommit( + key, + Number((e.target as HTMLInputElement).value), + ) + } + onTouchEnd={(e) => + handleSliderCommit( + key, + Number((e.currentTarget as HTMLInputElement).value), + ) + } + /> +
+
+ ))} +
+ )} +
+ ); +}