Add files via upload

This commit is contained in:
CK 2020-06-24 14:22:36 -04:00 committed by GitHub
parent 1f3483d825
commit 848158b9e8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 1231 additions and 0 deletions

763
module/actor/sheets/base.js Normal file
View file

@ -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: '<i class="far fa-circle"></i>',
0.5: '<i class="fas fa-adjust"></i>',
1: '<i class="fas fa-check"></i>',
2: '<i class="fas fa-check-double"></i>'
};
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: '<i class="fas fa-check"></i>',
label: game.i18n.localize('SW5E.PolymorphAcceptSettings'),
callback: html => this.actor.transformInto(sourceActor, rememberOptions(html))
},
wildshape: {
icon: '<i class="fas fa-paw"></i>',
label: game.i18n.localize('SW5E.PolymorphWildShape'),
callback: html => this.actor.transformInto(sourceActor, {
keepMental: true,
mergeSaves: true,
mergeSkills: true,
transformTokens: rememberOptions(html).transformTokens
})
},
polymorph: {
icon: '<i class="fas fa-pastafarianism"></i>',
label: game.i18n.localize('SW5E.Polymorph'),
callback: html => this.actor.transformInto(sourceActor, {
transformTokens: rememberOptions(html).transformTokens
})
},
cancel: {
icon: '<i class="fas fa-times"></i>',
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 = $(`<div class="item-summary">${chatData.description.value}</div>`);
let props = $(`<div class="item-properties"></div>`);
chatData.properties.forEach(p => props.append(`<span class="tag">${p}</span>`));
div.append(props);
li.append(div.hide());
div.slideDown(200);
}
li.toggleClass("expanded");
}
/* -------------------------------------------- */
/**
* Handle creating a new Owned Item for the actor using initial data defined in the HTML dataset
* @param {Event} event The originating click event
* @private
*/
_onItemCreate(event) {
event.preventDefault();
const header = event.currentTarget;
const type = header.dataset.type;
const itemData = {
name: `New ${type.capitalize()}`,
type: type,
data: duplicate(header.dataset)
};
delete itemData.data["type"];
return this.actor.createOwnedItem(itemData);
}
/* -------------------------------------------- */
/**
* Handle editing an existing Owned Item for the Actor
* @param {Event} event The originating click event
* @private
*/
_onItemEdit(event) {
event.preventDefault();
const li = event.currentTarget.closest(".item");
const item = this.actor.getOwnedItem(li.dataset.itemId);
item.sheet.render(true);
}
/* -------------------------------------------- */
/**
* Handle deleting an existing Owned Item for the Actor
* @param {Event} event The originating click event
* @private
*/
_onItemDelete(event) {
event.preventDefault();
const li = event.currentTarget.closest(".item");
this.actor.deleteOwnedItem(li.dataset.itemId);
}
/* -------------------------------------------- */
/**
* Handle rolling an Ability check, either a test or a saving throw
* @param {Event} event The originating click event
* @private
*/
_onRollAbilityTest(event) {
event.preventDefault();
let ability = event.currentTarget.parentElement.dataset.ability;
this.actor.rollAbility(ability, {event: event});
}
/* -------------------------------------------- */
/**
* Handle rolling a Skill check
* @param {Event} event The originating click event
* @private
*/
_onRollSkillCheck(event) {
event.preventDefault();
const skill = event.currentTarget.parentElement.dataset.skill;
this.actor.rollSkill(skill, {event: event});
}
/* -------------------------------------------- */
/**
* Handle toggling Ability score proficiency level
* @param {Event} event The originating click event
* @private
*/
_onToggleAbilityProficiency(event) {
event.preventDefault();
const field = event.currentTarget.previousElementSibling;
this.actor.update({[field.name]: 1 - parseInt(field.value)});
}
/* -------------------------------------------- */
/**
* Handle toggling of filters to display a different set of owned items
* @param {Event} event The click event which triggered the toggle
* @private
*/
_onToggleFilter(event) {
event.preventDefault();
const li = event.currentTarget;
const set = this._filters[li.parentElement.dataset.filter];
const filter = li.dataset.filter;
if ( set.has(filter) ) set.delete(filter);
else set.add(filter);
this.render();
}
/* -------------------------------------------- */
/**
* Handle spawning the TraitSelector application which allows a checkbox of multiple trait options
* @param {Event} event The click event which originated the selection
* @private
*/
_onTraitSelector(event) {
event.preventDefault();
const a = event.currentTarget;
const label = a.parentElement.querySelector("label");
const options = {
name: label.getAttribute("for"),
title: label.innerText,
choices: CONFIG.SW5E[a.dataset.options]
};
new TraitSelector(this.actor, options).render(true)
}
/* -------------------------------------------- */
/** @override */
_getHeaderButtons() {
let buttons = super._getHeaderButtons();
// Add button to revert polymorph
if ( !this.actor.isPolymorphed || this.actor.isToken ) return buttons;
buttons.unshift({
label: 'SW5E.PolymorphRestoreTransformation',
class: "restore-transformation",
icon: "fas fa-backward",
onclick: ev => this.actor.revertOriginalForm()
});
return buttons;
}
}

