Implement the 021-bestiary-statblock feature that adds a searchable D&D 2024 Monster Manual creature library with inline autocomplete suggestions, full stat block display in a fixed side panel, auto-numbering of duplicate creature names, HP/AC pre-fill from bestiary data, and automatic stat block display on turn change for wide viewports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,35 +1,143 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { Search } from "lucide-react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { BestiarySearch } from "./bestiary-search.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface ActionBarProps {
|
||||
onAddCombatant: (name: string) => void;
|
||||
onAddFromBestiary: (creature: Creature) => void;
|
||||
bestiarySearch: (query: string) => Creature[];
|
||||
bestiaryLoaded: boolean;
|
||||
suggestions: Creature[];
|
||||
onSearchChange: (query: string) => void;
|
||||
onShowStatBlock?: (creature: Creature) => void;
|
||||
}
|
||||
|
||||
export function ActionBar({ onAddCombatant }: ActionBarProps) {
|
||||
export function ActionBar({
|
||||
onAddCombatant,
|
||||
onAddFromBestiary,
|
||||
bestiarySearch,
|
||||
bestiaryLoaded,
|
||||
suggestions,
|
||||
onSearchChange,
|
||||
onShowStatBlock,
|
||||
}: ActionBarProps) {
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||
|
||||
const handleAdd = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (nameInput.trim() === "") return;
|
||||
onAddCombatant(nameInput);
|
||||
setNameInput("");
|
||||
onSearchChange("");
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setNameInput(value);
|
||||
setSuggestionIndex(-1);
|
||||
onSearchChange(value);
|
||||
};
|
||||
|
||||
const handleSelectCreature = (creature: Creature) => {
|
||||
onAddFromBestiary(creature);
|
||||
setSearchOpen(false);
|
||||
setNameInput("");
|
||||
onSearchChange("");
|
||||
onShowStatBlock?.(creature);
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (creature: Creature) => {
|
||||
onAddFromBestiary(creature);
|
||||
setNameInput("");
|
||||
onSearchChange("");
|
||||
onShowStatBlock?.(creature);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (suggestions.length === 0) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||
e.preventDefault();
|
||||
handleSelectSuggestion(suggestions[suggestionIndex]);
|
||||
} else if (e.key === "Escape") {
|
||||
setSuggestionIndex(-1);
|
||||
onSearchChange("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
||||
<form onSubmit={handleAdd} className="flex flex-1 items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={nameInput}
|
||||
onChange={(e) => setNameInput(e.target.value)}
|
||||
placeholder="Combatant name"
|
||||
className="max-w-xs"
|
||||
{searchOpen ? (
|
||||
<BestiarySearch
|
||||
onSelectCreature={handleSelectCreature}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
searchFn={bestiarySearch}
|
||||
/>
|
||||
<Button type="submit" size="sm">
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleAdd}
|
||||
className="relative flex flex-1 items-center gap-2"
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={nameInput}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Combatant name"
|
||||
className="max-w-xs"
|
||||
/>
|
||||
{suggestions.length > 0 && (
|
||||
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
||||
<ul className="max-h-48 overflow-y-auto py-1">
|
||||
{suggestions.map((creature, i) => (
|
||||
<li key={creature.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
||||
i === suggestionIndex
|
||||
? "bg-accent/20 text-foreground"
|
||||
: "text-foreground hover:bg-accent/10"
|
||||
}`}
|
||||
onClick={() => handleSelectSuggestion(creature)}
|
||||
onMouseEnter={() => setSuggestionIndex(i)}
|
||||
>
|
||||
<span>{creature.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{creature.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" size="sm">
|
||||
Add
|
||||
</Button>
|
||||
{bestiaryLoaded && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
129
apps/web/src/components/bestiary-search.tsx
Normal file
129
apps/web/src/components/bestiary-search.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { Search, X } from "lucide-react";
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface BestiarySearchProps {
|
||||
onSelectCreature: (creature: Creature) => void;
|
||||
onClose: () => void;
|
||||
searchFn: (query: string) => Creature[];
|
||||
}
|
||||
|
||||
export function BestiarySearch({
|
||||
onSelectCreature,
|
||||
onClose,
|
||||
searchFn,
|
||||
}: BestiarySearchProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const results = query.length >= 2 ? searchFn(query) : [];
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightIndex(-1);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((i) => (i < results.length - 1 ? i + 1 : 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((i) => (i > 0 ? i - 1 : results.length - 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && highlightIndex >= 0) {
|
||||
e.preventDefault();
|
||||
onSelectCreature(results[highlightIndex]);
|
||||
}
|
||||
},
|
||||
[results, highlightIndex, onClose, onSelectCreature],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full max-w-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search bestiary..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{query.length >= 2 && (
|
||||
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
|
||||
{results.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
No creatures found
|
||||
</div>
|
||||
) : (
|
||||
<ul className="max-h-60 overflow-y-auto py-1">
|
||||
{results.map((creature, i) => (
|
||||
<li key={creature.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
||||
i === highlightIndex
|
||||
? "bg-accent/20 text-foreground"
|
||||
: "text-foreground hover:bg-accent/10"
|
||||
}`}
|
||||
onClick={() => onSelectCreature(creature)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
>
|
||||
<span>{creature.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{creature.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type ConditionId,
|
||||
deriveHpStatus,
|
||||
} from "@initiative/domain";
|
||||
import { Brain, Shield, X } from "lucide-react";
|
||||
import { BookOpen, Brain, Shield, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConditionPicker } from "./condition-picker";
|
||||
@@ -34,6 +34,7 @@ interface CombatantRowProps {
|
||||
onSetAc: (id: CombatantId, value: number | undefined) => void;
|
||||
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
|
||||
onToggleConcentration: (id: CombatantId) => void;
|
||||
onShowStatBlock?: () => void;
|
||||
}
|
||||
|
||||
function EditableName({
|
||||
@@ -276,6 +277,7 @@ export function CombatantRow({
|
||||
onSetAc,
|
||||
onToggleCondition,
|
||||
onToggleConcentration,
|
||||
onShowStatBlock,
|
||||
}: CombatantRowProps) {
|
||||
const { id, name, initiative, maxHp, currentHp } = combatant;
|
||||
const status = deriveHpStatus(currentHp, maxHp);
|
||||
@@ -364,8 +366,19 @@ export function CombatantRow({
|
||||
/>
|
||||
|
||||
{/* Name */}
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<div className={cn("flex items-center gap-1", dimmed && "opacity-50")}>
|
||||
<EditableName name={name} combatantId={id} onRename={onRename} />
|
||||
{onShowStatBlock && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowStatBlock}
|
||||
className="text-muted-foreground hover:text-amber-400 transition-colors"
|
||||
title="View stat block"
|
||||
aria-label="View stat block"
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AC */}
|
||||
|
||||
77
apps/web/src/components/stat-block-panel.tsx
Normal file
77
apps/web/src/components/stat-block-panel.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { StatBlock } from "./stat-block.js";
|
||||
|
||||
interface StatBlockPanelProps {
|
||||
creature: Creature | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => window.matchMedia("(min-width: 1024px)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(min-width: 1024px)");
|
||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
if (!creature) return null;
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<div className="fixed top-0 right-0 bottom-0 flex w-[400px] flex-col border-l border-border bg-card">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
Stat Block
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<StatBlock creature={creature} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile drawer
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/50 animate-in fade-in"
|
||||
onClick={onClose}
|
||||
aria-label="Close stat block"
|
||||
/>
|
||||
{/* Drawer */}
|
||||
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-md animate-slide-in-right border-l border-border bg-card shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
Stat Block
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
|
||||
<StatBlock creature={creature} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
244
apps/web/src/components/stat-block.tsx
Normal file
244
apps/web/src/components/stat-block.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
|
||||
interface StatBlockProps {
|
||||
creature: Creature;
|
||||
}
|
||||
|
||||
function abilityMod(score: number): string {
|
||||
const mod = Math.floor((score - 10) / 2);
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
}
|
||||
|
||||
function PropertyLine({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
}) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold">{label}</span> {value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionDivider() {
|
||||
return (
|
||||
<div className="my-2 h-px bg-gradient-to-r from-amber-800/60 via-amber-700/40 to-transparent" />
|
||||
);
|
||||
}
|
||||
|
||||
export function StatBlock({ creature }: StatBlockProps) {
|
||||
const abilities = [
|
||||
{ label: "STR", score: creature.abilities.str },
|
||||
{ label: "DEX", score: creature.abilities.dex },
|
||||
{ label: "CON", score: creature.abilities.con },
|
||||
{ label: "INT", score: creature.abilities.int },
|
||||
{ label: "WIS", score: creature.abilities.wis },
|
||||
{ label: "CHA", score: creature.abilities.cha },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-1 text-foreground">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-amber-400">{creature.name}</h2>
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
{creature.size} {creature.type}, {creature.alignment}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{creature.sourceDisplayName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SectionDivider />
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="space-y-0.5 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold">Armor Class</span> {creature.ac}
|
||||
{creature.acSource && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
({creature.acSource})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Hit Points</span>{" "}
|
||||
{creature.hp.average}{" "}
|
||||
<span className="text-muted-foreground">({creature.hp.formula})</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Speed</span> {creature.speed}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionDivider />
|
||||
|
||||
{/* Ability scores */}
|
||||
<div className="grid grid-cols-6 gap-1 text-center text-sm">
|
||||
{abilities.map(({ label, score }) => (
|
||||
<div key={label}>
|
||||
<div className="font-semibold">{label}</div>
|
||||
<div>
|
||||
{score}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
({abilityMod(score)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SectionDivider />
|
||||
|
||||
{/* Properties */}
|
||||
<div className="space-y-0.5">
|
||||
<PropertyLine label="Saving Throws" value={creature.savingThrows} />
|
||||
<PropertyLine label="Skills" value={creature.skills} />
|
||||
<PropertyLine
|
||||
label="Damage Vulnerabilities"
|
||||
value={creature.vulnerable}
|
||||
/>
|
||||
<PropertyLine label="Damage Resistances" value={creature.resist} />
|
||||
<PropertyLine label="Damage Immunities" value={creature.immune} />
|
||||
<PropertyLine
|
||||
label="Condition Immunities"
|
||||
value={creature.conditionImmune}
|
||||
/>
|
||||
<PropertyLine label="Senses" value={creature.senses} />
|
||||
<PropertyLine label="Languages" value={creature.languages} />
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold">Challenge</span> {creature.cr}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
(Proficiency Bonus +{creature.proficiencyBonus})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traits */}
|
||||
{creature.traits && creature.traits.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<div className="space-y-2">
|
||||
{creature.traits.map((t) => (
|
||||
<div key={t.name} className="text-sm">
|
||||
<span className="font-semibold italic">{t.name}.</span> {t.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Spellcasting */}
|
||||
{creature.spellcasting && creature.spellcasting.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
{creature.spellcasting.map((sc) => (
|
||||
<div key={sc.name} className="space-y-1 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold italic">{sc.name}.</span>{" "}
|
||||
{sc.headerText}
|
||||
</div>
|
||||
{sc.atWill && sc.atWill.length > 0 && (
|
||||
<div className="pl-2">
|
||||
<span className="font-semibold">At Will:</span>{" "}
|
||||
{sc.atWill.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{sc.daily?.map((d) => (
|
||||
<div key={`${d.uses}${d.each ? "e" : ""}`} className="pl-2">
|
||||
<span className="font-semibold">
|
||||
{d.uses}/day
|
||||
{d.each ? " each" : ""}:
|
||||
</span>{" "}
|
||||
{d.spells.join(", ")}
|
||||
</div>
|
||||
))}
|
||||
{sc.restLong?.map((d) => (
|
||||
<div
|
||||
key={`rest-${d.uses}${d.each ? "e" : ""}`}
|
||||
className="pl-2"
|
||||
>
|
||||
<span className="font-semibold">
|
||||
{d.uses}/long rest
|
||||
{d.each ? " each" : ""}:
|
||||
</span>{" "}
|
||||
{d.spells.join(", ")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{creature.actions && creature.actions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="text-base font-bold text-amber-400">Actions</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.actions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bonus Actions */}
|
||||
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="text-base font-bold text-amber-400">Bonus Actions</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.bonusActions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Reactions */}
|
||||
{creature.reactions && creature.reactions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="text-base font-bold text-amber-400">Reactions</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.reactions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Legendary Actions */}
|
||||
{creature.legendaryActions && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="text-base font-bold text-amber-400">
|
||||
Legendary Actions
|
||||
</h3>
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
{creature.legendaryActions.preamble}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{creature.legendaryActions.entries.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user