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