Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#893] Add summoning to item activation workflow #3167

Merged
merged 2 commits into from
Mar 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1384,7 +1384,9 @@
"Action": {
"Add": "Add Profile",
"Configure": "Configure Summons",
"Remove": "Remove Profile"
"Place": "Place Summons",
"Remove": "Remove Profile",
"Summon": "Summon"
},
"ArmorClass": {
"Label": "Bonus Armor Class",
Expand Down Expand Up @@ -1420,8 +1422,13 @@
}
},
"Profile": {
"Label": "Summons Profiles",
"Label": "Summons Profile",
"LabelPl": "Summons Profiles",
"Empty": "Click above to add a profile or drop an creature to summon here."
},
"Prompt": {
"Label": "Summon Prompt",
"Hint": "Disable the summoning prompt when item is used. Players will still be able to summon from the chat card."
}
},
"DND5E.Supply": "Supply",
Expand Down
48 changes: 38 additions & 10 deletions module/applications/item/ability-use-dialog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,24 +29,33 @@ export default class AbilityUseDialog extends Dialog {
/* Rendering */
/* -------------------------------------------- */

/**
* Configuration options for displaying the ability use dialog.
*
* @typedef {object} AbilityUseDialogOptions
* @property {object} [button]
* @property {string} [button.icon] Icon used for the activation button.
* @property {string} [button.label] Label used for the activation button.
*/

/**
* A constructor function which displays the Spell Cast Dialog app for a given Actor and Item.
* Returns a Promise which resolves to the dialog FormData once the workflow has been completed.
* @param {Item5e} item Item being used.
* @param {ItemUseConfiguration} config The ability use configuration's values.
* @returns {Promise} Promise that is resolved when the use dialog is acted upon.
* @param {Item5e} item Item being used.
* @param {ItemUseConfiguration} config The ability use configuration's values.
* @param {AbilityUseDialogOptions} [options={}] Additional options for displaying the dialog.
* @returns {Promise} Promise that is resolved when the use dialog is acted upon.
*/
static async create(item, config) {
static async create(item, config, options={}) {
if ( !item.isOwned ) throw new Error("You cannot display an ability usage dialog for an unowned item");
config ??= item._getUsageConfig();
const slotOptions = config.consumeSpellSlot ? this._createSpellSlotOptions(item.actor, item.system.level) : [];
const resourceOptions = this._createResourceOptions(item);

const data = {
item,
...config,
slotOptions,
resourceOptions,
slotOptions: config.consumeSpellSlot ? this._createSpellSlotOptions(item.actor, item.system.level) : [],
summoningOptions: this._createSummoningOptions(item),
resourceOptions: this._createResourceOptions(item),
scaling: item.usageScaling,
note: this._getAbilityUseNote(item, config),
title: game.i18n.format("DND5E.AbilityUseHint", {
Expand All @@ -68,8 +77,8 @@ export default class AbilityUseDialog extends Dialog {
content: html,
buttons: {
use: {
icon: `<i class="fas ${isSpell ? "fa-magic" : "fa-fist-raised"}"></i>`,
label: label,
icon: options.button?.icon ?? `<i class="fas ${isSpell ? "fa-magic" : "fa-fist-raised"}"></i>`,
label: options.button?.label ?? label,
callback: html => {
const fd = new FormDataExtended(html[0].querySelector("form"));
resolve(fd.object);
Expand Down Expand Up @@ -136,6 +145,25 @@ export default class AbilityUseDialog extends Dialog {

/* -------------------------------------------- */

/**
* Create an array of summoning profiles.
* @param {Item5e} item The item.
* @returns {object|null} Array of select options.
*/
static _createSummoningOptions(item) {
const profiles = item.system.summons.profiles;
if ( profiles.length <= 1 ) return null;
const options = {};
for ( const profile of profiles ) {
const doc = profile.uuid ? fromUuidSync(profile.uuid) : null;
if ( profile.uuid && !doc ) continue;
options[profile._id] = profile.name ?? doc?.name ?? "—";
}
return options;
}

/* -------------------------------------------- */

/**
* Configure resource consumption options for a select.
* @param {Item5e} item The item.
Expand Down
18 changes: 16 additions & 2 deletions module/data/item/templates/action.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ItemDataModel } from "../../abstract.mjs";
import { FormulaField } from "../../fields.mjs";

const {
ArrayField, BooleanField, DocumentIdField, IntegerSortField, NumberField, SchemaField, StringField
ArrayField, BooleanField, DocumentIdField, NumberField, SchemaField, StringField
} = foundry.data.fields;

/**
Expand Down Expand Up @@ -41,6 +41,7 @@ const {
* @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.
* @property {boolean} summons.prompt Should the player be prompted to place the summons?
* @mixin
*/
export default class ActionTemplate extends ItemDataModel {
Expand Down Expand Up @@ -86,7 +87,10 @@ export default class ActionTemplate extends ItemDataModel {
count: new NumberField({integer: true, min: 1}),
name: new StringField(),
uuid: new StringField()
}))
})),
prompt: new BooleanField({
initial: true, label: "DND5E.Summoning.Prompt.Label", hint: "DND5E.Summoning.Prompt.Hint"
})
})
};
}
Expand Down Expand Up @@ -271,6 +275,16 @@ export default class ActionTemplate extends ItemDataModel {

/* -------------------------------------------- */

/**
* Does this Item implement summoning as part of its usage?
* @type {boolean}
*/
get hasSummoning() {
return (this.actionType === "summ") && !!this.summons.profiles.length;
}

/* -------------------------------------------- */

/**
* Does the Item provide an amount of healing instead of conventional damage?
* @type {boolean}
Expand Down
1 change: 1 addition & 0 deletions module/data/item/templates/activated-effect.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { FormulaField } from "../../fields.mjs";
* @property {number} target.width Width of line when line type is selected.
* @property {string} target.units Units used for value and width as defined in `DND5E.distanceUnits`.
* @property {string} target.type Targeting mode as defined in `DND5E.targetTypes`.
* @property {boolean} target.prompt Should the player be prompted to place the template?
* @property {object} range Effect's range.
* @property {number} range.value Regular targeting distance for item's effect.
* @property {number} range.long Maximum targeting distance for features that have a separate long range.
Expand Down
92 changes: 74 additions & 18 deletions module/documents/item.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -879,10 +879,12 @@ export default class Item5e extends SystemDocumentMixin(Item) {
*
* @typedef {object} ItemUseConfiguration
* @property {boolean} createMeasuredTemplate Should this item create a template?
* @property {boolean} createSummons Should this item create a summoned creature?
* @property {boolean} consumeResource Should this item consume a (non-ammo) resource?
* @property {boolean} consumeSpellSlot Should this item (a spell) consume a spell slot?
* @property {boolean} consumeUsage Should this item consume its limited uses or recharge?
* @property {string|number|null} slotLevel The spell slot type or level to consume by default.
* @property {string|null} summonsProfile ID of the summoning profile to use.
* @property {number|null} resourceAmount The amount to consume by default when scaling with consumption.
*/

Expand Down Expand Up @@ -1025,6 +1027,12 @@ export default class Item5e extends SystemDocumentMixin(Item) {
}
}

// Initiate summons creation
let summoned;
if ( config.createSummons ) {
console.log("TODO: Create Summons", config.summonsProfile);
}

/**
* A hook event that fires when an item is used, after the measured template has been created if one is needed.
* @function dnd5e.useItem
Expand All @@ -1033,8 +1041,9 @@ export default class Item5e extends SystemDocumentMixin(Item) {
* @param {ItemUseConfiguration} config Configuration data for the roll.
* @param {ItemUseOptions} options Additional options for configuring item usage.
* @param {MeasuredTemplateDocument[]|null} templates The measured templates if they were created.
* @param {TokenDocument5e[]|null} summoned Summoned tokens if they were created.
*/
Hooks.callAll("dnd5e.useItem", item, config, options, templates ?? null);
Hooks.callAll("dnd5e.useItem", item, config, options, templates ?? null, summoned ?? null);

return cardData;
}
Expand All @@ -1044,15 +1053,17 @@ export default class Item5e extends SystemDocumentMixin(Item) {
* @returns {ItemUseConfiguration} Configuration data for the roll.
*/
_getUsageConfig() {
const { consume, uses, target, level, preparation } = this.system;
const { consume, uses, summons, target, level, preparation } = this.system;

const config = {
consumeSpellSlot: null,
slotLevel: null,
consumeUsage: null,
consumeResource: null,
resourceAmount: null,
createMeasuredTemplate: null
createMeasuredTemplate: null,
createSummons: null,
summonsProfile: null
};

const scaling = this.usageScaling;
Expand All @@ -1069,6 +1080,10 @@ export default class Item5e extends SystemDocumentMixin(Item) {
if ( consume.target === this.id ) config.consumeUsage = null;
}
if ( game.user.can("TEMPLATE_CREATE") && this.hasAreaTarget ) config.createMeasuredTemplate = target.prompt;
if ( this.system.hasSummoning ) {
config.createSummons = summons.prompt;
config.summonsProfile = this.system.summons.profiles[0]._id;
}

return config;
}
Expand Down Expand Up @@ -1293,7 +1308,7 @@ export default class Item5e extends SystemDocumentMixin(Item) {
// Render the chat card template
const token = this.actor.token;
const hasButtons = this.hasAttack || this.hasDamage || this.isVersatile || this.hasSave || this.system.formula
|| this.hasAreaTarget || (this.type === "tool") || this.hasAbilityCheck;
|| this.hasAreaTarget || (this.type === "tool") || this.hasAbilityCheck || this.system.hasSummoning;
const templateData = {
hasButtons,
actor: this.actor,
Expand Down Expand Up @@ -1909,6 +1924,13 @@ export default class Item5e extends SystemDocumentMixin(Item) {
// Handle different actions
let targets;
switch ( action ) {
case "abilityCheck":
targets = this._getChatCardTargets(card);
for ( let token of targets ) {
const speaker = ChatMessage.getSpeaker({scene: canvas.scene, token: token.document});
await token.actor.rollAbilityTest(button.dataset.ability, { event, speaker });
}
break;
case "applyEffect":
const effect = await fromUuid(button.closest("[data-uuid]")?.dataset.uuid);
let warn = false;
Expand All @@ -1932,19 +1954,8 @@ export default class Item5e extends SystemDocumentMixin(Item) {
});
break;
case "formula":
await item.rollFormula({event, spellLevel}); break;
case "save":
targets = this._getChatCardTargets(card);
for ( let token of targets ) {
const dc = parseInt(button.dataset.dc);
const speaker = ChatMessage.getSpeaker({scene: canvas.scene, token: token.document});
await token.actor.rollAbilitySave(button.dataset.ability, {
event, speaker, targetValue: Number.isFinite(dc) ? dc : undefined
});
}
await item.rollFormula({event, spellLevel});
break;
case "toolCheck":
await item.rollToolCheck({event}); break;
case "placeTemplate":
try {
await dnd5e.canvas.AbilityTemplate.fromItem(item, {"flags.dnd5e.spellLevel": spellLevel})?.drawPreview();
Expand All @@ -1956,13 +1967,22 @@ export default class Item5e extends SystemDocumentMixin(Item) {
});
}
break;
case "abilityCheck":
case "save":
targets = this._getChatCardTargets(card);
for ( let token of targets ) {
const dc = parseInt(button.dataset.dc);
const speaker = ChatMessage.getSpeaker({scene: canvas.scene, token: token.document});
await token.actor.rollAbilityTest(button.dataset.ability, { event, speaker });
await token.actor.rollAbilitySave(button.dataset.ability, {
event, speaker, targetValue: Number.isFinite(dc) ? dc : undefined
});
}
break;
case "summon":
await this._onChatCardSummon(message, item);
break;
case "toolCheck":
await item.rollToolCheck({event});
break;
}

} catch(err) {
Expand Down Expand Up @@ -2005,6 +2025,42 @@ export default class Item5e extends SystemDocumentMixin(Item) {

/* -------------------------------------------- */

/**
* Handle summoning from a chat card.
* @param {ChatMessage5e} message The message that was clicked.
* @param {Item5e} item The item from which to summon.
*/
static async _onChatCardSummon(message, item) {
let summonsProfile;

// 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;
}

// Otherwise show the item use dialog to get the profile
else {
const config = await AbilityUseDialog.create(item, {
consumeResource: null,
consumeSpellSlot: null,
consumeUsage: null,
createMeasuredTemplate: null,
createSummons: true
}, {
button: {
icon: '<i class="fa-solid fa-spaghetti-monster-flying"></i>',
label: game.i18n.localize("DND5E.Summoning.Action.Summon")
}
});
if ( !config?.summonsProfile ) return;
summonsProfile = config.summonsProfile;
}

console.log("TODO: Create Summons", summonsProfile);
}

/* -------------------------------------------- */

/**
* Handle toggling the visibility of chat card content when the name is clicked
* @param {Event} event The originating click event
Expand Down
30 changes: 26 additions & 4 deletions templates/apps/ability-use.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -32,32 +32,54 @@

<div class="form-group">
<label class="checkbox">
<input type="checkbox" name="consumeSpellSlot" {{checked consumeSpellSlot}}>{{localize "DND5E.SpellCastConsume"}}
<input type="checkbox" name="consumeSpellSlot" {{ checked consumeSpellSlot }}>
{{localize "DND5E.SpellCastConsume"}}
</label>
</div>
{{/if}}

{{#if (ne consumeUsage null)}}
<div class="form-group">
<label class="checkbox">
<input type="checkbox" name="consumeUsage" {{checked consumeUsage}}>{{localize "DND5E.AbilityUseConsume"}}
<input type="checkbox" name="consumeUsage" {{ checked consumeUsage }}>
{{localize "DND5E.AbilityUseConsume"}}
</label>
</div>
{{/if}}

{{#if (ne consumeResource null)}}
<div class="form-group">
<label class="checkbox">
<input type="checkbox" name="consumeResource" {{checked consumeResource}}>{{localize "DND5E.ConsumeResource"}}
<input type="checkbox" name="consumeResource" {{ checked consumeResource }}>
{{localize "DND5E.ConsumeResource"}}
</label>
</div>
{{/if}}

{{#if (ne createMeasuredTemplate null)}}
<div class="form-group">
<label class="checkbox">
<input type="checkbox" name="createMeasuredTemplate" {{checked createMeasuredTemplate}}>{{localize "DND5E.PlaceTemplate"}}
<input type="checkbox" name="createMeasuredTemplate" {{ checked createMeasuredTemplate }}>
{{ localize "DND5E.PlaceTemplate" }}
</label>
</div>
{{/if}}

{{#if (ne createSummons null)}}
<div class="form-group">
<label class="checkbox">
<input type="checkbox" name="createSummons" {{ checked createSummons }}>
{{ localize "DND5E.Summoning.Action.Place" }}
</label>
{{#if summoningOptions}}
<div class="form-fields">
<select name="summonsProfile" aria-label="{{ localize 'DND5E.Summoning.Profile.Label' }}">
{{ selectOptions summoningOptions selected=summonsProfile }}
</select>
</div>
{{else}}
<input type="hidden" name="summonsProfile" value="{{ summonsProfile }}">
{{/if}}
</div>
{{/if}}
</form>
Loading