# Particle Effects / Atmosphere Panel Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the single fog toggle in the campaign header with an atmosphere panel supporting fog (CSS, unchanged), fire, rain, and embers (tsParticles), each with an on/off toggle and intensity slider synced via socket. **Architecture:** `AtmospherePanel` is a self-contained popover component that receives atmosphere state and an `onAtmosphereChange` callback from `CampaignView`. `CampaignView` owns socket emit and state. `ParticleOverlay` renders a tsParticles instance per active particle effect (fire/rain/embers). `FogOverlay` is unchanged — fog stays as CSS. **Tech Stack:** React 18, TypeScript, `@tsparticles/react`, `@tsparticles/slim`, Socket.IO client, CSS Modules --- ## File Map | File | Action | |------|--------| | `client/src/lib/atmosphereTypes.ts` | Create — shared types and default state | | `client/src/lib/particleConfigs.ts` | Create — pure config functions per effect | | `client/src/components/ParticleOverlay.tsx` | Create — tsParticles renderer | | `client/src/components/AtmospherePanel.tsx` | Create — button + popover | | `client/src/components/AtmospherePanel.module.css` | Create — panel styles | | `client/src/pages/CampaignView.tsx` | Modify — state, socket, JSX | | `server/src/socket.ts` | Modify — update TypeScript type | | `client/package.json` | Modify — add tsParticles packages | --- ### Task 1: Install tsParticles and define shared types **Files:** - Modify: `client/package.json` - Create: `client/src/lib/atmosphereTypes.ts` - [ ] **Step 1: Install packages** ```bash cd /path/to/shadowdark/client npm install @tsparticles/react @tsparticles/slim ``` Expected output: packages added to node_modules, `package.json` updated with both deps. - [ ] **Step 2: Verify installation** ```bash ls node_modules/@tsparticles/ ``` Expected: `engine react slim` directories visible. - [ ] **Step 3: Create shared types file** Create `client/src/lib/atmosphereTypes.ts`: ```typescript export interface EffectState { active: boolean; intensity: number; // 0–100 } export interface AtmosphereState { fog: EffectState; fire: EffectState; rain: EffectState; embers: EffectState; } export const defaultAtmosphere: AtmosphereState = { fog: { active: false, intensity: 50 }, fire: { active: false, intensity: 50 }, rain: { active: false, intensity: 50 }, embers: { active: false, intensity: 50 }, }; ``` - [ ] **Step 4: Verify TypeScript compiles** ```bash cd client npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 5: Commit** ```bash git add client/package.json client/package-lock.json client/src/lib/atmosphereTypes.ts git commit -m "feat: install tsParticles and add atmosphere types" ``` --- ### Task 2: Implement particle configs **Files:** - Create: `client/src/lib/particleConfigs.ts` - [ ] **Step 1: Create particle configs file** Create `client/src/lib/particleConfigs.ts`: ```typescript import type { ISourceOptions } from "@tsparticles/engine"; /** Intensity 0–100 → fire particle count 20–200, speed 2–8 */ export function getFireConfig(intensity: number): ISourceOptions { const count = Math.round(20 + (intensity / 100) * 180); const speed = 2 + (intensity / 100) * 6; return { fullScreen: { enable: false }, background: { opacity: 0 }, particles: { number: { value: count, density: { enable: false } }, color: { value: ["#ff4400", "#ff8800", "#ffcc00", "#ff2200"] }, shape: { type: "circle" }, opacity: { value: { min: 0.1, max: 0.8 }, animation: { enable: true, speed: 2, startValue: "max", destroy: "min" }, }, size: { value: { min: 1, max: 4 } }, move: { enable: true, speed: { min: speed * 0.5, max: speed }, direction: "top", random: true, straight: false, outModes: { default: "destroy", top: "destroy" }, }, life: { duration: { sync: false, value: 3 }, count: 1 }, }, emitters: { direction: "top", life: { count: 0, duration: 0.1, delay: 0.1 }, rate: { delay: 0.05, quantity: Math.max(1, Math.round(count / 20)) }, size: { width: 100, height: 0 }, position: { x: 50, y: 100 }, }, }; } /** Intensity 0–100 → rain particle count 50–500, speed 5–20 */ export function getRainConfig(intensity: number): ISourceOptions { const count = Math.round(50 + (intensity / 100) * 450); const speed = 5 + (intensity / 100) * 15; return { fullScreen: { enable: false }, background: { opacity: 0 }, particles: { number: { value: count, density: { enable: false } }, color: { value: "#7aaad8" }, shape: { type: "circle" }, opacity: { value: { min: 0.2, max: 0.45 } }, size: { value: { min: 0.5, max: 1.5 } }, move: { enable: true, speed: speed, direction: "bottom", straight: true, angle: { value: 15, offset: 0 }, outModes: { default: "out" }, }, }, }; } /** Intensity 0–100 → embers particle count 5–80, speed 0.2–1.5 */ export function getEmbersConfig(intensity: number): ISourceOptions { const count = Math.round(5 + (intensity / 100) * 75); const speed = 0.2 + (intensity / 100) * 1.3; return { fullScreen: { enable: false }, background: { opacity: 0 }, particles: { number: { value: count, density: { enable: false } }, color: { value: ["#ff8800", "#ffaa00", "#ffcc44", "#ff6600"] }, shape: { type: "circle" }, opacity: { value: { min: 0.3, max: 0.9 }, animation: { enable: true, speed: 0.5, sync: false }, }, size: { value: { min: 1, max: 3 } }, move: { enable: true, speed: speed, direction: "top", random: true, straight: false, outModes: { default: "out" }, }, }, }; } ``` - [ ] **Step 2: Verify TypeScript compiles** ```bash cd client && npx tsc --noEmit ``` Expected: no errors. If `ISourceOptions` import fails, check `@tsparticles/engine` is a transitive dep of `@tsparticles/slim` — it should be. If not, run `npm install @tsparticles/engine`. - [ ] **Step 3: Commit** ```bash git add client/src/lib/particleConfigs.ts git commit -m "feat: add tsParticles configs for fire, rain, embers" ``` --- ### Task 3: Implement ParticleOverlay **Files:** - Create: `client/src/components/ParticleOverlay.tsx` - [ ] **Step 1: Create ParticleOverlay component** Create `client/src/components/ParticleOverlay.tsx`: ```typescript import { useEffect, useState } from "react"; import Particles, { initParticlesEngine } from "@tsparticles/react"; import { loadSlim } from "@tsparticles/slim"; import type { AtmosphereState } from "../lib/atmosphereTypes"; import { getFireConfig, getRainConfig, getEmbersConfig } from "../lib/particleConfigs"; // Module-level singleton so the engine is only initialised once let enginePromise: Promise | null = null; function ensureEngine(): Promise { if (!enginePromise) { enginePromise = initParticlesEngine(async (engine) => { await loadSlim(engine); }); } return enginePromise; } const overlayStyle: React.CSSProperties = { position: "fixed", inset: 0, zIndex: 9997, pointerEvents: "none", }; const instanceStyle: React.CSSProperties = { position: "absolute", inset: 0, }; interface ParticleOverlayProps { atmosphere: AtmosphereState; } export default function ParticleOverlay({ atmosphere }: ParticleOverlayProps) { const [ready, setReady] = useState(false); useEffect(() => { ensureEngine().then(() => setReady(true)); }, []); if (!ready) return null; const { fire, rain, embers } = atmosphere; const anyActive = fire.active || rain.active || embers.active; if (!anyActive) return null; return (
{fire.active && ( )} {rain.active && ( )} {embers.active && ( )}
); } ``` Note: `key={`effect-${intensity}`}` causes the Particles component to remount when intensity changes (committed via slider release), giving clean re-initialization. This avoids stale container state. - [ ] **Step 2: Verify TypeScript compiles** ```bash cd client && npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add client/src/components/ParticleOverlay.tsx git commit -m "feat: add ParticleOverlay component for tsParticles effects" ``` --- ### Task 4: Style AtmospherePanel **Files:** - Create: `client/src/components/AtmospherePanel.module.css` - [ ] **Step 1: Create CSS module** Create `client/src/components/AtmospherePanel.module.css`: ```css /* Container holds trigger + popover — position relative so popover anchors here */ .container { position: relative; } /* Trigger button — matches fogBtn style from CampaignView.module.css */ .trigger { padding: 0.4rem 0.5rem; background: none; border: 1px solid rgba(var(--gold-rgb), 0.2); border-radius: 4px; cursor: pointer; font-size: 1rem; line-height: 1; color: var(--text-primary); opacity: 0.5; transition: opacity 0.15s, border-color 0.15s; white-space: nowrap; } .trigger:hover { opacity: 0.8; border-color: rgba(var(--gold-rgb), 0.4); } /* When any effect is active */ .triggerActive { opacity: 1; border-color: rgba(var(--gold-rgb), 0.5); background: rgba(var(--gold-rgb), 0.1); } /* Popover panel */ .panel { position: absolute; top: calc(100% + 0.4rem); right: 0; background-color: var(--bg-modal); background-image: var(--texture-surface); background-size: 256px 256px; background-repeat: repeat; border: 1px solid rgba(var(--gold-rgb), 0.3); border-radius: 4px; padding: 0.5rem 0.6rem; min-width: 180px; z-index: 9999; box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.5), inset 0 1px 0 rgba(var(--gold-rgb), 0.06); } .panelTitle { font-family: "Cinzel", Georgia, serif; font-size: 0.7rem; letter-spacing: 0.1em; text-transform: uppercase; color: rgba(var(--gold-rgb), 0.6); border-bottom: 1px solid rgba(var(--gold-rgb), 0.15); padding-bottom: 0.35rem; margin-bottom: 0.4rem; } /* One row per effect */ .effectRow { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.3rem 0; border-bottom: 1px solid rgba(var(--gold-rgb), 0.08); } .effectRow:last-child { border-bottom: none; } .effectTop { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; } .effectLabel { font-family: "Alegreya", Georgia, serif; font-size: 0.85rem; color: var(--text-primary); } /* Toggle switch */ .toggle { position: relative; width: 32px; height: 17px; flex-shrink: 0; border-radius: 9px; background: rgba(var(--gold-rgb), 0.1); border: 1px solid rgba(var(--gold-rgb), 0.2); cursor: pointer; transition: background 0.2s, border-color 0.2s; } .toggle::after { content: ""; position: absolute; top: 2px; left: 2px; width: 11px; height: 11px; border-radius: 50%; background: rgba(var(--gold-rgb), 0.35); transition: transform 0.2s, background 0.2s; } .toggleOn { background: rgba(var(--gold-rgb), 0.25); border-color: rgba(var(--gold-rgb), 0.5); } .toggleOn::after { transform: translateX(15px); background: var(--gold); } /* Intensity row — dimmed when effect is off */ .intensityRow { display: flex; align-items: center; gap: 0.4rem; opacity: 0.3; transition: opacity 0.15s; } .intensityActive { opacity: 1; } .intensityLabel { font-family: "Alegreya", Georgia, serif; font-size: 0.75rem; color: var(--text-secondary); width: 2.2rem; text-align: right; flex-shrink: 0; } .slider { flex: 1; -webkit-appearance: none; appearance: none; height: 3px; border-radius: 2px; background: rgba(var(--gold-rgb), 0.15); outline: none; cursor: pointer; } .slider::-webkit-slider-thumb { -webkit-appearance: none; width: 11px; height: 11px; border-radius: 50%; background: rgba(var(--gold-rgb), 0.4); cursor: pointer; transition: background 0.15s; } .slider:not(:disabled)::-webkit-slider-thumb { background: var(--gold); } .slider::-moz-range-thumb { width: 11px; height: 11px; border-radius: 50%; border: none; background: var(--gold); cursor: pointer; } ``` - [ ] **Step 2: Commit** ```bash git add client/src/components/AtmospherePanel.module.css git commit -m "feat: add AtmospherePanel CSS module" ``` --- ### Task 5: Implement AtmospherePanel component **Files:** - Create: `client/src/components/AtmospherePanel.tsx` - [ ] **Step 1: Create AtmospherePanel component** Create `client/src/components/AtmospherePanel.tsx`: ```typescript 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), ) } />
))}
)}
); } ``` - [ ] **Step 2: Verify TypeScript compiles** ```bash cd client && npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add client/src/components/AtmospherePanel.tsx git commit -m "feat: add AtmospherePanel component with toggles and intensity sliders" ``` --- ### Task 6: Update CampaignView **Files:** - Modify: `client/src/pages/CampaignView.tsx` - [ ] **Step 1: Update imports** In `CampaignView.tsx`, replace the existing FogOverlay import block with: ```typescript import FogOverlay from "../components/FogOverlay"; import AtmospherePanel from "../components/AtmospherePanel"; import ParticleOverlay from "../components/ParticleOverlay"; import type { AtmosphereState } from "../lib/atmosphereTypes"; import { defaultAtmosphere } from "../lib/atmosphereTypes"; ``` - [ ] **Step 2: Replace fogActive state** Find and remove: ```typescript const [fogActive, setFogActive] = useState(false); ``` Add in its place: ```typescript const [atmosphere, setAtmosphere] = useState(defaultAtmosphere); ``` - [ ] **Step 3: Add atmosphere change handler** After the state declarations, add: ```typescript function handleAtmosphereChange(next: AtmosphereState) { setAtmosphere(next); socket.emit("atmosphere:update", { campaignId, ...next }); } ``` - [ ] **Step 4: Update socket listener** Find the existing `atmosphere:update` listener: ```typescript socket.on("atmosphere:update", (data: { fog: boolean }) => { setFogActive(data.fog); }); ``` Replace with: ```typescript socket.on("atmosphere:update", (data: AtmosphereState) => { setAtmosphere(data); }); ``` Also update the `socket.off` call: ```typescript socket.off("atmosphere:update"); ``` (No change needed — it's already correct.) - [ ] **Step 5: Replace fog button in JSX with AtmospherePanel** Find in JSX: ```tsx ``` Replace with: ```tsx ``` - [ ] **Step 6: Update FogOverlay and add ParticleOverlay** Find near the bottom of the JSX: ```tsx ``` Replace with: ```tsx ``` - [ ] **Step 7: Verify TypeScript compiles** ```bash cd client && npx tsc --noEmit ``` Expected: no errors. Common issue: if `fogBtn`/`fogBtnActive` CSS classes are now unused in the CSS module but referenced nowhere, TypeScript won't complain — leave them in the CSS for now or remove if desired. - [ ] **Step 8: Commit** ```bash git add client/src/pages/CampaignView.tsx git commit -m "feat: wire AtmospherePanel and ParticleOverlay into CampaignView" ``` --- ### Task 7: Update server socket type **Files:** - Modify: `server/src/socket.ts` - [ ] **Step 1: Update the atmosphere:update handler type** In `server/src/socket.ts`, find: ```typescript socket.on( "atmosphere:update", (data: { campaignId: number; fog: boolean }) => { io.to(`campaign:${data.campaignId}`).emit("atmosphere:update", { fog: data.fog, }); }, ); ``` Replace with: ```typescript interface EffectState { active: boolean; intensity: number; } interface AtmosphereUpdateData { campaignId: number; fog: EffectState; fire: EffectState; rain: EffectState; embers: EffectState; } socket.on("atmosphere:update", (data: AtmosphereUpdateData) => { const { campaignId, ...atmosphere } = data; io.to(`campaign:${campaignId}`).emit("atmosphere:update", atmosphere); }); ``` Place the `EffectState` and `AtmosphereUpdateData` interfaces at the top of the `socket.ts` file (or just above the handler — either works since this file has no other shared types). - [ ] **Step 2: Verify server TypeScript compiles** ```bash cd server && npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add server/src/socket.ts git commit -m "feat: update atmosphere:update socket type for full effect state" ``` --- ### Task 8: Visual verification **Files:** none - [ ] **Step 1: Start the server and client** In two terminals: ```bash # Terminal 1 cd server && npm run dev # Terminal 2 cd client && npm run dev ``` - [ ] **Step 2: Open the app and navigate to a campaign** Open `http://localhost:5173` (or whatever Vite port). Open or create a campaign. - [ ] **Step 3: Verify the fog button is replaced** The header should show a single `🌫 ▾` button where the fog button was. Clicking it should open a popover with 4 effect rows (Fog, Fire, Rain, Embers), each with a toggle and a dimmed slider. - [ ] **Step 4: Test fog (CSS)** Toggle Fog on. The CSS fog overlay should drift across the screen (unchanged from before). Toggle it off — fog clears. - [ ] **Step 5: Test fire** Toggle Fire on. Particles should rise from the bottom — orange/red/yellow sparks. Adjust the slider and release — particle density should visibly change. Toggle off — particles stop. - [ ] **Step 6: Test rain** Toggle Rain on. Blue-ish particles should fall from the top. High intensity = heavy rain. Toggle off. - [ ] **Step 7: Test embers** Toggle Embers on. Slow-drifting amber dots should float upward. Toggle off. - [ ] **Step 8: Test stacking** Enable Fog + Embers simultaneously. Both should render correctly — fog CSS overlay above the ember particles (z-index 9998 vs 9997). - [ ] **Step 9: Test socket sync** Open a second browser tab on the same campaign. Toggle fire on in one tab — verify it appears in the other tab. Adjust intensity and release slider — verify it syncs to the other tab. - [ ] **Step 10: Commit if any tweaks were made** ```bash git add -p # stage only intentional changes git commit -m "fix: tweak particle configs after visual verification" ``` If particle effects look poor out of the box, adjust the count/speed/color values in `particleConfigs.ts`. The tsParticles config reference is at https://particles.js.org/docs/