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; } } /* -------------------------------------------- */ /** @override */ static get defaultOptions() { return mergeObject(super.defaultOptions, { width: 560, height: 400, 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 */ async getData(options) { const data = super.getData(options); 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(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"; data.isLine = ["line", "wall"].includes(data.item.data.target?.type); // Original maximum uses formula if ( this.item._data.data?.uses?.max ) data.data.uses.max = this.item._data.data.uses.max; // Vehicles data.isCrewed = data.item.data.activation?.type === 'crew'; data.isMountable = this._isItemMountable(data.item); // Prepare Active Effects data.effects = prepareActiveEffectCategories(this.entity.effects); 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 = 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) => { // 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.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" ) { //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 === "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'); } /* -------------------------------------------- */ /** @override */ setPosition(position={}) { if ( !(this._minimized || position.height) ) { position.height = (this._tabs[0].active === "details") ? "auto" : this.options.height; } return super.setPosition(position); } /* -------------------------------------------- */ /* Form Submission */ /* -------------------------------------------- */ /** @override */ _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); } /* -------------------------------------------- */ /** @override */ 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._onConfigureClassSkills.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 = 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.target, 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) } /* -------------------------------------------- */ /** @override */ async _onSubmit(...args) { if ( this._tabs[0].active === "details" ) this.position.height = "auto"; await super._onSubmit(...args); } }