diff --git a/lang/en.json b/lang/en.json index 72ba22c7f3..927ac3f5ef 100644 --- a/lang/en.json +++ b/lang/en.json @@ -135,6 +135,7 @@ "DND5E.ActionRSAK": "Ranged Spell Attack", "DND5E.ActionRWAK": "Ranged Weapon Attack", "DND5E.ActionSave": "Saving Throw", +"DND5E.ActionSumm": "Summon", "DND5E.ActionUtil": "Utility", "DND5E.ActionWarningNoItem": "The requested item {item} no longer exists on Actor {name}", "DND5E.ActionWarningNoToken": "You must have one or more controlled Tokens in order to use this option.", @@ -1378,6 +1379,51 @@ "DND5E.SubclassMismatchWarn": "{name} subclass has no matching class with identifier '{class}'.", "DND5E.SubclassName": "Subclass Name", "DND5E.Subtype": "Subtype", +"DND5E.Summoning": { + "Label": "Summoning", + "Action": { + "Add": "Add Profile", + "Configure": "Configure Summons", + "Remove": "Remove Profile" + }, + "ArmorClass": { + "Label": "Bonus Armor Class", + "Hint": "Bonus to the Armor Class set on the summoned creature added to what is specified in their statblock." + }, + "Configuration": "Summoning Configuration", + "CreatureChanges": { + "Label": "Creature Changes", + "Hint": "Changes that will be made to the creature being summoned. Any @ references used in the following formulas will be based on the summoner's stats." + }, + "DisplayName": "Display Name", + "DropHint": "Drop creature here", + "HitPoints": { + "Label": "Bonus Hit Points", + "Hint": "Additional hit points added to the creature on top of what is specified in their statblock." + }, + "ItemChanges": { + "Label": "Item Changes", + "Hint": "Changes made to items on the summoned creature." + }, + "Match": { + "Attacks": { + "Label": "Match Attacks", + "Hint": "Modify to hit values on the summoned creature's attacks to match that of the summoner." + }, + "Proficiency": { + "Label": "Match Proficiency", + "Hint": "Modify the summoned creature's proficiency to match that of the summoner." + }, + "Saves": { + "Label": "Match Saves", + "Hint": "Modify to saving throw DCs on the summoned creature's abilities to match that of the summoner." + } + }, + "Profile": { + "Label": "Summons Profiles", + "Empty": "Click above to add a profile or drop an creature to summon here." + } +}, "DND5E.Supply": "Supply", "DND5E.Suppressed": "Suppressed", "DND5E.Target": "Target", diff --git a/less/v1/items.less b/less/v1/items.less index d1cb22699c..45a8505a3e 100644 --- a/less/v1/items.less +++ b/less/v1/items.less @@ -340,6 +340,13 @@ } } + .summoning.form-group { + .config-button { + opacity: 1; + font-size: var(--font-size-12); + } + } + /* ----------------------------------------- */ /* Item Actions */ /* ----------------------------------------- */ @@ -438,3 +445,61 @@ padding: 0.1em 0.5em; } } + +/* ----------------------------------------- */ +/* Summoning Configuration */ +/* ----------------------------------------- */ + +.dnd5e.summoning-config { + .unbutton { + width: unset; + border: none; + background: none; + line-height: unset; + + &:hover, &:focus { box-shadow: none; } + &:hover { text-shadow: 0 0 8px var(--color-shadow-primary); } + &:focus-visible { outline: 2px solid black; } + } + + .form-header { + justify-content: space-between; + button { flex: unset; } + } + + ul.profiles { + padding: 0; + list-style: none; + gap: 12px; + } + li.profile { + position: relative; + padding: 8px; + background: var(--dnd5e-color-card); + border: 2px solid var(--dnd5e-color-gold); + border-radius: 4px; + box-shadow: 0 0 4px var(--dnd5e-shadow-45); + + .details { + gap: 4px; + input { height: unset; } + input::placeholder { opacity: .5; } + } + [data-action="delete-profile"] { + --size: 26px; + flex: 0 0 var(--size); + block-size: var(--size); + inline-size: var(--size); + } + .content-link, .drop-area { + flex: 0 0 175px; + display: flex; + align-items: center; + } + .drop-area { + border: 1px dashed black; + border-radius: 4px; + padding-inline: 4px; + } + } +} diff --git a/module/applications/item/_module.mjs b/module/applications/item/_module.mjs index a6f30ad7eb..522a3394ca 100644 --- a/module/applications/item/_module.mjs +++ b/module/applications/item/_module.mjs @@ -4,3 +4,4 @@ export {default as ItemDirectory5e} from "./item-directory.mjs"; export {default as ItemSheet5e} from "./item-sheet.mjs"; export {default as AbilityUseDialog} from "./ability-use-dialog.mjs"; +export {default as SummoningConfig} from "./summoning-config.mjs"; diff --git a/module/applications/item/item-sheet.mjs b/module/applications/item/item-sheet.mjs index 839a77753f..e6c3c29550 100644 --- a/module/applications/item/item-sheet.mjs +++ b/module/applications/item/item-sheet.mjs @@ -1,3 +1,5 @@ +import * as Trait from "../../documents/actor/trait.mjs"; +import { filteredKeys, sortObjectEntries } from "../../utils.mjs"; import ActorMovementConfig from "../actor/movement-config.mjs"; import ActorSensesConfig from "../actor/senses-config.mjs"; import ActorTypeConfig from "../actor/type-config.mjs"; @@ -6,8 +8,7 @@ import AdvancementMigrationDialog from "../advancement/advancement-migration-dia import Accordion from "../accordion.mjs"; import EffectsElement from "../components/effects.mjs"; import SourceConfig from "../source-config.mjs"; -import * as Trait from "../../documents/actor/trait.mjs"; -import { filteredKeys, sortObjectEntries } from "../../utils.mjs"; +import SummoningConfig from "./summoning-config.mjs"; /** * Override and extend the core ItemSheet implementation to handle specific item types. @@ -561,6 +562,9 @@ export default class ItemSheet5e extends ItemSheet { case "source": app = new SourceConfig(this.item, { keyPath: "system.source" }); break; + case "summoning": + app = new SummoningConfig(this.item); + break; case "type": app = new ActorTypeConfig(this.item, { keyPath: "system.type" }); break; diff --git a/module/applications/item/summoning-config.mjs b/module/applications/item/summoning-config.mjs new file mode 100644 index 0000000000..07dc1acc4d --- /dev/null +++ b/module/applications/item/summoning-config.mjs @@ -0,0 +1,134 @@ +/** + * Application for configuring summoning information for an item. + */ +export default class SummoningConfig extends DocumentSheet { + + /** @inheritDoc */ + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["dnd5e", "summoning-config"], + dragDrop: [{ dropSelector: "form" }], + template: "systems/dnd5e/templates/apps/summoning-config.hbs", + width: 500, + height: "auto", + sheetConfig: false, + closeOnSubmit: false, + submitOnChange: true, + submitOnClose: true + }); + } + + /* -------------------------------------------- */ + /* Properties */ + /* -------------------------------------------- */ + + /** + * Shortcut to the summoning profiles. + * @type {object[]} + */ + get profiles() { + return this.document.system.summons.profiles; + } + + /* -------------------------------------------- */ + + /** @inheritDoc */ + get title() { + return `${game.i18n.localize("DND5E.Summoning.Configuration")}: ${this.document.name}`; + } + + /* -------------------------------------------- */ + /* Rendering */ + /* -------------------------------------------- */ + + /** @inheritDoc */ + async getData(options={}) { + const context = await super.getData(options); + context.profiles = this.profiles.map(p => { + const profile = { id: p._id, ...p }; + if ( p.uuid ) profile.document = fromUuidSync(p.uuid); + return profile; + }).sort((lhs, rhs) => + (lhs.name || lhs.document?.name || "").localeCompare(rhs.name || rhs.document?.name || "", game.i18n.lang) + ); + context.summons = this.document.system.summons; + return context; + } + + /* -------------------------------------------- */ + /* Event Handling */ + /* -------------------------------------------- */ + + /** @inheritDoc */ + activateListeners(jQuery) { + super.activateListeners(jQuery); + const html = jQuery[0]; + + for ( const element of html.querySelectorAll("[data-action]") ) { + element.addEventListener("click", event => this.submit({ updateData: { + action: event.target.dataset.action, + profileId: event.target.closest("[data-profile-id]")?.dataset.profileId + } })); + } + } + + /* -------------------------------------------- */ + + /** @inheritDoc */ + _getSubmitData(...args) { + const data = foundry.utils.expandObject(super._getSubmitData(...args)); + data.profiles = Object.values(data.profiles ?? {}); + + switch ( data.action ) { + case "add-profile": + data.profiles.push({ + _id: foundry.utils.randomID(), + ...(data.addDetails ?? {}) + }); + break; + case "delete-profile": + data.profiles = data.profiles.filter(e => e._id !== data.profileId); + break; + } + + return data; + } + + /* -------------------------------------------- */ + + /** @inheritDoc */ + async _updateObject(event, formData) { + this.document.update({"system.summons": formData}); + } + + /* -------------------------------------------- */ + /* Drag & Drop */ + /* -------------------------------------------- */ + + /** @inheritDoc */ + _canDragDrop() { + return this.isEditable; + } + + /* -------------------------------------------- */ + + /** @inheritDoc */ + async _onDrop(event) { + // Try to extract the data + const data = TextEditor.getDragEventData(event); + + // Handle dropping linked items + if ( data?.type !== "Actor" ) return; + const actor = await Actor.implementation.fromDropData(data); + + // Determine where this was dropped + const existingProfile = event.target.closest("[data-profile-id]"); + const { profileId } = existingProfile?.dataset ?? {}; + + // If dropped onto existing profile, add or replace link + if ( profileId ) this.submit({ updateData: { [`profiles.${profileId}.uuid`]: actor.uuid } }); + + // Otherwise create a new profile + else this.submit({ updateData: { action: "add-profile", addDetails: { uuid: actor.uuid } } }); + } +} diff --git a/module/config.mjs b/module/config.mjs index 7d19b9c77c..5c9d0f3e37 100644 --- a/module/config.mjs +++ b/module/config.mjs @@ -823,6 +823,7 @@ DND5E.itemActionTypes = { msak: "DND5E.ActionMSAK", rsak: "DND5E.ActionRSAK", save: "DND5E.ActionSave", + summ: "DND5E.ActionSumm", heal: "DND5E.ActionHeal", abil: "DND5E.ActionAbil", util: "DND5E.ActionUtil", diff --git a/module/data/item/templates/action.mjs b/module/data/item/templates/action.mjs index 890ae236a2..3ba918217e 100644 --- a/module/data/item/templates/action.mjs +++ b/module/data/item/templates/action.mjs @@ -1,6 +1,20 @@ import { ItemDataModel } from "../../abstract.mjs"; import { FormulaField } from "../../fields.mjs"; +const { + ArrayField, BooleanField, DocumentIdField, IntegerSortField, NumberField, SchemaField, StringField +} = foundry.data.fields; + +/** + * Information for a single summoned creature. + * + * @typedef {object} SummonsProfile + * @property {string} _id Unique ID for this profile. + * @property {number} count Number of creatures to summon. + * @property {string} name Display name for this profile if it differs from actor's name. + * @property {string} uuid UUID of the actor to summon. + */ + /** * Data model template for item actions. * @@ -19,42 +33,61 @@ import { FormulaField } from "../../fields.mjs"; * @property {string} save.ability Ability required for the save. * @property {number} save.dc Custom saving throw value. * @property {string} save.scaling Method for automatically determining saving throw DC. + * @property {object} summons + * @property {string} summons.ac Formula used to calculate the AC on summoned actors. + * @property {string} summons.hp Formula indicating bonus hit points to add to each summoned actor. + * @property {object} summons.match + * @property {boolean} summons.match.attacks Match the to hit values on summoned actor's attack to the summoner. + * @property {boolean} summons.match.proficiency Match proficiency on summoned actor to the summoner. + * @property {boolean} summons.match.saves Match the save DC on summoned actor's abilities to the summoner. + * @property {SummonsProfile[]} summons.profiles Information on creatures that can be summoned. * @mixin */ export default class ActionTemplate extends ItemDataModel { /** @inheritdoc */ static defineSchema() { return { - ability: new foundry.data.fields.StringField({ - required: true, nullable: true, initial: null, label: "DND5E.AbilityModifier" - }), - actionType: new foundry.data.fields.StringField({ - required: true, nullable: true, initial: null, label: "DND5E.ItemActionType" - }), + ability: new StringField({required: true, nullable: true, initial: null, label: "DND5E.AbilityModifier"}), + actionType: new StringField({required: true, nullable: true, initial: null, label: "DND5E.ItemActionType"}), attackBonus: new FormulaField({required: true, label: "DND5E.ItemAttackBonus"}), - chatFlavor: new foundry.data.fields.StringField({required: true, label: "DND5E.ChatFlavor"}), - critical: new foundry.data.fields.SchemaField({ - threshold: new foundry.data.fields.NumberField({ + chatFlavor: new StringField({required: true, label: "DND5E.ChatFlavor"}), + critical: new SchemaField({ + threshold: new NumberField({ required: true, integer: true, initial: null, positive: true, label: "DND5E.ItemCritThreshold" }), damage: new FormulaField({required: true, label: "DND5E.ItemCritExtraDamage"}) }), - damage: new foundry.data.fields.SchemaField({ - parts: new foundry.data.fields.ArrayField(new foundry.data.fields.ArrayField( - new foundry.data.fields.StringField({nullable: true}) - ), {required: true}), + damage: new SchemaField({ + parts: new ArrayField(new ArrayField(new StringField({nullable: true})), {required: true}), versatile: new FormulaField({required: true, label: "DND5E.VersatileDamage"}) }, {label: "DND5E.Damage"}), formula: new FormulaField({required: true, label: "DND5E.OtherFormula"}), - save: new foundry.data.fields.SchemaField({ - ability: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.Ability"}), - dc: new foundry.data.fields.NumberField({ - required: true, min: 0, integer: true, label: "DND5E.AbbreviationDC" + save: new SchemaField({ + ability: new StringField({required: true, blank: true, label: "DND5E.Ability"}), + dc: new NumberField({required: true, min: 0, integer: true, label: "DND5E.AbbreviationDC"}), + scaling: new StringField({required: true, blank: false, initial: "spell", label: "DND5E.ScalingFormula"}) + }, {label: "DND5E.SavingThrow"}), + summons: new SchemaField({ + ac: new FormulaField({label: "DND5E.Summoning.ArmorClass.Label", hint: "DND5E.Summoning.ArmorClass.hint"}), + hp: new FormulaField({label: "DND5E.Summoning.HitPoints.Label", hint: "DND5E.Summoning.HitPoints.hint"}), + match: new SchemaField({ + attacks: new BooleanField({ + label: "DND5E.Summoning.Match.Attacks.Label", hint: "DND5E.Summoning.Match.Attacks.Hint" + }), + proficiency: new BooleanField({ + label: "DND5E.Summoning.Match.Proficiency.Label", hint: "DND5E.Summoning.Match.Proficiency.Hint" + }), + saves: new BooleanField({ + label: "DND5E.Summoning.Match.Saves.Label", hint: "DND5E.Summoning.Match.Saves.Hint" + }) }), - scaling: new foundry.data.fields.StringField({ - required: true, blank: false, initial: "spell", label: "DND5E.ScalingFormula" - }) - }, {label: "DND5E.SavingThrow"}) + profiles: new ArrayField(new SchemaField({ + _id: new DocumentIdField({initial: () => foundry.utils.randomID()}), + count: new NumberField({integer: true, min: 1}), + name: new StringField(), + uuid: new StringField() + })) + }) }; } diff --git a/templates/apps/summoning-config.hbs b/templates/apps/summoning-config.hbs new file mode 100644 index 0000000000..40a0806908 --- /dev/null +++ b/templates/apps/summoning-config.hbs @@ -0,0 +1,67 @@ +
diff --git a/templates/items/parts/item-action.hbs b/templates/items/parts/item-action.hbs index f6ba7992b1..03b8262699 100644 --- a/templates/items/parts/item-action.hbs +++ b/templates/items/parts/item-action.hbs @@ -116,6 +116,19 @@ +{{!-- Summoning --}} +{{#if (eq system.actionType "summ")}} + +{{/if}} + {{!-- Chat Message Flavor --}}