From 02de1b472d82b9fed219f67344323efd4bddf499 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Fri, 16 Feb 2024 16:09:05 -0800 Subject: [PATCH] [#893] Add data model & configuration data for summoning --- lang/en.json | 46 +++++ less/v1/items.less | 80 +++++++- module/applications/item/_module.mjs | 1 + module/applications/item/item-sheet.mjs | 8 +- module/applications/item/summoning-config.mjs | 186 ++++++++++++++++++ module/config.mjs | 1 + module/data/item/templates/action.mjs | 75 +++++-- templates/apps/summoning-config.hbs | 69 +++++++ templates/items/parts/item-action.hbs | 13 ++ 9 files changed, 455 insertions(+), 24 deletions(-) create mode 100644 module/applications/item/summoning-config.mjs create mode 100644 templates/apps/summoning-config.hbs diff --git a/lang/en.json b/lang/en.json index bc67ef723b..32ac08bf57 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.", @@ -1361,6 +1362,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" + }, + "ActorChanges": { + "Label": "Actor Changes", + "Hint": "Changes that will be made to the actor being summoned. Any @ references used in the following formulas will be based on the summoner’s stats." + }, + "ArmorClass": { + "Label": "Bonus Armor Class", + "Hint": "Bonus to the Armor Class set on the summoned actor added to what is specified in their statblock." + }, + "Configuration": "Summoning Configuration", + "DisplayName": "Display Name", + "DropHint": "Drop actor here", + "HitPoints": { + "Label": "Bonus Hit Points", + "Hint": "Additional hit points added to the actor on top of what is specified in their statblock." + }, + "ItemChanges": { + "Label": "Item Changes", + "Hint": "Changes made to items on the summoned actor." + }, + "Match": { + "Attacks": { + "Label": "Match Attacks", + "Hint": "Modify to hit values on the summoned actor’s attacks to match that of the summoner." + }, + "Proficiency": { + "Label": "Match Proficiency", + "Hint": "Modify the summoned actor’s proficiency to match that of the summoner." + }, + "Saves": { + "Label": "Match Saves", + "Hint": "Modify to saving throw DCs on the summoned actor’s abilities to match that of the summoner." + } + }, + "Profile": { + "Label": "Summons Profiles", + "Empty": "Click above to add a profile or drop an actor 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..b57f47c3fe 100644 --- a/less/v1/items.less +++ b/less/v1/items.less @@ -286,7 +286,7 @@ /* Item Actions */ /* ----------------------------------------- */ - h4.damage-header { + h4.damage-header, h4.summons-header { margin: 0; padding: 0; font-weight: bold; @@ -340,6 +340,13 @@ } } + .summoning.form-group { + .config-button { + opacity: 1; + font-size: var(--font-size-12); + } + } + /* ----------------------------------------- */ /* Item Actions */ /* ----------------------------------------- */ @@ -438,3 +445,74 @@ 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); + + &:has(.drag-bar) { padding-inline-end: 18px; } + + .details { + gap: 4px; + input { height: unset; } + input::placeholder { opacity: .5; } + } + .drag-bar { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + inset-block: 0; + inset-inline-end: 0; + inline-size: 16px; + cursor: grab; + color: var(--dnd5e-color-faint); + } + [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 08725ccc0c..0d779d7ed5 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. @@ -574,6 +575,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..f54649ef07 --- /dev/null +++ b/module/applications/item/summoning-config.mjs @@ -0,0 +1,186 @@ +/** + * 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: [{ dragSelector: ".drag-bar", 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.sort - rhs.sort); + 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 ?? {}); + const highestSort = this.profiles.reduce((sort, i) => i.sort > sort ? i.sort : sort, 0); + + switch ( data.action ) { + case "add-profile": + data.profiles.push({ + _id: foundry.utils.randomID(), + sort: highestSort + CONST.SORT_INTEGER_DENSITY, + ...(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 */ + _onDragStart(event) { + const entry = event.target.closest("[data-profile-id]"); + if ( !entry ) return; + event.dataTransfer.setData("text/plain", JSON.stringify({ + type: "summoning-profile", item: this.document.uuid, profileId: entry.dataset.profileId + })); + const box = entry.getBoundingClientRect(); + event.dataTransfer.setDragImage(entry, box.width - 6, box.height / 2); + } + + /* -------------------------------------------- */ + + /** @inheritDoc */ + async _onDrop(event) { + // Try to extract the data + const data = TextEditor.getDragEventData(event); + + // Handle re-ordering of list + if ( data?.item && (data.item === this.document.uuid) ) return this._onSortEntry(event, data); + + // 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 } } }); + } + + /* -------------------------------------------- */ + + /** + * Sort a profile on drop. + * @param {DragEvent} event Triggering drop event. + * @param {object} data Drag event data. + */ + _onSortEntry(event, data) { + const dropArea = event.target.closest("[data-profile-id]"); + const dragProfile = this.profiles.find(p => p._id === data?.profileId); + const dropProfile = this.profiles.find(p => p._id === dropArea?.dataset.profileId); + + // Do nothing if dropped on itself + if ( dragProfile === dropProfile ) return; + + const siblings = this.profiles.filter(e => e !== dragProfile).sort((lhs, rhs) => lhs.sort - rhs.sort); + let sortBefore; + let target = dropProfile; + + // If dropped outside any profile, sort to top or bottom of list + if ( !target ) { + const box = this.form.getBoundingClientRect(); + sortBefore = (event.clientY - box.y) < (box.height * .25); + target = sortBefore ? siblings[0] : siblings[siblings.length - 1]; + } + + if ( !target ) return; + + const sortUpdates = SortingHelpers.performIntegerSort(dragProfile, { target, siblings, sortBefore }); + const updateData = sortUpdates.reduce((obj, { target, update }) => { + obj[`profiles.${target._id}.sort`] = update.sort; + return obj; + }, {}); + this.submit({ updateData }); + } +} diff --git a/module/config.mjs b/module/config.mjs index 283d886859..4df1aafa42 100644 --- a/module/config.mjs +++ b/module/config.mjs @@ -788,6 +788,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..8d60bafa89 100644 --- a/module/data/item/templates/action.mjs +++ b/module/data/item/templates/action.mjs @@ -1,6 +1,19 @@ 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 {number} count Number of creatures to summon. + * @property {string} label 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 +32,62 @@ 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(), + sort: new IntegerSortField(), + uuid: new StringField() + })) + }) }; } diff --git a/templates/apps/summoning-config.hbs b/templates/apps/summoning-config.hbs new file mode 100644 index 0000000000..a69a1aa624 --- /dev/null +++ b/templates/apps/summoning-config.hbs @@ -0,0 +1,69 @@ +
+

+ {{ localize "DND5E.Summoning.Profile.Label" }} +

+ + +

{{ localize "DND5E.Summoning.ActorChanges.Label" }}

+

{{ localize "DND5E.Summoning.ActorChanges.Hint" }}

+
+ + +

{{ localize "DND5E.Summoning.Match.Proficiency.Hint" }}

+
+
+ + +

{{ localize "DND5E.Summoning.ArmorClass.Hint" }}

+
+
+ + +

{{ localize "DND5E.Summoning.HitPoints.Hint" }}

+
+ +

{{ localize "DND5E.Summoning.ItemChanges.Label" }}

+

{{ localize "DND5E.Summoning.ItemChanges.Hint" }}

+
+ + +

{{ localize "DND5E.Summoning.Match.Attacks.Hint" }}

+
+
+ + +

{{ localize "DND5E.Summoning.Match.Saves.Hint" }}

+
+
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 --}}