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

[#3242] Add level limits to summoning profiles #3439

Merged
merged 1 commit into from
Apr 22, 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
8 changes: 8 additions & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1484,6 +1484,7 @@
"Remove": "Remove Profile",
"Summon": "Summon"
},
"AdditionalSettings": "Additional Settings",
"Bonuses": {
"ArmorClass": {
"Label": "Bonus Armor Class",
Expand Down Expand Up @@ -1525,6 +1526,13 @@
"Label": "Item Changes",
"Hint": "Changes made to items on the summoned creature."
},
"Level": {
"Label": "Level Limit",
"Max": "Maximum Level",
"Min": "Minimum Level",
"Hint": "Range of levels required to use this profile.",
"IdentifierHint": "Identifier used to determine whether the character level or a specific class level should be used for profile level limits."
},
"Match": {
"Attacks": {
"Label": "Match Attacks",
Expand Down
26 changes: 25 additions & 1 deletion less/v1/items.less
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,6 @@
.details {
gap: 4px;
input { height: unset; }
input::placeholder { opacity: .5; }
}
[data-action="delete-profile"] {
--size: 26px;
Expand All @@ -600,6 +599,31 @@
border-radius: 4px;
padding-inline: 4px;
}
input::placeholder { opacity: .5; }
}
.additional-tray {
margin-block-start: 8px;

> label {
cursor: pointer;
display: flex;
justify-content: center;
gap: .25rem;
font-size: var(--font-size-11);

> span { flex: none; }
.fa-gears { color: var(--color-text-light-6); }

&::before, &::after {
content: "";
flex-basis: 50%;
border-top: 1px dotted var(--dnd5e-color-gold);
align-self: center;
}
}
.form-group:last-child {
margin-block-end: -3px;
}
}

multi-select .tags {
Expand Down
14 changes: 14 additions & 0 deletions less/v2/apps.less
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,20 @@
}
}

.collapsible {
&.collapsed {
label .fa-caret-down { transform: rotate(-90deg); }
.collapsible-content { grid-template-rows: 0fr; }
}
.fa-caret-down { transition: transform 250ms ease; }
.collapsible-content {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 250ms ease;
> .wrapper { overflow: hidden; }
}
}

.unlist {
list-style: none;
padding: 0;
Expand Down
12 changes: 0 additions & 12 deletions less/v2/chat.less
Original file line number Diff line number Diff line change
Expand Up @@ -451,18 +451,6 @@
}
}

&.collapsed {
label .fa-caret-down { transform: rotate(-90deg); }
.collapsible-content { grid-template-rows: 0fr; }
}

.collapsible-content {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 250ms ease;
> .wrapper { overflow: hidden; }
}

