feat: add CombatStartModal for DM combat setup

This commit is contained in:
Aaron Wood 2026-04-11 15:46:11 -04:00
parent 77be024ec6
commit 65c914e3e0
2 changed files with 388 additions and 0 deletions

View file

@ -0,0 +1,225 @@
.backdrop {
position: absolute;
inset: 0;
background: var(--bg-overlay, rgba(0,0,0,0.6));
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
}
.modal {
background: var(--bg-modal, #1a1814);
background-image: var(--texture-surface), var(--texture-speckle);
background-size: 256px 256px, 128px 128px;
border: 2px solid rgba(var(--gold-rgb), 0.3);
border-radius: 4px;
width: 100%;
max-width: 480px;
max-height: 85vh;
overflow-y: auto;
padding: 1.25rem;
box-shadow: 0 8px 40px rgba(0,0,0,0.7);
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.modalTitle {
font-family: var(--font-display, "Cinzel", Georgia, serif);
font-size: 1rem;
font-weight: 700;
color: var(--gold);
letter-spacing: 0.05em;
}
.closeBtn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.2rem;
cursor: pointer;
padding: 0.1rem 0.3rem;
}
.closeBtn:hover {
color: var(--text-primary);
}
.field {
margin-bottom: 1rem;
}
.fieldLabel {
display: block;
font-family: var(--font-display, "Cinzel", Georgia, serif);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 0.4rem;
}
.input {
width: 100%;
font-size: 0.82rem;
color: var(--text-primary);
background: var(--bg-input, rgba(0,0,0,0.3));
border: 1px solid rgba(var(--gold-rgb), 0.2);
border-radius: 3px;
padding: 0.4rem 0.6rem;
font-family: inherit;
}
.input:focus {
outline: none;
border-color: rgba(var(--gold-rgb), 0.5);
}
.charList {
display: flex;
flex-direction: column;
gap: 0.3rem;
background: var(--bg-inset, rgba(0,0,0,0.2));
border: 1px solid rgba(var(--gold-rgb), 0.12);
border-radius: 3px;
padding: 0.5rem 0.6rem;
}
.charRow {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.82rem;
color: var(--text-primary);
}
.charRow input[type="checkbox"] {
accent-color: var(--gold);
}
.charDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.charName {
flex: 1;
}
.enemyRow {
display: flex;
gap: 0.4rem;
align-items: center;
margin-bottom: 0.35rem;
}
.enemyRow .input {
flex: 1;
}
.hpInput {
max-width: 80px;
flex: 0 0 80px !important;
}
.removeRowBtn {
font-size: 0.7rem;
padding: 0.25rem 0.4rem;
background: none;
border: 1px solid rgba(var(--gold-rgb), 0.15);
border-radius: 3px;
color: var(--text-tertiary);
cursor: pointer;
flex-shrink: 0;
transition: all 0.12s;
}
.removeRowBtn:hover:not(:disabled) {
color: var(--danger);
border-color: rgba(var(--danger-rgb), 0.3);
}
.removeRowBtn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.addRowBtn {
font-family: var(--font-display, "Cinzel", Georgia, serif);
font-size: 0.68rem;
font-weight: 600;
padding: 0.3rem 0.6rem;
background: transparent;
border: 1px dashed rgba(var(--gold-rgb), 0.3);
border-radius: 3px;
color: var(--text-secondary);
cursor: pointer;
width: 100%;
margin-top: 0.15rem;
transition: all 0.12s;
}
.addRowBtn:hover {
border-color: rgba(var(--gold-rgb), 0.5);
color: var(--gold);
}
.actions {
display: flex;
gap: 0.5rem;
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid rgba(var(--gold-rgb), 0.15);
}
.startBtn {
font-family: var(--font-display, "Cinzel", Georgia, serif);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.05em;
padding: 0.5rem 1rem;
background: rgba(var(--gold-rgb), 0.85);
border: none;
border-radius: 4px;
color: var(--btn-active-text);
cursor: pointer;
flex: 1;
transition: filter 0.12s;
}
.startBtn:hover:not(:disabled) {
filter: brightness(1.1);
}
.startBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.cancelBtn {
font-family: var(--font-display, "Cinzel", Georgia, serif);
font-size: 0.82rem;
font-weight: 600;
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid rgba(var(--gold-rgb), 0.3);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.12s;
}
.cancelBtn:hover {
border-color: rgba(var(--gold-rgb), 0.5);
color: var(--gold);
}

View file

@ -0,0 +1,163 @@
import { useState } from "react";
import socket from "../socket.js";
import type { Character } from "../types.js";
import styles from "./CombatStartModal.module.css";
interface CombatStartModalProps {
characters: Character[];
campaignId: number;
onClose: () => void;
}
interface EnemyEntry {
key: number;
name: string;
hp_max: string;
}
let enemyKey = 0;
export default function CombatStartModal({
characters,
campaignId,
onClose,
}: CombatStartModalProps) {
const [label, setLabel] = useState("");
const [selectedCharIds, setSelectedCharIds] = useState<Set<number>>(
new Set(characters.map((c) => c.id))
);
const [enemies, setEnemies] = useState<EnemyEntry[]>([
{ key: enemyKey++, name: "", hp_max: "" },
]);
function toggleChar(id: number) {
setSelectedCharIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
function updateEnemy(key: number, field: "name" | "hp_max", value: string) {
setEnemies((prev) =>
prev.map((e) => (e.key === key ? { ...e, [field]: value } : e))
);
}
function addEnemyRow() {
setEnemies((prev) => [...prev, { key: enemyKey++, name: "", hp_max: "" }]);
}
function removeEnemyRow(key: number) {
setEnemies((prev) => prev.filter((e) => e.key !== key));
}
function handleStart() {
const validEnemies = enemies
.filter((e) => e.name.trim() && parseInt(e.hp_max, 10) > 0)
.map((e) => ({ name: e.name.trim(), hp_max: parseInt(e.hp_max, 10) }));
socket.emit("initiative:start", {
campaignId,
label: label.trim() || undefined,
character_ids: Array.from(selectedCharIds),
enemies: validEnemies,
});
onClose();
}
const canStart = selectedCharIds.size > 0;
return (
<div className={styles.backdrop} onClick={onClose}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<span className={styles.modalTitle}>Start Combat</span>
<button className={styles.closeBtn} onClick={onClose}></button>
</div>
{/* Optional label */}
<div className={styles.field}>
<label className={styles.fieldLabel}>Label (optional)</label>
<input
className={styles.input}
placeholder="e.g. Throne Room"
value={label}
onChange={(e) => setLabel(e.target.value)}
/>
</div>
{/* Character selection */}
<div className={styles.field}>
<label className={styles.fieldLabel}>Party Members</label>
<div className={styles.charList}>
{characters.map((c) => (
<label key={c.id} className={styles.charRow}>
<input
type="checkbox"
checked={selectedCharIds.has(c.id)}
onChange={() => toggleChar(c.id)}
/>
<span
className={styles.charDot}
style={{ background: c.color }}
/>
<span className={styles.charName}>{c.name}</span>
</label>
))}
</div>
</div>
{/* Enemy entries */}
<div className={styles.field}>
<label className={styles.fieldLabel}>Enemies</label>
{enemies.map((e) => (
<div key={e.key} className={styles.enemyRow}>
<input
className={styles.input}
placeholder="Name (e.g. Goblin x3)"
value={e.name}
onChange={(ev) => updateEnemy(e.key, "name", ev.target.value)}
/>
<input
className={`${styles.input} ${styles.hpInput}`}
placeholder="Max HP"
type="number"
min={1}
value={e.hp_max}
onChange={(ev) => updateEnemy(e.key, "hp_max", ev.target.value)}
/>
<button
className={styles.removeRowBtn}
onClick={() => removeEnemyRow(e.key)}
disabled={enemies.length === 1}
>
</button>
</div>
))}
<button className={styles.addRowBtn} onClick={addEnemyRow}>
+ Add Another Enemy
</button>
</div>
<div className={styles.actions}>
<button
className={styles.startBtn}
onClick={handleStart}
disabled={!canStart}
>
Roll Initiative
</button>
<button className={styles.cancelBtn} onClick={onClose}>
Cancel
</button>
</div>
</div>
</div>
);
}