Skip to content

Commit

Permalink
[#3242] Support summoning more than one creature at a time
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
arbron committed Apr 9, 2024
1 parent 4d0be44 commit ebc257a
Show file tree
Hide file tree
Showing 4 changed files with 82 additions and 11 deletions.
5 changes: 4 additions & 1 deletion lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -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",
Expand Down
73 changes: 68 additions & 5 deletions module/applications/item/ability-use-dialog.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import simplifyRollFormula from "../../dice/simplify-roll-formula.mjs";

/**
* A specialized Dialog subclass for ability usage.
*
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
}
13 changes: 8 additions & 5 deletions module/data/item/fields/summons-field.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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()
})),
Expand Down Expand Up @@ -396,8 +396,11 @@ export class SummonsData extends foundry.abstract.DataModel {
* @param {SummonsProfile} profile Profile used for summoning.
* @returns {Promise<PlacementData[]>}
*/
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) });
}

/* -------------------------------------------- */
Expand Down
2 changes: 2 additions & 0 deletions templates/apps/summoning-config.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
{{#each profiles}}
<li class="profile" data-profile-id="{{ id }}">
<div class="details flexrow">
<input type="text" name="profiles.{{ id }}.count" value="{{ count }}" placeholder="1"
aria-label="{{ localize 'DND5E.Summoning.Count.Label' }}">
{{#if document}}
{{{ dnd5e-linkForUuid uuid }}}
{{else}}
Expand Down

0 comments on commit ebc257a

Please sign in to comment.