503 lines
16 KiB
TypeScript
503 lines
16 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,
|
|
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<Character[]>([]);
|
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
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);
|
|
const [focusSpells, setFocusSpells] = useState<Map<number, string>>(new Map());
|
|
const [combats, setCombats] = useState<CombatState[]>([]);
|
|
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<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);
|
|
}
|
|
}
|
|
|
|
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<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);
|
|
}
|
|
|
|
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 (
|
|
<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}>
|
|
{role === "dm" && (
|
|
<AtmospherePanel
|
|
atmosphere={atmosphere}
|
|
onAtmosphereChange={handleAtmosphereChange}
|
|
/>
|
|
)}
|
|
{role === "dm" && (
|
|
<button
|
|
className={`${styles.addBtn} ${combats.length > 0 ? styles.addBtnActive : ""}`}
|
|
onClick={() => setShowCombatStart(true)}
|
|
>
|
|
⚔ Combat
|
|
</button>
|
|
)}
|
|
{role === "dm" && (
|
|
<button className={styles.addBtn} onClick={handleInvite}>
|
|
Invite Player
|
|
</button>
|
|
)}
|
|
<button
|
|
className={styles.addBtn}
|
|
onClick={() => setShowCreate(true)}
|
|
>
|
|
+ Add Character
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`${styles.content} ${combats.length > 0 ? styles.withCombat : ""}`}>
|
|
{combats.length > 0 && (
|
|
<div className={styles.combatSidebar}>
|
|
<InitiativeTracker
|
|
combat={combats[0]}
|
|
characters={characters}
|
|
isDM={role === "dm"}
|
|
currentUserId={user?.userId ?? null}
|
|
campaignId={campaignId}
|
|
/>
|
|
</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}
|
|
canEdit={role === "dm" || char.user_id === user?.userId}
|
|
focusSpell={focusSpells.get(char.id)}
|
|
isDM={role === "dm"}
|
|
/>
|
|
))}
|
|
</div>
|
|
</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)}
|
|
canEdit={role === "dm" || selectedCharacter.user_id === user?.userId}
|
|
/>
|
|
)}
|
|
|
|
{showCreate && (
|
|
<CharacterWizard
|
|
campaignId={campaignId}
|
|
onSubmit={handleCreate}
|
|
onClose={() => setShowCreate(false)}
|
|
/>
|
|
)}
|
|
{showCombatStart && (
|
|
<CombatStartModal
|
|
characters={characters}
|
|
campaignId={campaignId}
|
|
onClose={() => setShowCombatStart(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} onUndoRoll={handleUndoRoll} />
|
|
<DiceTray roll={diceRoll} onAnimationComplete={handleDiceComplete} />
|
|
<FogOverlay active={atmosphere.fog.active} intensity={atmosphere.fog.intensity} />
|
|
<ThreeFireOverlay active={atmosphere.fire.active} intensity={atmosphere.fire.intensity} />
|
|
<ParticleOverlay atmosphere={atmosphere} />
|
|
</div>
|
|
);
|
|
}
|