darkwatch/client/src/pages/CampaignView.tsx
Aaron Wood e2ce57527f feat: replace particle fire with Three.js FBM shader overlay
Switch fire effect from tsParticles to a full-screen Three.js WebGL
shader using layered FBM noise for volumetric-style flames rising from
the bottom of the screen. Also fix rain/embers canvas banding by
switching them to tsParticles fullScreen mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:17:36 -04:00

492 lines
15 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { useParams, Link } from "react-router-dom";
import socket from "../socket";
import {
getCampaigns,
getCharacters,
createCharacter,
updateCharacter,
deleteCharacter,
updateStat,
addGear,
removeGear,
addTalent,
removeTalent,
getRolls,
} from "../api";
import type { Character, Gear, Talent, GameItem, RollResult } from "../types";
import CharacterCard from "../components/CharacterCard";
import CharacterDetail from "../components/CharacterDetail";
import RollLog from "../components/RollLog";
import DiceTray from "../components/DiceTray";
import FogOverlay from "../components/FogOverlay";
import AtmospherePanel from "../components/AtmospherePanel";
import ParticleOverlay from "../components/ParticleOverlay";
import ThreeFireOverlay from "../components/ThreeFireOverlay";
import type { AtmosphereState } from "../lib/atmosphereTypes";
import { defaultAtmosphere } from "../lib/atmosphereTypes";
import SelectDropdown from "../components/SelectDropdown";
import styles from "./CampaignView.module.css";
const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"];
const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"];
export default function CampaignView() {
const { id } = useParams<{ id: string }>();
const campaignId = Number(id);
const [characters, setCharacters] = useState<Character[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [newChar, setNewChar] = useState({
name: "",
class: "Fighter",
ancestry: "Human",
hp_max: 1,
});
const [campaignName, setCampaignName] = useState("");
const [rolls, setRolls] = useState<RollResult[]>([]);
const [freshIds, setFreshIds] = useState<Set<number>>(new Set());
const [critKeys, setCritKeys] = useState<Set<string>>(new Set());
const [diceRoll, setDiceRoll] = useState<{
expression: string;
rolls: number[];
characterColor?: string;
id: number;
} | null>(null);
const pendingRollRef = useRef<RollResult | null>(null);
const [atmosphere, setAtmosphere] = useState<AtmosphereState>(defaultAtmosphere);
function handleAtmosphereChange(next: AtmosphereState) {
setAtmosphere(next);
socket.emit("atmosphere:update", { campaignId, ...next });
}
// Fetch characters and join socket room
useEffect(() => {
getCharacters(campaignId).then(setCharacters);
getRolls(campaignId).then(setRolls);
getCampaigns().then((camps) => {
const c = camps.find((x) => x.id === campaignId);
if (c) setCampaignName(c.name);
});
socket.emit("join-campaign", String(campaignId));
return () => {
socket.emit("leave-campaign", String(campaignId));
};
}, [campaignId]);
function showRollInLog(roll: RollResult) {
setRolls((prev) => [roll, ...prev].slice(0, 50));
setFreshIds((prev) => new Set(prev).add(roll.id));
setTimeout(() => {
setFreshIds((prev) => {
const next = new Set(prev);
next.delete(roll.id);
return next;
});
}, 2000);
// Track crits by character+weapon key
if (roll.nat20 && roll.character_id && roll.label.includes("attack")) {
const weaponName = roll.label.replace(" attack", "");
const key = `${roll.character_id}:${weaponName}`;
setCritKeys((prev) => new Set(prev).add(key));
}
if (roll.character_id && roll.label.includes("damage")) {
const weaponName = roll.label
.replace(" damage", "")
.replace(" (CRIT)", "");
const key = `${roll.character_id}:${weaponName}`;
setCritKeys((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}
}
function handleDiceComplete() {
if (pendingRollRef.current) {
showRollInLog(pendingRollRef.current);
pendingRollRef.current = null;
}
}
// Socket event listeners
useEffect(() => {
function onCharacterCreated(char: Character) {
setCharacters((prev) => {
if (prev.some((c) => c.id === char.id)) return prev;
return [...prev, char];
});
}
function onCharacterUpdated(data: Partial<Character> & { id: number }) {
setCharacters((prev) =>
prev.map((c) => (c.id === data.id ? { ...c, ...data } : c)),
);
}
function onCharacterDeleted({ id }: { id: number }) {
setCharacters((prev) => prev.filter((c) => c.id !== id));
setSelectedId((prev) => (prev === id ? null : prev));
}
function onStatUpdated({
characterId,
statName,
value,
}: {
characterId: number;
statName: string;
value: number;
}) {
setCharacters((prev) =>
prev.map((c) =>
c.id === characterId
? {
...c,
stats: c.stats.map((s) =>
s.stat_name === statName ? { ...s, value } : s,
),
}
: c,
),
);
}
function onGearAdded({
characterId,
gear,
}: {
characterId: number;
gear: Gear;
}) {
setCharacters((prev) =>
prev.map((c) =>
c.id === characterId
? { ...c, gear: [...c.gear.filter((g) => g.id !== gear.id), gear] }
: c,
),
);
}
function onGearRemoved({
characterId,
gearId,
}: {
characterId: number;
gearId: number;
}) {
setCharacters((prev) =>
prev.map((c) =>
c.id === characterId
? { ...c, gear: c.gear.filter((g) => g.id !== gearId) }
: c,
),
);
}
function onTalentAdded({
characterId,
talent,
}: {
characterId: number;
talent: Talent;
}) {
setCharacters((prev) =>
prev.map((c) =>
c.id === characterId
? {
...c,
talents: [
...c.talents.filter((t) => t.id !== talent.id),
talent,
],
}
: c,
),
);
}
function onTalentRemoved({
characterId,
talentId,
}: {
characterId: number;
talentId: number;
}) {
setCharacters((prev) =>
prev.map((c) =>
c.id === characterId
? { ...c, talents: c.talents.filter((t) => t.id !== talentId) }
: c,
),
);
}
function onRollResult(roll: RollResult) {
// Check if this roll can trigger 3D dice
const hasDice = /\d*d\d+/i.test(roll.dice_expression);
if (hasDice) {
// Queue roll — it will appear in log when dice animation finishes
pendingRollRef.current = roll;
setDiceRoll({
expression: roll.dice_expression,
rolls: roll.rolls,
characterColor: roll.character_color,
id: roll.id,
});
} else {
// No 3D dice — show immediately
showRollInLog(roll);
}
}
socket.on("character:created", onCharacterCreated);
socket.on("character:updated", onCharacterUpdated);
socket.on("character:deleted", onCharacterDeleted);
socket.on("stat:updated", onStatUpdated);
socket.on("gear:added", onGearAdded);
socket.on("gear:removed", onGearRemoved);
socket.on("talent:added", onTalentAdded);
socket.on("talent:removed", onTalentRemoved);
socket.on("roll:result", onRollResult);
function onAtmosphereUpdate(data: AtmosphereState) {
setAtmosphere(data);
}
socket.on("character:created", onCharacterCreated);
socket.on("character:updated", onCharacterUpdated);
socket.on("character:deleted", onCharacterDeleted);
socket.on("stat:updated", onStatUpdated);
socket.on("gear:added", onGearAdded);
socket.on("gear:removed", onGearRemoved);
socket.on("talent:added", onTalentAdded);
socket.on("talent:removed", onTalentRemoved);
socket.on("roll:result", onRollResult);
socket.on("atmosphere:update", onAtmosphereUpdate);
return () => {
socket.off("character:created", onCharacterCreated);
socket.off("character:updated", onCharacterUpdated);
socket.off("character:deleted", onCharacterDeleted);
socket.off("stat:updated", onStatUpdated);
socket.off("gear:added", onGearAdded);
socket.off("gear:removed", onGearRemoved);
socket.off("talent:added", onTalentAdded);
socket.off("talent:removed", onTalentRemoved);
socket.off("roll:result", onRollResult);
socket.off("atmosphere:update", onAtmosphereUpdate);
};
}, []);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!newChar.name.trim()) return;
try {
await createCharacter(campaignId, newChar);
setNewChar({ name: "", class: "Fighter", ancestry: "Human", hp_max: 1 });
setShowCreate(false);
} catch (err) {
console.error("Failed to create character:", err);
}
}
async function handleHpChange(characterId: number, hp: number) {
await updateCharacter(characterId, { hp_current: hp });
}
async function handleStatChange(
characterId: number,
statName: string,
value: number,
) {
await updateStat(characterId, statName, value);
}
async function handleUpdate(characterId: number, data: Partial<Character>) {
await updateCharacter(characterId, data);
}
async function handleDelete(characterId: number) {
await deleteCharacter(characterId);
setSelectedId(null);
}
async function handleAddGearFromItem(characterId: number, item: GameItem) {
await addGear(characterId, {
name: item.name,
type: item.type,
slot_count: item.slot_count,
properties: item.properties,
effects: item.effects,
game_item_id: item.id,
});
}
async function handleAddGearCustom(
characterId: number,
data: { name: string; type: string; slot_count: number },
) {
await addGear(characterId, data);
}
async function handleRemoveGear(characterId: number, gearId: number) {
await removeGear(characterId, gearId);
}
async function handleAddTalent(
characterId: number,
data: {
name: string;
description: string;
effect?: Record<string, unknown>;
game_talent_id?: number | null;
},
) {
await addTalent(characterId, data);
}
async function handleRemoveTalent(characterId: number, talentId: number) {
await removeTalent(characterId, talentId);
}
const selectedCharacter = characters.find((c) => c.id === selectedId) ?? null;
return (
<div className={styles.layout}>
<div className={styles.main}>
<div className={styles.header}>
<Link to="/" className={styles.backLink}>
Campaigns
</Link>
<span className={styles.campaignName}>
{campaignName || "Campaign"}
</span>
<div className={styles.headerBtns}>
<AtmospherePanel
atmosphere={atmosphere}
onAtmosphereChange={handleAtmosphereChange}
/>
<button
className={styles.addBtn}
onClick={() => setShowCreate(true)}
>
+ Add Character
</button>
</div>
</div>
<div className={styles.grid}>
{characters.length === 0 && (
<p className={styles.empty}>
No characters yet. Add one to get started!
</p>
)}
{characters.map((char) => (
<CharacterCard
key={char.id}
character={char}
onHpChange={handleHpChange}
onUpdate={handleUpdate}
onClick={setSelectedId}
/>
))}
</div>
{selectedCharacter && (
<CharacterDetail
character={selectedCharacter}
campaignId={campaignId}
critKeys={critKeys}
onUpdate={handleUpdate}
onStatChange={handleStatChange}
onAddGearFromItem={handleAddGearFromItem}
onAddGearCustom={handleAddGearCustom}
onRemoveGear={handleRemoveGear}
onAddTalent={handleAddTalent}
onRemoveTalent={handleRemoveTalent}
onDelete={handleDelete}
onClose={() => setSelectedId(null)}
/>
)}
{showCreate && (
<div
className={styles.createModal}
onClick={() => setShowCreate(false)}
>
<form
className={styles.createForm}
onClick={(e) => e.stopPropagation()}
onSubmit={handleCreate}
>
<div className={styles.createTitle}>New Character</div>
<div className={styles.formField}>
<label className={styles.formLabel}>Name</label>
<input
className={styles.formInput}
type="text"
value={newChar.name}
onChange={(e) =>
setNewChar({ ...newChar, name: e.target.value })
}
autoFocus
/>
</div>
<div className={styles.formField}>
<label className={styles.formLabel}>Class</label>
<SelectDropdown
value={newChar.class}
options={CLASSES}
onChange={(v) => setNewChar({ ...newChar, class: v })}
/>
</div>
<div className={styles.formField}>
<label className={styles.formLabel}>Ancestry</label>
<SelectDropdown
value={newChar.ancestry}
options={ANCESTRIES}
onChange={(v) => setNewChar({ ...newChar, ancestry: v })}
/>
</div>
<div className={styles.formField}>
<label className={styles.formLabel}>Max HP</label>
<input
className={styles.formInput}
type="number"
min={1}
value={newChar.hp_max}
onChange={(e) =>
setNewChar({ ...newChar, hp_max: Number(e.target.value) })
}
/>
</div>
<div className={styles.formActions}>
<button
type="button"
className={`${styles.formBtn} ${styles.formBtnSecondary}`}
onClick={() => setShowCreate(false)}
>
Cancel
</button>
<button
type="submit"
className={`${styles.formBtn} ${styles.formBtnPrimary}`}
>
Create
</button>
</div>
</form>
</div>
)}
</div>
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} />
<DiceTray roll={diceRoll} onAnimationComplete={handleDiceComplete} />
<FogOverlay active={atmosphere.fog.active} />
<ThreeFireOverlay active={atmosphere.fire.active} intensity={atmosphere.fire.intensity} />
<ParticleOverlay atmosphere={atmosphere} />
</div>
);
}