Skip to content

Commit

Permalink
[#893] Add data model & configuration data for summoning
Browse files Browse the repository at this point in the history
  • Loading branch information
arbron committed Feb 21, 2024
1 parent fadc6b7 commit 02de1b4
Show file tree
Hide file tree
Showing 9 changed files with 455 additions and 24 deletions.
46 changes: 46 additions & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@
"DND5E.ActionRSAK": "Ranged Spell Attack",
"DND5E.ActionRWAK": "Ranged Weapon Attack",
"DND5E.ActionSave": "Saving Throw",
"DND5E.ActionSumm": "Summon",
"DND5E.ActionUtil": "Utility",
"DND5E.ActionWarningNoItem": "The requested item {item} no longer exists on Actor {name}",
"DND5E.ActionWarningNoToken": "You must have one or more controlled Tokens in order to use this option.",
Expand Down Expand Up @@ -1361,6 +1362,51 @@
"DND5E.SubclassMismatchWarn": "{name} subclass has no matching class with identifier '{class}'.",
"DND5E.SubclassName": "Subclass Name",
"DND5E.Subtype": "Subtype",
"DND5E.Summoning": {
"Label": "Summoning",
"Action": {
"Add": "Add Profile",
"Configure": "Configure Summons",
"Remove": "Remove Profile"
},
"ActorChanges": {
"Label": "Actor Changes",
"Hint": "Changes that will be made to the actor being summoned. Any @ references used in the following formulas will be based on the summoner’s stats."
},
"ArmorClass": {
"Label": "Bonus Armor Class",
"Hint": "Bonus to the Armor Class set on the summoned actor added to what is specified in their statblock."
},
"Configuration": "Summoning Configuration",
"DisplayName": "Display Name",
"DropHint": "Drop actor here",
"HitPoints": {
"Label": "Bonus Hit Points",
"Hint": "Additional hit points added to the actor on top of what is specified in their statblock."
},
"ItemChanges": {
"Label": "Item Changes",
"Hint": "Changes made to items on the summoned actor."
},
"Match": {
"Attacks": {
"Label": "Match Attacks",
"Hint": "Modify to hit values on the summoned actor’s attacks to match that of the summoner."
},
"Proficiency": {
"Label": "Match Proficiency",
"Hint": "Modify the summoned actor’s proficiency to match that of the summoner."
},
"Saves": {
"Label": "Match Saves",
"Hint": "Modify to saving throw DCs on the summoned actor’s abilities to match that of the summoner."
}
},
"Profile": {
"Label": "Summons Profiles",
"Empty": "Click above to add a profile or drop an actor to summon here."
}
},
"DND5E.Supply": "Supply",
"DND5E.Suppressed": "Suppressed",
"DND5E.Target": "Target",
Expand Down
80 changes: 79 additions & 1 deletion less/v1/items.less
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@
/* Item Actions */
/* ----------------------------------------- */

h4.damage-header {
h4.damage-header, h4.summons-header {
margin: 0;
padding: 0;
font-weight: bold;
Expand Down Expand Up @@ -340,6 +340,13 @@
}
}

.summoning.form-group {
.config-button {
opacity: 1;
font-size: var(--font-size-12);
}
}

/* ----------------------------------------- */
/* Item Actions */
/* ----------------------------------------- */
Expand Down Expand Up @@ -438,3 +445,74 @@
padding: 0.1em 0.5em;
}
}

/* ----------------------------------------- */
/* Summoning Configuration */
/* ----------------------------------------- */

