diff --git a/module/item/entity.js b/module/item/entity.js new file mode 100644 index 00000000..c259c0a0 --- /dev/null +++ b/module/item/entity.js @@ -0,0 +1,916 @@ +import { Dice5e } from "../dice.js"; +import { AbilityUseDialog } from "../apps/ability-use-dialog.js"; +import { AbilityTemplate } from "../pixi/ability-template.js"; + +/** + * Override and extend the basic :class:`Item` implementation + */ +export class Item5e extends Item { + + /* -------------------------------------------- */ + /* Item Properties */ + /* -------------------------------------------- */ + + /** + * Determine which ability score modifier is used by this item + * @type {string|null} + */ + get abilityMod() { + const itemData = this.data.data; + if (!("ability" in itemData)) return null; + + // Case 1 - defined directly by the item + if ( itemData.ability ) return itemData.ability; + + // Case 2 - inferred from a parent actor + else if ( this.actor ) { + const actorData = this.actor.data.data; + if ( this.data.type === "power" ) return actorData.attributes.powercasting || "int"; + else if ( this.data.type === "tool" ) return "int"; + else return "str"; + } + + // Case 3 - unknown + return null + } + + /* -------------------------------------------- */ + + /** + * Does the Item implement an attack roll as part of its usage + * @type {boolean} + */ + get hasAttack() { + return ["mwak", "rwak", "msak", "rsak"].includes(this.data.data.actionType); + } + + /* -------------------------------------------- */ + + /** + * Does the Item implement a damage roll as part of its usage + * @type {boolean} + */ + get hasDamage() { + return !!(this.data.data.damage && this.data.data.damage.parts.length); + } + + /* -------------------------------------------- */ + + /** + * Does the Item implement a versatile damage roll as part of its usage + * @type {boolean} + */ + get isVersatile() { + return !!(this.hasDamage && this.data.data.damage.versatile); + } + + /* -------------------------------------------- */ + + /** + * Does the item provide an amount of healing instead of conventional damage? + * @return {boolean} + */ + get isHealing() { + return (this.data.data.actionType === "heal") && this.data.data.damage.parts.length; + } + + /* -------------------------------------------- */ + + /** + * Does the Item implement a saving throw as part of its usage + * @type {boolean} + */ + get hasSave() { + return !!(this.data.data.save && this.data.data.save.ability); + } + + /* -------------------------------------------- */ + + /** + * Does the Item have a target + * @type {boolean} + */ + get hasTarget() { + const target = this.data.data.target; + return target && !["none",""].includes(target.type); + } + + /* -------------------------------------------- */ + + /** + * Does the Item have an area of effect target + * @type {boolean} + */ + get hasAreaTarget() { + const target = this.data.data.target; + return target && (target.type in CONFIG.SW5E.areaTargetTypes); + } + + /* -------------------------------------------- */ + + /** + * A flag for whether this Item is limited in it's ability to be used by charges or by recharge. + * @type {boolean} + */ + get hasLimitedUses() { + let chg = this.data.data.recharge || {}; + let uses = this.data.data.uses || {}; + return !!chg.value || (!!uses.per && (uses.max > 0)); + } + + /* -------------------------------------------- */ + /* Data Preparation */ + /* -------------------------------------------- */ + + /** + * Augment the basic Item data model with additional dynamic data. + */ + prepareData() { + super.prepareData(); + + // Get the Item's data + const itemData = this.data; + const actorData = this.actor ? this.actor.data : {}; + const data = itemData.data; + const C = CONFIG.SW5E; + const labels = {}; + + // Classes + if ( itemData.type === "class" ) { + data.levels = Math.clamped(data.levels, 1, 20); + } + + // Power Level, School, and Components + if ( itemData.type === "power" ) { + labels.level = C.powerLevels[data.level]; + labels.school = C.powerSchools[data.school]; + labels.components = Object.entries(data.components).reduce((arr, c) => { + if ( c[1] !== true ) return arr; + arr.push(c[0].titleCase().slice(0, 1)); + return arr; + }, []); + } + + // Feat Items + else if ( itemData.type === "feat" ) { + const act = data.activation; + if ( act && (act.type === C.abilityActivationTypes.legendary) ) labels.featType = "Legendary Action"; + else if ( act && (act.type === C.abilityActivationTypes.lair) ) labels.featType = "Lair Action"; + else if ( act && act.type ) labels.featType = data.damage.length ? "Attack" : "Action"; + else labels.featType = "Passive"; + } + + // Equipment Items + else if ( itemData.type === "equipment" ) { + labels.armor = data.armor.value ? `${data.armor.value} AC` : ""; + } + + // Activated Items + if ( data.hasOwnProperty("activation") ) { + + // Ability Activation Label + let act = data.activation || {}; + if ( act ) labels.activation = [act.cost, C.abilityActivationTypes[act.type]].filterJoin(" "); + + // Target Label + let tgt = data.target || {}; + if (["none", "touch", "self"].includes(tgt.units)) tgt.value = null; + if (["none", "self"].includes(tgt.type)) { + tgt.value = null; + tgt.units = null; + } + labels.target = [tgt.value, C.distanceUnits[tgt.units], C.targetTypes[tgt.type]].filterJoin(" "); + + // Range Label + let rng = data.range || {}; + if (["none", "touch", "self"].includes(rng.units) || (rng.value === 0)) { + rng.value = null; + rng.long = null; + } + labels.range = [rng.value, rng.long ? `/ ${rng.long}` : null, C.distanceUnits[rng.units]].filterJoin(" "); + + // Duration Label + let dur = data.duration || {}; + if (["inst", "perm"].includes(dur.units)) dur.value = null; + labels.duration = [dur.value, C.timePeriods[dur.units]].filterJoin(" "); + + // Recharge Label + let chg = data.recharge || {}; + labels.recharge = `Recharge [${chg.value}${parseInt(chg.value) < 6 ? "+" : ""}]`; + } + + // Item Actions + if ( data.hasOwnProperty("actionType") ) { + + // Save DC + let save = data.save || {}; + if ( !save.ability ) save.dc = null; + else if ( this.isOwned ) { // Actor owned items + if ( save.scaling === "power" ) save.dc = actorData.data.attributes.powerdc; + else if ( save.scaling !== "flat" ) save.dc = this.actor.getPowerDC(save.scaling); + } else { // Un-owned items + if ( save.scaling !== "flat" ) save.dc = null; + } + labels.save = save.ability ? `DC ${save.dc || ""} ${C.abilities[save.ability]}` : ""; + + // Damage + let dam = data.damage || {}; + if ( dam.parts ) { + labels.damage = dam.parts.map(d => d[0]).join(" + ").replace(/\+ -/g, "- "); + labels.damageTypes = dam.parts.map(d => C.damageTypes[d[1]]).join(", "); + } + } + + // Assign labels + this.labels = labels; + } + + /* -------------------------------------------- */ + + /** + * Roll the item to Chat, creating a chat card which contains follow up attack or damage roll options + * @return {Promise} + */ + async roll({configureDialog=true}={}) { + + // Basic template rendering data + const token = this.actor.token; + const templateData = { + actor: this.actor, + tokenId: token ? `${token.scene._id}.${token.id}` : null, + item: this.data, + data: this.getChatData(), + labels: this.labels, + hasAttack: this.hasAttack, + isHealing: this.isHealing, + hasDamage: this.hasDamage, + isVersatile: this.isVersatile, + isPower: this.data.type === "power", + hasSave: this.hasSave, + hasAreaTarget: this.hasAreaTarget + }; + + // For feature items, optionally show an ability usage dialog + if (this.data.type === "feat") { + let configured = await this._rollFeat(configureDialog); + if ( configured === false ) return; + } + + // Render the chat card template + const templateType = ["tool", "consumable"].includes(this.data.type) ? this.data.type : "item"; + const template = `systems/sw5e/templates/chat/${templateType}-card.html`; + const html = await renderTemplate(template, templateData); + + // Basic chat message data + const chatData = { + user: game.user._id, + type: CONST.CHAT_MESSAGE_TYPES.OTHER, + content: html, + speaker: { + actor: this.actor._id, + token: this.actor.token, + alias: this.actor.name + } + }; + + // Toggle default roll mode + let rollMode = game.settings.get("core", "rollMode"); + if ( ["gmroll", "blindroll"].includes(rollMode) ) chatData["whisper"] = ChatMessage.getWhisperIDs("GM"); + if ( rollMode === "blindroll" ) chatData["blind"] = true; + + // Create the chat message + return ChatMessage.create(chatData); + } + + /* -------------------------------------------- */ + + /** + * Additional rolling steps when rolling a feat-type item + * @private + * @return {boolean} whether the roll should be prevented + */ + async _rollFeat(configureDialog) { + if ( this.data.type !== "feat" ) throw new Error("Wrong Item type"); + + // Configure whether to consume a limited use or to place a template + const usesRecharge = !!this.data.data.recharge.value; + const uses = this.data.data.uses; + let usesCharges = !!uses.per && (uses.max > 0); + let placeTemplate = false; + let consume = usesRecharge || usesCharges; + + // Determine whether the feat uses charges + configureDialog = configureDialog && (consume || this.hasAreaTarget); + if ( configureDialog ) { + const usage = await AbilityUseDialog.create(this); + if ( usage === null ) return false; + consume = Boolean(usage.get("consume")); + placeTemplate = Boolean(usage.get("placeTemplate")); + } + + // Update Item data + const current = getProperty(this.data, "data.uses.value") || 0; + if ( consume && usesRecharge ) { + await this.update({"data.recharge.charged": false}); + } + else if ( consume && usesCharges ) { + await this.update({"data.uses.value": Math.max(current - 1, 0)}); + } + + // Maybe initiate template placement workflow + if ( this.hasAreaTarget && placeTemplate ) { + const template = AbilityTemplate.fromItem(this); + if ( template ) template.drawPreview(event); + if ( this.owner && this.owner.sheet ) this.owner.sheet.minimize(); + } + return true; + } + + /* -------------------------------------------- */ + /* Chat Cards */ + /* -------------------------------------------- */ + + /** + * Prepare an object of chat data used to display a card for the Item in the chat log + * @param {Object} htmlOptions Options used by the TextEditor.enrichHTML function + * @return {Object} An object of chat data to render + */ + getChatData(htmlOptions) { + const data = duplicate(this.data.data); + const labels = this.labels; + + // Rich text description + data.description.value = TextEditor.enrichHTML(data.description.value, htmlOptions); + + // Item type specific properties + const props = []; + const fn = this[`_${this.data.type}ChatData`]; + if ( fn ) fn.bind(this)(data, labels, props); + + // General equipment properties + if ( data.hasOwnProperty("equipped") && !["loot", "tool"].includes(this.data.type) ) { + props.push( + data.equipped ? "Equipped" : "Not Equipped", + data.proficient ? "Proficient": "Not Proficient", + ); + } + + // Ability activation properties + if ( data.hasOwnProperty("activation") ) { + props.push( + labels.target, + labels.activation, + labels.range, + labels.duration + ); + } + + // Filter properties and return + data.properties = props.filter(p => !!p); + return data; + } + + /* -------------------------------------------- */ + + /** + * Prepare chat card data for equipment type items + * @private + */ + _equipmentChatData(data, labels, props) { + props.push( + CONFIG.SW5E.equipmentTypes[data.armor.type], + labels.armor || null, + data.stealth.value ? "Stealth Disadvantage" : null, + ); + } + + /* -------------------------------------------- */ + + /** + * Prepare chat card data for weapon type items + * @private + */ + _weaponChatData(data, labels, props) { + props.push( + CONFIG.SW5E.weaponTypes[data.weaponType], + ); + } + + /* -------------------------------------------- */ + + /** + * Prepare chat card data for consumable type items + * @private + */ + _consumableChatData(data, labels, props) { + props.push( + CONFIG.SW5E.consumableTypes[data.consumableType], + data.uses.value + "/" + data.uses.max + " Charges" + ); + data.hasCharges = data.uses.value >= 0; + } + + /* -------------------------------------------- */ + + /** + * Prepare chat card data for tool type items + * @private + */ + _toolChatData(data, labels, props) { + props.push( + CONFIG.SW5E.abilities[data.ability] || null, + CONFIG.SW5E.proficiencyLevels[data.proficient || 0] + ); + } + + /* -------------------------------------------- */ + + /** + * Prepare chat card data for tool type items + * @private + */ + _lootChatData(data, labels, props) { + props.push( + "Loot", + data.weight ? data.weight + " lbs." : null + ); + } + + /* -------------------------------------------- */ + + /** + * Render a chat card for Power type data + * @return {Object} + * @private + */ + _powerChatData(data, labels, props) { + props.push( + labels.level, + labels.components, + ); + } + + /* -------------------------------------------- */ + + /** + * Prepare chat card data for items of the "Feat" type + * @private + */ + _featChatData(data, labels, props) { + props.push(data.requirements); + } + + /* -------------------------------------------- */ + /* Item Rolls - Attack, Damage, Saves, Checks */ + /* -------------------------------------------- */ + + /** + * Place an attack roll using an item (weapon, feat, power, or equipment) + * Rely upon the Dice5e.d20Roll logic for the core implementation + * + * @return {Promise.} A Promise which resolves to the created Roll instance + */ + rollAttack(options={}) { + const itemData = this.data.data; + const actorData = this.actor.data.data; + const flags = this.actor.data.flags.sw5e || {}; + if ( !this.hasAttack ) { + throw new Error("You may not place an Attack Roll with this Item."); + } + const rollData = this.getRollData(); + + // Define Roll bonuses + const parts = [`@mod`]; + if ( (this.data.type !== "weapon") || itemData.proficient ) { + parts.push("@prof"); + } + + // Attack Bonus + const actorBonus = actorData.bonuses[itemData.actionType] || {}; + if ( itemData.attackBonus || actorBonus.attack ) { + parts.push("@atk"); + rollData["atk"] = [itemData.attackBonus, actorBonus.attack].filterJoin(" + "); + } + + // Compose roll options + const rollConfig = { + event: options.event, + parts: parts, + actor: this.actor, + data: rollData, + title: `${this.name} - Attack Roll`, + speaker: ChatMessage.getSpeaker({actor: this.actor}), + dialogOptions: { + width: 400, + top: options.event ? options.event.clientY - 80 : null, + left: window.innerWidth - 710 + } + }; + + // Expanded weapon critical threshold + if (( this.data.type === "weapon" ) && flags.weaponCriticalThreshold) { + rollConfig.critical = parseInt(flags.weaponCriticalThreshold); + } + + // Elven Accuracy + if ( ["weapon", "power"].includes(this.data.type) ) { + if (flags.elvenAccuracy && ["dex", "int", "wis", "cha"].includes(this.abilityMod)) { + rollConfig.elvenAccuracy = true; + } + } + + // Apply Halfling Lucky + if ( flags.halflingLucky ) rollConfig.halflingLucky = true; + + // Invoke the d20 roll helper + return Dice5e.d20Roll(rollConfig); + } + + /* -------------------------------------------- */ + + /** + * Place a damage roll using an item (weapon, feat, power, or equipment) + * Rely upon the Dice5e.damageRoll logic for the core implementation + * + * @return {Promise.} A Promise which resolves to the created Roll instance + */ + rollDamage({event, powerLevel=null, versatile=false}={}) { + const itemData = this.data.data; + const actorData = this.actor.data.data; + if ( !this.hasDamage ) { + throw new Error("You may not make a Damage Roll with this Item."); + } + const rollData = this.getRollData(); + if ( powerLevel ) rollData.item.level = powerLevel; + + // Define Roll parts + const parts = itemData.damage.parts.map(d => d[0]); + if ( versatile && itemData.damage.versatile ) parts[0] = itemData.damage.versatile; + if ( (this.data.type === "power") ) { + if ( (itemData.scaling.mode === "cantrip") ) { + const lvl = this.actor.data.type === "character" ? actorData.details.level : actorData.details.powerLevel; + this._scaleCantripDamage(parts, lvl, itemData.scaling.formula ); + } else if ( powerLevel && (itemData.scaling.mode === "level") && itemData.scaling.formula ) { + this._scalePowerDamage(parts, itemData.level, powerLevel, itemData.scaling.formula ); + } + } + + // Define Roll Data + const actorBonus = actorData.bonuses[itemData.actionType] || {}; + if ( actorBonus.damage && parseInt(actorBonus.damage) !== 0 ) { + parts.push("@dmg"); + rollData["dmg"] = actorBonus.damage; + } + + // Call the roll helper utility + const title = `${this.name} - Damage Roll`; + const flavor = this.labels.damageTypes.length ? `${title} (${this.labels.damageTypes})` : title; + return Dice5e.damageRoll({ + event: event, + parts: parts, + actor: this.actor, + data: rollData, + title: title, + flavor: flavor, + speaker: ChatMessage.getSpeaker({actor: this.actor}), + dialogOptions: { + width: 400, + top: event ? event.clientY - 80 : null, + left: window.innerWidth - 710 + } + }); + } + + /* -------------------------------------------- */ + + /** + * Adjust a cantrip damage formula to scale it for higher level characters and monsters + * @private + */ + _scaleCantripDamage(parts, level, scale) { + const add = Math.floor((level + 1) / 6); + if ( add === 0 ) return; + if ( scale && (scale !== parts[0]) ) { + parts[0] = parts[0] + " + " + scale.replace(new RegExp(Roll.diceRgx, "g"), (match, nd, d) => `${add}d${d}`); + } else { + parts[0] = parts[0].replace(new RegExp(Roll.diceRgx, "g"), (match, nd, d) => `${parseInt(nd)+add}d${d}`); + } + } + + /* -------------------------------------------- */ + + /** + * Adjust the power damage formula to scale it for power level up-casting + * @param {Array} parts The original damage parts + * @param {number} baseLevel The default power level + * @param {number} powerLevel The casted power level + * @param {string} formula The scaling formula + * @private + */ + _scalePowerDamage(parts, baseLevel, powerLevel, formula) { + const upcastLevels = Math.max(powerLevel - baseLevel, 0); + if ( upcastLevels === 0 ) return parts; + const bonus = new Roll(formula).alter(0, upcastLevels); + parts.push(bonus.formula); + return parts; + } + + /* -------------------------------------------- */ + + /** + * Place an attack roll using an item (weapon, feat, power, or equipment) + * Rely upon the Dice5e.d20Roll logic for the core implementation + * + * @return {Promise.} A Promise which resolves to the created Roll instance + */ + async rollFormula(options={}) { + if ( !this.data.data.formula ) { + throw new Error("This Item does not have a formula to roll!"); + } + + // Define Roll Data + const rollData = this.getRollData(); + const title = `${this.name} - Other Formula`; + + // Invoke the roll and submit it to chat + const roll = new Roll(rollData.item.formula, rollData).roll(); + roll.toMessage({ + speaker: ChatMessage.getSpeaker({actor: this.actor}), + flavor: this.data.data.chatFlavor || title, + rollMode: game.settings.get("core", "rollMode") + }); + return roll; + } + + /* -------------------------------------------- */ + + /** + * Use a consumable item, deducting from the quantity or charges of the item. + * + * @return {Promise.} A Promise which resolves to the created Roll instance or null + */ + async rollConsumable(options={}) { + const itemData = this.data.data; + + // Dispatch a damage roll + let roll = null; + if ( itemData.damage.parts.length ) { + roll = await this.rollDamage(options); + } + + // Dispatch an other formula + if ( itemData.formula ) { + roll = await this.rollFormula(options); + } + + // Deduct consumed charges from the item + if ( itemData.uses.autoUse ) { + let q = itemData.quantity; + let c = itemData.uses.value; + + // Deduct an item quantity + if ( c <= 1 && q > 1 ) { + await this.update({ + 'data.quantity': Math.max(q - 1, 0), + 'data.uses.value': itemData.uses.max + }); + } + + // Optionally destroy the item + else if ( c <= 1 && q <= 1 && itemData.uses.autoDestroy ) { + await this.actor.deleteOwnedItem(this.id); + } + + // Deduct the remaining charges + else { + await this.update({'data.uses.value': Math.max(c - 1, 0)}); + } + } + return roll; + } + + /* -------------------------------------------- */ + + /** + * Perform an ability recharge test for an item which uses the d6 recharge mechanic + * @prarm {Object} options + * + * @return {Promise.} A Promise which resolves to the created Roll instance + */ + async rollRecharge(options={}) { + const data = this.data.data; + if ( !data.recharge.value ) return; + + // Roll the check + const roll = new Roll("1d6").roll(); + const success = roll.total >= parseInt(data.recharge.value); + + // Display a Chat Message + const promises = [roll.toMessage({ + flavor: `${this.name} recharge check - ${success ? "success!" : "failure!"}`, + speaker: ChatMessage.getSpeaker({actor: this.actor, token: this.actor.token}) + })]; + + // Update the Item data + if ( success ) promises.push(this.update({"data.recharge.charged": true})); + return Promise.all(promises).then(() => roll); + } + + /* -------------------------------------------- */ + + /** + * Roll a Tool Check + * Rely upon the Dice5e.d20Roll logic for the core implementation + * + * @return {Promise.} A Promise which resolves to the created Roll instance + */ + rollToolCheck(options={}) { + if ( this.type !== "tool" ) throw "Wrong item type!"; + + // Prepare roll data + let rollData = this.getRollData(); + const parts = [`@mod`, "@prof"]; + const title = `${this.name} - Tool Check`; + + // Call the roll helper utility + return Dice5e.d20Roll({ + event: options.event, + parts: parts, + data: rollData, + template: "systems/sw5e/templates/chat/tool-roll-dialog.html", + title: title, + speaker: ChatMessage.getSpeaker({actor: this.actor}), + flavor: `${this.name} - Tool Check`, + dialogOptions: { + width: 400, + top: options.event ? options.event.clientY - 80 : null, + left: window.innerWidth - 710, + }, + halflingLucky: this.actor.getFlag("sw5e", "halflingLucky" ) || false + }); + } + + /* -------------------------------------------- */ + + /** + * Prepare a data object which is passed to any Roll formulas which are created related to this Item + * @private + */ + getRollData() { + if ( !this.actor ) return null; + const rollData = this.actor.getRollData(); + rollData.item = duplicate(this.data.data); + + // Include an ability score modifier if one exists + const abl = this.abilityMod; + if ( abl ) { + const ability = rollData.abilities[abl]; + rollData["mod"] = ability.mod || 0; + } + + // Include a proficiency score + const prof = "proficient" in rollData.item ? (rollData.item.proficient || 0) : 1; + rollData["prof"] = Math.floor(prof * rollData.attributes.prof); + return rollData; + } + + /* -------------------------------------------- */ + /* Chat Message Helpers */ + /* -------------------------------------------- */ + + static chatListeners(html) { + html.on('click', '.card-buttons button', this._onChatCardAction.bind(this)); + html.on('click', '.item-name', this._onChatCardToggleContent.bind(this)); + } + + /* -------------------------------------------- */ + + /** + * Handle execution of a chat card action via a click event on one of the card buttons + * @param {Event} event The originating click event + * @returns {Promise} A promise which resolves once the handler workflow is complete + * @private + */ + static async _onChatCardAction(event) { + event.preventDefault(); + + // Extract card data + const button = event.currentTarget; + button.disabled = true; + const card = button.closest(".chat-card"); + const messageId = card.closest(".message").dataset.messageId; + const message = game.messages.get(messageId); + const action = button.dataset.action; + + // Validate permission to proceed with the roll + const isTargetted = action === "save"; + if ( !( isTargetted || game.user.isGM || message.isAuthor ) ) return; + + // Get the Actor from a synthetic Token + const actor = this._getChatCardActor(card); + if ( !actor ) return; + + // Get the Item + const item = actor.getOwnedItem(card.dataset.itemId); + if ( !item ) { + return ui.notifications.error(`The requested item ${card.dataset.itemId} no longer exists on Actor ${actor.name}`) + } + const powerLevel = parseInt(card.dataset.powerLevel) || null; + + // Get card targets + let targets = []; + if ( isTargetted ) { + targets = this._getChatCardTargets(card); + if ( !targets.length ) { + ui.notifications.warn(`You must have one or more controlled Tokens in order to use this option.`); + return button.disabled = false; + } + } + + // Attack and Damage Rolls + if ( action === "attack" ) await item.rollAttack({event}); + else if ( action === "damage" ) await item.rollDamage({event, powerLevel}); + else if ( action === "versatile" ) await item.rollDamage({event, powerLevel, versatile: true}); + else if ( action === "formula" ) await item.rollFormula({event}); + + // Saving Throws for card targets + else if ( action === "save" ) { + for ( let t of targets ) { + await t.rollAbilitySave(button.dataset.ability, {event}); + } + } + + // Consumable usage + else if ( action === "consume" ) await item.rollConsumable({event}); + + // Tool usage + else if ( action === "toolCheck" ) await item.rollToolCheck({event}); + + // Power Template Creation + else if ( action === "placeTemplate") { + const template = AbilityTemplate.fromItem(item); + if ( template ) template.drawPreview(event); + } + + // Re-enable the button + button.disabled = false; + } + + /* -------------------------------------------- */ + + /** + * Handle toggling the visibility of chat card content when the name is clicked + * @param {Event} event The originating click event + * @private + */ + static _onChatCardToggleContent(event) { + event.preventDefault(); + const header = event.currentTarget; + const card = header.closest(".chat-card"); + const content = card.querySelector(".card-content"); + content.style.display = content.style.display === "none" ? "block" : "none"; + } + + /* -------------------------------------------- */ + + /** + * Get the Actor which is the author of a chat card + * @param {HTMLElement} card The chat card being used + * @return {Actor|null} The Actor entity or null + * @private + */ + static _getChatCardActor(card) { + + // Case 1 - a synthetic actor from a Token + const tokenKey = card.dataset.tokenId; + if (tokenKey) { + const [sceneId, tokenId] = tokenKey.split("."); + const scene = game.scenes.get(sceneId); + if (!scene) return null; + const tokenData = scene.getEmbeddedEntity("Token", tokenId); + if (!tokenData) return null; + const token = new Token(tokenData); + return token.actor; + } + + // Case 2 - use Actor ID directory + const actorId = card.dataset.actorId; + return game.actors.get(actorId) || null; + } + + /* -------------------------------------------- */ + + /** + * Get the Actor which is the author of a chat card + * @param {HTMLElement} card The chat card being used + * @return {Array.} An Array of Actor entities, if any + * @private + */ + static _getChatCardTargets(card) { + const character = game.user.character; + const controlled = canvas.tokens.controlled; + const targets = controlled.reduce((arr, t) => t.actor ? arr.concat([t.actor]) : arr, []); + if ( character && (controlled.length === 0) ) targets.push(character); + return targets; + } +} diff --git a/module/item/sheet.js b/module/item/sheet.js new file mode 100644 index 00000000..9457b588 --- /dev/null +++ b/module/item/sheet.js @@ -0,0 +1,218 @@ +import { TraitSelector } from "../apps/trait-selector.js"; + + +/** + * Override and extend the core ItemSheet implementation to handle D&D5E specific item types + * @type {ItemSheet} + */ +export class ItemSheet5e extends ItemSheet { + + /** @override */ + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + width: 560, + height: 420, + classes: ["sw5e", "sheet", "item"], + resizable: false, + 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"); + + // Action Details + data.hasAttackRoll = this.item.hasAttack; + data.isHealing = data.item.data.actionType === "heal"; + data.isFlatDC = getProperty(data.item.data, "save.scaling") === "flat"; + return data; + } + + /* -------------------------------------------- */ + + /** + * 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 item.data.equipped ? "Equipped" : "Unequipped"; + } + else if ( item.type === "tool" ) { + return item.data.proficient ? "Proficient" : "Not Proficient"; + } + } + + /* -------------------------------------------- */ + + /** + * 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 ? "Concentration" : null, + item.data.components.ritual ? "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); + } + + // 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); + } + + /* -------------------------------------------- */ + + /** @override */ + setPosition(position={}) { + position.height = this._tabs[0].active === "details" ? "auto" : this.options.height; + return super.setPosition(position); + } + + /* -------------------------------------------- */ + /* Form Submission */ + /* -------------------------------------------- */ + + /** @override */ + _updateObject(event, formData) { + + // Handle Damage Array + let damage = Object.entries(formData).filter(e => e[0].startsWith("data.damage.parts")); + formData["data.damage.parts"] = damage.reduce((arr, entry) => { + let [i, j] = entry[0].split(".").slice(3); + if ( !arr[i] ) arr[i] = []; + arr[i][j] = entry[1]; + return arr; + }, []); + + // Update the Item + super._updateObject(event, formData); + } + + /* -------------------------------------------- */ + + /** @override */ + activateListeners(html) { + super.activateListeners(html); + html.find(".damage-control").click(this._onDamageControl.bind(this)); + + // Activate any Trait Selectors + html.find('.trait-selector.class-skills').click(this._onConfigureClassSkills.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) + } +}