feat: add CombatStartModal for DM combat setup
This commit is contained in:
parent
77be024ec6
commit
65c914e3e0
2 changed files with 388 additions and 0 deletions
225
client/src/components/CombatStartModal.module.css
Normal file
225
client/src/components/CombatStartModal.module.css
Normal 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);
|
||||
}
|
||||
163
client/src/components/CombatStartModal.tsx
Normal file
163
client/src/components/CombatStartModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue