Implement the 018-combatant-concentration feature that adds a per-combatant concentration toggle with Brain icon, purple border accent, and damage pulse animation in the encounter tracker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-06 14:34:28 +01:00
parent febe892e15
commit e59fd83292
19 changed files with 779 additions and 7 deletions

View File

@@ -16,6 +16,7 @@ export function App() {
adjustHp,
setAc,
toggleCondition,
toggleConcentration,
} = useEncounter();
return (
@@ -53,6 +54,7 @@ export function App() {
onAdjustHp={adjustHp}
onSetAc={setAc}
onToggleCondition={toggleCondition}
onToggleConcentration={toggleConcentration}
/>
))
)}

View File

@@ -3,8 +3,8 @@ import {
type ConditionId,
deriveHpStatus,
} from "@initiative/domain";
import { Shield, X } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { Brain, Shield, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
import { ConditionPicker } from "./condition-picker";
import { ConditionTags } from "./condition-tags";
@@ -20,6 +20,7 @@ interface Combatant {
readonly currentHp?: number;
readonly ac?: number;
readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean;
}
interface CombatantRowProps {
@@ -32,6 +33,7 @@ interface CombatantRowProps {
onAdjustHp: (id: CombatantId, delta: number) => void;
onSetAc: (id: CombatantId, value: number | undefined) => void;
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
onToggleConcentration: (id: CombatantId) => void;
}
function EditableName({
@@ -240,22 +242,69 @@ export function CombatantRow({
onAdjustHp,
onSetAc,
onToggleCondition,
onToggleConcentration,
}: CombatantRowProps) {
const { id, name, initiative, maxHp, currentHp } = combatant;
const status = deriveHpStatus(currentHp, maxHp);
const [pickerOpen, setPickerOpen] = useState(false);
const prevHpRef = useRef(currentHp);
const [isPulsing, setIsPulsing] = useState(false);
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(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]);
return (
<div
className={cn(
"rounded-md px-3 py-2 transition-colors",
"group rounded-md px-3 py-2 transition-colors",
isActive
? "border-l-2 border-l-accent bg-accent/10"
: "border-l-2 border-l-transparent",
: combatant.isConcentrating
? "border-l-2 border-l-purple-400"
: "border-l-2 border-l-transparent",
status === "unconscious" && "opacity-50",
isPulsing && "animate-concentration-pulse",
)}
>
<div className="grid grid-cols-[3rem_1fr_auto_auto_2rem] items-center gap-3">
<div className="grid grid-cols-[1.25rem_3rem_1fr_auto_auto_2rem] items-center gap-3">
{/* Concentration */}
<button
type="button"
onClick={() => onToggleConcentration(id)}
title="Concentrating"
aria-label="Toggle concentration"
className={cn(
"flex items-center justify-center transition-opacity",
combatant.isConcentrating
? "opacity-100 text-purple-400"
: "opacity-0 group-hover:opacity-50 text-muted-foreground",
)}
>
<Brain size={16} />
</button>
{/* Initiative */}
<Input
type="text"

View File

@@ -9,6 +9,7 @@ import {
setAcUseCase,
setHpUseCase,
setInitiativeUseCase,
toggleConcentrationUseCase,
toggleConditionUseCase,
} from "@initiative/application";
import type {
@@ -204,6 +205,19 @@ export function useEncounter() {
[makeStore],
);
const toggleConcentration = useCallback(
(id: CombatantId) => {
const result = toggleConcentrationUseCase(makeStore(), id);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
return {
encounter,
events,
@@ -217,5 +231,6 @@ export function useEncounter() {
adjustHp,
setAc,
toggleCondition,
toggleConcentration,
} as const;
}

View File

@@ -19,6 +19,42 @@
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
@keyframes concentration-shake {
0% {
translate: 0;
}
20% {
translate: -3px;
}
40% {
translate: 3px;
}
60% {
translate: -2px;
}
80% {
translate: 1px;
}
100% {
translate: 0;
}
}
@keyframes concentration-glow {
0% {
box-shadow: 0 0 4px 2px #c084fc;
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
@utility animate-concentration-pulse {
animation:
concentration-shake 450ms ease-out,
concentration-glow 1200ms ease-out;
}
body {
background-color: var(--color-background);
color: var(--color-foreground);

View File

@@ -72,6 +72,9 @@ export function loadEncounter(): Encounter | null {
? validConditions
: undefined;
// Validate isConcentrating field
const isConcentrating = entry.isConcentrating === true ? true : undefined;
// Validate and attach HP fields if valid
const maxHp = entry.maxHp;
const currentHp = entry.currentHp;
@@ -85,12 +88,13 @@ export function loadEncounter(): Encounter | null {
...base,
ac: validAc,
conditions,
isConcentrating,
maxHp,
currentHp: validCurrentHp ? currentHp : maxHp,
};
}
return { ...base, ac: validAc, conditions };
return { ...base, ac: validAc, conditions, isConcentrating };
});
const result = createEncounter(