forked from GitHub-Mirrors/foundry-sw5e
370 lines
14 KiB
JavaScript
370 lines
14 KiB
JavaScript
import TraitSelector from "../apps/trait-selector.js";
|
|
import {onManageActiveEffect, prepareActiveEffectCategories} from "../effects.js";
|
|
|
|
/**
|
|
* Override and extend the core ItemSheet implementation to handle specific item types
|
|
* @extends {ItemSheet}
|
|
*/
|
|
export default class ItemSheet5e extends ItemSheet {
|
|
constructor(...args) {
|
|
super(...args);
|
|
|
|
// Expand the default size of the class sheet
|
|
if (this.object.data.type === "class") {
|
|
this.options.width = this.position.width = 600;
|
|
this.options.height = this.position.height = 680;
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
static get defaultOptions() {
|
|
return foundry.utils.mergeObject(super.defaultOptions, {
|
|
width: 560,
|
|
height: 400,
|
|
classes: ["sw5e", "sheet", "item"],
|
|
resizable: true,
|
|
scrollY: [".tab.details"],
|
|
tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}]
|
|
});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
get template() {
|
|
const path = "systems/sw5e/templates/items/";
|
|
return `${path}/${this.item.data.type}.html`;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @override */
|
|
async getData(options) {
|
|
const data = super.getData(options);
|
|
const itemData = data.data;
|
|
data.labels = this.item.labels;
|
|
data.config = CONFIG.SW5E;
|
|
|
|
// Item Type, Status, and Details
|
|
data.itemType = game.i18n.localize(`ITEM.Type${data.item.type.titleCase()}`);
|
|
data.itemStatus = this._getItemStatus(itemData);
|
|
data.itemProperties = this._getItemProperties(itemData);
|
|
data.isPhysical = itemData.data.hasOwnProperty("quantity");
|
|
|
|
// Potential consumption targets
|
|
data.abilityConsumptionTargets = this._getItemConsumptionTargets(itemData);
|
|
|
|
// Action Details
|
|
data.hasAttackRoll = this.item.hasAttack;
|
|
data.isHealing = itemData.data.actionType === "heal";
|
|
data.isFlatDC = getProperty(itemData, "data.save.scaling") === "flat";
|
|
data.isLine = ["line", "wall"].includes(itemData.data.target?.type);
|
|
|
|
// Original maximum uses formula
|
|
const sourceMax = foundry.utils.getProperty(this.item.data._source, "data.uses.max");
|
|
if (sourceMax) itemData.data.uses.max = sourceMax;
|
|
|
|
// Vehicles
|
|
data.isCrewed = itemData.data.activation?.type === "crew";
|
|
data.isMountable = this._isItemMountable(itemData);
|
|
|
|
// Prepare Active Effects
|
|
data.effects = prepareActiveEffectCategories(this.item.effects);
|
|
|
|
// Re-define the template data references (backwards compatible)
|
|
data.item = itemData;
|
|
data.data = itemData.data;
|
|
return data;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Get the valid item consumption targets which exist on the actor
|
|
* @param {Object} item Item data for the item being displayed
|
|
* @return {{string: string}} An object of potential consumption targets
|
|
* @private
|
|
*/
|
|
_getItemConsumptionTargets(item) {
|
|
const consume = item.data.consume || {};
|
|
if (!consume.type) return [];
|
|
const actor = this.item.actor;
|
|
if (!actor) return {};
|
|
|
|
// Ammunition
|
|
if (consume.type === "ammo") {
|
|
return actor.itemTypes.consumable.reduce(
|
|
(ammo, i) => {
|
|
if (i.data.data.consumableType === "ammo") {
|
|
ammo[i.id] = `${i.name} (${i.data.data.quantity})`;
|
|
}
|
|
return ammo;
|
|
},
|
|
{[item._id]: `${item.name} (${item.data.quantity})`}
|
|
);
|
|
}
|
|
|
|
// Attributes
|
|
else if (consume.type === "attribute") {
|
|
const attributes = TokenDocument.getTrackedAttributes(actor.data.data);
|
|
attributes.bar.forEach((a) => a.push("value"));
|
|
return attributes.bar.concat(attributes.value).reduce((obj, a) => {
|
|
let k = a.join(".");
|
|
obj[k] = k;
|
|
return obj;
|
|
}, {});
|
|
}
|
|
|
|
// Materials
|
|
else if (consume.type === "material") {
|
|
return actor.items.reduce((obj, i) => {
|
|
if (["consumable", "loot"].includes(i.data.type) && !i.data.data.activation) {
|
|
obj[i.id] = `${i.name} (${i.data.data.quantity})`;
|
|
}
|
|
return obj;
|
|
}, {});
|
|
}
|
|
|
|
// Charges
|
|
else if (consume.type === "charges") {
|
|
return actor.items.reduce((obj, i) => {
|
|
// Limited-use items
|
|
const uses = i.data.data.uses || {};
|
|
if (uses.per && uses.max) {
|
|
const label =
|
|
uses.per === "charges"
|
|
? ` (${game.i18n.format("SW5E.AbilityUseChargesLabel", {value: uses.value})})`
|
|
: ` (${game.i18n.format("SW5E.AbilityUseConsumableLabel", {
|
|
max: uses.max,
|
|
per: uses.per
|
|
})})`;
|
|
obj[i.id] = i.name + label;
|
|
}
|
|
|
|
// Recharging items
|
|
const recharge = i.data.data.recharge || {};
|
|
if (recharge.value) obj[i.id] = `${i.name} (${game.i18n.format("SW5E.Recharge")})`;
|
|
return obj;
|
|
}, {});
|
|
} else return {};
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Get the text item status which is shown beneath the Item type in the top-right corner of the sheet
|
|
* @return {string}
|
|
* @private
|
|
*/
|
|
_getItemStatus(item) {
|
|
if (item.type === "power") {
|
|
return CONFIG.SW5E.powerPreparationModes[item.data.preparation];
|
|
} else if (["weapon", "equipment"].includes(item.type)) {
|
|
return game.i18n.localize(item.data.equipped ? "SW5E.Equipped" : "SW5E.Unequipped");
|
|
} else if (item.type === "tool") {
|
|
return game.i18n.localize(item.data.proficient ? "SW5E.Proficient" : "SW5E.NotProficient");
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Get the Array of item properties which are used in the small sidebar of the description tab
|
|
* @return {Array}
|
|
* @private
|
|
*/
|
|
_getItemProperties(item) {
|
|
const props = [];
|
|
const labels = this.item.labels;
|
|
|
|
if (item.type === "weapon") {
|
|
props.push(
|
|
...Object.entries(item.data.properties)
|
|
.filter((e) => e[1] === true)
|
|
.map((e) => CONFIG.SW5E.weaponProperties[e[0]])
|
|
);
|
|
} else if (item.type === "power") {
|
|
props.push(
|
|
labels.materials,
|
|
item.data.components.concentration ? game.i18n.localize("SW5E.Concentration") : null,
|
|
item.data.components.ritual ? game.i18n.localize("SW5E.Ritual") : null
|
|
);
|
|
} else if (item.type === "equipment") {
|
|
props.push(CONFIG.SW5E.equipmentTypes[item.data.armor.type]);
|
|
props.push(labels.armor);
|
|
} else if (item.type === "feat") {
|
|
props.push(labels.featType);
|
|
//TODO: Work out these
|
|
} else if (item.type === "species") {
|
|
//props.push(labels.species);
|
|
} else if (item.type === "archetype") {
|
|
//props.push(labels.archetype);
|
|
} else if (item.type === "background") {
|
|
//props.push(labels.background);
|
|
} else if (item.type === "classfeature") {
|
|
//props.push(labels.classfeature);
|
|
} else if (item.type === "deployment") {
|
|
//props.push(labels.deployment);
|
|
} else if (item.type === "venture") {
|
|
//props.push(labels.venture);
|
|
} else if (item.type === "fightingmastery") {
|
|
//props.push(labels.fightingmastery);
|
|
} else if (item.type === "fightingstyle") {
|
|
//props.push(labels.fightingstyle);
|
|
} else if (item.type === "lightsaberform") {
|
|
//props.push(labels.lightsaberform);
|
|
}
|
|
|
|
// Action type
|
|
if (item.data.actionType) {
|
|
props.push(CONFIG.SW5E.itemActionTypes[item.data.actionType]);
|
|
}
|
|
|
|
// Action usage
|
|
if (item.type !== "weapon" && item.data.activation && !isObjectEmpty(item.data.activation)) {
|
|
props.push(labels.activation, labels.range, labels.target, labels.duration);
|
|
}
|
|
return props.filter((p) => !!p);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Is this item a separate large object like a siege engine or vehicle
|
|
* component that is usually mounted on fixtures rather than equipped, and
|
|
* has its own AC and HP.
|
|
* @param item
|
|
* @returns {boolean}
|
|
* @private
|
|
*/
|
|
_isItemMountable(item) {
|
|
const data = item.data;
|
|
return (
|
|
(item.type === "weapon" && data.weaponType === "siege") ||
|
|
(item.type === "equipment" && data.armor.type === "vehicle")
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
setPosition(position = {}) {
|
|
if (!(this._minimized || position.height)) {
|
|
position.height = this._tabs[0].active === "details" ? "auto" : this.options.height;
|
|
}
|
|
return super.setPosition(position);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Form Submission */
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
_getSubmitData(updateData = {}) {
|
|
// Create the expanded update data object
|
|
const fd = new FormDataExtended(this.form, {editors: this.editors});
|
|
let data = fd.toObject();
|
|
if (updateData) data = mergeObject(data, updateData);
|
|
else data = expandObject(data);
|
|
|
|
// Handle Damage array
|
|
const damage = data.data?.damage;
|
|
if (damage) damage.parts = Object.values(damage?.parts || {}).map((d) => [d[0] || "", d[1] || ""]);
|
|
|
|
// Return the flattened submission data
|
|
return flattenObject(data);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
activateListeners(html) {
|
|
super.activateListeners(html);
|
|
if (this.isEditable) {
|
|
html.find(".damage-control").click(this._onDamageControl.bind(this));
|
|
html.find(".trait-selector.class-skills").click(this._onConfigureTraits.bind(this));
|
|
html.find(".effect-control").click((ev) => {
|
|
if (this.item.isOwned)
|
|
return ui.notifications.warn(
|
|
"Managing Active Effects within an Owned Item is not currently supported and will be added in a subsequent update."
|
|
);
|
|
onManageActiveEffect(ev, this.item);
|
|
});
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Add or remove a damage part from the damage formula
|
|
* @param {Event} event The original click event
|
|
* @return {Promise}
|
|
* @private
|
|
*/
|
|
async _onDamageControl(event) {
|
|
event.preventDefault();
|
|
const a = event.currentTarget;
|
|
|
|
// Add new damage component
|
|
if (a.classList.contains("add-damage")) {
|
|
await this._onSubmit(event); // Submit any unsaved changes
|
|
const damage = this.item.data.data.damage;
|
|
return this.item.update({"data.damage.parts": damage.parts.concat([["", ""]])});
|
|
}
|
|
|
|
// Remove a damage component
|
|
if (a.classList.contains("delete-damage")) {
|
|
await this._onSubmit(event); // Submit any unsaved changes
|
|
const li = a.closest(".damage-part");
|
|
const damage = foundry.utils.deepClone(this.item.data.data.damage);
|
|
damage.parts.splice(Number(li.dataset.damagePart), 1);
|
|
return this.item.update({"data.damage.parts": damage.parts});
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Handle spawning the TraitSelector application for selection various options.
|
|
* @param {Event} event The click event which originated the selection
|
|
* @private
|
|
*/
|
|
_onConfigureTraits(event) {
|
|
event.preventDefault();
|
|
const a = event.currentTarget;
|
|
|
|
const options = {
|
|
name: a.dataset.target,
|
|
title: a.parentElement.innerText,
|
|
choices: [],
|
|
allowCustom: false
|
|
};
|
|
|
|
switch (a.dataset.options) {
|
|
case "saves":
|
|
options.choices = CONFIG.SW5E.abilities;
|
|
options.valueKey = null;
|
|
break;
|
|
case "skills":
|
|
const skills = this.item.data.data.skills;
|
|
const choiceSet =
|
|
skills.choices && skills.choices.length ? skills.choices : Object.keys(CONFIG.SW5E.skills);
|
|
options.choices = Object.fromEntries(
|
|
Object.entries(CONFIG.SW5E.skills).filter((skill) => choiceSet.includes(skill[0]))
|
|
);
|
|
options.maximum = skills.number;
|
|
break;
|
|
}
|
|
new TraitSelector(this.item, options).render(true);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
async _onSubmit(...args) {
|
|
if (this._tabs[0].active === "details") this.position.height = "auto";
|
|
await super._onSubmit(...args);
|
|
}
|
|
}
|