feat: integrate initiative tracker into campaign view

Wires up InitiativeTracker and CombatStartModal into CampaignView with socket event handlers, a DM-only Combat button, two-column layout with combat sidebar, and responsive CSS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aaron Wood 2026-04-11 15:48:41 -04:00
parent 65c914e3e0
commit 247379ba57
2 changed files with 113 additions and 17 deletions

View file

@ -165,3 +165,54 @@
font-style: italic;
grid-column: 1 / -1;
}
/* ── Combat layout ── */
.content {
display: block;
}
.content.withCombat {
display: flex;
gap: 0;
align-items: flex-start;
}
.combatSidebar {
width: 190px;
flex-shrink: 0;
border-right: 1px solid rgba(var(--gold-rgb), 0.15);
padding-right: 0.5rem;
margin-right: 0.75rem;
min-height: 300px;
position: sticky;
top: 0;
}
.content.withCombat .grid {
flex: 1;
min-width: 0;
}
.addBtnActive {
background: rgba(var(--gold-rgb), 0.15) !important;
border: 1px solid rgba(var(--gold-rgb), 0.5) !important;
color: var(--gold) !important;
}
@media (max-width: 768px) {
.content.withCombat {
flex-direction: column;
}
.combatSidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid rgba(var(--gold-rgb), 0.15);
padding-right: 0;
margin-right: 0;
padding-bottom: 0.5rem;
margin-bottom: 0.75rem;
position: static;
}
}

View file

@ -18,7 +18,9 @@ import {
undoRoll,
} from "../api";
import { useAuth } from "../context/AuthContext";
import type { Character, Gear, Talent, GameItem, RollResult } from "../types";
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";
@ -54,6 +56,8 @@ export default function CampaignView() {
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);
@ -70,6 +74,7 @@ export default function CampaignView() {
if (c) setCampaignName(c.name);
});
socket.emit("join-campaign", String(campaignId));
socket.emit("initiative:request-state", campaignId);
return () => {
socket.emit("leave-campaign", String(campaignId));
};
@ -256,6 +261,14 @@ export default function CampaignView() {
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);
@ -268,6 +281,8 @@ export default function CampaignView() {
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);
@ -282,6 +297,8 @@ export default function CampaignView() {
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);
};
}, []);
@ -387,6 +404,14 @@ export default function CampaignView() {
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
@ -401,23 +426,36 @@ export default function CampaignView() {
</div>
</div>
<div className={styles.grid}>
{characters.length === 0 && (
<p className={styles.empty}>
No characters yet. Add one to get started!
</p>
<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>
)}
{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)}
/>
))}
<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)}
/>
))}
</div>
</div>
{selectedCharacter && (
@ -445,6 +483,13 @@ export default function CampaignView() {
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} />