.dnd5e.summoning-config {
.unbutton {
width: unset;
border: none;
background: none;
line-height: unset;

&:hover, &:focus { box-shadow: none; }
&:hover { text-shadow: 0 0 8px var(--color-shadow-primary); }
&:focus-visible { outline: 2px solid black; }
}

.form-header {
justify-content: space-between;
button { flex: unset; }
}

ul.profiles {
padding: 0;
list-style: none;
gap: 12px;
}
li.profile {
position: relative;
padding: 8px;
background: var(--dnd5e-color-card);
border: 2px solid var(--dnd5e-color-gold);
border-radius: 4px;
box-shadow: 0 0 4px var(--dnd5e-shadow-45);

&:has(.drag-bar) { padding-inline-end: 18px; }

.details {
gap: 4px;
input { height: unset; }
input::placeholder { opacity: .5; }
}
.drag-bar {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
inset-block: 0;
inset-inline-end: 0;
inline-size: 16px;
cursor: grab;
color: var(--dnd5e-color-faint);
}
[data-action="delete-profile"] {
--size: 26px;
flex: 0 0 var(--size);
block-size: var(--size);
inline-size: var(--size);
}
.content-link, .drop-area {
flex: 0 0 175px;
display: flex;
align-items: center;
}
.drop-area {
border: 1px dashed black;
border-radius: 4px;
padding-inline: 4px;
}
}
}
1 change: 1 addition & 0 deletions module/applications/item/_module.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export {default as ItemDirectory5e} from "./item-directory.mjs";
export {default as ItemSheet5e} from "./item-sheet.mjs";

export {default as AbilityUseDialog} from "./ability-use-dialog.mjs";
export {default as SummoningConfig} from "./summoning-config.mjs";
8 changes: 6 additions & 2 deletions module/applications/item/item-sheet.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as Trait from "../../documents/actor/trait.mjs";
import { filteredKeys, sortObjectEntries } from "../../utils.mjs";
import ActorMovementConfig from "../actor/movement-config.mjs";
import ActorSensesConfig from "../actor/senses-config.mjs";
import ActorTypeConfig from "../actor/type-config.mjs";
Expand All @@ -6,8 +8,7 @@ import AdvancementMigrationDialog from "../advancement/advancement-migration-dia
import Accordion from "../accordion.mjs";
import EffectsElement from "../components/effects.mjs";
import SourceConfig from "../source-config.mjs";
import * as Trait from "../../documents/actor/trait.mjs";
import { filteredKeys, sortObjectEntries } from "../../utils.mjs";
import SummoningConfig from "./summoning-config.mjs";

/**
* Override and extend the core ItemSheet implementation to handle specific item types.
Expand Down Expand Up @@ -574,6 +575,9 @@ export default class ItemSheet5e extends ItemSheet {
case "source":
app = new SourceConfig(this.item, { keyPath: "system.source" });
break;
case "summoning":
app = new SummoningConfig(this.item);
break;
case "type":
app = new ActorTypeConfig(this.item, { keyPath: "system.type" });
break;
Expand Down
186 changes: 186 additions & 0 deletions module/applications/item/summoning-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* Application for configuring summoning information for an item.
*/
export default class SummoningConfig extends DocumentSheet {

/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e", "summoning-config"],
dragDrop: [{ dragSelector: ".drag-bar", dropSelector: "form" }],
template: "systems/dnd5e/templates/apps/summoning-config.hbs",
width: 500,
height: "auto",
sheetConfig: false,
closeOnSubmit: false,
submitOnChange: true,
submitOnClose: true
});
}

/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */

/**
* Shortcut to the summoning profiles.
* @type {object[]}
*/
get profiles() {
return this.document.system.summons.profiles;
}

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

/** @inheritDoc */
get title() {
return `${game.i18n.localize("DND5E.Summoning.Configuration")}: ${this.document.name}`;
}

/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */

/** @inheritDoc */
async getData(options={}) {
const context = await super.getData(options);
context.profiles = this.profiles.map(p => {
const profile = { id: p._id, ...p };
if ( p.uuid ) profile.document = fromUuidSync(p.uuid);
return profile;
}).sort((lhs, rhs) => lhs.sort - rhs.sort);
context.summons = this.document.system.summons;
return context;
}

/* -------------------------------------------- */
/* Event Handling */
/* -------------------------------------------- */

/** @inheritDoc */
activateListeners(jQuery) {
super.activateListeners(jQuery);
const html = jQuery[0];

for ( const element of html.querySelectorAll("[data-action]") ) {
element.addEventListener("click", event => this.submit({ updateData: {
action: event.target.dataset.action,
profileId: event.target.closest("[data-profile-id]")?.dataset.profileId
} }));
}
}

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

