From 7ffb16d08b1efc91a89d5c7119dcd29830e95449 Mon Sep 17 00:00:00 2001 From: Aaron Wood Date: Fri, 10 Apr 2026 18:59:46 -0400 Subject: [PATCH] Add particle effects implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-04-10-particle-effects.md | 934 ++++++++++++++++++ 1 file changed, 934 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-10-particle-effects.md diff --git a/docs/superpowers/plans/2026-04-10-particle-effects.md b/docs/superpowers/plans/2026-04-10-particle-effects.md new file mode 100644 index 0000000..4e46520 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-particle-effects.md @@ -0,0 +1,934 @@ +# 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/