diff --git a/module/actor/sheets/base.js b/module/actor/sheets/base.js new file mode 100644 index 00000000..f89aecea --- /dev/null +++ b/module/actor/sheets/base.js @@ -0,0 +1,763 @@ +import {TraitSelector} from "../../apps/trait-selector.js"; +import {ActorSheetFlags} from "../../apps/actor-flags.js"; +import {SW5E} from '../../config.js'; + +/** + * Extend the basic ActorSheet class to do all the D&D5e things! + * This sheet is an Abstract layer which is not used. + * + * @type {ActorSheet} + */ +export 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() + }; + } + + /* -------------------------------------------- */ + + /** @override */ + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + scrollY: [ + ".inventory .inventory-list", + ".features .inventory-list", + ".powerbook .inventory-list" + ], + tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}] + }); + } + + + /* -------------------------------------------- */ + + /** @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", + 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]; + } + + // Update skill labels + for ( let [s, skl] of Object.entries(data.actor.data.skills)) { + skl.ability = data.actor.data.abilities[skl.ability].label.substring(0, 3); + skl.icon = this._getProficiencyIcon(skl.value); + skl.hover = CONFIG.SW5E.proficiencyLevels[skl.value]; + skl.label = CONFIG.SW5E.skills[s]; + } + + // Update traits + this._prepareTraits(data.actor.data.traits); + + // Prepare owned items + this._prepareItems(data); + + // Return data to the sheet + return data + } + + /* -------------------------------------------- */ + + _prepareTraits(traits) { + const map = { + "dr": CONFIG.SW5E.damageTypes, + "di": CONFIG.SW5E.damageTypes, + "dv": CONFIG.SW5E.damageTypes, + "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, level={}) => { + powerbook[i] = { + order: i, + label: label, + usesSlots: i > 0, + canCreate: owner && (i >= 1), + canPrepare: (data.actor.type === "character") && (i >= 1), + powers: [], + uses: useLabels[i] || level.value || 0, + slots: useLabels[i] || level.max || 0, + override: level.override || 0, + dataset: {"type": "power", "level": i}, + 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); + + // Structure the powerbook for every level up to the maximum which has a slot + 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]); + } + } + if ( levels.pact && levels.pact.max ) { + registerSection("power0", 0, CONFIG.SW5E.powerLevels[0]); + registerSection("pact", sections.pact, CONFIG.SW5E.powerPreparationModes.pact, levels.pact); + } + + // 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}`; + + // Powercasting mode specific headings + if ( mode in sections ) { + s = sections[mode]; + if ( !powerbook[s] ){ + registerSection(sl, s, CONFIG.SW5E.powerPreparationModes[mode], levels[mode]); + } + } + + // Higher-level power headings + else if ( !powerbook[s] ) { + registerSection(sl, s, CONFIG.SW5E.powerLevels[s], 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 && 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]; + } + + /* -------------------------------------------- */ + /* 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 h4').click(event => this._onItemSummary(event)); + + // Editable Only Listeners + if ( this.isEditable ) { + + // Relative updates for numeric fields + html.find('input[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('.configure-flags').click(this._onConfigureFlags.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)); + } + + // 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 Dragging + let handler = ev => this._onDragItemStart(ev); + html.find('li.item').each((i, li) => { + if ( li.classList.contains("inventory-header") ) return; + li.setAttribute("draggable", true); + li.addEventListener("dragstart", handler, false); + }); + + // 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 click events for the Traits tab button to configure special Character Flags + */ + _onConfigureFlags(event) { + event.preventDefault(); + new ActorSheetFlags(this.actor).render(true); + } + + /* -------------------------------------------- */ + + /** + * 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 _onDrop (event) { + event.preventDefault(); + + // Get dropped data + let data; + try { + data = JSON.parse(event.dataTransfer.getData('text/plain')); + } catch (err) { + return false; + } + + // Handle a polymorph + if (data && (data.type === "Actor")) { + if (game.user.isGM || (game.settings.get('sw5e', 'allowPolymorphing') && this.actor.owner)) { + return this._onDropPolymorph(event, data); + } + } + + // Call parent on drop logic + return super._onDrop(event); + } + + /* -------------------------------------------- */ + + /** + * Handle dropping an Actor on the sheet to trigger a Polymorph workflow + * @param {DragEvent} event The drop event + * @param {Object} data The data transfer + * @private + */ + async _onDropPolymorph(event, data) { + + // 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, { + 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); + } + + /* -------------------------------------------- */ + + /** + * 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); + + // Roll powers through the actor + if ( item.data.type === "power" ) { + return this.actor.usePower(item, {configureDialog: !event.shiftKey}); + } + + // Otherwise roll the Item directly + else 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 = $(`
${game.i18n.localize("SW5E.CurrencyConvertHint")}
`, + yes: () => this.actor.convertCurrency() + }); + } +} diff --git a/module/actor/sheets/npc.js b/module/actor/sheets/npc.js new file mode 100644 index 00000000..9046bccb --- /dev/null +++ b/module/actor/sheets/npc.js @@ -0,0 +1,155 @@ +import { ActorSheet5e } from "../sheets/base.js"; + +/** + * An Actor sheet for NPC type characters in the D&D5E system. + * Extends the base ActorSheet5e class. + * @type {ActorSheet5e} + */ +export class ActorSheet5eNPC extends ActorSheet5e { + + /** + * Define default rendering options for the NPC sheet + * @return {Object} + */ + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + classes: ["sw5e", "sheet", "actor", "npc"], + width: 600, + height: 658 + }); + } + + /* -------------------------------------------- */ + /* Rendering */ + /* -------------------------------------------- */ + + /** + * Get the correct HTML template path to use for rendering this particular sheet + * @type {String} + */ + get template() { + if ( !game.user.isGM && this.actor.limited ) return "systems/sw5e/templates/actors/limited-sheet.html"; + return "systems/sw5e/templates/actors/npc-sheet.html"; + } + + /* -------------------------------------------- */ + + /** + * Organize Owned Items for rendering the NPC sheet + * @private + */ + _prepareItems(data) { + + // Categorize Items as Features and Powers + const features = { + weapons: { label: "Attacks", items: [] , hasActions: true, dataset: {type: "weapon", "weapon-type": "natural"} }, + actions: { label: "Actions", items: [] , hasActions: true, dataset: {type: "feat", "activation.type": "action"} }, + passive: { label: "Features", items: [], dataset: {type: "feat"} }, + equipment: { label: "Inventory", items: [], dataset: {type: "loot"}} + }; + + // Start by classifying items into groups for rendering + let [powers, other] = data.items.reduce((arr, item) => { + item.img = item.img || DEFAULT_TOKEN; + item.isStack = item.data.quantity ? item.data.quantity > 1 : false; + item.hasUses = item.data.uses && (item.data.uses.max > 0); + item.isOnCooldown = item.data.recharge && !!item.data.recharge.value && (item.data.recharge.charged === false); + item.isDepleted = item.isOnCooldown && (item.data.uses.per && (item.data.uses.value > 0)); + item.hasTarget = !!item.data.target && !(["none",""].includes(item.data.target.type)); + if ( item.type === "power" ) arr[0].push(item); + else arr[1].push(item); + return arr; + }, [[], []]); + + // Apply item filters + powers = this._filterItems(powers, this._filters.powerbook); + other = this._filterItems(other, this._filters.features); + + // Organize Powerbook + const powerbook = this._preparePowerbook(data, powers); + + // Organize Features + for ( let item of other ) { + if ( item.type === "weapon" ) features.weapons.items.push(item); + else if ( item.type === "feat" ) { + if ( item.data.activation.type ) features.actions.items.push(item); + else features.passive.items.push(item); + } + else features.equipment.items.push(item); + } + + // Assign and return + data.features = Object.values(features); + data.powerbook = powerbook; + } + + + /* -------------------------------------------- */ + + /** + * Add some extra data when rendering the sheet to reduce the amount of logic required within the template. + */ + getData() { + const data = super.getData(); + + // Challenge Rating + const cr = parseFloat(data.data.details.cr || 0); + const crLabels = {0: "0", 0.125: "1/8", 0.25: "1/4", 0.5: "1/2"}; + data.labels["cr"] = cr >= 1 ? String(cr) : crLabels[cr] || 1; + return data; + } + + /* -------------------------------------------- */ + /* Object Updates */ + /* -------------------------------------------- */ + + /** + * This method is called upon form submission after form data is validated + * @param event {Event} The initial triggering submission event + * @param formData {Object} The object of validated form data with which to update the object + * @private + */ + _updateObject(event, formData) { + + // Format NPC Challenge Rating + const crs = {"1/8": 0.125, "1/4": 0.25, "1/2": 0.5}; + let crv = "data.details.cr"; + let cr = formData[crv]; + cr = crs[cr] || parseFloat(cr); + if ( cr ) formData[crv] = cr < 1 ? cr : parseInt(cr); + + // Parent ActorSheet update steps + super._updateObject(event, formData); + } + + /* -------------------------------------------- */ + /* 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) { + super.activateListeners(html); + + // Rollable Health Formula + html.find(".health .rollable").click(this._onRollHealthFormula.bind(this)); + } + + /* -------------------------------------------- */ + + /** + * Handle rolling NPC health values using the provided formula + * @param {Event} event The original click event + * @private + */ + _onRollHealthFormula(event) { + event.preventDefault(); + const formula = this.actor.data.data.attributes.hp.formula; + if ( !formula ) return; + const hp = new Roll(formula).roll().total; + AudioHelper.play({src: CONFIG.sounds.dice}); + this.actor.update({"data.attributes.hp.value": hp, "data.attributes.hp.max": hp}); + } +} \ No newline at end of file