Skip to content

Commit

Permalink
[#3242] Add ability to change creature type when summoning
Browse files Browse the repository at this point in the history
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 `<multi-select>` element for input.
  • Loading branch information
arbron committed Mar 27, 2024
1 parent 22ad483 commit 38fb2b9
Show file tree
Hide file tree
Showing 8 changed files with 116 additions and 43 deletions.
4 changes: 4 additions & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 6 additions & 0 deletions less/v1/items.less
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,8 @@
/* ----------------------------------------- */

.dnd5e.summoning-config {
max-block-size: 90vh;

.unbutton {
width: unset;
border: none;
Expand Down Expand Up @@ -596,4 +598,8 @@
padding-inline: 4px;
}
}

multi-select .tags .tag {
cursor: pointer;
}
}
19 changes: 12 additions & 7 deletions module/applications/item/ability-use-dialog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
5 changes: 5 additions & 0 deletions module/applications/item/summoning-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}

Expand Down
77 changes: 52 additions & 25 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, StringField
ArrayField, BooleanField, DocumentIdField, NumberField, SchemaField, SetField, StringField
} = foundry.data.fields;

/**
Expand Down Expand Up @@ -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<string>} 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.
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -159,20 +172,21 @@ export class SummonsData extends foundry.abstract.DataModel {
actor,
placement,
tokenUpdates: {},
actorUpdates: await this.getChanges(actor, profile)
actorUpdates: await this.getChanges(actor, profile, options)
};

/**
* A hook event that fires before a specific token is summoned. After placement has been determined but before
* 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);
Expand All @@ -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);
}
Expand All @@ -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);
}

/* -------------------------------------------- */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 14 additions & 8 deletions module/documents/item.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}
Expand Down Expand Up @@ -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,
Expand All @@ -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" });
}
Expand Down
17 changes: 14 additions & 3 deletions templates/apps/ability-use.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,26 @@
<input type="checkbox" name="createSummons" {{ checked createSummons }}>
{{ localize "DND5E.Summoning.Action.Place" }}
</label>
{{#if summoningOptions}}
{{#if summoningOptions.profiles}}
<div class="form-fields">
<select name="summonsProfile" aria-label="{{ localize 'DND5E.Summoning.Profile.Label' }}">
{{ selectOptions summoningOptions selected=summonsProfile }}
{{ selectOptions summoningOptions.profiles selected=summonsProfile }}
</select>
</div>
{{else}}
<input type="hidden" name="summonsProfile" value="{{ summonsProfile }}">
<input type="hidden" name="summonsProfile" value="{{ summoningOptions.profile }}">
{{/if}}
</div>

{{#if summoningOptions.creatureTypes}}
<div class="form-group">
<label>{{ localize "DND5E.CreatureType" }}</label>
<div class="form-fields">
<select name="summonsOptions.creatureType">
{{ selectOptions summoningOptions.creatureTypes }}
</select>
</div>
</div>
{{/if}}
{{/if}}
</form>
9 changes: 9 additions & 0 deletions templates/apps/summoning-config.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@
<input type="text" name="bonuses.hp" value="{{ summons.bonuses.hp }}">
<p class="hint">{{ localize "DND5E.Summoning.Bonuses.HitPoints.Hint" }}</p>
</div>
<div class="form-group">
<label>{{ localize "DND5E.Summoning.CreatureTypes.Label" }}</label>
<multi-select name="creatureTypes">
{{#each creatureTypes}}
<option value="{{ @key }}" {{ selected }}>{{ label }}</option>
{{/each}}
</multi-select>
<p class="hint">{{ localize "DND5E.Summoning.CreatureTypes.Hint" }}</p>
</div>

<h3 class="form-header">{{ localize "DND5E.Summoning.ItemChanges.Label" }}</h3>
<p class="hint">{{ localize "DND5E.Summoning.ItemChanges.Hint" }}</p>
Expand Down

0 comments on commit 38fb2b9

Please sign in to comment.