``` ~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js ```
400 lines
11 KiB
TypeScript
400 lines
11 KiB
TypeScript
import clsx from "clsx";
|
|
import { FC, HTMLAttributes, ReactNode } from "react";
|
|
|
|
import {
|
|
Creature,
|
|
CreatureUtils,
|
|
ConditionUtils,
|
|
DamageAdjustmentContract,
|
|
FormatUtils,
|
|
RuleData,
|
|
CreatureAbilityInfo,
|
|
} from "@dndbeyond/character-rules-engine/es";
|
|
|
|
import { HtmlContent } from "~/components/HtmlContent";
|
|
|
|
import {
|
|
AbilityStatMentalList,
|
|
AbilityStatPhysicalList,
|
|
DDB_MEDIA_URL,
|
|
StatBlockTypeEnum,
|
|
} from "../../../../constants";
|
|
import styles from "./styles.module.css";
|
|
|
|
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
variant: "default" | "basic";
|
|
creature: Creature;
|
|
ruleData: RuleData;
|
|
}
|
|
|
|
export const CreatureBlock: FC<Props> = ({
|
|
variant = "default",
|
|
creature,
|
|
ruleData,
|
|
className = "",
|
|
}) => {
|
|
const statBlockType = CreatureUtils.getStatBlockType(creature);
|
|
const isVersion2024 = statBlockType === StatBlockTypeEnum.CORE_RULES_2024;
|
|
|
|
const getDamageAdjustment = (
|
|
type: "vulnerability" | "resistance" | "immunity"
|
|
): string | null => {
|
|
let damageAdjustments: Array<DamageAdjustmentContract> = [];
|
|
|
|
switch (type) {
|
|
case "vulnerability":
|
|
damageAdjustments = CreatureUtils.getDamageVulnerabilities(creature);
|
|
break;
|
|
case "resistance":
|
|
damageAdjustments = CreatureUtils.getDamageResistances(creature);
|
|
break;
|
|
case "immunity":
|
|
damageAdjustments = CreatureUtils.getDamageImmunities(creature);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
if (!damageAdjustments.length) {
|
|
return null;
|
|
} else {
|
|
return damageAdjustments
|
|
.map((damageAdjustment) => damageAdjustment.name)
|
|
.join(", ");
|
|
}
|
|
};
|
|
|
|
const renderSeparator = (): ReactNode => {
|
|
if (isVersion2024) {
|
|
return null;
|
|
}
|
|
//TODO not hard-code the src url here?
|
|
return (
|
|
<img
|
|
className={styles.separator}
|
|
alt=""
|
|
src={`${DDB_MEDIA_URL}/file-attachments/0/579/stat-block-header-bar.svg`}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const renderStatTable = (
|
|
stats: Array<CreatureAbilityInfo>,
|
|
type: "physical" | "mental"
|
|
): ReactNode => {
|
|
return (
|
|
<table className={clsx([styles.statTable, styles[type]])}>
|
|
<thead>
|
|
<tr>
|
|
<th />
|
|
<th />
|
|
<th>Mod</th>
|
|
<th>Save</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{stats.map((stat) => {
|
|
return (
|
|
<tr key={stat.id}>
|
|
<th>{stat.statKey}</th>
|
|
<td>{stat.score}</td>
|
|
<td className={styles.modifier}>
|
|
{FormatUtils.renderSignedNumber(stat.modifier)}
|
|
</td>
|
|
<td className={styles.modifier}>
|
|
{FormatUtils.renderSignedNumber(stat.saveModifier)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
);
|
|
};
|
|
|
|
const renderStats = (): ReactNode => {
|
|
const stats = CreatureUtils.getAbilities(creature);
|
|
|
|
if (isVersion2024) {
|
|
const mentalStats: Array<CreatureAbilityInfo> = [];
|
|
const physicalStats: Array<CreatureAbilityInfo> = [];
|
|
|
|
stats.forEach((ability) => {
|
|
if (AbilityStatPhysicalList.includes(ability.id)) {
|
|
physicalStats.push(ability);
|
|
}
|
|
if (AbilityStatMentalList.includes(ability.id)) {
|
|
mentalStats.push(ability);
|
|
}
|
|
});
|
|
return (
|
|
<div className={styles.stats}>
|
|
{renderStatTable(physicalStats, "physical")}
|
|
{renderStatTable(mentalStats, "mental")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.stats}>
|
|
{stats.map((stat) => {
|
|
const statKey: string = stat.statKey ? stat.statKey : "";
|
|
const modifier: number = stat.modifier ? stat.modifier : 0;
|
|
const score: number | null = stat.score;
|
|
|
|
return (
|
|
<div className={styles.stat} key={statKey}>
|
|
<h2 className={styles.statHeading}>{statKey}</h2>
|
|
<p className={styles.statScore}>{score}</p>
|
|
<p className={styles.statModifier}>
|
|
({FormatUtils.renderSignedNumber(modifier)})
|
|
</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderActions = (): ReactNode => {
|
|
let groupActionNode: ReactNode;
|
|
const groupActionSnippet = CreatureUtils.getGroupActionSnippet(creature);
|
|
if (groupActionSnippet) {
|
|
groupActionNode = <HtmlContent html={groupActionSnippet} />;
|
|
}
|
|
|
|
let actionsNode: ReactNode;
|
|
const actionsDescription = CreatureUtils.getActionsDescription(creature);
|
|
if (actionsDescription) {
|
|
actionsNode = <HtmlContent html={actionsDescription} />;
|
|
}
|
|
|
|
const contentNode: ReactNode = (
|
|
<>
|
|
{groupActionNode}
|
|
{actionsNode}
|
|
</>
|
|
);
|
|
|
|
return renderDescription("Actions", contentNode);
|
|
};
|
|
|
|
const renderLegendaryActions = (): ReactNode => {
|
|
if (!CreatureUtils.canUseLegendaryActions(creature)) {
|
|
return null;
|
|
}
|
|
|
|
return renderDescription(
|
|
"Legendary Actions",
|
|
CreatureUtils.getLegendaryActionsDescription(creature)
|
|
);
|
|
};
|
|
|
|
const renderSpecialDescription = (): ReactNode => {
|
|
const groupInfo = CreatureUtils.getGroupInfo(creature);
|
|
let groupNode: ReactNode;
|
|
|
|
if (groupInfo?.specialQualityText) {
|
|
let groupTitle: ReactNode;
|
|
if (groupInfo.specialQualityTitle) {
|
|
groupTitle = (
|
|
<>
|
|
<strong>
|
|
<em>{groupInfo.specialQualityTitle}</em>
|
|
</strong>
|
|
.{" "}
|
|
</>
|
|
);
|
|
}
|
|
groupNode = (
|
|
<p>
|
|
{groupTitle}
|
|
{groupInfo.specialQualityText}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
let monsterNode: ReactNode;
|
|
const monsterDescription =
|
|
CreatureUtils.getSpecialTraitsDescription(creature);
|
|
if (monsterDescription) {
|
|
monsterNode = <HtmlContent html={monsterDescription} />;
|
|
}
|
|
|
|
let contentNode: ReactNode = null;
|
|
if (monsterNode || groupNode) {
|
|
contentNode = (
|
|
<>
|
|
{monsterNode}
|
|
{groupNode}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return renderDescription("Traits", contentNode);
|
|
};
|
|
|
|
const renderDescription = (
|
|
label: string | null,
|
|
description: ReactNode
|
|
): ReactNode => {
|
|
if (!description) {
|
|
return null;
|
|
}
|
|
|
|
let descriptionNode: ReactNode;
|
|
if (typeof description === "string") {
|
|
descriptionNode = (
|
|
<HtmlContent className={styles.description} html={description} />
|
|
);
|
|
} else {
|
|
descriptionNode = <div className={styles.description}>{description}</div>;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{label && <h2 className={styles.descriptionHeading}>{label}</h2>}
|
|
{descriptionNode}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const renderAttribute = (label: string, content: string | null) => {
|
|
if (!content) {
|
|
return null;
|
|
}
|
|
return (
|
|
<div className={styles.attribute}>
|
|
<h2 className={styles.attributeLabel}>{label}</h2>
|
|
<p>{content}</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderImmunities = (): ReactNode => {
|
|
const damageImmunities = getDamageAdjustment("immunity");
|
|
const conditionImmunities = CreatureUtils.getConditionImmunities(creature)
|
|
.map((condition) => ConditionUtils.getName(condition))
|
|
.join(", ");
|
|
|
|
if (!isVersion2024) {
|
|
if (!damageImmunities && !conditionImmunities) {
|
|
return null;
|
|
}
|
|
|
|
const immunityChunks: Array<string> = [];
|
|
if (damageImmunities) {
|
|
immunityChunks.push(damageImmunities);
|
|
}
|
|
if (conditionImmunities) {
|
|
immunityChunks.push(conditionImmunities);
|
|
}
|
|
return renderAttribute("Immunities", immunityChunks.join("; "));
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{renderAttribute("Damage Immunities", damageImmunities)}
|
|
{renderAttribute("Condition Immunities", conditionImmunities)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<section
|
|
className={clsx([
|
|
className,
|
|
styles.creatureBlock,
|
|
isVersion2024 && styles.v2024,
|
|
])}
|
|
>
|
|
<h1 className={styles.header}>{CreatureUtils.getName(creature)}</h1>
|
|
<p className={styles.meta}>
|
|
{CreatureUtils.renderMetaText(creature, ruleData, variant === "basic")}
|
|
</p>
|
|
{renderSeparator()}
|
|
{renderAttribute(
|
|
isVersion2024 ? "AC" : "Armor Class",
|
|
CreatureUtils.renderArmorClass(creature)
|
|
)}
|
|
{renderAttribute(
|
|
"Initiative",
|
|
CreatureUtils.renderInitiativeInfo(creature)
|
|
)}
|
|
{renderAttribute(
|
|
isVersion2024 ? "HP" : "Hit Points",
|
|
CreatureUtils.renderHitPointInfo(creature)
|
|
)}
|
|
{renderAttribute(
|
|
"Speed",
|
|
CreatureUtils.renderSpeedInfo(creature, ruleData)
|
|
)}
|
|
{variant === "default" && (
|
|
<>
|
|
{renderSeparator()}
|
|
{renderStats()}
|
|
{renderSeparator()}
|
|
</>
|
|
)}
|
|
{variant === "default" && (
|
|
<>
|
|
{!isVersion2024 &&
|
|
renderAttribute(
|
|
"Saving Throws",
|
|
CreatureUtils.renderSavingThrows(creature)
|
|
)}
|
|
{renderAttribute(
|
|
"Skills",
|
|
CreatureUtils.renderSkills(creature, ruleData)
|
|
)}
|
|
{renderAttribute(
|
|
isVersion2024 ? "Vulnerabilities" : "Damage Vulnerabilities",
|
|
getDamageAdjustment("vulnerability")
|
|
)}
|
|
{renderAttribute(
|
|
isVersion2024 ? "Resistances" : "Damage Resistances",
|
|
getDamageAdjustment("resistance")
|
|
)}
|
|
{renderImmunities()}
|
|
{renderAttribute("Gear", CreatureUtils.getGear(creature))}
|
|
{renderAttribute(
|
|
"Senses",
|
|
CreatureUtils.renderSensesInfo(creature, ruleData)
|
|
)}
|
|
{renderAttribute(
|
|
"Languages",
|
|
CreatureUtils.renderLanguages(creature, ruleData)
|
|
)}
|
|
</>
|
|
)}
|
|
{renderAttribute(
|
|
isVersion2024 ? "CR" : "Challenge",
|
|
CreatureUtils.renderChallengeRatingInfo(creature)
|
|
)}
|
|
{variant === "default" && (
|
|
<>
|
|
{!isVersion2024 &&
|
|
renderAttribute(
|
|
"Proficiency Bonus",
|
|
CreatureUtils.renderProficiencyBonus(creature)
|
|
)}
|
|
{renderSeparator()}
|
|
<div className={styles.descriptions}>
|
|
{renderSpecialDescription()}
|
|
{renderActions()}
|
|
{renderDescription(
|
|
"Bonus Actions",
|
|
CreatureUtils.getBonusActionsDescription(creature)
|
|
)}
|
|
{renderDescription(
|
|
"Reactions",
|
|
CreatureUtils.getReactionsDescription(creature)
|
|
)}
|
|
{renderLegendaryActions()}
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
);
|
|
};
|