Implement the 026-roll-initiative feature that adds d20 roll buttons for bestiary combatants' initiative using a click-to-edit pattern (d20 icon when empty, plain text when set), plus a Roll All button in the top bar that batch-rolls for all unrolled bestiary combatants, with randomness confined to the adapter layer and the domain receiving pre-resolved dice values
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConditionPicker } from "./condition-picker";
|
||||
import { ConditionTags } from "./condition-tags";
|
||||
import { D20Icon } from "./d20-icon";
|
||||
import { HpAdjustPopover } from "./hp-adjust-popover";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
@@ -35,6 +36,7 @@ interface CombatantRowProps {
|
||||
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
|
||||
onToggleConcentration: (id: CombatantId) => void;
|
||||
onShowStatBlock?: () => void;
|
||||
onRollInitiative?: (id: CombatantId) => void;
|
||||
}
|
||||
|
||||
function EditableName({
|
||||
@@ -266,6 +268,100 @@ function AcDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
function InitiativeDisplay({
|
||||
initiative,
|
||||
combatantId,
|
||||
dimmed,
|
||||
onSetInitiative,
|
||||
onRollInitiative,
|
||||
}: {
|
||||
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<HTMLInputElement>(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 (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={draft}
|
||||
placeholder="--"
|
||||
className={cn(
|
||||
"h-7 w-[6ch] text-center text-sm tabular-nums",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
onChange={(e) => 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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRollInitiative(combatantId)}
|
||||
className={cn(
|
||||
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-primary",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
title="Roll initiative"
|
||||
aria-label="Roll initiative"
|
||||
>
|
||||
<D20Icon className="h-5 w-5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Has value → bold number, click to edit
|
||||
// Empty + manual → "--" placeholder, click to edit
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEditing}
|
||||
className={cn(
|
||||
"h-7 w-full text-center text-sm leading-7 tabular-nums transition-colors",
|
||||
initiative !== undefined
|
||||
? "font-medium text-foreground hover:text-primary"
|
||||
: "text-muted-foreground hover:text-primary",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{initiative ?? "--"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CombatantRow({
|
||||
ref,
|
||||
combatant,
|
||||
@@ -279,6 +375,7 @@ export function CombatantRow({
|
||||
onToggleCondition,
|
||||
onToggleConcentration,
|
||||
onShowStatBlock,
|
||||
onRollInitiative,
|
||||
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
|
||||
const { id, name, initiative, maxHp, currentHp } = combatant;
|
||||
const status = deriveHpStatus(currentHp, maxHp);
|
||||
@@ -345,26 +442,12 @@ export function CombatantRow({
|
||||
</button>
|
||||
|
||||
{/* Initiative */}
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={initiative ?? ""}
|
||||
placeholder="--"
|
||||
className={cn(
|
||||
"h-7 w-[6ch] text-center text-sm tabular-nums",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
onSetInitiative(id, undefined);
|
||||
} else {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (!Number.isNaN(n)) {
|
||||
onSetInitiative(id, n);
|
||||
}
|
||||
}
|
||||
}}
|
||||
<InitiativeDisplay
|
||||
initiative={initiative}
|
||||
combatantId={id}
|
||||
dimmed={dimmed}
|
||||
onSetInitiative={onSetInitiative}
|
||||
onRollInitiative={onRollInitiative}
|
||||
/>
|
||||
|
||||
{/* Name */}
|
||||
|
||||
Reference in New Issue
Block a user