import TraitSelector from "../apps/trait-selector.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); if ( this.object.data.type === "class" ) { this.options.resizable = true; this.options.width = 600; this.options.height = 640; } } /* -------------------------------------------- */ /** @override */ static get defaultOptions() { return mergeObject(super.defaultOptions, { width: 560, height: 420, classes: ["sw5e", "sheet", "item"], resizable: true, scrollY: [".tab.details"], tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}] }); } /* -------------------------------------------- */ /** @override */ get template() { const path = "systems/sw5e/templates/items/"; return `${path}/${this.item.data.type}.html`; } /* -------------------------------------------- */ /** @override */ getData() { const data = super.getData(); data.labels = this.item.labels; // Include CONFIG values data.config = CONFIG.SW5E; // Item Type, Status, and Details data.itemType = data.item.type.titleCase(); data.itemStatus = this._getItemStatus(data.item); data.itemProperties = this._getItemProperties(data.item); data.isPhysical = data.item.data.hasOwnProperty("quantity"); // Potential consumption targets data.abilityConsumptionTargets = this._getItemConsumptionTargets(data.item); // Action Details data.hasAttackRoll = this.item.hasAttack; data.isHealing = data.item.data.actionType === "heal"; data.isFlatDC = getProperty(data.item.data, "save.scaling") === "flat"; // Vehicles data.isCrewed = data.item.data.activation?.type === 'crew'; data.isMountable = this._isItemMountable(data.item); 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; }, {}); } // Attributes else if ( consume.type === "attribute" ) { const attributes = Object.values(CombatTrackerConfig.prototype.getAttributeChoices())[0]; // Bit of a hack return attributes.reduce((obj, a) => { obj[a] = a; 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) => { 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; } 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.components, 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); } else if ( item.type === "species" ) { } // 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'); } /* -------------------------------------------- */ /** @override */ setPosition(position={}) { position.height = this._tabs[0].active === "details" ? "auto" : this.options.height; return super.setPosition(position); } /* -------------------------------------------- */ /* Form Submission */ /* -------------------------------------------- */ /** @override */ _updateObject(event, formData) { // TODO: This can be removed once 0.7.x is release channel if ( !formData.data ) formData = expandObject(formData); // Handle Damage Array const damage = formData.data?.damage; if ( damage ) damage.parts = Object.values(damage?.parts || {}).map(d => [d[0] || "", d[1] || ""]); // Update the Item super._updateObject(event, formData); } /* -------------------------------------------- */ /** @override */ activateListeners(html) { super.activateListeners(html); html.find(".damage-control").click(this._onDamageControl.bind(this)); html.find('.trait-selector.class-skills').click(this._onConfigureClassSkills.bind(this)); // Armor properties html.find(".armorproperties-control").click(this._onarmorpropertiesControl.bind(this)); // Weapon properties html.find(".weaponproperties-control").click(this._onweaponpropertiesControl.bind(this)); } /* -------------------------------------------- */ /** * 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 = duplicate(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 which allows a checkbox of multiple trait options * @param {Event} event The click event which originated the selection * @private */ _onConfigureClassSkills(event) { event.preventDefault(); const skills = this.item.data.data.skills; const choices = skills.choices && skills.choices.length ? skills.choices : Object.keys(CONFIG.SW5E.skills); const a = event.currentTarget; const label = a.parentElement; // Render the Trait Selector dialog new TraitSelector(this.item, { name: a.dataset.edit, title: label.innerText, choices: Object.entries(CONFIG.SW5E.skills).reduce((obj, e) => { if ( choices.includes(e[0] ) ) obj[e[0]] = e[1]; return obj; }, {}), minimum: skills.number, maximum: skills.number }).render(true) } }