import { type CombatantId, type ConditionId, deriveHpStatus, type PlayerIcon, } from "@initiative/domain"; import { BookOpen, Brain, X } from "lucide-react"; import { type Ref, useCallback, useEffect, useRef, useState } from "react"; import { cn } from "../lib/utils"; import { AcShield } from "./ac-shield"; import { ConditionPicker } from "./condition-picker"; import { ConditionTags } from "./condition-tags"; import { D20Icon } from "./d20-icon"; import { HpAdjustPopover } from "./hp-adjust-popover"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { ConfirmButton } from "./ui/confirm-button"; import { Input } from "./ui/input"; interface Combatant { readonly id: CombatantId; readonly name: string; readonly initiative?: number; readonly maxHp?: number; readonly currentHp?: number; readonly ac?: number; readonly conditions?: readonly ConditionId[]; readonly isConcentrating?: boolean; readonly color?: string; readonly icon?: string; } interface CombatantRowProps { combatant: Combatant; isActive: boolean; onRename: (id: CombatantId, newName: string) => void; onSetInitiative: (id: CombatantId, value: number | undefined) => void; onRemove: (id: CombatantId) => void; onSetHp: (id: CombatantId, maxHp: number | undefined) => void; onAdjustHp: (id: CombatantId, delta: number) => void; onSetAc: (id: CombatantId, value: number | undefined) => void; onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void; onToggleConcentration: (id: CombatantId) => void; onShowStatBlock?: () => void; onRollInitiative?: (id: CombatantId) => void; } function EditableName({ name, combatantId, onRename, color, }: Readonly<{ name: string; combatantId: CombatantId; onRename: (id: CombatantId, newName: string) => void; color?: string; }>) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(name); const inputRef = useRef(null); const commit = useCallback(() => { const trimmed = draft.trim(); if (trimmed !== "" && trimmed !== name) { onRename(combatantId, trimmed); } setEditing(false); }, [draft, name, combatantId, onRename]); const startEditing = useCallback(() => { setDraft(name); setEditing(true); requestAnimationFrame(() => inputRef.current?.select()); }, [name]); if (editing) { return ( setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") setEditing(false); }} /> ); } return ( ); } function MaxHpDisplay({ maxHp, onCommit, }: Readonly<{ maxHp: number | undefined; onCommit: (value: number | undefined) => void; }>) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(maxHp?.toString() ?? ""); const inputRef = useRef(null); const commit = useCallback(() => { if (draft === "") { onCommit(undefined); } else { const n = Number.parseInt(draft, 10); if (!Number.isNaN(n) && n >= 1) { onCommit(n); } } setEditing(false); }, [draft, onCommit]); const startEditing = useCallback(() => { setDraft(maxHp?.toString() ?? ""); setEditing(true); requestAnimationFrame(() => inputRef.current?.select()); }, [maxHp]); if (editing) { return ( setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") setEditing(false); }} /> ); } return ( ); } function ClickableHp({ currentHp, maxHp, onAdjust, dimmed, }: Readonly<{ currentHp: number | undefined; maxHp: number | undefined; onAdjust: (delta: number) => void; dimmed?: boolean; }>) { const [popoverOpen, setPopoverOpen] = useState(false); const status = deriveHpStatus(currentHp, maxHp); if (maxHp === undefined) { return ( -- ); } return (
{!!popoverOpen && ( setPopoverOpen(false)} /> )}
); } function AcDisplay({ ac, onCommit, }: Readonly<{ ac: number | undefined; onCommit: (value: number | undefined) => void; }>) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(ac?.toString() ?? ""); const inputRef = useRef(null); const commit = useCallback(() => { if (draft === "") { onCommit(undefined); } else { const n = Number.parseInt(draft, 10); if (!Number.isNaN(n) && n >= 0) { onCommit(n); } } setEditing(false); }, [draft, onCommit]); const startEditing = useCallback(() => { setDraft(ac?.toString() ?? ""); setEditing(true); requestAnimationFrame(() => inputRef.current?.select()); }, [ac]); if (editing) { return ( setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") setEditing(false); }} /> ); } return ; } function InitiativeDisplay({ initiative, combatantId, dimmed, onSetInitiative, onRollInitiative, }: Readonly<{ initiative: number | undefined; combatantId: CombatantId; dimmed: boolean; onSetInitiative: (id: CombatantId, value: number | undefined) => void; onRollInitiative?: (id: CombatantId) => void; }>) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(initiative?.toString() ?? ""); const inputRef = useRef(null); const commit = useCallback(() => { if (draft === "") { onSetInitiative(combatantId, undefined); } else { const n = Number.parseInt(draft, 10); if (!Number.isNaN(n)) { onSetInitiative(combatantId, n); } } setEditing(false); }, [draft, combatantId, onSetInitiative]); const startEditing = useCallback(() => { setDraft(initiative?.toString() ?? ""); setEditing(true); requestAnimationFrame(() => inputRef.current?.select()); }, [initiative]); if (editing) { return ( setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") setEditing(false); }} /> ); } // Empty + bestiary creature → d20 roll button if (initiative === undefined && onRollInitiative) { return ( ); } // Has value → bold number, click to edit // Empty + manual → "--" placeholder, click to edit return ( ); } function rowBorderClass( isActive: boolean, isConcentrating: boolean | undefined, ): string { if (isActive) return "border border-accent/40 bg-accent/10 card-glow"; if (isConcentrating) return "border-l-2 border-l-purple-400"; return "border-l-2 border-l-transparent"; } function concentrationIconClass( isConcentrating: boolean | undefined, dimmed: boolean, ): string { if (!isConcentrating) return "opacity-0 group-hover:opacity-50 text-muted-foreground"; return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400"; } export function CombatantRow({ ref, combatant, isActive, onRename, onSetInitiative, onRemove, onSetHp, onAdjustHp, onSetAc, onToggleCondition, onToggleConcentration, onShowStatBlock, onRollInitiative, }: CombatantRowProps & { ref?: Ref }) { const { id, name, initiative, maxHp, currentHp } = combatant; const status = deriveHpStatus(currentHp, maxHp); const dimmed = status === "unconscious"; const [pickerOpen, setPickerOpen] = useState(false); const prevHpRef = useRef(currentHp); const [isPulsing, setIsPulsing] = useState(false); const pulseTimerRef = useRef>(undefined); useEffect(() => { const prevHp = prevHpRef.current; prevHpRef.current = currentHp; if ( prevHp !== undefined && currentHp !== undefined && currentHp < prevHp && combatant.isConcentrating ) { setIsPulsing(true); clearTimeout(pulseTimerRef.current); pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200); } }, [currentHp, combatant.isConcentrating]); useEffect(() => { if (!combatant.isConcentrating) { clearTimeout(pulseTimerRef.current); setIsPulsing(false); } }, [combatant.isConcentrating]); const pcColor = combatant.color ? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX] : undefined; return (
{/* Concentration */} {/* Initiative */} {/* Name + Conditions */}
{!!onShowStatBlock && ( )} {!!combatant.icon && !!combatant.color && (() => { const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon]; const iconColor = PLAYER_COLOR_HEX[ combatant.color as keyof typeof PLAYER_COLOR_HEX ]; return PcIcon ? ( ) : null; })()} onToggleCondition(id, conditionId)} onOpenPicker={() => setPickerOpen((prev) => !prev)} /> {!!pickerOpen && ( onToggleCondition(id, conditionId)} onClose={() => setPickerOpen(false)} /> )}
{/* AC */}
onSetAc(id, v)} />
{/* HP */}
onAdjustHp(id, delta)} dimmed={dimmed} /> {maxHp !== undefined && ( / )}
onSetHp(id, v)} />
{/* Actions */} } label="Remove combatant" onConfirm={() => onRemove(id)} className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100" />
); }