Merge branch 'master' into character-sheet-importer

This commit is contained in:
Mike Magarino 2021-01-24 16:34:34 -05:00
commit ea7a6e063a
161 changed files with 5350 additions and 3368 deletions

View file

@ -1,8 +1,6 @@
import { d20Roll, damageRoll } from "../dice.js";
import ShortRestDialog from "../apps/short-rest.js";
import LongRestDialog from "../apps/long-rest.js";
import AbilityUseDialog from "../apps/ability-use-dialog.js";
import AbilityTemplate from "../pixi/ability-template.js";
import {SW5E} from '../config.js';
/**
@ -94,8 +92,14 @@ export default class Actor5e extends Actor {
init.total = init.mod + init.prof + init.bonus;
// Prepare power-casting data
this._computePowercastingDC(this.data);
data.attributes.powerdc = data.attributes.powercasting ? data.abilities[data.attributes.powercasting].dc : 10;
this._computePowercastingProgression(this.data);
// Compute owned item attributes which depend on prepared Actor data
this.items.forEach(item => {
item.getSaveDC();
item.getAttackToHit();
});
}
/* -------------------------------------------- */
@ -163,14 +167,17 @@ export default class Actor5e extends Actor {
}
// Acquire archetype features
const subConfig = clsConfig.archetypes[archetypeName] || {};
for ( let [l, f] of Object.entries(subConfig.features || {}) ) {
const archConfig = clsConfig.archetypes[archetypeName] || {};
for ( let [l, f] of Object.entries(archConfig.features || {}) ) {
l = parseInt(l);
if ( (l <= level) && (l > priorLevel) ) ids = ids.concat(f);
}
// Load item data for all identified features
const features = await Promise.all(ids.map(id => fromUuid(id)));
const features = [];
for ( let id of ids ) {
features.push(await fromUuid(id));
}
// Class powers should always be prepared
for ( const feature of features ) {
@ -207,7 +214,7 @@ export default class Actor5e extends Actor {
const updateData = expandObject(u);
const config = {
className: updateData.name || item.data.name,
archetypeName: updateData.data.archetype || item.data.data.archetype,
archetypeName: getProperty(updateData, "data.archetype") || item.data.data.archetype,
level: getProperty(updateData, "data.levels"),
priorLevel: item ? item.data.data.levels : 0
}
@ -314,19 +321,22 @@ export default class Actor5e extends Actor {
const joat = flags.jackOfAllTrades;
const observant = flags.observantFeat;
const skillBonus = Number.isNumeric(bonuses.skill) ? parseInt(bonuses.skill) : 0;
let round = Math.floor;
for (let [id, skl] of Object.entries(data.skills)) {
skl.value = parseFloat(skl.value || 0);
skl.value = Math.clamped(Number(skl.value).toNearest(0.5), 0, 2) ?? 0;
let round = Math.floor;
// Apply Remarkable Athlete or Jack of all Trades
let multi = skl.value;
if ( athlete && (skl.value === 0) && feats.remarkableAthlete.abilities.includes(skl.ability) ) {
multi = 0.5;
// Remarkable
if ( athlete && (skl.value < 0.5) && feats.remarkableAthlete.abilities.includes(skl.ability) ) {
skl.value = 0.5;
round = Math.ceil;
}
if ( joat && (skl.value === 0 ) ) multi = 0.5;
// Retain the maximum skill proficiency when skill proficiencies are merged
// Jack of All Trades
if ( joat && (skl.value < 0.5) ) {
skl.value = 0.5;
}
// Polymorph Skill Proficiencies
if ( originalSkills ) {
skl.value = Math.max(skl.value, originalSkills[id].value);
}
@ -334,7 +344,7 @@ export default class Actor5e extends Actor {
// Compute modifier
skl.bonus = checkBonus + skillBonus;
skl.mod = data.abilities[skl.ability].mod;
skl.prof = round(multi * data.attributes.prof);
skl.prof = round(skl.value * data.attributes.prof);
skl.total = skl.mod + skl.prof + skl.bonus;
// Compute passive bonus
@ -345,31 +355,6 @@ export default class Actor5e extends Actor {
/* -------------------------------------------- */
/**
* Compute the powercasting DC for all item abilities which use power DC scaling
* @param {object} actorData The actor data being prepared
* @private
*/
_computePowercastingDC(actorData) {
// Compute the powercasting DC
const data = actorData.data;
data.attributes.powerdc = data.attributes.powercasting ? data.abilities[data.attributes.powercasting].dc : 10;
// Apply powercasting DC to any power items which use it
for ( let i of this.items ) {
const save = i.data.data.save;
if ( save?.ability ) {
if ( save.scaling === "power" ) save.dc = data.attributes.powerdc;
else if ( save.scaling !== "flat" ) save.dc = data.abilities[save.scaling]?.dc ?? 10;
const ability = CONFIG.SW5E.abilities[save.ability];
i.labels.save = game.i18n.format("SW5E.SaveDC", {dc: save.dc || "", ability});
}
}
}
/* -------------------------------------------- */
/**
* Prepare data related to the power-casting capabilities of the Actor
* @private
@ -418,7 +403,7 @@ export default class Actor5e extends Actor {
progression.slot = Math.ceil(caster.data.levels / denom);
}
// EXCEPTION: NPC with an explicit powercaster level
// EXCEPTION: NPC with an explicit power-caster level
if (isNPC && actorData.data.details.powerLevel) {
progression.slot = actorData.data.details.powerLevel;
}
@ -429,9 +414,9 @@ export default class Actor5e extends Actor {
for ( let [n, lvl] of Object.entries(powers) ) {
let i = parseInt(n.slice(-1));
if ( Number.isNaN(i) ) continue;
if ( Number.isNumeric(lvl.override) ) lvl.max = Math.max(parseInt(lvl.override), 1);
if ( Number.isNumeric(lvl.override) ) lvl.max = Math.max(parseInt(lvl.override), 0);
else lvl.max = slots[i-1] || 0;
lvl.value = Math.min(parseInt(lvl.value), lvl.max);
lvl.value = parseInt(lvl.value);
}
// Determine the Actor's pact magic level (if any)
@ -473,8 +458,8 @@ export default class Actor5e extends Actor {
return weight + (q * w);
}, 0);
// [Optional] add Currency Weight
if ( game.settings.get("sw5e", "currencyWeight") ) {
// [Optional] add Currency Weight (for non-transformed actors)
if ( game.settings.get("sw5e", "currencyWeight") && actorData.data.currency ) {
const currency = actorData.data.currency;
const numCoins = Object.values(currency).reduce((val, denom) => val += Math.max(denom, 0), 0);
weight += numCoins / CONFIG.SW5E.encumbrance.currencyPerWeight;
@ -546,20 +531,82 @@ export default class Actor5e extends Actor {
/* -------------------------------------------- */
/** @override */
async createOwnedItem(itemData, options) {
async createEmbeddedEntity(embeddedName, itemData, options={}) {
// Assume NPCs are always proficient with weapons and always have powers prepared
if ( !this.hasPlayerOwner ) {
let t = itemData.type;
let initial = {};
if ( t === "weapon" ) initial["data.proficient"] = true;
if ( ["weapon", "equipment"].includes(t) ) initial["data.equipped"] = true;
if ( t === "power" ) initial["data.prepared"] = true;
mergeObject(itemData, initial);
}
return super.createOwnedItem(itemData, options);
// Pre-creation steps for owned items
if ( embeddedName === "OwnedItem" ) this._preCreateOwnedItem(itemData, options);
// Standard embedded entity creation
return super.createEmbeddedEntity(embeddedName, itemData, options);
}
/* -------------------------------------------- */
/**
* A temporary shim function which will eventually (in core fvtt version 0.8.0+) be migrated to the new abstraction layer
* @param itemData
* @param options
* @private
*/
_preCreateOwnedItem(itemData, options) {
if ( this.data.type === "vehicle" ) return;
const isNPC = this.data.type === 'npc';
let initial = {};
switch ( itemData.type ) {
case "weapon":
if ( getProperty(itemData, "data.equipped") === undefined ) {
initial["data.equipped"] = isNPC; // NPCs automatically equip weapons
}
if ( getProperty(itemData, "data.proficient") === undefined ) {
if ( isNPC ) {
initial["data.proficient"] = true; // NPCs automatically have equipment proficiency
} else {
const weaponProf = {
"natural": true,
"simpleVW": "sim",
"simpleB": "sim",
"simpleLW": "sim",
"martialVW": "mar",
"martialB": "mar",
"martialLW": "mar"
}[itemData.data?.weaponType]; // Player characters check proficiency
const actorWeaponProfs = this.data.data.traits?.weaponProf?.value || [];
const hasWeaponProf = (weaponProf === true) || actorWeaponProfs.includes(weaponProf);
initial["data.proficient"] = hasWeaponProf;
}
}
break;
case "equipment":
if ( getProperty(itemData, "data.equipped") === undefined ) {
initial["data.equipped"] = isNPC; // NPCs automatically equip equipment
}
if ( getProperty(itemData, "data.proficient") === undefined ) {
if ( isNPC ) {
initial["data.proficient"] = true; // NPCs automatically have equipment proficiency
} else {
const armorProf = {
"natural": true,
"clothing": true,
"light": "lgt",
"medium": "med",
"heavy": "hvy",
"shield": "shl"
}[itemData.data?.armor?.type]; // Player characters check proficiency
const actorArmorProfs = this.data.data.traits?.armorProf?.value || [];
const hasEquipmentProf = (armorProf === true) || actorArmorProfs.includes(armorProf);
initial["data.proficient"] = hasEquipmentProf;
}
}
break;
case "power":
initial["data.prepared"] = true; // automatically prepare powers for everyone
break;
}
mergeObject(itemData, initial);
}
/* -------------------------------------------- */
/* Gameplay Mechanics */
@ -600,77 +647,16 @@ export default class Actor5e extends Actor {
"data.attributes.hp.temp": tmp - dt,
"data.attributes.hp.value": dh
};
return this.update(updates);
}
/* -------------------------------------------- */
/**
* Cast a Power, consuming a power slot of a certain level
* @param {Item5e} item The power being cast by the actor
* @param {Event} event The originating user interaction which triggered the cast
*/
async usePower(item, {configureDialog=true}={}) {
if ( item.data.type !== "power" ) throw new Error("Wrong Item type");
const itemData = item.data.data;
// Configure powercasting data
let lvl = itemData.level;
const usesSlots = (lvl > 0) && CONFIG.SW5E.powerUpcastModes.includes(itemData.preparation.mode);
const limitedUses = !!itemData.uses.per;
let consumeSlot = `power${lvl}`;
let consumeUse = false;
let placeTemplate = false;
// Configure power slot consumption and measured template placement from the form
if ( configureDialog && (usesSlots || item.hasAreaTarget || limitedUses) ) {
const usage = await AbilityUseDialog.create(item);
if ( usage === null ) return;
// Determine consumption preferences
consumeSlot = Boolean(usage.get("consumeSlot"));
consumeUse = Boolean(usage.get("consumeUse"));
placeTemplate = Boolean(usage.get("placeTemplate"));
// Determine the cast power level
const isPact = usage.get('level') === 'pact';
const lvl = isPact ? this.data.data.powers.pact.level : parseInt(usage.get("level"));
if ( lvl !== item.data.data.level ) {
const upcastData = mergeObject(item.data, {"data.level": lvl}, {inplace: false});
item = item.constructor.createOwned(upcastData, this);
}
// Denote the power slot being consumed
if ( consumeSlot ) consumeSlot = isPact ? "pact" : `power${lvl}`;
}
// Update Actor data
if ( usesSlots && consumeSlot && (lvl > 0) ) {
const slots = parseInt(this.data.data.powers[consumeSlot]?.value);
if ( slots === 0 || Number.isNaN(slots) ) {
return ui.notifications.error(game.i18n.localize("SW5E.PowerCastNoSlots"));
}
await this.update({
[`data.powers.${consumeSlot}.value`]: Math.max(slots - 1, 0)
});
}
// Update Item data
if ( limitedUses && consumeUse ) {
const uses = parseInt(itemData.uses.value || 0);
if ( uses <= 0 ) ui.notifications.warn(game.i18n.format("SW5E.ItemNoUses", {name: item.name}));
await item.update({"data.uses.value": Math.max(parseInt(item.data.data.uses.value || 0) - 1, 0)})
}
// Initiate ability template placement workflow if selected
if ( placeTemplate && item.hasAreaTarget ) {
const template = AbilityTemplate.fromItem(item);
if ( template ) template.drawPreview();
if ( this.sheet.rendered ) this.sheet.minimize();
}
// Invoke the Item roll
return item.roll();
// Delegate damage application to a hook
// TODO replace this in the future with a better modifyTokenAttribute function in the core
const allowed = Hooks.call("modifyTokenAttribute", {
attribute: "attributes.hp",
value: amount,
isDelta: false,
isBar: true
}, updates);
return allowed !== false ? this.update(updates) : this;
}
/* -------------------------------------------- */
@ -989,7 +975,7 @@ export default class Actor5e extends Actor {
// Adjust actor data
await cls.update({"data.hitDiceUsed": cls.data.data.hitDiceUsed + 1});
const hp = this.data.data.attributes.hp;
const dhp = Math.min(hp.max - hp.value, roll.total);
const dhp = Math.min(hp.max + (hp.tempmax ?? 0) - hp.value, roll.total);
await this.update({"data.attributes.hp.value": hp.value + dhp});
return roll;
}
@ -1130,8 +1116,7 @@ export default class Actor5e extends Actor {
// Recover power slots
for ( let [k, v] of Object.entries(data.powers) ) {
if ( !v.max && !v.override ) continue;
updateData[`data.powers.${k}.value`] = v.override || v.max;
updateData[`data.powers.${k}.value`] = Number.isNumeric(v.override) ? v.override : (v.max ?? 0);
}
// Recover pact slots.
@ -1238,10 +1223,10 @@ export default class Actor5e extends Actor {
}
// Get the original Actor data and the new source data
const o = duplicate(this.data);
const o = duplicate(this.toJSON());
o.flags.sw5e = o.flags.sw5e || {};
o.flags.sw5e.transformOptions = {mergeSkills, mergeSaves};
const source = duplicate(target.data);
const source = duplicate(target.toJSON());
// Prepare new data to merge from the source
const d = {
@ -1249,6 +1234,7 @@ export default class Actor5e extends Actor {
name: `${o.name} (${source.name})`, // Append the new shape to your old name
data: source.data, // Get the data model of your new form
items: source.items, // Get the items of your new form
effects: o.effects.concat(source.effects), // Combine active effects from both forms
token: source.token, // New token configuration
img: source.img, // New appearance
permission: o.permission, // Use the original actor permissions
@ -1271,7 +1257,7 @@ export default class Actor5e extends Actor {
// Handle wildcard
if ( source.token.randomImg ) {
const images = await target.getTokenImages();
d.token.img = images[0];
d.token.img = images[Math.floor(Math.random() * images.length)];
}
// Keep Token configurations
@ -1355,7 +1341,7 @@ export default class Actor5e extends Actor {
newTokenData.actorId = newActor.id;
return newTokenData;
});
return canvas.scene.updateEmbeddedEntity("Token", updates);
return canvas.scene?.updateEmbeddedEntity("Token", updates);
}
/* -------------------------------------------- */
@ -1437,4 +1423,18 @@ export default class Actor5e extends Actor {
console.warn(`The Actor5e#getPowerDC(ability) method has been deprecated in favor of Actor5e#data.data.abilities[ability].dc`);
return this.data.data.abilities[ability]?.dc;
}
/* -------------------------------------------- */
/**
* Cast a Power, consuming a power slot of a certain level
* @param {Item5e} item The power being cast by the actor
* @param {Event} event The originating user interaction which triggered the cast
* @deprecated since sw5e 1.2.0
*/
async usePower(item, {configureDialog=true}={}) {
console.warn(`The Actor5e#usePower method has been deprecated in favor of Item5e#roll`);
if ( item.data.type !== "power" ) throw new Error("Wrong Item type");
return item.roll();
}
}

View file

@ -0,0 +1,860 @@
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 .group-list",
".features .group-list",
".powerbook .group-list",
".effects .effects-list"
],
tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}]
});
}
/* -------------------------------------------- */
/** @override */
get template() {
if ( !game.user.isGM && this.actor.limited ) return "systems/sw5e/templates/actors/newActor/expanded-limited-sheet.html";
return `systems/sw5e/templates/actors/newActor/${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": "&infin;"
};
// 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: '<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] || 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: '<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, {
keepBio: true,
keepClass: true,
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);
}
/* -------------------------------------------- */
/** @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 = $(`<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: game.i18n.format("SW5E.ItemNew", {type: type.capitalize()}),
type: type,
data: duplicate(header.dataset)
};
delete itemData.data["type"];
return this.actor.createEmbeddedEntity("OwnedItem", 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 choices = CONFIG.SW5E[a.dataset.options];
const options = { name: a.dataset.target, title: label.innerText, choices };
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

@ -1,4 +1,4 @@
import ActorSheet5e from "../base.js";
import ActorSheet5e from "./base.js";
import Actor5e from "../../entity.js";
/**
@ -57,6 +57,9 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e {
// Experience Tracking
sheetData["disableExperience"] = game.settings.get("sw5e", "disableExperienceTracking");
sheetData["classLabels"] = this.actor.itemTypes.class.map(c => c.name).join(", ");
sheetData["multiclassLabels"] = this.actor.itemTypes.class.map(c => {
return [c.data.data.archetype, c.name, c.data.data.levels].filterJoin(' ')
}).join(', ');
// Return data for rendering
return sheetData;
@ -79,13 +82,25 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e {
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, species, archetypes, classfeatures, backgrounds, lightsaberforms] = data.items.reduce((arr, item) => {
let [items, powers, feats, classes, species, archetypes, classfeatures, backgrounds, fightingstyles, fightingmasteries, lightsaberforms] = data.items.reduce((arr, item) => {
// Item details
item.img = item.img || DEFAULT_TOKEN;
item.isStack = Number.isNumeric(item.data.quantity) && (item.data.quantity !== 1);
item.attunement = {
[CONFIG.SW5E.attunementTypes.REQUIRED]: {
icon: "fa-sun",
cls: "not-attuned",
title: "SW5E.AttunementRequired"
},
[CONFIG.SW5E.attunementTypes.ATTUNED]: {
icon: "fa-sun",
cls: "attuned",
title: "SW5E.AttunementAttuned"
}
}[item.data.attunement];
// Item usage
item.hasUses = item.data.uses && (item.data.uses.max > 0);
@ -104,10 +119,12 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e {
else if ( item.type === "archetype" ) arr[5].push(item);
else if ( item.type === "classfeature" ) arr[6].push(item);
else if ( item.type === "background" ) arr[7].push(item);
else if ( item.type === "lightsaberform" ) arr[8].push(item);
else if ( item.type === "fightingstyle" ) arr[8].push(item);
else if ( item.type === "fightingmastery" ) arr[9].push(item);
else if ( item.type === "lightsaberform" ) arr[10].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);
@ -131,11 +148,13 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e {
// Organize Features
const features = {
classes: { label: "SW5E.ItemTypeClassPl", items: [], hasActions: false, dataset: {type: "class"}, isClass: true },
classfeatures: { label: "SW5E.ItemTypeClassFeats", items: [], hasActions: false, dataset: {type: "classfeature"}, isClassfeature: true },
archetype: { label: "SW5E.ItemTypeArchetype", items: [], hasActions: false, dataset: {type: "archetype"}, isArchetype: true },
species: { label: "SW5E.ItemTypeSpecies", items: [], hasActions: false, dataset: {type: "species"}, isSpecies: true },
background: { label: "SW5E.ItemTypeBackground", items: [], hasActions: false, dataset: {type: "background"}, isBackground: true },
lightsaberform: { label: "SW5E.ItemTypeLightsaberForm", items: [], hasActions: false, dataset: {type: "lightsaberform"}, isLightsaberform: true },
classfeatures: { label: "SW5E.ItemTypeClassFeats", items: [], hasActions: true, dataset: {type: "classfeature"}, isClassfeature: true },
archetype: { label: "SW5E.ItemTypeArchetype", items: [], hasActions: false, dataset: {type: "archetype"}, isArchetype: true },
species: { label: "SW5E.ItemTypeSpecies", items: [], hasActions: false, dataset: {type: "species"}, isSpecies: true },
background: { label: "SW5E.ItemTypeBackground", items: [], hasActions: false, dataset: {type: "background"}, isBackground: true },
fightingstyles: { label: "SW5E.ItemTypeFightingStylePl", items: [], hasActions: false, dataset: {type: "fightingstyle"}, isFightingstyle: true },
fightingmasteries: { label: "SW5E.ItemTypeFightingMasteryPl", items: [], hasActions: false, dataset: {type: "fightingmastery"}, isFightingmastery: true },
lightsaberforms: { label: "SW5E.ItemTypeLightsaberFormPl", items: [], hasActions: false, dataset: {type: "lightsaberform"}, isLightsaberform: true },
active: { label: "SW5E.FeatureActive", items: [], hasActions: true, dataset: {type: "feat", "activation.type": "action"} },
passive: { label: "SW5E.FeaturePassive", items: [], hasActions: false, dataset: {type: "feat"} }
};
@ -145,11 +164,13 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e {
}
classes.sort((a, b) => b.levels - a.levels);
features.classes.items = classes;
features.classfeatures.items = classfeatures;
features.archetype.items = archetypes;
features.species.items = species;
features.background.items = backgrounds;
features.lightsaberform.items = lightsaberforms;
features.classfeatures.items = classfeatures;
features.archetype.items = archetypes;
features.species.items = species;
features.background.items = backgrounds;
features.fightingstyles.items = fightingstyles;
features.fightingmasteries.items = fightingmasteries;
features.lightsaberforms.items = lightsaberforms;
// Assign and return
data.inventory = Object.values(inventory);
@ -195,7 +216,7 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e {
if ( !this.options.editable ) return;
// Inventory Functions
html.find(".currency-convert").click(this._onConvertCurrency.bind(this));
// html.find(".currency-convert").click(this._onConvertCurrency.bind(this));
// Item State Toggling
html.find('.item-toggle').click(this._onToggleItem.bind(this));
@ -204,8 +225,8 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e {
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));
// Rollable sheet actions
html.find(".rollable[data-action]").click(this._onSheetAction.bind(this));
// Send Languages to Chat onClick
html.find('[data-options="share-languages"]').click(event => {
@ -271,13 +292,19 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e {
/* -------------------------------------------- */
/**
* Handle rolling a death saving throw for the Character
* Handle mouse click events for character sheet actions
* @param {MouseEvent} event The originating click event
* @private
*/
_onDeathSave(event) {
_onSheetAction(event) {
event.preventDefault();
return this.actor.rollDeathSave({event: event});
const button = event.currentTarget;
switch( button.dataset.action ) {
case "rollDeathSave":
return this.actor.rollDeathSave({event: event});
case "rollInitiative":
return this.actor.rollInitiative({createCombatants: true});
}
}
/* -------------------------------------------- */
@ -324,57 +351,26 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e {
/* -------------------------------------------- */
/**
* 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()
});
}
/* -------------------------------------------- */
/** @override */
async _onDropItemCreate(itemData) {
// Upgrade the number of class levels a character has and add features
// Increment the number of class levels a character instead of creating a new item
if ( itemData.type === "class" ) {
const cls = this.actor.itemTypes.class.find(c => c.name === itemData.name);
const classWasAlreadyPresent = !!cls;
// Add new features for class level
if ( !classWasAlreadyPresent ) {
Actor5e.getClassFeatures(itemData).then(features => {
this.actor.createEmbeddedEntity("OwnedItem", features);
});
}
// If the actor already has the class, increment the level instead of creating a new item
// then add new features as long as level increases
if ( classWasAlreadyPresent ) {
const lvl = cls.data.data.levels;
const newLvl = Math.min(lvl + 1, 20 + lvl - this.actor.data.data.details.level);
if ( !(lvl === newLvl) ) {
cls.update({"data.levels": newLvl});
itemData.data.levels = newLvl;
Actor5e.getClassFeatures(itemData).then(features => {
this.actor.createEmbeddedEntity("OwnedItem", features);
});
let priorLevel = cls?.data.data.levels ?? 0;
if ( !!cls ) {
const next = Math.min(priorLevel + 1, 20 + priorLevel - this.actor.data.data.details.level);
if ( next > priorLevel ) {
itemData.levels = next;
return cls.update({"data.levels": next});
}
return
}
}
// Default drop handling if levels were not added
super._onDropItemCreate(itemData);
}
}
async function addFavorites(app, html, data) {
// Thisfunction is adapted for the SwaltSheet from the Favorites Item
// Tab Module created for Foundry VTT - by Felix Müller (Felix#6196 on Discord).

View file

@ -1,4 +1,4 @@
import ActorSheet5e from "../base.js";
import ActorSheet5e from "./base.js";
/**
* An Actor sheet for NPC type characters in the SW5E system.
@ -16,7 +16,6 @@ export default class ActorSheet5eNPCNew extends ActorSheet5e {
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
classes: ["sw5e", "sheet", "actor", "npc"],
width: 600,
width: 800,
tabs: [{
navSelector: ".root-tabs",
@ -116,7 +115,7 @@ export default class ActorSheet5eNPCNew extends ActorSheet5e {
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find(".health .rollable").click(this._onRollHealthFormula.bind(this));
html.find(".health .rollable").click(this._onRollHPFormula.bind(this));
}
/* -------------------------------------------- */
@ -126,7 +125,7 @@ export default class ActorSheet5eNPCNew extends ActorSheet5e {
* @param {Event} event The original click event
* @private
*/
_onRollHealthFormula(event) {
_onRollHPFormula(event) {
event.preventDefault();
const formula = this.actor.data.data.attributes.hp.formula;
if ( !formula ) return;
@ -135,3 +134,4 @@ export default class ActorSheet5eNPCNew extends ActorSheet5e {
this.actor.update({"data.attributes.hp.value": hp, "data.attributes.hp.max": hp});
}
}

View file

@ -0,0 +1,385 @@
import ActorSheet5e from "./base.js";
/**
* An Actor sheet for Vehicle type actors.
* Extends the base ActorSheet5e class.
* @type {ActorSheet5e}
*/
export default class ActorSheet5eVehicle extends ActorSheet5e {
/**
* Define default rendering options for the Vehicle sheet.
* @returns {Object}
*/
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
classes: ["sw5e", "sheet", "actor", "vehicle"],
width: 605,
height: 680
});
}
/* -------------------------------------------- */
/**
* Creates a new cargo entry for a vehicle Actor.
*/
static get newCargo() {
return {
name: '',
quantity: 1
};
}
/* -------------------------------------------- */
/**
* Compute the total weight of the vehicle's cargo.
* @param {Number} totalWeight The cumulative item weight from inventory items
* @param {Object} actorData The data object for the Actor being rendered
* @returns {{max: number, value: number, pct: number}}
* @private
*/
_computeEncumbrance(totalWeight, actorData) {
// Compute currency weight
const totalCoins = Object.values(actorData.data.currency).reduce((acc, denom) => acc + denom, 0);
totalWeight += totalCoins / CONFIG.SW5E.encumbrance.currencyPerWeight;
// Vehicle weights are an order of magnitude greater.
totalWeight /= CONFIG.SW5E.encumbrance.vehicleWeightMultiplier;
// Compute overall encumbrance
const max = actorData.data.attributes.capacity.cargo;
const pct = Math.clamped((totalWeight * 100) / max, 0, 100);
return {value: totalWeight.toNearest(0.1), max, pct};
}
/* -------------------------------------------- */
/** @override */
_getMovementSpeed(actorData, largestPrimary=true) {
return super._getMovementSpeed(actorData, largestPrimary);
}
/* -------------------------------------------- */
/**
* Prepare items that are mounted to a vehicle and require one or more crew
* to operate.
* @private
*/
_prepareCrewedItem(item) {
// Determine crewed status
const isCrewed = item.data.crewed;
item.toggleClass = isCrewed ? 'active' : '';
item.toggleTitle = game.i18n.localize(`SW5E.${isCrewed ? 'Crewed' : 'Uncrewed'}`);
// Handle crew actions
if (item.type === 'feat' && item.data.activation.type === 'crew') {
item.crew = item.data.activation.cost;
item.cover = game.i18n.localize(`SW5E.${item.data.cover ? 'CoverTotal' : 'None'}`);
if (item.data.cover === .5) item.cover = '½';
else if (item.data.cover === .75) item.cover = '¾';
else if (item.data.cover === null) item.cover = '—';
if (item.crew < 1 || item.crew === null) item.crew = '—';
}
// Prepare vehicle weapons
if (item.type === 'equipment' || item.type === 'weapon') {
item.threshold = item.data.hp.dt ? item.data.hp.dt : '—';
}
}
/* -------------------------------------------- */
/**
* Organize Owned Items for rendering the Vehicle sheet.
* @private
*/
_prepareItems(data) {
const cargoColumns = [{
label: game.i18n.localize('SW5E.Quantity'),
css: 'item-qty',
property: 'quantity',
editable: 'Number'
}];
const equipmentColumns = [{
label: game.i18n.localize('SW5E.Quantity'),
css: 'item-qty',
property: 'data.quantity'
}, {
label: game.i18n.localize('SW5E.AC'),
css: 'item-ac',
property: 'data.armor.value'
}, {
label: game.i18n.localize('SW5E.HP'),
css: 'item-hp',
property: 'data.hp.value',
editable: 'Number'
}, {
label: game.i18n.localize('SW5E.Threshold'),
css: 'item-threshold',
property: 'threshold'
}];
const features = {
actions: {
label: game.i18n.localize('SW5E.ActionPl'),
items: [],
crewable: true,
dataset: {type: 'feat', 'activation.type': 'crew'},
columns: [{
label: game.i18n.localize('SW5E.VehicleCrew'),
css: 'item-crew',
property: 'crew'
}, {
label: game.i18n.localize('SW5E.Cover'),
css: 'item-cover',
property: 'cover'
}]
},
equipment: {
label: game.i18n.localize('SW5E.ItemTypeEquipment'),
items: [],
crewable: true,
dataset: {type: 'equipment', 'armor.type': 'vehicle'},
columns: equipmentColumns
},
passive: {
label: game.i18n.localize('SW5E.Features'),
items: [],
dataset: {type: 'feat'}
},
reactions: {
label: game.i18n.localize('SW5E.ReactionPl'),
items: [],
dataset: {type: 'feat', 'activation.type': 'reaction'}
},
weapons: {
label: game.i18n.localize('SW5E.ItemTypeWeaponPl'),
items: [],
crewable: true,
dataset: {type: 'weapon', 'weapon-type': 'siege'},
columns: equipmentColumns
}
};
const cargo = {
crew: {
label: game.i18n.localize('SW5E.VehicleCrew'),
items: data.data.cargo.crew,
css: 'cargo-row crew',
editableName: true,
dataset: {type: 'crew'},
columns: cargoColumns
},
passengers: {
label: game.i18n.localize('SW5E.VehiclePassengers'),
items: data.data.cargo.passengers,
css: 'cargo-row passengers',
editableName: true,
dataset: {type: 'passengers'},
columns: cargoColumns
},
cargo: {
label: game.i18n.localize('SW5E.VehicleCargo'),
items: [],
dataset: {type: 'loot'},
columns: [{
label: game.i18n.localize('SW5E.Quantity'),
css: 'item-qty',
property: 'data.quantity',
editable: 'Number'
}, {
label: game.i18n.localize('SW5E.Price'),
css: 'item-price',
property: 'data.price',
editable: 'Number'
}, {
label: game.i18n.localize('SW5E.Weight'),
css: 'item-weight',
property: 'data.weight',
editable: 'Number'
}]
}
};
let totalWeight = 0;
for (const item of data.items) {
this._prepareCrewedItem(item);
if (item.type === 'weapon') features.weapons.items.push(item);
else if (item.type === 'equipment') features.equipment.items.push(item);
else if (item.type === 'loot') {
totalWeight += (item.data.weight || 0) * item.data.quantity;
cargo.cargo.items.push(item);
}
else if (item.type === 'feat') {
if (!item.data.activation.type || item.data.activation.type === 'none') {
features.passive.items.push(item);
}
else if (item.data.activation.type === 'reaction') features.reactions.items.push(item);
else features.actions.items.push(item);
}
}
data.features = Object.values(features);
data.cargo = Object.values(cargo);
data.data.attributes.encumbrance = this._computeEncumbrance(totalWeight, data);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
if (!this.options.editable) return;
html.find('.item-toggle').click(this._onToggleItem.bind(this));
html.find('.item-hp input')
.click(evt => evt.target.select())
.change(this._onHPChange.bind(this));
html.find('.item:not(.cargo-row) input[data-property]')
.click(evt => evt.target.select())
.change(this._onEditInSheet.bind(this));
html.find('.cargo-row input')
.click(evt => evt.target.select())
.change(this._onCargoRowChange.bind(this));
if (this.actor.data.data.attributes.actions.stations) {
html.find('.counter.actions, .counter.action-thresholds').hide();
}
}
/* -------------------------------------------- */
/**
* Handle saving a cargo row (i.e. crew or passenger) in-sheet.
* @param event {Event}
* @returns {Promise<Actor>|null}
* @private
*/
_onCargoRowChange(event) {
event.preventDefault();
const target = event.currentTarget;
const row = target.closest('.item');
const idx = Number(row.dataset.itemId);
const property = row.classList.contains('crew') ? 'crew' : 'passengers';
// Get the cargo entry
const cargo = duplicate(this.actor.data.data.cargo[property]);
const entry = cargo[idx];
if (!entry) return null;
// Update the cargo value
const key = target.dataset.property || 'name';
const type = target.dataset.dtype;
let value = target.value;
if (type === 'Number') value = Number(value);
entry[key] = value;
// Perform the Actor update
return this.actor.update({[`data.cargo.${property}`]: cargo});
}
/* -------------------------------------------- */
/**
* Handle editing certain values like quantity, price, and weight in-sheet.
* @param event {Event}
* @returns {Promise<Item>}
* @private
*/
_onEditInSheet(event) {
event.preventDefault();
const itemID = event.currentTarget.closest('.item').dataset.itemId;
const item = this.actor.items.get(itemID);
const property = event.currentTarget.dataset.property;
const type = event.currentTarget.dataset.dtype;
let value = event.currentTarget.value;
switch (type) {
case 'Number': value = parseInt(value); break;
case 'Boolean': value = value === 'true'; break;
}
return item.update({[`${property}`]: value});
}
/* -------------------------------------------- */
/**
* Handle creating a new crew or passenger row.
* @param event {Event}
* @returns {Promise<Actor|Item>}
* @private
*/
_onItemCreate(event) {
event.preventDefault();
const target = event.currentTarget;
const type = target.dataset.type;
if (type === 'crew' || type === 'passengers') {
const cargo = duplicate(this.actor.data.data.cargo[type]);
cargo.push(this.constructor.newCargo);
return this.actor.update({[`data.cargo.${type}`]: cargo});
}
return super._onItemCreate(event);
}
/* -------------------------------------------- */
/**
* Handle deleting a crew or passenger row.
* @param event {Event}
* @returns {Promise<Actor|Item>}
* @private
*/
_onItemDelete(event) {
event.preventDefault();
const row = event.currentTarget.closest('.item');
if (row.classList.contains('cargo-row')) {
const idx = Number(row.dataset.itemId);
const type = row.classList.contains('crew') ? 'crew' : 'passengers';
const cargo = duplicate(this.actor.data.data.cargo[type]).filter((_, i) => i !== idx);
return this.actor.update({[`data.cargo.${type}`]: cargo});
}
return super._onItemDelete(event);
}
/* -------------------------------------------- */
/**
* Special handling for editing HP to clamp it within appropriate range.
* @param event {Event}
* @returns {Promise<Item>}
* @private
*/
_onHPChange(event) {
event.preventDefault();
const itemID = event.currentTarget.closest('.item').dataset.itemId;
const item = this.actor.items.get(itemID);
const hp = Math.clamped(0, parseInt(event.currentTarget.value), item.data.data.hp.max);
event.currentTarget.value = hp;
return item.update({'data.hp.value': hp});
}
/* -------------------------------------------- */
/**
* Handle toggling an item's crewed status.
* @param event {Event}
* @returns {Promise<Item>}
* @private
*/
_onToggleItem(event) {
event.preventDefault();
const itemID = event.currentTarget.closest('.item').dataset.itemId;
const item = this.actor.items.get(itemID);
const crewed = !!item.data.data.crewed;
return item.update({'data.crewed': !crewed});
}
};

View file

@ -1,9 +1,10 @@
import Item5e from "../../item/entity.js";
import TraitSelector from "../../apps/trait-selector.js";
import ActorSheetFlags from "../../apps/actor-flags.js";
import MovementConfig from "../../apps/movement-config.js";
import {SW5E} from '../../config.js';
import {onManageActiveEffect, prepareActiveEffectCategories} from "../../effects.js";
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.
@ -99,6 +100,9 @@ export default class ActorSheet5e extends ActorSheet {
// Movement speeds
data.movement = this._getMovementSpeed(data.actor);
// Senses
data.senses = this._getSenses(data.actor);
// Update traits
this._prepareTraits(data.actor.data.traits);
@ -115,23 +119,59 @@ export default class ActorSheet5e extends ActorSheet {
/* -------------------------------------------- */
/**
* Prepare the display of movement speed data for the Actor
* @param {object} actorData
* 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) {
const movement = actorData.data.attributes.movement;
const speeds = [
_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}`]
].filter(s => !!s[0]).sort((a, b) => b[0] - a[0]);
return {
primary: `${movement.walk || 0} ${movement.units}`,
special: speeds.length ? speeds.map(s => s[1]).join(", ") : ""
]
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;
}
/* -------------------------------------------- */
@ -334,7 +374,7 @@ export default class ActorSheet5e extends ActorSheet {
1: '<i class="fas fa-check"></i>',
2: '<i class="fas fa-check-double"></i>'
};
return icons[level];
return icons[level] || icons[0];
}
/* -------------------------------------------- */
@ -373,8 +413,7 @@ export default class ActorSheet5e extends ActorSheet {
html.find('.trait-selector').click(this._onTraitSelector.bind(this));
// Configure Special Flags
html.find('.configure-movement').click(this._onMovementConfig.bind(this));
html.find('.configure-flags').click(this._onConfigureFlags.bind(this));
html.find('.config-button').click(this._onConfigMenu.bind(this));
// Owned Item management
html.find('.item-create').click(this._onItemCreate.bind(this));
@ -448,11 +487,24 @@ export default class ActorSheet5e extends ActorSheet {
/* -------------------------------------------- */
/**
* Handle click events for the Traits tab button to configure special Character Flags
* Handle spawning the TraitSelector application which allows a checkbox of multiple trait options
* @param {Event} event The click event which originated the selection
* @private
*/
_onConfigureFlags(event) {
_onConfigMenu(event) {
event.preventDefault();
new ActorSheetFlags(this.actor).render(true);
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;
}
}
/* -------------------------------------------- */
@ -529,6 +581,8 @@ export default class ActorSheet5e extends ActorSheet {
icon: '<i class="fas fa-paw"></i>',
label: game.i18n.localize('SW5E.PolymorphWildShape'),
callback: html => this.actor.transformInto(sourceActor, {
keepBio: true,
keepClass: true,
keepMental: true,
mergeSaves: true,
mergeSkills: true,
@ -565,6 +619,11 @@ export default class ActorSheet5e extends ActorSheet {
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);
}
@ -619,14 +678,7 @@ export default class ActorSheet5e extends ActorSheet {
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();
return item.roll();
}
/* -------------------------------------------- */
@ -687,7 +739,7 @@ export default class ActorSheet5e extends ActorSheet {
data: duplicate(header.dataset)
};
delete itemData.data["type"];
return this.actor.createOwnedItem(itemData);
return this.actor.createEmbeddedEntity("OwnedItem", itemData);
}
/* -------------------------------------------- */
@ -791,18 +843,6 @@ export default class ActorSheet5e extends ActorSheet {
/* -------------------------------------------- */
/**
* Handle spawning the TraitSelector application which allows a checkbox of multiple trait options
* @param {Event} event The click event which originated the selection
* @private
*/
_onMovementConfig(event) {
event.preventDefault();
new MovementConfig(this.object).render(true);
}
/* -------------------------------------------- */
/** @override */
_getHeaderButtons() {
let buttons = super._getHeaderButtons();

View file

@ -1,4 +1,4 @@
import ActorSheet5e from "../base.js";
import ActorSheet5e from "./base.js";
import Actor5e from "../../entity.js";
/**
@ -46,6 +46,9 @@ export default class ActorSheet5eCharacter extends ActorSheet5e {
// Experience Tracking
sheetData["disableExperience"] = game.settings.get("sw5e", "disableExperienceTracking");
sheetData["classLabels"] = this.actor.itemTypes.class.map(c => c.name).join(", ");
sheetData["multiclassLabels"] = this.actor.itemTypes.class.map(c => {
return [c.data.data.archetype, c.name, c.data.data.levels].filterJoin(' ')
}).join(', ');
// Return data for rendering
return sheetData;
@ -75,6 +78,18 @@ export default class ActorSheet5eCharacter extends ActorSheet5e {
// Item details
item.img = item.img || DEFAULT_TOKEN;
item.isStack = Number.isNumeric(item.data.quantity) && (item.data.quantity !== 1);
item.attunement = {
[CONFIG.SW5E.attunementTypes.REQUIRED]: {
icon: "fa-sun",
cls: "not-attuned",
title: "SW5E.AttunementRequired"
},
[CONFIG.SW5E.attunementTypes.ATTUNED]: {
icon: "fa-sun",
cls: "attuned",
title: "SW5E.AttunementAttuned"
}
}[item.data.attunement];
// Item usage
item.hasUses = item.data.uses && (item.data.uses.max > 0);
@ -122,7 +137,7 @@ export default class ActorSheet5eCharacter extends ActorSheet5e {
// Organize Features
const features = {
classes: { label: "SW5E.ItemTypeClassPl", items: [], hasActions: false, dataset: {type: "class"}, isClass: true },
classfeatures: { label: "SW5E.ItemTypeClassFeats", items: [], hasActions: false, dataset: {type: "classfeature"}, isClassfeature: true },
classfeatures: { label: "SW5E.ItemTypeClassFeats", items: [], hasActions: true, dataset: {type: "classfeature"}, isClassfeature: true },
archetype: { label: "SW5E.ItemTypeArchetype", items: [], hasActions: false, dataset: {type: "archetype"}, isArchetype: true },
species: { label: "SW5E.ItemTypeSpecies", items: [], hasActions: false, dataset: {type: "species"}, isSpecies: true },
background: { label: "SW5E.ItemTypeBackground", items: [], hasActions: false, dataset: {type: "background"}, isBackground: true },
@ -203,7 +218,7 @@ export default class ActorSheet5eCharacter extends ActorSheet5e {
/* -------------------------------------------- */
/**
* Handle rolling a death saving throw for the Character
* Handle mouse click events for character sheet actions
* @param {MouseEvent} event The originating click event
* @private
*/
@ -263,37 +278,21 @@ export default class ActorSheet5eCharacter extends ActorSheet5e {
/** @override */
async _onDropItemCreate(itemData) {
let addLevel = false;
// Upgrade the number of class levels a character has and add features
// Increment the number of class levels a character instead of creating a new item
if ( itemData.type === "class" ) {
const cls = this.actor.itemTypes.class.find(c => c.name === itemData.name);
let priorLevel = cls?.data.data.levels ?? 0;
const hasClass = !!cls;
// Increment levels instead of creating a new item
if ( hasClass ) {
if ( !!cls ) {
const next = Math.min(priorLevel + 1, 20 + priorLevel - this.actor.data.data.details.level);
if ( next > priorLevel ) {
itemData.levels = next;
await cls.update({"data.levels": next});
addLevel = true;
return cls.update({"data.levels": next});
}
}
// Add class features
if ( !hasClass || addLevel ) {
const features = await Actor5e.getClassFeatures({
className: itemData.name,
archetypeName: itemData.data.archetype,
level: itemData.levels,
priorLevel: priorLevel
});
await this.actor.createEmbeddedEntity("OwnedItem", features);
}
}
// Default drop handling if levels were not added
if ( !addLevel ) super._onDropItemCreate(itemData);
super._onDropItemCreate(itemData);
}
}
}

View file

@ -1,4 +1,4 @@
import ActorSheet5e from "../base.js";
import ActorSheet5e from "./base.js";
/**
* An Actor sheet for NPC type characters in the SW5E system.

View file

@ -1,4 +1,4 @@
import ActorSheet5e from "../base.js";
import ActorSheet5e from "./base.js";
/**
* An Actor sheet for Vehicle type actors.
@ -56,6 +56,13 @@ export default class ActorSheet5eVehicle extends ActorSheet5e {
/* -------------------------------------------- */
/** @override */
_getMovementSpeed(actorData, largestPrimary=true) {
return super._getMovementSpeed(actorData, largestPrimary);
}
/* -------------------------------------------- */
/**
* Prepare items that are mounted to a vehicle and require one or more crew
* to operate.
@ -86,13 +93,6 @@ export default class ActorSheet5eVehicle extends ActorSheet5e {
/* -------------------------------------------- */
/** @override */
_getMovementSpeed(actorData) {
return {primary: "", special: ""};
}
/* -------------------------------------------- */
/**
* Organize Owned Items for rendering the Vehicle sheet.
* @private

View file

@ -34,15 +34,19 @@ export default class AbilityUseDialog extends Dialog {
const quantity = itemData.quantity || 0;
const recharge = itemData.recharge || {};
const recharges = !!recharge.value;
const sufficientUses = (quantity > 0 && !uses.value) || uses.value > 0;
// Prepare dialog form data
const data = {
item: item.data,
title: game.i18n.format("SW5E.AbilityUseHint", item.data),
note: this._getAbilityUseNote(item.data, uses, recharge),
hasLimitedUses: uses.max || recharges,
canUse: recharges ? recharge.charged : (quantity > 0 && !uses.value) || uses.value > 0,
hasPlaceableTemplate: game.user.can("TEMPLATE_CREATE") && item.hasAreaTarget,
consumePowerSlot: false,
consumeRecharge: recharges,
consumeResource: !!itemData.consume.target,
consumeUses: uses.max,
canUse: recharges ? recharge.charged : sufficientUses,
createTemplate: game.user.can("TEMPLATE_CREATE") && item.hasAreaTarget,
errors: []
};
if ( item.data.type === "power" ) this._getPowerData(actorData, itemData, data);
@ -50,7 +54,7 @@ export default class AbilityUseDialog extends Dialog {
// Render the ability usage template
const html = await renderTemplate("systems/sw5e/templates/apps/ability-use.html", data);
// Create the Dialog and return as a Promise
// Create the Dialog and return data as a Promise
const icon = data.isPower ? "fa-magic" : "fa-fist-raised";
const label = game.i18n.localize("SW5E.AbilityUse" + (data.isPower ? "Cast" : "Use"));
return new Promise((resolve) => {
@ -61,7 +65,10 @@ export default class AbilityUseDialog extends Dialog {
use: {
icon: `<i class="fas ${icon}"></i>`,
label: label,
callback: html => resolve(new FormData(html[0].querySelector("form")))
callback: html => {
const fd = new FormDataExtended(html[0].querySelector("form"));
resolve(fd.toObject());
}
}
},
default: "use",
@ -83,11 +90,11 @@ export default class AbilityUseDialog extends Dialog {
// Determine whether the power may be up-cast
const lvl = itemData.level;
const canUpcast = (lvl > 0) && CONFIG.SW5E.powerUpcastModes.includes(itemData.preparation.mode);
const consumePowerSlot = (lvl > 0) && CONFIG.SW5E.powerUpcastModes.includes(itemData.preparation.mode);
// If can't upcast, return early and don't bother calculating available power slots
if (!canUpcast) {
data = mergeObject(data, { isPower: true, canUpcast });
if (!consumePowerSlot) {
mergeObject(data, { isPower: true, consumePowerSlot });
return;
}
@ -120,10 +127,13 @@ export default class AbilityUseDialog extends Dialog {
});
}
const canCast = powerLevels.some(l => l.hasSlots);
if ( !canCast ) data.errors.push(game.i18n.format("SW5E.PowerCastNoSlots", {
level: CONFIG.SW5E.powerLevels[lvl],
name: data.item.name
}));
// Return merged data
data = mergeObject(data, { isPower: true, canUpcast, powerLevels });
if ( !canCast ) data.errors.push("SW5E.PowerCastNoSlots");
// Merge power casting data
return mergeObject(data, { isPower: true, consumePowerSlot, powerLevels });
}
/* -------------------------------------------- */
@ -158,6 +168,8 @@ export default class AbilityUseDialog extends Dialog {
type: item.data.consumableType,
value: uses.value,
quantity: item.data.quantity,
max: uses.max,
per: CONFIG.SW5E.limitedUsePeriods[uses.per]
});
}

View file

@ -2,21 +2,27 @@
* A simple form to set actor movement speeds
* @implements {BaseEntitySheet}
*/
export default class MovementConfig extends BaseEntitySheet {
export default class ActorMovementConfig extends BaseEntitySheet {
/** @override */
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
title: "SW5E.MovementConfig",
classes: ["sw5e"],
template: "systems/sw5e/templates/apps/movement-config.html",
width: 240,
width: 300,
height: "auto"
});
}
/* -------------------------------------------- */
/** @override */
get title() {
return `${game.i18n.localize("SW5E.MovementConfig")}: ${this.entity.name}`;
}
/* -------------------------------------------- */
/** @override */
getData(options) {
const data = {

View file

@ -0,0 +1,43 @@
/**
* A simple form to set actor movement speeds
* @implements {BaseEntitySheet}
*/
export default class ActorSensesConfig extends BaseEntitySheet {
/** @override */
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
classes: ["sw5e"],
template: "systems/sw5e/templates/apps/senses-config.html",
width: 300,
height: "auto"
});
}
/* -------------------------------------------- */
/** @override */
get title() {
return `${game.i18n.localize("SW5E.SensesConfig")}: ${this.entity.name}`;
}
/* -------------------------------------------- */
/** @override */
getData(options) {
const senses = this.entity._data.data.attributes?.senses ?? {};
const data = {
senses: {},
special: senses.special ?? "",
units: senses.units, movementUnits: CONFIG.SW5E.movementUnits
};
for ( let [name, label] of Object.entries(CONFIG.SW5E.senses) ) {
const v = senses[name];
data.senses[name] = {
label: game.i18n.localize(label),
value: Number.isNumeric(v) ? v.toNearest(0.1) : 0
}
}
return data;
}
}

View file

@ -36,8 +36,8 @@ export default class TraitSelector extends FormApplication {
getData() {
// Get current values
let attr = getProperty(this.object._data, this.attribute) || {};
attr.value = attr.value || [];
let attr = getProperty(this.object._data, this.attribute);
if ( getType(attr) !== "Object" ) attr = {value: [], custom: ""};
// Populate choices
const choices = duplicate(this.options.choices);
@ -49,7 +49,7 @@ export default class TraitSelector extends FormApplication {
}
// Return data
return {
return {
allowCustom: this.options.allowCustom,
choices: choices,
custom: attr ? attr.custom : ""

View file

@ -66,7 +66,7 @@ export const displayChatActionButtons = function(message, html, data) {
export const addChatMessageContextOptions = function(html, options) {
let canApply = li => {
const message = game.messages.get(li.data("messageId"));
return message.isRoll && message.isContentVisible && canvas.tokens.controlled.length;
return message?.isRoll && message?.isContentVisible && canvas?.tokens.controlled.length;
};
options.push(
{
@ -103,15 +103,16 @@ export const addChatMessageContextOptions = function(html, options) {
* Apply rolled dice damage to the token or tokens which are currently controlled.
* This allows for damage to be scaled by a multiplier to account for healing, critical hits, or resistance
*
* @param {HTMLElement} roll The chat entry which contains the roll data
* @param {HTMLElement} li The chat entry which contains the roll data
* @param {Number} multiplier A damage multiplier to apply to the rolled damage.
* @return {Promise}
*/
function applyChatCardDamage(roll, multiplier) {
const amount = roll.find('.dice-total').text();
function applyChatCardDamage(li, multiplier) {
const message = game.messages.get(li.data("messageId"));
const roll = message.roll;
return Promise.all(canvas.tokens.controlled.map(t => {
const a = t.actor;
return a.applyDamage(amount, multiplier);
return a.applyDamage(roll.total, multiplier);
}));
}

View file

@ -1,3 +1,4 @@
export const ClassFeatures = {
};

View file

@ -12,8 +12,8 @@ export const _getInitiativeFormula = function(combatant) {
let nd = 1;
let mods = "";
if (actor.getFlag("sw5e", "halflingLucky")) mods += "r=1";
if (actor.getFlag("sw5e", "halflingLucky")) mods += "r1=1";
if (actor.getFlag("sw5e", "initiativeAdv")) {
nd = 2;
mods += "kh";
@ -26,15 +26,3 @@ export const _getInitiativeFormula = function(combatant) {
if ( tiebreaker ) parts.push(actor.data.data.abilities.dex.value / 100);
return parts.filter(p => p !== null).join(" + ");
};
/**
* When the Combat encounter updates - re-render open Actor sheets for combatants in the encounter.
*/
Hooks.on("updateCombat", (combat, data, options, userId) => {
const updateTurn = ("turn" in data) || ("round" in data);
if ( !updateTurn ) return;
for ( let t of combat.turns ) {
const a = t.actor;
if ( t.actor ) t.actor.sheet.render(false);
}
});

View file

@ -4,14 +4,13 @@ import {ClassFeatures} from "./classFeatures.js"
export const SW5E = {};
// ASCII Artwork
SW5E.ASCII = `__________________________________________
_
| |
___| |_ __ _ _ ____ ____ _ _ __ ___
/ __| __/ _\ | |__\ \ /\ / / _\ | |__/ __|
\__ \ || (_) | | \ V V / (_) | | \__ \
|___/\__\__/_|_| \_/\_/ \__/_|_| |___/
__________________________________________`;
SW5E.ASCII = `
___________ ___________
/ _____/ \\ / \\ ____/ ____
\\_____ \\\\ \\/\\/ /____ \\_/ __ \\
/ \\\\ // \\ ___/
\\______ / \\__/\\ //______ /\\__ >
\\/ \\/ \\/ \\/ `;
/**
@ -54,6 +53,30 @@ SW5E.alignments = {
'cd': "SW5E.AlignmentCD"
};
/* -------------------------------------------- */
/**
* An enumeration of item attunement types
* @enum {number}
*/
SW5E.attunementTypes = {
NONE: 0,
REQUIRED: 1,
ATTUNED: 2,
}
/**
* An enumeration of item attunement states
* @type {{"0": string, "1": string, "2": string}}
*/
SW5E.attunements = {
0: "SW5E.AttunementNone",
1: "SW5E.AttunementRequired",
2: "SW5E.AttunementAttuned"
};
/* -------------------------------------------- */
SW5E.weaponProficiencies = {
"sim": "SW5E.WeaponSimpleProficiency",
@ -291,6 +314,7 @@ SW5E.damageResistanceTypes = duplicate(SW5E.damageTypes);
/* -------------------------------------------- */
// armor Types
SW5E.armorPropertiesTypes = {
"Absorptive": "SW5E.ArmorProperAbsorptive",
@ -325,6 +349,19 @@ SW5E.armorPropertiesTypes = {
"Versatile": "SW5E.ArmorProperVersatile"
};
/**
* The valid units of measure for movement distances in the game system.
* By default this uses the imperial units of feet and miles.
* @type {Object<string,string>}
*/
SW5E.movementTypes = {
"burrow": "SW5E.MovementBurrow",
"climb": "SW5E.MovementClimb",
"fly": "SW5E.MovementFly",
"swim": "SW5E.MovementSwim",
"walk": "SW5E.MovementWalk",
}
/**
* The valid units of measure for movement distances in the game system.
* By default this uses the imperial units of feet and miles.
@ -433,17 +470,16 @@ SW5E.hitDieTypes = ["d4", "d6", "d8", "d10", "d12", "d20"];
/* -------------------------------------------- */
/**
* Character senses options
* @type {Object}
* The set of possible sensory perception types which an Actor may have
* @type {object}
*/
SW5E.senses = {
"bs": "SW5E.SenseBS",
"dv": "SW5E.SenseDV",
"ts": "SW5E.SenseTS",
"tr": "SW5E.SenseTR"
"blindsight": "SW5E.SenseBlindsight",
"darkvision": "SW5E.SenseDarkvision",
"tremorsense": "SW5E.SenseTremorsense",
"truesight": "SW5E.SenseTruesight"
};
/* -------------------------------------------- */
/**
@ -1140,7 +1176,7 @@ SW5E.characterFlags = {
section: "Feats",
type: Boolean
},
"remarkableAthlete": {
"remarkableAthlete": {
name: "SW5E.FlagsRemarkableAthlete",
hint: "SW5E.FlagsRemarkableAthleteHint",
abilities: ['str','dex','con'],

View file

@ -1,3 +1,69 @@
/**
* A standardized helper function for simplifying the constant parts of a multipart roll formula
*
* @param {string} formula The original Roll formula
* @param {Object} data Actor or item data against which to parse the roll
* @param {Object} options Formatting options
* @param {boolean} options.constantFirst Puts the constants before the dice terms in the resulting formula
*
* @return {string} The resulting simplified formula
*/
export function simplifyRollFormula(formula, data, {constantFirst = false} = {}) {
const roll = new Roll(formula, data); // Parses the formula and replaces any @properties
const terms = roll.terms;
// Some terms are "too complicated" for this algorithm to simplify
// In this case, the original formula is returned.
if (terms.some(_isUnsupportedTerm)) return roll.formula;
const rollableTerms = []; // Terms that are non-constant, and their associated operators
const constantTerms = []; // Terms that are constant, and their associated operators
let operators = []; // Temporary storage for operators before they are moved to one of the above
for (let term of terms) { // For each term
if (["+", "-"].includes(term)) operators.push(term); // If the term is an addition/subtraction operator, push the term into the operators array
else { // Otherwise the term is not an operator
if (term instanceof DiceTerm) { // If the term is something rollable
rollableTerms.push(...operators); // Place all the operators into the rollableTerms array
rollableTerms.push(term); // Then place this rollable term into it as well
} //
else { // Otherwise, this must be a constant
constantTerms.push(...operators); // Place the operators into the constantTerms array
constantTerms.push(term); // Then also add this constant term to that array.
} //
operators = []; // Finally, the operators have now all been assigend to one of the arrays, so empty this before the next iteration.
}
}
const constantFormula = Roll.cleanFormula(constantTerms); // Cleans up the constant terms and produces a new formula string
const rollableFormula = Roll.cleanFormula(rollableTerms); // Cleans up the non-constant terms and produces a new formula string
const constantPart = roll._safeEval(constantFormula); // Mathematically evaluate the constant formula to produce a single constant term
const parts = constantFirst ? // Order the rollable and constant terms, either constant first or second depending on the optional argumen
[constantPart, rollableFormula] : [rollableFormula, constantPart];
// Join the parts with a + sign, pass them to `Roll` once again to clean up the formula
return new Roll(parts.filterJoin(" + ")).formula;
}
/* -------------------------------------------- */
/**
* Only some terms are supported by simplifyRollFormula, this method returns true when the term is not supported.
* @param {*} term - A single Dice term to check support on
* @return {Boolean} True when unsupported, false if supported
*/
function _isUnsupportedTerm(term) {
const diceTerm = term instanceof DiceTerm;
const operator = ["+", "-"].includes(term);
const number = !isNaN(Number(term));
return !(diceTerm || operator || number);
}
/* -------------------------------------------- */
/**
* A standardized helper function for managing core 5e "d20 rolls"
*
@ -44,8 +110,8 @@ export async function d20Roll({parts=[], data={}, event={}, rollMode=null, templ
let adv = 0;
fastForward = fastForward ?? (event && (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey));
if (fastForward) {
if ( advantage || event.altKey ) adv = 1;
else if ( disadvantage || event.ctrlKey || event.metaKey ) adv = -1;
if ( advantage ?? event.altKey ) adv = 1;
else if ( disadvantage ?? (event.ctrlKey || event.metaKey) ) adv = -1;
}
// Define the inner roll function
@ -53,7 +119,7 @@ export async function d20Roll({parts=[], data={}, event={}, rollMode=null, templ
// Determine the d20 roll and modifiers
let nd = 1;
let mods = halflingLucky ? "r=1" : "";
let mods = halflingLucky ? "r1=1" : "";
// Handle advantage
if (adv === 1) {
@ -109,6 +175,8 @@ export async function d20Roll({parts=[], data={}, event={}, rollMode=null, templ
if (d.faces === 20) {
d.options.critical = critical;
d.options.fumble = fumble;
if ( adv === 1 ) d.options.advantage = true;
else if ( adv === -1 ) d.options.disadvantage = true;
if (targetValue) d.options.target = targetValue;
}
}
@ -131,7 +199,6 @@ export async function d20Roll({parts=[], data={}, event={}, rollMode=null, templ
/* -------------------------------------------- */
/**
* Present a Dialog form which creates a d20 roll once submitted
* @return {Promise<Roll>}
@ -175,7 +242,6 @@ async function _d20RollDialog({template, title, parts, data, rollMode, dialogOpt
});
}
/* -------------------------------------------- */
/**
@ -214,7 +280,6 @@ export async function damageRoll({parts, actor, data, event={}, rollMode=null, t
messageData.speaker = speaker || ChatMessage.getSpeaker();
const messageOptions = {rollMode: rollMode || game.settings.get("core", "rollMode")};
parts = parts.concat(["@bonus"]);
fastForward = fastForward ?? (event && (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey));
// Define inner roll function
const _roll = function(parts, crit, form) {
@ -242,7 +307,9 @@ export async function damageRoll({parts, actor, data, event={}, rollMode=null, t
// Execute the roll
try {
return roll.roll();
roll.evaluate()
if ( crit ) roll.dice.forEach(d => d.options.critical = true); // TODO workaround core bug which wipes Roll#options on roll
return roll;
} catch(err) {
console.error(err);
ui.notifications.error(`Dice roll evaluation failed: ${err.message}`);
@ -251,7 +318,7 @@ export async function damageRoll({parts, actor, data, event={}, rollMode=null, t
};
// Create the Roll instance
const roll = fastForward ? _roll(parts, critical || event.altKey) : await _damageRollDialog({
const roll = fastForward ? _roll(parts, critical) : await _damageRollDialog({
template, title, parts, data, allowCritical, rollMode: messageOptions.rollMode, dialogOptions, roll: _roll
});

View file

@ -1,6 +1,5 @@
import {d20Roll, damageRoll} from "../dice.js";
import {simplifyRollFormula, d20Roll, damageRoll} 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
@ -101,7 +100,8 @@ export default class Item5e extends Item {
* @type {boolean}
*/
get hasSave() {
return !!(this.data.data.save && this.data.data.save.ability);
const save = this.data.data?.save || {};
return !!(save.ability && save.scaling);
}
/* -------------------------------------------- */
@ -152,7 +152,7 @@ export default class Item5e extends Item {
const itemData = this.data;
const data = itemData.data;
const C = CONFIG.SW5E;
const labels = {};
const labels = this.labels = {};
// Classes
if ( itemData.type === "class" ) {
@ -251,12 +251,13 @@ export default class Item5e extends Item {
// Item Actions
if ( data.hasOwnProperty("actionType") ) {
// if this item is owned, we populate the label and saving throw during actor init
if (!this.isOwned) {
// Saving throws
this.getSaveDC();
// Saving throws for unowned items
const save = data.save;
if ( save?.ability && !this.isOwned ) {
if ( save.scaling !== "flat" ) save.dc = null;
labels.save = game.i18n.format("SW5E.SaveDC", {dc: save.dc || "", ability: C.abilities[save.ability]});
// To Hit
this.getAttackToHit();
}
// Damage
@ -265,10 +266,111 @@ export default class Item5e extends Item {
labels.damage = dam.parts.map(d => d[0]).join(" + ").replace(/\+ -/g, "- ");
labels.damageTypes = dam.parts.map(d => C.damageTypes[d[1]]).join(", ");
}
// Limited Uses
if ( this.isOwned && !!data.uses?.max ) {
let max = data.uses.max;
if ( !Number.isNumeric(max) ) {
max = Roll.replaceFormulaData(max, this.actor.getRollData());
if ( Roll.MATH_PROXY.safeEval ) max = Roll.MATH_PROXY.safeEval(max);
}
data.uses.max = Number(max);
}
}
}
/* -------------------------------------------- */
/**
* Update the derived power DC for an item that requires a saving throw
* @returns {number|null}
*/
getSaveDC() {
if ( !this.hasSave ) return;
const save = this.data.data?.save;
// Actor power-DC based scaling
if ( save.scaling === "power" ) {
save.dc = this.isOwned ? getProperty(this.actor.data, "data.attributes.powerdc") : null;
}
// Assign labels
this.labels = labels;
// Ability-score based scaling
else if ( save.scaling !== "flat" ) {
save.dc = this.isOwned ? getProperty(this.actor.data, `data.abilities.${save.scaling}.dc`) : null;
}
// Update labels
const abl = CONFIG.SW5E.abilities[save.ability];
this.labels.save = game.i18n.format("SW5E.SaveDC", {dc: save.dc || "", ability: abl});
return save.dc;
}
/* -------------------------------------------- */
/**
* Update a label to the Item detailing its total to hit bonus.
* Sources:
* - item entity's innate attack bonus
* - item's actor's proficiency bonus if applicable
* - item's actor's global bonuses to the given item type
* - item's ammunition if applicable
*
* @returns {Object} returns `rollData` and `parts` to be used in the item's Attack roll
*/
getAttackToHit() {
const itemData = this.data.data;
if ( !this.hasAttack || !itemData ) return;
const rollData = this.getRollData();
// Define Roll bonuses
const parts = [];
// Include the item's innate attack bonus as the initial value and label
if ( itemData.attackBonus ) {
parts.push(itemData.attackBonus)
this.labels.toHit = itemData.attackBonus;
}
// Take no further action for un-owned items
if ( !this.isOwned ) return {rollData, parts};
// Ability score modifier
parts.push(`@mod`);
// Add proficiency bonus if an explicit proficiency flag is present or for non-item features
if ( !["weapon", "consumable"].includes(this.data.type) || itemData.proficient ) {
parts.push("@prof");
}
// Actor-level global bonus to attack rolls
const actorBonus = this.actor.data.data.bonuses?.[itemData.actionType] || {};
if ( actorBonus.attack ) parts.push(actorBonus.attack);
// One-time bonus provided by consumed ammunition
if ( (itemData.consume?.type === 'ammo') && !!this.actor.items ) {
const ammoItemData = this.actor.items.get(itemData.consume.target)?.data;
if (ammoItemData) {
const ammoItemQuantity = ammoItemData.data.quantity;
const ammoCanBeConsumed = ammoItemQuantity && (ammoItemQuantity - (itemData.consume.amount ?? 0) >= 0);
const ammoItemAttackBonus = ammoItemData.data.attackBonus;
const ammoIsTypeConsumable = (ammoItemData.type === "consumable") && (ammoItemData.data.consumableType === "ammo")
if ( ammoCanBeConsumed && ammoItemAttackBonus && ammoIsTypeConsumable ) {
parts.push("@ammo");
rollData["ammo"] = ammoItemAttackBonus;
}
}
}
// Condense the resulting attack bonus formula into a simplified label
let toHitLabel = simplifyRollFormula(parts.join('+'), rollData).trim();
if (toHitLabel.charAt(0) !== '-') {
toHitLabel = '+ ' + toHitLabel
}
this.labels.toHit = toHitLabel;
// Update labels and return the prepared roll data
return {rollData, parts};
}
/* -------------------------------------------- */
@ -279,9 +381,251 @@ export default class Item5e extends Item {
* @param {string} [rollMode] The roll display mode with which to display (or not) the card
* @param {boolean} [createMessage] Whether to automatically create a chat message (if true) or simply return
* the prepared chat message data (if false).
* @return {Promise}
* @return {Promise<ChatMessage|object|void>}
*/
async roll({configureDialog=true, rollMode=null, createMessage=true}={}) {
async roll({configureDialog=true, rollMode, createMessage=true}={}) {
let item = this;
const actor = this.actor;
// Reference aspects of the item data necessary for usage
const id = this.data.data; // Item data
const hasArea = this.hasAreaTarget; // Is the ability usage an AoE?
const resource = id.consume || {}; // Resource consumption
const recharge = id.recharge || {}; // Recharge mechanic
const uses = id?.uses ?? {}; // Limited uses
const isPower = this.type === "power"; // Does the item require a power slot?
const requirePowerSlot = isPower && (id.level > 0) && CONFIG.SW5E.powerUpcastModes.includes(id.preparation.mode);
// Define follow-up actions resulting from the item usage
let createMeasuredTemplate = hasArea; // Trigger a template creation
let consumeRecharge = !!recharge.value; // Consume recharge
let consumeResource = !!resource.target && (resource.type !== "ammo") // Consume a linked (non-ammo) resource
let consumePowerSlot = requirePowerSlot; // Consume a power slot
let consumeUsage = !!uses.per; // Consume limited uses
let consumeQuantity = uses.autoDestroy; // Consume quantity of the item in lieu of uses
// Display a configuration dialog to customize the usage
const needsConfiguration = createMeasuredTemplate || consumeRecharge || consumeResource || consumePowerSlot || consumeUsage;
if (configureDialog && needsConfiguration) {
const configuration = await AbilityUseDialog.create(this);
if (!configuration) return;
// Determine consumption preferences
createMeasuredTemplate = Boolean(configuration.placeTemplate);
consumeUsage = Boolean(configuration.consumeUse);
consumeRecharge = Boolean(configuration.consumeRecharge);
consumeResource = Boolean(configuration.consumeResource);
consumePowerSlot = Boolean(configuration.consumeSlot);
// Handle power upcasting
if ( requirePowerSlot ) {
const slotLevel = configuration.level;
const powerLevel = slotLevel === "pact" ? actor.data.data.powers.pact.level : parseInt(slotLevel);
if (powerLevel !== id.level) {
const upcastData = mergeObject(this.data, {"data.level": powerLevel}, {inplace: false});
item = this.constructor.createOwned(upcastData, actor); // Replace the item with an upcast version
}
if ( consumePowerSlot ) consumePowerSlot = slotLevel === "pact" ? "pact" : `power${powerLevel}`;
}
}
// Determine whether the item can be used by testing for resource consumption
const usage = item._getUsageUpdates({consumeRecharge, consumeResource, consumePowerSlot, consumeUsage, consumeQuantity});
if ( !usage ) return;
const {actorUpdates, itemUpdates, resourceUpdates} = usage;
// Commit pending data updates
if ( !isObjectEmpty(itemUpdates) ) await item.update(itemUpdates);
if ( consumeQuantity && (item.data.data.quantity === 0) ) await item.delete();
if ( !isObjectEmpty(actorUpdates) ) await actor.update(actorUpdates);
if ( !isObjectEmpty(resourceUpdates) ) {
const resource = actor.items.get(id.consume?.target);
if ( resource ) await resource.update(resourceUpdates);
}
// Initiate measured template creation
if ( createMeasuredTemplate ) {
const template = game.sw5e.canvas.AbilityTemplate.fromItem(item);
if ( template ) template.drawPreview();
}
// Create or return the Chat Message data
return item.displayCard({rollMode, createMessage});
}
/* -------------------------------------------- */
/**
* Verify that the consumed resources used by an Item are available.
* Otherwise display an error and return false.
* @param {boolean} consumeQuantity Consume quantity of the item if other consumption modes are not available?
* @param {boolean} consumeRecharge Whether the item consumes the recharge mechanic
* @param {boolean} consumeResource Whether the item consumes a limited resource
* @param {string|boolean} consumePowerSlot A level of power slot consumed, or false
* @param {boolean} consumeUsage Whether the item consumes a limited usage
* @returns {object|boolean} A set of data changes to apply when the item is used, or false
* @private
*/
_getUsageUpdates({consumeQuantity=false, consumeRecharge=false, consumeResource=false, consumePowerSlot=false, consumeUsage=false}) {
// Reference item data
const id = this.data.data;
const actorUpdates = {};
const itemUpdates = {};
const resourceUpdates = {};
// Consume Recharge
if ( consumeRecharge ) {
const recharge = id.recharge || {};
if ( recharge.charged === false ) {
ui.notifications.warn(game.i18n.format("SW5E.ItemNoUses", {name: this.name}));
return false;
}
itemUpdates["data.recharge.charged"] = false;
}
// Consume Limited Resource
if ( consumeResource ) {
const canConsume = this._handleConsumeResource(itemUpdates, actorUpdates, resourceUpdates);
if ( canConsume === false ) return false;
}
// Consume Power Slots
if ( consumePowerSlot ) {
const level = this.actor?.data.data.powers[consumePowerSlot];
const powers = Number(level?.value ?? 0);
if ( powers === 0 ) {
const label = game.i18n.localize(consumePowerSlot === "pact" ? "SW5E.PowerProgPact" : `SW5E.PowerLevel${id.level}`);
ui.notifications.warn(game.i18n.format("SW5E.PowerCastNoSlots", {name: this.name, level: label}));
return false;
}
actorUpdates[`data.powers.${consumePowerSlot}.value`] = Math.max(powers - 1, 0);
}
// Consume Limited Usage
if ( consumeUsage ) {
const uses = id.uses || {};
const available = Number(uses.value ?? 0);
let used = false;
// Reduce usages
const remaining = Math.max(available - 1, 0);
if ( available >= 1 ) {
used = true;
itemUpdates["data.uses.value"] = remaining;
}
// Reduce quantity if not reducing usages or if usages hit 0 and we are set to consumeQuantity
if ( consumeQuantity && (!used || (remaining === 0)) ) {
const q = Number(id.quantity ?? 1);
if ( q >= 1 ) {
used = true;
itemUpdates["data.quantity"] = Math.max(q - 1, 0);
itemUpdates["data.uses.value"] = uses.max ?? 1;
}
}
// If the item was not used, return a warning
if ( !used ) {
ui.notifications.warn(game.i18n.format("SW5E.ItemNoUses", {name: this.name}));
return false;
}
}
// Return the configured usage
return {itemUpdates, actorUpdates, resourceUpdates};
}
/* -------------------------------------------- */
/**
* Handle update actions required when consuming an external resource
* @param {object} itemUpdates An object of data updates applied to this item
* @param {object} actorUpdates An object of data updates applied to the item owner (Actor)
* @param {object} resourceUpdates An object of data updates applied to a different resource item (Item)
* @return {boolean|void} Return false to block further progress, or return nothing to continue
* @private
*/
_handleConsumeResource(itemUpdates, actorUpdates, resourceUpdates) {
const actor = this.actor;
const itemData = this.data.data;
const consume = itemData.consume || {};
if ( !consume.type ) return;
// No consumed target
const typeLabel = CONFIG.SW5E.abilityConsumptionTypes[consume.type];
if ( !consume.target ) {
ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoResource", {name: this.name, type: typeLabel}));
return false;
}
// Identify the consumed resource and its current quantity
let resource = null;
let amount = Number(consume.amount ?? 1);
let quantity = 0;
switch ( consume.type ) {
case "attribute":
resource = getProperty(actor.data.data, consume.target);
quantity = resource || 0;
break;
case "ammo":
case "material":
resource = actor.items.get(consume.target);
quantity = resource ? resource.data.data.quantity : 0;
break;
case "charges":
resource = actor.items.get(consume.target);
if ( !resource ) break;
const uses = resource.data.data.uses;
if ( uses.per && uses.max ) quantity = uses.value;
else if ( resource.data.data.recharge?.value ) {
quantity = resource.data.data.recharge.charged ? 1 : 0;
amount = 1;
}
break;
}
// Verify that a consumed resource is available
if ( !resource ) {
ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoSource", {name: this.name, type: typeLabel}));
return false;
}
// Verify that the required quantity is available
let remaining = quantity - amount;
if ( remaining < 0 ) {
ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoQuantity", {name: this.name, type: typeLabel}));
return false;
}
// Define updates to provided data objects
switch ( consume.type ) {
case "attribute":
actorUpdates[`data.${consume.target}`] = remaining;
break;
case "ammo":
case "material":
resourceUpdates["data.quantity"] = remaining;
break;
case "charges":
const uses = resource.data.data.uses || {};
const recharge = resource.data.data.recharge || {};
if ( uses.per && uses.max ) resourceUpdates["data.uses.value"] = remaining;
else if ( recharge.value ) resourceUpdates["data.recharge.charged"] = false;
break;
}
}
/* -------------------------------------------- */
/**
* Display the chat card for an Item as a Chat Message
* @param {object} options Options which configure the display of the item chat card
* @param {string} rollMode The message visibility mode to apply to the created card
* @param {boolean} createMessage Whether to automatically create a ChatMessage entity (if true), or only return
* the prepared message data (if false)
*/
async displayCard({rollMode, createMessage=true}={}) {
// Basic template rendering data
const token = this.actor.token;
@ -300,190 +644,31 @@ export default class Item5e extends Item {
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;
} else if ( this.data.type === "consumable" ) {
let configured = await this._rollConsumable(configureDialog);
if ( configured === false ) return;
}
// For items which consume a resource, handle that here
const allowed = await this._handleResourceConsumption({isCard: true, isAttack: false});
if ( allowed === false ) return;
// Render the chat card template
const templateType = ["tool"].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
// Create the ChatMessage data object
const chatData = {
user: game.user._id,
type: CONST.CHAT_MESSAGE_TYPES.OTHER,
content: html,
flavor: this.data.data.chatFlavor || this.name,
speaker: {
actor: this.actor._id,
token: this.actor.token,
alias: this.actor.name
},
speaker: ChatMessage.getSpeaker({actor: this.actor, token}),
flags: {"core.canPopout": true}
};
// If the consumable was destroyed in the process - embed the item data in the surviving message
// If the Item was destroyed in the process of displaying its card - embed the item data in the chat message
if ( (this.data.type === "consumable") && !this.actor.items.has(this.id) ) {
chatData.flags["sw5e.itemData"] = this.data;
}
// Toggle default roll mode
rollMode = rollMode || game.settings.get("core", "rollMode");
if ( ["gmroll", "blindroll"].includes(rollMode) ) chatData["whisper"] = ChatMessage.getWhisperRecipients("GM");
if ( rollMode === "blindroll" ) chatData["blind"] = true;
// Apply the roll mode to adjust message visibility
ChatMessage.applyRollMode(chatData, rollMode || game.settings.get("core", "rollMode"));
// Create the chat message
if ( createMessage ) return ChatMessage.create(chatData);
else return chatData;
}
/* -------------------------------------------- */
/**
* For items which consume a resource, handle the consumption of that resource when the item is used.
* There are four types of ability consumptions which are handled:
* 1. Ammunition (on attack rolls)
* 2. Attributes (on card usage)
* 3. Materials (on card usage)
* 4. Item Charges (on card usage)
*
* @param {boolean} isCard Is the item card being played?
* @param {boolean} isAttack Is an attack roll being made?
* @return {Promise<boolean>} Can the item card or attack roll be allowed to proceed?
* @private
*/
async _handleResourceConsumption({isCard=false, isAttack=false}={}) {
const itemData = this.data.data;
const consume = itemData.consume || {};
if ( !consume.type ) return true;
const actor = this.actor;
const typeLabel = CONFIG.SW5E.abilityConsumptionTypes[consume.type];
// Only handle certain types for certain actions
if ( ((consume.type === "ammo") && !isAttack ) || ((consume.type !== "ammo") && !isCard) ) return true;
// No consumed target set
if ( !consume.target ) {
ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoResource", {name: this.name, type: typeLabel}));
return false;
}
// Identify the consumed resource and it's quantity
let consumed = null;
let amount = parseInt(consume.amount || 1);
let quantity = 0;
switch ( consume.type ) {
case "attribute":
consumed = getProperty(actor.data.data, consume.target);
quantity = consumed || 0;
break;
case "ammo":
case "material":
consumed = actor.items.get(consume.target);
quantity = consumed ? consumed.data.data.quantity : 0;
break;
case "charges":
consumed = actor.items.get(consume.target);
if ( !consumed ) break;
const uses = consumed.data.data.uses;
if ( uses.per && uses.max ) quantity = uses.value;
else if ( consumed.data.data.recharge?.value ) {
quantity = consumed.data.data.recharge.charged ? 1 : 0;
amount = 1;
}
break;
}
// Verify that the consumed resource is available
if ( [null, undefined].includes(consumed) ) {
ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoSource", {name: this.name, type: typeLabel}));
return false;
}
let remaining = quantity - amount;
if ( remaining < 0) {
ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoQuantity", {name: this.name, type: typeLabel}));
return false;
}
// Update the consumed resource
switch ( consume.type ) {
case "attribute":
await this.actor.update({[`data.${consume.target}`]: remaining});
break;
case "ammo":
case "material":
await consumed.update({"data.quantity": remaining});
break;
case "charges":
const uses = consumed.data.data.uses || {};
const recharge = consumed.data.data.recharge || {};
if ( uses.per && uses.max ) await consumed.update({"data.uses.value": remaining});
else if ( recharge.value ) await consumed.update({"data.recharge.charged": false});
break;
}
return true;
}
/* -------------------------------------------- */
/**
* 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 charge = this.data.data.recharge;
const uses = this.data.data.uses;
let usesCharges = !!uses.per && !!uses.max;
let placeTemplate = false;
let consume = charge.value || 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("consumeUse"));
placeTemplate = Boolean(usage.get("placeTemplate"));
}
// Update Item data
const current = getProperty(this.data, "data.uses.value") || 0;
if ( consume && charge.value ) {
if ( !charge.charged ) {
ui.notifications.warn(game.i18n.format("SW5E.ItemNoUses", {name: this.name}));
return false;
}
else await this.update({"data.recharge.charged": false});
}
else if ( consume && usesCharges ) {
if ( uses.value <= 0 ) {
ui.notifications.warn(game.i18n.format("SW5E.ItemNoUses", {name: this.name}));
return false;
}
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();
if ( this.owner && this.owner.sheet ) this.owner.sheet.minimize();
}
return true;
// Create the Chat Message or return its data
return createMessage ? ChatMessage.create(chatData) : chatData;
}
/* -------------------------------------------- */
@ -507,8 +692,9 @@ export default class Item5e extends Item {
const fn = this[`_${this.data.type}ChatData`];
if ( fn ) fn.bind(this)(data, labels, props);
// General equipment properties
// Equipment properties
if ( data.hasOwnProperty("equipped") && !["loot", "tool"].includes(this.data.type) ) {
if ( data.attunement === CONFIG.SW5E.attunementTypes.REQUIRED ) props.push(game.i18n.localize(CONFIG.SW5E.attunements[CONFIG.SW5E.attunementTypes.REQUIRED]));
props.push(
game.i18n.localize(data.equipped ? "SW5E.Equipped" : "SW5E.Unequipped"),
game.i18n.localize(data.proficient ? "SW5E.Proficient" : "SW5E.NotProficient"),
@ -633,43 +819,35 @@ export default class Item5e extends Item {
*/
async 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.");
}
let title = `${this.name} - ${game.i18n.localize("SW5E.AttackRoll")}`;
const rollData = this.getRollData();
// Define Roll bonuses
const parts = [`@mod`];
if ( (this.data.type !== "weapon") || itemData.proficient ) {
parts.push("@prof");
}
// get the parts and rollData for this item's attack
const {parts, rollData} = this.getAttackToHit();
// Attack Bonus
if ( itemData.attackBonus ) parts.push(itemData.attackBonus);
const actorBonus = actorData?.bonuses?.[itemData.actionType] || {};
if ( actorBonus.attack ) parts.push(actorBonus.attack);
// Ammunition Bonus
// Handle ammunition consumption
delete this._ammo;
let ammo = null;
let ammoUpdate = null;
const consume = itemData.consume;
if ( consume?.type === "ammo" ) {
const ammo = this.actor.items.get(consume.target);
if(ammo?.data){
ammo = this.actor.items.get(consume.target);
if (ammo?.data) {
const q = ammo.data.data.quantity;
const consumeAmount = consume.amount ?? 0;
if ( q && (q - consumeAmount >= 0) ) {
this._ammo = ammo;
let ammoBonus = ammo.data.data.attackBonus;
if ( ammoBonus ) {
parts.push("@ammo");
rollData["ammo"] = ammoBonus;
title += ` [${ammo.name}]`;
}
title += ` [${ammo.name}]`;
}
}
// Get pending ammunition update
const usage = this._getUsageUpdates({consumeResource: true});
if ( usage === false ) return null;
ammoUpdate = usage.resourceUpdates || {};
}
// Compose roll options
@ -710,9 +888,8 @@ export default class Item5e extends Item {
const roll = await d20Roll(rollConfig);
if ( roll === false ) return null;
// Handle resource consumption if the attack roll was made
const allowed = await this._handleResourceConsumption({isCard: false, isAttack: true});
if ( allowed === false ) return null;
// Commit ammunition consumption on attack rolls resource consumption if the attack roll was made
if ( ammo && !isObjectEmpty(ammoUpdate) ) await ammo.update(ammoUpdate);
return roll;
}
@ -722,12 +899,13 @@ export default class Item5e extends Item {
* Place a damage roll using an item (weapon, feat, power, or equipment)
* Rely upon the damageRoll logic for the core implementation.
* @param {MouseEvent} [event] An event which triggered this roll, if any
* @param {boolean} [critical] Should damage be rolled as a critical hit?
* @param {number} [powerLevel] If the item is a power, override the level for damage scaling
* @param {boolean} [versatile] If the item is a weapon, roll damage using the versatile formula
* @param {object} [options] Additional options passed to the damageRoll function
* @return {Promise<Roll>} A Promise which resolves to the created Roll instance
*/
rollDamage({event, powerLevel=null, versatile=false, options={}}={}) {
rollDamage({critical=false, event=null, powerLevel=null, versatile=false, options={}}={}) {
if ( !this.hasDamage ) throw new Error("You may not make a Damage Roll with this Item.");
const itemData = this.data.data;
const actorData = this.actor.data.data;
@ -739,12 +917,15 @@ export default class Item5e extends Item {
if ( powerLevel ) rollData.item.level = powerLevel;
// Configure the damage roll
const title = `${this.name} - ${game.i18n.localize("SW5E.DamageRoll")}`;
const actionFlavor = game.i18n.localize(itemData.actionType === "heal" ? "SW5E.Healing" : "SW5E.DamageRoll");
const title = `${this.name} - ${actionFlavor}`;
const rollConfig = {
event: event,
parts: parts,
actor: this.actor,
critical: critical ?? event?.altKey ?? false,
data: rollData,
event: event,
fastForward: event ? event.shiftKey || event.altKey || event.ctrlKey || event.metaKey : false,
parts: parts,
title: title,
flavor: this.labels.damageTypes.length ? `${title} (${this.labels.damageTypes})` : title,
speaker: ChatMessage.getSpeaker({actor: this.actor}),
@ -780,10 +961,13 @@ export default class Item5e extends Item {
parts.push(actorBonus.damage);
}
// Add ammunition damage
if ( this._ammo ) {
// Handle ammunition damage
const ammoData = this._ammo?.data;
// only add the ammunition damage if the ammution is a consumable with type 'ammo'
if ( this._ammo && (ammoData.type === "consumable") && (ammoData.data.consumableType === "ammo") ) {
parts.push("@ammo");
rollData["ammo"] = this._ammo.data.data.damage.parts.map(p => p[0]).join("+");
rollData["ammo"] = ammoData.data.damage.parts.map(p => p[0]).join("+");
rollConfig.flavor += ` [${this._ammo.name}]`;
delete this._ammo;
}
@ -893,74 +1077,6 @@ export default class Item5e extends Item {
/* -------------------------------------------- */
/**
* Use a consumable item, deducting from the quantity or charges of the item.
* @param {boolean} configureDialog Whether to show a configuration dialog
* @return {boolean} Whether further execution should be prevented
* @private
*/
async _rollConsumable(configureDialog) {
if ( this.data.type !== "consumable" ) throw new Error("Wrong Item type");
const itemData = this.data.data;
// Determine whether to deduct uses of the item
const uses = itemData.uses || {};
const autoDestroy = uses.autoDestroy;
let usesCharges = !!uses.per && (uses.max > 0);
const recharge = itemData.recharge || {};
const usesRecharge = !!recharge.value;
// Display a configuration dialog to confirm the usage
let placeTemplate = false;
let consume = uses.autoUse || true;
if ( configureDialog ) {
const usage = await AbilityUseDialog.create(this);
if ( usage === null ) return false;
consume = Boolean(usage.get("consumeUse"));
placeTemplate = Boolean(usage.get("placeTemplate"));
}
// Update Item data
if ( consume ) {
const current = uses.value || 0;
const remaining = usesCharges ? Math.max(current - 1, 0) : current;
if ( usesRecharge ) await this.update({"data.recharge.charged": false});
else {
const q = itemData.quantity;
// Case 1, reduce charges
if ( remaining ) {
await this.update({"data.uses.value": remaining});
}
// Case 2, reduce quantity
else if ( q > 1 ) {
await this.update({"data.quantity": q - 1, "data.uses.value": uses.max || 0});
}
// Case 3, destroy the item
else if ( (q <= 1) && autoDestroy ) {
await this.actor.deleteOwnedItem(this.id);
}
// Case 4, reduce item to 0 quantity and 0 charges
else if ( (q === 1) ) {
await this.update({"data.quantity": q - 1, "data.uses.value": 0});
}
// Case 5, item unusable, display warning and do nothing
else {
ui.notifications.warn(game.i18n.format("SW5E.ItemNoUses", {name: this.name}));
}
}
}
// Maybe initiate template placement workflow
if ( this.hasAreaTarget && placeTemplate ) {
const template = AbilityTemplate.fromItem(this);
if ( template ) template.drawPreview();
if ( this.owner && this.owner.sheet ) this.owner.sheet.minimize();
}
return true;
}
/* -------------------------------------------- */
/**
* Perform an ability recharge test for an item which uses the d6 recharge mechanic
* @return {Promise<Roll>} A Promise which resolves to the created Roll instance
@ -1013,6 +1129,7 @@ export default class Item5e extends Item {
left: window.innerWidth - 710,
},
halflingLucky: this.actor.getFlag("sw5e", "halflingLucky" ) || false,
reliableTalent: (this.data.data.proficient >= 1) && this.actor.getFlag("sw5e", "reliableTalent"),
messageData: {"flags.sw5e.roll": {type: "tool", itemId: this.id }}
}, options);
rollConfig.event = options.event;
@ -1094,9 +1211,14 @@ export default class Item5e extends Item {
case "attack":
await item.rollAttack({event}); break;
case "damage":
await item.rollDamage({event, powerLevel}); break;
case "versatile":
await item.rollDamage({event, powerLevel, versatile: true}); break;
await item.rollDamage({
critical: event.altKey,
event: event,
powerLevel: powerLevel,
versatile: action === "versatile"
});
break;
case "formula":
await item.rollFormula({event, powerLevel}); break;
case "save":
@ -1109,7 +1231,7 @@ export default class Item5e extends Item {
case "toolCheck":
await item.rollToolCheck({event}); break;
case "placeTemplate":
const template = AbilityTemplate.fromItem(item);
const template = game.sw5e.canvas.AbilityTemplate.fromItem(item);
if ( template ) template.drawPreview();
break;
}

View file

@ -61,6 +61,9 @@ export default class ItemSheet5e extends ItemSheet {
data.isFlatDC = getProperty(data.item.data, "save.scaling") === "flat";
data.isLine = ["line", "wall"].includes(data.item.data.target?.type);
// Original maximum uses formula
if ( this.item._data.data?.uses?.max ) data.data.uses.max = this.item._data.data.uses.max;
// Vehicles
data.isCrewed = data.item.data.activation?.type === 'crew';
data.isMountable = this._isItemMountable(data.item);
@ -91,7 +94,7 @@ export default class ItemSheet5e extends ItemSheet {
ammo[i.id] = `${i.name} (${i.data.data.quantity})`;
}
return ammo;
}, {});
}, {[item._id]: `${item.name} (${item.data.quantity})`});
}
// Attributes
@ -335,7 +338,7 @@ export default class ItemSheet5e extends ItemSheet {
// Render the Trait Selector dialog
new TraitSelector(this.item, {
name: a.dataset.edit,
name: a.dataset.target,
title: label.innerText,
choices: Object.entries(CONFIG.SW5E.skills).reduce((obj, e) => {
if ( choices.includes(e[0] ) ) obj[e[0]] = e[1];

View file

@ -55,6 +55,5 @@ export function rollItemMacro(itemName) {
const item = items[0];
// Trigger the item roll
if ( item.data.type === "power" ) return actor.usePower(item);
return item.roll();
}

View file

@ -3,7 +3,7 @@
* @return {Promise} A Promise which resolves once the migration is completed
*/
export const migrateWorld = async function() {
ui.notifications.info(`Applying SW5E System Migration for version ${game.system.data.version}. Please be patient and do not close your game or shut down your server.`, {permanent: true});
ui.notifications.info(`Applying SW5e System Migration for version ${game.system.data.version}. Please be patient and do not close your game or shut down your server.`, {permanent: true});
// Migrate World Actors
for ( let a of game.actors.entities ) {
@ -56,7 +56,7 @@ export const migrateWorld = async function() {
// Set the migration as complete
game.settings.set("sw5e", "systemMigrationVersion", game.system.data.version);
ui.notifications.info(`SW5E System Migration to version ${game.system.data.version} completed!`, {permanent: true});
ui.notifications.info(`SW5e System Migration to version ${game.system.data.version} completed!`, {permanent: true});
};
/* -------------------------------------------- */
@ -120,15 +120,15 @@ export const migrateCompendium = async function(pack) {
/**
* Migrate a single Actor entity to incorporate latest data model changes
* Return an Object of updateData to be applied
* @param {Actor} actor The actor to Update
* @return {Object} The updateData to apply
* @param {object} actor The actor data object to update
* @return {Object} The updateData to apply
*/
export const migrateActorData = function(actor) {
const updateData = {};
// Actor Data Updates
_migrateActorBonuses(actor, updateData);
_migrateActorMovement(actor, updateData);
_migrateActorSenses(actor, updateData);
// Migrate Owned Items
if ( !actor.items ) return updateData;
@ -191,6 +191,7 @@ function cleanActorData(actorData) {
*/
export const migrateItemData = function(item) {
const updateData = {};
_migrateItemAttunement(item, updateData);
return updateData;
};
@ -228,33 +229,83 @@ export const migrateSceneData = function(scene) {
/* -------------------------------------------- */
/**
* Migrate the actor bonuses object
* Migrate the actor speed string to movement object
* @private
*/
function _migrateActorBonuses(actor, updateData) {
const b = game.system.model.Actor.character.bonuses;
for ( let k of Object.keys(actor.data.bonuses || {}) ) {
if ( k in b ) updateData[`data.bonuses.${k}`] = b[k];
else updateData[`data.bonuses.-=${k}`] = null;
function _migrateActorMovement(actorData, updateData) {
const ad = actorData.data;
// Work is needed if old data is present
const old = actorData.type === 'vehicle' ? ad?.attributes?.speed : ad?.attributes?.speed?.value;
const hasOld = old !== undefined;
if ( hasOld ) {
// If new data is not present, migrate the old data
const hasNew = ad?.attributes?.movement?.walk !== undefined;
if ( !hasNew && (typeof old === "string") ) {
const s = (old || "").split(" ");
if ( s.length > 0 ) updateData["data.attributes.movement.walk"] = Number.isNumeric(s[0]) ? parseInt(s[0]) : null;
}
// Remove the old attribute
updateData["data.attributes.-=speed"] = null;
}
return updateData
}
/* -------------------------------------------- */
/**
* Migrate the actor bonuses object
* Migrate the actor traits.senses string to attributes.senses object
* @private
*/
function _migrateActorMovement(actor, updateData) {
if ( actor.data.attributes?.movement?.walk !== undefined ) return;
const s = (actor.data.attributes?.speed?.value || "").split(" ");
if ( s.length > 0 ) updateData["data.attributes.movement.walk"] = Number.isNumeric(s[0]) ? parseInt(s[0]) : null;
function _migrateActorSenses(actor, updateData) {
const ad = actor.data;
if ( ad?.traits?.senses === undefined ) return;
const original = ad.traits.senses || "";
// Try to match old senses with the format like "Darkvision 60 ft, Blindsight 30 ft"
const pattern = /([A-z]+)\s?([0-9]+)\s?([A-z]+)?/;
let wasMatched = false;
// Match each comma-separated term
for ( let s of original.split(",") ) {
s = s.trim();
const match = s.match(pattern);
if ( !match ) continue;
const type = match[1].toLowerCase();
if ( type in CONFIG.SW5E.senses ) {
updateData[`data.attributes.senses.${type}`] = Number(match[2]).toNearest(0.5);
wasMatched = true;
}
}
// If nothing was matched, but there was an old string - put the whole thing in "special"
if ( !wasMatched && !!original ) {
updateData["data.attributes.senses.special"] = original;
}
// Remove the old traits.senses string once the migration is complete
updateData["data.traits.-=senses"] = null;
return updateData;
}
/* -------------------------------------------- */
/**
* Delete the old data.attuned boolean
* @private
*/
function _migrateItemAttunement(item, updateData) {
if ( item.data.attuned === undefined ) return;
updateData["data.attunement"] = CONFIG.SW5E.attunementTypes.NONE;
updateData["data.-=attuned"] = null;
return updateData;
}
/* -------------------------------------------- */
/**
* A general tool to purge flags from all entities in a Compendium pack.
* @param {Compendium} pack The compendium pack to clean

View file

@ -29,8 +29,8 @@ export default class AbilityTemplate extends MeasuredTemplate {
// Additional type-specific data
switch ( templateShape ) {
case "cone": // 5e cone RAW should be 53.13 degrees
templateData.angle = 53.13;
case "cone":
templateData.angle = CONFIG.MeasuredTemplate.defaults.angle;
break;
case "rect": // 5e rectangular AoEs are always cubes
templateData.distance = Math.hypot(target.value, target.value);
@ -45,7 +45,10 @@ export default class AbilityTemplate extends MeasuredTemplate {
}
// Return the template constructed from the item data
return new this(templateData);
const template = new this(templateData);
template.item = item;
template.actorSheet = item.actor?.sheet || null;
return template;
}
/* -------------------------------------------- */
@ -55,9 +58,16 @@ export default class AbilityTemplate extends MeasuredTemplate {
*/
drawPreview() {
const initialLayer = canvas.activeLayer;
// Draw the template and switch to the template layer
this.draw();
this.layer.activate();
this.layer.preview.addChild(this);
// Hide the sheet that originated the preview
if ( this.actorSheet ) this.actorSheet.minimize();
// Activate interactivity
this.activatePreviewListeners(initialLayer);
}
@ -92,6 +102,7 @@ export default class AbilityTemplate extends MeasuredTemplate {
canvas.app.view.oncontextmenu = null;
canvas.app.view.onwheel = null;
initialLayer.activate();
this.actorSheet.maximize();
};
// Confirm the workflow (left-click)

View file

@ -14,12 +14,13 @@ export const preloadHandlebarsTemplates = async function() {
"systems/sw5e/templates/actors/oldActor/parts/actor-inventory.html",
"systems/sw5e/templates/actors/oldActor/parts/actor-features.html",
"systems/sw5e/templates/actors/oldActor/parts/actor-powerbook.html",
"systems/sw5e/templates/actors/oldActor/parts/actor-notes.html",
"systems/sw5e/templates/actors/newActor/parts/swalt-biography.html",
"systems/sw5e/templates/actors/newActor/parts/swalt-core.html",
"systems/sw5e/templates/actors/newActor/parts/swalt-active-effects.html",
"systems/sw5e/templates/actors/newActor/parts/swalt-features.html",
"systems/sw5e/templates/actors/newActor/parts/swalt-inventory.html",
"systems/sw5e/templates/actors/newActor/parts/swalt-notes.html",
"systems/sw5e/templates/actors/newActor/parts/swalt-powerbook.html",
"systems/sw5e/templates/actors/newActor/parts/swalt-resources.html",
"systems/sw5e/templates/actors/newActor/parts/swalt-traits.html",