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, getMyCampaignRole, generateInvite, undoRoll, } from "../api"; import { useAuth } from "../context/AuthContext"; import type { Character, Gear, Talent, GameItem, RollResult, CombatState } from "../types.js"; import InitiativeTracker from "../components/InitiativeTracker.js"; import CombatStartModal from "../components/CombatStartModal.js"; 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 CharacterWizard from "../components/CharacterWizard"; import type { CreateCharacterData } from "../api"; import styles from "./CampaignView.module.css"; export default function CampaignView() { const { id } = useParams<{ id: string }>(); const campaignId = Number(id); const { user } = useAuth(); const [role, setRole] = useState<"dm" | "player" | null>(null); const [characters, setCharacters] = useState([]); const [selectedId, setSelectedId] = useState(null); const [showCreate, setShowCreate] = useState(false); 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); const [focusSpells, setFocusSpells] = useState>(new Map()); const [combats, setCombats] = useState([]); const [showCombatStart, setShowCombatStart] = useState(false); 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); getMyCampaignRole(campaignId).then((r) => setRole(r.role)).catch(() => {}); getCampaigns().then((camps) => { const c = camps.find((x) => x.id === campaignId); if (c) setCampaignName(c.name); }); socket.emit("join-campaign", String(campaignId)); socket.emit("initiative:request-state", 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); } } function onAtmosphereUpdate(data: AtmosphereState) { setAtmosphere(data); } function onRollUndone({ rollId }: { rollId: number }) { setRolls((prev) => prev.map((r) => r.id === rollId ? { ...r, undone: true } : r)); } function onCharacterRested({ characterId }: { characterId: number }) { // SpellList local state already updated via handleRest; suppress unused warning void characterId; } function onInitiativeState(data: CombatState[]) { setCombats(data); } function onInitiativeUpdated(data: CombatState[]) { setCombats(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); socket.on("roll:undone", onRollUndone); socket.on("character:rested", onCharacterRested); socket.on("initiative:state", onInitiativeState); socket.on("initiative:updated", onInitiativeUpdated); 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); socket.off("roll:undone", onRollUndone); socket.off("character:rested", onCharacterRested); socket.off("initiative:state", onInitiativeState); socket.off("initiative:updated", onInitiativeUpdated); }; }, []); async function handleCreate(data: CreateCharacterData) { try { await createCharacter(campaignId, data); setShowCreate(false); } catch (err) { console.error("Failed to create character:", err); } } async function handleHpChange(characterId: number, hp: number) { const clampedHp = Math.max(0, hp); await updateCharacter(characterId, { hp_current: clampedHp }); } 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); } async function handleUndoRoll(rollId: number) { await undoRoll(campaignId, rollId); setRolls((prev) => prev.map((r) => r.id === rollId ? { ...r, undone: true } : r)); } async function handleInvite() { try { const { url } = await generateInvite(campaignId); await navigator.clipboard.writeText(url); alert("Invite link copied to clipboard!"); } catch { alert("Failed to generate invite link"); } } const selectedCharacter = characters.find((c) => c.id === selectedId) ?? null; return (
← Campaigns {campaignName || "Campaign"}
{role === "dm" && ( )} {role === "dm" && ( )} {role === "dm" && ( )}
0 ? styles.withCombat : ""}`}> {combats.length > 0 && (
)}
{characters.length === 0 && (

No characters yet. Add one to get started!

)} {characters.map((char) => ( ))}
{selectedCharacter && ( setSelectedId(null)} canEdit={role === "dm" || selectedCharacter.user_id === user?.userId} /> )} {showCreate && ( setShowCreate(false)} /> )} {showCombatStart && ( setShowCombatStart(false)} /> )}
); }