/** @inheritDoc */
_getSubmitData(...args) {
const data = foundry.utils.expandObject(super._getSubmitData(...args));
data.profiles = Object.values(data.profiles ?? {});
const highestSort = this.profiles.reduce((sort, i) => i.sort > sort ? i.sort : sort, 0);

switch ( data.action ) {
case "add-profile":
data.profiles.push({
_id: foundry.utils.randomID(),
sort: highestSort + CONST.SORT_INTEGER_DENSITY,
...(data.addDetails ?? {})
});
break;
case "delete-profile":
data.profiles = data.profiles.filter(e => e._id !== data.profileId);
break;
}

return data;
}

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

/** @inheritDoc */
async _updateObject(event, formData) {
this.document.update({"system.summons": formData});
}

/* -------------------------------------------- */
/* Drag & Drop */
/* -------------------------------------------- */

/** @inheritDoc */
_canDragDrop() {
return this.isEditable;
}

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

/** @inheritDoc */
_onDragStart(event) {
const entry = event.target.closest("[data-profile-id]");
if ( !entry ) return;
event.dataTransfer.setData("text/plain", JSON.stringify({
type: "summoning-profile", item: this.document.uuid, profileId: entry.dataset.profileId
}));
const box = entry.getBoundingClientRect();
event.dataTransfer.setDragImage(entry, box.width - 6, box.height / 2);
}

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

/** @inheritDoc */
async _onDrop(event) {
// Try to extract the data
const data = TextEditor.getDragEventData(event);

// Handle re-ordering of list
if ( data?.item && (data.item === this.document.uuid) ) return this._onSortEntry(event, data);

// Handle dropping linked items
if ( data?.type !== "Actor" ) return;
const actor = await Actor.implementation.fromDropData(data);

// Determine where this was dropped
const existingProfile = event.target.closest("[data-profile-id]");
const { profileId } = existingProfile?.dataset ?? {};

// If dropped onto existing profile, add or replace link
if ( profileId ) this.submit({ updateData: { [`profiles.${profileId}.uuid`]: actor.uuid } });

// Otherwise create a new profile
else this.submit({ updateData: { action: "add-profile", addDetails: { uuid: actor.uuid } } });
}

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

/**
* Sort a profile on drop.
* @param {DragEvent} event Triggering drop event.
* @param {object} data Drag event data.
*/
_onSortEntry(event, data) {
const dropArea = event.target.closest("[data-profile-id]");
const dragProfile = this.profiles.find(p => p._id === data?.profileId);
const dropProfile = this.profiles.find(p => p._id === dropArea?.dataset.profileId);

// Do nothing if dropped on itself
if ( dragProfile === dropProfile ) return;

const siblings = this.profiles.filter(e => e !== dragProfile).sort((lhs, rhs) => lhs.sort - rhs.sort);
let sortBefore;
let target = dropProfile;

// If dropped outside any profile, sort to top or bottom of list
if ( !target ) {
const box = this.form.getBoundingClientRect();
sortBefore = (event.clientY - box.y) < (box.height * .25);
target = sortBefore ? siblings[0] : siblings[siblings.length - 1];
}

if ( !target ) return;

const sortUpdates = SortingHelpers.performIntegerSort(dragProfile, { target, siblings, sortBefore });
const updateData = sortUpdates.reduce((obj, { target, update }) => {
obj[`profiles.${target._id}.sort`] = update.sort;
return obj;
}, {});
this.submit({ updateData });
}
}
1 change: 1 addition & 0 deletions module/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,7 @@ DND5E.itemActionTypes = {
msak: "DND5E.ActionMSAK",
rsak: "DND5E.ActionRSAK",
save: "DND5E.ActionSave",
summ: "DND5E.ActionSumm",
heal: "DND5E.ActionHeal",
abil: "DND5E.ActionAbil",
util: "DND5E.ActionUtil",
Expand Down
Loading

0 comments on commit 02de1b4

Please sign in to comment.