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:
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