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:
@@ -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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user