import Item5e from "../../../item/entity.js"; import TraitSelector from "../../../apps/trait-selector.js"; import ActorSheetFlags from "../../../apps/actor-flags.js"; import ActorMovementConfig from "../../../apps/movement-config.js"; import ActorSensesConfig from "../../../apps/senses-config.js"; import {SW5E} from "../../../config.js"; import {onManageActiveEffect, prepareActiveEffectCategories} from "../../../effects.js"; /** * Extend the basic ActorSheet class to suppose SW5e-specific logic and functionality. * This sheet is an Abstract layer which is not used. * @extends {ActorSheet} */ export default class ActorSheet5e extends ActorSheet { constructor(...args) { super(...args); /** * Track the set of item filters which are applied * @type {Set} */ this._filters = { inventory: new Set(), powerbook: new Set(), features: new Set(), effects: new Set() }; } /* -------------------------------------------- */ /** @override */ static get defaultOptions() { return mergeObject(super.defaultOptions, { scrollY: [ ".inventory .inventory-list", ".features .inventory-list", ".powerbook .inventory-list", ".effects .inventory-list" ], tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}] }); } /* -------------------------------------------- */ /** @override */ get template() { if (!game.user.isGM && this.actor.limited) return "systems/sw5e/templates/actors/oldActor/limited-sheet.html"; return `systems/sw5e/templates/actors/oldActor/${this.actor.data.type}-sheet.html`; } /* -------------------------------------------- */ /** @override */ getData() { // Basic data let isOwner = this.entity.owner; const data = { owner: isOwner, limited: this.entity.limited, options: this.options, editable: this.isEditable, cssClass: isOwner ? "editable" : "locked", isCharacter: this.entity.data.type === "character", isNPC: this.entity.data.type === "npc", isVehicle: this.entity.data.type === "vehicle", config: CONFIG.SW5E }; // The Actor and its Items data.actor = duplicate(this.actor.data); data.items = this.actor.items.map(i => { i.data.labels = i.labels; return i.data; }); data.items.sort((a, b) => (a.sort || 0) - (b.sort || 0)); data.data = data.actor.data; data.labels = this.actor.labels || {}; data.filters = this._filters; // Ability Scores for (let [a, abl] of Object.entries(data.actor.data.abilities)) { abl.icon = this._getProficiencyIcon(abl.proficient); abl.hover = CONFIG.SW5E.proficiencyLevels[abl.proficient]; abl.label = CONFIG.SW5E.abilities[a]; } // Skills if (data.actor.data.skills) { for (let [s, skl] of Object.entries(data.actor.data.skills)) { skl.ability = CONFIG.SW5E.abilityAbbreviations[skl.ability]; skl.icon = this._getProficiencyIcon(skl.value); skl.hover = CONFIG.SW5E.proficiencyLevels[skl.value]; skl.label = CONFIG.SW5E.skills[s]; } } // Movement speeds data.movement = this._getMovementSpeed(data.actor); // Senses data.senses = this._getSenses(data.actor); // Update traits this._prepareTraits(data.actor.data.traits); // Prepare owned items this._prepareItems(data); // Prepare active effects data.effects = prepareActiveEffectCategories(this.entity.effects); // Return data to the sheet return data; } /* -------------------------------------------- */ /** * Prepare the display of movement speed data for the Actor* * @param {object} actorData The Actor data being prepared. * @param {boolean} [largestPrimary=false] Show the largest movement speed as "primary", otherwise show "walk" * @returns {{primary: string, special: string}} * @private */ _getMovementSpeed(actorData, largestPrimary = false) { const movement = actorData.data.attributes.movement || {}; // Prepare an array of available movement speeds let speeds = [ [movement.burrow, `${game.i18n.localize("SW5E.MovementBurrow")} ${movement.burrow}`], [movement.climb, `${game.i18n.localize("SW5E.MovementClimb")} ${movement.climb}`], [ movement.fly, `${game.i18n.localize("SW5E.MovementFly")} ${movement.fly}` + (movement.hover ? ` (${game.i18n.localize("SW5E.MovementHover")})` : "") ], [movement.swim, `${game.i18n.localize("SW5E.MovementSwim")} ${movement.swim}`] ]; if (largestPrimary) { speeds.push([movement.walk, `${game.i18n.localize("SW5E.MovementWalk")} ${movement.walk}`]); } // Filter and sort speeds on their values speeds = speeds.filter(s => !!s[0]).sort((a, b) => b[0] - a[0]); // Case 1: Largest as primary if (largestPrimary) { let primary = speeds.shift(); return { primary: `${primary ? primary[1] : "0"} ${movement.units}`, special: speeds.map(s => s[1]).join(", ") }; } // Case 2: Walk as primary else { return { primary: `${movement.walk || 0} ${movement.units}`, special: speeds.length ? speeds.map(s => s[1]).join(", ") : "" }; } } /* -------------------------------------------- */ _getSenses(actorData) { const senses = actorData.data.attributes.senses || {}; const tags = {}; for (let [k, label] of Object.entries(CONFIG.SW5E.senses)) { const v = senses[k] ?? 0; if (v === 0) continue; tags[k] = `${game.i18n.localize(label)} ${v} ${senses.units}`; } if (!!senses.special) tags["special"] = senses.special; return tags; } /* -------------------------------------------- */ /** * Prepare the data structure for traits data like languages, resistances & vulnerabilities, and proficiencies * @param {object} traits The raw traits data object from the actor data * @private */ _prepareTraits(traits) { const map = { dr: CONFIG.SW5E.damageResistanceTypes, di: CONFIG.SW5E.damageResistanceTypes, dv: CONFIG.SW5E.damageResistanceTypes, ci: CONFIG.SW5E.conditionTypes, languages: CONFIG.SW5E.languages, armorProf: CONFIG.SW5E.armorProficiencies, weaponProf: CONFIG.SW5E.weaponProficiencies, toolProf: CONFIG.SW5E.toolProficiencies }; for (let [t, choices] of Object.entries(map)) { const trait = traits[t]; if (!trait) continue; let values = []; if (trait.value) { values = trait.value instanceof Array ? trait.value : [trait.value]; } trait.selected = values.reduce((obj, t) => { obj[t] = choices[t]; return obj; }, {}); // Add custom entry if (trait.custom) { trait.custom.split(";").forEach((c, i) => (trait.selected[`custom${i + 1}`] = c.trim())); } trait.cssClass = !isObjectEmpty(trait.selected) ? "" : "inactive"; } } /* -------------------------------------------- */ /** * Insert a power into the powerbook object when rendering the character sheet * @param {Object} data The Actor data being prepared * @param {Array} powers The power data being prepared * @private */ _preparePowerbook(data, powers) { const owner = this.actor.owner; const levels = data.data.powers; const powerbook = {}; // Define some mappings const sections = { atwill: -20, innate: -10, pact: 0.5 }; // Label power slot uses headers const useLabels = { "-20": "-", "-10": "-", 0: "∞" }; // Format a powerbook entry for a certain indexed level const registerSection = (sl, i, label, {prepMode = "prepared", value, max, override} = {}) => { powerbook[i] = { order: i, label: label, usesSlots: i > 0, canCreate: owner, canPrepare: data.actor.type === "character" && i >= 1, powers: [], uses: useLabels[i] || value || 0, slots: useLabels[i] || max || 0, override: override || 0, dataset: {type: "power", level: prepMode in sections ? 1 : i, "preparation.mode": prepMode}, prop: sl }; }; // Determine the maximum power level which has a slot const maxLevel = Array.fromRange(10).reduce((max, i) => { if (i === 0) return max; const level = levels[`power${i}`]; if ((level.max || level.override) && i > max) max = i; return max; }, 0); // Level-based powercasters have cantrips and leveled slots if (maxLevel > 0) { registerSection("power0", 0, CONFIG.SW5E.powerLevels[0]); for (let lvl = 1; lvl <= maxLevel; lvl++) { const sl = `power${lvl}`; registerSection(sl, lvl, CONFIG.SW5E.powerLevels[lvl], levels[sl]); } } // Pact magic users have cantrips and a pact magic section if (levels.pact && levels.pact.max) { if (!powerbook["0"]) registerSection("power0", 0, CONFIG.SW5E.powerLevels[0]); const l = levels.pact; const config = CONFIG.SW5E.powerPreparationModes.pact; registerSection("pact", sections.pact, config, { prepMode: "pact", value: l.value, max: l.max, override: l.override }); } // Iterate over every power item, adding powers to the powerbook by section powers.forEach(power => { const mode = power.data.preparation.mode || "prepared"; let s = power.data.level || 0; const sl = `power${s}`; // Specialized powercasting modes (if they exist) if (mode in sections) { s = sections[mode]; if (!powerbook[s]) { const l = levels[mode] || {}; const config = CONFIG.SW5E.powerPreparationModes[mode]; registerSection(mode, s, config, { prepMode: mode, value: l.value, max: l.max, override: l.override }); } } // Sections for higher-level powers which the caster "should not" have, but power items exist for else if (!powerbook[s]) { registerSection(sl, s, CONFIG.SW5E.powerLevels[s], {levels: levels[sl]}); } // Add the power to the relevant heading powerbook[s].powers.push(power); }); // Sort the powerbook by section level const sorted = Object.values(powerbook); sorted.sort((a, b) => a.order - b.order); return sorted; } /* -------------------------------------------- */ /** * Determine whether an Owned Item will be shown based on the current set of filters * @return {boolean} * @private */ _filterItems(items, filters) { return items.filter(item => { const data = item.data; // Action usage for (let f of ["action", "bonus", "reaction"]) { if (filters.has(f)) { if (data.activation && data.activation.type !== f) return false; } } // Power-specific filters if (filters.has("ritual")) { if (data.components.ritual !== true) return false; } if (filters.has("concentration")) { if (data.components.concentration !== true) return false; } if (filters.has("prepared")) { if (data.level === 0 || ["innate", "always"].includes(data.preparation.mode)) return true; if (this.actor.data.type === "npc") return true; return data.preparation.prepared; } // Equipment-specific filters if (filters.has("equipped")) { if (data.equipped !== true) return false; } return true; }); } /* -------------------------------------------- */ /** * Get the font-awesome icon used to display a certain level of skill proficiency * @private */ _getProficiencyIcon(level) { const icons = { 0: '', 0.5: '', 1: '', 2: '' }; return icons[level] || icons[0]; } /* -------------------------------------------- */ /* Event Listeners and Handlers /* -------------------------------------------- */ /** * Activate event listeners using the prepared sheet HTML * @param html {HTML} The prepared HTML object ready to be rendered into the DOM */ activateListeners(html) { // Activate Item Filters const filterLists = html.find(".filter-list"); filterLists.each(this._initializeFilterItemList.bind(this)); filterLists.on("click", ".filter-item", this._onToggleFilter.bind(this)); // Item summaries html.find(".item .item-name.rollable h4").click(event => this._onItemSummary(event)); // Editable Only Listeners if (this.isEditable) { // Input focus and update const inputs = html.find("input"); inputs.focus(ev => ev.currentTarget.select()); inputs.addBack().find('[data-dtype="Number"]').change(this._onChangeInputDelta.bind(this)); // Ability Proficiency html.find(".ability-proficiency").click(this._onToggleAbilityProficiency.bind(this)); // Toggle Skill Proficiency html.find(".skill-proficiency").on("click contextmenu", this._onCycleSkillProficiency.bind(this)); // Trait Selector html.find(".trait-selector").click(this._onTraitSelector.bind(this)); // Configure Special Flags html.find(".config-button").click(this._onConfigMenu.bind(this)); // Owned Item management html.find(".item-create").click(this._onItemCreate.bind(this)); html.find(".item-edit").click(this._onItemEdit.bind(this)); html.find(".item-delete").click(this._onItemDelete.bind(this)); html .find(".item-uses input") .click(ev => ev.target.select()) .change(this._onUsesChange.bind(this)); html.find(".slot-max-override").click(this._onPowerSlotOverride.bind(this)); // Active Effect management html.find(".effect-control").click(ev => onManageActiveEffect(ev, this.entity)); } // Owner Only Listeners if (this.actor.owner) { // Ability Checks html.find(".ability-name").click(this._onRollAbilityTest.bind(this)); // Roll Skill Checks html.find(".skill-name").click(this._onRollSkillCheck.bind(this)); // Item Rolling html.find(".item .item-image").click(event => this._onItemRoll(event)); html.find(".item .item-recharge").click(event => this._onItemRecharge(event)); } // Otherwise remove rollable classes else { html.find(".rollable").each((i, el) => el.classList.remove("rollable")); } // Handle default listeners last so system listeners are triggered first super.activateListeners(html); } /* -------------------------------------------- */ /** * Iinitialize Item list filters by activating the set of filters which are currently applied * @private */ _initializeFilterItemList(i, ul) { const set = this._filters[ul.dataset.filter]; const filters = ul.querySelectorAll(".filter-item"); for (let li of filters) { if (set.has(li.dataset.filter)) li.classList.add("active"); } } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** * Handle input changes to numeric form fields, allowing them to accept delta-typed inputs * @param event * @private */ _onChangeInputDelta(event) { const input = event.target; const value = input.value; if (["+", "-"].includes(value[0])) { let delta = parseFloat(value); input.value = getProperty(this.actor.data, input.name) + delta; } else if (value[0] === "=") { input.value = value.slice(1); } } /* -------------------------------------------- */ /** * Handle spawning the TraitSelector application which allows a checkbox of multiple trait options * @param {Event} event The click event which originated the selection * @private */ _onConfigMenu(event) { event.preventDefault(); const button = event.currentTarget; switch (button.dataset.action) { case "movement": new ActorMovementConfig(this.object).render(true); break; case "flags": new ActorSheetFlags(this.object).render(true); break; case "senses": new ActorSensesConfig(this.object).render(true); break; } } /* -------------------------------------------- */ /** * Handle cycling proficiency in a Skill * @param {Event} event A click or contextmenu event which triggered the handler * @private */ _onCycleSkillProficiency(event) { event.preventDefault(); const field = $(event.currentTarget).siblings('input[type="hidden"]'); // Get the current level and the array of levels const level = parseFloat(field.val()); const levels = [0, 1, 0.5, 2]; let idx = levels.indexOf(level); // Toggle next level - forward on click, backwards on right if (event.type === "click") { field.val(levels[idx === levels.length - 1 ? 0 : idx + 1]); } else if (event.type === "contextmenu") { field.val(levels[idx === 0 ? levels.length - 1 : idx - 1]); } // Update the field value and save the form this._onSubmit(event); } /* -------------------------------------------- */ /** @override */ async _onDropActor(event, data) { const canPolymorph = game.user.isGM || (this.actor.owner && game.settings.get("sw5e", "allowPolymorphing")); if (!canPolymorph) return false; // Get the target actor let sourceActor = null; if (data.pack) { const pack = game.packs.find(p => p.collection === data.pack); sourceActor = await pack.getEntity(data.id); } else { sourceActor = game.actors.get(data.id); } if (!sourceActor) return; // Define a function to record polymorph settings for future use const rememberOptions = html => { const options = {}; html.find("input").each((i, el) => { options[el.name] = el.checked; }); const settings = mergeObject(game.settings.get("sw5e", "polymorphSettings") || {}, options); game.settings.set("sw5e", "polymorphSettings", settings); return settings; }; // Create and render the Dialog return new Dialog( { title: game.i18n.localize("SW5E.PolymorphPromptTitle"), content: { options: game.settings.get("sw5e", "polymorphSettings"), i18n: SW5E.polymorphSettings, isToken: this.actor.isToken }, default: "accept", buttons: { accept: { icon: '', label: game.i18n.localize("SW5E.PolymorphAcceptSettings"), callback: html => this.actor.transformInto(sourceActor, rememberOptions(html)) }, wildshape: { icon: '', label: game.i18n.localize("SW5E.PolymorphWildShape"), callback: html => this.actor.transformInto(sourceActor, { keepBio: true, keepClass: true, keepMental: true, mergeSaves: true, mergeSkills: true, transformTokens: rememberOptions(html).transformTokens }) }, polymorph: { icon: '', label: game.i18n.localize("SW5E.Polymorph"), callback: html => this.actor.transformInto(sourceActor, { transformTokens: rememberOptions(html).transformTokens }) }, cancel: { icon: '', label: game.i18n.localize("Cancel") } } }, { classes: ["dialog", "sw5e"], width: 600, template: "systems/sw5e/templates/apps/polymorph-prompt.html" } ).render(true); } /* -------------------------------------------- */ /** @override */ async _onDropItemCreate(itemData) { // Create a Consumable power scroll on the Inventory tab if (itemData.type === "power" && this._tabs[0].active === "inventory") { const scroll = await Item5e.createScrollFromPower(itemData); itemData = scroll.data; } // Ignore certain statuses if (itemData.data) { ["attunement", "equipped", "proficient", "prepared"].forEach(k => delete itemData.data[k]); } // Create the owned item as normal return super._onDropItemCreate(itemData); } /* -------------------------------------------- */ /** * Handle enabling editing for a power slot override value * @param {MouseEvent} event The originating click event * @private */ async _onPowerSlotOverride(event) { const span = event.currentTarget.parentElement; const level = span.dataset.level; const override = this.actor.data.data.powers[level].override || span.dataset.slots; const input = document.createElement("INPUT"); input.type = "text"; input.name = `data.powers.${level}.override`; input.value = override; input.placeholder = span.dataset.slots; input.dataset.dtype = "Number"; // Replace the HTML const parent = span.parentElement; parent.removeChild(span); parent.appendChild(input); } /* -------------------------------------------- */ /** * Change the uses amount of an Owned Item within the Actor * @param {Event} event The triggering click event * @private */ async _onUsesChange(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; const item = this.actor.getOwnedItem(itemId); const uses = Math.clamped(0, parseInt(event.target.value), item.data.data.uses.max); event.target.value = uses; return item.update({"data.uses.value": uses}); } /* -------------------------------------------- */ /** * Handle rolling of an item from the Actor sheet, obtaining the Item instance and dispatching to it's roll method * @private */ _onItemRoll(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; const item = this.actor.getOwnedItem(itemId); return item.roll(); } /* -------------------------------------------- */ /** * Handle attempting to recharge an item usage by rolling a recharge check * @param {Event} event The originating click event * @private */ _onItemRecharge(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; const item = this.actor.getOwnedItem(itemId); return item.rollRecharge(); } /* -------------------------------------------- */ /** * Handle rolling of an item from the Actor sheet, obtaining the Item instance and dispatching to it's roll method * @private */ _onItemSummary(event) { event.preventDefault(); let li = $(event.currentTarget).parents(".item"), item = this.actor.getOwnedItem(li.data("item-id")), chatData = item.getChatData({secrets: this.actor.owner}); // Toggle summary if (li.hasClass("expanded")) { let summary = li.children(".item-summary"); summary.slideUp(200, () => summary.remove()); } else { let div = $(`