Add temporary hit points as a separate damage buffer
Temp HP absorbs damage before current HP, cannot be healed, and does not stack (higher value wins). Displayed as cyan +N after current HP with a Shield button in the HP adjustment popover. Column space is reserved across all rows only when any combatant has temp HP. Concentration pulse fires on any damage, including damage fully absorbed by temp HP. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ interface Combatant {
|
||||
readonly initiative?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly currentHp?: number;
|
||||
readonly tempHp?: number;
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
@@ -181,12 +182,18 @@ function MaxHpDisplay({
|
||||
function ClickableHp({
|
||||
currentHp,
|
||||
maxHp,
|
||||
tempHp,
|
||||
hasTempHp,
|
||||
onAdjust,
|
||||
onSetTempHp,
|
||||
dimmed,
|
||||
}: Readonly<{
|
||||
currentHp: number | undefined;
|
||||
maxHp: number | undefined;
|
||||
tempHp: number | undefined;
|
||||
hasTempHp: boolean;
|
||||
onAdjust: (delta: number) => void;
|
||||
onSetTempHp: (value: number) => void;
|
||||
dimmed?: boolean;
|
||||
}>) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
@@ -208,11 +215,11 @@ function ClickableHp({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPopoverOpen(true)}
|
||||
aria-label={`Current HP: ${currentHp} (${status})`}
|
||||
aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
|
||||
className={cn(
|
||||
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral",
|
||||
status === "bloodied" && "text-amber-400",
|
||||
@@ -223,9 +230,21 @@ function ClickableHp({
|
||||
>
|
||||
{currentHp}
|
||||
</button>
|
||||
{!!hasTempHp && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block min-w-[3ch] text-center text-sm tabular-nums leading-7",
|
||||
tempHp ? "font-medium text-cyan-400" : "invisible",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{tempHp ? `+${tempHp}` : ""}
|
||||
</span>
|
||||
)}
|
||||
{!!popoverOpen && (
|
||||
<HpAdjustPopover
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={() => setPopoverOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -443,6 +462,8 @@ export function CombatantRow({
|
||||
removeCombatant,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setTempHp,
|
||||
hasTempHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
@@ -475,24 +496,27 @@ export function CombatantRow({
|
||||
const conditionAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const prevHpRef = useRef(currentHp);
|
||||
const prevTempHpRef = useRef(combatant.tempHp);
|
||||
const [isPulsing, setIsPulsing] = useState(false);
|
||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const prevHp = prevHpRef.current;
|
||||
const prevTempHp = prevTempHpRef.current;
|
||||
prevHpRef.current = currentHp;
|
||||
prevTempHpRef.current = combatant.tempHp;
|
||||
|
||||
if (
|
||||
prevHp !== undefined &&
|
||||
currentHp !== undefined &&
|
||||
currentHp < prevHp &&
|
||||
combatant.isConcentrating
|
||||
) {
|
||||
const realHpDropped =
|
||||
prevHp !== undefined && currentHp !== undefined && currentHp < prevHp;
|
||||
const tempHpDropped =
|
||||
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
|
||||
|
||||
if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) {
|
||||
setIsPulsing(true);
|
||||
clearTimeout(pulseTimerRef.current);
|
||||
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
||||
}
|
||||
}, [currentHp, combatant.isConcentrating]);
|
||||
}, [currentHp, combatant.tempHp, combatant.isConcentrating]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!combatant.isConcentrating) {
|
||||
@@ -595,7 +619,10 @@ export function CombatantRow({
|
||||
<ClickableHp
|
||||
currentHp={currentHp}
|
||||
maxHp={maxHp}
|
||||
tempHp={combatant.tempHp}
|
||||
hasTempHp={hasTempHp}
|
||||
onAdjust={(delta) => adjustHp(id, delta)}
|
||||
onSetTempHp={(value) => setTempHp(id, value)}
|
||||
dimmed={dimmed}
|
||||
/>
|
||||
{maxHp !== undefined && (
|
||||
|
||||
Reference in New Issue
Block a user