View file

@ -0,0 +1,313 @@
import { ActorSheet5e } from "./base.js";
/**
* An Actor sheet for player character type actors in the D&D5E system.
* Extends the base ActorSheet5e class.
* @type {ActorSheet5e}
*/
export class ActorSheet5eCharacter extends ActorSheet5e {
/**
* Define default rendering options for the NPC sheet
* @return {Object}
*/
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
classes: ["sw5e", "sheet", "actor", "character"],
width: 672,
height: 736
});
}
/* -------------------------------------------- */
/* 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/character-sheet.html";
}
/* -------------------------------------------- */
/**
* Add some extra data when rendering the sheet to reduce the amount of logic required within the template.
*/
getData() {
const sheetData = super.getData();
// Temporary HP
let hp = sheetData.data.attributes.hp;
if (hp.temp === 0) delete hp.temp;
if (hp.tempmax === 0) delete hp.tempmax;
// Resources
sheetData["resources"] = ["primary", "secondary", "tertiary"].reduce((arr, r) => {
const res = sheetData.data.resources[r] || {};
res.name = r;
res.placeholder = game.i18n.localize("SW5E.Resource"+r.titleCase());
if (res && res.value === 0) delete res.value;
if (res && res.max === 0) delete res.max;
return arr.concat([res]);
}, []);
// Experience Tracking
sheetData["disableExperience"] = game.settings.get("sw5e", "disableExperienceTracking");
// Return data for rendering
return sheetData;
}
/* -------------------------------------------- */
/**
* Organize and classify Owned Items for Character sheets
* @private
*/
_prepareItems(data) {
// Categorize items as inventory, powerbook, features, and classes
const inventory = {
weapon: { label: "SW5E.ItemTypeWeaponPl", items: [], dataset: {type: "weapon"} },
equipment: { label: "SW5E.ItemTypeEquipmentPl", items: [], dataset: {type: "equipment"} },
consumable: { label: "SW5E.ItemTypeConsumablePl", items: [], dataset: {type: "consumable"} },
tool: { label: "SW5E.ItemTypeToolPl", items: [], dataset: {type: "tool"} },
backpack: { label: "SW5E.ItemTypeContainerPl", items: [], dataset: {type: "backpack"} },
loot: { label: "SW5E.ItemTypeLootPl", items: [], dataset: {type: "loot"} }
};
// Partition items by category
let [items, powers, feats, classes] = data.items.reduce((arr, item) => {
// Item details
item.img = item.img || DEFAULT_TOKEN;
item.isStack = item.data.quantity ? item.data.quantity > 1 : false;
// Item usage
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));
// Item toggle state
this._prepareItemToggleState(item);
// Classify items into types
if ( item.type === "power" ) arr[1].push(item);
else if ( item.type === "feat" ) arr[2].push(item);
else if ( item.type === "class" ) arr[3].push(item);
else if ( Object.keys(inventory).includes(item.type ) ) arr[0].push(item);
return arr;
}, [[], [], [], []]);
// Apply active item filters
items = this._filterItems(items, this._filters.inventory);
powers = this._filterItems(powers, this._filters.powerbook);
feats = this._filterItems(feats, this._filters.features);
// Organize Powerbook and count the number of prepared powers (excluding always, at will, etc...)
const powerbook = this._preparePowerbook(data, powers);
const nPrepared = powers.filter(s => {
return (s.data.level > 0) && (s.data.preparation.mode === "prepared") && s.data.preparation.prepared;
}).length;
// Organize Inventory
let totalWeight = 0;
for ( let i of items ) {
i.data.quantity = i.data.quantity || 0;
i.data.weight = i.data.weight || 0;
i.totalWeight = Math.round(i.data.quantity * i.data.weight * 10) / 10;
inventory[i.type].items.push(i);
totalWeight += i.totalWeight;
}
data.data.attributes.encumbrance = this._computeEncumbrance(totalWeight, data);
// Organize Features
const features = {
classes: { label: "SW5E.ItemTypeClassPl", items: [], hasActions: false, dataset: {type: "class"}, isClass: true },
active: { label: "SW5E.FeatureActive", items: [], hasActions: true, dataset: {type: "feat", "activation.type": "action"} },
passive: { label: "SW5E.FeaturePassive", items: [], hasActions: false, dataset: {type: "feat"} }
};
for ( let f of feats ) {
if ( f.data.activation.type ) features.active.items.push(f);
else features.passive.items.push(f);
}
classes.sort((a, b) => b.levels - a.levels);
features.classes.items = classes;
// Assign and return
data.inventory = Object.values(inventory);
data.powerbook = powerbook;
data.preparedPowers = nPrepared;
data.features = Object.values(features);
}
/* -------------------------------------------- */
/**
* A helper method to establish the displayed preparation state for an item
* @param {Item} item
* @private
*/
_prepareItemToggleState(item) {
if (item.type === "power") {
const isAlways = getProperty(item.data, "preparation.mode") === "always";
const isPrepared = getProperty(item.data, "preparation.prepared");
item.toggleClass = isPrepared ? "active" : "";
if ( isAlways ) item.toggleClass = "fixed";
if ( isAlways ) item.toggleTitle = CONFIG.SW5E.powerPreparationModes.always;
else if ( isPrepared ) item.toggleTitle = CONFIG.SW5E.powerPreparationModes.prepared;
else item.toggleTitle = game.i18n.localize("SW5E.PowerUnprepared");
}
else {
const isActive = getProperty(item.data, "equipped");
item.toggleClass = isActive ? "active" : "";
item.toggleTitle = game.i18n.localize(isActive ? "SW5E.Equipped" : "SW5E.Unequipped");
}
}
/* -------------------------------------------- */
/**
* Compute the level and percentage of encumbrance for an Actor.
*
* Optionally include the weight of carried currency across all denominations by applying the standard rule
* from the PHB pg. 143
*
* @param {Number} totalWeight The cumulative item weight from inventory items
* @param {Object} actorData The data object for the Actor being rendered
* @return {Object} An object describing the character's encumbrance level
* @private
*/
_computeEncumbrance(totalWeight, actorData) {
// Encumbrance classes
let mod = {
tiny: 0.5,
sm: 1,
med: 1,
lg: 2,
huge: 4,
grg: 8
}[actorData.data.traits.size] || 1;
// Apply Powerful Build feat
if ( this.actor.getFlag("sw5e", "powerfulBuild") ) mod = Math.min(mod * 2, 8);
// Add Currency Weight
if ( game.settings.get("sw5e", "currencyWeight") ) {
const currency = actorData.data.currency;
const numCoins = Object.values(currency).reduce((val, denom) => val += denom, 0);
totalWeight += numCoins / CONFIG.SW5E.encumbrance.currencyPerWeight;
}
// Compute Encumbrance percentage
const enc = {
max: actorData.data.abilities.str.value * CONFIG.SW5E.encumbrance.strMultiplier * mod,
value: Math.round(totalWeight * 10) / 10,
};
enc.pct = Math.min(enc.value * 100 / enc.max, 99);
enc.encumbered = enc.pct > (2/3);
return enc;
}
/* -------------------------------------------- */
/* 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);
if ( !this.options.editable ) return;
// Inventory Functions
html.find(".currency-convert").click(this._onConvertCurrency.bind(this));
// Item State Toggling
html.find('.item-toggle').click(this._onToggleItem.bind(this));
// Short and Long Rest
html.find('.short-rest').click(this._onShortRest.bind(this));
html.find('.long-rest').click(this._onLongRest.bind(this));
// Death saving throws
html.find('.death-save').click(this._onDeathSave.bind(this));
}
/* -------------------------------------------- */
/**
* Handle rolling a death saving throw for the Character
* @param {MouseEvent} event The originating click event
* @private
*/
_onDeathSave(event) {
event.preventDefault();
return this.actor.rollDeathSave({event: event});
}
/* -------------------------------------------- */
/**
* Handle toggling the state of an Owned Item within the Actor
* @param {Event} event The triggering click event
* @private
*/
_onToggleItem(event) {
event.preventDefault();
const itemId = event.currentTarget.closest(".item").dataset.itemId;
const item = this.actor.getOwnedItem(itemId);
const attr = item.data.type === "power" ? "data.preparation.prepared" : "data.equipped";
return item.update({[attr]: !getProperty(item.data, attr)});
}
/* -------------------------------------------- */
/**
* Take a short rest, calling the relevant function on the Actor instance
* @param {Event} event The triggering click event
* @private
*/
async _onShortRest(event) {
event.preventDefault();
await this._onSubmit(event);
return this.actor.shortRest();
}
/* -------------------------------------------- */
/**
* Take a long rest, calling the relevant function on the Actor instance
* @param {Event} event The triggering click event
* @private
*/
async _onLongRest(event) {
event.preventDefault();
await this._onSubmit(event);
return this.actor.longRest();
}
/* -------------------------------------------- */
/**
* Handle mouse click events to convert currency to the highest possible denomination
* @param {MouseEvent} event The originating click event
* @private
*/
async _onConvertCurrency(event) {
event.preventDefault();
return Dialog.confirm({
title: `${game.i18n.localize("SW5E.CurrencyConvert")}`,
content: `<p>${game.i18n.localize("SW5E.CurrencyConvertHint")}</p>`,
yes: () => this.actor.convertCurrency()
});
}
}

155
module/actor/sheets/npc.js Normal file
View file

@ -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});
}
}