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>
492 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|