- 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>
934 lines
24 KiB
Markdown
934 lines
24 KiB
Markdown
# 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<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/
|