From 38fb2b91f50c90bed6174d48c1c8b54b64252819 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Wed, 27 Mar 2024 14:09:24 -0700 Subject: [PATCH] [#3242] Add ability to change creature type when summoning Introduces a new `creatureTypes` set in summoning configuration that defines creature types that the summoned creature will be turned into upon summoning. If more than one type is listed, then the player will have the chance to choose that in the usage dialog. Makes use of Foundry's new `` element for input. --- lang/en.json | 4 + less/v1/items.less | 6 ++ .../applications/item/ability-use-dialog.mjs | 19 +++-- module/applications/item/summoning-config.mjs | 5 ++ module/data/item/fields/summons-field.mjs | 77 +++++++++++++------ module/documents/item.mjs | 22 ++++-- templates/apps/ability-use.hbs | 17 +++- templates/apps/summoning-config.hbs | 9 +++ 8 files changed, 116 insertions(+), 43 deletions(-) diff --git a/lang/en.json b/lang/en.json index 86002522d6..e3e7d080f2 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1480,6 +1480,10 @@ "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." }, + "CreatureTypes": { + "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", "DropHint": "Drop creature here", "ItemChanges": { diff --git a/less/v1/items.less b/less/v1/items.less index 277a6477a1..b0ea1850d1 100644 --- a/less/v1/items.less +++ b/less/v1/items.less @@ -545,6 +545,8 @@ /* ----------------------------------------- */ .dnd5e.summoning-config { + max-block-size: 90vh; + .unbutton { width: unset; border: none; @@ -596,4 +598,8 @@ padding-inline: 4px; } } + + multi-select .tags .tag { + cursor: pointer; + } } diff --git a/module/applications/item/ability-use-dialog.mjs b/module/applications/item/ability-use-dialog.mjs index 000e6daf53..d6627c5844 100644 --- a/module/applications/item/ability-use-dialog.mjs +++ b/module/applications/item/ability-use-dialog.mjs @@ -177,17 +177,22 @@ export default class AbilityUseDialog extends Dialog { /** * Create an array of summoning profiles. * @param {Item5e} item The item. - * @returns {object|null} Array of select options. + * @returns {{ profiles: object, creatureTypes: object }|null} Array of select options. */ static _createSummoningOptions(item) { - const profiles = item.system.summons?.profiles ?? []; - if ( profiles.length <= 1 ) return null; + const summons = item.system.summons; + if ( !summons?.profiles.length ) return null; const options = {}; - for ( const profile of profiles ) { + if ( summons.profiles.length > 1 ) options.profiles = summons.profiles.reduce((obj, profile) => { const doc = profile.uuid ? fromUuidSync(profile.uuid) : null; - if ( profile.uuid && !doc ) continue; - options[profile._id] = profile.name ? profile.name : (doc?.name ?? "—"); - } + if ( !profile.uuid || doc ) obj[profile._id] = profile.name ? profile.name : (doc?.name ?? "—"); + return obj; + }, {}); + 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; + return obj; + }, {}); return options; } diff --git a/module/applications/item/summoning-config.mjs b/module/applications/item/summoning-config.mjs index 8c554c30e5..1e15b8837a 100644 --- a/module/applications/item/summoning-config.mjs +++ b/module/applications/item/summoning-config.mjs @@ -44,6 +44,7 @@ export default class SummoningConfig extends DocumentSheet { /** @inheritDoc */ async getData(options={}) { const context = await super.getData(options); + context.CONFIG = CONFIG.DND5E; context.profiles = this.profiles.map(p => { const profile = { id: p._id, ...p }; if ( p.uuid ) profile.document = fromUuidSync(p.uuid); @@ -52,6 +53,10 @@ export default class SummoningConfig extends DocumentSheet { (lhs.name || lhs.document?.name || "").localeCompare(rhs.name || rhs.document?.name || "", game.i18n.lang) ); context.summons = this.document.system.summons; + context.creatureTypes = Object.entries(CONFIG.DND5E.creatureTypes).reduce((obj, [k, c]) => { + obj[k] = { label: c.label, selected: context.summons?.creatureTypes.has(k) ? "selected" : "" }; + return obj; + }, {}); return context; } diff --git a/module/data/item/fields/summons-field.mjs b/module/data/item/fields/summons-field.mjs index 8eee7c94e8..a1d0ad2fe0 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, StringField + ArrayField, BooleanField, DocumentIdField, NumberField, SchemaField, SetField, StringField } = foundry.data.fields; /** @@ -35,6 +35,7 @@ export default class SummonsField extends foundry.data.fields.EmbeddedDataField * @property {string} bonuses.attackDamage Formula for bonus added to damage for attacks. * @property {string} bonuses.saveDamage Formula for bonus added to damage for saving throws. * @property {string} bonuses.healing Formula for bonus added to healing. + * @property {Set} creatureTypes Set of creature types that will be set on summoned creature. * @property {object} match * @property {boolean} match.attacks Match the to hit values on summoned actor's attack to the summoner. * @property {boolean} match.proficiency Match proficiency on summoned actor to the summoner. @@ -63,6 +64,9 @@ export class SummonsData extends foundry.abstract.DataModel { label: "DND5E.Summoning.Bonuses.Healing.Label", hint: "DND5E.Summoning.Bonuses.Healing.Hint" }) }), + creatureTypes: new SetField(new StringField(), { + label: "DND5E.Summoning.CreatureTypes.Label", hint: "DND5E.Summoning.CreatureTypes.Hint" + }), match: new SchemaField({ attacks: new BooleanField({ label: "DND5E.Summoning.Match.Attacks.Label", hint: "DND5E.Summoning.Match.Attacks.Hint" @@ -116,11 +120,19 @@ export class SummonsData extends foundry.abstract.DataModel { /* Summoning */ /* -------------------------------------------- */ + /** + * Additional options that might modify summoning behavior. + * + * @typedef {object} SummoningOptions + * @property {string} creatureType Selected creature type if multiple are available. + */ + /** * Process for summoning actor to the scene. - * @param {string} profileId ID of the summoning profile to use. + * @param {string} profileId ID of the summoning profile to use. + * @param {object} [options={}] Additional summoning options. */ - async summon(profileId) { + async summon(profileId, options={}) { if ( !this.canSummon || !canvas.scene ) return; const profile = this.profiles.find(p => p._id === profileId); @@ -132,11 +144,12 @@ export class SummonsData extends foundry.abstract.DataModel { * A hook event that fires before summoning is performed. * @function dnd5e.preSummon * @memberof hookEvents - * @param {Item5e} item The item that is performing the summoning. - * @param {SummonsProfile} profile Profile used for summoning. - * @returns {boolean} Explicitly return `false` to prevent summoning. + * @param {Item5e} item The item that is performing the summoning. + * @param {SummonsProfile} profile Profile used for summoning. + * @param {SummoningOptions} options Additional summoning options. + * @returns {boolean} Explicitly return `false` to prevent summoning. */ - if ( Hooks.call("dnd5e.preSummon", this.item, profile) === false ) return; + if ( Hooks.call("dnd5e.preSummon", this.item, profile, options) === false ) return; // Fetch the actor that will be summoned const actor = await this.fetchActor(profile.uuid); @@ -159,7 +172,7 @@ export class SummonsData extends foundry.abstract.DataModel { actor, placement, tokenUpdates: {}, - actorUpdates: await this.getChanges(actor, profile) + actorUpdates: await this.getChanges(actor, profile, options) }; /** @@ -167,12 +180,13 @@ export class SummonsData extends foundry.abstract.DataModel { * the final token data is constructed. * @function dnd5e.preSummonToken * @memberof hookEvents - * @param {Item5e} item The item that is performing the summoning. - * @param {SummonsProfile} profile Profile used for summoning. - * @param {TokenUpdateData} config Configuration for creating a modified token. - * @returns {boolean} Explicitly return `false` to prevent this token from being summoned. + * @param {Item5e} item The item that is performing the summoning. + * @param {SummonsProfile} profile Profile used for summoning. + * @param {TokenUpdateData} config Configuration for creating a modified token. + * @param {SummoningOptions} options Additional summoning options. + * @returns {boolean} Explicitly return `false` to prevent this token from being summoned. */ - if ( Hooks.call("dnd5e.preSummonToken", this.item, profile, tokenUpdateData) === false ) continue; + if ( Hooks.call("dnd5e.preSummonToken", this.item, profile, tokenUpdateData, options) === false ) continue; // Create a token document and apply updates const tokenData = await this.getTokenData(tokenUpdateData); @@ -181,11 +195,12 @@ export class SummonsData extends foundry.abstract.DataModel { * A hook event that fires after token creation data is prepared, but before summoning occurs. * @function dnd5e.summonToken * @memberof hookEvents - * @param {Item5e} item The item that is performing the summoning. - * @param {SummonsProfile} profile Profile used for summoning. - * @param {object} tokenData Data for creating a token. + * @param {Item5e} item The item that is performing the summoning. + * @param {SummonsProfile} profile Profile used for summoning. + * @param {object} tokenData Data for creating a token. + * @param {SummoningOptions} options Additional summoning options. */ - Hooks.callAll("dnd5e.summonToken", this.item, profile, tokenData); + Hooks.callAll("dnd5e.summonToken", this.item, profile, tokenData, options); tokensData.push(tokenData); } @@ -199,11 +214,12 @@ export class SummonsData extends foundry.abstract.DataModel { * A hook event that fires when summoning is complete. * @function dnd5e.postSummon * @memberof hookEvents - * @param {Item5e} item The item that is performing the summoning. - * @param {SummonsProfile} profile Profile used for summoning. - * @param {Token5e[]} tokens Tokens that have been created. + * @param {Item5e} item The item that is performing the summoning. + * @param {SummonsProfile} profile Profile used for summoning. + * @param {Token5e[]} tokens Tokens that have been created. + * @param {SummoningOptions} options Additional summoning options. */ - Hooks.callAll("dnd5e.postSummon", this.item, profile, createdTokens); + Hooks.callAll("dnd5e.postSummon", this.item, profile, createdTokens, options); } /* -------------------------------------------- */ @@ -237,11 +253,12 @@ export class SummonsData extends foundry.abstract.DataModel { /** * Prepare the updates to apply to the summoned actor. - * @param {Actor5e} actor Actor that will be modified. - * @param {SummonsProfile} profile Summoning profile used to summon the actor. - * @returns {object} Changes that will be applied to the actor & its items. + * @param {Actor5e} actor Actor that will be modified. + * @param {SummonsProfile} profile Summoning profile used to summon the actor. + * @param {SummoningOptions} options Additional summoning options. + * @returns {object} Changes that will be applied to the actor & its items. */ - async getChanges(actor, profile) { + async getChanges(actor, profile, options) { const updates = { effects: [], items: [] }; const rollData = this.item.getRollData(); const prof = rollData.attributes?.prof ?? 0; @@ -306,6 +323,16 @@ export class SummonsData extends foundry.abstract.DataModel { } } + // Change creature type + if ( this.creatureTypes.size ) { + const type = this.creatureTypes.has(options.creatureType) ? options.creatureType : this.creatureTypes.first(); + if ( actor.system.details?.race instanceof Item ) { + updates.items.push({ _id: actor.system.details.race.id, "system.type.value": type }); + } else { + updates["system.details.type.value"] = type; + } + } + const attackDamageBonus = Roll.replaceFormulaData(this.bonuses.attackDamage, rollData); const saveDamageBonus = Roll.replaceFormulaData(this.bonuses.saveDamage, rollData); const healingBonus = Roll.replaceFormulaData(this.bonuses.healing, rollData); diff --git a/module/documents/item.mjs b/module/documents/item.mjs index e5d49b8fe6..7817e9b34f 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -1057,7 +1057,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { let summoned; if ( config.createSummons ) { try { - summoned = await item.system.summons.summon(config.summonsProfile); + summoned = await item.system.summons.summon(config.summonsProfile, config.summonsOptions); } catch(err) { Hooks.onError("Item5e#use", err, { log: "error", notify: "error" }); } @@ -2137,15 +2137,19 @@ export default class Item5e extends SystemDocumentMixin(Item) { */ static async _onChatCardSummon(message, item) { let summonsProfile; + let summonsOptions = {}; + let needsConfiguration = false; // No profile specified and only one profile on item, use that one - if ( item.system.summons.profiles.length === 1 ) { - summonsProfile = item.system.summons.profiles[0]._id; - } + if ( item.system.summons.profiles.length === 1 ) summonsProfile = item.system.summons.profiles[0]._id; + else needsConfiguration = true; - // Otherwise show the item use dialog to get the profile - else { - const config = await AbilityUseDialog.create(item, { + // More than one creature type requires configuration + if ( item.system.summons.creatureTypes.size > 1 ) needsConfiguration = true; + + // Show the item use dialog to get the profile and other options + if ( needsConfiguration ) { + let config = await AbilityUseDialog.create(item, { beginConcentrating: null, consumeResource: null, consumeSpellSlot: null, @@ -2160,11 +2164,13 @@ export default class Item5e extends SystemDocumentMixin(Item) { disableScaling: true }); if ( !config?.summonsProfile ) return; + config = foundry.utils.expandObject(config); summonsProfile = config.summonsProfile; + summonsOptions = config.summonsOptions; } try { - await item.system.summons.summon(summonsProfile); + await item.system.summons.summon(summonsProfile, summonsOptions); } catch(err) { Hooks.onError("Item5e#_onChatCardSummon", err, { log: "error", notify: "error" }); } diff --git a/templates/apps/ability-use.hbs b/templates/apps/ability-use.hbs index 05fa63ef8c..b98398a34c 100644 --- a/templates/apps/ability-use.hbs +++ b/templates/apps/ability-use.hbs @@ -91,15 +91,26 @@ {{ localize "DND5E.Summoning.Action.Place" }} - {{#if summoningOptions}} + {{#if summoningOptions.profiles}}
{{else}} - + {{/if}} + + {{#if summoningOptions.creatureTypes}} +
+ +
+ +
+
+ {{/if}} {{/if}} diff --git a/templates/apps/summoning-config.hbs b/templates/apps/summoning-config.hbs index fc7037d353..a29e48695d 100644 --- a/templates/apps/summoning-config.hbs +++ b/templates/apps/summoning-config.hbs @@ -51,6 +51,15 @@

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

+
+ + + {{#each creatureTypes}} + + {{/each}} + +

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

+

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

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