From ebc257af756cafa3e604019d31519df71e03fe31 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 9 Apr 2024 10:20:13 -0500 Subject: [PATCH] [#3242] Support summoning more than one creature at a time Adds summoning count formulas to summons profiles which allow specifying how many creatures are summoned, modifiable based on spell level or any other calculations required. The ability use dialog has been updated to display how many of each profile will be summoned and will update dynamically when the spell level is changed. --- lang/en.json | 5 +- .../applications/item/ability-use-dialog.mjs | 73 +++++++++++++++++-- module/data/item/fields/summons-field.mjs | 13 ++-- templates/apps/summoning-config.hbs | 2 + 4 files changed, 82 insertions(+), 11 deletions(-) diff --git a/lang/en.json b/lang/en.json index 69df02f36f..93a6300ffa 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1507,6 +1507,9 @@ } }, "Configuration": "Summoning Configuration", + "Count": { + "Label": "Count" + }, "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." @@ -1515,7 +1518,7 @@ "Label": "Creature Types", "Hint": "Summoned creature will be changed to this type. If more than one type is selected, then player will be able to choose from these types when summoning." }, - "DisplayName": "Display Name", + "DisplayName": "Profile Name", "DropHint": "Drop creature here", "ItemChanges": { "Label": "Item Changes", diff --git a/module/applications/item/ability-use-dialog.mjs b/module/applications/item/ability-use-dialog.mjs index bce286a539..4ea754df58 100644 --- a/module/applications/item/ability-use-dialog.mjs +++ b/module/applications/item/ability-use-dialog.mjs @@ -1,3 +1,5 @@ +import simplifyRollFormula from "../../dice/simplify-roll-formula.mjs"; + /** * A specialized Dialog subclass for ability usage. * @@ -185,11 +187,22 @@ export default class AbilityUseDialog extends Dialog { const summons = item.system.summons; if ( !summons?.profiles.length ) return null; const options = {}; - if ( summons.profiles.length > 1 ) options.profiles = summons.profiles.reduce((obj, profile) => { - const doc = profile.uuid ? fromUuidSync(profile.uuid) : null; - if ( !profile.uuid || doc ) obj[profile._id] = profile.name ? profile.name : (doc?.name ?? "—"); - return obj; - }, {}); + const rollData = item.getRollData(); + if ( summons.profiles.length > 1 ) options.profiles = Object.fromEntries( + summons.profiles + .map(profile => { + const doc = profile.uuid ? fromUuidSync(profile.uuid) : null; + if ( !doc ) return null; + let label = profile.name ? profile.name : (doc?.name ?? "—"); + let count = simplifyRollFormula(Roll.replaceFormulaData(profile.count ?? "1", rollData)); + if ( Number.isNumeric(count) ) { + count = parseInt(count); + if ( count > 1 ) label = `${count} x ${label}`; + } else if ( count ) label = `${count} x ${label}`; + return [profile._id, label]; + }) + .filter(f => f) + ); else options.profile = summons.profiles[0]._id; if ( summons.creatureTypes.size > 1 ) options.creatureTypes = summons.creatureTypes.reduce((obj, k) => { obj[k] = CONFIG.DND5E.creatureTypes[k].label; @@ -410,4 +423,54 @@ export default class AbilityUseDialog extends Dialog { const value = uses.value - consume; return value <= 0; } + + /* -------------------------------------------- */ + /* Event Handlers */ + /* -------------------------------------------- */ + + /** @inheritDoc */ + activateListeners(jQuery) { + super.activateListeners(jQuery); + const [html] = jQuery; + + html.querySelector('[name="slotLevel"]')?.addEventListener("change", this._onChangeSlotLevel.bind(this)); + } + + /* -------------------------------------------- */ + + /** + * Update summoning profiles when spell slot level is changed. + * @param {Event} event Triggering change event. + */ + _onChangeSlotLevel(event) { + const level = parseInt(event.target.value.replace("spell", "")); + const item = this.item.clone({ "system.level": level }); + const summoningData = this.constructor._createSummoningOptions(item); + const originalInput = this.element[0].querySelector('[name="summonsProfile"]'); + if ( !originalInput ) return; + + // If only one profile, replace with hidden input + if ( !summoningData.profiles ) { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = "summonsProfile"; + input.value = summoningData.profile; + originalInput.replaceWith(input); + } + + // Otherwise replace with select element + else { + const select = document.createElement("select"); + select.name = "summonsProfile"; + select.ariaLabel = game.i18n.localize("DND5E.Summoning.Profile.Label"); + for ( const [id, label] of Object.entries(summoningData.profiles) ) { + const option = document.createElement("option"); + option.value = id; + option.innerText = label; + if ( id === originalInput.value ) option.selected = true; + select.append(option); + } + originalInput.replaceWith(select); + } + } } diff --git a/module/data/item/fields/summons-field.mjs b/module/data/item/fields/summons-field.mjs index 68857b78fd..1039c0fe87 100644 --- a/module/data/item/fields/summons-field.mjs +++ b/module/data/item/fields/summons-field.mjs @@ -2,7 +2,7 @@ import TokenPlacement from "../../../canvas/token-placement.mjs"; import { FormulaField } from "../../fields.mjs"; const { - ArrayField, BooleanField, DocumentIdField, NumberField, SchemaField, SetField, StringField + ArrayField, BooleanField, DocumentIdField, SchemaField, SetField, StringField } = foundry.data.fields; /** @@ -21,7 +21,7 @@ export default class SummonsField extends foundry.data.fields.EmbeddedDataField * * @typedef {object} SummonsProfile * @property {string} _id Unique ID for this profile. - * @property {number} count Number of creatures to summon. + * @property {string} count Formula for the 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. */ @@ -80,7 +80,7 @@ export class SummonsData extends foundry.abstract.DataModel { }), profiles: new ArrayField(new SchemaField({ _id: new DocumentIdField({initial: () => foundry.utils.randomID()}), - count: new NumberField({integer: true, min: 1}), + count: new FormulaField(), name: new StringField(), uuid: new StringField() })), @@ -396,8 +396,11 @@ export class SummonsData extends foundry.abstract.DataModel { * @param {SummonsProfile} profile Profile used for summoning. * @returns {Promise} */ - getPlacement(token, profile) { - return TokenPlacement.place({ tokens: [token] }); + async getPlacement(token, profile) { + const rollData = this.item.getRollData(); + const count = new Roll(profile.count ?? "1", rollData); + await count.evaluate(); + return TokenPlacement.place({ tokens: Array.fromRange(Math.max(count.total, 0)).map(() => token) }); } /* -------------------------------------------- */ diff --git a/templates/apps/summoning-config.hbs b/templates/apps/summoning-config.hbs index a29e48695d..0d91a27f5b 100644 --- a/templates/apps/summoning-config.hbs +++ b/templates/apps/summoning-config.hbs @@ -10,6 +10,8 @@ {{#each profiles}}
  • + {{#if document}} {{{ dnd5e-linkForUuid uuid }}} {{else}}