import React, { useCallback, useContext, useEffect, useMemo } 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 { Dice, DiceEvent, DiceTools, IRollContext, RollRequest, RollType, } from "@dndbeyond/dice"; import { GameLogContext } from "@dndbeyond/game-log-components"; import { ItemName } from "~/components/ItemName"; import { NumberDisplay } from "~/components/NumberDisplay"; import { SpellName } from "~/components/SpellName"; import { TypeScriptUtils } from "../../utils"; 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: IRollContext; } 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] = React.useState(false); const [isCriticalHit, setIsCriticalHit] = React.useState(false); const spellsSpellNode = React.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: RollRequest): void => { let wasCrit = DiceComponentUtils.isCriticalRoll(result); setIsCriticalHit(wasCrit); }; const diceEventHandler = useMemo(() => { if (!spellsSpellNode.current) { return () => { /* NOOP */ }; } return DiceComponentUtils.setupResetCritStateOnRoll( spell.getName(), spellsSpellNode.current ); }, [spell]); // TODO: use mui click away listener const handleDocumentClick = useCallback((): void => { handleSpellSlotChooserClose(); document.removeEventListener("click", handleDocumentClick); }, []); useEffect( () => () => { if (isSlotChooserOpen) { document.removeEventListener("click", handleDocumentClick); } Dice.removeEventListener(DiceEvent.ROLL, diceEventHandler); }, [isSlotChooserOpen, handleDocumentClick, diceEventHandler] ); // Todo: consider making a separate component for this const [{ messageTargetOptions, defaultMessageTargetOption, userId }] = useContext(GameLogContext); 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 (
); } 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()}
{showNotes && (
)}
); } export default SpellsSpell;