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 */}
|
||||
|
||||
29
apps/web/src/components/d20-icon.tsx
Normal file
29
apps/web/src/components/d20-icon.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
interface D20IconProps {
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
export function D20Icon({ className }: D20IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
width="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon points="20.22 16.76 20.22 7.26 12.04 2.51 3.85 7.26 3.85 16.76 12.04 21.51 20.22 16.76" />
|
||||
<line x1="7.29" y1="9.26" x2="3.85" y2="7.26" />
|
||||
<line x1="20.22" y1="7.26" x2="16.79" y2="9.26" />
|
||||
<line x1="12.04" y1="17.44" x2="12.04" y2="21.51" />
|
||||
<polygon points="12.04 17.44 20.22 16.76 16.79 9.26 12.04 17.44" />
|
||||
<polygon points="12.04 17.44 7.29 9.26 3.85 16.76 12.04 17.44" />
|
||||
<polygon points="12.04 2.51 7.29 9.26 16.79 9.26 12.04 2.51" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Encounter } from "@initiative/domain";
|
||||
import { ChevronLeft, ChevronRight, Trash2 } from "lucide-react";
|
||||
import { D20Icon } from "./d20-icon";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface TurnNavigationProps {
|
||||
@@ -7,6 +8,7 @@ interface TurnNavigationProps {
|
||||
onAdvanceTurn: () => void;
|
||||
onRetreatTurn: () => void;
|
||||
onClearEncounter: () => void;
|
||||
onRollAllInitiative: () => void;
|
||||
}
|
||||
|
||||
export function TurnNavigation({
|
||||
@@ -14,6 +16,7 @@ export function TurnNavigation({
|
||||
onAdvanceTurn,
|
||||
onRetreatTurn,
|
||||
onClearEncounter,
|
||||
onRollAllInitiative,
|
||||
}: TurnNavigationProps) {
|
||||
const hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
@@ -57,6 +60,16 @@ export function TurnNavigation({
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-primary"
|
||||
onClick={onRollAllInitiative}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
>
|
||||
<D20Icon className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
Reference in New Issue
Block a user