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([]); const [selectedId, setSelectedId] = useState(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([]); const [freshIds, setFreshIds] = useState>(new Set()); const [critKeys, setCritKeys] = useState>(new Set()); const [diceRoll, setDiceRoll] = useState<{ expression: string; rolls: number[]; characterColor?: string; id: number; } | null>(null); const pendingRollRef = useRef(null); const [atmosphere, setAtmosphere] = useState(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 & { 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) { 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; 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 (
← Campaigns {campaignName || "Campaign"}
{characters.length === 0 && (

No characters yet. Add one to get started!

)} {characters.map((char) => ( ))}
{selectedCharacter && ( setSelectedId(null)} /> )} {showCreate && (
setShowCreate(false)} >
e.stopPropagation()} onSubmit={handleCreate} >
New Character
setNewChar({ ...newChar, name: e.target.value }) } autoFocus />
setNewChar({ ...newChar, class: v })} />
setNewChar({ ...newChar, ancestry: v })} />
setNewChar({ ...newChar, hp_max: Number(e.target.value) }) } />
)}
); }