import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Tooltip } from "@dndbeyond/character-common-components/es"; import { DiceComponentUtils, DigitalDiceWrapper, NoteComponents, SpellDamageEffect, } from "@dndbeyond/character-components/es"; import { AbilityLookup, ActivationUtils, CharacterTheme, Constants, DataOriginRefData, EntityUtils, FormatUtils, RuleData, ScaledSpell, Spell, LeveledSpellManager, } from "@dndbeyond/character-rules-engine/es"; import { RollTypes } from "@dndbeyond/pocket-dimension-dice/constants"; import { RollContext, RollDicePayload, } from "@dndbeyond/pocket-dimension-dice/types"; import { ItemName } from "~/components/ItemName"; import { NumberDisplay } from "~/components/NumberDisplay"; import { SpellName } from "~/components/SpellName"; import SpellSlotChooser from "../SpellSlotChooser"; import { ThemeButton } from "../common/Button"; interface Props { className?: string; spell: ScaledSpell; castLevel: number; characterLevel: number; isSpellSlotAvailable: boolean; isPactSlotAvailable: boolean; doesSpellSlotExist: boolean; doesPactSlotExist: boolean; ruleData: RuleData; abilityLookup: AbilityLookup; dataOriginRefData: DataOriginRefData; onClick?: (spell: Spell, castLevel: number) => void; onUse?: ( useSpellSlot: boolean, usePactMagicSlot: boolean, dataOriginType: Constants.DataOriginTypeEnum, uses: number | null, mappingId: number | null, mappingTypeId: number | null ) => void; showNotes: boolean; isInteractive: boolean; diceEnabled: boolean; theme: CharacterTheme; proficiencyBonus: number; rollContext: RollContext; } function SpellsSpell({ className = "", spell: spellData, castLevel, characterLevel, isSpellSlotAvailable, isPactSlotAvailable, doesSpellSlotExist, doesPactSlotExist, ruleData, abilityLookup, dataOriginRefData, onClick, onUse, showNotes = true, isInteractive, diceEnabled, theme, proficiencyBonus, rollContext, }: Props) { const spell = useMemo( () => new LeveledSpellManager({ spell: spellData }), [spellData] ); const [isSlotChooserOpen, setIsSlotChooserOpen] = useState(false); const [isCriticalHit, setIsCriticalHit] = useState(false); const spellsSpellNode = useRef(null); const handleSpellClick = (evt: React.MouseEvent): void => { evt.nativeEvent.stopImmediatePropagation(); evt.stopPropagation(); if (onClick) { onClick(spell.spell, castLevel); } }; const handleDisabledCastClick = (): void => { if (onClick) { onClick(spell.spell, castLevel); } }; const handleSpellSlotChooserOpen = (): void => { setIsSlotChooserOpen(true); document.addEventListener("click", handleDocumentClick); }; const handleSpellSlotChooserClose = (): void => { setIsSlotChooserOpen(false); }; const handleSpellSlotChosen = (): void => { spell.handleSpellUse(true, false); setIsSlotChooserOpen(false); }; const handlePactSlotChosen = (): void => { spell.handleSpellUse(false, true); setIsSlotChooserOpen(false); }; const handleRollResults = (result: RollDicePayload["data"]): void => { let wasCrit = DiceComponentUtils.isCriticalRoll(result); setIsCriticalHit(wasCrit); }; // TODO: use mui click away listener const handleDocumentClick = useCallback((): void => { handleSpellSlotChooserClose(); document.removeEventListener("click", handleDocumentClick); }, []); useEffect( () => () => { if (isSlotChooserOpen) { document.removeEventListener("click", handleDocumentClick); } }, [isSlotChooserOpen, handleDocumentClick] ); // Todo: consider making a separate component for this const renderAttackInfo = (): React.ReactNode => { let toHit: number | null = null; let saveDcValue: number | null = null; let saveDcLabel: string | null = null; let asPartOfWeaponAttack = spell.asPartOfWeaponAttack(); let requiresAttackRoll = spell.getRequiresAttackRoll(); let requiresSavingThrow = spell.getRequiresSavingThrow(); if (requiresAttackRoll) { toHit = spell.getToHit(); } else if (requiresSavingThrow) { saveDcValue = spell.getAttackSaveValue(); saveDcLabel = spell.getSaveDcAbilityKey(); } if (!asPartOfWeaponAttack) { if (requiresAttackRoll && toHit !== null) { return (
0 ? `+${toHit}` : toHit !== 0 ? toHit : "" }`} rollType={RollTypes.ToHit} action={spell.getName()} isDiceEnabled={diceEnabled} rollCallback={handleRollResults} entityId={String(rollContext.entityId)} entityType={String(rollContext.entityType)} name={String(rollContext.name)} >
); } else if (requiresSavingThrow) { return (
{saveDcLabel} {saveDcValue}
); } } return
--
; }; let range = spell.getRange(); let limitedUse = spell.getLimitedUse(); let activation = spell.getActivation(); let isScaled = spell.getCastLevel() !== spell.getLevel(); let isAtWill = spell.isAtWill(); const spellDataOriginType = spell.getDataOriginType(); const spellDataOrigin = spell.getDataOrigin(); let limitedUseButtonText: React.ReactNode; if (limitedUse) { let maxUses = spell.getMaxUses(); let consumedAmount = spell.getConsumedUses(); if ( maxUses === 1 && spellDataOriginType !== Constants.DataOriginTypeEnum.ITEM ) { limitedUseButtonText = "Use"; } else { limitedUseButtonText = `${consumedAmount} ${ spellDataOriginType === Constants.DataOriginTypeEnum.ITEM ? "C" : "U" }`; } } let usesSpellSlot = spell.getUsesSpellSlot(); let canCastSpell: boolean = true; if (limitedUse) { canCastSpell = spell.isLimitedUseAvailableAtScaledAmount(); } else if (usesSpellSlot) { canCastSpell = isSpellSlotAvailable || isPactSlotAvailable; } let scaledInfoNode: React.ReactNode; if (isScaled) { scaledInfoNode = ( {spell.getLevel()} {FormatUtils.getOrdinalSuffix(spell.getLevel())} ); } let combinedMetaItems: Array = []; if (spell.isLegacy()) { combinedMetaItems.push("Legacy"); } switch (spellDataOriginType) { case Constants.DataOriginTypeEnum.ITEM: combinedMetaItems.push( ); break; default: combinedMetaItems.push(EntityUtils.getDataOriginName(spellDataOrigin)); } let expandedDataOriginRef = spell.getExpandedDataOriginRef(); if (expandedDataOriginRef !== null) { combinedMetaItems.push( EntityUtils.getDataOriginRefName(expandedDataOriginRef, dataOriginRefData) ); } let castAsRitual = spell.isCastAsRitual(); let actionNode: React.ReactNode; if (castAsRitual) { actionNode = As Ritual; } else if (isAtWill) { actionNode = At Will; } else { actionNode = ( <> spell.handleCastClick(handleSpellSlotChooserOpen) : handleDisabledCastClick } disabled={!canCastSpell} block={true} isInteractive={isInteractive} > {scaledInfoNode} {limitedUse ? limitedUseButtonText : "Cast"} {isSlotChooserOpen && canCastSpell && ( )} ); } let classNames: Array = ["ct-spells-spell", className]; if (isCriticalHit) { classNames.push("ct-spells-spell--crit"); } return (
{actionNode}
{combinedMetaItems.length > 0 && (
{combinedMetaItems.map((metaItem, idx) => ( {metaItem} ))}
)}
{activation !== null && (
{ActivationUtils.renderCastingTimeAbbreviation(activation)} {castAsRitual && ( +10m )}
)} {range !== null && (
{!!range.origin && range.origin !== Constants.SpellRangeTypeNameEnum.RANGED && ( {range.origin} )} {!!range.rangeValue && ( )}
)}
{renderAttackInfo()}
{ if (isCriticalHit) { setIsCriticalHit(false); } }} />
{showNotes && (
)}
); } export default SpellsSpell;