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:
parent
65c914e3e0
commit
247379ba57
2 changed files with 113 additions and 17 deletions
|
|
@ -165,3 +165,54 @@
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
grid-column: 1 / -1;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ import {
|
||||||
undoRoll,
|
undoRoll,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { useAuth } from "../context/AuthContext";
|
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 CharacterCard from "../components/CharacterCard";
|
||||||
import CharacterDetail from "../components/CharacterDetail";
|
import CharacterDetail from "../components/CharacterDetail";
|
||||||
import RollLog from "../components/RollLog";
|
import RollLog from "../components/RollLog";
|
||||||
|
|
@ -54,6 +56,8 @@ export default function CampaignView() {
|
||||||
const pendingRollRef = useRef<RollResult | null>(null);
|
const pendingRollRef = useRef<RollResult | null>(null);
|
||||||
const [atmosphere, setAtmosphere] = useState<AtmosphereState>(defaultAtmosphere);
|
const [atmosphere, setAtmosphere] = useState<AtmosphereState>(defaultAtmosphere);
|
||||||
const [focusSpells, setFocusSpells] = useState<Map<number, string>>(new Map());
|
const [focusSpells, setFocusSpells] = useState<Map<number, string>>(new Map());
|
||||||
|
const [combats, setCombats] = useState<CombatState[]>([]);
|
||||||
|
const [showCombatStart, setShowCombatStart] = useState(false);
|
||||||
|
|
||||||
function handleAtmosphereChange(next: AtmosphereState) {
|
function handleAtmosphereChange(next: AtmosphereState) {
|
||||||
setAtmosphere(next);
|
setAtmosphere(next);
|
||||||
|
|
@ -70,6 +74,7 @@ export default function CampaignView() {
|
||||||
if (c) setCampaignName(c.name);
|
if (c) setCampaignName(c.name);
|
||||||
});
|
});
|
||||||
socket.emit("join-campaign", String(campaignId));
|
socket.emit("join-campaign", String(campaignId));
|
||||||
|
socket.emit("initiative:request-state", campaignId);
|
||||||
return () => {
|
return () => {
|
||||||
socket.emit("leave-campaign", String(campaignId));
|
socket.emit("leave-campaign", String(campaignId));
|
||||||
};
|
};
|
||||||
|
|
@ -256,6 +261,14 @@ export default function CampaignView() {
|
||||||
void characterId;
|
void characterId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onInitiativeState(data: CombatState[]) {
|
||||||
|
setCombats(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInitiativeUpdated(data: CombatState[]) {
|
||||||
|
setCombats(data);
|
||||||
|
}
|
||||||
|
|
||||||
socket.on("character:created", onCharacterCreated);
|
socket.on("character:created", onCharacterCreated);
|
||||||
socket.on("character:updated", onCharacterUpdated);
|
socket.on("character:updated", onCharacterUpdated);
|
||||||
socket.on("character:deleted", onCharacterDeleted);
|
socket.on("character:deleted", onCharacterDeleted);
|
||||||
|
|
@ -268,6 +281,8 @@ export default function CampaignView() {
|
||||||
socket.on("atmosphere:update", onAtmosphereUpdate);
|
socket.on("atmosphere:update", onAtmosphereUpdate);
|
||||||
socket.on("roll:undone", onRollUndone);
|
socket.on("roll:undone", onRollUndone);
|
||||||
socket.on("character:rested", onCharacterRested);
|
socket.on("character:rested", onCharacterRested);
|
||||||
|
socket.on("initiative:state", onInitiativeState);
|
||||||
|
socket.on("initiative:updated", onInitiativeUpdated);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("character:created", onCharacterCreated);
|
socket.off("character:created", onCharacterCreated);
|
||||||
|
|
@ -282,6 +297,8 @@ export default function CampaignView() {
|
||||||
socket.off("atmosphere:update", onAtmosphereUpdate);
|
socket.off("atmosphere:update", onAtmosphereUpdate);
|
||||||
socket.off("roll:undone", onRollUndone);
|
socket.off("roll:undone", onRollUndone);
|
||||||
socket.off("character:rested", onCharacterRested);
|
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}
|
onAtmosphereChange={handleAtmosphereChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{role === "dm" && (
|
||||||
|
<button
|
||||||
|
className={`${styles.addBtn} ${combats.length > 0 ? styles.addBtnActive : ""}`}
|
||||||
|
onClick={() => setShowCombatStart(true)}
|
||||||
|
>
|
||||||
|
⚔ Combat
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{role === "dm" && (
|
{role === "dm" && (
|
||||||
<button className={styles.addBtn} onClick={handleInvite}>
|
<button className={styles.addBtn} onClick={handleInvite}>
|
||||||
Invite Player
|
Invite Player
|
||||||
|
|
@ -401,23 +426,36 @@ export default function CampaignView() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.grid}>
|
<div className={`${styles.content} ${combats.length > 0 ? styles.withCombat : ""}`}>
|
||||||
{characters.length === 0 && (
|
{combats.length > 0 && (
|
||||||
<p className={styles.empty}>
|
<div className={styles.combatSidebar}>
|
||||||
No characters yet. Add one to get started!
|
<InitiativeTracker
|
||||||
</p>
|
combat={combats[0]}
|
||||||
|
characters={characters}
|
||||||
|
isDM={role === "dm"}
|
||||||
|
currentUserId={user?.userId ?? null}
|
||||||
|
campaignId={campaignId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{characters.map((char) => (
|
<div className={styles.grid}>
|
||||||
<CharacterCard
|
{characters.length === 0 && (
|
||||||
key={char.id}
|
<p className={styles.empty}>
|
||||||
character={char}
|
No characters yet. Add one to get started!
|
||||||
onHpChange={handleHpChange}
|
</p>
|
||||||
onUpdate={handleUpdate}
|
)}
|
||||||
onClick={setSelectedId}
|
{characters.map((char) => (
|
||||||
canEdit={role === "dm" || char.user_id === user?.userId}
|
<CharacterCard
|
||||||
focusSpell={focusSpells.get(char.id)}
|
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>
|
</div>
|
||||||
|
|
||||||
{selectedCharacter && (
|
{selectedCharacter && (
|
||||||
|
|
@ -445,6 +483,13 @@ export default function CampaignView() {
|
||||||
onClose={() => setShowCreate(false)}
|
onClose={() => setShowCreate(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showCombatStart && (
|
||||||
|
<CombatStartModal
|
||||||
|
characters={characters}
|
||||||
|
campaignId={campaignId}
|
||||||
|
onClose={() => setShowCombatStart(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} onUndoRoll={handleUndoRoll} />
|
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} onUndoRoll={handleUndoRoll} />
|
||||||
<DiceTray roll={diceRoll} onAnimationComplete={handleDiceComplete} />
|
<DiceTray roll={diceRoll} onAnimationComplete={handleDiceComplete} />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue