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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user