import { type CombatantId, type ConditionId, type CreatureId, deriveHpStatus, type PlayerIcon, type RollMode, } from "@initiative/domain"; import { Brain, Pencil, X } from "lucide-react"; import { type Ref, useCallback, useEffect, useRef, useState } from "react"; import { useEncounterContext } from "../contexts/encounter-context.js"; import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js"; import { useSidePanelContext } from "../contexts/side-panel-context.js"; import { useLongPress } from "../hooks/use-long-press.js"; import { cn } from "../lib/utils.js"; import { AcShield } from "./ac-shield.js"; import { ConditionPicker } from "./condition-picker.js"; import { ConditionTags } from "./condition-tags.js"; import { D20Icon } from "./d20-icon.js"; import { HpAdjustPopover } from "./hp-adjust-popover.js"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js"; import { RollModeMenu } from "./roll-mode-menu.js"; import { ConfirmButton } from "./ui/confirm-button.js"; import { Input } from "./ui/input.js"; interface Combatant { readonly id: CombatantId; readonly name: string; readonly initiative?: number; readonly maxHp?: number; readonly currentHp?: number; readonly tempHp?: number; readonly ac?: number; readonly conditions?: readonly ConditionId[]; readonly isConcentrating?: boolean; readonly color?: string; readonly icon?: string; readonly creatureId?: CreatureId; } interface CombatantRowProps { combatant: Combatant; isActive: boolean; } function EditableName({ name, combatantId, onRename, color, onToggleStatBlock, }: Readonly<{ name: string; combatantId: CombatantId; onRename: (id: CombatantId, newName: string) => void; color?: string; onToggleStatBlock?: () => void; }>) { 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, tempHp, onAdjust, onSetTempHp, }: Readonly<{ currentHp: number | undefined; maxHp: number | undefined; tempHp: number | undefined; onAdjust: (delta: number) => void; onSetTempHp: (value: number) => void; }>) { const [popoverOpen, setPopoverOpen] = useState(false); const status = deriveHpStatus(currentHp, maxHp); if (maxHp === undefined) { return null; } return (
{!!tempHp && ( +{tempHp} )} {!!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, mode?: RollMode) => void; }>) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(initiative?.toString() ?? ""); const inputRef = useRef(null); const [menuPos, setMenuPos] = useState<{ x: number; y: number; } | null>(null); const openMenu = useCallback((x: number, y: number) => { setMenuPos({ x, y }); }, []); const longPress = useLongPress( useCallback( (e: React.TouchEvent) => { const touch = e.touches[0]; if (touch) openMenu(touch.clientX, touch.clientY); }, [openMenu], ), ); 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 ( <> {!!menuPos && ( onRollInitiative(combatantId, mode)} onClose={() => setMenuPos(null)} /> )} ); } // Has value -> bold number, click to edit // Empty + manual -> "--" placeholder, click to edit return ( ); } function rowBorderClass( isActive: boolean, isConcentrating: boolean | undefined, ): string { if (isActive && isConcentrating) return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow"; if (isActive) return "border border-l-2 border-active-row-border bg-active-row-bg card-glow"; if (isConcentrating) return "border border-l-2 border-transparent border-l-purple-400"; return "border border-l-2 border-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, }: CombatantRowProps & { ref?: Ref }) { const { editCombatant, setInitiative, removeCombatant, setHp, adjustHp, setTempHp, setAc, toggleCondition, toggleConcentration, } = useEncounterContext(); const { selectedCreatureId, showCreature, toggleCollapse } = useSidePanelContext(); const { handleRollInitiative } = useInitiativeRollsContext(); // Derive what was previously conditional props const isStatBlockOpen = combatant.creatureId === selectedCreatureId; const { creatureId } = combatant; const hasStatBlock = !!creatureId; const onToggleStatBlock = hasStatBlock ? () => { if (isStatBlockOpen) { toggleCollapse(); } else { showCreature(creatureId); } } : undefined; const onRollInitiative = combatant.creatureId ? handleRollInitiative : undefined; const { id, name, initiative, maxHp, currentHp } = combatant; const status = deriveHpStatus(currentHp, maxHp); const dimmed = status === "unconscious"; const [pickerOpen, setPickerOpen] = useState(false); const conditionAnchorRef = useRef(null); const prevHpRef = useRef(currentHp); const prevTempHpRef = useRef(combatant.tempHp); const [isPulsing, setIsPulsing] = useState(false); const pulseTimerRef = useRef>(undefined); useEffect(() => { const prevHp = prevHpRef.current; const prevTempHp = prevTempHpRef.current; prevHpRef.current = currentHp; prevTempHpRef.current = combatant.tempHp; const realHpDropped = prevHp !== undefined && currentHp !== undefined && currentHp < prevHp; const tempHpDropped = prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp; if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) { setIsPulsing(true); clearTimeout(pulseTimerRef.current); pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200); } }, [currentHp, combatant.tempHp, 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 */}
{/* AC */}
setAc(id, v)} />
{/* Name + Conditions */}
{!!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; })()}
toggleCondition(id, conditionId)} onOpenPicker={() => setPickerOpen((prev) => !prev)} />
{!!pickerOpen && ( toggleCondition(id, conditionId)} onClose={() => setPickerOpen(false)} /> )}
{/* HP */}
adjustHp(id, delta)} onSetTempHp={(value) => setTempHp(id, value)} /> {maxHp !== undefined && ( / )} setHp(id, v)} />
{/* Actions */} } label="Remove combatant" onConfirm={() => removeCombatant(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" />
); }