Super VJ Update

This commit is contained in:
Kakeman89 2020-09-11 17:11:11 -04:00
parent 442212bdea
commit 1983b74bde
59 changed files with 4640 additions and 2462 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,14 @@
import {TraitSelector} from "../../apps/trait-selector.js";
import {ActorSheetFlags} from "../../apps/actor-flags.js";
import Item5e from "../../item/entity.js";
import TraitSelector from "../../apps/trait-selector.js";
import ActorSheetFlags from "../../apps/actor-flags.js";
import {SW5E} from '../../config.js';
/**
* Extend the basic ActorSheet class to do all the SW5e things!
* This sheet is an Abstract layer which is not used.
*
* @type {ActorSheet}
* @extends {ActorSheet}
*/
export class ActorSheet5e extends ActorSheet {
export default class ActorSheet5e extends ActorSheet {
constructor(...args) {
super(...args);
@ -37,6 +37,13 @@ export class ActorSheet5e extends ActorSheet {
});
}
/* -------------------------------------------- */
/** @override */
get template() {
if ( !game.user.isGM && this.actor.limited ) return "systems/sw5e/templates/actors/limited-sheet.html";
return `systems/sw5e/templates/actors/${this.actor.data.type}-sheet.html`;
}
/* -------------------------------------------- */
@ -53,6 +60,7 @@ export class ActorSheet5e extends ActorSheet {
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,
};
@ -75,11 +83,13 @@ export class ActorSheet5e extends ActorSheet {
}
// Update skill labels
for ( let [s, skl] of Object.entries(data.actor.data.skills)) {
skl.ability = data.actor.data.abilities[skl.ability].label.substring(0, 3);
skl.icon = this._getProficiencyIcon(skl.value);
skl.hover = CONFIG.SW5E.proficiencyLevels[skl.value];
skl.label = CONFIG.SW5E.skills[s];
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];
}
}
// Update traits
@ -96,9 +106,9 @@ export class ActorSheet5e extends ActorSheet {
_prepareTraits(traits) {
const map = {
"dr": CONFIG.SW5E.damageTypes,
"di": CONFIG.SW5E.damageTypes,
"dv": CONFIG.SW5E.damageTypes,
"dr": CONFIG.SW5E.damageResistanceTypes,
"di": CONFIG.SW5E.damageResistanceTypes,
"dv": CONFIG.SW5E.damageResistanceTypes,
"ci": CONFIG.SW5E.conditionTypes,
"languages": CONFIG.SW5E.languages,
"armorProf": CONFIG.SW5E.armorProficiencies,
@ -200,7 +210,7 @@ export class ActorSheet5e extends ActorSheet {
if ( mode in sections ) {
s = sections[mode];
if ( !powerbook[s] ){
registerSection(sl, s, CONFIG.SW5E.powerPreparationModes[mode], levels[mode]);
registerSection(mode, s, CONFIG.SW5E.powerPreparationModes[mode], levels[mode]);
}
}
@ -252,7 +262,7 @@ export class ActorSheet5e extends ActorSheet {
// Equipment-specific filters
if ( filters.has("equipped") ) {
if (data.equipped && data.equipped !== true) return false;
if ( data.equipped !== true ) return false;
}
return true;
});
@ -295,8 +305,10 @@ export class ActorSheet5e extends ActorSheet {
// Editable Only Listeners
if ( this.isEditable ) {
// Relative updates for numeric fields
html.find('input[data-dtype="Number"]').change(this._onChangeInputDelta.bind(this));
// 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));
@ -328,14 +340,6 @@ export class ActorSheet5e extends ActorSheet {
// Roll Skill Checks
html.find('.skill-name').click(this._onRollSkillCheck.bind(this));
// Item Dragging
let handler = ev => this._onDragItemStart(ev);
html.find('li.item').each((i, li) => {
if ( li.classList.contains("inventory-header") ) return;
li.setAttribute("draggable", true);
li.addEventListener("dragstart", handler, false);
});
// Item Rolling
html.find('.item .item-image').click(event => this._onItemRoll(event));
html.find('.item .item-recharge').click(event => this._onItemRecharge(event));
@ -424,37 +428,9 @@ export class ActorSheet5e extends ActorSheet {
/* -------------------------------------------- */
/** @override */
async _onDrop (event) {
event.preventDefault();
// Get dropped data
let data;
try {
data = JSON.parse(event.dataTransfer.getData('text/plain'));
} catch (err) {
return false;
}
// Handle a polymorph
if (data && (data.type === "Actor")) {
if (game.user.isGM || (game.settings.get('sw5e', 'allowPolymorphing') && this.actor.owner)) {
return this._onDropPolymorph(event, data);
}
}
// Call parent on drop logic
return super._onDrop(event);
}
/* -------------------------------------------- */
/**
* Handle dropping an Actor on the sheet to trigger a Polymorph workflow
* @param {DragEvent} event The drop event
* @param {Object} data The data transfer
* @private
*/
async _onDropPolymorph(event, data) {
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;
@ -521,6 +497,30 @@ export class ActorSheet5e extends ActorSheet {
}).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;
}
// Upgrade the number of class levels a character has
if ( (itemData.type === "class") && ( this.actor.itemTypes.class.find(c => c.name === itemData.name)) ) {
const cls = this.actor.itemTypes.class.find(c => c.name === itemData.name);
const lvl = cls.data.data.levels;
return cls.update({"data.levels": Math.min(lvl + 1, 20 + lvl - this.actor.data.data.details.level)})
}
// Create the owned item as normal
// TODO remove conditional logic in 0.7.x
if (isNewerVersion(game.data.version, "0.6.9")) return super._onDropItemCreate(itemData);
else return this.actor.createEmbeddedEntity("OwnedItem", itemData);
}
/* -------------------------------------------- */
/**
@ -634,7 +634,7 @@ export class ActorSheet5e extends ActorSheet {
const header = event.currentTarget;
const type = header.dataset.type;
const itemData = {
name: `New ${type.capitalize()}`,
name: game.i18n.format("SW5E.ItemNew", {type: type.capitalize()}),
type: type,
data: duplicate(header.dataset)
};
@ -736,11 +736,8 @@ export class ActorSheet5e extends ActorSheet {
event.preventDefault();
const a = event.currentTarget;
const label = a.parentElement.querySelector("label");
const options = {
name: label.getAttribute("for"),
title: label.innerText,
choices: CONFIG.SW5E[a.dataset.options]
};
const choices = CONFIG.SW5E[a.dataset.options];
const options = { name: a.dataset.target, title: label.innerText, choices };
new TraitSelector(this.actor, options).render(true)
}
@ -760,4 +757,90 @@ export class ActorSheet5e extends ActorSheet {
});
return buttons;
}
/* -------------------------------------------- */
/* DEPRECATED */
/* -------------------------------------------- */
/**
* TODO: Remove once 0.7.x is release
* @deprecated since 0.7.0
*/
async _onDrop (event) {
event.preventDefault();
// Get dropped data
let data;
try {
data = JSON.parse(event.dataTransfer.getData('text/plain'));
} catch (err) {
return false;
}
if ( !data ) return false;
// Handle the drop with a Hooked function
const allowed = Hooks.call("dropActorSheetData", this.actor, this, data);
if ( allowed === false ) return;
// Case 1 - Dropped Item
if ( data.type === "Item" ) {
return this._onDropItem(event, data);
}
// Case 2 - Dropped Actor
if ( data.type === "Actor" ) {
return this._onDropActor(event, data);
}
}
/* -------------------------------------------- */
/**
* TODO: Remove once 0.7.x is release
* @deprecated since 0.7.0
*/
async _onDropItem(event, data) {
if ( !this.actor.owner ) return false;
let itemData = await this._getItemDropData(event, data);
// Handle item sorting within the same Actor
const actor = this.actor;
let sameActor = (data.actorId === actor._id) || (actor.isToken && (data.tokenId === actor.token.id));
if (sameActor) return this._onSortItem(event, itemData);
// Create a new item
this._onDropItemCreate(itemData);
}
/* -------------------------------------------- */
/**
* TODO: Remove once 0.7.x is release
* @deprecated since 0.7.0
*/
async _getItemDropData(event, data) {
let itemData = null;
// Case 1 - Import from a Compendium pack
if (data.pack) {
const pack = game.packs.get(data.pack);
if (pack.metadata.entity !== "Item") return;
itemData = await pack.getEntry(data.id);
}
// Case 2 - Data explicitly provided
else if (data.data) {
itemData = data.data;
}
// Case 3 - Import from World entity
else {
let item = game.items.get(data.id);
if (!item) return;
itemData = item.data;
}
// Return a copy of the extracted data
return duplicate(itemData);
}
}

View file

@ -1,11 +1,11 @@
import { ActorSheet5e } from "./base.js";
import ActorSheet5e from "./base.js";
/**
* An Actor sheet for player character type actors in the SW5E system.
* Extends the base ActorSheet5e class.
* @type {ActorSheet5e}
*/
export class ActorSheet5eCharacter extends ActorSheet5e {
export default class ActorSheet5eCharacter extends ActorSheet5e {
/**
* Define default rendering options for the NPC sheet
@ -14,24 +14,11 @@ export class ActorSheet5eCharacter extends ActorSheet5e {
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
classes: ["sw5e", "sheet", "actor", "character"],
width: 672,
width: 720,
height: 736
});
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* Get the correct HTML template path to use for rendering this particular sheet
* @type {String}
*/
get template() {
if ( !game.user.isGM && this.actor.limited ) return "systems/sw5e/templates/actors/limited-sheet.html";
return "systems/sw5e/templates/actors/character-sheet.html";
}
/* -------------------------------------------- */
/**
@ -57,6 +44,7 @@ export class ActorSheet5eCharacter extends ActorSheet5e {
// Experience Tracking
sheetData["disableExperience"] = game.settings.get("sw5e", "disableExperienceTracking");
sheetData["classLabels"] = this.actor.itemTypes.class.map(c => c.name).join(", ");
// Return data for rendering
return sheetData;
@ -80,13 +68,12 @@ export class ActorSheet5eCharacter extends ActorSheet5e {
loot: { label: "SW5E.ItemTypeLootPl", items: [], dataset: {type: "loot"} }
};
// Partition items by category
let [items, powers, feats, classes, species] = data.items.reduce((arr, item) => {
// Item details
item.img = item.img || DEFAULT_TOKEN;
item.isStack = item.data.quantity ? item.data.quantity > 1 : false;
item.isStack = Number.isNumeric(item.data.quantity) && (item.data.quantity !== 1);
// Item usage
item.hasUses = item.data.uses && (item.data.uses.max > 0);
@ -101,7 +88,7 @@ export class ActorSheet5eCharacter extends ActorSheet5e {
if ( item.type === "power" ) arr[1].push(item);
else if ( item.type === "feat" ) arr[2].push(item);
else if ( item.type === "class" ) arr[3].push(item);
else if ( item.type === "species" ) arr[4].push(item);
else if ( item.type === "species" ) arr[4].push(item);
else if ( Object.keys(inventory).includes(item.type ) ) arr[0].push(item);
return arr;
}, [[], [], [], [], []]);
@ -111,27 +98,24 @@ export class ActorSheet5eCharacter extends ActorSheet5e {
powers = this._filterItems(powers, this._filters.powerbook);
feats = this._filterItems(feats, this._filters.features);
// Organize items
for ( let i of items ) {
i.data.quantity = i.data.quantity || 0;
i.data.weight = i.data.weight || 0;
i.totalWeight = Math.round(i.data.quantity * i.data.weight * 10) / 10;
inventory[i.type].items.push(i);
}
// Organize Powerbook and count the number of prepared powers (excluding always, at will, etc...)
const powerbook = this._preparePowerbook(data, powers);
const nPrepared = powers.filter(s => {
return (s.data.level > 0) && (s.data.preparation.mode === "prepared") && s.data.preparation.prepared;
}).length;
// Organize Inventory
let totalWeight = 0;
for ( let i of items ) {
i.data.quantity = i.data.quantity || 0;
i.data.weight = i.data.weight || 0;
i.totalWeight = Math.round(i.data.quantity * i.data.weight * 10) / 10;
inventory[i.type].items.push(i);
totalWeight += i.totalWeight;
}
data.data.attributes.encumbrance = this._computeEncumbrance(totalWeight, data);
// Organize Features
const features = {
classes: { label: "SW5E.ItemTypeClassPl", items: [], hasActions: false, dataset: {type: "class"}, isClass: true },
species: { label: "SW5E.ItemTypeSpecies", items: [], hasActions: false, dataset: {type: "species"}, isSpecies: true},
species: { label: "SW5E.ItemTypeSpecies", items: [], hasActions: false, dataset: {type: "species"}, isSpecies: true},
active: { label: "SW5E.FeatureActive", items: [], hasActions: true, dataset: {type: "feat", "activation.type": "action"} },
passive: { label: "SW5E.FeaturePassive", items: [], hasActions: false, dataset: {type: "feat"} }
};
@ -141,7 +125,7 @@ export class ActorSheet5eCharacter extends ActorSheet5e {
}
classes.sort((a, b) => b.levels - a.levels);
features.classes.items = classes;
features.species.items = species;
features.species.items = species;
// Assign and return
data.inventory = Object.values(inventory);
@ -174,51 +158,6 @@ export class ActorSheet5eCharacter extends ActorSheet5e {
}
}
/* -------------------------------------------- */
/**
* Compute the level and percentage of encumbrance for an Actor.
*
* Optionally include the weight of carried currency across all denominations by applying the standard rule
* from the PHB pg. 143
*
* @param {Number} totalWeight The cumulative item weight from inventory items
* @param {Object} actorData The data object for the Actor being rendered
* @return {Object} An object describing the character's encumbrance level
* @private
*/
_computeEncumbrance(totalWeight, actorData) {
// Encumbrance classes
let mod = {
tiny: 0.5,
sm: 1,
med: 1,
lg: 2,
huge: 4,
grg: 8
}[actorData.data.traits.size] || 1;
// Apply Powerful Build feat
if ( this.actor.getFlag("sw5e", "powerfulBuild") ) mod = Math.min(mod * 2, 8);
// Add Currency Weight
if ( game.settings.get("sw5e", "currencyWeight") ) {
const currency = actorData.data.currency;
const numCoins = Object.values(currency).reduce((val, denom) => val += denom, 0);
totalWeight += numCoins / CONFIG.SW5E.encumbrance.currencyPerWeight;
}
// Compute Encumbrance percentage
const enc = {
max: actorData.data.abilities.str.value * CONFIG.SW5E.encumbrance.strMultiplier * mod,
value: Math.round(totalWeight * 10) / 10,
};
enc.pct = Math.min(enc.value * 100 / enc.max, 99);
enc.encumbered = enc.pct > (2/3);
return enc;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */

View file

@ -1,37 +1,21 @@
import { ActorSheet5e } from "../sheets/base.js";
import ActorSheet5e from "../sheets/base.js";
/**
* An Actor sheet for NPC type characters in the SW5E system.
* Extends the base ActorSheet5e class.
* @type {ActorSheet5e}
* @extends {ActorSheet5e}
*/
export class ActorSheet5eNPC extends ActorSheet5e {
export default class ActorSheet5eNPC extends ActorSheet5e {
/**
* Define default rendering options for the NPC sheet
* @return {Object}
*/
/** @override */
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
classes: ["sw5e", "sheet", "actor", "npc"],
width: 600,
height: 658
height: 680
});
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* Get the correct HTML template path to use for rendering this particular sheet
* @type {String}
*/
get template() {
if ( !game.user.isGM && this.actor.limited ) return "systems/sw5e/templates/actors/limited-sheet.html";
return "systems/sw5e/templates/actors/npc-sheet.html";
}
/* -------------------------------------------- */
/**
@ -42,16 +26,16 @@ export class ActorSheet5eNPC extends ActorSheet5e {
// Categorize Items as Features and Powers
const features = {
weapons: { label: "Attacks", items: [] , hasActions: true, dataset: {type: "weapon", "weapon-type": "natural"} },
actions: { label: "Actions", items: [] , hasActions: true, dataset: {type: "feat", "activation.type": "action"} },
passive: { label: "Features", items: [], dataset: {type: "feat"} },
equipment: { label: "Inventory", items: [], dataset: {type: "loot"}}
weapons: { label: game.i18n.localize("SW5E.AttackPl"), items: [] , hasActions: true, dataset: {type: "weapon", "weapon-type": "natural"} },
actions: { label: game.i18n.localize("SW5E.ActionPl"), items: [] , hasActions: true, dataset: {type: "feat", "activation.type": "action"} },
passive: { label: game.i18n.localize("SW5E.Features"), items: [], dataset: {type: "feat"} },
equipment: { label: game.i18n.localize("SW5E.Inventory"), items: [], dataset: {type: "loot"}}
};
// Start by classifying items into groups for rendering
let [powers, other] = data.items.reduce((arr, item) => {
item.img = item.img || DEFAULT_TOKEN;
item.isStack = item.data.quantity ? item.data.quantity > 1 : false;
item.isStack = Number.isNumeric(item.data.quantity) && (item.data.quantity !== 1);
item.hasUses = item.data.uses && (item.data.uses.max > 0);
item.isOnCooldown = item.data.recharge && !!item.data.recharge.value && (item.data.recharge.charged === false);
item.isDepleted = item.isOnCooldown && (item.data.uses.per && (item.data.uses.value > 0));
@ -86,9 +70,7 @@ export class ActorSheet5eNPC extends ActorSheet5e {
/* -------------------------------------------- */
/**
* Add some extra data when rendering the sheet to reduce the amount of logic required within the template.
*/
/** @override */
getData() {
const data = super.getData();
@ -103,12 +85,7 @@ export class ActorSheet5eNPC extends ActorSheet5e {
/* Object Updates */
/* -------------------------------------------- */
/**
* This method is called upon form submission after form data is validated
* @param event {Event} The initial triggering submission event
* @param formData {Object} The object of validated form data with which to update the object
* @private
*/
/** @override */
_updateObject(event, formData) {
// Format NPC Challenge Rating
@ -126,14 +103,9 @@ export class ActorSheet5eNPC extends ActorSheet5e {
/* 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
*/
/** @override */
activateListeners(html) {
super.activateListeners(html);
// Rollable Health Formula
html.find(".health .rollable").click(this._onRollHealthFormula.bind(this));
}
@ -152,4 +124,4 @@ export class ActorSheet5eNPC extends ActorSheet5e {
AudioHelper.play({src: CONFIG.sounds.dice});
this.actor.update({"data.attributes.hp.value": hp, "data.attributes.hp.max": hp});
}
}
}

View file

@ -0,0 +1,381 @@
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 enc = {
max: actorData.data.attributes.capacity.cargo,
value: Math.round(totalWeight * 10) / 10
};
enc.pct = Math.min(enc.value * 100 / enc.max, 99);
return enc;
}
/* -------------------------------------------- */
/**
* 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

@ -2,7 +2,7 @@
* A specialized Dialog subclass for ability usage
* @type {Dialog}
*/
export class AbilityUseDialog extends Dialog {
export default class AbilityUseDialog extends Dialog {
constructor(item, dialogData={}, options={}) {
super(dialogData, options);
this.options.classes = ["sw5e", "dialog"];
@ -25,40 +25,150 @@ export class AbilityUseDialog extends Dialog {
* @return {Promise}
*/
static async create(item) {
if ( !item.isOwned ) throw new Error("You cannot display an ability usage dialog for an unowned item");
const uses = item.data.data.uses;
const recharge = item.data.data.recharge;
// Prepare data
const actorData = item.actor.data.data;
const itemData = item.data.data;
const uses = itemData.uses || {};
const quantity = itemData.quantity || 0;
const recharge = itemData.recharge || {};
const recharges = !!recharge.value;
// Render the ability usage template
const html = await renderTemplate("systems/sw5e/templates/apps/ability-use.html", {
// Prepare dialog form data
const data = {
item: item.data,
canUse: recharges ? recharge.charged : uses.value > 0,
consume: true,
uses: uses,
recharges: !!recharge.value,
isCharged: recharge.charged,
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,
perLabel: CONFIG.SW5E.limitedUsePeriods[uses.per]
});
errors: []
};
if ( item.data.type === "power" ) this._getPowerData(actorData, itemData, data);
// 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
const icon = data.hasPowerSlots ? "fa-magic" : "fa-fist-raised";
const label = game.i18n.localize("SW5E.AbilityUse" + (data.hasPowerSlots ? "Cast" : "Use"));
return new Promise((resolve) => {
let formData = null;
const dlg = new this(item, {
title: `${item.name}: Ability Configuration`,
title: `${item.name}: Usage Configuration`,
content: html,
buttons: {
use: {
icon: '<i class="fas fa-fist-raised"></i>',
label: "Use Ability",
callback: html => formData = new FormData(html[0].querySelector("#ability-use-form"))
icon: `<i class="fas ${icon}"></i>`,
label: label,
callback: html => resolve(new FormData(html[0].querySelector("form")))
}
},
default: "use",
close: () => resolve(formData)
close: () => resolve(null)
});
dlg.render(true);
});
}
/* -------------------------------------------- */
/* Helpers */
/* -------------------------------------------- */
/**
* Get dialog data related to limited power slots
* @private
*/
static _getPowerData(actorData, itemData, data) {
// Determine whether the power may be up-cast
const lvl = itemData.level;
const canUpcast = (lvl > 0) && CONFIG.SW5E.powerUpcastModes.includes(itemData.preparation.mode);
// Determine the levels which are feasible
let lmax = 0;
const powerLevels = Array.fromRange(10).reduce((arr, i) => {
if ( i < lvl ) return arr;
const label = CONFIG.SW5E.powerLevels[i];
const l = actorData.powers["power"+i] || {max: 0, override: null};
let max = parseInt(l.override || l.max || 0);
let slots = Math.clamped(parseInt(l.value || 0), 0, max);
if ( max > 0 ) lmax = i;
arr.push({
level: i,
label: i > 0 ? game.i18n.format('SW5E.PowerLevelSlot', {level: label, n: slots}) : label,
canCast: canUpcast && (max > 0),
hasSlots: slots > 0
});
return arr;
}, []).filter(sl => sl.level <= lmax);
// If this character has pact slots, present them as an option for casting the power.
const pact = actorData.powers.pact;
if (pact.level >= lvl) {
powerLevels.push({
level: 'pact',
label: `${game.i18n.format('SW5E.PowerLevelPact', {level: pact.level, n: pact.value})}`,
canCast: canUpcast,
hasSlots: pact.value > 0
});
}
const canCast = powerLevels.some(l => l.hasSlots);
// Return merged data
data = mergeObject(data, { hasPowerSlots: true, canUpcast, powerLevels });
if ( !canCast ) data.errors.push("SW5E.PowerCastNoSlots");
}
/* -------------------------------------------- */
/**
* Get the ability usage note that is displayed
* @private
*/
static _getAbilityUseNote(item, uses, recharge) {
// Zero quantity
const quantity = item.data.quantity;
if ( quantity <= 0 ) return game.i18n.localize("SW5E.AbilityUseUnavailableHint");
// Abilities which use Recharge
if ( !!recharge.value ) {
return game.i18n.format(recharge.charged ? "SW5E.AbilityUseChargedHint" : "SW5E.AbilityUseRechargeHint", {
type: item.type,
})
}
// Does not use any resource
if ( !uses.per || !uses.max ) return "";
// Consumables
if ( item.type === "consumable" ) {
let str = "SW5E.AbilityUseNormalHint";
if ( uses.value > 1 ) str = "SW5E.AbilityUseConsumableChargeHint";
else if ( item.data.quantity === 1 && uses.autoDestroy ) str = "SW5E.AbilityUseConsumableDestroyHint";
else if ( item.data.quantity > 1 ) str = "SW5E.AbilityUseConsumableQuantityHint";
return game.i18n.format(str, {
type: item.data.consumableType,
value: uses.value,
quantity: item.data.quantity,
});
}
// Other Items
else {
return game.i18n.format("SW5E.AbilityUseNormalHint", {
type: item.type,
value: uses.value,
max: uses.max,
per: CONFIG.SW5E.limitedUsePeriods[uses.per]
});
}
}
/* -------------------------------------------- */
static _handleSubmit(formData, item) {
}
}

View file

@ -1,5 +1,9 @@
export class ActorSheetFlags extends BaseEntitySheet {
static get defaultOptions() {
/**
* An application class which provides advanced configuration for special character flags which modify an Actor
* @extends {BaseEntitySheet}
*/
export default class ActorSheetFlags extends BaseEntitySheet {
static get defaultOptions() {
const options = super.defaultOptions;
return mergeObject(options, {
id: "actor-flags",
@ -68,10 +72,10 @@ export class ActorSheetFlags extends BaseEntitySheet {
{name: "data.bonuses.mwak.damage", label: "SW5E.BonusMWDamage"},
{name: "data.bonuses.rwak.attack", label: "SW5E.BonusRWAttack"},
{name: "data.bonuses.rwak.damage", label: "SW5E.BonusRWDamage"},
{name: "data.bonuses.mpak.attack", label: "SW5E.BonusMSAttack"},
{name: "data.bonuses.mpak.damage", label: "SW5E.BonusMSDamage"},
{name: "data.bonuses.rpak.attack", label: "SW5E.BonusRSAttack"},
{name: "data.bonuses.rpak.damage", label: "SW5E.BonusRSDamage"},
{name: "data.bonuses.mpak.attack", label: "SW5E.BonusMPAttack"},
{name: "data.bonuses.mpak.damage", label: "SW5E.BonusMPDamage"},
{name: "data.bonuses.rpak.attack", label: "SW5E.BonusRPAttack"},
{name: "data.bonuses.rpak.damage", label: "SW5E.BonusRPDamage"},
{name: "data.bonuses.abilities.check", label: "SW5E.BonusAbilityCheck"},
{name: "data.bonuses.abilities.save", label: "SW5E.BonusAbilitySave"},
{name: "data.bonuses.abilities.skill", label: "SW5E.BonusAbilitySkill"},
@ -91,7 +95,7 @@ export class ActorSheetFlags extends BaseEntitySheet {
*/
async _updateObject(event, formData) {
const actor = this.object;
const updateData = expandObject(formData);
let updateData = expandObject(formData);
// Unset any flags which are "false"
let unset = false;
@ -106,7 +110,18 @@ export class ActorSheetFlags extends BaseEntitySheet {
}
}
// Apply the changes
// Clear any bonuses which are whitespace only
for ( let b of Object.values(updateData.data.bonuses ) ) {
for ( let [k, v] of Object.entries(b) ) {
b[k] = v.trim();
}
}
// Diff the data against any applied overrides and apply
// TODO: Remove this logical gate once 0.7.x is release channel
if ( !isNewerVersion("0.7.1", game.data.version) ){
updateData.data = diffObject(this.object.overrides, updateData.data);
}
await actor.update(updateData, {diff: false});
}
}

69
module/apps/long-rest.js Normal file
View file

@ -0,0 +1,69 @@
/**
* A helper Dialog subclass for completing a long rest
* @extends {Dialog}
*/
export default class LongRestDialog extends Dialog {
constructor(actor, dialogData = {}, options = {}) {
super(dialogData, options);
this.actor = actor;
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
template: "systems/sw5e/templates/apps/long-rest.html",
classes: ["sw5e", "dialog"]
});
}
/* -------------------------------------------- */
/** @override */
getData() {
const data = super.getData();
const variant = game.settings.get("sw5e", "restVariant");
data.promptNewDay = variant !== "gritty"; // It's always a new day when resting 1 week
data.newDay = variant === "normal"; // It's probably a new day when resting normally (8 hours)
return data;
}
/* -------------------------------------------- */
/**
* A helper constructor function which displays the Long Rest confirmation dialog and returns a Promise once it's
* workflow has been resolved.
* @param {Actor5e} actor
* @return {Promise}
*/
static async longRestDialog({ actor } = {}) {
return new Promise((resolve, reject) => {
const dlg = new this(actor, {
title: "Long Rest",
buttons: {
rest: {
icon: '<i class="fas fa-bed"></i>',
label: "Rest",
callback: html => {
let newDay = false;
if (game.settings.get("sw5e", "restVariant") === "normal")
newDay = html.find('input[name="newDay"]')[0].checked;
else if(game.settings.get("sw5e", "restVariant") === "gritty")
newDay = true;
resolve(newDay);
}
},
cancel: {
icon: '<i class="fas fa-times"></i>',
label: "Cancel",
callback: reject
}
},
default: 'rest',
close: reject
});
dlg.render(true);
});
}
}

View file

@ -1,8 +1,10 @@
import LongRestDialog from "./long-rest.js";
/**
* A helper Dialog subclass for rolling Hit Dice on short rest
* @type {Dialog}
* @extends {Dialog}
*/
export class ShortRestDialog extends Dialog {
export default class ShortRestDialog extends Dialog {
constructor(actor, dialogData={}, options={}) {
super(dialogData, options);
@ -34,6 +36,8 @@ export class ShortRestDialog extends Dialog {
/** @override */
getData() {
const data = super.getData();
// Determine Hit Dice
data.availableHD = this.actor.data.items.reduce((hd, item) => {
if ( item.type === "class" ) {
const d = item.data;
@ -45,6 +49,11 @@ export class ShortRestDialog extends Dialog {
}, {});
data.canRoll = this.actor.data.data.attributes.hd > 0;
data.denomination = this._denom;
// Determine rest type
const variant = game.settings.get("sw5e", "restVariant");
data.promptNewDay = variant !== "epic"; // It's never a new day when only resting 1 minute
data.newDay = false; // It may be a new day, but not by default
return data;
}
@ -56,7 +65,6 @@ export class ShortRestDialog extends Dialog {
super.activateListeners(html);
let btn = html.find("#roll-hd");
btn.click(this._onRollHitDie.bind(this));
super.activateListeners(html);
}
/* -------------------------------------------- */
@ -83,21 +91,27 @@ export class ShortRestDialog extends Dialog {
* @return {Promise}
*/
static async shortRestDialog({actor}={}) {
return new Promise(resolve => {
return new Promise((resolve, reject) => {
const dlg = new this(actor, {
title: "Short Rest",
buttons: {
rest: {
icon: '<i class="fas fa-bed"></i>',
label: "Rest",
callback: () => resolve(true)
callback: html => {
let newDay = false;
if (game.settings.get("sw5e", "restVariant") === "gritty")
newDay = html.find('input[name="newDay"]')[0].checked;
resolve(newDay);
}
},
cancel: {
icon: '<i class="fas fa-times"></i>',
label: "Cancel",
callback: () => resolve(false)
callback: reject
}
}
},
close: reject
});
dlg.render(true);
});
@ -108,31 +122,12 @@ export class ShortRestDialog extends Dialog {
/**
* A helper constructor function which displays the Long Rest confirmation dialog and returns a Promise once it's
* workflow has been resolved.
* @deprecated
* @param {Actor5e} actor
* @return {Promise}
*/
static async longRestDialog({actor}={}) {
const content = `<p>Take a long rest?</p><p>On a long rest you will recover hit points, half your maximum hit dice,
class resources, limited use item charges, and power slots.</p>`;
return new Promise((resolve, reject) => {
new Dialog({
title: "Long Rest",
content: content,
buttons: {
rest: {
icon: '<i class="fas fa-bed"></i>',
label: "Rest",
callback: resolve
},
cancel: {
icon: '<i class="fas fa-times"></i>',
label: "Cancel",
callback: reject
},
},
default: 'rest',
close: reject
}, {classes: ["sw5e", "dialog"]}).render(true);
});
console.warn("WARNING! ShortRestDialog.longRestDialog has been deprecated, use LongRestDialog.longRestDialog instead.");
return LongRestDialog.longRestDialog(...arguments);
}
}

View file

@ -2,7 +2,7 @@
* A specialized form used to select from a checklist of attributes, traits, or properties
* @extends {FormApplication}
*/
export class TraitSelector extends FormApplication {
export default class TraitSelector extends FormApplication {
/** @override */
static get defaultOptions() {

View file

@ -1,32 +1,40 @@
/**
* Measure the distance between two pixel coordinates
* See BaseGrid.measureDistance for more details
*
* @param {Object} p0 The origin coordinate {x, y}
* @param {Object} p1 The destination coordinate {x, y}
* @param {boolean} gridSpaces Enforce grid distance (if true) vs. direct point-to-point (if false)
* @return {number} The distance between p1 and p0
*/
export const measureDistance = function(p0, p1, {gridSpaces=true}={}) {
if ( !gridSpaces ) return BaseGrid.prototype.measureDistance.bind(this)(p0, p1, {gridSpaces});
let gs = canvas.dimensions.size,
ray = new Ray(p0, p1),
nx = Math.abs(Math.ceil(ray.dx / gs)),
ny = Math.abs(Math.ceil(ray.dy / gs));
/** @override */
export const measureDistances = function(segments, options={}) {
if ( !options.gridSpaces ) return BaseGrid.prototype.measureDistances.call(this, segments, options);
// Get the number of straight and diagonal moves
let nDiagonal = Math.min(nx, ny),
nStraight = Math.abs(ny - nx);
// Track the total number of diagonals
let nDiagonal = 0;
const rule = this.parent.diagonalRule;
const d = canvas.dimensions;
// Alternative DMG Movement
if ( this.parent.diagonalRule === "5105" ) {
let nd10 = Math.floor(nDiagonal / 2);
let spaces = (nd10 * 2) + (nDiagonal - nd10) + nStraight;
return spaces * canvas.dimensions.distance;
}
// Iterate over measured segments
return segments.map(s => {
let r = s.ray;
// Standard PHB Movement
else return (nStraight + nDiagonal) * canvas.scene.data.gridDistance;
// Determine the total distance traveled
let nx = Math.abs(Math.ceil(r.dx / d.size));
let ny = Math.abs(Math.ceil(r.dy / d.size));
// Determine the number of straight and diagonal moves
let nd = Math.min(nx, ny);
let ns = Math.abs(ny - nx);
nDiagonal += nd;
// Alternative DMG Movement
if (rule === "5105") {
let nd10 = Math.floor(nDiagonal / 2) - Math.floor((nDiagonal - nd) / 2);
let spaces = (nd10 * 2) + (nd - nd10) + ns;
return spaces * canvas.dimensions.distance;
}
// Euclidean Measurement
else if (rule === "EUCL") {
return Math.round(Math.hypot(nx, ny) * canvas.scene.data.gridDistance);
}
// Standard PHB Movement
else return (ns + nd) * canvas.scene.data.gridDistance;
});
};
/* -------------------------------------------- */
@ -39,8 +47,8 @@ const _TokenGetBarAttribute = Token.prototype.getBarAttribute;
export const getBarAttribute = function(...args) {
const data = _TokenGetBarAttribute.bind(this)(...args);
if ( data && (data.attribute === "attributes.hp") ) {
data.value += parseInt(data['temp'] || 0);
data.max += parseInt(data['tempmax'] || 0);
data.value += parseInt(getProperty(this.actor.data, "data.attributes.hp.temp") || 0);
data.max += parseInt(getProperty(this.actor.data, "data.attributes.hp.tempmax") || 0);
}
return data;
};

View file

@ -1,18 +1,18 @@
import {Actor5e} from "./actor/entity.js";
/**
* Highlight critical success or failure on d20 rolls
*/
export const highlightCriticalSuccessFailure = function(message, html, data) {
if ( !message.isRoll || !message.isRollVisible || !message.roll.parts.length ) return;
if ( !message.isRoll || !message.isContentVisible ) return;
// Highlight rolls where the first part is a d20 roll
const roll = message.roll;
let d = roll.parts[0];
const isD20Roll = d instanceof Die && (d.faces === 20) && (d.results.length === 1);
if ( !isD20Roll ) return;
if ( !roll.dice.length ) return;
const d = roll.dice[0];
// Ensure it is not a modified roll
// Ensure it is an un-modified d20 roll
const isD20 = (d.faces === 20) && ( d.results.length === 1 );
if ( !isD20 ) return;
const isModifiedRoll = ("success" in d.rolls[0]) || d.options.marginSuccess || d.options.marginFailure;
if ( isModifiedRoll ) return;
@ -60,32 +60,55 @@ export const displayChatActionButtons = function(message, html, data) {
* @return {Array} The extended options Array including new context choices
*/
export const addChatMessageContextOptions = function(html, options) {
let canApply = li => canvas.tokens.controlledTokens.length && li.find(".dice-roll").length;
let canApply = li => {
const message = game.messages.get(li.data("messageId"));
return message.isRoll && message.isContentVisible && canvas.tokens.controlled.length;
};
options.push(
{
name: game.i18n.localize("SW5E.ChatContextDamage"),
icon: '<i class="fas fa-user-minus"></i>',
condition: canApply,
callback: li => Actor5e.applyDamage(li, 1)
callback: li => applyChatCardDamage(li, 1)
},
{
name: game.i18n.localize("SW5E.ChatContextHealing"),
icon: '<i class="fas fa-user-plus"></i>',
condition: canApply,
callback: li => Actor5e.applyDamage(li, -1)
callback: li => applyChatCardDamage(li, -1)
},
{
name: game.i18n.localize("SW5E.ChatContextDoubleDamage"),
icon: '<i class="fas fa-user-injured"></i>',
condition: canApply,
callback: li => Actor5e.applyDamage(li, 2)
callback: li => applyChatCardDamage(li, 2)
},
{
name: game.i18n.localize("SW5E.ChatContextHalfDamage"),
icon: '<i class="fas fa-user-shield"></i>',
condition: canApply,
callback: li => Actor5e.applyDamage(li, 0.5)
callback: li => applyChatCardDamage(li, 0.5)
}
);
return 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 {Number} multiplier A damage multiplier to apply to the rolled damage.
* @return {Promise}
*/
function applyChatCardDamage(roll, multiplier) {
const amount = roll.find('.dice-total').text();
return Promise.all(canvas.tokens.controlled.map(t => {
const a = t.actor;
return a.applyDamage(amount, multiplier);
}));
}
/* -------------------------------------------- */

View file

@ -11,6 +11,48 @@ export const _getInitiativeFormula = function(combatant) {
const init = actor.data.data.attributes.init;
const parts = ["1d20", init.mod, (init.prof !== 0) ? init.prof : null, (init.bonus !== 0) ? init.bonus : null];
if ( actor.getFlag("sw5e", "initiativeAdv") ) parts[0] = "2d20kh";
if ( CONFIG.Combat.initiative.tiebreaker ) parts.push(actor.data.data.abilities.dex.value / 100);
// Optionally apply Dexterity tiebreaker
const tiebreaker = game.settings.get("sw5e", "initiativeDexTiebreaker");
if ( tiebreaker ) parts.push(actor.data.data.abilities.dex.value / 100);
return parts.filter(p => p !== null).join(" + ");
};
/* -------------------------------------------- */
/**
* TODO: A temporary shim until 0.7.x becomes stable
* @override
*/
TokenConfig.getTrackedAttributes = function(data, _path=[]) {
// Track the path and record found attributes
const attributes = {
"bar": [],
"value": []
};
// Recursively explore the object
for ( let [k, v] of Object.entries(data) ) {
let p = _path.concat([k]);
// Check objects for both a "value" and a "max"
if ( v instanceof Object ) {
const isBar = ("value" in v) && ("max" in v);
if ( isBar ) attributes.bar.push(p);
else {
const inner = this.getTrackedAttributes(data[k], p);
attributes.bar.push(...inner.bar);
attributes.value.push(...inner.value);
}
}
// Otherwise identify values which are numeric or null
else if ( Number.isNumeric(v) || (v === null) ) {
attributes.value.push(p);
}
}
return attributes;
};

View file

@ -2,14 +2,14 @@
export const SW5E = {};
// ASCII Artwork
SW5E.ASCII = `_______________________________
SW5E.ASCII = `__________________________________________
_
| |
___| |_ __ _ _ ____ ____ _ _ __ ___
/ __| __/ _\ | |__\ \ /\ / / _\ | |__/ __|
\__ \ || (_) | | \ V V / (_) | | \__ \
|___/\__\__/_|_| \_/\_/ \__/_|_| |___/
_______________________________`;
__________________________________________`;
/**
@ -25,6 +25,15 @@ SW5E.abilities = {
"cha": "SW5E.AbilityCha"
};
SW5E.abilityAbbreviations = {
"str": "SW5E.AbilityStrAbbr",
"dex": "SW5E.AbilityDexAbbr",
"con": "SW5E.AbilityConAbbr",
"int": "SW5E.AbilityIntAbbr",
"wis": "SW5E.AbilityWisAbbr",
"cha": "SW5E.AbilityChaAbbr"
};
/* -------------------------------------------- */
/**
@ -46,7 +55,7 @@ SW5E.alignments = {
SW5E.weaponProficiencies = {
"sim": "SW5E.WeaponSimpleProficiency",
"mar": "SW5E.WeaponMartialProficiency",
"mar": "SW5E.WeaponMartialProficiency"
};
SW5E.toolProficiencies = {
@ -119,9 +128,21 @@ SW5E.abilityActivationTypes = {
"day": SW5E.timePeriods.day,
"special": SW5E.timePeriods.spec,
"legendary": "SW5E.LegAct",
"lair": "SW5E.LairAct"
"lair": "SW5E.LairAct",
"crew": "SW5E.VehicleCrewAction"
};
/* -------------------------------------------- */
SW5E.abilityConsumptionTypes = {
"ammo": "SW5E.ConsumeAmmunition",
"attribute": "SW5E.ConsumeAttribute",
"material": "SW5E.ConsumeMaterial",
"charges": "SW5E.ConsumeCharges"
};
/* -------------------------------------------- */
// Creature Sizes
@ -196,7 +217,8 @@ SW5E.equipmentTypes = {
"natural": "SW5E.EquipmentNatural",
"shield": "SW5E.EquipmentShield",
"clothing": "SW5E.EquipmentClothing",
"trinket": "SW5E.EquipmentTrinket"
"trinket": "SW5E.EquipmentTrinket",
"vehicle": "SW5E.EquipmentVehicle"
};
@ -231,7 +253,6 @@ SW5E.consumableTypes = {
"trinket": "SW5E.ConsumableTrinket"
};
/* -------------------------------------------- */
/**
@ -261,10 +282,14 @@ SW5E.damageTypes = {
"sonic": "SW5E.DamageSonic"
};
// Damage Resistance Types
SW5E.damageResistanceTypes = mergeObject(duplicate(SW5E.damageTypes), {
"physical": "SW5E.DamagePhysical"
});
/* -------------------------------------------- */
// armor Types
SW5E.armorpropertiesTypes = {
SW5E.armorPropertiesTypes = {
"Absorptive": "SW5E.ArmorProperAbsorptive",
"Agile": "SW5E.ArmorProperAgile",
"Anchor": "SW5E.ArmorProperAnchor",
@ -315,7 +340,8 @@ SW5E.distanceUnits = {
*/
SW5E.encumbrance = {
currencyPerWeight: 50,
strMultiplier: 15
strMultiplier: 15,
vehicleWeightMultiplier: 2000 // 2000 lbs in a ton
};
/* -------------------------------------------- */
@ -435,7 +461,7 @@ SW5E.powerPreparationModes = {
"prepared": "SW5E.PowerPrepPrepared"
};
SW5E.powerUpcastModes = ["always"];
SW5E.powerUpcastModes = ["always", "pact", "prepared"];
SW5E.powerProgression = {
@ -604,12 +630,28 @@ SW5E.proficiencyLevels = {
/* -------------------------------------------- */
/**
* The amount of cover provided by an object.
* In cases where multiple pieces of cover are
* in play, we take the highest value.
*/
SW5E.cover = {
0: 'SW5E.None',
.5: 'SW5E.CoverHalf',
.75: 'SW5E.CoverThreeQuarters',
1: 'SW5E.CoverTotal'
};
/* -------------------------------------------- */
// Condition Types
SW5E.conditionTypes = {
"blinded": "SW5E.ConBlinded",
"charmed": "SW5E.ConCharmed",
"deafened": "SW5E.ConDeafened",
"diseased": "SW5E.ConDiseased",
"exhaustion": "SW5E.ConExhaustion",
"frightened": "SW5E.ConFrightened",
"grappled": "SW5E.ConGrappled",
@ -768,8 +810,8 @@ SW5E.characterFlags = {
type: Boolean
},
"powerfulBuild": {
name: "Powerful Build",
hint: "You count as one size larger when determining your carrying capacity and the weight you can push, drag, or lift.",
name: "SW5E.FlagsPowerfulBuild",
hint: "SW5E.FlagsPowerfulBuildHint",
section: "Racial Traits",
type: Boolean
},
@ -798,40 +840,40 @@ SW5E.characterFlags = {
type: Boolean
},
"initiativeAdv": {
name: "Advantage on Initiative",
hint: "Provided by feats or magical items.",
name: "SW5E.FlagsInitiativeAdv",
hint: "SW5E.FlagsInitiativeAdvHint",
section: "Feats",
type: Boolean
},
"initiativeAlert": {
name: "Alert Feat",
hint: "Provides +5 to Initiative.",
name: "SW5E.FlagsAlert",
hint: "SW5E.FlagsAlertHint",
section: "Feats",
type: Boolean
},
"jackOfAllTrades": {
name: "Jack of All Trades",
hint: "Half-Proficiency to Ability Checks in which you are not already Proficient.",
name: "SW5E.FlagsJOAT",
hint: "SW5E.FlagsJOATHint",
section: "Feats",
type: Boolean
},
"observantFeat": {
name: "Observant Feat",
hint: "Provides a +5 to passive Perception and Investigation.",
name: "SW5E.FlagsObservant",
hint: "SW5E.FlagsObservantHint",
skills: ['prc','inv'],
section: "Feats",
type: Boolean
},
"remarkableAthlete": {
name: "Remarkable Athlete.",
hint: "Half-Proficiency (rounded-up) to physical Ability Checks and Initiative.",
name: "SW5E.FlagsRemarkableAthlete",
hint: "SW5E.FlagsRemarkableAthleteHint",
abilities: ['str','dex','con'],
section: "Feats",
type: Boolean
},
"weaponCriticalThreshold": {
name: "Critical Hit Threshold",
hint: "Allow for expanded critical range; for example Improved or Superior Critical",
name: "SW5E.FlagsCritThreshold",
hint: "SW5E.FlagsCritThresholdHint",
section: "Feats",
type: Number,
placeholder: 20

View file

@ -1,112 +1,144 @@
export class Dice5e {
/**
* A standardized helper function for managing core 5e "d20 rolls"
*
* Holding SHIFT, ALT, or CTRL when the attack is rolled will "fast-forward".
* This chooses the default options of a normal attack with no bonus, Advantage, or Disadvantage respectively
*
* @param {Array} parts The dice roll component parts, excluding the initial d20
* @param {Object} data Actor or item data against which to parse the roll
* @param {Event|object} event The triggering event which initiated the roll
* @param {string} rollMode A specific roll mode to apply as the default for the resulting roll
* @param {string|null} template The HTML template used to render the roll dialog
* @param {string|null} title The dice roll UI window title
* @param {Object} speaker The ChatMessage speaker to pass when creating the chat
* @param {string|null} flavor Flavor text to use in the posted chat message
* @param {Boolean} fastForward Allow fast-forward advantage selection
* @param {Function} onClose Callback for actions to take when the dialog form is closed
* @param {Object} dialogOptions Modal dialog options
* @param {boolean} advantage Apply advantage to the roll (unless otherwise specified)
* @param {boolean} disadvantage Apply disadvantage to the roll (unless otherwise specified)
* @param {number} critical The value of d20 result which represents a critical success
* @param {number} fumble The value of d20 result which represents a critical failure
* @param {number} targetValue Assign a target value against which the result of this roll should be compared
* @param {boolean} elvenAccuracy Allow Elven Accuracy to modify this roll?
* @param {boolean} halflingLucky Allow Halfling Luck to modify this roll?
*
* @return {Promise} A Promise which resolves once the roll workflow has completed
* @param {Array} parts The dice roll component parts, excluding the initial d20
* @param {Object} data Actor or item data against which to parse the roll
* @param {Event|object} event The triggering event which initiated the roll
* @param {string} rollMode A specific roll mode to apply as the default for the resulting roll
* @param {string|null} template The HTML template used to render the roll dialog
* @param {string|null} title The dice roll UI window title
* @param {Object} speaker The ChatMessage speaker to pass when creating the chat
* @param {string|null} flavor Flavor text to use in the posted chat message
* @param {Boolean} fastForward Allow fast-forward advantage selection
* @param {Function} onClose Callback for actions to take when the dialog form is closed
* @param {Object} dialogOptions Modal dialog options
* @param {boolean} advantage Apply advantage to the roll (unless otherwise specified)
* @param {boolean} disadvantage Apply disadvantage to the roll (unless otherwise specified)
* @param {number} critical The value of d20 result which represents a critical success
* @param {number} fumble The value of d20 result which represents a critical failure
* @param {number} targetValue Assign a target value against which the result of this roll should be compared
* @param {boolean} elvenAccuracy Allow Elven Accuracy to modify this roll?
* @param {boolean} halflingLucky Allow Halfling Luck to modify this roll?
* @param {boolean} reliableTalent Allow Reliable Talent to modify this roll?
* @param {boolean} chatMessage Automatically create a Chat Message for the result of this roll
* @param {object} messageData Additional data which is applied to the created Chat Message, if any
*
* @return {Promise} A Promise which resolves once the roll workflow has completed
*/
static async d20Roll({parts=[], data={}, event={}, rollMode=null, template=null, title=null, speaker=null,
flavor=null, fastForward=null, onClose, dialogOptions,
advantage=null, disadvantage=null, critical=20, fumble=1, targetValue=null,
elvenAccuracy=false, halflingLucky=false}={}) {
// Handle input arguments
flavor = flavor || title;
speaker = speaker || ChatMessage.getSpeaker();
export async function d20Roll({parts=[], data={}, event={}, rollMode=null, template=null, title=null, speaker=null,
flavor=null, fastForward=null, dialogOptions,
advantage=null, disadvantage=null, critical=20, fumble=1, targetValue=null,
elvenAccuracy=false, halflingLucky=false, reliableTalent=false,
chatMessage=true, messageData={}}={}) {
// Prepare Message Data
messageData.flavor = flavor || title;
messageData.speaker = speaker || ChatMessage.getSpeaker();
const messageOptions = {rollMode: rollMode || game.settings.get("core", "rollMode")};
parts = parts.concat(["@bonus"]);
rollMode = rollMode || game.settings.get("core", "rollMode");
let rolled = false;
// Define inner roll function
const _roll = function(parts, adv, form=null) {
// Determine the d20 roll and modifiers
// Handle fast-forward events
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;
}
// Define the inner roll function
const _roll = (parts, adv, form) => {
// Determine the d20 roll and modifiers
let nd = 1;
let mods = halflingLucky ? "r=1" : "";
// Handle advantage
if ( adv === 1 ) {
if (adv === 1) {
nd = elvenAccuracy ? 3 : 2;
flavor += ` (${game.i18n.localize("SW5E.Advantage")})`;
mods += "kh";
messageData.flavor += ` (${game.i18n.localize("SW5E.Advantage")})`;
if ( "flags.sw5e.roll" in messageData ) messageData["flags.sw5e.roll"].advantage = true;
mods += "kh";
}
// Handle disadvantage
else if ( adv === -1 ) {
else if (adv === -1) {
nd = 2;
flavor += ` (${game.i18n.localize("SW5E.Disadvantage")})`;
mods += "kl";
messageData.flavor += ` (${game.i18n.localize("SW5E.Disadvantage")})`;
if ( "flags.sw5e.roll" in messageData ) messageData["flags.sw5e.roll"].disadvantage = true;
mods += "kl";
}
// Include the d20 roll
parts.unshift(`${nd}d20${mods}`);
// Prepend the d20 roll
let formula = `${nd}d20${mods}`;
if (reliableTalent) formula = `{${nd}d20${mods},10}kh`;
parts.unshift(formula);
// Optionally include a situational bonus
if ( form !== null ) data['bonus'] = form.bonus.value;
if ( !data["bonus"] ) parts.pop();
if ( form ) {
data['bonus'] = form.bonus.value;
messageOptions.rollMode = form.rollMode.value;
}
if (!data["bonus"]) parts.pop();
// Optionally include an ability score selection (used for tool checks)
const ability = form ? form.ability : null;
if ( ability && ability.value ) {
if (ability && ability.value) {
data.ability = ability.value;
const abl = data.abilities[data.ability];
if ( abl ) {
data.mod = abl.mod;
flavor += ` (${CONFIG.SW5E.abilities[data.ability]})`;
}
if (abl) {
data.mod = abl.mod;
messageData.flavor += ` (${CONFIG.SW5E.abilities[data.ability]})`;
}
}
// Execute the roll and flag critical thresholds on the d20
let roll = new Roll(parts.join(" + "), data).roll();
const d20 = roll.parts[0];
d20.options.critical = critical;
d20.options.fumble = fumble;
if ( targetValue ) d20.options.target = targetValue;
// Convert the roll to a chat message and return the roll
rollMode = form ? form.rollMode.value : rollMode;
roll.toMessage({
speaker: speaker,
flavor: flavor
}, { rollMode });
rolled = true;
return roll;
};
// Determine whether the roll can be fast-forward
if ( fastForward === null ) {
fastForward = event && (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey);
// Execute the roll
let roll = new Roll(parts.join(" + "), data);
try {
roll.roll();
} catch (err) {
console.error(err);
ui.notifications.error(`Dice roll evaluation failed: ${err.message}`);
return null;
}
// Optionally allow fast-forwarding to specify advantage or disadvantage
if ( fastForward ) {
if ( advantage || event.altKey ) return _roll(parts, 1);
else if ( disadvantage || event.ctrlKey || event.metaKey ) return _roll(parts, -1);
else return _roll(parts, 0);
// Flag d20 options for any 20-sided dice in the roll
for (let d of roll.dice) {
if (d.faces === 20) {
d.options.critical = critical;
d.options.fumble = fumble;
if (targetValue) d.options.target = targetValue;
}
}
// If reliable talent was applied, add it to the flavor text
if (reliableTalent && roll.dice[0].total < 10) {
messageData.flavor += ` (${game.i18n.localize("SW5E.FlagsReliableTalent")})`;
}
return roll;
};
// Create the Roll instance
const roll = fastForward ? _roll(parts, adv) :
await _d20RollDialog({template, title, parts, data, rollMode: messageOptions.rollMode, dialogOptions, roll: _roll});
// Create a Chat Message
if ( roll && chatMessage ) roll.toMessage(messageData, messageOptions);
return roll;
}
/* -------------------------------------------- */
/**
* Present a Dialog form which creates a d20 roll once submitted
* @return {Promise<Roll>}
* @private
*/
async function _d20RollDialog({template, title, parts, data, rollMode, dialogOptions, roll}={}) {
// Render modal dialog
template = template || "systems/sw5e/templates/chat/roll-dialog.html";
let dialogData = {
@ -119,7 +151,6 @@ export class Dice5e {
const html = await renderTemplate(template, dialogData);
// Create the Dialog window
let roll;
return new Promise(resolve => {
new Dialog({
title: title,
@ -127,26 +158,24 @@ export class Dice5e {
buttons: {
advantage: {
label: game.i18n.localize("SW5E.Advantage"),
callback: html => roll = _roll(parts, 1, html[0].children[0])
callback: html => resolve(roll(parts, 1, html[0].querySelector("form")))
},
normal: {
label: game.i18n.localize("SW5E.Normal"),
callback: html => roll = _roll(parts, 0, html[0].children[0])
callback: html => resolve(roll(parts, 0, html[0].querySelector("form")))
},
disadvantage: {
label: game.i18n.localize("SW5E.Disadvantage"),
callback: html => roll = _roll(parts, -1, html[0].children[0])
callback: html => resolve(roll(parts, -1, html[0].querySelector("form")))
}
},
default: "normal",
close: html => {
if (onClose) onClose(html, parts, data);
resolve(rolled ? roll : false)
}
close: () => resolve(null)
}, dialogOptions).render(true);
})
});
}
/* -------------------------------------------- */
/**
@ -169,83 +198,103 @@ export class Dice5e {
* @param {Boolean} fastForward Allow fast-forward advantage selection
* @param {Function} onClose Callback for actions to take when the dialog form is closed
* @param {Object} dialogOptions Modal dialog options
* @param {boolean} chatMessage Automatically create a Chat Message for the result of this roll
* @param {object} messageData Additional data which is applied to the created Chat Message, if any
*
* @return {Promise} A Promise which resolves once the roll workflow has completed
*/
static async damageRoll({parts, actor, data, event={}, rollMode=null, template, title, speaker, flavor,
allowCritical=true, critical=false, fastForward=null, onClose, dialogOptions}) {
// Handle input arguments
flavor = flavor || title;
speaker = speaker || ChatMessage.getSpeaker();
rollMode = game.settings.get("core", "rollMode");
let rolled = false;
export async function damageRoll({parts, actor, data, event={}, rollMode=null, template, title, speaker, flavor,
allowCritical=true, critical=false, fastForward=null, dialogOptions, chatMessage=true, messageData={}}={}) {
// Prepare Message Data
messageData.flavor = flavor || title;
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) {
data['bonus'] = form ? form.bonus.value : 0;
// Optionally include a situational bonus
if ( form ) {
data['bonus'] = form.bonus.value;
messageOptions.rollMode = form.rollMode.value;
}
if (!data["bonus"]) parts.pop();
// Create the damage roll
let roll = new Roll(parts.join("+"), data);
// Modify the damage formula for critical hits
if ( crit === true ) {
let add = (actor && actor.getFlag("sw5e", "savageAttacks")) ? 1 : 0;
let mult = 2;
roll.alter(add, mult);
flavor = `${flavor} (${game.i18n.localize("SW5E.Critical")})`;
}
// Convert the roll to a chat message
rollMode = form ? form.rollMode.value : rollMode;
roll.toMessage({
speaker: speaker,
flavor: flavor
}, { rollMode });
rolled = true;
return roll;
};
// Determine whether the roll can be fast-forward
if ( fastForward === null ) {
fastForward = event && (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey);
// Modify the damage formula for critical hits
if ( crit === true ) {
let add = (actor && actor.getFlag("sw5e", "savageAttacks")) ? 1 : 0;
let mult = 2;
// TODO Backwards compatibility - REMOVE LATER
if (isNewerVersion(game.data.version, "0.6.9")) roll.alter(mult, add);
else roll.alter(add, mult);
messageData.flavor += ` (${game.i18n.localize("SW5E.Critical")})`;
if ( "flags.sw5e.roll" in messageData ) messageData["flags.sw5e.roll"].critical = true;
}
// Modify the roll and handle fast-forwarding
if ( fastForward ) return _roll(parts, critical || event.altKey);
else parts = parts.concat(["@bonus"]);
// Execute the roll
try {
return roll.roll();
} catch(err) {
console.error(err);
ui.notifications.error(`Dice roll evaluation failed: ${err.message}`);
return null;
}
};
// Render modal dialog
template = template || "systems/sw5e/templates/chat/roll-dialog.html";
let dialogData = {
formula: parts.join(" + "),
data: data,
rollMode: rollMode,
rollModes: CONFIG.Dice.rollModes
};
const html = await renderTemplate(template, dialogData);
// Create the Roll instance
const roll = fastForward ? _roll(parts, critical || event.altKey) : await _damageRollDialog({
template, title, parts, data, allowCritical, rollMode: messageOptions.rollMode, dialogOptions, roll: _roll
});
// Create a Chat Message
if ( roll && chatMessage ) roll.toMessage(messageData, messageOptions);
return roll;
// Create the Dialog window
let roll;
return new Promise(resolve => {
new Dialog({
title: title,
content: html,
buttons: {
critical: {
condition: allowCritical,
label: game.i18n.localize("SW5E.CriticalHit"),
callback: html => roll = _roll(parts, true, html[0].children[0])
},
normal: {
label: game.i18n.localize(allowCritical ? "SW5E.Normal" : "SW5E.Roll"),
callback: html => roll = _roll(parts, false, html[0].children[0])
},
},
default: "normal",
close: html => {
if (onClose) onClose(html, parts, data);
resolve(rolled ? roll : false);
}
}, dialogOptions).render(true);
});
}
}
/* -------------------------------------------- */
/**
* Present a Dialog form which creates a damage roll once submitted
* @return {Promise<Roll>}
* @private
*/
async function _damageRollDialog({template, title, parts, data, allowCritical, rollMode, dialogOptions, roll}={}) {
// Render modal dialog
template = template || "systems/sw5e/templates/chat/roll-dialog.html";
let dialogData = {
formula: parts.join(" + "),
data: data,
rollMode: rollMode,
rollModes: CONFIG.Dice.rollModes
};
const html = await renderTemplate(template, dialogData);
// Create the Dialog window
return new Promise(resolve => {
new Dialog({
title: title,
content: html,
buttons: {
critical: {
condition: allowCritical,
label: game.i18n.localize("SW5E.CriticalHit"),
callback: html => resolve(roll(parts, true, html[0].querySelector("form")))
},
normal: {
label: game.i18n.localize(allowCritical ? "SW5E.Normal" : "SW5E.Roll"),
callback: html => resolve(roll(parts, false, html[0].querySelector("form")))
},
},
default: "normal",
close: () => resolve(null)
}, dialogOptions).render(true);
});
}

View file

@ -1,11 +1,11 @@
import { Dice5e } from "../dice.js";
import { AbilityUseDialog } from "../apps/ability-use-dialog.js";
import { AbilityTemplate } from "../pixi/ability-template.js";
import {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
*/
export class Item5e extends Item {
export default class Item5e extends Item {
/* -------------------------------------------- */
/* Item Properties */
@ -20,14 +20,34 @@ export class Item5e extends Item {
if (!("ability" in itemData)) return null;
// Case 1 - defined directly by the item
if ( itemData.ability ) return itemData.ability;
if (itemData.ability) return itemData.ability;
// Case 2 - inferred from a parent actor
else if ( this.actor ) {
else if (this.actor) {
const actorData = this.actor.data.data;
if ( this.data.type === "power" ) return actorData.attributes.powercasting || "int";
else if ( this.data.type === "tool" ) return "int";
else return "str";
// Powers - Use Actor powercasting modifier
if (this.data.type === "power") return actorData.attributes.powercasting || "int";
// Tools - default to Intelligence
else if (this.data.type === "tool") return "int";
// Weapons
else if (this.data.type === "weapon") {
const wt = itemData.weaponType;
// Melee weapons - Str or Dex if Finesse (PHB pg. 147)
if ( ["simpleVW", "martialVW", "simpleLW", "martialLW"].includes(wt) ) {
if (itemData.properties.fin === true) { // Finesse weapons
return (actorData.abilities["dex"].mod >= actorData.abilities["str"].mod) ? "dex" : "str";
}
return "str";
}
// Ranged weapons - Dex (PH p.194)
else if ( ["simpleB", "martialB"].includes(wt) ) return "dex";
}
return "str";
}
// Case 3 - unknown
@ -149,15 +169,16 @@ export class Item5e extends Item {
arr.push(c[0].titleCase().slice(0, 1));
return arr;
}, []);
labels.materials = data?.materials?.value ?? null;
}
// Feat Items
else if ( itemData.type === "feat" ) {
const act = data.activation;
if ( act && (act.type === C.abilityActivationTypes.legendary) ) labels.featType = "Legendary Action";
else if ( act && (act.type === C.abilityActivationTypes.lair) ) labels.featType = "Lair Action";
else if ( act && act.type ) labels.featType = data.damage.length ? "Attack" : "Action";
else labels.featType = "Passive";
if ( act && (act.type === C.abilityActivationTypes.legendary) ) labels.featType = game.i18n.localize("SW5E.LegendaryActionLabel");
else if ( act && (act.type === C.abilityActivationTypes.lair) ) labels.featType = game.i18n.localize("SW5E.LairActionLabel");
else if ( act && act.type ) labels.featType = game.i18n.localize(data.damage.length ? "SW5E.Attack" : "SW5E.Action");
else labels.featType = game.i18n.localize("SW5E.Passive");
}
// Species Items
@ -167,7 +188,7 @@ export class Item5e extends Item {
// Equipment Items
else if ( itemData.type === "equipment" ) {
labels.armor = data.armor.value ? `${data.armor.value} AC` : "";
labels.armor = data.armor.value ? `${data.armor.value} ${game.i18n.localize("SW5E.AC")}` : "";
}
// Activated Items
@ -201,7 +222,7 @@ export class Item5e extends Item {
// Recharge Label
let chg = data.recharge || {};
labels.recharge = `Recharge [${chg.value}${parseInt(chg.value) < 6 ? "+" : ""}]`;
labels.recharge = `${game.i18n.localize("SW5E.Recharge")} [${chg.value}${parseInt(chg.value) < 6 ? "+" : ""}]`;
}
// Item Actions
@ -216,7 +237,7 @@ export class Item5e extends Item {
} else { // Un-owned items
if ( save.scaling !== "flat" ) save.dc = null;
}
labels.save = save.ability ? `DC ${save.dc || ""} ${C.abilities[save.ability]}` : "";
labels.save = save.ability ? `${game.i18n.localize("SW5E.AbbreviationDC")} ${save.dc || ""} ${C.abilities[save.ability]}` : "";
// Damage
let dam = data.damage || {};
@ -234,9 +255,13 @@ export class Item5e extends Item {
/**
* Roll the item to Chat, creating a chat card which contains follow up attack or damage roll options
* @param {boolean} [configureDialog] Display a configuration dialog for the item roll, if applicable?
* @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}
*/
async roll({configureDialog=true}={}) {
async roll({configureDialog=true, rollMode=null, createMessage=true}={}) {
// Basic template rendering data
const token = this.actor.token;
@ -259,10 +284,17 @@ export class Item5e extends Item {
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", "consumable"].includes(this.data.type) ? this.data.type : "item";
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);
@ -279,12 +311,90 @@ export class Item5e extends Item {
};
// Toggle default roll mode
let rollMode = game.settings.get("core", "rollMode");
if ( ["gmroll", "blindroll"].includes(rollMode) ) chatData["whisper"] = ChatMessage.getWhisperIDs("GM");
rollMode = rollMode || game.settings.get("core", "rollMode");
if ( ["gmroll", "blindroll"].includes(rollMode) ) chatData["whisper"] = ChatMessage.getWhisperRecipients("GM");
if ( rollMode === "blindroll" ) chatData["blind"] = true;
// Create the chat message
return ChatMessage.create(chatData);
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];
const amount = parseInt(consume.amount || 1);
// 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 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);
quantity = consumed ? consumed.data.data.uses.value : 0;
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":
await consumed.update({"data.uses.value": remaining});
}
return true;
}
/* -------------------------------------------- */
@ -298,27 +408,35 @@ export class Item5e extends Item {
if ( this.data.type !== "feat" ) throw new Error("Wrong Item type");
// Configure whether to consume a limited use or to place a template
const usesRecharge = !!this.data.data.recharge.value;
const charge = this.data.data.recharge;
const uses = this.data.data.uses;
let usesCharges = !!uses.per && (uses.max > 0);
let placeTemplate = false;
let consume = usesRecharge || usesCharges;
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("consume"));
consume = Boolean(usage.get("consumeUse"));
placeTemplate = Boolean(usage.get("placeTemplate"));
}
// Update Item data
const current = getProperty(this.data, "data.uses.value") || 0;
if ( consume && usesRecharge ) {
await this.update({"data.recharge.charged": false});
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)});
}
@ -340,14 +458,13 @@ export class Item5e extends Item {
* @param {Object} htmlOptions Options used by the TextEditor.enrichHTML function
* @return {Object} An object of chat data to render
*/
getChatData(htmlOptions) {
getChatData(htmlOptions={}) {
const data = duplicate(this.data.data);
const labels = this.labels;
// Rich text description
data.description.value = TextEditor.enrichHTML(data.description.value, htmlOptions);
// Item type specific properties
const props = [];
const fn = this[`_${this.data.type}ChatData`];
@ -356,16 +473,16 @@ export class Item5e extends Item {
// General equipment properties
if ( data.hasOwnProperty("equipped") && !["loot", "tool"].includes(this.data.type) ) {
props.push(
data.equipped ? "Equipped" : "Not Equipped",
data.proficient ? "Proficient": "Not Proficient",
game.i18n.localize(data.equipped ? "SW5E.Equipped" : "SW5E.Unequipped"),
game.i18n.localize(data.proficient ? "SW5E.Proficient" : "SW5E.NotProficient"),
);
}
// Ability activation properties
if ( data.hasOwnProperty("activation") ) {
props.push(
labels.activation + (data.activation?.condition ? ` (${data.activation.condition})` : ""),
labels.target,
labels.activation,
labels.range,
labels.duration
);
@ -386,7 +503,7 @@ export class Item5e extends Item {
props.push(
CONFIG.SW5E.equipmentTypes[data.armor.type],
labels.armor || null,
data.stealth.value ? "Stealth Disadvantage" : null,
data.stealth.value ? game.i18n.localize("SW5E.StealthDisadvantage") : null
);
}
@ -411,7 +528,7 @@ export class Item5e extends Item {
_consumableChatData(data, labels, props) {
props.push(
CONFIG.SW5E.consumableTypes[data.consumableType],
data.uses.value + "/" + data.uses.max + " Charges"
data.uses.value + "/" + data.uses.max + " " + game.i18n.localize("SW5E.Charges")
);
data.hasCharges = data.uses.value >= 0;
}
@ -437,8 +554,8 @@ export class Item5e extends Item {
*/
_lootChatData(data, labels, props) {
props.push(
"Loot",
data.weight ? data.weight + " lbs." : null
game.i18n.localize("SW5E.ItemTypeLoot"),
data.weight ? data.weight + " " + game.i18n.localize("SW5E.AbbreviationLbs") : null
);
}
@ -452,7 +569,7 @@ export class Item5e extends Item {
_powerChatData(data, labels, props) {
props.push(
labels.level,
labels.components,
labels.components + (labels.materials ? ` (${labels.materials})` : "")
);
}
@ -472,17 +589,19 @@ export class Item5e extends Item {
/**
* Place an attack roll using an item (weapon, feat, power, or equipment)
* Rely upon the Dice5e.d20Roll logic for the core implementation
* Rely upon the d20Roll logic for the core implementation
*
* @return {Promise.<Roll>} A Promise which resolves to the created Roll instance
* @param {object} options Roll options which are configured and provided to the d20Roll function
* @return {Promise<Roll|null>} A Promise which resolves to the created Roll instance
*/
rollAttack(options={}) {
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
@ -492,26 +611,44 @@ export class Item5e extends Item {
}
// Attack Bonus
const actorBonus = actorData.bonuses[itemData.actionType] || {};
const actorBonus = actorData?.bonuses?.[itemData.actionType] || {};
if ( itemData.attackBonus || actorBonus.attack ) {
parts.push("@atk");
rollData["atk"] = [itemData.attackBonus, actorBonus.attack].filterJoin(" + ");
}
// Ammunition Bonus
delete this._ammo;
const consume = itemData.consume;
if ( consume?.type === "ammo" ) {
const ammo = this.actor.items.get(consume.target);
const q = ammo.data.data.quantity;
if ( q && (q - consume.amount >= 0) ) {
let ammoBonus = ammo.data.data.attackBonus;
if ( ammoBonus ) {
parts.push("@ammo");
rollData["ammo"] = ammoBonus;
title += ` [${ammo.name}]`;
this._ammo = ammo;
}
}
}
// Compose roll options
const rollConfig = {
event: options.event,
const rollConfig = mergeObject({
parts: parts,
actor: this.actor,
data: rollData,
title: `${this.name} - Attack Roll`,
title: title,
speaker: ChatMessage.getSpeaker({actor: this.actor}),
dialogOptions: {
width: 400,
top: options.event ? options.event.clientY - 80 : null,
left: window.innerWidth - 710
}
};
},
messageData: {"flags.sw5e.roll": {type: "attack", itemId: this.id }}
}, options);
rollConfig.event = options.event;
// Expanded weapon critical threshold
if (( this.data.type === "weapon" ) && flags.weaponCriticalThreshold) {
@ -529,16 +666,22 @@ export class Item5e extends Item {
if ( flags.halflingLucky ) rollConfig.halflingLucky = true;
// Invoke the d20 roll helper
return Dice5e.d20Roll(rollConfig);
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;
return roll;
}
/* -------------------------------------------- */
/**
* Place a damage roll using an item (weapon, feat, power, or equipment)
* Rely upon the Dice5e.damageRoll logic for the core implementation
* Rely upon the damageRoll logic for the core implementation
*
* @return {Promise.<Roll>} A Promise which resolves to the created Roll instance
* @return {Promise<Roll>} A Promise which resolves to the created Roll instance
*/
rollDamage({event, powerLevel=null, versatile=false}={}) {
const itemData = this.data.data;
@ -546,32 +689,54 @@ export class Item5e extends Item {
if ( !this.hasDamage ) {
throw new Error("You may not make a Damage Roll with this Item.");
}
const messageData = {"flags.sw5e.roll": {type: "damage", itemId: this.id }};
// Get roll data
const rollData = this.getRollData();
if ( powerLevel ) rollData.item.level = powerLevel;
// Get message labels
const title = `${this.name} - ${game.i18n.localize("SW5E.DamageRoll")}`;
let flavor = this.labels.damageTypes.length ? `${title} (${this.labels.damageTypes})` : title;
// Define Roll parts
const parts = itemData.damage.parts.map(d => d[0]);
if ( versatile && itemData.damage.versatile ) parts[0] = itemData.damage.versatile;
// Adjust damage from versatile usage
if ( versatile && itemData.damage.versatile ) {
parts[0] = itemData.damage.versatile;
messageData["flags.sw5e.roll"].versatile = true;
}
// Scale damage from up-casting powers
if ( (this.data.type === "power") ) {
if ( (itemData.scaling.mode === "atwill") ) {
const lvl = this.actor.data.type === "character" ? actorData.details.level : actorData.details.powerLevel;
this._scaleAtWillDamage(parts, lvl, itemData.scaling.formula );
} else if ( powerLevel && (itemData.scaling.mode === "level") && itemData.scaling.formula ) {
this._scalePowerDamage(parts, itemData.level, powerLevel, itemData.scaling.formula );
if ( (itemData.scaling.mode === "cantrip") ) {
const level = this.actor.data.type === "character" ? actorData.details.level : actorData.details.powerLevel;
this._scaleCantripDamage(parts, itemData.scaling.formula, level, rollData);
}
else if ( powerLevel && (itemData.scaling.mode === "level") && itemData.scaling.formula ) {
const scaling = itemData.scaling.formula;
this._scalePowerDamage(parts, itemData.level, powerLevel, scaling, rollData);
}
}
// Define Roll Data
const actorBonus = actorData.bonuses[itemData.actionType] || {};
const actorBonus = getProperty(actorData, `bonuses.${itemData.actionType}`) || {};
if ( actorBonus.damage && parseInt(actorBonus.damage) !== 0 ) {
parts.push("@dmg");
rollData["dmg"] = actorBonus.damage;
}
// Ammunition Damage
if ( this._ammo ) {
parts.push("@ammo");
rollData["ammo"] = this._ammo.data.data.damage.parts.map(p => p[0]).join("+");
flavor += ` [${this._ammo.name}]`;
delete this._ammo;
}
// Call the roll helper utility
const title = `${this.name} - Damage Roll`;
const flavor = this.labels.damageTypes.length ? `${title} (${this.labels.damageTypes})` : title;
return Dice5e.damageRoll({
return damageRoll({
event: event,
parts: parts,
actor: this.actor,
@ -583,7 +748,8 @@ export class Item5e extends Item {
width: 400,
top: event ? event.clientY - 80 : null,
left: window.innerWidth - 710
}
},
messageData
});
}
@ -593,13 +759,24 @@ export class Item5e extends Item {
* Adjust an at-will damage formula to scale it for higher level characters and monsters
* @private
*/
_scaleAtWillDamage(parts, level, scale) {
_scaleAtWillDamage(parts, scale, level, rollData) {
const add = Math.floor((level + 1) / 6);
if ( add === 0 ) return;
if ( scale && (scale !== parts[0]) ) {
parts[0] = parts[0] + " + " + scale.replace(new RegExp(Roll.diceRgx, "g"), (match, nd, d) => `${add}d${d}`);
} else {
parts[0] = parts[0].replace(new RegExp(Roll.diceRgx, "g"), (match, nd, d) => `${parseInt(nd)+add}d${d}`);
// FUTURE SOLUTION - 0.7.0 AND LATER
if (isNewerVersion(game.data.version, "0.6.9")) {
this._scaleDamage(parts, scale || parts.join(" + "), add, rollData)
}
// LEGACY SOLUTION - 0.6.x AND OLDER
// TODO: Deprecate the legacy solution one FVTT 0.7.x is RELEASE
else {
if ( scale && (scale !== parts[0]) ) {
parts[0] = parts[0] + " + " + scale.replace(new RegExp(Roll.diceRgx, "g"), (match, nd, d) => `${add}d${d}`);
} else {
parts[0] = parts[0].replace(new RegExp(Roll.diceRgx, "g"), (match, nd, d) => `${parseInt(nd)+add}d${d}`);
}
}
}
@ -611,13 +788,61 @@ export class Item5e extends Item {
* @param {number} baseLevel The default power level
* @param {number} powerLevel The casted power level
* @param {string} formula The scaling formula
* @param {object} rollData A data object that should be applied to the scaled damage roll
* @return {string[]} The scaled roll parts
* @private
*/
_scalePowerDamage(parts, baseLevel, powerLevel, formula) {
_scalePowerDamage(parts, baseLevel, powerLevel, formula, rollData) {
const upcastLevels = Math.max(powerLevel - baseLevel, 0);
if ( upcastLevels === 0 ) return parts;
const bonus = new Roll(formula).alter(0, upcastLevels);
parts.push(bonus.formula);
// FUTURE SOLUTION - 0.7.0 AND LATER
if (isNewerVersion(game.data.version, "0.6.9")) {
this._scaleDamage(parts, formula, upcastLevels, rollData);
}
// LEGACY SOLUTION - 0.6.x AND OLDER
// TODO: Deprecate the legacy solution one FVTT 0.7.x is RELEASE
else {
const bonus = new Roll(formula);
bonus.alter(0, upcastLevels);
parts.push(bonus.formula);
}
return parts;
}
/* -------------------------------------------- */
/**
* Scale an array of damage parts according to a provided scaling formula and scaling multiplier
* @param {string[]} parts Initial roll parts
* @param {string} scaling A scaling formula
* @param {number} times A number of times to apply the scaling formula
* @param {object} rollData A data object that should be applied to the scaled damage roll
* @return {string[]} The scaled roll parts
* @private
*/
_scaleDamage(parts, scaling, times, rollData) {
if ( times <= 0 ) return parts;
const p0 = new Roll(parts[0], rollData);
const s = new Roll(scaling, rollData).alter(times);
// Attempt to simplify by combining like dice terms
let simplified = false;
if ( (s.terms[0] instanceof Die) && (s.terms.length === 1) ) {
const d0 = p0.terms[0];
const s0 = s.terms[0];
if ( (d0 instanceof Die) && (d0.faces === s0.faces) && d0.modifiers.equals(s0.modifiers) ) {
d0.number += s0.number;
parts[0] = p0.formula;
simplified = true;
}
}
// Otherwise add to the first part
if ( !simplified ) {
parts[0] = `${parts[0]} + ${s.formula}`;
}
return parts;
}
@ -625,8 +850,8 @@ export class Item5e extends Item {
/**
* Place an attack roll using an item (weapon, feat, power, or equipment)
* Rely upon the Dice5e.d20Roll logic for the core implementation
*
* Rely upon the d20Roll logic for the core implementation
*
* @return {Promise.<Roll>} A Promise which resolves to the created Roll instance
*/
async rollFormula(options={}) {
@ -636,14 +861,16 @@ export class Item5e extends Item {
// Define Roll Data
const rollData = this.getRollData();
const title = `${this.name} - Other Formula`;
if ( options.powerLevel ) rollData.item.level = options.powerLevel;
const title = `${this.name} - ${game.i18n.localize("SW5E.OtherFormula")}`;
// Invoke the roll and submit it to chat
const roll = new Roll(rollData.item.formula, rollData).roll();
roll.toMessage({
speaker: ChatMessage.getSpeaker({actor: this.actor}),
flavor: this.data.data.chatFlavor || title,
rollMode: game.settings.get("core", "rollMode")
rollMode: game.settings.get("core", "rollMode"),
messageData: {"flags.sw5e.roll": {type: "other", itemId: this.id }}
});
return roll;
}
@ -652,58 +879,77 @@ export class Item5e extends Item {
/**
* Use a consumable item, deducting from the quantity or charges of the item.
*
* @return {Promise.<Roll>} A Promise which resolves to the created Roll instance or null
* @param {boolean} configureDialog Whether to show a configuration dialog
* @return {boolean} Whether further execution should be prevented
* @private
*/
async rollConsumable(options={}) {
async _rollConsumable(configureDialog) {
if ( this.data.type !== "consumable" ) throw new Error("Wrong Item type");
const itemData = this.data.data;
// Dispatch a damage roll
let roll = null;
if ( itemData.damage.parts.length ) {
roll = await this.rollDamage(options);
// 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"));
}
// Dispatch an other formula
if ( itemData.formula ) {
roll = await this.rollFormula(options);
}
// Deduct consumed charges from the item
if ( itemData.uses.autoUse ) {
let q = itemData.quantity;
let c = itemData.uses.value;
// Deduct an item quantity
if ( c <= 1 && q > 1 ) {
await this.update({
'data.quantity': Math.max(q - 1, 0),
'data.uses.value': itemData.uses.max
});
}
// Optionally destroy the item
else if ( c <= 1 && q <= 1 && itemData.uses.autoDestroy ) {
await this.actor.deleteOwnedItem(this.id);
}
// Deduct the remaining charges
// 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 {
await this.update({'data.uses.value': Math.max(c - 1, 0)});
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}));
}
}
}
return roll;
}
// Maybe initiate template placement workflow
if ( this.hasAreaTarget && placeTemplate ) {
const template = AbilityTemplate.fromItem(this);
if ( template ) template.drawPreview(event);
if ( this.owner && this.owner.sheet ) this.owner.sheet.minimize();
}
return true;
}
/* -------------------------------------------- */
/**
* Perform an ability recharge test for an item which uses the d6 recharge mechanic
* @prarm {Object} options
*
* @return {Promise.<Roll>} A Promise which resolves to the created Roll instance
* @return {Promise<Roll>} A Promise which resolves to the created Roll instance
*/
async rollRecharge(options={}) {
async rollRecharge() {
const data = this.data.data;
if ( !data.recharge.value ) return;
@ -713,7 +959,7 @@ export class Item5e extends Item {
// Display a Chat Message
const promises = [roll.toMessage({
flavor: `${this.name} recharge check - ${success ? "success!" : "failure!"}`,
flavor: `${game.i18n.format("SW5E.ItemRechargeCheck", {name: this.name})} - ${game.i18n.localize(success ? "SW5E.ItemRechargeSuccess" : "SW5E.ItemRechargeFailure")}`,
speaker: ChatMessage.getSpeaker({actor: this.actor, token: this.actor.token})
})];
@ -725,10 +971,9 @@ export class Item5e extends Item {
/* -------------------------------------------- */
/**
* Roll a Tool Check
* Rely upon the Dice5e.d20Roll logic for the core implementation
*
* @return {Promise.<Roll>} A Promise which resolves to the created Roll instance
* Roll a Tool Check. Rely upon the d20Roll logic for the core implementation
* @prarm {Object} options Roll configuration options provided to the d20Roll function
* @return {Promise<Roll>} A Promise which resolves to the created Roll instance
*/
rollToolCheck(options={}) {
if ( this.type !== "tool" ) throw "Wrong item type!";
@ -736,24 +981,28 @@ export class Item5e extends Item {
// Prepare roll data
let rollData = this.getRollData();
const parts = [`@mod`, "@prof"];
const title = `${this.name} - Tool Check`;
const title = `${this.name} - ${game.i18n.localize("SW5E.ToolCheck")}`;
// Call the roll helper utility
return Dice5e.d20Roll({
event: options.event,
// Compose the roll data
const rollConfig = mergeObject({
parts: parts,
data: rollData,
template: "systems/sw5e/templates/chat/tool-roll-dialog.html",
title: title,
speaker: ChatMessage.getSpeaker({actor: this.actor}),
flavor: `${this.name} - Tool Check`,
flavor: `${this.name} - ${game.i18n.localize("SW5E.ToolCheck")}`,
dialogOptions: {
width: 400,
top: options.event ? options.event.clientY - 80 : null,
left: window.innerWidth - 710,
},
halflingLucky: this.actor.getFlag("sw5e", "halflingLucky" ) || false
});
halflingLucky: this.actor.getFlag("sw5e", "halflingLucky" ) || false,
messageData: {"flags.sw5e.roll": {type: "tool", itemId: this.id }}
}, options);
rollConfig.event = options.event;
// Call the roll helper utility
return d20Roll(rollConfig);
}
/* -------------------------------------------- */
@ -819,7 +1068,7 @@ export class Item5e extends Item {
// Get the Item
const item = actor.getOwnedItem(card.dataset.itemId);
if ( !item ) {
return ui.notifications.error(`The requested item ${card.dataset.itemId} no longer exists on Actor ${actor.name}`)
return ui.notifications.error(game.i18n.format("SW5E.ActionWarningNoItem", {item: card.dataset.itemId, name: actor.name}))
}
const powerLevel = parseInt(card.dataset.powerLevel) || null;
@ -828,7 +1077,7 @@ export class Item5e extends Item {
if ( isTargetted ) {
targets = this._getChatCardTargets(card);
if ( !targets.length ) {
ui.notifications.warn(`You must have one or more controlled Tokens in order to use this option.`);
ui.notifications.warn(game.i18n.localize("SW5E.ActionWarningNoToken"));
return button.disabled = false;
}
}
@ -837,18 +1086,16 @@ export class Item5e extends Item {
if ( action === "attack" ) await item.rollAttack({event});
else if ( action === "damage" ) await item.rollDamage({event, powerLevel});
else if ( action === "versatile" ) await item.rollDamage({event, powerLevel, versatile: true});
else if ( action === "formula" ) await item.rollFormula({event});
else if ( action === "formula" ) await item.rollFormula({event, powerLevel});
// Saving Throws for card targets
else if ( action === "save" ) {
for ( let t of targets ) {
await t.rollAbilitySave(button.dataset.ability, {event});
for ( let a of targets ) {
const speaker = ChatMessage.getSpeaker({scene: canvas.scene, token: a.token});
await a.rollAbilitySave(button.dataset.ability, { event, speaker });
}
}
// Consumable usage
else if ( action === "consume" ) await item.rollConsumable({event});
// Tool usage
else if ( action === "toolCheck" ) await item.rollToolCheck({event});
@ -919,4 +1166,56 @@ export class Item5e extends Item {
if ( character && (controlled.length === 0) ) targets.push(character);
return targets;
}
/* -------------------------------------------- */
/* Factory Methods */
/* -------------------------------------------- */
/**
* Create a consumable power scroll Item from a power Item.
* @param {Item5e} power The power to be made into a scroll
* @return {Item5e} The created scroll consumable item
* @private
*/
static async createScrollFromPower(power) {
// Get power data
const itemData = power instanceof Item5e ? power.data : power;
const {actionType, description, source, activation, duration, target, range, damage, save, level} = itemData.data;
// Get scroll data
const scrollUuid = CONFIG.SW5E.powerScrollIds[level];
const scrollItem = await fromUuid(scrollUuid);
const scrollData = scrollItem.data;
delete scrollData._id;
// Split the scroll description into an intro paragraph and the remaining details
const scrollDescription = scrollData.data.description.value;
const pdel = '</p>';
const scrollIntroEnd = scrollDescription.indexOf(pdel);
const scrollIntro = scrollDescription.slice(0, scrollIntroEnd + pdel.length);
const scrollDetails = scrollDescription.slice(scrollIntroEnd + pdel.length);
// Create a composite description from the scroll description and the power details
const desc = `${scrollIntro}<hr/><h3>${itemData.name} (Level ${level})</h3><hr/>${description.value}<hr/><h3>Scroll Details</h3><hr/>${scrollDetails}`;
// Create the power scroll data
const powerScrollData = mergeObject(scrollData, {
name: `${game.i18n.localize("SW5E.PowerScroll")}: ${itemData.name}`,
img: itemData.img,
data: {
"description.value": desc.trim(),
source,
actionType,
activation,
duration,
target,
range,
damage,
save,
level
}
});
return new this(powerScrollData);
}
}

View file

@ -1,11 +1,20 @@
import { TraitSelector } from "../apps/trait-selector.js";
import TraitSelector from "../apps/trait-selector.js";
/**
* Override and extend the core ItemSheet implementation to handle SW5E specific item types
* @type {ItemSheet}
* Override and extend the core ItemSheet implementation to handle specific item types
* @extends {ItemSheet}
*/
export class ItemSheet5e extends ItemSheet {
export default class ItemSheet5e extends ItemSheet {
constructor(...args) {
super(...args);
if ( this.object.data.type === "class" ) {
this.options.resizable = true;
this.options.width = 600;
this.options.height = 640;
}
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
@ -13,7 +22,7 @@ export class ItemSheet5e extends ItemSheet {
width: 560,
height: 420,
classes: ["sw5e", "sheet", "item"],
resizable: false,
resizable: true,
scrollY: [".tab.details"],
tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}]
});
@ -43,14 +52,79 @@ export class ItemSheet5e extends ItemSheet {
data.itemProperties = this._getItemProperties(data.item);
data.isPhysical = data.item.data.hasOwnProperty("quantity");
// Potential consumption targets
data.abilityConsumptionTargets = this._getItemConsumptionTargets(data.item);
// Action Details
data.hasAttackRoll = this.item.hasAttack;
data.isHealing = data.item.data.actionType === "heal";
data.isFlatDC = getProperty(data.item.data, "save.scaling") === "flat";
data.isWeapon = data.item.type === "weapon";
// Vehicles
data.isCrewed = data.item.data.activation?.type === 'crew';
data.isMountable = this._isItemMountable(data.item);
return data;
}
/* -------------------------------------------- */
/**
* Get the valid item consumption targets which exist on the actor
* @param {Object} item Item data for the item being displayed
* @return {{string: string}} An object of potential consumption targets
* @private
*/
_getItemConsumptionTargets(item) {
const consume = item.data.consume || {};
if ( !consume.type ) return [];
const actor = this.item.actor;
if ( !actor ) return {};
// Ammunition
if ( consume.type === "ammo" ) {
return actor.itemTypes.consumable.reduce((ammo, i) => {
if ( i.data.data.consumableType === "ammo" ) {
ammo[i.id] = `${i.name} (${i.data.data.quantity})`;
}
return ammo;
}, {});
}
// Attributes
else if ( consume.type === "attribute" ) {
const attributes = Object.values(CombatTrackerConfig.prototype.getAttributeChoices())[0]; // Bit of a hack
return attributes.reduce((obj, a) => {
obj[a] = a;
return obj;
}, {});
}
// Materials
else if ( consume.type === "material" ) {
return actor.items.reduce((obj, i) => {
if ( ["consumable", "loot"].includes(i.data.type) && !i.data.data.activation ) {
obj[i.id] = `${i.name} (${i.data.data.quantity})`;
}
return obj;
}, {});
}
// Charges
else if ( consume.type === "charges" ) {
return actor.items.reduce((obj, i) => {
const uses = i.data.data.uses || {};
if ( uses.per && uses.max ) {
const label = uses.per === "charges" ?
` (${game.i18n.format("SW5E.AbilityUseChargesLabel", {value: uses.value})})` :
` (${game.i18n.format("SW5E.AbilityUseConsumableLabel", {max: uses.max, per: uses.per})})`;
obj[i.id] = i.name + label;
}
return obj;
}, {})
}
else return {};
}
/* -------------------------------------------- */
/**
@ -63,10 +137,10 @@ export class ItemSheet5e extends ItemSheet {
return CONFIG.SW5E.powerPreparationModes[item.data.preparation];
}
else if ( ["weapon", "equipment"].includes(item.type) ) {
return item.data.equipped ? "Equipped" : "Unequipped";
return game.i18n.localize(item.data.equipped ? "SW5E.Equipped" : "SW5E.Unequipped");
}
else if ( item.type === "tool" ) {
return item.data.proficient ? "Proficient" : "Not Proficient";
return game.i18n.localize(item.data.proficient ? "SW5E.Proficient" : "SW5E.NotProficient");
}
}
@ -91,8 +165,8 @@ export class ItemSheet5e extends ItemSheet {
props.push(
labels.components,
labels.materials,
item.data.components.concentration ? "Concentration" : null,
item.data.components.ritual ? "Ritual" : null
item.data.components.concentration ? game.i18n.localize("SW5E.Concentration") : null,
item.data.components.ritual ? game.i18n.localize("SW5E.Ritual") : null
)
}
@ -128,6 +202,22 @@ export class ItemSheet5e extends ItemSheet {
/* -------------------------------------------- */
/**
* Is this item a separate large object like a siege engine or vehicle
* component that is usually mounted on fixtures rather than equipped, and
* has its own AC and HP.
* @param item
* @returns {boolean}
* @private
*/
_isItemMountable(item) {
const data = item.data;
return (item.type === 'weapon' && data.weaponType === 'siege')
|| (item.type === 'equipment' && data.armor.type === 'vehicle');
}
/* -------------------------------------------- */
/** @override */
setPosition(position={}) {
position.height = this._tabs[0].active === "details" ? "auto" : this.options.height;
@ -141,33 +231,12 @@ export class ItemSheet5e extends ItemSheet {
/** @override */
_updateObject(event, formData) {
// TODO: This can be removed once 0.7.x is release channel
if ( !formData.data ) formData = expandObject(formData);
// Handle Damage Array
let damage = Object.entries(formData).filter(e => e[0].startsWith("data.damage.parts"));
formData["data.damage.parts"] = damage.reduce((arr, entry) => {
let [i, j] = entry[0].split(".").slice(3);
if ( !arr[i] ) arr[i] = [];
arr[i][j] = entry[1];
return arr;
}, []);
// Handle armorproperties Array
let armorproperties = Object.entries(formData).filter(e => e[0].startsWith("data.armorproperties.parts"));
formData["data.armorproperties.parts"] = armorproperties.reduce((arr, entry) => {
let [i, j] = entry[0].split(".").slice(3);
if ( !arr[i] ) arr[i] = [];
arr[i][j] = entry[1];
return arr;
}, []);
// Handle weaponproperties Array
let weaponproperties = Object.entries(formData).filter(e => e[0].startsWith("data.weaponproperties.parts"));
formData["data.weaponproperties.parts"] = weaponproperties.reduce((arr, entry) => {
let [i, j] = entry[0].split(".").slice(3);
if ( !arr[i] ) arr[i] = [];
arr[i][j] = entry[1];
return arr;
}, []);
const damage = formData.data?.damage;
if ( damage ) damage.parts = Object.values(damage?.parts || {}).map(d => [d[0] || "", d[1] || ""]);
// Update the Item
super._updateObject(event, formData);
@ -179,16 +248,14 @@ export class ItemSheet5e extends ItemSheet {
activateListeners(html) {
super.activateListeners(html);
html.find(".damage-control").click(this._onDamageControl.bind(this));
// Activate any Trait Selectors
html.find('.trait-selector.class-skills').click(this._onConfigureClassSkills.bind(this));
// Armor properties
html.find(".armorproperties-control").click(this._onarmorpropertiesControl.bind(this));
// Weapon properties
html.find(".weaponproperties-control").click(this._onweaponpropertiesControl.bind(this));
// Armor properties
html.find(".armorproperties-control").click(this._onarmorpropertiesControl.bind(this));
// Weapon properties
html.find(".weaponproperties-control").click(this._onweaponpropertiesControl.bind(this));
}
/* -------------------------------------------- */
@ -222,64 +289,6 @@ export class ItemSheet5e extends ItemSheet {
/* -------------------------------------------- */
/**
* Add or remove a armorproperties part from the armorproperties formula
* @param {Event} event The original click event
* @return {Promise}
* @private
*/
async _onarmorpropertiesControl(event) {
event.preventDefault();
const a = event.currentTarget;
// Add new armorproperties component
if ( a.classList.contains("add-armorproperties") ) {
await this._onSubmit(event); // Submit any unsaved changes
const armorproperties = this.item.data.data.armorproperties;
return this.item.update({"data.armorproperties.parts": armorproperties.parts.concat([["", ""]])});
}
// Remove a armorproperties component
if ( a.classList.contains("delete-armorproperties") ) {
await this._onSubmit(event); // Submit any unsaved changes
const li = a.closest(".armorproperties-part");
const armorproperties = duplicate(this.item.data.data.armorproperties);
armorproperties.parts.splice(Number(li.dataset.armorpropertiesPart), 1);
return this.item.update({"data.armorproperties.parts": armorproperties.parts});
}
}
/* -------------------------------------------- */
/**
* Add or remove a weaponproperties part from the weaponproperties formula
* @param {Event} event The original click event
* @return {Promise}
* @private
*/
async _onweaponpropertiesControl(event) {
event.preventDefault();
const a = event.currentTarget;
// Add new weaponproperties component
if ( a.classList.contains("add-weaponproperties") ) {
await this._onSubmit(event); // Submit any unsaved changes
const weaponproperties = this.item.data.data.weaponproperties;
return this.item.update({"data.weaponproperties.parts": weaponproperties.parts.concat([["", ""]])});
}
// Remove a weaponproperties component
if ( a.classList.contains("delete-weaponproperties") ) {
await this._onSubmit(event); // Submit any unsaved changes
const li = a.closest(".weaponproperties-part");
const weaponproperties = duplicate(this.item.data.data.weaponproperties);
weaponproperties.parts.splice(Number(li.dataset.weaponpropertiesPart), 1);
return this.item.update({"data.weaponproperties.parts": weaponproperties.parts});
}
}
/* -------------------------------------------- */
/**
* Handle spawning the TraitSelector application which allows a checkbox of multiple trait options
* @param {Event} event The click event which originated the selection

60
module/macros.js Normal file
View file

@ -0,0 +1,60 @@
/* -------------------------------------------- */
/* Hotbar Macros */
/* -------------------------------------------- */
/**
* Create a Macro from an Item drop.
* Get an existing item macro if one exists, otherwise create a new one.
* @param {Object} data The dropped data
* @param {number} slot The hotbar slot to use
* @returns {Promise}
*/
export async function create5eMacro(data, slot) {
if ( data.type !== "Item" ) return;
if (!( "data" in data ) ) return ui.notifications.warn("You can only create macro buttons for owned Items");
const item = data.data;
// Create the macro command
const command = `game.sw5e.rollItemMacro("${item.name}");`;
let macro = game.macros.entities.find(m => (m.name === item.name) && (m.command === command));
if ( !macro ) {
macro = await Macro.create({
name: item.name,
type: "script",
img: item.img,
command: command,
flags: {"sw5e.itemMacro": true}
});
}
game.user.assignHotbarMacro(macro, slot);
return false;
}
/* -------------------------------------------- */
/**
* Create a Macro from an Item drop.
* Get an existing item macro if one exists, otherwise create a new one.
* @param {string} itemName
* @return {Promise}
*/
export function rollItemMacro(itemName) {
const speaker = ChatMessage.getSpeaker();
let actor;
if ( speaker.token ) actor = game.actors.tokens[speaker.token];
if ( !actor ) actor = game.actors.get(speaker.actor);
// Get matching items
const items = actor ? actor.items.filter(i => i.name === itemName) : [];
if ( items.length > 1 ) {
ui.notifications.warn(`Your controlled Actor ${actor.name} has more than one Item with name ${itemName}. The first matched item will be chosen.`);
} else if ( items.length === 0 ) {
return ui.notifications.warn(`Your controlled Actor does not have an item named ${itemName}`);
}
const item = items[0];
// Trigger the item roll
if ( item.data.type === "power" ) return actor.usePower(item);
return item.roll();
}

View file

@ -4,7 +4,7 @@ import { SW5E } from "../config.js";
* A helper class for building MeasuredTemplates for 5e powers and abilities
* @extends {MeasuredTemplate}
*/
export class AbilityTemplate extends MeasuredTemplate {
export default class AbilityTemplate extends MeasuredTemplate {
/**
* A factory method to create an AbilityTemplate instance using provided data from an Item5e instance
@ -37,8 +37,8 @@ export class AbilityTemplate extends MeasuredTemplate {
templateData.width = target.value;
templateData.direction = 45;
break;
case "ray": // 5e rays are most commonly 5ft wide
templateData.width = 5;
case "ray": // 5e rays are most commonly 1 square (5 ft) in width
templateData.width = canvas.dimensions.distance;
break;
default:
break;

View file

@ -11,6 +11,23 @@ export const registerSystemSettings = function() {
default: 0
});
/**
* Register resting variants
*/
game.settings.register("sw5e", "restVariant", {
name: "SETTINGS.5eRestN",
hint: "SETTINGS.5eRestL",
scope: "world",
config: true,
default: "normal",
type: String,
choices: {
"normal": "SETTINGS.5eRestPHB",
"gritty": "SETTINGS.5eRestGritty",
"epic": "SETTINGS.5eRestEpic",
}
});
/**
* Register diagonal movement rule setting
*/
@ -23,7 +40,8 @@ export const registerSystemSettings = function() {
type: String,
choices: {
"555": "SETTINGS.5eDiagPHB",
"5105": "SETTINGS.5eDiagDMG"
"5105": "SETTINGS.5eDiagDMG",
"EUCL": "SETTINGS.5eDiagEuclidean",
},
onChange: rule => canvas.grid.diagonalRule = rule
});
@ -31,21 +49,14 @@ export const registerSystemSettings = function() {
/**
* Register Initiative formula setting
*/
function _set5eInitiative(tiebreaker) {
CONFIG.Combat.initiative.tiebreaker = tiebreaker;
CONFIG.Combat.initiative.decimals = tiebreaker ? 2 : 0;
if ( ui.combat && ui.combat._rendered ) ui.combat.render();
}
game.settings.register("sw5e", "initiativeDexTiebreaker", {
name: "SETTINGS.5eInitTBN",
hint: "SETTINGS.5eInitTBL",
scope: "world",
config: true,
default: false,
type: Boolean,
onChange: enable => _set5eInitiative(enable)
type: Boolean
});
_set5eInitiative(game.settings.get("sw5e", "initiativeDexTiebreaker"));
/**
* Require Currency Carrying Weight

View file

@ -17,7 +17,8 @@ export const preloadHandlebarsTemplates = async function() {
// Item Sheet Partials
"systems/sw5e/templates/items/parts/item-action.html",
"systems/sw5e/templates/items/parts/item-activation.html",
"systems/sw5e/templates/items/parts/item-description.html"
"systems/sw5e/templates/items/parts/item-description.html",
"systems/sw5e/templates/items/parts/item-mountable.html"
];
// Load the template parts