.target-source-control {
&:not([hidden]) { display: flex; }
justify-content: space-evenly;
Expand Down
78 changes: 72 additions & 6 deletions module/applications/item/ability-use-dialog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,28 @@ 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;
}, {});
else options.profile = summons.profiles[0]._id;
const rollData = item.getRollData();
const keyPath = item.type === "spell"
? "item.level"
: summons.classIdentifier
? `classes.${summons.classIdentifier}.levels`
: "details.level";
const level = foundry.utils.getProperty(rollData, keyPath) ?? 0;
options.profiles = Object.fromEntries(
summons.profiles
.map(profile => {
const doc = profile.uuid ? fromUuidSync(profile.uuid) : null;
const withinRange = ((profile.level.min ?? -Infinity) <= level) && (level <= (profile.level.max ?? Infinity));
if ( !doc || !withinRange ) return null;
const label = profile.name ? profile.name : (doc?.name ?? "—");
return [profile._id, label];
})
.filter(f => f)
);
if ( Object.values(options.profiles).length <= 1 ) {
options.profiles = null;
options.profile = summons.profiles[0]._id;
}
if ( summons.creatureSizes.size > 1 ) options.creatureSizes = summons.creatureSizes.reduce((obj, k) => {
obj[k] = CONFIG.DND5E.actorSizes[k]?.label;
return obj;
Expand Down Expand Up @@ -414,4 +430,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 multiple profiles, replace with select element
if ( summoningData.profles ) {
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);
}

// If only one profile, replace with hidden input
else {
const input = document.createElement("input");
input.type = "hidden";
input.name = "summonsProfile";
input.value = summoningData.profile;
originalInput.replaceWith(input);
}
}
}
22 changes: 21 additions & 1 deletion module/applications/item/summoning-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ export default class SummoningConfig extends DocumentSheet {
/* Properties */
/* -------------------------------------------- */

/**
* Expanded states for each profile.
* @type {Map<string, boolean>}
*/
expandedProfiles = new Map();

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

/**
* Shortcut to the summoning profiles.
* @type {object[]}
Expand All @@ -44,8 +52,9 @@ export default class SummoningConfig extends DocumentSheet {
/** @inheritDoc */
async getData(options={}) {
const context = await super.getData(options);
context.isSpell = this.document.type === "spell";
context.profiles = this.profiles.map(p => {
const profile = { id: p._id, ...p };
const profile = { id: p._id, ...p, collapsed: this.expandedProfiles.get(p._id) ? "" : "collapsed" };
if ( p.uuid ) profile.document = fromUuidSync(p.uuid);
return profile;
}).sort((lhs, rhs) =>
Expand Down Expand Up @@ -82,6 +91,17 @@ export default class SummoningConfig extends DocumentSheet {
for ( const element of html.querySelectorAll("multi-select") ) {
element.addEventListener("change", this._onChangeInput.bind(this));
}

for ( const element of html.querySelectorAll(".collapsible") ) {
element.addEventListener("click", event => {
if ( event.target.closest(".collapsible-content") ) return;
event.currentTarget.classList.toggle("collapsed");
this.expandedProfiles.set(
event.target.closest("[data-profile-id]").dataset.profileId,
!event.currentTarget.classList.contains("collapsed")
);
});
}
}

/* -------------------------------------------- */
Expand Down
20 changes: 15 additions & 5 deletions module/data/item/fields/summons-field.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import TokenPlacement from "../../../canvas/token-placement.mjs";
import { FormulaField } from "../../fields.mjs";
import { FormulaField, IdentifierField } from "../../fields.mjs";

const {
ArrayField, BooleanField, DocumentIdField, NumberField, SchemaField, SetField, StringField
Expand All @@ -20,10 +20,13 @@ export default class SummonsField extends foundry.data.fields.EmbeddedDataField
* Information for a single summoned creature.
*
* @typedef {object} SummonsProfile
* @property {string} _id Unique ID for this profile.
* @property {number} count 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.
* @property {string} _id Unique ID for this profile.
* @property {number} count Number of creatures to summon.
* @property {object} level
* @property {number} level.min Minimum level at which this profile can be used.
* @property {number} level.max Maximum level at which this profile can be used.
* @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 All @@ -35,6 +38,8 @@ 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 {string} classIdentifier Class identifier that will be used to determine applicable level.
* @property {Set<string>} creatureSizes Set of creature sizes that will be set on summoned creature.
* @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.
Expand Down Expand Up @@ -64,6 +69,7 @@ export class SummonsData extends foundry.abstract.DataModel {
label: "DND5E.Summoning.Bonuses.Healing.Label", hint: "DND5E.Summoning.Bonuses.Healing.Hint"
})
}),
classIdentifier: new IdentifierField(),
creatureSizes: new SetField(new StringField(), {
label: "DND5E.Summoning.CreatureSizes.Label", hint: "DND5E.Summoning.CreatureSizes.Hint"
}),
Expand All @@ -84,6 +90,10 @@ 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}),
level: new SchemaField({
min: new NumberField({integer: true, min: 0}),
max: new NumberField({integer: true, min: 0})
}),
name: new StringField(),
uuid: new StringField()
})),
Expand Down
32 changes: 31 additions & 1 deletion templates/apps/summoning-config.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
</h3>
<ul class="profiles flexcol">
{{#each profiles}}
<li class="profile" data-profile-id="{{ id }}">
<li class="profile dnd5e2" data-profile-id="{{ id }}">
<div class="details flexrow">
{{#if document}}
{{{ dnd5e-linkForUuid uuid }}}
Expand All @@ -28,12 +28,42 @@
</div>
<input type="hidden" name="profiles.{{ id }}._id" value="{{ id }}">
<input type="hidden" name="profiles.{{ id }}.uuid" value="{{ uuid }}">
<div class="additional-tray collapsible {{ collapsed }}">
<label class="roboto-upper">
<i class="fa-solid fa-gears" inert></i>
<span>{{ localize "DND5E.Summoning.AdditionalSettings" }}</span>
<i class="fas fa-caret-down" inert></i>
</label>
<div class="collapsible-content">
<div class="wrapper">
<div class="form-group">
<label>{{ localize "DND5E.Summoning.Level.Label" }}</label>
<div class="form-fields">
<input type="number" name="profiles.{{ id }}.level.min" min="1" step="1" placeholder="0"
value="{{ level.min }}" aria-label="{{ localize 'DND5E.Summoning.Level.Min' }}">
<span class="sep">&ndash;</span>
<input type="number" name="profiles.{{ id }}.level.max" min="1" step="1" placeholder="∞"
value="{{ level.max }}" aria-label="{{ localize 'DND5E.Summoning.Level.Max' }}">
</div>
<p class="hint">{{ localize "DND5E.Summoning.Level.Hint" }}</p>
</div>
</div>
</div>
</div>
</li>
{{else}}
<li class="empty">{{ localize "DND5E.Summoning.Profile.Empty" }}</li>
{{/each}}
</ul>

{{#unless @root.isSpell}}
<div class="form-group">
<label>{{ localize "DND5E.ClassIdentifier" }}</label>
<input type="text" name="classIdentifier" value="{{ summons.classIdentifier }}">
<p class="hint">{{ localize "DND5E.Summoning.Level.IdentifierHint" }}</p>
</div>
{{/unless}}

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