darkwatch/docs/plans/2026-04-10-particle-effects.md
Aaron Wood 7c7bdf2ee5 chore: consolidate docs into flat structure and commit all plans
- Move docs/superpowers/{plans,specs}/ → docs/{plans,specs}/
- Add 4 previously untracked implementation plans to git
- Update CLAUDE.md with docs path overrides for superpowers skills
- Update HANDBOOK.md repo structure and workflow paths
- Add per-enemy dice rolls to ROADMAP planned section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 23:55:45 -04:00

934 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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; // 0100
}
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 0100 → fire particle count 20200, speed 28 */
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 0100 → rain particle count 50500, speed 520 */
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 0100 → embers particle count 580, speed 0.21.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<void> | null = null;
function ensureEngine(): Promise<void> {
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 (
<div style={overlayStyle}>
{fire.active && (
<Particles
key={`fire-${fire.intensity}`}
id="particles-fire"
options={getFireConfig(fire.intensity)}
style={instanceStyle}
/>
)}
{rain.active && (
<Particles
key={`rain-${rain.intensity}`}
id="particles-rain"
options={getRainConfig(rain.intensity)}
style={instanceStyle}
/>
)}
{embers.active && (
<Particles
key={`embers-${embers.intensity}`}
id="particles-embers"
options={getEmbersConfig(embers.intensity)}
style={instanceStyle}
/>
)}
</div>
);
}
```
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<HTMLDivElement>(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 (
<div className={styles.container} ref={containerRef}>
<button
className={`${styles.trigger} ${activeEmojis ? styles.triggerActive : ""}`}
onClick={() => setOpen((o) => !o)}
title="Atmosphere effects"
>
{activeEmojis || "🌫"}
</button>
{open && (
<div className={styles.panel}>
<div className={styles.panelTitle}>Atmosphere</div>
{EFFECTS.map(({ key, emoji, label }) => (
<div key={key} className={styles.effectRow}>
<div className={styles.effectTop}>
<span className={styles.effectLabel}>{emoji} {label}</span>
<button
className={`${styles.toggle} ${atmosphere[key].active ? styles.toggleOn : ""}`}
onClick={() => toggleEffect(key)}
aria-label={`Toggle ${label}`}
/>
</div>
<div
className={`${styles.intensityRow} ${
atmosphere[key].active ? styles.intensityActive : ""
}`}
>
<span className={styles.intensityLabel}>
{localIntensity[key]}%
</span>
<input
type="range"
min={0}
max={100}
value={localIntensity[key]}
disabled={!atmosphere[key].active}
className={styles.slider}
onChange={(e) =>
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),
)
}
/>
</div>
</div>
))}
</div>
)}
</div>
);
}
```
- [ ] **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<AtmosphereState>(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
<button
className={`${styles.fogBtn} ${fogActive ? styles.fogBtnActive : ""}`}
onClick={() => {
const next = !fogActive;
setFogActive(next);
socket.emit("atmosphere:update", {
campaignId,
fog: next,
});
}}
title={fogActive ? "Clear fog" : "Summon fog"}
>
🌫
</button>
```
Replace with:
```tsx
<AtmospherePanel
atmosphere={atmosphere}
onAtmosphereChange={handleAtmosphereChange}
/>
```
- [ ] **Step 6: Update FogOverlay and add ParticleOverlay**
Find near the bottom of the JSX:
```tsx
<FogOverlay active={fogActive} />
```
Replace with:
```tsx
<FogOverlay active={atmosphere.fog.active} />
<ParticleOverlay atmosphere={atmosphere} />
```
- [ ] **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/