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