feat: spellcasting UI — SpellList, cast result modal, undo button, rest button, focus indicator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aaron Wood 2026-04-11 11:49:29 -04:00
parent ba02ffcfcb
commit 9399fc2186
12 changed files with 804 additions and 16 deletions

View file

@ -73,10 +73,18 @@
.meta {
font-size: 0.85rem;
color: var(--text-tertiary);
margin-bottom: 0.6rem;
margin-bottom: 0.3rem;
margin-left: 3.4rem;
}
.focusIndicator {
font-size: 0.72rem;
color: var(--gold);
font-weight: 600;
margin-left: 3.4rem;
margin-bottom: 0.4rem;
}
.vitalsRow {
display: flex;
align-items: center;

View file

@ -21,6 +21,7 @@ interface CharacterCardProps {
onUpdate: (characterId: number, data: Partial<Character>) => void;
onClick: (characterId: number) => void;
canEdit?: boolean;
focusSpell?: string;
}
export default function CharacterCard({
@ -29,6 +30,7 @@ export default function CharacterCard({
onUpdate,
onClick,
canEdit = true,
focusSpell,
}: CharacterCardProps) {
return (
<div
@ -49,6 +51,11 @@ export default function CharacterCard({
<div className={styles.meta}>
{character.ancestry} {character.class}
</div>
{focusSpell && (
<div className={styles.focusIndicator}>
&#9679; Focusing: {focusSpell}
</div>
)}
<div className={styles.vitalsRow} onClick={(e) => e.stopPropagation()}>
<HpBar

View file

@ -96,6 +96,32 @@
color: var(--text-primary);
}
.restRow {
margin-top: 1rem;
display: flex;
justify-content: flex-end;
}
.restBtn {
font-family: "Cinzel", Georgia, serif;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.05em;
padding: 0.4rem 1rem;
background: transparent;
border: 1px solid rgba(var(--gold-rgb), 0.35);
border-radius: 3px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.restBtn:hover {
background: rgba(var(--gold-rgb), 0.08);
border-color: rgba(var(--gold-rgb), 0.55);
color: var(--gold);
}
@media (max-width: 768px) {
.overlay {
position: fixed;

View file

@ -1,6 +1,9 @@
import { useState } from "react";
import type { Character, GameItem } from "../types";
import { useState, useEffect } from "react";
import type { Character, GameItem, CharacterSpell, SpellCastResult } from "../types";
import { getCharacterSpells, restCharacter } from "../api";
import CharacterSheet from "./CharacterSheet";
import SpellList from "./SpellList";
import SpellCastResultModal from "./SpellCastResult";
import styles from "./CharacterDetail.module.css";
interface CharacterDetailProps {
@ -46,6 +49,19 @@ export default function CharacterDetail({
canEdit = true,
}: CharacterDetailProps) {
const [mode, setMode] = useState<"view" | "edit">("view");
const [spells, setSpells] = useState<CharacterSpell[]>([]);
const [castResult, setCastResult] = useState<{ result: SpellCastResult; spellName: string } | null>(null);
useEffect(() => {
if (["Wizard", "Priest"].includes(character.class)) {
getCharacterSpells(character.id).then(setSpells);
}
}, [character.id, character.class]);
async function handleRest() {
await restCharacter(character.id);
setSpells((prev) => prev.map((s) => ({ ...s, exhausted: 0, focus_active: 0, focus_started_at: null })));
}
return (
<div className={styles.overlay} onClick={onClose}>
@ -78,7 +94,37 @@ export default function CharacterDetail({
onRemoveTalent={onRemoveTalent}
onDelete={onDelete}
/>
<SpellList
characterId={character.id}
characterClass={character.class}
spells={spells}
mode={mode}
canEdit={canEdit}
onSpellAdded={(spell) => setSpells((prev) => [...prev, spell])}
onSpellRemoved={(spellId) =>
setSpells((prev) => prev.filter((s) => s.spell_id !== spellId))
}
onSpellCast={(result, spellName) => setCastResult({ result, spellName })}
onSpellsUpdated={setSpells}
/>
{canEdit && ["Wizard", "Priest"].includes(character.class) && (
<div className={styles.restRow}>
<button className={styles.restBtn} onClick={handleRest}>
Take Rest
</button>
</div>
)}
</div>
{castResult && (
<SpellCastResultModal
result={castResult.result}
spellName={castResult.spellName}
onClose={() => setCastResult(null)}
/>
)}
</div>
);
}

View file

@ -129,6 +129,36 @@
text-transform: uppercase;
}
.undoRow {
margin-top: 0.25rem;
display: flex;
justify-content: flex-end;
}
.undoBtn {
font-size: 0.65rem;
font-weight: 600;
padding: 0.15rem 0.5rem;
background: none;
border: 1px solid rgba(var(--danger-rgb), 0.35);
border-radius: 2px;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.12s;
}
.undoBtn:hover {
color: var(--danger);
border-color: rgba(var(--danger-rgb), 0.6);
background: rgba(var(--danger-rgb), 0.06);
}
.reverted {
font-size: 0.65rem;
color: var(--text-faint);
font-style: italic;
}
.critBanner {
font-family: "Cinzel", Georgia, serif;
text-align: center;

View file

@ -4,6 +4,7 @@ import styles from "./RollEntry.module.css";
interface RollEntryProps {
roll: RollResult;
fresh?: boolean;
onUndo?: () => void;
}
function timeAgo(dateStr: string): string {
@ -18,7 +19,7 @@ function timeAgo(dateStr: string): string {
return `${hours}h ago`;
}
export default function RollEntry({ roll, fresh }: RollEntryProps) {
export default function RollEntry({ roll, fresh, onUndo }: RollEntryProps) {
const { rolls, advantage, disadvantage, dice_expression } = roll;
const isAdvantage = advantage && rolls.length === 2;
@ -77,6 +78,19 @@ export default function RollEntry({ roll, fresh }: RollEntryProps) {
</div>
)}
<div className={styles.total}>{roll.total}</div>
{roll.subtype === "spell_cast" && (
<div className={styles.undoRow}>
{roll.undone ? (
<span className={styles.reverted}>Reverted</span>
) : (
onUndo && (
<button className={styles.undoBtn} onClick={onUndo}>
Undo
</button>
)
)}
</div>
)}
</div>
);
}

View file

@ -8,11 +8,12 @@ interface RollLogProps {
campaignId: number;
rolls: RollResult[];
freshIds: Set<number>;
onUndoRoll?: (rollId: number) => void;
}
type MobileState = "hidden" | "peek" | "open";
export default function RollLog({ campaignId, rolls, freshIds }: RollLogProps) {
export default function RollLog({ campaignId, rolls, freshIds, onUndoRoll }: RollLogProps) {
const [collapsed, setCollapsed] = useState(false);
const [mobileState, setMobileState] = useState<MobileState>("peek");
const [input, setInput] = useState("");
@ -96,7 +97,12 @@ export default function RollLog({ campaignId, rolls, freshIds }: RollLogProps) {
<div className={styles.entries}>
{rolls.length === 0 && <p className={styles.empty}>No rolls yet</p>}
{rolls.map((roll) => (
<RollEntry key={roll.id} roll={roll} fresh={freshIds.has(roll.id)} />
<RollEntry
key={roll.id}
roll={roll}
fresh={freshIds.has(roll.id)}
onUndo={onUndoRoll ? () => onUndoRoll(roll.id) : undefined}
/>
))}
</div>
</div>

View file

@ -0,0 +1,160 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(var(--shadow-rgb), 0.82);
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
padding: 1rem;
animation: fadeIn 0.15s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
background-color: var(--bg-modal);
background-image: var(--texture-surface), var(--texture-speckle);
background-size: 256px 256px, 128px 128px;
background-repeat: repeat, repeat;
border: 2px solid rgba(var(--gold-rgb), 0.3);
border-radius: 6px;
padding: 2rem 2.5rem;
max-width: 420px;
width: 100%;
text-align: center;
box-shadow:
0 12px 48px rgba(var(--shadow-rgb), 0.8),
0 4px 12px rgba(var(--shadow-rgb), 0.5),
inset 0 1px 0 rgba(var(--gold-rgb), 0.1);
animation: popIn 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes popIn {
from { transform: scale(0.88); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.spellName {
font-family: var(--font-display, "Cinzel", Georgia, serif);
font-size: 1.15rem;
font-weight: 700;
color: var(--gold);
letter-spacing: 0.06em;
margin-bottom: 0.75rem;
}
.roll {
font-size: 0.9rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.dc {
color: var(--text-tertiary);
}
/* ── Result label variants ── */
.resultLabel {
font-family: var(--font-display, "Cinzel", Georgia, serif);
font-size: 1.6rem;
font-weight: 700;
letter-spacing: 0.06em;
margin: 0.75rem 0;
text-transform: uppercase;
}
.success {
color: var(--gold);
text-shadow: 0 0 16px rgba(var(--gold-rgb), 0.45);
}
.failure {
color: var(--text-secondary);
}
.critSuccess {
color: var(--crit);
text-shadow:
0 0 20px rgba(var(--crit-rgb), 0.6),
0 0 40px rgba(var(--crit-rgb), 0.3);
animation: critGlow 1s ease-in-out infinite alternate;
}
@keyframes critGlow {
from { text-shadow: 0 0 16px rgba(var(--crit-rgb), 0.5), 0 0 32px rgba(var(--crit-rgb), 0.2); }
to { text-shadow: 0 0 28px rgba(var(--crit-rgb), 0.8), 0 0 56px rgba(var(--crit-rgb), 0.4); }
}
.critFail {
color: var(--danger);
text-shadow: 0 0 16px rgba(var(--danger-rgb), 0.5);
}
/* ── Notes ── */
.note {
font-size: 0.82rem;
color: var(--text-secondary);
font-style: italic;
margin: 0.5rem 0 0;
}
/* ── Mishap block ── */
.mishap {
margin-top: 1rem;
background: rgba(var(--danger-rgb), 0.08);
border: 1px solid rgba(var(--danger-rgb), 0.3);
border-radius: 4px;
padding: 0.75rem 1rem;
text-align: left;
}
.mishapTitle {
font-family: var(--font-display, "Cinzel", Georgia, serif);
font-size: 0.85rem;
font-weight: 700;
color: var(--danger);
margin-bottom: 0.4rem;
letter-spacing: 0.04em;
}
.mishapDesc {
font-size: 0.8rem;
color: var(--text-primary);
line-height: 1.45;
margin: 0 0 0.4rem;
}
.mishapEffect {
font-size: 0.78rem;
color: var(--danger);
font-weight: 600;
margin: 0;
}
/* ── Dismiss button ── */
.closeBtn {
font-family: var(--font-display, "Cinzel", Georgia, serif);
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.06em;
padding: 0.5rem 1.5rem;
margin-top: 1.25rem;
background: var(--btn-gold-bg);
border: none;
border-radius: 3px;
color: var(--btn-active-text);
cursor: pointer;
transition: filter 0.15s;
}
.closeBtn:hover {
filter: brightness(1.1);
}

View file

@ -0,0 +1,56 @@
import type { SpellCastResult as CastResult } from "../types.js";
import styles from "./SpellCastResult.module.css";
interface Props {
result: CastResult;
spellName: string;
onClose: () => void;
}
export default function SpellCastResult({ result, spellName, onClose }: Props) {
const labels = {
success: { text: "Success", cls: styles.success },
failure: { text: "Failure", cls: styles.failure },
crit_success: { text: "Critical Success!", cls: styles.critSuccess },
crit_fail: { text: "Critical Failure!", cls: styles.critFail },
};
const label = labels[result.result];
const mishap = result.mishapResult as Record<string, unknown> | null;
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.spellName}>{spellName}</div>
<div className={styles.roll}>
{result.roll} {result.modifier >= 0 ? "+" : ""}
{result.modifier} = {result.total}
<span className={styles.dc}> vs DC {result.dc}</span>
</div>
<div className={`${styles.resultLabel} ${label.cls}`}>{label.text}</div>
{result.result === "failure" && (
<p className={styles.note}>Spell exhausted until rest.</p>
)}
{result.result === "crit_success" && (
<p className={styles.note}>Double one numerical effect!</p>
)}
{result.result === "crit_fail" && mishap && (
<div className={styles.mishap}>
<div className={styles.mishapTitle}>&#9888; Wizard Mishap</div>
<p className={styles.mishapDesc}>{String(mishap.description ?? "")}</p>
{mishap.damage ? (
<p className={styles.mishapEffect}>Took {String(mishap.damage)} damage</p>
) : null}
</div>
)}
{result.result === "crit_fail" && !mishap && (
<p className={styles.note}>Spell exhausted. Deity is displeased &mdash; penance required.</p>
)}
<button className={styles.closeBtn} onClick={onClose}>
Dismiss
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,255 @@
.container {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid rgba(var(--gold-rgb), 0.15);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.sectionTitle {
font-family: var(--font-display, "Cinzel", Georgia, serif);
font-size: 0.9rem;
font-weight: 700;
color: var(--gold);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.addBtn {
font-family: var(--font-display, "Cinzel", Georgia, serif);
font-size: 0.72rem;
font-weight: 600;
padding: 0.3rem 0.65rem;
background: transparent;
border: 1px solid rgba(var(--gold-rgb), 0.4);
border-radius: 3px;
color: var(--gold);
cursor: pointer;
letter-spacing: 0.04em;
transition: all 0.15s;
}
.addBtn:hover {
background: rgba(var(--gold-rgb), 0.1);
border-color: var(--gold);
}
/* ── Picker ── */
.picker {
background: var(--bg-inset);
border: 1px solid rgba(var(--gold-rgb), 0.15);
border-radius: 4px;
padding: 0.5rem;
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 260px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(var(--gold-rgb), 0.2) transparent;
}
.pickerItem {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: 1px solid transparent;
border-radius: 3px;
padding: 0.4rem 0.6rem;
text-align: left;
cursor: pointer;
color: var(--text-primary);
font-size: 0.8rem;
transition: all 0.12s;
}
.pickerItem:hover {
background: rgba(var(--gold-rgb), 0.08);
border-color: rgba(var(--gold-rgb), 0.25);
}
.tierBadge {
font-family: var(--font-display, "Cinzel", Georgia, serif);
font-size: 0.65rem;
font-weight: 700;
color: var(--gold);
background: rgba(var(--gold-rgb), 0.1);
border: 1px solid rgba(var(--gold-rgb), 0.25);
border-radius: 2px;
padding: 0.1rem 0.3rem;
flex-shrink: 0;
}
.pickerName {
flex: 1;
color: var(--text-primary);
}
.focusBadge {
font-size: 0.65rem;
color: var(--gold);
background: rgba(var(--gold-rgb), 0.08);
border: 1px solid rgba(var(--gold-rgb), 0.2);
border-radius: 2px;
padding: 0.1rem 0.3rem;
}
/* ── Empty state ── */
.empty {
color: var(--text-tertiary);
font-size: 0.8rem;
font-style: italic;
margin: 0.25rem 0;
}
/* ── Tier groups ── */
.tierGroup {
margin-bottom: 0.75rem;
}
.tierLabel {
font-family: var(--font-display, "Cinzel", Georgia, serif);
font-size: 0.68rem;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 0.4rem;
padding-bottom: 0.2rem;
border-bottom: 1px solid rgba(var(--gold-rgb), 0.1);
}
/* ── Individual spell row ── */
.spell {
background: var(--bg-inset);
border: 1px solid rgba(var(--gold-rgb), 0.1);
border-radius: 4px;
padding: 0.6rem 0.75rem;
margin-bottom: 0.4rem;
transition: border-color 0.15s, opacity 0.15s;
}
.spell.exhausted {
opacity: 0.5;
}
.spell.exhausted .spellName {
text-decoration: line-through;
color: var(--text-tertiary);
}
.spell.focusing {
border-color: rgba(var(--gold-rgb), 0.55);
box-shadow: 0 0 8px rgba(var(--gold-rgb), 0.12), inset 0 0 12px rgba(var(--gold-rgb), 0.04);
}
.spellMain {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.25rem;
}
.spellName {
font-family: var(--font-display, "Cinzel", Georgia, serif);
font-size: 0.82rem;
font-weight: 700;
color: var(--text-primary);
}
.focusActive {
font-size: 0.68rem;
color: var(--gold);
font-weight: 600;
}
.exhaustedLabel {
font-size: 0.65rem;
color: var(--text-tertiary);
background: rgba(var(--shadow-rgb), 0.3);
border-radius: 2px;
padding: 0.1rem 0.3rem;
}
.lockedLabel {
font-size: 0.65rem;
color: var(--danger);
background: rgba(var(--danger-rgb), 0.1);
border: 1px solid rgba(var(--danger-rgb), 0.25);
border-radius: 2px;
padding: 0.1rem 0.3rem;
}
.spellMeta {
font-size: 0.7rem;
color: var(--text-tertiary);
margin-bottom: 0.3rem;
}
.spellDesc {
font-size: 0.76rem;
color: var(--text-secondary);
line-height: 1.4;
margin-bottom: 0.5rem;
}
.spellActions {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Cast button — gold primary */
.castBtn {
font-family: var(--font-display, "Cinzel", Georgia, serif);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
padding: 0.3rem 0.8rem;
background: var(--btn-gold-bg);
border: none;
border-radius: 3px;
color: var(--btn-active-text);
cursor: pointer;
transition: filter 0.15s, opacity 0.15s;
}
.castBtn:hover:not(:disabled) {
filter: brightness(1.1);
}
.castBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
filter: none;
}
/* Remove button — subtle, red on hover */
.removeBtn {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background: none;
border: 1px solid rgba(var(--shadow-rgb), 0.3);
border-radius: 3px;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.15s;
}
.removeBtn:hover {
color: var(--danger);
border-color: rgba(var(--danger-rgb), 0.4);
background: rgba(var(--danger-rgb), 0.06);
}

View file

@ -0,0 +1,168 @@
import { useState, useEffect } from "react";
import type { CharacterSpell, Spell, SpellCastResult } from "../types.js";
import { getSpells, addCharacterSpell, removeCharacterSpell, castSpell } from "../api.js";
import styles from "./SpellList.module.css";
interface SpellListProps {
characterId: number;
characterClass: string;
spells: CharacterSpell[];
mode: "view" | "edit";
canEdit: boolean;
onSpellAdded: (spell: CharacterSpell) => void;
onSpellRemoved: (spellId: number) => void;
onSpellCast: (result: SpellCastResult, spellName: string) => void;
onSpellsUpdated: (spells: CharacterSpell[]) => void;
}
export default function SpellList({
characterId,
characterClass,
spells,
mode,
canEdit,
onSpellAdded,
onSpellRemoved,
onSpellCast,
onSpellsUpdated,
}: SpellListProps) {
const [allSpells, setAllSpells] = useState<Spell[]>([]);
const [showPicker, setShowPicker] = useState(false);
const [casting, setCasting] = useState<number | null>(null);
const isCaster = ["Wizard", "Priest"].includes(characterClass);
useEffect(() => {
if (!isCaster) return;
const spellClass = characterClass.toLowerCase();
getSpells(spellClass).then(setAllSpells);
}, [characterClass, isCaster]);
if (!isCaster) return null;
const knownIds = new Set(spells.map((s) => s.spell_id));
const availableToAdd = allSpells.filter((s) => !knownIds.has(s.id));
const byTier: Record<number, CharacterSpell[]> = {};
for (const s of spells) {
byTier[s.tier] = byTier[s.tier] ?? [];
byTier[s.tier].push(s);
}
async function handleCast(spell: CharacterSpell) {
setCasting(spell.spell_id);
try {
const result = await castSpell(characterId, spell.spell_id);
onSpellCast(result, spell.name);
const exhausted = result.result === "success" || result.result === "crit_success" ? 0 : 1;
onSpellsUpdated(
spells.map((s) => (s.spell_id === spell.spell_id ? { ...s, exhausted } : s))
);
} catch (err) {
console.error("Cast failed", err);
} finally {
setCasting(null);
}
}
async function handleRemove(spellId: number) {
await removeCharacterSpell(characterId, spellId);
onSpellRemoved(spellId);
}
async function handleAdd(spell: Spell) {
const cs = await addCharacterSpell(characterId, spell.id);
onSpellAdded(cs);
setShowPicker(false);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<span className={styles.sectionTitle}>Spells</span>
{canEdit && mode === "edit" && (
<button className={styles.addBtn} onClick={() => setShowPicker((p) => !p)}>
{showPicker ? "Cancel" : "+ Add Spell"}
</button>
)}
</div>
{showPicker && (
<div className={styles.picker}>
{availableToAdd.length === 0 && (
<p className={styles.empty}>All available spells known.</p>
)}
{availableToAdd.map((s) => (
<button key={s.id} className={styles.pickerItem} onClick={() => handleAdd(s)}>
<span className={styles.tierBadge}>T{s.tier}</span>
<span className={styles.pickerName}>{s.name}</span>
{s.is_focus ? <span className={styles.focusBadge}>Focus</span> : null}
</button>
))}
</div>
)}
{spells.length === 0 && !showPicker && (
<p className={styles.empty}>
No spells known.{canEdit && mode === "edit" ? " Add some above." : ""}
</p>
)}
{Object.entries(byTier)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([tier, tierSpells]) => (
<div key={tier} className={styles.tierGroup}>
<div className={styles.tierLabel}>Tier {tier}</div>
{tierSpells.map((s) => (
<div
key={s.id}
className={[
styles.spell,
s.exhausted ? styles.exhausted : "",
s.focus_active ? styles.focusing : "",
]
.filter(Boolean)
.join(" ")}
>
<div className={styles.spellMain}>
<span className={styles.spellName}>{s.name}</span>
{s.focus_active ? (
<span className={styles.focusActive}>&#9679; Focusing</span>
) : null}
{s.exhausted ? (
<span className={styles.exhaustedLabel}>Exhausted</span>
) : null}
{s.locked_until ? (
<span className={styles.lockedLabel}>Locked</span>
) : null}
</div>
<div className={styles.spellMeta}>
{s.duration} &middot; {s.range}
</div>
<div className={styles.spellDesc}>{s.description}</div>
<div className={styles.spellActions}>
{canEdit && (
<button
className={styles.castBtn}
disabled={!!s.exhausted || !!s.locked_until || casting === s.spell_id}
onClick={() => handleCast(s)}
>
{casting === s.spell_id ? "Rolling\u2026" : "Cast"}
</button>
)}
{canEdit && mode === "edit" && (
<button
className={styles.removeBtn}
onClick={() => handleRemove(s.spell_id)}
>
&#10005;
</button>
)}
</div>
</div>
))}
</div>
))}
</div>
);
}

View file

@ -15,6 +15,7 @@ import {
getRolls,
getMyCampaignRole,
generateInvite,
undoRoll,
} from "../api";
import { useAuth } from "../context/AuthContext";
import type { Character, Gear, Talent, GameItem, RollResult } from "../types";
@ -60,6 +61,7 @@ export default function CampaignView() {
} | null>(null);
const pendingRollRef = useRef<RollResult | null>(null);
const [atmosphere, setAtmosphere] = useState<AtmosphereState>(defaultAtmosphere);
const [focusSpells, setFocusSpells] = useState<Map<number, string>>(new Map());
function handleAtmosphereChange(next: AtmosphereState) {
setAtmosphere(next);
@ -249,19 +251,19 @@ export default function CampaignView() {
}
}
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);
}
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;
}
socket.on("character:created", onCharacterCreated);
socket.on("character:updated", onCharacterUpdated);
socket.on("character:deleted", onCharacterDeleted);
@ -272,6 +274,8 @@ export default function CampaignView() {
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);
return () => {
socket.off("character:created", onCharacterCreated);
@ -284,6 +288,8 @@ export default function CampaignView() {
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);
};
}, []);
@ -358,6 +364,11 @@ export default function CampaignView() {
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);
@ -415,6 +426,7 @@ export default function CampaignView() {
onUpdate={handleUpdate}
onClick={setSelectedId}
canEdit={role === "dm" || char.user_id === user?.userId}
focusSpell={focusSpells.get(char.id)}
/>
))}
</div>
@ -507,7 +519,7 @@ export default function CampaignView() {
</div>
)}
</div>
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} />
<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} />