Grabbed dndbeyond's source code
``` ~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js ```
This commit is contained in:
+220
@@ -0,0 +1,220 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useContext } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
import {
|
||||
DigitalDiceWrapper,
|
||||
LightDiceSvg,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import { RollKind, RollRequest, RollType } from "@dndbeyond/dice";
|
||||
import { GameLogContext } from "@dndbeyond/game-log-components";
|
||||
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { isNotNullOrUndefined } from "~/helpers/validation";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { getDataOriginComponentInfo } from "~/subApps/sheet/components/Sidebar/helpers/paneUtils";
|
||||
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
|
||||
import DiceAdjustmentSummary from "~/tools/js/Shared/components/DiceAdjustmentSummary";
|
||||
import {
|
||||
appEnvSelectors,
|
||||
characterRollContextSelectors,
|
||||
} from "~/tools/js/Shared/selectors";
|
||||
import { DataOrigin } from "~/types";
|
||||
|
||||
import { Heading } from "../../../components/Heading";
|
||||
import { DeathSavesMarks } from "./DeathSavesMarks";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
damageValue: number;
|
||||
}
|
||||
|
||||
export const DeathSavesManager: FC<Props> = ({
|
||||
damageValue,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const {
|
||||
characterTheme,
|
||||
deathSaveInfo,
|
||||
ruleData,
|
||||
characterActions,
|
||||
ruleDataUtils,
|
||||
} = useCharacterEngine();
|
||||
|
||||
const isDiceEnabled = useSelector(appEnvSelectors.getDiceEnabled);
|
||||
const characterRollContext = useSelector(
|
||||
characterRollContextSelectors.getCharacterRollContext
|
||||
);
|
||||
const [{ messageTargetOptions, defaultMessageTargetOption, userId }] =
|
||||
useContext(GameLogContext);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
pane: { paneHistoryPush },
|
||||
} = useSidebar();
|
||||
|
||||
const oneHPRestoreType = ruleDataUtils
|
||||
.getRestoreTypes(ruleData)
|
||||
.find((r) => r.name === "OneHP");
|
||||
|
||||
const hasAdvantage = deathSaveInfo.advantageAdjustments.length > 0;
|
||||
const hasDisadvantage = deathSaveInfo.disadvantageAdjustments.length > 0;
|
||||
|
||||
let rollKind = RollKind.None;
|
||||
if (hasAdvantage && !hasDisadvantage) {
|
||||
rollKind = RollKind.Advantage;
|
||||
} else if (hasDisadvantage && !hasAdvantage) {
|
||||
rollKind = RollKind.Disadvantage;
|
||||
}
|
||||
|
||||
/* --- On Click Functions --- */
|
||||
|
||||
const onClickDataOrigin = (dataOrigin: DataOrigin): void => {
|
||||
let component = getDataOriginComponentInfo(dataOrigin);
|
||||
if (component.type !== PaneComponentEnum.ERROR_404) {
|
||||
paneHistoryPush(component.type, component.identifiers);
|
||||
}
|
||||
};
|
||||
|
||||
const dispatchDeathSaves = (fails: number, successes: number) => {
|
||||
dispatch(
|
||||
characterActions.deathSavesSet(
|
||||
Math.min(ruleDataUtils.getMaxDeathsavesFail(ruleData), fails),
|
||||
Math.min(ruleDataUtils.getMaxDeathsavesSuccess(ruleData), successes)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const onFail = (evt?: React.MouseEvent, isCritical = false) => {
|
||||
const uses = isCritical ? 2 : 1;
|
||||
|
||||
if (evt) {
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
evt.stopPropagation();
|
||||
}
|
||||
|
||||
dispatchDeathSaves(
|
||||
deathSaveInfo.failCount + uses,
|
||||
deathSaveInfo.successCount
|
||||
);
|
||||
};
|
||||
|
||||
const onFailClear = (evt?: React.MouseEvent) => {
|
||||
if (evt) {
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
evt.stopPropagation();
|
||||
}
|
||||
|
||||
dispatchDeathSaves(deathSaveInfo.failCount - 1, deathSaveInfo.successCount);
|
||||
};
|
||||
|
||||
const onSuccess = (evt?: React.MouseEvent) => {
|
||||
if (evt) {
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
evt.stopPropagation();
|
||||
}
|
||||
|
||||
dispatchDeathSaves(deathSaveInfo.failCount, deathSaveInfo.successCount + 1);
|
||||
};
|
||||
|
||||
const onSuccessClear = (evt?: React.MouseEvent) => {
|
||||
if (evt) {
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
evt.stopPropagation();
|
||||
}
|
||||
|
||||
dispatchDeathSaves(deathSaveInfo.failCount, deathSaveInfo.successCount - 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.column, className])} {...props}>
|
||||
<div className={clsx([styles.row, styles.heading])}>
|
||||
<Heading>Death Saves</Heading>
|
||||
{isDiceEnabled && (
|
||||
<DigitalDiceWrapper
|
||||
diceNotation={"1d20"}
|
||||
onRollResults={(rollRequest: RollRequest) => {
|
||||
const roll = rollRequest.rolls[0].result?.total;
|
||||
|
||||
if (roll) {
|
||||
if (roll === 1) {
|
||||
onFail(undefined, true);
|
||||
} else if (roll === 20 && oneHPRestoreType) {
|
||||
dispatch(characterActions.restoreLife(oneHPRestoreType.id));
|
||||
} else if (roll >= 10) {
|
||||
onSuccess.bind(this)();
|
||||
} else {
|
||||
onFail.bind(this)();
|
||||
}
|
||||
}
|
||||
}}
|
||||
rollType={RollType.Save}
|
||||
rollKind={rollKind}
|
||||
rollAction={"Death"}
|
||||
diceEnabled={isDiceEnabled}
|
||||
rollContext={characterRollContext}
|
||||
rollTargetOptions={
|
||||
messageTargetOptions?.entities
|
||||
? Object.values(messageTargetOptions.entities).filter(
|
||||
isNotNullOrUndefined
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
rollTargetDefault={defaultMessageTargetOption}
|
||||
userId={Number(userId)}
|
||||
>
|
||||
<LightDiceSvg />
|
||||
<span>Roll</span>
|
||||
</DigitalDiceWrapper>
|
||||
)}
|
||||
</div>
|
||||
<div className={clsx([styles.row, styles.deathSavesGroups])}>
|
||||
<DeathSavesMarks
|
||||
label="Failures"
|
||||
type="fails"
|
||||
activeCount={deathSaveInfo.failCount}
|
||||
willBeActiveCount={damageValue !== 0 ? 1 : 0}
|
||||
totalCount={ruleData.maxDeathsavesFail}
|
||||
onUse={onFail}
|
||||
onClear={onFailClear}
|
||||
/>
|
||||
<DeathSavesMarks
|
||||
label="Successes"
|
||||
type="successes"
|
||||
activeCount={deathSaveInfo.successCount}
|
||||
totalCount={ruleData.maxDeathsavesSuccess}
|
||||
onUse={onSuccess}
|
||||
onClear={onSuccessClear}
|
||||
/>
|
||||
</div>
|
||||
{(!!deathSaveInfo.advantageAdjustments.length ||
|
||||
!!deathSaveInfo.disadvantageAdjustments.length) && (
|
||||
<div className={styles.diceAdjustments}>
|
||||
{deathSaveInfo.advantageAdjustments.map((diceAdjustment) => {
|
||||
return (
|
||||
<DiceAdjustmentSummary
|
||||
key={diceAdjustment.uniqueKey}
|
||||
diceAdjustment={diceAdjustment}
|
||||
ruleData={ruleData}
|
||||
theme={characterTheme}
|
||||
onDataOriginClick={onClickDataOrigin}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{deathSaveInfo.disadvantageAdjustments.map((diceAdjustment) => {
|
||||
return (
|
||||
<DiceAdjustmentSummary
|
||||
key={diceAdjustment.uniqueKey}
|
||||
diceAdjustment={diceAdjustment}
|
||||
ruleData={ruleData}
|
||||
theme={characterTheme}
|
||||
onDataOriginClick={onClickDataOrigin}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import clsx from "clsx";
|
||||
import { HTMLAttributes, FC } from "react";
|
||||
|
||||
import { CloseSvg } from "@dndbeyond/character-components/es";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
type: "successes" | "fails";
|
||||
label: string;
|
||||
activeCount: number;
|
||||
totalCount: number;
|
||||
willBeActiveCount?: number;
|
||||
onUse: (evt: React.MouseEvent) => void;
|
||||
onClear: (evt: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const DeathSavesMarks: FC<Props> = ({
|
||||
type,
|
||||
label,
|
||||
activeCount,
|
||||
totalCount,
|
||||
willBeActiveCount = 0,
|
||||
onUse,
|
||||
onClear,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
// TODO: Need to make these marks accessible - they currently cannot be interacted with with a keyboard.
|
||||
|
||||
let marks: Array<React.ReactNode> = [];
|
||||
let availableSlots: number = totalCount;
|
||||
|
||||
for (let i = 0; i < activeCount; i++) {
|
||||
marks.push(
|
||||
<span
|
||||
className={styles.mark}
|
||||
key={`${type}-active-${i}`}
|
||||
onClick={onClear}
|
||||
>
|
||||
<CloseSvg
|
||||
fillColor={type === "successes" ? "#00c797" : "#c53131"}
|
||||
secondaryFillColor=""
|
||||
key={`${type}-active-${i}`}
|
||||
className={clsx([styles.mark, styles.activeMark])}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
availableSlots -= activeCount;
|
||||
|
||||
for (let i = 0; i < Math.min(availableSlots, willBeActiveCount); i++) {
|
||||
marks.push(
|
||||
<span
|
||||
key={`${type}-willbe-${i}`}
|
||||
className={clsx([
|
||||
styles.mark,
|
||||
styles.willBeActive,
|
||||
type === "fails" && styles.willBeFail,
|
||||
])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
availableSlots -= willBeActiveCount;
|
||||
|
||||
for (let i = 0; i < Math.min(availableSlots, totalCount); i++) {
|
||||
marks.push(
|
||||
<span
|
||||
key={`${type}-inactive-${i}`}
|
||||
className={clsx([styles.mark])}
|
||||
onClick={onUse}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx([styles.container, className])}
|
||||
data-testid={`${type}-group`}
|
||||
{...props}
|
||||
>
|
||||
<p className={styles.label}>{label}</p>
|
||||
<div className={styles.marks}>{marks}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+537
@@ -0,0 +1,537 @@
|
||||
import clsx from "clsx";
|
||||
import { createRef, FC, HTMLAttributes, useEffect, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { DataOriginName } from "@dndbeyond/character-components/es";
|
||||
import { Constants, ItemManager } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { Checkbox } from "~/components/Checkbox";
|
||||
import { ItemName } from "~/components/ItemName";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { HP_DAMAGE_TAKEN_VALUE } from "~/subApps/sheet/constants";
|
||||
import { Item, HitPointInfo, Creature } from "~/types";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
hpInfo: HitPointInfo;
|
||||
creature?: Creature;
|
||||
handleDamageUpdate?: (damageAmount: number) => void;
|
||||
vibrationAmount?: number;
|
||||
}
|
||||
|
||||
// TODO This hitpoints adjuster doesn't work as a generic component for vehicles yet.
|
||||
|
||||
export const HitPointsAdjuster: FC<Props> = ({
|
||||
hpInfo,
|
||||
creature,
|
||||
handleDamageUpdate,
|
||||
vibrationAmount = 15,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const {
|
||||
characterTheme,
|
||||
protectionSuppliers,
|
||||
deathSaveInfo,
|
||||
ruleData,
|
||||
characterActions,
|
||||
actionUtils,
|
||||
characterUtils,
|
||||
helperUtils,
|
||||
itemUtils,
|
||||
limitedUseUtils,
|
||||
modifierUtils,
|
||||
ruleDataUtils,
|
||||
creatureUtils,
|
||||
} = useCharacterEngine();
|
||||
|
||||
const getInitialActiveProtectionSupplierKey = (): string | null => {
|
||||
const foundSupplier = protectionSuppliers.find(
|
||||
(supplier) =>
|
||||
supplier.availabilityStatus ===
|
||||
Constants.ProtectionAvailabilityStatusEnum.AVAILABLE
|
||||
);
|
||||
|
||||
if (foundSupplier) {
|
||||
return foundSupplier.key;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const [tickCount, setTickCount] = useState(0);
|
||||
const [healingAmount, setHealingAmount] = useState<number | null>(null);
|
||||
const [damageAmount, setDamageAmount] = useState<number | null>(null);
|
||||
const [hpDifference, setHpDifference] = useState(
|
||||
characterUtils.calculateHitPoints(hpInfo, 0)
|
||||
);
|
||||
const [isValueChanged, setIsValueChanged] = useState<boolean>(false);
|
||||
const [activeProtectionSupplierKey, setActiveProtectionSupplierKey] =
|
||||
useState<string | null>(getInitialActiveProtectionSupplierKey());
|
||||
|
||||
const damageInput = createRef<HTMLInputElement>();
|
||||
const healingInput = createRef<HTMLInputElement>();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
/* --- Helper Functions --- */
|
||||
|
||||
const reset = (newTemp: number | null): void => {
|
||||
if (hpInfo.tempHp !== newTemp && newTemp !== null) {
|
||||
creature
|
||||
? dispatch(
|
||||
characterActions.creatureHitPointsSet(
|
||||
creatureUtils.getMappingId(creature),
|
||||
hpInfo.removedHp,
|
||||
newTemp
|
||||
)
|
||||
)
|
||||
: dispatch(characterActions.hitPointsSet(hpInfo.removedHp, newTemp));
|
||||
}
|
||||
setTickCount(0);
|
||||
setHealingAmount(0);
|
||||
setDamageAmount(0);
|
||||
setIsValueChanged(false);
|
||||
setActiveProtectionSupplierKey(getInitialActiveProtectionSupplierKey());
|
||||
};
|
||||
|
||||
const onKeyUpSave = (evt: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (evt.key === "Enter") {
|
||||
onClickSave();
|
||||
}
|
||||
};
|
||||
|
||||
// onKeyDown must be used for the Escape key to reset the adjuster because the
|
||||
// Sidebar uses onKeyDown to close when the Escape key is pressed.
|
||||
const onKeyDownCancel = (
|
||||
evt: React.KeyboardEvent<HTMLInputElement>
|
||||
): void => {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
if (evt.key === "Escape") {
|
||||
reset(hpInfo.tempHp);
|
||||
}
|
||||
};
|
||||
|
||||
/* --- On Input Change Functions --- */
|
||||
|
||||
const onChangeHealing = (evt: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setTickCount(0);
|
||||
setHealingAmount(
|
||||
helperUtils.clampInt(
|
||||
helperUtils.parseInputInt(evt.target.value, HP_DAMAGE_TAKEN_VALUE.MIN),
|
||||
HP_DAMAGE_TAKEN_VALUE.MIN,
|
||||
HP_DAMAGE_TAKEN_VALUE.MAX
|
||||
)
|
||||
);
|
||||
setDamageAmount(0);
|
||||
setIsValueChanged(true);
|
||||
};
|
||||
|
||||
const onChangeDamage = (evt: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setTickCount(0);
|
||||
setHealingAmount(0);
|
||||
setDamageAmount(
|
||||
helperUtils.clampInt(
|
||||
helperUtils.parseInputInt(evt.target.value, HP_DAMAGE_TAKEN_VALUE.MIN),
|
||||
HP_DAMAGE_TAKEN_VALUE.MIN,
|
||||
HP_DAMAGE_TAKEN_VALUE.MAX
|
||||
) * -1
|
||||
);
|
||||
setIsValueChanged(true);
|
||||
};
|
||||
|
||||
/* --- On Click Functions --- */
|
||||
|
||||
const onClickIncrease = (): void => {
|
||||
setHealingAmount(
|
||||
healingAmount !== null && damageAmount === 0 ? healingAmount + 1 : 0
|
||||
);
|
||||
setDamageAmount(
|
||||
damageAmount !== null && damageAmount < 0 ? damageAmount + 1 : 0
|
||||
);
|
||||
setIsValueChanged(true);
|
||||
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(vibrationAmount);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickDecrease = (): void => {
|
||||
setHealingAmount(
|
||||
healingAmount !== null && healingAmount > 0 ? healingAmount - 1 : 0
|
||||
);
|
||||
setDamageAmount(
|
||||
damageAmount !== null && healingAmount === 0 ? damageAmount - 1 : 0
|
||||
);
|
||||
setIsValueChanged(true);
|
||||
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(vibrationAmount);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickSave = (): void => {
|
||||
const { newTemp, startHp, newHp } = hpDifference;
|
||||
|
||||
let protectedHpResult: number | null = null;
|
||||
if (!creature) {
|
||||
if (
|
||||
startHp !== 0 &&
|
||||
newHp === 0 &&
|
||||
activeProtectionSupplierKey !== null
|
||||
) {
|
||||
let foundActiveSupplier = protectionSuppliers.find(
|
||||
(supplier) => supplier.key === activeProtectionSupplierKey
|
||||
);
|
||||
|
||||
if (foundActiveSupplier) {
|
||||
protectedHpResult = foundActiveSupplier.setHpValue;
|
||||
|
||||
switch (foundActiveSupplier.type) {
|
||||
case Constants.ProtectionSupplierTypeEnum.ITEM: {
|
||||
const itemData = foundActiveSupplier.data as Item;
|
||||
const item = ItemManager.getItem(
|
||||
itemUtils.getMappingId(itemData)
|
||||
);
|
||||
|
||||
const numberUsed = item.getNumberUsed();
|
||||
if (numberUsed !== null) {
|
||||
item.handleItemLimitedUseSet(numberUsed + 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Constants.ProtectionSupplierTypeEnum.RACIAL_TRAIT:
|
||||
case Constants.ProtectionSupplierTypeEnum.CLASS_FEATURE:
|
||||
case Constants.ProtectionSupplierTypeEnum.FEAT: {
|
||||
let action = foundActiveSupplier.action;
|
||||
if (action !== null) {
|
||||
let limitedUse = actionUtils.getLimitedUse(action);
|
||||
const id = actionUtils.getId(action);
|
||||
const entityTypeId = actionUtils.getEntityTypeId(action);
|
||||
const dataOriginType = actionUtils.getDataOriginType(action);
|
||||
|
||||
if (
|
||||
limitedUse !== null &&
|
||||
id !== null &&
|
||||
entityTypeId !== null
|
||||
) {
|
||||
const numberUsed = limitedUseUtils.getNumberUsed(limitedUse);
|
||||
dispatch(
|
||||
characterActions.actionUseSet(
|
||||
id,
|
||||
entityTypeId,
|
||||
numberUsed + 1,
|
||||
dataOriginType
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// not implemented
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let removedHp: number = 0;
|
||||
if (protectedHpResult === null) {
|
||||
removedHp = hpInfo.totalHp - newHp;
|
||||
} else {
|
||||
removedHp = hpInfo.totalHp - protectedHpResult;
|
||||
}
|
||||
|
||||
creature
|
||||
? dispatch(
|
||||
characterActions.creatureHitPointsSet(
|
||||
creatureUtils.getMappingId(creature),
|
||||
removedHp,
|
||||
newTemp
|
||||
)
|
||||
)
|
||||
: dispatch(characterActions.hitPointsSet(removedHp, newTemp));
|
||||
|
||||
const damage: number =
|
||||
damageAmount === null
|
||||
? 0
|
||||
: Math.min(
|
||||
tickCount +
|
||||
(healingAmount === null ? 0 : healingAmount) +
|
||||
damageAmount,
|
||||
0
|
||||
);
|
||||
|
||||
if (startHp === 0 && !creature) {
|
||||
if (newHp > 0) {
|
||||
dispatch(characterActions.deathSavesSet(0, 0));
|
||||
} else if (damage !== 0 && protectedHpResult === null) {
|
||||
dispatch(
|
||||
characterActions.deathSavesSet(
|
||||
Math.min(
|
||||
ruleDataUtils.getMaxDeathsavesFail(ruleData),
|
||||
deathSaveInfo.failCount + 1
|
||||
),
|
||||
Math.min(
|
||||
ruleDataUtils.getMaxDeathsavesSuccess(ruleData),
|
||||
deathSaveInfo.successCount
|
||||
)
|
||||
)
|
||||
);
|
||||
reset(hpInfo.tempHp);
|
||||
}
|
||||
}
|
||||
|
||||
if (startHp === newHp) {
|
||||
reset(newTemp);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickCancel = (): void => {
|
||||
reset(hpInfo.tempHp);
|
||||
};
|
||||
|
||||
/* --- useEffects --- */
|
||||
|
||||
useEffect(() => {
|
||||
const difference: number =
|
||||
(healingAmount === null ? 0 : healingAmount) +
|
||||
(damageAmount === null ? 0 : damageAmount) +
|
||||
tickCount;
|
||||
|
||||
setHpDifference(characterUtils.calculateHitPoints(hpInfo, difference));
|
||||
}, [healingAmount, damageAmount, tickCount, hpInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
reset(hpInfo.tempHp);
|
||||
}, [hpInfo, protectionSuppliers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (handleDamageUpdate) {
|
||||
const damageValue: number =
|
||||
damageAmount === null
|
||||
? 0
|
||||
: Math.min(
|
||||
tickCount +
|
||||
(healingAmount === null ? 0 : healingAmount) +
|
||||
damageAmount,
|
||||
0
|
||||
);
|
||||
handleDamageUpdate(damageValue);
|
||||
}
|
||||
}, [tickCount, healingAmount, damageAmount]);
|
||||
|
||||
/* --- Render Functions --- */
|
||||
|
||||
const renderProtectionInfo = (): React.ReactNode => {
|
||||
const { startHp, newHp } = hpDifference;
|
||||
|
||||
if (
|
||||
!isValueChanged ||
|
||||
startHp === 0 ||
|
||||
newHp !== 0 ||
|
||||
protectionSuppliers.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{protectionSuppliers.map((protectionSupplier) => {
|
||||
if (
|
||||
protectionSupplier.availabilityStatus !==
|
||||
Constants.ProtectionAvailabilityStatusEnum.AVAILABLE
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let dataOrigin = modifierUtils.getDataOrigin(
|
||||
protectionSupplier.modifier
|
||||
);
|
||||
let isEnabled =
|
||||
activeProtectionSupplierKey !== null &&
|
||||
protectionSupplier.key === activeProtectionSupplierKey;
|
||||
|
||||
//TODO remove if check and ItemName => use DataOriginName when EntityDataOriginLookup is available to be passed as a prop to DataOriginName
|
||||
let nameNode: React.ReactNode = null;
|
||||
if (
|
||||
protectionSupplier.type ===
|
||||
Constants.ProtectionSupplierTypeEnum.ITEM
|
||||
) {
|
||||
nameNode = <ItemName item={protectionSupplier.data as Item} />;
|
||||
} else {
|
||||
nameNode = (
|
||||
<DataOriginName dataOrigin={dataOrigin} theme={characterTheme} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx([styles.row, styles.protectionNotice])}
|
||||
key={protectionSupplier.key}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isEnabled}
|
||||
themed
|
||||
darkMode={characterTheme.isDarkMode}
|
||||
id={`${protectionSupplier.key}-checkbox`}
|
||||
onClick={() => {
|
||||
isEnabled = !isEnabled;
|
||||
setActiveProtectionSupplierKey(
|
||||
isEnabled ? protectionSupplier.key : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={`${protectionSupplier.key}-checkbox`}>
|
||||
Instead of dropping to 0 hit points, use{" "}
|
||||
<strong>{nameNode}</strong> to set hit points to{" "}
|
||||
<strong>{protectionSupplier.setHpValue}</strong>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx([styles.column, styles.container, className])}
|
||||
{...props}
|
||||
>
|
||||
<div className={clsx([styles.row, styles.container])}>
|
||||
<div className={clsx([styles.column, styles.inputsContainer])}>
|
||||
<div
|
||||
className={clsx([styles.inputContainer, styles.healingContainer])}
|
||||
>
|
||||
<label
|
||||
htmlFor="healing-input"
|
||||
className={clsx([styles.inputLabel, styles.positiveColor])}
|
||||
>
|
||||
Healing
|
||||
</label>
|
||||
<input
|
||||
ref={healingInput}
|
||||
type="number"
|
||||
id="healing-input"
|
||||
className={styles.input}
|
||||
value={
|
||||
healingAmount === null
|
||||
? ""
|
||||
: Math.max(
|
||||
tickCount +
|
||||
healingAmount +
|
||||
(damageAmount === null ? 0 : damageAmount),
|
||||
0
|
||||
)
|
||||
}
|
||||
min={HP_DAMAGE_TAKEN_VALUE.MIN}
|
||||
max={HP_DAMAGE_TAKEN_VALUE.MAX}
|
||||
onChange={onChangeHealing}
|
||||
onKeyUp={onKeyUpSave}
|
||||
onKeyDown={onKeyDownCancel}
|
||||
/>
|
||||
</div>
|
||||
<div className={clsx([styles.row, styles.newValues])}>
|
||||
<div
|
||||
className={clsx([
|
||||
styles.column,
|
||||
hpDifference.newHp > hpDifference.startHp &&
|
||||
styles.positiveColor,
|
||||
hpDifference.newHp < hpDifference.startHp &&
|
||||
styles.negativeColor,
|
||||
])}
|
||||
>
|
||||
<span className={styles.label}>New HP</span>
|
||||
<span className={styles.value} data-testid="new-hp">
|
||||
{hpDifference.newHp}
|
||||
</span>
|
||||
</div>
|
||||
{hpInfo.tempHp !== null && hpInfo.tempHp > 0 && (
|
||||
<div
|
||||
className={clsx([
|
||||
styles.column,
|
||||
hpDifference.newTemp > hpInfo.tempHp && styles.positiveColor,
|
||||
hpDifference.newTemp < hpInfo.tempHp && styles.negativeColor,
|
||||
])}
|
||||
>
|
||||
<span className={styles.label}>New Temp</span>
|
||||
<span className={styles.value} data-testid="new-temp-hp">
|
||||
{hpDifference.newTemp}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={clsx([styles.inputContainer, styles.damageContainer])}
|
||||
>
|
||||
<label
|
||||
htmlFor="damage-input"
|
||||
className={clsx([styles.inputLabel, styles.negativeColor])}
|
||||
>
|
||||
Damage
|
||||
</label>
|
||||
<input
|
||||
ref={damageInput}
|
||||
type="number"
|
||||
id="damage-input"
|
||||
className={styles.input}
|
||||
value={
|
||||
damageAmount === null
|
||||
? ""
|
||||
: Math.abs(
|
||||
Math.min(
|
||||
tickCount +
|
||||
(healingAmount === null ? 0 : healingAmount) +
|
||||
damageAmount,
|
||||
0
|
||||
)
|
||||
)
|
||||
}
|
||||
min={HP_DAMAGE_TAKEN_VALUE.MIN}
|
||||
max={HP_DAMAGE_TAKEN_VALUE.MAX}
|
||||
onChange={onChangeDamage}
|
||||
onKeyUp={onKeyUpSave}
|
||||
onKeyDown={onKeyDownCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={clsx([styles.column, styles.modifyButtons])}>
|
||||
<Button
|
||||
onClick={onClickIncrease}
|
||||
themed
|
||||
size="xx-small"
|
||||
className={styles.increase}
|
||||
aria-label="Increase Hit Points"
|
||||
/>
|
||||
<Button
|
||||
onClick={onClickDecrease}
|
||||
themed
|
||||
size="xx-small"
|
||||
className={styles.decrease}
|
||||
aria-label="Decrease Hit Points"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!creature && renderProtectionInfo()}
|
||||
{isValueChanged && (
|
||||
<div className={clsx([styles.row, styles.applyButtons])}>
|
||||
<Button onClick={onClickSave} size="xx-small" themed>
|
||||
Apply Changes
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClickCancel}
|
||||
variant="outline"
|
||||
size="xx-small"
|
||||
themed
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { FC, HTMLAttributes, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { Constants } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { RuleKeyEnum } from "~/constants";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
|
||||
import { toastMessageActions } from "~/tools/js/Shared/actions";
|
||||
import { ShortModelInfoContract } from "~/types";
|
||||
|
||||
import { HitPointsSummary } from "../../../HitPointsBox/HitPointsSummary";
|
||||
import { DeathSavesManager } from "./DeathSavesManager/DeathSavesManager";
|
||||
import { HitPointsAdjuster } from "./HitPointsAdjuster";
|
||||
import { HitPointsOverrides } from "./HitPointsOverrides";
|
||||
import { RestoreLifeManager } from "./RestoreLifeManager/RestoreLifeManager";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {}
|
||||
export const HitPointsManagePane: FC<Props> = ({ className, ...props }) => {
|
||||
const { hpInfo, deathCause, ruleData, ruleDataUtils, characterActions } =
|
||||
useCharacterEngine();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [damage, setDamage] = useState<number>(0);
|
||||
|
||||
const handleDamageUpdate = (damageValue: number) => {
|
||||
setDamage(damageValue);
|
||||
};
|
||||
|
||||
const onRestoreToLife = (restoreType: ShortModelInfoContract): void => {
|
||||
const restoreChoice = restoreType.name === "Full" ? "full" : "1";
|
||||
|
||||
dispatch(characterActions.restoreLife(restoreType.id));
|
||||
dispatch(
|
||||
toastMessageActions.toastSuccess(
|
||||
"Character Restored to Life",
|
||||
`You have been restored to life with ${restoreChoice} HP.`
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderDeathSavesRules = (): React.ReactNode => {
|
||||
if (
|
||||
hpInfo.remainingHp > 0 ||
|
||||
(deathCause !== Constants.DeathCauseEnum.NONE &&
|
||||
deathCause !== Constants.DeathCauseEnum.DEATHSAVES)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deathSavesRule = ruleDataUtils.getRule(
|
||||
RuleKeyEnum.DEATH_SAVING_THROWS,
|
||||
ruleData
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Heading>Death Saving Throws Rules</Heading>
|
||||
<HtmlContent
|
||||
html={deathSavesRule?.description ? deathSavesRule.description : ""}
|
||||
withoutTooltips
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} {...props}>
|
||||
<Header>HP Management</Header>
|
||||
<div className={styles.container}>
|
||||
{hpInfo.remainingHp <= 0 &&
|
||||
(deathCause === Constants.DeathCauseEnum.NONE ||
|
||||
deathCause === Constants.DeathCauseEnum.DEATHSAVES) && (
|
||||
<DeathSavesManager damageValue={damage} className={styles.border} />
|
||||
)}
|
||||
{deathCause !== Constants.DeathCauseEnum.NONE && (
|
||||
<RestoreLifeManager
|
||||
onSave={onRestoreToLife}
|
||||
className={styles.border}
|
||||
/>
|
||||
)}
|
||||
<HitPointsSummary
|
||||
hpInfo={hpInfo}
|
||||
showOriginalMax
|
||||
showPermanentInputs
|
||||
className={styles.border}
|
||||
/>
|
||||
<HitPointsAdjuster
|
||||
hpInfo={hpInfo}
|
||||
handleDamageUpdate={handleDamageUpdate}
|
||||
className={styles.border}
|
||||
/>
|
||||
<HitPointsOverrides className={styles.border} />
|
||||
{hpInfo.remainingHp <= 0 &&
|
||||
(deathCause === Constants.DeathCauseEnum.NONE ||
|
||||
deathCause === Constants.DeathCauseEnum.DEATHSAVES) &&
|
||||
renderDeathSavesRules()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import clsx from "clsx";
|
||||
import { createRef, FC, HTMLAttributes, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import {
|
||||
HP_BONUS_VALUE,
|
||||
HP_DAMAGE_TAKEN_VALUE,
|
||||
HP_OVERRIDE_MAX_VALUE,
|
||||
HP_TEMP_VALUE,
|
||||
} from "~/subApps/sheet/constants";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const HitPointsOverrides: FC<Props> = ({ className, ...props }) => {
|
||||
const { characterActions, hpInfo, ruleData, ruleDataUtils, helperUtils } =
|
||||
useCharacterEngine();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [maxModifier, setMaxModifier] = useState<number | null>(hpInfo.bonusHp);
|
||||
const [maxOverride, setMaxOverride] = useState<number | null>(
|
||||
hpInfo.overrideHp
|
||||
);
|
||||
|
||||
const maxModifierInput = createRef<HTMLInputElement>();
|
||||
const maxOverrideInput = createRef<HTMLInputElement>();
|
||||
|
||||
/* --- Helper functions --- */
|
||||
|
||||
const updateDamageTaken = (maxHp: number) => {
|
||||
if (hpInfo.totalHp !== null) {
|
||||
let maxDiff = hpInfo.totalHp - maxHp;
|
||||
if (maxDiff > 0) {
|
||||
let newRemovedHp = Math.max(
|
||||
HP_DAMAGE_TAKEN_VALUE.MIN,
|
||||
hpInfo.removedHp - maxDiff
|
||||
);
|
||||
if (newRemovedHp !== hpInfo.removedHp) {
|
||||
dispatch(
|
||||
characterActions.hitPointsSet(
|
||||
newRemovedHp,
|
||||
hpInfo.tempHp ?? HP_TEMP_VALUE.MIN
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyUp = (
|
||||
func: any,
|
||||
evt: React.KeyboardEvent<HTMLInputElement>
|
||||
): void => {
|
||||
if (evt.key === "Enter") {
|
||||
func(evt);
|
||||
}
|
||||
};
|
||||
|
||||
/* --- Max hit point modifier functions --- */
|
||||
|
||||
const onChangeMaxModifier = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setMaxModifier(helperUtils.parseInputInt(evt.target.value));
|
||||
};
|
||||
|
||||
const onBlurMaxModifier = (evt: React.FocusEvent<HTMLInputElement>) => {
|
||||
let value = helperUtils.parseInputInt(evt.target.value);
|
||||
if (value !== null) {
|
||||
value = helperUtils.clampInt(
|
||||
value,
|
||||
HP_BONUS_VALUE.MIN,
|
||||
HP_BONUS_VALUE.MAX
|
||||
);
|
||||
}
|
||||
|
||||
if (value !== null) {
|
||||
updateDamageTaken(hpInfo.baseTotalHp + value);
|
||||
}
|
||||
|
||||
if (value !== hpInfo.bonusHp) {
|
||||
dispatch(characterActions.bonusHitPointsSet(value));
|
||||
}
|
||||
|
||||
setMaxModifier(value);
|
||||
};
|
||||
|
||||
/* --- Max hit point override functions --- */
|
||||
|
||||
const onChangeMaxOverride = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setMaxOverride(helperUtils.parseInputInt(evt.target.value));
|
||||
};
|
||||
|
||||
const onBlurMaxOverride = (evt: React.FocusEvent<HTMLInputElement>) => {
|
||||
let value = helperUtils.parseInputInt(evt.target.value);
|
||||
if (value !== null) {
|
||||
value = helperUtils.clampInt(
|
||||
value,
|
||||
ruleDataUtils.getMinimumHpTotal(ruleData),
|
||||
HP_OVERRIDE_MAX_VALUE
|
||||
);
|
||||
}
|
||||
|
||||
if (value !== null) {
|
||||
updateDamageTaken(value);
|
||||
}
|
||||
|
||||
if (value !== hpInfo.overrideHp) {
|
||||
//TODO fix when it can accept null
|
||||
dispatch(characterActions.overrideHitPointsSet(value as number));
|
||||
}
|
||||
|
||||
setMaxOverride(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.row, styles.overrides, className])} {...props}>
|
||||
<label className={clsx([styles.column, styles.override, styles.label])}>
|
||||
Max HP Modifier
|
||||
<input
|
||||
ref={maxModifierInput}
|
||||
className={styles.input}
|
||||
type="number"
|
||||
value={maxModifier ?? ""}
|
||||
min={HP_BONUS_VALUE.MIN}
|
||||
max={HP_BONUS_VALUE.MAX}
|
||||
onChange={onChangeMaxModifier}
|
||||
onBlur={onBlurMaxModifier}
|
||||
onKeyUp={onKeyUp.bind(this, onBlurMaxModifier)}
|
||||
placeholder="--"
|
||||
/>
|
||||
</label>
|
||||
<label className={clsx([styles.column, styles.override, styles.label])}>
|
||||
Override Max HP
|
||||
<input
|
||||
ref={maxOverrideInput}
|
||||
className={styles.input}
|
||||
type="number"
|
||||
value={maxOverride ?? ""}
|
||||
min={ruleDataUtils.getMinimumHpTotal(ruleData)}
|
||||
max={HP_OVERRIDE_MAX_VALUE}
|
||||
onChange={onChangeMaxOverride}
|
||||
onBlur={onBlurMaxOverride}
|
||||
onKeyUp={onKeyUp.bind(this, onBlurMaxOverride)}
|
||||
placeholder="--"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useState } from "react";
|
||||
|
||||
import {
|
||||
ExclusiveCheckbox,
|
||||
TypeScriptUtils,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { ShortModelInfoContract } from "~/types";
|
||||
|
||||
import { Heading } from "../../../components/Heading";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
onSave?: (restoreType: ShortModelInfoContract) => void;
|
||||
}
|
||||
|
||||
export const RestoreLifeManager: FC<Props> = ({
|
||||
onSave,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const { characterTheme, ruleData, ruleDataUtils } = useCharacterEngine();
|
||||
|
||||
const [activeChoice, setActiveChoice] = useState<number | null>(null);
|
||||
|
||||
const onClickRestore = () => {
|
||||
if (onSave && activeChoice !== null) {
|
||||
let restoreTypes = ruleDataUtils.getRestoreTypes(ruleData);
|
||||
onSave(restoreTypes[activeChoice]);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickReset = () => {
|
||||
setActiveChoice(null);
|
||||
};
|
||||
|
||||
const onSelection = (slotIdx: number) => {
|
||||
setActiveChoice(slotIdx);
|
||||
};
|
||||
|
||||
const renderRestoreLifeChoices = (): React.ReactNode => {
|
||||
const restoreLifeChoices: Array<string> = ruleDataUtils
|
||||
.getRestoreTypes(ruleData)
|
||||
.map((type) => type.description)
|
||||
.filter(TypeScriptUtils.isNotNullOrUndefined);
|
||||
|
||||
return (
|
||||
<ExclusiveCheckbox
|
||||
theme={characterTheme}
|
||||
choices={restoreLifeChoices}
|
||||
activeChoice={activeChoice}
|
||||
onSelection={onSelection}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderActions = (): React.ReactNode => {
|
||||
if (activeChoice === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<Button onClick={onClickRestore} themed size="xx-small">
|
||||
Restore Life
|
||||
</Button>
|
||||
<Button onClick={onClickReset} themed size="xx-small" variant="outline">
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx([className])} {...props}>
|
||||
<Heading className={styles.heading}>Restore Life</Heading>
|
||||
{renderRestoreLifeChoices()}
|
||||
{renderActions()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user