Add advantage/disadvantage rolling for initiative
All checks were successful
CI / check (push) Successful in 1m17s
CI / build-image (push) Successful in 19s

Right-click or long-press the d20 button (per-combatant or Roll All)
to open a context menu with Advantage and Disadvantage options.
Normal left-click behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-18 09:16:04 +01:00
parent 7f38cbab73
commit 6584d8d064
12 changed files with 392 additions and 46 deletions

View File

@@ -3,9 +3,11 @@ import {
type ConditionId,
deriveHpStatus,
type PlayerIcon,
type RollMode,
} from "@initiative/domain";
import { Book, BookOpen, Brain, X } from "lucide-react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
import { useLongPress } from "../hooks/use-long-press";
import { cn } from "../lib/utils";
import { AcShield } from "./ac-shield";
import { ConditionPicker } from "./condition-picker";
@@ -13,6 +15,7 @@ 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 { RollModeMenu } from "./roll-mode-menu";
import { ConfirmButton } from "./ui/confirm-button";
import { Input } from "./ui/input";
@@ -42,7 +45,7 @@ interface CombatantRowProps {
onToggleConcentration: (id: CombatantId) => void;
onShowStatBlock?: () => void;
isStatBlockOpen?: boolean;
onRollInitiative?: (id: CombatantId) => void;
onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
}
function EditableName({
@@ -279,11 +282,29 @@ function InitiativeDisplay({
combatantId: CombatantId;
dimmed: boolean;
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRollInitiative?: (id: CombatantId) => void;
onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(initiative?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(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 === "") {
@@ -328,18 +349,32 @@ function InitiativeDisplay({
// 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-hover-neutral",
dimmed && "opacity-50",
<>
<button
type="button"
onClick={() => onRollInitiative(combatantId)}
onContextMenu={(e) => {
e.preventDefault();
openMenu(e.clientX, e.clientY);
}}
{...longPress}
className={cn(
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
dimmed && "opacity-50",
)}
title="Roll initiative"
aria-label="Roll initiative"
>
<D20Icon className="h-7 w-7" />
</button>
{!!menuPos && (
<RollModeMenu
position={menuPos}
onSelect={(mode) => onRollInitiative(combatantId, mode)}
onClose={() => setMenuPos(null)}
/>
)}
title="Roll initiative"
aria-label="Roll initiative"
>
<D20Icon className="h-7 w-7" />
</button>
</>
);
}