Add particle effects implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
646a16cd0f
commit
7ffb16d08b
1 changed files with 934 additions and 0 deletions
934
docs/superpowers/plans/2026-04-10-particle-effects.md
Normal file
934
docs/superpowers/plans/2026-04-10-particle-effects.md
Normal file
|
|
@ -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<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/
|
||||
Loading…
Add table
Reference in a new issue