Compare commits
40 commits
Cleaning-u
...
master
Author | SHA1 | Date | |
---|---|---|---|
![]() |
2212e3c7d8 | ||
![]() |
8c0ad582f7 | ||
![]() |
584767b352 | ||
![]() |
d1b123100e | ||
![]() |
0a9c9f8ef0 | ||
![]() |
ee7418f552 | ||
![]() |
c44ad926a5 | ||
![]() |
10ee20354b | ||
![]() |
7214e3d260 | ||
![]() |
97df236b54 | ||
![]() |
cac466462b | ||
![]() |
063d529f09 | ||
![]() |
b343a06ef6 | ||
![]() |
fc09308d11 | ||
![]() |
7a18055c18 | ||
![]() |
e271c41239 | ||
![]() |
5d879f99e2 | ||
![]() |
df44ad0635 | ||
![]() |
aff43d3e98 | ||
![]() |
211201caea | ||
![]() |
7a29dfe600 | ||
![]() |
c467738845 | ||
![]() |
8a0940ccce | ||
![]() |
3b4300a8eb | ||
![]() |
55bbb95cfb | ||
![]() |
62e31afff2 | ||
![]() |
da5223cab8 | ||
![]() |
db286f7883 | ||
![]() |
25684173fa | ||
![]() |
74d841e9e1 | ||
![]() |
84ab6cf478 | ||
![]() |
d39fa6acf2 | ||
![]() |
0a4b6de0fa | ||
![]() |
c33982f97c | ||
![]() |
4f0c6addcf | ||
![]() |
7c03cd4b04 | ||
![]() |
fee77e2172 | ||
![]() |
87d615babc | ||
![]() |
04dcaf332d | ||
![]() |
4ee235566d |
BIN
.DS_Store
vendored
Normal file
6
.gitignore
vendored
|
@ -28,10 +28,4 @@ hs_err_pid*
|
|||
# IDE Folders
|
||||
.idea/
|
||||
.vs/
|
||||
.vscode/
|
||||
|
||||
# Nodejs Modules
|
||||
node_modules
|
||||
|
||||
# Compiled Files
|
||||
dist/
|
14
.prettierrc
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"quoteProps": "consistent",
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": false,
|
||||
"jsxBracketSameLine": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
3
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"editor.formatOnSave": true
|
||||
}
|
177
gulpfile.js
|
@ -1,198 +1,41 @@
|
|||
const gulp = require("gulp");
|
||||
const less = require("gulp-less");
|
||||
const through = require('through2');
|
||||
const fs = require('fs');
|
||||
|
||||
/* ----------------------------------------- */
|
||||
/* Constants
|
||||
/* ----------------------------------------- */
|
||||
|
||||
const src = "./src/"
|
||||
const dest = "./dist/"
|
||||
|
||||
const foundryDest = (process.platform === "win32" ? process.env.LOCALAPPDATA : process.env.HOME + (process.platform === "darwin" ? "" : "/.local/share")) + "/FoundryVTT/Data/systems/sw5e/";
|
||||
|
||||
/* ----------------------------------------- */
|
||||
/* Compile LESS
|
||||
/* ----------------------------------------- */
|
||||
|
||||
const SW5E_LESS = ["less/**/*.less"];
|
||||
|
||||
function compileLESS() {
|
||||
return gulp.src(src + "less/original/sw5e.less")
|
||||
.pipe(less())
|
||||
.pipe(gulp.dest(dest));
|
||||
return gulp.src("less/original/sw5e.less").pipe(less()).pipe(gulp.dest("./"));
|
||||
}
|
||||
|
||||
function compileGlobalLess() {
|
||||
return gulp.src(src + "less/update/sw5e-global.less")
|
||||
.pipe(less())
|
||||
.pipe(gulp.dest(dest));
|
||||
return gulp.src("less/update/sw5e-global.less").pipe(less()).pipe(gulp.dest("./"));
|
||||
}
|
||||
|
||||
function compileLightLess() {
|
||||
return gulp.src(src + "less/update/sw5e-light.less")
|
||||
.pipe(less())
|
||||
.pipe(gulp.dest(dest));
|
||||
return gulp.src("less/update/sw5e-light.less").pipe(less()).pipe(gulp.dest("./"));
|
||||
}
|
||||
|
||||
function compileDarkLess() {
|
||||
return gulp.src(src + "less/update/sw5e-dark.less")
|
||||
.pipe(less())
|
||||
.pipe(gulp.dest(dest));
|
||||
return gulp.src("less/update/sw5e-dark.less").pipe(less()).pipe(gulp.dest("./"));
|
||||
}
|
||||
|
||||
const css = gulp.parallel(compileLESS, compileGlobalLess, compileLightLess, compileDarkLess);
|
||||
|
||||
/* ----------------------------------------- */
|
||||
/* Compile packs
|
||||
/* ----------------------------------------- */
|
||||
|
||||
function jsonToDB() {
|
||||
return through.obj(async (chunk, enc, cb) => {
|
||||
let result = "";
|
||||
|
||||
const files = await fs.promises.readdir(chunk.path);
|
||||
|
||||
for( const file of files ) {
|
||||
result += JSON.stringify(JSON.parse(fs.readFileSync(chunk.path + "/" + file, "utf-8"))) + "\n";
|
||||
}
|
||||
|
||||
chunk.contents = new Buffer(result);
|
||||
cb(null, chunk)
|
||||
})
|
||||
}
|
||||
|
||||
function compilePacks() {
|
||||
return gulp.src(src + "packs/packs/*").pipe(jsonToDB()).pipe(gulp.dest(dest + "packs/packs"));
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
/* Copy Files
|
||||
/* ----------------------------------------- */
|
||||
|
||||
async function copySystemTemplate() {
|
||||
return new Promise ((resolve, reject) => {
|
||||
gulp.src(src + "system.json").pipe(gulp.dest(dest));
|
||||
gulp.src(src + "template.json").pipe(gulp.dest(dest));
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function copyLang() {
|
||||
return new Promise ((resolve, reject) => {
|
||||
gulp.src(src + "lang/**").pipe(gulp.dest(dest + "lang/"));
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function copyJS() {
|
||||
return new Promise ((resolve, reject) => {
|
||||
gulp.src(src + "sw5e.js").pipe(gulp.dest(dest));
|
||||
gulp.src(src + "module/**").pipe(gulp.dest(dest + "module/"));
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function copyTemplates() {
|
||||
return new Promise ((resolve, reject) => {
|
||||
gulp.src(src + "templates/**").pipe(gulp.dest(dest + "templates/"));
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function copyRemaining() {
|
||||
return new Promise ((resolve, reject) => {
|
||||
gulp.src("README.md").pipe(gulp.dest(dest));
|
||||
gulp.src(src + "fonts/**").pipe(gulp.dest(dest + "fonts/"));
|
||||
gulp.src(src + "packs/icons/**").pipe(gulp.dest(dest + "packs/Icons"));
|
||||
gulp.src(src + "ui/**").pipe(gulp.dest(dest + "ui/"));
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
const copy = gulp.parallel(copySystemTemplate, copyLang, copyJS, copyTemplates, copyRemaining)
|
||||
|
||||
/* ----------------------------------------- */
|
||||
/* build
|
||||
/* ----------------------------------------- */
|
||||
|
||||
const build = gulp.parallel(css, compilePacks, copy)
|
||||
|
||||
/* ----------------------------------------- */
|
||||
/* Send to foundry
|
||||
/* ----------------------------------------- */
|
||||
|
||||
async function sendCSSToFoundry() {
|
||||
return new Promise ((resolve, reject) => {
|
||||
gulp.src(dest + "*.css").pipe(gulp.dest(foundryDest));
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function sendSystemTemplateToFoundry() {
|
||||
return new Promise ((resolve, reject) => {
|
||||
gulp.src(dest + "*.json").pipe(gulp.dest(foundryDest));
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function sendLangToFoundry() {
|
||||
return new Promise ((resolve, reject) => {
|
||||
gulp.src(dest + "lang/**").pipe(gulp.dest(foundryDest + "lang"));
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function sendJSToFoundry() {
|
||||
return new Promise ((resolve, reject) => {
|
||||
gulp.src(dest + "sw5e.js").pipe(gulp.dest(foundryDest));
|
||||
gulp.src(dest + "module/**").pipe(gulp.dest(foundryDest + "module"));
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function sendPacksToFoundry() {
|
||||
return new Promise ((resolve, reject) => {
|
||||
gulp.src(dest + "packs/packs/**").pipe(gulp.dest(foundryDest + "packs/packs"));
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function sendTemplatesToFoundry() {
|
||||
return new Promise ((resolve, reject) => {
|
||||
gulp.src(dest + "templates/**").pipe(gulp.dest(foundryDest + "templates"));
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function sendRemainingToFoundry() {
|
||||
return new Promise ((resolve, reject) => {
|
||||
gulp.src(dest + "README.md").pipe(gulp.dest(foundryDest));
|
||||
gulp.src(dest + "fonts/**").pipe(gulp.dest(foundryDest + "fonts"));
|
||||
gulp.src(dest + "packs/Icons/**").pipe(gulp.dest(foundryDest + "packs/Icons"));
|
||||
gulp.src(dest + "ui/**").pipe(gulp.dest(foundryDest + "ui"));
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
const foundry = gulp.series(build, gulp.parallel(sendCSSToFoundry, sendSystemTemplateToFoundry, sendLangToFoundry, sendJSToFoundry, sendPacksToFoundry, sendTemplatesToFoundry, sendRemainingToFoundry));
|
||||
const css = gulp.series(compileLESS, compileGlobalLess, compileLightLess, compileDarkLess);
|
||||
|
||||
/* ----------------------------------------- */
|
||||
/* Watch Updates
|
||||
/* ----------------------------------------- */
|
||||
|
||||
function watchUpdates() {
|
||||
gulp.watch([src + "less/**/*.less"], gulp.series(css, sendCSSToFoundry));
|
||||
gulp.watch([src + "system.json", src + "template.json"], gulp.series(copySystemTemplate, sendSystemTemplateToFoundry));
|
||||
gulp.watch([src + "lang/**"], gulp.series(copyLang, sendLangToFoundry));
|
||||
gulp.watch([src + "sw5e.js", src + "module/**"], gulp.series(copyJS, sendJSToFoundry));
|
||||
gulp.watch([src + "packs/packs/**"], gulp.series(compilePacks, sendPacksToFoundry));
|
||||
gulp.watch([src + "templates/**"], gulp.series(copyTemplates, sendTemplatesToFoundry));
|
||||
gulp.watch(SW5E_LESS, css);
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
/* Export Tasks
|
||||
/* ----------------------------------------- */
|
||||
|
||||
exports.default = build;
|
||||
exports.foundry = foundry;
|
||||
exports.watch = gulp.series(foundry, watchUpdates);
|
||||
exports.default = css;
|
||||
gulp.parallel(css), (exports.watch = gulp.series(gulp.parallel(css), watchUpdates));
|
||||
|
|
1884
module/actor/entity.js
Normal file
2126
module/actor/old_entity.js
Normal file
989
module/actor/sheets/newSheet/base.js
Normal file
|
@ -0,0 +1,989 @@
|
|||
import Item5e from "../../../item/entity.js";
|
||||
import TraitSelector from "../../../apps/trait-selector.js";
|
||||
import ActorSheetFlags from "../../../apps/actor-flags.js";
|
||||
import ActorHitDiceConfig from "../../../apps/hit-dice-config.js";
|
||||
import ActorMovementConfig from "../../../apps/movement-config.js";
|
||||
import ActorSensesConfig from "../../../apps/senses-config.js";
|
||||
import ActorTypeConfig from "../../../apps/actor-type.js";
|
||||
import {SW5E} from "../../../config.js";
|
||||
import {onManageActiveEffect, prepareActiveEffectCategories} from "../../../effects.js";
|
||||
|
||||
/**
|
||||
* Extend the basic ActorSheet class to suppose SW5e-specific logic and functionality.
|
||||
* This sheet is an Abstract layer which is not used.
|
||||
* @extends {ActorSheet}
|
||||
*/
|
||||
export default class ActorSheet5e extends ActorSheet {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* Track the set of item filters which are applied
|
||||
* @type {Set}
|
||||
*/
|
||||
this._filters = {
|
||||
inventory: new Set(),
|
||||
forcePowerbook: new Set(),
|
||||
techPowerbook: new Set(),
|
||||
features: new Set(),
|
||||
effects: new Set()
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return mergeObject(super.defaultOptions, {
|
||||
scrollY: [
|
||||
".inventory .group-list",
|
||||
".features .group-list",
|
||||
".force-powerbook .group-list",
|
||||
".tech-powerbook .group-list",
|
||||
".effects .effects-list"
|
||||
],
|
||||
tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A set of item types that should be prevented from being dropped on this type of actor sheet.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
static unsupportedItemTypes = new Set();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get template() {
|
||||
if (!game.user.isGM && this.actor.limited)
|
||||
return "systems/sw5e/templates/actors/newActor/expanded-limited-sheet.html";
|
||||
return `systems/sw5e/templates/actors/newActor/${this.actor.data.type}-sheet.html`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData(options) {
|
||||
// Basic data
|
||||
let isOwner = this.actor.isOwner;
|
||||
const data = {
|
||||
owner: isOwner,
|
||||
limited: this.actor.limited,
|
||||
options: this.options,
|
||||
editable: this.isEditable,
|
||||
cssClass: isOwner ? "editable" : "locked",
|
||||
isCharacter: this.actor.type === "character",
|
||||
isNPC: this.actor.type === "npc",
|
||||
isStarship: this.actor.type === "starship",
|
||||
isVehicle: this.actor.type === "vehicle",
|
||||
config: CONFIG.SW5E,
|
||||
rollData: this.actor.getRollData.bind(this.actor)
|
||||
};
|
||||
|
||||
// The Actor's data
|
||||
const actorData = this.actor.data.toObject(false);
|
||||
data.actor = actorData;
|
||||
data.data = actorData.data;
|
||||
|
||||
// Owned Items
|
||||
data.items = actorData.items;
|
||||
for (let i of data.items) {
|
||||
const item = this.actor.items.get(i._id);
|
||||
i.labels = item.labels;
|
||||
}
|
||||
data.items.sort((a, b) => (a.sort || 0) - (b.sort || 0));
|
||||
|
||||
// Labels and filters
|
||||
data.labels = this.actor.labels || {};
|
||||
data.filters = this._filters;
|
||||
|
||||
// Ability Scores
|
||||
for (let [a, abl] of Object.entries(actorData.data.abilities)) {
|
||||
abl.icon = this._getProficiencyIcon(abl.proficient);
|
||||
abl.hover = CONFIG.SW5E.proficiencyLevels[abl.proficient];
|
||||
abl.label = CONFIG.SW5E.abilities[a];
|
||||
}
|
||||
|
||||
// Skills
|
||||
if (actorData.data.skills) {
|
||||
for (let [s, skl] of Object.entries(actorData.data.skills)) {
|
||||
skl.ability = CONFIG.SW5E.abilityAbbreviations[skl.ability];
|
||||
skl.icon = this._getProficiencyIcon(skl.value);
|
||||
skl.hover = CONFIG.SW5E.proficiencyLevels[skl.value];
|
||||
if (data.actor.type === "starship") {
|
||||
skl.label = CONFIG.SW5E.starshipSkills[s];
|
||||
} else {
|
||||
skl.label = CONFIG.SW5E.skills[s];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Movement speeds
|
||||
data.movement = this._getMovementSpeed(actorData);
|
||||
|
||||
// Senses
|
||||
data.senses = this._getSenses(actorData);
|
||||
|
||||
// Update traits
|
||||
this._prepareTraits(actorData.data.traits);
|
||||
|
||||
// Prepare owned items
|
||||
this._prepareItems(data);
|
||||
|
||||
// Prepare active effects
|
||||
data.effects = prepareActiveEffectCategories(this.actor.effects);
|
||||
|
||||
// Return data to the sheet
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare the display of movement speed data for the Actor*
|
||||
* @param {object} actorData The Actor data being prepared.
|
||||
* @param {boolean} [largestPrimary=false] Show the largest movement speed as "primary", otherwise show "walk"
|
||||
* @returns {{primary: string, special: string}}
|
||||
* @private
|
||||
*/
|
||||
_getMovementSpeed(actorData, largestPrimary = false) {
|
||||
const movement = actorData.data.attributes.movement || {};
|
||||
|
||||
// Prepare an array of available movement speeds
|
||||
let speeds = [
|
||||
[movement.burrow, `${game.i18n.localize("SW5E.MovementBurrow")} ${movement.burrow}`],
|
||||
[movement.climb, `${game.i18n.localize("SW5E.MovementClimb")} ${movement.climb}`],
|
||||
[
|
||||
movement.fly,
|
||||
`${game.i18n.localize("SW5E.MovementFly")} ${movement.fly}` +
|
||||
(movement.hover ? ` (${game.i18n.localize("SW5E.MovementHover")})` : "")
|
||||
],
|
||||
[movement.swim, `${game.i18n.localize("SW5E.MovementSwim")} ${movement.swim}`]
|
||||
];
|
||||
if (largestPrimary) {
|
||||
speeds.push([movement.walk, `${game.i18n.localize("SW5E.MovementWalk")} ${movement.walk}`]);
|
||||
}
|
||||
|
||||
// Filter and sort speeds on their values
|
||||
speeds = speeds.filter((s) => !!s[0]).sort((a, b) => b[0] - a[0]);
|
||||
|
||||
// Case 1: Largest as primary
|
||||
if (largestPrimary) {
|
||||
let primary = speeds.shift();
|
||||
return {
|
||||
primary: `${primary ? primary[1] : "0"} ${movement.units}`,
|
||||
special: speeds.map((s) => s[1]).join(", ")
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: Walk as primary
|
||||
else {
|
||||
return {
|
||||
primary: `${movement.walk || 0} ${movement.units}`,
|
||||
special: speeds.length ? speeds.map((s) => s[1]).join(", ") : ""
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
_getSenses(actorData) {
|
||||
const senses = actorData.data.attributes.senses || {};
|
||||
const tags = {};
|
||||
for (let [k, label] of Object.entries(CONFIG.SW5E.senses)) {
|
||||
const v = senses[k] ?? 0;
|
||||
if (v === 0) continue;
|
||||
tags[k] = `${game.i18n.localize(label)} ${v} ${senses.units}`;
|
||||
}
|
||||
if (!!senses.special) tags["special"] = senses.special;
|
||||
return tags;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare the data structure for traits data like languages, resistances & vulnerabilities, and proficiencies
|
||||
* @param {object} traits The raw traits data object from the actor data
|
||||
* @private
|
||||
*/
|
||||
_prepareTraits(traits) {
|
||||
const map = {
|
||||
dr: CONFIG.SW5E.damageResistanceTypes,
|
||||
di: CONFIG.SW5E.damageResistanceTypes,
|
||||
dv: CONFIG.SW5E.damageResistanceTypes,
|
||||
ci: CONFIG.SW5E.conditionTypes,
|
||||
languages: CONFIG.SW5E.languages,
|
||||
armorProf: CONFIG.SW5E.armorProficiencies,
|
||||
weaponProf: CONFIG.SW5E.weaponProficiencies,
|
||||
toolProf: CONFIG.SW5E.toolProficiencies
|
||||
};
|
||||
for (let [t, choices] of Object.entries(map)) {
|
||||
const trait = traits[t];
|
||||
if (!trait) continue;
|
||||
let values = [];
|
||||
if (trait.value) {
|
||||
values = trait.value instanceof Array ? trait.value : [trait.value];
|
||||
}
|
||||
trait.selected = values.reduce((obj, t) => {
|
||||
obj[t] = choices[t];
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
// Add custom entry
|
||||
if (trait.custom) {
|
||||
trait.custom.split(";").forEach((c, i) => (trait.selected[`custom${i + 1}`] = c.trim()));
|
||||
}
|
||||
trait.cssClass = !isObjectEmpty(trait.selected) ? "" : "inactive";
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Insert a power into the powerbook object when rendering the character sheet
|
||||
* @param {Object} data The Actor data being prepared
|
||||
* @param {Array} powers The power data being prepared
|
||||
* @param {string} school The school of the powerbook being prepared
|
||||
* @private
|
||||
*/
|
||||
_preparePowerbook(data, powers, school) {
|
||||
const owner = this.actor.isOwner;
|
||||
const levels = data.data.powers;
|
||||
const powerbook = {};
|
||||
|
||||
// Define some mappings
|
||||
const sections = {
|
||||
atwill: -20,
|
||||
innate: -10
|
||||
};
|
||||
|
||||
// Label power slot uses headers
|
||||
const useLabels = {
|
||||
"-20": "-",
|
||||
"-10": "-",
|
||||
"0": "∞"
|
||||
};
|
||||
|
||||
// Format a powerbook entry for a certain indexed level
|
||||
const registerSection = (sl, i, label, {prepMode = "prepared", value, max, override} = {}) => {
|
||||
powerbook[i] = {
|
||||
order: i,
|
||||
label: label,
|
||||
usesSlots: i > 0,
|
||||
canCreate: owner,
|
||||
canPrepare: data.actor.type === "character" && i >= 1,
|
||||
powers: [],
|
||||
uses: useLabels[i] || value || 0,
|
||||
slots: useLabels[i] || max || 0,
|
||||
override: override || 0,
|
||||
dataset: {
|
||||
"type": "power",
|
||||
"level": prepMode in sections ? 1 : i,
|
||||
"preparation.mode": prepMode,
|
||||
"school": school
|
||||
},
|
||||
prop: sl
|
||||
};
|
||||
};
|
||||
|
||||
// Determine the maximum power level which has a slot
|
||||
const maxLevel = Array.fromRange(10).reduce((max, i) => {
|
||||
if (i === 0) return max;
|
||||
const level = levels[`power${i}`];
|
||||
if ((level.max || level.override) && i > max) max = i;
|
||||
return max;
|
||||
}, 0);
|
||||
|
||||
// Level-based powercasters have cantrips and leveled slots
|
||||
if (maxLevel > 0) {
|
||||
registerSection("power0", 0, CONFIG.SW5E.powerLevels[0]);
|
||||
for (let lvl = 1; lvl <= maxLevel; lvl++) {
|
||||
const sl = `power${lvl}`;
|
||||
registerSection(sl, lvl, CONFIG.SW5E.powerLevels[lvl], levels[sl]);
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate over every power item, adding powers to the powerbook by section
|
||||
powers.forEach((power) => {
|
||||
const mode = power.data.preparation.mode || "prepared";
|
||||
let s = power.data.level || 0;
|
||||
const sl = `power${s}`;
|
||||
|
||||
// Specialized powercasting modes (if they exist)
|
||||
if (mode in sections) {
|
||||
s = sections[mode];
|
||||
if (!powerbook[s]) {
|
||||
const l = levels[mode] || {};
|
||||
const config = CONFIG.SW5E.powerPreparationModes[mode];
|
||||
registerSection(mode, s, config, {
|
||||
prepMode: mode,
|
||||
value: l.value,
|
||||
max: l.max,
|
||||
override: l.override
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sections for higher-level powers which the caster "should not" have, but power items exist for
|
||||
else if (!powerbook[s]) {
|
||||
registerSection(sl, s, CONFIG.SW5E.powerLevels[s], {levels: levels[sl]});
|
||||
}
|
||||
|
||||
// Add the power to the relevant heading
|
||||
powerbook[s].powers.push(power);
|
||||
});
|
||||
|
||||
// Sort the powerbook by section level
|
||||
const sorted = Object.values(powerbook);
|
||||
sorted.sort((a, b) => a.order - b.order);
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine whether an Owned Item will be shown based on the current set of filters
|
||||
* @return {boolean}
|
||||
* @private
|
||||
*/
|
||||
_filterItems(items, filters) {
|
||||
return items.filter((item) => {
|
||||
const data = item.data;
|
||||
|
||||
// Action usage
|
||||
for (let f of ["action", "bonus", "reaction"]) {
|
||||
if (filters.has(f)) {
|
||||
if (data.activation && data.activation.type !== f) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Power-specific filters
|
||||
if (filters.has("ritual")) {
|
||||
if (data.components.ritual !== true) return false;
|
||||
}
|
||||
if (filters.has("concentration")) {
|
||||
if (data.components.concentration !== true) return false;
|
||||
}
|
||||
if (filters.has("prepared")) {
|
||||
if (data.level === 0 || ["innate", "always"].includes(data.preparation.mode)) return true;
|
||||
if (this.actor.data.type === "npc") return true;
|
||||
if (this.actor.data.type === "starship") return true;
|
||||
return data.preparation.prepared;
|
||||
}
|
||||
|
||||
// Equipment-specific filters
|
||||
if (filters.has("equipped")) {
|
||||
if (data.equipped !== true) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the font-awesome icon used to display a certain level of skill proficiency
|
||||
* @private
|
||||
*/
|
||||
_getProficiencyIcon(level) {
|
||||
const icons = {
|
||||
0: '<i class="far fa-circle"></i>',
|
||||
0.5: '<i class="fas fa-adjust"></i>',
|
||||
1: '<i class="fas fa-check"></i>',
|
||||
2: '<i class="fas fa-check-double"></i>'
|
||||
};
|
||||
return icons[level] || icons[0];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
// Activate Item Filters
|
||||
const filterLists = html.find(".filter-list");
|
||||
filterLists.each(this._initializeFilterItemList.bind(this));
|
||||
filterLists.on("click", ".filter-item", this._onToggleFilter.bind(this));
|
||||
|
||||
// Item summaries
|
||||
html.find(".item .item-name.rollable h4").click((event) => this._onItemSummary(event));
|
||||
|
||||
// View Item Sheets
|
||||
html.find(".item-edit").click(this._onItemEdit.bind(this));
|
||||
|
||||
// Editable Only Listeners
|
||||
if (this.isEditable) {
|
||||
// Input focus and update
|
||||
const inputs = html.find("input");
|
||||
inputs.focus((ev) => ev.currentTarget.select());
|
||||
inputs.addBack().find('[data-dtype="Number"]').change(this._onChangeInputDelta.bind(this));
|
||||
|
||||
// Ability Proficiency
|
||||
html.find(".ability-proficiency").click(this._onToggleAbilityProficiency.bind(this));
|
||||
|
||||
// Toggle Skill Proficiency
|
||||
html.find(".skill-proficiency").on("click contextmenu", this._onCycleSkillProficiency.bind(this));
|
||||
|
||||
// Trait Selector
|
||||
html.find(".trait-selector").click(this._onTraitSelector.bind(this));
|
||||
|
||||
// Configure Special Flags
|
||||
html.find(".config-button").click(this._onConfigMenu.bind(this));
|
||||
|
||||
// Owned Item management
|
||||
html.find(".item-create").click(this._onItemCreate.bind(this));
|
||||
html.find(".item-delete").click(this._onItemDelete.bind(this));
|
||||
html.find(".item-collapse").click(this._onItemCollapse.bind(this));
|
||||
html.find(".item-uses input")
|
||||
.click((ev) => ev.target.select())
|
||||
.change(this._onUsesChange.bind(this));
|
||||
html.find(".slot-max-override").click(this._onPowerSlotOverride.bind(this));
|
||||
html.find(".increment-class-level").click(this._onIncrementClassLevel.bind(this));
|
||||
html.find(".decrement-class-level").click(this._onDecrementClassLevel.bind(this));
|
||||
|
||||
// Active Effect management
|
||||
html.find(".effect-control").click((ev) => onManageActiveEffect(ev, this.actor));
|
||||
}
|
||||
|
||||
// Owner Only Listeners
|
||||
if (this.actor.isOwner) {
|
||||
// Ability Checks
|
||||
html.find(".ability-name").click(this._onRollAbilityTest.bind(this));
|
||||
|
||||
// Roll Skill Checks
|
||||
html.find(".skill-name").click(this._onRollSkillCheck.bind(this));
|
||||
|
||||
// Item Rolling
|
||||
html.find(".item .item-image").click((event) => this._onItemRoll(event));
|
||||
html.find(".item .item-recharge").click((event) => this._onItemRecharge(event));
|
||||
}
|
||||
|
||||
// Otherwise remove rollable classes
|
||||
else {
|
||||
html.find(".rollable").each((i, el) => el.classList.remove("rollable"));
|
||||
}
|
||||
|
||||
// Handle default listeners last so system listeners are triggered first
|
||||
super.activateListeners(html);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Iinitialize Item list filters by activating the set of filters which are currently applied
|
||||
* @private
|
||||
*/
|
||||
_initializeFilterItemList(i, ul) {
|
||||
const set = this._filters[ul.dataset.filter];
|
||||
const filters = ul.querySelectorAll(".filter-item");
|
||||
for (let li of filters) {
|
||||
if (set.has(li.dataset.filter)) li.classList.add("active");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle input changes to numeric form fields, allowing them to accept delta-typed inputs
|
||||
* @param event
|
||||
* @private
|
||||
*/
|
||||
_onChangeInputDelta(event) {
|
||||
const input = event.target;
|
||||
const value = input.value;
|
||||
if (["+", "-"].includes(value[0])) {
|
||||
let delta = parseFloat(value);
|
||||
input.value = getProperty(this.actor.data, input.name) + delta;
|
||||
} else if (value[0] === "=") {
|
||||
input.value = value.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle spawning the TraitSelector application which allows a checkbox of multiple trait options
|
||||
* @param {Event} event The click event which originated the selection
|
||||
* @private
|
||||
*/
|
||||
_onConfigMenu(event) {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget;
|
||||
let app;
|
||||
switch (button.dataset.action) {
|
||||
case "hit-dice":
|
||||
app = new ActorHitDiceConfig(this.object);
|
||||
break;
|
||||
case "movement":
|
||||
app = new ActorMovementConfig(this.object);
|
||||
break;
|
||||
case "flags":
|
||||
app = new ActorSheetFlags(this.object);
|
||||
break;
|
||||
case "senses":
|
||||
app = new ActorSensesConfig(this.object);
|
||||
break;
|
||||
case "type":
|
||||
new ActorTypeConfig(this.object).render(true);
|
||||
break;
|
||||
}
|
||||
app?.render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle cycling proficiency in a Skill
|
||||
* @param {Event} event A click or contextmenu event which triggered the handler
|
||||
* @private
|
||||
*/
|
||||
_onCycleSkillProficiency(event) {
|
||||
event.preventDefault();
|
||||
const field = $(event.currentTarget).siblings('input[type="hidden"]');
|
||||
|
||||
// Get the current level and the array of levels
|
||||
const level = parseFloat(field.val());
|
||||
const levels = [0, 1, 0.5, 2];
|
||||
let idx = levels.indexOf(level);
|
||||
|
||||
// Toggle next level - forward on click, backwards on right
|
||||
if (event.type === "click") {
|
||||
field.val(levels[idx === levels.length - 1 ? 0 : idx + 1]);
|
||||
} else if (event.type === "contextmenu") {
|
||||
field.val(levels[idx === 0 ? levels.length - 1 : idx - 1]);
|
||||
}
|
||||
|
||||
// Update the field value and save the form
|
||||
this._onSubmit(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onDropActor(event, data) {
|
||||
const canPolymorph = game.user.isGM || (this.actor.isOwner && game.settings.get("sw5e", "allowPolymorphing"));
|
||||
if (!canPolymorph) return false;
|
||||
|
||||
// Get the target actor
|
||||
let sourceActor = null;
|
||||
if (data.pack) {
|
||||
const pack = game.packs.find((p) => p.collection === data.pack);
|
||||
sourceActor = await pack.getEntity(data.id);
|
||||
} else {
|
||||
sourceActor = game.actors.get(data.id);
|
||||
}
|
||||
if (!sourceActor) return;
|
||||
|
||||
// Define a function to record polymorph settings for future use
|
||||
const rememberOptions = (html) => {
|
||||
const options = {};
|
||||
html.find("input").each((i, el) => {
|
||||
options[el.name] = el.checked;
|
||||
});
|
||||
const settings = mergeObject(game.settings.get("sw5e", "polymorphSettings") || {}, options);
|
||||
game.settings.set("sw5e", "polymorphSettings", settings);
|
||||
return settings;
|
||||
};
|
||||
|
||||
// Create and render the Dialog
|
||||
return new Dialog(
|
||||
{
|
||||
title: game.i18n.localize("SW5E.PolymorphPromptTitle"),
|
||||
content: {
|
||||
options: game.settings.get("sw5e", "polymorphSettings"),
|
||||
i18n: SW5E.polymorphSettings,
|
||||
isToken: this.actor.isToken
|
||||
},
|
||||
default: "accept",
|
||||
buttons: {
|
||||
accept: {
|
||||
icon: '<i class="fas fa-check"></i>',
|
||||
label: game.i18n.localize("SW5E.PolymorphAcceptSettings"),
|
||||
callback: (html) => this.actor.transformInto(sourceActor, rememberOptions(html))
|
||||
},
|
||||
wildshape: {
|
||||
icon: '<i class="fas fa-paw"></i>',
|
||||
label: game.i18n.localize("SW5E.PolymorphWildShape"),
|
||||
callback: (html) =>
|
||||
this.actor.transformInto(sourceActor, {
|
||||
keepBio: true,
|
||||
keepClass: true,
|
||||
keepMental: true,
|
||||
mergeSaves: true,
|
||||
mergeSkills: true,
|
||||
transformTokens: rememberOptions(html).transformTokens
|
||||
})
|
||||
},
|
||||
polymorph: {
|
||||
icon: '<i class="fas fa-pastafarianism"></i>',
|
||||
label: game.i18n.localize("SW5E.Polymorph"),
|
||||
callback: (html) =>
|
||||
this.actor.transformInto(sourceActor, {
|
||||
transformTokens: rememberOptions(html).transformTokens
|
||||
})
|
||||
},
|
||||
cancel: {
|
||||
icon: '<i class="fas fa-times"></i>',
|
||||
label: game.i18n.localize("Cancel")
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
classes: ["dialog", "sw5e"],
|
||||
width: 600,
|
||||
template: "systems/sw5e/templates/apps/polymorph-prompt.html"
|
||||
}
|
||||
).render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onDropItemCreate(itemData) {
|
||||
// Check to make sure items of this type are allowed on this actor
|
||||
if (this.constructor.unsupportedItemTypes.has(itemData.type)) {
|
||||
return ui.notifications.warn(
|
||||
game.i18n.format("SW5E.ActorWarningInvalidItem", {
|
||||
itemType: game.i18n.localize(CONFIG.Item.typeLabels[itemData.type]),
|
||||
actorType: game.i18n.localize(CONFIG.Actor.typeLabels[this.actor.type])
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (itemData.data) {
|
||||
// Ignore certain statuses
|
||||
["equipped", "proficient", "prepared"].forEach((k) => delete itemData.data[k]);
|
||||
|
||||
// Downgrade ATTUNED to REQUIRED
|
||||
itemData.data.attunement = Math.min(itemData.data.attunement, CONFIG.SW5E.attunementTypes.REQUIRED);
|
||||
}
|
||||
|
||||
// Stack identical consumables
|
||||
if (itemData.type === "consumable" && itemData.flags.core?.sourceId) {
|
||||
const similarItem = this.actor.items.find((i) => {
|
||||
const sourceId = i.getFlag("core", "sourceId");
|
||||
return sourceId && sourceId === itemData.flags.core?.sourceId && i.type === "consumable";
|
||||
});
|
||||
if (similarItem && itemData.name !== "Power Cell") {
|
||||
// Always create a new powercell instead of increasing quantity
|
||||
return similarItem.update({
|
||||
"data.quantity": similarItem.data.data.quantity + Math.max(itemData.data.quantity, 1)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create the owned item as normal
|
||||
return super._onDropItemCreate(itemData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle enabling editing for a power slot override value
|
||||
* @param {MouseEvent} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
async _onPowerSlotOverride(event) {
|
||||
const span = event.currentTarget.parentElement;
|
||||
const level = span.dataset.level;
|
||||
const override = this.actor.data.data.powers[level].override || span.dataset.slots;
|
||||
const input = document.createElement("INPUT");
|
||||
input.type = "text";
|
||||
input.name = `data.powers.${level}.override`;
|
||||
input.value = override;
|
||||
input.placeholder = span.dataset.slots;
|
||||
input.dataset.dtype = "Number";
|
||||
|
||||
// Replace the HTML
|
||||
const parent = span.parentElement;
|
||||
parent.removeChild(span);
|
||||
parent.appendChild(input);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Change the uses amount of an Owned Item within the Actor
|
||||
* @param {Event} event The triggering click event
|
||||
* @private
|
||||
*/
|
||||
async _onUsesChange(event) {
|
||||
event.preventDefault();
|
||||
const itemId = event.currentTarget.closest(".item").dataset.itemId;
|
||||
const item = this.actor.items.get(itemId);
|
||||
const uses = Math.clamped(0, parseInt(event.target.value), item.data.data.uses.max);
|
||||
event.target.value = uses;
|
||||
return item.update({"data.uses.value": uses});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle rolling of an item from the Actor sheet, obtaining the Item instance and dispatching to it's roll method
|
||||
* @private
|
||||
*/
|
||||
_onItemRoll(event) {
|
||||
event.preventDefault();
|
||||
const itemId = event.currentTarget.closest(".item").dataset.itemId;
|
||||
const item = this.actor.items.get(itemId);
|
||||
return item.roll();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle attempting to recharge an item usage by rolling a recharge check
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onItemRecharge(event) {
|
||||
event.preventDefault();
|
||||
const itemId = event.currentTarget.closest(".item").dataset.itemId;
|
||||
const item = this.actor.items.get(itemId);
|
||||
return item.rollRecharge();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle rolling of an item from the Actor sheet, obtaining the Item instance and dispatching to it's roll method
|
||||
* @private
|
||||
*/
|
||||
_onItemSummary(event) {
|
||||
event.preventDefault();
|
||||
let li = $(event.currentTarget).parents(".item"),
|
||||
item = this.actor.items.get(li.data("item-id")),
|
||||
chatData = item.getChatData({secrets: this.actor.isOwner});
|
||||
|
||||
// Toggle summary
|
||||
if (li.hasClass("expanded")) {
|
||||
let summary = li.children(".item-summary");
|
||||
summary.slideUp(200, () => summary.remove());
|
||||
} else {
|
||||
let div = $(`<div class="item-summary">${chatData.description.value}</div>`);
|
||||
let props = $(`<div class="item-properties"></div>`);
|
||||
chatData.properties.forEach((p) => props.append(`<span class="tag">${p}</span>`));
|
||||
div.append(props);
|
||||
li.append(div.hide());
|
||||
div.slideDown(200);
|
||||
}
|
||||
li.toggleClass("expanded");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle creating a new Owned Item for the actor using initial data defined in the HTML dataset
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onItemCreate(event) {
|
||||
event.preventDefault();
|
||||
const header = event.currentTarget;
|
||||
const type = header.dataset.type;
|
||||
const itemData = {
|
||||
name: game.i18n.format("SW5E.ItemNew", {type: game.i18n.localize(`SW5E.ItemType${type.capitalize()}`)}),
|
||||
type: type,
|
||||
data: foundry.utils.deepClone(header.dataset)
|
||||
};
|
||||
delete itemData.data["type"];
|
||||
return this.actor.createEmbeddedDocuments("Item", [itemData]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle editing an existing Owned Item for the Actor
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onItemEdit(event) {
|
||||
event.preventDefault();
|
||||
const li = event.currentTarget.closest(".item");
|
||||
const item = this.actor.items.get(li.dataset.itemId);
|
||||
return item.sheet.render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle deleting an existing Owned Item for the Actor
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onItemDelete(event) {
|
||||
event.preventDefault();
|
||||
const li = event.currentTarget.closest(".item");
|
||||
const item = this.actor.items.get(li.dataset.itemId);
|
||||
if (item) return item.delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle collapsing a Feature row on the actor sheet
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
|
||||
_onItemCollapse(event) {
|
||||
event.preventDefault();
|
||||
|
||||
event.currentTarget.classList.toggle("active");
|
||||
|
||||
const li = event.currentTarget.closest("li");
|
||||
const content = li.querySelector(".content");
|
||||
|
||||
if (content.style.display === "none") {
|
||||
content.style.display = "block";
|
||||
} else {
|
||||
content.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incrementing class level on the actor sheet
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
|
||||
_onIncrementClassLevel(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const div = event.currentTarget.closest(".character");
|
||||
const li = event.currentTarget.closest("li");
|
||||
|
||||
const actorId = div.id.split("-")[1];
|
||||
const itemId = li.dataset.itemId;
|
||||
|
||||
const actor = game.actors.get(actorId);
|
||||
const item = actor.items.get(itemId);
|
||||
|
||||
let levels = item.data.data.levels;
|
||||
const update = {_id: item.data._id, data: {levels: levels + 1}};
|
||||
|
||||
actor.updateEmbeddedDocuments("Item", [update]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle decrementing class level on the actor sheet
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
|
||||
_onDecrementClassLevel(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const div = event.currentTarget.closest(".character");
|
||||
const li = event.currentTarget.closest("li");
|
||||
|
||||
const actorId = div.id.split("-")[1];
|
||||
const itemId = li.dataset.itemId;
|
||||
|
||||
const actor = game.actors.get(actorId);
|
||||
const item = actor.items.get(itemId);
|
||||
|
||||
let levels = item.data.data.levels;
|
||||
const update = {_id: item.data._id, data: {levels: levels - 1}};
|
||||
|
||||
actor.updateEmbeddedDocuments("Item", [update]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle rolling an Ability check, either a test or a saving throw
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onRollAbilityTest(event) {
|
||||
event.preventDefault();
|
||||
let ability = event.currentTarget.parentElement.dataset.ability;
|
||||
return this.actor.rollAbility(ability, {event: event});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle rolling a Skill check
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onRollSkillCheck(event) {
|
||||
event.preventDefault();
|
||||
const skill = event.currentTarget.parentElement.dataset.skill;
|
||||
return this.actor.rollSkill(skill, {event: event});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle toggling Ability score proficiency level
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onToggleAbilityProficiency(event) {
|
||||
event.preventDefault();
|
||||
const field = event.currentTarget.previousElementSibling;
|
||||
return this.actor.update({[field.name]: 1 - parseInt(field.value)});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle toggling of filters to display a different set of owned items
|
||||
* @param {Event} event The click event which triggered the toggle
|
||||
* @private
|
||||
*/
|
||||
_onToggleFilter(event) {
|
||||
event.preventDefault();
|
||||
const li = event.currentTarget;
|
||||
const set = this._filters[li.parentElement.dataset.filter];
|
||||
const filter = li.dataset.filter;
|
||||
if (set.has(filter)) set.delete(filter);
|
||||
else set.add(filter);
|
||||
return this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle spawning the TraitSelector application which allows a checkbox of multiple trait options
|
||||
* @param {Event} event The click event which originated the selection
|
||||
* @private
|
||||
*/
|
||||
_onTraitSelector(event) {
|
||||
event.preventDefault();
|
||||
const a = event.currentTarget;
|
||||
const label = a.parentElement.querySelector("label");
|
||||
const choices = CONFIG.SW5E[a.dataset.options];
|
||||
const options = {name: a.dataset.target, title: label.innerText, choices};
|
||||
return new TraitSelector(this.actor, options).render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getHeaderButtons() {
|
||||
let buttons = super._getHeaderButtons();
|
||||
if (this.actor.isPolymorphed) {
|
||||
buttons.unshift({
|
||||
label: "SW5E.PolymorphRestoreTransformation",
|
||||
class: "restore-transformation",
|
||||
icon: "fas fa-backward",
|
||||
onclick: () => this.actor.revertOriginalForm()
|
||||
});
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
}
|
752
module/actor/sheets/newSheet/character.js
Normal file
|
@ -0,0 +1,752 @@
|
|||
import ActorSheet5e from "./base.js";
|
||||
import Actor5e from "../../entity.js";
|
||||
|
||||
/**
|
||||
* An Actor sheet for player character type actors in the SW5E system.
|
||||
* Extends the base ActorSheet5e class.
|
||||
* @type {ActorSheet5e}
|
||||
*/
|
||||
export default class ActorSheet5eCharacterNew extends ActorSheet5e {
|
||||
get template() {
|
||||
if (!game.user.isGM && this.actor.limited) return "systems/sw5e/templates/actors/newActor/limited-sheet.html";
|
||||
return "systems/sw5e/templates/actors/newActor/character-sheet.html";
|
||||
}
|
||||
/**
|
||||
* Define default rendering options for the NPC sheet
|
||||
* @return {Object}
|
||||
*/
|
||||
static get defaultOptions() {
|
||||
return mergeObject(super.defaultOptions, {
|
||||
classes: ["swalt", "sw5e", "sheet", "actor", "character"],
|
||||
blockFavTab: true,
|
||||
subTabs: null,
|
||||
width: 800,
|
||||
tabs: [
|
||||
{
|
||||
navSelector: ".root-tabs",
|
||||
contentSelector: ".sheet-body",
|
||||
initial: "attributes"
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add some extra data when rendering the sheet to reduce the amount of logic required within the template.
|
||||
*/
|
||||
getData() {
|
||||
const sheetData = super.getData();
|
||||
|
||||
// Temporary HP
|
||||
let hp = sheetData.data.attributes.hp;
|
||||
if (hp.temp === 0) delete hp.temp;
|
||||
if (hp.tempmax === 0) delete hp.tempmax;
|
||||
|
||||
// Resources
|
||||
sheetData["resources"] = ["primary", "secondary", "tertiary"].reduce((arr, r) => {
|
||||
const res = sheetData.data.resources[r] || {};
|
||||
res.name = r;
|
||||
res.placeholder = game.i18n.localize("SW5E.Resource" + r.titleCase());
|
||||
if (res && res.value === 0) delete res.value;
|
||||
if (res && res.max === 0) delete res.max;
|
||||
return arr.concat([res]);
|
||||
}, []);
|
||||
|
||||
// Experience Tracking
|
||||
sheetData["disableExperience"] = game.settings.get("sw5e", "disableExperienceTracking");
|
||||
sheetData["classLabels"] = this.actor.itemTypes.class.map((c) => c.name).join(", ");
|
||||
sheetData["multiclassLabels"] = this.actor.itemTypes.class
|
||||
.map((c) => {
|
||||
return [c.data.data.archetype, c.name, c.data.data.levels].filterJoin(" ");
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
// Return data for rendering
|
||||
return sheetData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Organize and classify Owned Items for Character sheets
|
||||
* @private
|
||||
*/
|
||||
_prepareItems(data) {
|
||||
// Categorize items as inventory, powerbook, features, and classes
|
||||
const inventory = {
|
||||
weapon: {label: "SW5E.ItemTypeWeaponPl", items: [], dataset: {type: "weapon"}},
|
||||
equipment: {label: "SW5E.ItemTypeEquipmentPl", items: [], dataset: {type: "equipment"}},
|
||||
consumable: {label: "SW5E.ItemTypeConsumablePl", items: [], dataset: {type: "consumable"}},
|
||||
tool: {label: "SW5E.ItemTypeToolPl", items: [], dataset: {type: "tool"}},
|
||||
backpack: {label: "SW5E.ItemTypeContainerPl", items: [], dataset: {type: "backpack"}},
|
||||
loot: {label: "SW5E.ItemTypeLootPl", items: [], dataset: {type: "loot"}}
|
||||
};
|
||||
|
||||
// Partition items by category
|
||||
let [
|
||||
items,
|
||||
forcepowers,
|
||||
techpowers,
|
||||
feats,
|
||||
classes,
|
||||
deployments,
|
||||
deploymentfeatures,
|
||||
ventures,
|
||||
species,
|
||||
archetypes,
|
||||
classfeatures,
|
||||
backgrounds,
|
||||
fightingstyles,
|
||||
fightingmasteries,
|
||||
lightsaberforms
|
||||
] = data.items.reduce(
|
||||
(arr, item) => {
|
||||
// Item details
|
||||
item.img = item.img || CONST.DEFAULT_TOKEN;
|
||||
item.isStack = Number.isNumeric(item.data.quantity) && item.data.quantity !== 1;
|
||||
item.attunement = {
|
||||
[CONFIG.SW5E.attunementTypes.REQUIRED]: {
|
||||
icon: "fa-sun",
|
||||
cls: "not-attuned",
|
||||
title: "SW5E.AttunementRequired"
|
||||
},
|
||||
[CONFIG.SW5E.attunementTypes.ATTUNED]: {
|
||||
icon: "fa-sun",
|
||||
cls: "attuned",
|
||||
title: "SW5E.AttunementAttuned"
|
||||
}
|
||||
}[item.data.attunement];
|
||||
|
||||
// Item usage
|
||||
item.hasUses = item.data.uses && item.data.uses.max > 0;
|
||||
item.isOnCooldown =
|
||||
item.data.recharge && !!item.data.recharge.value && item.data.recharge.charged === false;
|
||||
item.isDepleted = item.isOnCooldown && item.data.uses.per && item.data.uses.value > 0;
|
||||
item.hasTarget = !!item.data.target && !["none", ""].includes(item.data.target.type);
|
||||
|
||||
// Item toggle state
|
||||
this._prepareItemToggleState(item);
|
||||
|
||||
// Primary Class
|
||||
if (item.type === "class")
|
||||
item.isOriginalClass = item._id === this.actor.data.data.details.originalClass;
|
||||
|
||||
// Classify items into types
|
||||
if (item.type === "power" && ["lgt", "drk", "uni"].includes(item.data.school)) arr[1].push(item);
|
||||
else if (item.type === "power" && ["tec"].includes(item.data.school)) arr[2].push(item);
|
||||
else if (item.type === "feat") arr[3].push(item);
|
||||
else if (item.type === "class") arr[4].push(item);
|
||||
else if (item.type === "deployment") arr[5].push(item);
|
||||
else if (item.type === "deploymentfeature") arr[6].push(item);
|
||||
else if (item.type === "venture") arr[7].push(item);
|
||||
else if (item.type === "species") arr[8].push(item);
|
||||
else if (item.type === "archetype") arr[9].push(item);
|
||||
else if (item.type === "classfeature") arr[10].push(item);
|
||||
else if (item.type === "background") arr[11].push(item);
|
||||
else if (item.type === "fightingstyle") arr[12].push(item);
|
||||
else if (item.type === "fightingmastery") arr[13].push(item);
|
||||
else if (item.type === "lightsaberform") arr[14].push(item);
|
||||
else if (Object.keys(inventory).includes(item.type)) arr[0].push(item);
|
||||
return arr;
|
||||
},
|
||||
[[], [], [], [], [], [], [], [], [], [], [], [], [], [], []]
|
||||
);
|
||||
|
||||
// Apply active item filters
|
||||
items = this._filterItems(items, this._filters.inventory);
|
||||
forcepowers = this._filterItems(forcepowers, this._filters.forcePowerbook);
|
||||
techpowers = this._filterItems(techpowers, this._filters.techPowerbook);
|
||||
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 = (i.data.quantity * i.data.weight).toNearest(0.1);
|
||||
inventory[i.type].items.push(i);
|
||||
}
|
||||
|
||||
// Organize Powerbook and count the number of prepared powers (excluding always, at will, etc...)
|
||||
const forcePowerbook = this._preparePowerbook(data, forcepowers, "uni");
|
||||
const techPowerbook = this._preparePowerbook(data, techpowers, "tec");
|
||||
|
||||
// Organize Features
|
||||
const features = {
|
||||
classes: {
|
||||
label: "SW5E.ItemTypeClassPl",
|
||||
items: [],
|
||||
hasActions: false,
|
||||
dataset: {type: "class"},
|
||||
isClass: true
|
||||
},
|
||||
classfeatures: {
|
||||
label: "SW5E.ItemTypeClassFeats",
|
||||
items: [],
|
||||
hasActions: true,
|
||||
dataset: {type: "classfeature"},
|
||||
isClassfeature: true
|
||||
},
|
||||
archetype: {
|
||||
label: "SW5E.ItemTypeArchetype",
|
||||
items: [],
|
||||
hasActions: false,
|
||||
dataset: {type: "archetype"},
|
||||
isArchetype: true
|
||||
},
|
||||
deployments: {
|
||||
label: "SW5E.ItemTypeDeploymentPl",
|
||||
items: [],
|
||||
hasActions: false,
|
||||
dataset: {type: "deployment"},
|
||||
isDeployment: true
|
||||
},
|
||||
deploymentfeatures: {
|
||||
label: "SW5E.ItemTypeDeploymentFeaturePl",
|
||||
items: [],
|
||||
hasActions: true,
|
||||
dataset: {type: "deploymentfeature"},
|
||||
isDeploymentfeature: true
|
||||
},
|
||||
ventures: {
|
||||
label: "SW5E.ItemTypeVenturePl",
|
||||
items: [],
|
||||
hasActions: false,
|
||||
dataset: {type: "venture"},
|
||||
isVenture: true
|
||||
},
|
||||
species: {
|
||||
label: "SW5E.ItemTypeSpecies",
|
||||
items: [],
|
||||
hasActions: false,
|
||||
dataset: {type: "species"},
|
||||
isSpecies: true
|
||||
},
|
||||
background: {
|
||||
label: "SW5E.ItemTypeBackground",
|
||||
items: [],
|
||||
hasActions: false,
|
||||
dataset: {type: "background"},
|
||||
isBackground: true
|
||||
},
|
||||
fightingstyles: {
|
||||
label: "SW5E.ItemTypeFightingStylePl",
|
||||
items: [],
|
||||
hasActions: false,
|
||||
dataset: {type: "fightingstyle"},
|
||||
isFightingstyle: true
|
||||
},
|
||||
fightingmasteries: {
|
||||
label: "SW5E.ItemTypeFightingMasteryPl",
|
||||
items: [],
|
||||
hasActions: false,
|
||||
dataset: {type: "fightingmastery"},
|
||||
isFightingmastery: true
|
||||
},
|
||||
lightsaberforms: {
|
||||
label: "SW5E.ItemTypeLightsaberFormPl",
|
||||
items: [],
|
||||
hasActions: false,
|
||||
dataset: {type: "lightsaberform"},
|
||||
isLightsaberform: true
|
||||
},
|
||||
active: {
|
||||
label: "SW5E.FeatureActive",
|
||||
items: [],
|
||||
hasActions: true,
|
||||
dataset: {"type": "feat", "activation.type": "action"}
|
||||
},
|
||||
passive: {label: "SW5E.FeaturePassive", items: [], hasActions: false, dataset: {type: "feat"}}
|
||||
};
|
||||
for (let f of feats) {
|
||||
if (f.data.activation.type) features.active.items.push(f);
|
||||
else features.passive.items.push(f);
|
||||
}
|
||||
classes.sort((a, b) => b.data.levels - a.data.levels);
|
||||
features.classes.items = classes;
|
||||
features.classfeatures.items = classfeatures;
|
||||
features.archetype.items = archetypes;
|
||||
features.deployments.items = deployments;
|
||||
features.deploymentfeatures.items = deploymentfeatures;
|
||||
features.ventures.items = ventures;
|
||||
features.species.items = species;
|
||||
features.background.items = backgrounds;
|
||||
features.fightingstyles.items = fightingstyles;
|
||||
features.fightingmasteries.items = fightingmasteries;
|
||||
features.lightsaberforms.items = lightsaberforms;
|
||||
|
||||
// Assign and return
|
||||
data.inventory = Object.values(inventory);
|
||||
data.forcePowerbook = forcePowerbook;
|
||||
data.techPowerbook = techPowerbook;
|
||||
data.features = Object.values(features);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A helper method to establish the displayed preparation state for an item
|
||||
* @param {Item} item
|
||||
* @private
|
||||
*/
|
||||
_prepareItemToggleState(item) {
|
||||
if (item.type === "power") {
|
||||
const isAlways = getProperty(item.data, "preparation.mode") === "always";
|
||||
const isPrepared = getProperty(item.data, "preparation.prepared");
|
||||
item.toggleClass = isPrepared ? "active" : "";
|
||||
if (isAlways) item.toggleClass = "fixed";
|
||||
if (isAlways) item.toggleTitle = CONFIG.SW5E.powerPreparationModes.always;
|
||||
else if (isPrepared) item.toggleTitle = CONFIG.SW5E.powerPreparationModes.prepared;
|
||||
else item.toggleTitle = game.i18n.localize("SW5E.PowerUnprepared");
|
||||
} else {
|
||||
const isActive = getProperty(item.data, "equipped");
|
||||
item.toggleClass = isActive ? "active" : "";
|
||||
item.toggleTitle = game.i18n.localize(isActive ? "SW5E.Equipped" : "SW5E.Unequipped");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate event listeners using the prepared sheet HTML
|
||||
* @param html {jQuery} The prepared HTML object ready to be rendered into the DOM
|
||||
*/
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
if (!this.isEditable) return;
|
||||
|
||||
// Inventory Functions
|
||||
// html.find(".currency-convert").click(this._onConvertCurrency.bind(this));
|
||||
|
||||
// Item State Toggling
|
||||
html.find(".item-toggle").click(this._onToggleItem.bind(this));
|
||||
|
||||
// Short and Long Rest
|
||||
html.find(".short-rest").click(this._onShortRest.bind(this));
|
||||
html.find(".long-rest").click(this._onLongRest.bind(this));
|
||||
|
||||
// Rollable sheet actions
|
||||
html.find(".rollable[data-action]").click(this._onSheetAction.bind(this));
|
||||
|
||||
// Send Languages to Chat onClick
|
||||
html.find('[data-options="share-languages"]').click((event) => {
|
||||
event.preventDefault();
|
||||
let langs = this.actor.data.data.traits.languages.value
|
||||
.map((l) => CONFIG.SW5E.languages[l] || l)
|
||||
.join(", ");
|
||||
let custom = this.actor.data.data.traits.languages.custom;
|
||||
if (custom) langs += ", " + custom.replace(/;/g, ",");
|
||||
let content = `
|
||||
<div class="sw5e chat-card item-card" data-acor-id="${this.actor.data._id}">
|
||||
<header class="card-header flexrow">
|
||||
<img src="${this.actor.data.token.img}" title="" width="36" height="36" style="border: none;"/>
|
||||
<h3>Known Languages</h3>
|
||||
</header>
|
||||
<div class="card-content">${langs}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Send to Chat
|
||||
let rollBlind = false;
|
||||
let rollMode = game.settings.get("core", "rollMode");
|
||||
if (rollMode === "blindroll") rollBlind = true;
|
||||
let data = {
|
||||
user: game.user.data._id,
|
||||
content: content,
|
||||
blind: rollBlind,
|
||||
speaker: {
|
||||
actor: this.actor.data._id,
|
||||
token: this.actor.token,
|
||||
alias: this.actor.name
|
||||
},
|
||||
type: CONST.CHAT_MESSAGE_TYPES.OTHER
|
||||
};
|
||||
|
||||
if (["gmroll", "blindroll"].includes(rollMode)) data["whisper"] = ChatMessage.getWhisperRecipients("GM");
|
||||
else if (rollMode === "selfroll") data["whisper"] = [game.users.get(game.user.data._id)];
|
||||
|
||||
ChatMessage.create(data);
|
||||
});
|
||||
|
||||
// Item Delete Confirmation
|
||||
html.find(".item-delete").off("click");
|
||||
html.find(".item-delete").click((event) => {
|
||||
let li = $(event.currentTarget).parents(".item");
|
||||
let itemId = li.attr("data-item-id");
|
||||
let item = this.actor.items.get(itemId);
|
||||
new Dialog({
|
||||
title: `Deleting ${item.data.name}`,
|
||||
content: `<p>Are you sure you want to delete ${item.data.name}?</p>`,
|
||||
buttons: {
|
||||
Yes: {
|
||||
icon: '<i class="fa fa-check"></i>',
|
||||
label: "Yes",
|
||||
callback: (dlg) => {
|
||||
this.actor.deleteOwnedItem(itemId);
|
||||
}
|
||||
},
|
||||
cancel: {
|
||||
icon: '<i class="fas fa-times"></i>',
|
||||
label: "No"
|
||||
}
|
||||
},
|
||||
default: "cancel"
|
||||
}).render(true);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse click events for character sheet actions
|
||||
* @param {MouseEvent} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onSheetAction(event) {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget;
|
||||
switch (button.dataset.action) {
|
||||
case "rollDeathSave":
|
||||
return this.actor.rollDeathSave({event: event});
|
||||
case "rollInitiative":
|
||||
return this.actor.rollInitiative({createCombatants: true});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle toggling the state of an Owned Item within the Actor
|
||||
* @param {Event} event The triggering click event
|
||||
* @private
|
||||
*/
|
||||
_onToggleItem(event) {
|
||||
event.preventDefault();
|
||||
const itemId = event.currentTarget.closest(".item").dataset.itemId;
|
||||
const item = this.actor.items.get(itemId);
|
||||
const attr = item.data.type === "power" ? "data.preparation.prepared" : "data.equipped";
|
||||
return item.update({[attr]: !getProperty(item.data, attr)});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Take a short rest, calling the relevant function on the Actor instance
|
||||
* @param {Event} event The triggering click event
|
||||
* @private
|
||||
*/
|
||||
async _onShortRest(event) {
|
||||
event.preventDefault();
|
||||
await this._onSubmit(event);
|
||||
return this.actor.shortRest();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Take a long rest, calling the relevant function on the Actor instance
|
||||
* @param {Event} event The triggering click event
|
||||
* @private
|
||||
*/
|
||||
async _onLongRest(event) {
|
||||
event.preventDefault();
|
||||
await this._onSubmit(event);
|
||||
return this.actor.longRest();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onDropItemCreate(itemData) {
|
||||
// Increment the number of class levels of a character instead of creating a new item
|
||||
if (itemData.type === "class") {
|
||||
const cls = this.actor.itemTypes.class.find((c) => c.name === itemData.name);
|
||||
let priorLevel = cls?.data.data.levels ?? 0;
|
||||
if (!!cls) {
|
||||
const next = Math.min(priorLevel + 1, 20 + priorLevel - this.actor.data.data.details.level);
|
||||
if (next > priorLevel) {
|
||||
itemData.levels = next;
|
||||
return cls.update({"data.levels": next});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Increment the number of deployment ranks of a character instead of creating a new item
|
||||
// else if ( itemData.type === "deployment" ) {
|
||||
// const rnk = this.actor.itemTypes.deployment.find(c => c.name === itemData.name);
|
||||
// let priorRank = rnk?.data.data.ranks ?? 0;
|
||||
// if ( !!rnk ) {
|
||||
// const next = Math.min(priorLevel + 1, 5 + priorRank - this.actor.data.data.details.rank);
|
||||
// if ( next > priorRank ) {
|
||||
// itemData.ranks = next;
|
||||
// return rnk.update({"data.ranks": next});
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// Default drop handling if levels were not added
|
||||
return super._onDropItemCreate(itemData);
|
||||
}
|
||||
}
|
||||
async function addFavorites(app, html, data) {
|
||||
// Thisfunction is adapted for the SwaltSheet from the Favorites Item
|
||||
// Tab Module created for Foundry VTT - by Felix Müller (Felix#6196 on Discord).
|
||||
// It is licensed under a Creative Commons Attribution 4.0 International License
|
||||
// and can be found at https://github.com/syl3r86/favtab.
|
||||
let favItems = [];
|
||||
let favFeats = [];
|
||||
let favPowers = {
|
||||
0: {
|
||||
isCantrip: true,
|
||||
powers: []
|
||||
},
|
||||
1: {
|
||||
powers: [],
|
||||
value: data.actor.data.powers.power1.value,
|
||||
max: data.actor.data.powers.power1.max
|
||||
},
|
||||
2: {
|
||||
powers: [],
|
||||
value: data.actor.data.powers.power2.value,
|
||||
max: data.actor.data.powers.power2.max
|
||||
},
|
||||
3: {
|
||||
powers: [],
|
||||
value: data.actor.data.powers.power3.value,
|
||||
max: data.actor.data.powers.power3.max
|
||||
},
|
||||
4: {
|
||||
powers: [],
|
||||
value: data.actor.data.powers.power4.value,
|
||||
max: data.actor.data.powers.power4.max
|
||||
},
|
||||
5: {
|
||||
powers: [],
|
||||
value: data.actor.data.powers.power5.value,
|
||||
max: data.actor.data.powers.power5.max
|
||||
},
|
||||
6: {
|
||||
powers: [],
|
||||
value: data.actor.data.powers.power6.value,
|
||||
max: data.actor.data.powers.power6.max
|
||||
},
|
||||
7: {
|
||||
powers: [],
|
||||
value: data.actor.data.powers.power7.value,
|
||||
max: data.actor.data.powers.power7.max
|
||||
},
|
||||
8: {
|
||||
powers: [],
|
||||
value: data.actor.data.powers.power8.value,
|
||||
max: data.actor.data.powers.power8.max
|
||||
},
|
||||
9: {
|
||||
powers: [],
|
||||
value: data.actor.data.powers.power9.value,
|
||||
max: data.actor.data.powers.power9.max
|
||||
}
|
||||
};
|
||||
|
||||
let powerCount = 0;
|
||||
let items = data.actor.items;
|
||||
for (let item of items) {
|
||||
if (item.type == "class") continue;
|
||||
if (item.flags.favtab === undefined || item.flags.favtab.isFavourite === undefined) {
|
||||
item.flags.favtab = {
|
||||
isFavourite: false
|
||||
};
|
||||
}
|
||||
let isFav = item.flags.favtab.isFavourite;
|
||||
if (app.options.editable) {
|
||||
let favBtn = $(
|
||||
`<a class="item-control item-toggle item-fav ${isFav ? "active" : ""}" data-fav="${isFav}" title="${
|
||||
isFav ? "Remove from Favourites" : "Add to Favourites"
|
||||
}"><i class="fas fa-star"></i></a>`
|
||||
);
|
||||
favBtn.click((ev) => {
|
||||
app.actor.items.get(item.data._id).update({
|
||||
"flags.favtab.isFavourite": !item.flags.favtab.isFavourite
|
||||
});
|
||||
});
|
||||
html.find(`.item[data-item-id="${item.data._id}"]`).find(".item-controls").prepend(favBtn);
|
||||
}
|
||||
|
||||
if (isFav) {
|
||||
item.powerComps = "";
|
||||
if (item.data.components) {
|
||||
let comps = item.data.components;
|
||||
let v = comps.vocal ? "V" : "";
|
||||
let s = comps.somatic ? "S" : "";
|
||||
let m = comps.material ? "M" : "";
|
||||
let c = !!comps.concentration;
|
||||
let r = !!comps.ritual;
|
||||
item.powerComps = `${v}${s}${m}`;
|
||||
item.powerCon = c;
|
||||
item.powerRit = r;
|
||||
}
|
||||
|
||||
item.editable = app.options.editable;
|
||||
switch (item.type) {
|
||||
case "feat":
|
||||
if (item.flags.favtab.sort === undefined) {
|
||||
item.flags.favtab.sort = (favFeats.count + 1) * 100000; // initial sort key if not present
|
||||
}
|
||||
favFeats.push(item);
|
||||
break;
|
||||
case "power":
|
||||
if (item.data.preparation.mode) {
|
||||
item.powerPrepMode = ` (${CONFIG.SW5E.powerPreparationModes[item.data.preparation.mode]})`;
|
||||
}
|
||||
if (item.data.level) {
|
||||
favPowers[item.data.level].powers.push(item);
|
||||
} else {
|
||||
favPowers[0].powers.push(item);
|
||||
}
|
||||
powerCount++;
|
||||
break;
|
||||
default:
|
||||
if (item.flags.favtab.sort === undefined) {
|
||||
item.flags.favtab.sort = (favItems.count + 1) * 100000; // initial sort key if not present
|
||||
}
|
||||
favItems.push(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alter core CSS to fit new button
|
||||
// if (app.options.editable) {
|
||||
// html.find('.powerbook .item-controls').css('flex', '0 0 88px');
|
||||
// html.find('.inventory .item-controls, .features .item-controls').css('flex', '0 0 90px');
|
||||
// html.find('.favourite .item-controls').css('flex', '0 0 22px');
|
||||
// }
|
||||
|
||||
let tabContainer = html.find(".favtabtarget");
|
||||
data.favItems = favItems.length > 0 ? favItems.sort((a, b) => a.flags.favtab.sort - b.flags.favtab.sort) : false;
|
||||
data.favFeats = favFeats.length > 0 ? favFeats.sort((a, b) => a.flags.favtab.sort - b.flags.favtab.sort) : false;
|
||||
data.favPowers = powerCount > 0 ? favPowers : false;
|
||||
data.editable = app.options.editable;
|
||||
|
||||
await loadTemplates(["systems/sw5e/templates/actors/newActor/item.hbs"]);
|
||||
let favtabHtml = $(await renderTemplate("systems/sw5e/templates/actors/newActor/template.hbs", data));
|
||||
favtabHtml.find(".item-name h4").click((event) => app._onItemSummary(event));
|
||||
|
||||
if (app.options.editable) {
|
||||
favtabHtml.find(".item-image").click((ev) => app._onItemRoll(ev));
|
||||
let handler = (ev) => app._onDragStart(ev);
|
||||
favtabHtml.find(".item").each((i, li) => {
|
||||
if (li.classList.contains("inventory-header")) return;
|
||||
li.setAttribute("draggable", true);
|
||||
li.addEventListener("dragstart", handler, false);
|
||||
});
|
||||
//favtabHtml.find('.item-toggle').click(event => app._onToggleItem(event));
|
||||
favtabHtml.find(".item-edit").click((ev) => {
|
||||
let itemId = $(ev.target).parents(".item")[0].dataset.itemId;
|
||||
app.actor.items.get(itemId).sheet.render(true);
|
||||
});
|
||||
favtabHtml.find(".item-fav").click((ev) => {
|
||||
let itemId = $(ev.target).parents(".item")[0].dataset.itemId;
|
||||
let val = !app.actor.items.get(itemId).data.flags.favtab.isFavourite;
|
||||
app.actor.items.get(itemId).update({
|
||||
"flags.favtab.isFavourite": val
|
||||
});
|
||||
});
|
||||
|
||||
// Sorting
|
||||
favtabHtml.find(".item").on("drop", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
let dropData = JSON.parse(ev.originalEvent.dataTransfer.getData("text/plain"));
|
||||
// if (dropData.actorId !== app.actor.id || dropData.data.type === 'power') return;
|
||||
if (dropData.actorId !== app.actor.id) return;
|
||||
let list = null;
|
||||
if (dropData.data.type === "feat") list = favFeats;
|
||||
else list = favItems;
|
||||
let dragSource = list.find((i) => i.data._id === dropData.data._id);
|
||||
let siblings = list.filter((i) => i.data._id !== dropData.data._id);
|
||||
let targetId = ev.target.closest(".item").dataset.itemId;
|
||||
let dragTarget = siblings.find((s) => s.data._id === targetId);
|
||||
|
||||
if (dragTarget === undefined) return;
|
||||
const sortUpdates = SortingHelpers.performIntegerSort(dragSource, {
|
||||
target: dragTarget,
|
||||
siblings: siblings,
|
||||
sortKey: "flags.favtab.sort"
|
||||
});
|
||||
const updateData = sortUpdates.map((u) => {
|
||||
const update = u.update;
|
||||
update._id = u.target.data._id;
|
||||
return update;
|
||||
});
|
||||
app.actor.updateEmbeddedEntity("OwnedItem", updateData);
|
||||
});
|
||||
}
|
||||
tabContainer.append(favtabHtml);
|
||||
// if(app.options.editable) {
|
||||
// let handler = ev => app._onDragItemStart(ev);
|
||||
// tabContainer.find('.item').each((i, li) => {
|
||||
// if (li.classList.contains("inventory-header")) return;
|
||||
// li.setAttribute("draggable", true);
|
||||
// li.addEventListener("dragstart", handler, false);
|
||||
// });
|
||||
//}
|
||||
// try {
|
||||
// if (game.modules.get("betterrolls5e") && game.modules.get("betterrolls5e").active) BetterRolls.addItemContent(app.object, favtabHtml, ".item .item-name h4", ".item-properties", ".item > .rollable div");
|
||||
// }
|
||||
// catch (err) {
|
||||
// // Better Rolls not found!
|
||||
// }
|
||||
Hooks.callAll("renderedSwaltSheet", app, html, data);
|
||||
}
|
||||
async function addSubTabs(app, html, data) {
|
||||
if (data.options.subTabs == null) {
|
||||
//let subTabs = []; //{subgroup: '', target: '', active: false}
|
||||
data.options.subTabs = {};
|
||||
html.find("[data-subgroup-selection] [data-subgroup]").each((idx, el) => {
|
||||
let subgroup = el.getAttribute("data-subgroup");
|
||||
let target = el.getAttribute("data-target");
|
||||
let targetObj = {target: target, active: el.classList.contains("active")};
|
||||
if (data.options.subTabs.hasOwnProperty(subgroup)) {
|
||||
data.options.subTabs[subgroup].push(targetObj);
|
||||
} else {
|
||||
data.options.subTabs[subgroup] = [];
|
||||
data.options.subTabs[subgroup].push(targetObj);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const group in data.options.subTabs) {
|
||||
data.options.subTabs[group].forEach((tab) => {
|
||||
if (tab.active) {
|
||||
html.find(`[data-subgroup=${group}][data-target=${tab.target}]`).addClass("active");
|
||||
} else {
|
||||
html.find(`[data-subgroup=${group}][data-target=${tab.target}]`).removeClass("active");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
html.find("[data-subgroup-selection]")
|
||||
.children()
|
||||
.on("click", (event) => {
|
||||
let subgroup = event.target.closest("[data-subgroup]").getAttribute("data-subgroup");
|
||||
let target = event.target.closest("[data-target]").getAttribute("data-target");
|
||||
html.find(`[data-subgroup=${subgroup}]`).removeClass("active");
|
||||
html.find(`[data-subgroup=${subgroup}][data-target=${target}]`).addClass("active");
|
||||
let tabId = data.options.subTabs[subgroup].find((tab) => {
|
||||
return tab.target == target;
|
||||
});
|
||||
data.options.subTabs[subgroup].map((el) => {
|
||||
el.active = el.target == target;
|
||||
return el;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Hooks.on("renderActorSheet5eCharacterNew", (app, html, data) => {
|
||||
addFavorites(app, html, data);
|
||||
addSubTabs(app, html, data);
|
||||
});
|
159
module/actor/sheets/newSheet/npc.js
Normal file
|
@ -0,0 +1,159 @@
|
|||
import ActorSheet5e from "./base.js";
|
||||
|
||||
/**
|
||||
* An Actor sheet for NPC type characters in the SW5E system.
|
||||
* Extends the base ActorSheet5e class.
|
||||
* @extends {ActorSheet5e}
|
||||
*/
|
||||
export default class ActorSheet5eNPCNew extends ActorSheet5e {
|
||||
/** @override */
|
||||
get template() {
|
||||
if (!game.user.isGM && this.actor.limited) return "systems/sw5e/templates/actors/newActor/limited-sheet.html";
|
||||
return `systems/sw5e/templates/actors/newActor/npc-sheet.html`;
|
||||
}
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return mergeObject(super.defaultOptions, {
|
||||
classes: ["sw5e", "sheet", "actor", "npc"],
|
||||
width: 800,
|
||||
tabs: [
|
||||
{
|
||||
navSelector: ".root-tabs",
|
||||
contentSelector: ".sheet-body",
|
||||
initial: "attributes"
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static unsupportedItemTypes = new Set(["class"]);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Organize Owned Items for rendering the NPC sheet
|
||||
* @private
|
||||
*/
|
||||
_prepareItems(data) {
|
||||
// Categorize Items as Features and Powers
|
||||
const features = {
|
||||
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 [forcepowers, techpowers, other] = data.items.reduce(
|
||||
(arr, item) => {
|
||||
item.img = item.img || CONST.DEFAULT_TOKEN;
|
||||
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;
|
||||
item.hasTarget = !!item.data.target && !["none", ""].includes(item.data.target.type);
|
||||
if (item.type === "power" && ["lgt", "drk", "uni"].includes(item.data.school)) arr[0].push(item);
|
||||
else if (item.type === "power" && ["tec"].includes(item.data.school)) arr[1].push(item);
|
||||
else arr[2].push(item);
|
||||
return arr;
|
||||
},
|
||||
[[], [], []]
|
||||
);
|
||||
|
||||
// Apply item filters
|
||||
forcepowers = this._filterItems(forcepowers, this._filters.forcePowerbook);
|
||||
techpowers = this._filterItems(techpowers, this._filters.techPowerbook);
|
||||
other = this._filterItems(other, this._filters.features);
|
||||
|
||||
// Organize Powerbook
|
||||
const forcePowerbook = this._preparePowerbook(data, forcepowers, "uni");
|
||||
const techPowerbook = this._preparePowerbook(data, techpowers, "tec");
|
||||
|
||||
// Organize Features
|
||||
for (let item of other) {
|
||||
if (item.type === "weapon") features.weapons.items.push(item);
|
||||
else if (item.type === "feat") {
|
||||
if (item.data.activation.type) features.actions.items.push(item);
|
||||
else features.passive.items.push(item);
|
||||
} else features.equipment.items.push(item);
|
||||
}
|
||||
|
||||
// Assign and return
|
||||
data.features = Object.values(features);
|
||||
data.forcePowerbook = forcePowerbook;
|
||||
data.techPowerbook = techPowerbook;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options) {
|
||||
const data = super.getData(options);
|
||||
|
||||
// Challenge Rating
|
||||
const cr = parseFloat(data.data.details.cr || 0);
|
||||
const crLabels = {0: "0", 0.125: "1/8", 0.25: "1/4", 0.5: "1/2"};
|
||||
data.labels["cr"] = cr >= 1 ? String(cr) : crLabels[cr] || 1;
|
||||
|
||||
// Creature Type
|
||||
data.labels["type"] = this.actor.labels.creatureType;
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Object Updates */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
// Format NPC Challenge Rating
|
||||
const crs = {"1/8": 0.125, "1/4": 0.25, "1/2": 0.5};
|
||||
let crv = "data.details.cr";
|
||||
let cr = formData[crv];
|
||||
cr = crs[cr] || parseFloat(cr);
|
||||
if (cr) formData[crv] = cr < 1 ? cr : parseInt(cr);
|
||||
|
||||
// Parent ActorSheet update steps
|
||||
return super._updateObject(event, formData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find(".health .rollable").click(this._onRollHPFormula.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle rolling NPC health values using the provided formula
|
||||
* @param {Event} event The original click event
|
||||
* @private
|
||||
*/
|
||||
_onRollHPFormula(event) {
|
||||
event.preventDefault();
|
||||
const formula = this.actor.data.data.attributes.hp.formula;
|
||||
if (!formula) return;
|
||||
const hp = new Roll(formula).roll().total;
|
||||
AudioHelper.play({src: CONFIG.sounds.dice});
|
||||
this.actor.update({"data.attributes.hp.value": hp, "data.attributes.hp.max": hp});
|
||||
}
|
||||
}
|
169
module/actor/sheets/newSheet/starship.js
Normal file
|
@ -0,0 +1,169 @@
|
|||
import ActorSheet5e from "./base.js";
|
||||
|
||||
/**
|
||||
* An Actor sheet for starships in the SW5E system.
|
||||
* Extends the base ActorSheet5e class.
|
||||
* @extends {ActorSheet5e}
|
||||
*/
|
||||
export default class ActorSheet5eStarship extends ActorSheet5e {
|
||||
/** @override */
|
||||
get template() {
|
||||
if (!game.user.isGM && this.actor.limited) return "systems/sw5e/templates/actors/newActor/limited-sheet.html";
|
||||
return `systems/sw5e/templates/actors/newActor/starship.html`;
|
||||
}
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return mergeObject(super.defaultOptions, {
|
||||
classes: ["sw5e", "sheet", "actor", "starship"],
|
||||
width: 800,
|
||||
tabs: [
|
||||
{
|
||||
navSelector: ".root-tabs",
|
||||
contentSelector: ".sheet-body",
|
||||
initial: "attributes"
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Organize Owned Items for rendering the starship sheet
|
||||
* @private
|
||||
*/
|
||||
_prepareItems(data) {
|
||||
// Categorize Items as Features and Powers
|
||||
const features = {
|
||||
weapons: {
|
||||
label: game.i18n.localize("SW5E.ItemTypeWeaponPl"),
|
||||
items: [],
|
||||
hasActions: true,
|
||||
dataset: {"type": "weapon", "weapon-type": "natural"}
|
||||
},
|
||||
passive: {label: game.i18n.localize("SW5E.Features"), items: [], dataset: {type: "feat"}},
|
||||
equipment: {label: game.i18n.localize("SW5E.StarshipEquipment"), items: [], dataset: {type: "equipment"}},
|
||||
starshipfeatures: {
|
||||
label: game.i18n.localize("SW5E.StarshipfeaturePl"),
|
||||
items: [],
|
||||
hasActions: true,
|
||||
dataset: {type: "starshipfeature"}
|
||||
},
|
||||
starshipmods: {
|
||||
label: game.i18n.localize("SW5E.StarshipmodPl"),
|
||||
items: [],
|
||||
hasActions: false,
|
||||
dataset: {type: "starshipmod"}
|
||||
}
|
||||
};
|
||||
|
||||
// Start by classifying items into groups for rendering
|
||||
let [forcepowers, techpowers, other] = data.items.reduce(
|
||||
(arr, item) => {
|
||||
item.img = item.img || CONST.DEFAULT_TOKEN;
|
||||
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;
|
||||
item.hasTarget = !!item.data.target && !["none", ""].includes(item.data.target.type);
|
||||
if (item.type === "power" && ["lgt", "drk", "uni"].includes(item.data.school)) arr[0].push(item);
|
||||
else if (item.type === "power" && ["tec"].includes(item.data.school)) arr[1].push(item);
|
||||
else arr[2].push(item);
|
||||
return arr;
|
||||
},
|
||||
[[], [], []]
|
||||
);
|
||||
|
||||
// Apply item filters
|
||||
forcepowers = this._filterItems(forcepowers, this._filters.forcePowerbook);
|
||||
techpowers = this._filterItems(techpowers, this._filters.techPowerbook);
|
||||
other = this._filterItems(other, this._filters.features);
|
||||
|
||||
// Organize Powerbook
|
||||
// const forcePowerbook = this._preparePowerbook(data, forcepowers, "uni");
|
||||
// const techPowerbook = this._preparePowerbook(data, techpowers, "tec");
|
||||
|
||||
// Organize Features
|
||||
for (let item of other) {
|
||||
if (item.type === "weapon") features.weapons.items.push(item);
|
||||
else if (item.type === "feat") {
|
||||
if (item.data.activation.type) features.actions.items.push(item);
|
||||
else features.passive.items.push(item);
|
||||
} else if (item.type === "starshipfeature") {
|
||||
features.starshipfeatures.items.push(item);
|
||||
} else if (item.type === "starshipmod") {
|
||||
features.starshipmods.items.push(item);
|
||||
} else features.equipment.items.push(item);
|
||||
}
|
||||
|
||||
// Assign and return
|
||||
data.features = Object.values(features);
|
||||
// data.forcePowerbook = forcePowerbook;
|
||||
// data.techPowerbook = techPowerbook;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData(options) {
|
||||
const data = super.getData(options);
|
||||
|
||||
// Add Size info
|
||||
data.isTiny = data.actor.data.traits.size === "tiny";
|
||||
data.isSmall = data.actor.data.traits.size === "sm";
|
||||
data.isMedium = data.actor.data.traits.size === "med";
|
||||
data.isLarge = data.actor.data.traits.size === "lg";
|
||||
data.isHuge = data.actor.data.traits.size === "huge";
|
||||
data.isGargantuan = data.actor.data.traits.size === "grg";
|
||||
|
||||
// Challenge Rating
|
||||
const cr = parseFloat(data.data.details.cr || 0);
|
||||
const crLabels = {0: "0", 0.125: "1/8", 0.25: "1/4", 0.5: "1/2"};
|
||||
data.labels["cr"] = cr >= 1 ? String(cr) : crLabels[cr] || 1;
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Object Updates */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
// Format NPC Challenge Rating
|
||||
const crs = {"1/8": 0.125, "1/4": 0.25, "1/2": 0.5};
|
||||
let crv = "data.details.cr";
|
||||
let cr = formData[crv];
|
||||
cr = crs[cr] || parseFloat(cr);
|
||||
if (cr) formData[crv] = cr < 1 ? cr : parseInt(cr);
|
||||
|
||||
// Parent ActorSheet update steps
|
||||
return super._updateObject(event, formData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find(".health .rollable").click(this._onRollHPFormula.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle rolling NPC health values using the provided formula
|
||||
* @param {Event} event The original click event
|
||||
* @private
|
||||
*/
|
||||
_onRollHPFormula(event) {
|
||||
event.preventDefault();
|
||||
const formula = this.actor.data.data.attributes.hp.formula;
|
||||
if (!formula) return;
|
||||
const hp = new Roll(formula).roll().total;
|
||||
AudioHelper.play({src: CONFIG.sounds.dice});
|
||||
this.actor.update({"data.attributes.hp.value": hp, "data.attributes.hp.max": hp});
|
||||
}
|
||||
}
|
432
module/actor/sheets/newSheet/vehicle.js
Normal file
|
@ -0,0 +1,432 @@
|
|||
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
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static unsupportedItemTypes = new Set(["class"]);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Creates a new cargo entry for a vehicle Actor.
|
||||
*/
|
||||
static get newCargo() {
|
||||
return {
|
||||
name: "",
|
||||
quantity: 1
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Compute the total weight of the vehicle's cargo.
|
||||
* @param {Number} totalWeight The cumulative item weight from inventory items
|
||||
* @param {Object} actorData The data object for the Actor being rendered
|
||||
* @returns {{max: number, value: number, pct: number}}
|
||||
* @private
|
||||
*/
|
||||
_computeEncumbrance(totalWeight, actorData) {
|
||||
// Compute currency weight
|
||||
const totalCoins = Object.values(actorData.data.currency).reduce((acc, denom) => acc + denom, 0);
|
||||
totalWeight += totalCoins / CONFIG.SW5E.encumbrance.currencyPerWeight;
|
||||
|
||||
// Vehicle weights are an order of magnitude greater.
|
||||
totalWeight /= CONFIG.SW5E.encumbrance.vehicleWeightMultiplier;
|
||||
|
||||
// Compute overall encumbrance
|
||||
const max = actorData.data.attributes.capacity.cargo;
|
||||
const pct = Math.clamped((totalWeight * 100) / max, 0, 100);
|
||||
return {value: totalWeight.toNearest(0.1), max, pct};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getMovementSpeed(actorData, largestPrimary = true) {
|
||||
return super._getMovementSpeed(actorData, largestPrimary);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare items that are mounted to a vehicle and require one or more crew
|
||||
* to operate.
|
||||
* @private
|
||||
*/
|
||||
_prepareCrewedItem(item) {
|
||||
// Determine crewed status
|
||||
const isCrewed = item.data.crewed;
|
||||
item.toggleClass = isCrewed ? "active" : "";
|
||||
item.toggleTitle = game.i18n.localize(`SW5E.${isCrewed ? "Crewed" : "Uncrewed"}`);
|
||||
|
||||
// Handle crew actions
|
||||
if (item.type === "feat" && item.data.activation.type === "crew") {
|
||||
item.crew = item.data.activation.cost;
|
||||
item.cover = game.i18n.localize(`SW5E.${item.data.cover ? "CoverTotal" : "None"}`);
|
||||
if (item.data.cover === 0.5) item.cover = "½";
|
||||
else if (item.data.cover === 0.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"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// Classify items owned by the vehicle and compute total cargo weight
|
||||
let totalWeight = 0;
|
||||
for (const item of data.items) {
|
||||
this._prepareCrewedItem(item);
|
||||
|
||||
// Handle cargo explicitly
|
||||
const isCargo = item.flags.sw5e?.vehicleCargo === true;
|
||||
if (isCargo) {
|
||||
totalWeight += (item.data.weight || 0) * item.data.quantity;
|
||||
cargo.cargo.items.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle non-cargo item types
|
||||
switch (item.type) {
|
||||
case "weapon":
|
||||
features.weapons.items.push(item);
|
||||
break;
|
||||
case "equipment":
|
||||
features.equipment.items.push(item);
|
||||
break;
|
||||
case "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);
|
||||
break;
|
||||
default:
|
||||
totalWeight += (item.data.weight || 0) * item.data.quantity;
|
||||
cargo.cargo.items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the rendering context data
|
||||
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.isEditable) 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 = foundry.utils.deepClone(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 = foundry.utils.deepClone(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 = foundry.utils.deepClone(this.actor.data.data.cargo[type]).filter((_, i) => i !== idx);
|
||||
return this.actor.update({[`data.cargo.${type}`]: cargo});
|
||||
}
|
||||
|
||||
return super._onItemDelete(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onDropItemCreate(itemData) {
|
||||
const cargoTypes = ["weapon", "equipment", "consumable", "tool", "loot", "backpack"];
|
||||
const isCargo = cargoTypes.includes(itemData.type) && this._tabs[0].active === "cargo";
|
||||
foundry.utils.setProperty(itemData, "flags.sw5e.vehicleCargo", isCargo);
|
||||
return super._onDropItemCreate(itemData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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});
|
||||
}
|
||||
}
|
920
module/actor/sheets/oldSheets/base.js
Normal file
|
@ -0,0 +1,920 @@
|
|||
import Item5e from "../../../item/entity.js";
|
||||
import TraitSelector from "../../../apps/trait-selector.js";
|
||||
import ActorSheetFlags from "../../../apps/actor-flags.js";
|
||||
import ActorHitDiceConfig from "../../../apps/hit-dice-config.js";
|
||||
import ActorMovementConfig from "../../../apps/movement-config.js";
|
||||
import ActorSensesConfig from "../../../apps/senses-config.js";
|
||||
import ActorTypeConfig from "../../../apps/actor-type.js";
|
||||
import {SW5E} from "../../../config.js";
|
||||
import {onManageActiveEffect, prepareActiveEffectCategories} from "../../../effects.js";
|
||||
|
||||
/**
|
||||
* Extend the basic ActorSheet class to suppose SW5e-specific logic and functionality.
|
||||
* This sheet is an Abstract layer which is not used.
|
||||
* @extends {ActorSheet}
|
||||
*/
|
||||
export default class ActorSheet5e extends ActorSheet {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* Track the set of item filters which are applied
|
||||
* @type {Set}
|
||||
*/
|
||||
this._filters = {
|
||||
inventory: new Set(),
|
||||
powerbook: new Set(),
|
||||
features: new Set(),
|
||||
effects: new Set()
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return mergeObject(super.defaultOptions, {
|
||||
scrollY: [
|
||||
".inventory .inventory-list",
|
||||
".features .inventory-list",
|
||||
".powerbook .inventory-list",
|
||||
".effects .inventory-list"
|
||||
],
|
||||
tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A set of item types that should be prevented from being dropped on this type of actor sheet.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
static unsupportedItemTypes = new Set();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get template() {
|
||||
if (!game.user.isGM && this.actor.limited) return "systems/sw5e/templates/actors/oldActor/limited-sheet.html";
|
||||
return `systems/sw5e/templates/actors/oldActor/${this.actor.data.type}-sheet.html`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData(options) {
|
||||
// Basic data
|
||||
let isOwner = this.actor.isOwner;
|
||||
const data = {
|
||||
owner: isOwner,
|
||||
limited: this.actor.limited,
|
||||
options: this.options,
|
||||
editable: this.isEditable,
|
||||
cssClass: isOwner ? "editable" : "locked",
|
||||
isCharacter: this.actor.type === "character",
|
||||
isNPC: this.actor.type === "npc",
|
||||
isStarship: this.actor.type === "starship",
|
||||
isVehicle: this.actor.type === "vehicle",
|
||||
config: CONFIG.SW5E,
|
||||
rollData: this.actor.getRollData.bind(this.actor)
|
||||
};
|
||||
|
||||
// The Actor's data
|
||||
const actorData = this.actor.data.toObject(false);
|
||||
data.actor = actorData;
|
||||
data.data = actorData.data;
|
||||
|
||||
// Owned Items
|
||||
data.items = actorData.items;
|
||||
for (let i of data.items) {
|
||||
const item = this.actor.items.get(i._id);
|
||||
i.labels = item.labels;
|
||||
}
|
||||
data.items.sort((a, b) => (a.sort || 0) - (b.sort || 0));
|
||||
|
||||
// Labels and filters
|
||||
data.labels = this.actor.labels || {};
|
||||
data.filters = this._filters;
|
||||
|
||||
// Ability Scores
|
||||
for (let [a, abl] of Object.entries(actorData.data.abilities)) {
|
||||
abl.icon = this._getProficiencyIcon(abl.proficient);
|
||||
abl.hover = CONFIG.SW5E.proficiencyLevels[abl.proficient];
|
||||
abl.label = CONFIG.SW5E.abilities[a];
|
||||
}
|
||||
|
||||
// Skills
|
||||
if (actorData.data.skills) {
|
||||
for (let [s, skl] of Object.entries(actorData.data.skills)) {
|
||||
skl.ability = CONFIG.SW5E.abilityAbbreviations[skl.ability];
|
||||
skl.icon = this._getProficiencyIcon(skl.value);
|
||||
skl.hover = CONFIG.SW5E.proficiencyLevels[skl.value];
|
||||
skl.label = CONFIG.SW5E.skills[s];
|
||||
}
|
||||
}
|
||||
|
||||
// Movement speeds
|
||||
data.movement = this._getMovementSpeed(actorData);
|
||||
|
||||
// Senses
|
||||
data.senses = this._getSenses(actorData);
|
||||
|
||||
// Update traits
|
||||
this._prepareTraits(actorData.data.traits);
|
||||
|
||||
// Prepare owned items
|
||||
this._prepareItems(data);
|
||||
|
||||
// Prepare active effects
|
||||
data.effects = prepareActiveEffectCategories(this.actor.effects);
|
||||
|
||||
// Return data to the sheet
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare the display of movement speed data for the Actor*
|
||||
* @param {object} actorData The Actor data being prepared.
|
||||
* @param {boolean} [largestPrimary=false] Show the largest movement speed as "primary", otherwise show "walk"
|
||||
* @returns {{primary: string, special: string}}
|
||||
* @private
|
||||
*/
|
||||
_getMovementSpeed(actorData, largestPrimary = false) {
|
||||
const movement = actorData.data.attributes.movement || {};
|
||||
|
||||
// Prepare an array of available movement speeds
|
||||
let speeds = [
|
||||
[movement.burrow, `${game.i18n.localize("SW5E.MovementBurrow")} ${movement.burrow}`],
|
||||
[movement.climb, `${game.i18n.localize("SW5E.MovementClimb")} ${movement.climb}`],
|
||||
[
|
||||
movement.fly,
|
||||
`${game.i18n.localize("SW5E.MovementFly")} ${movement.fly}` +
|
||||
(movement.hover ? ` (${game.i18n.localize("SW5E.MovementHover")})` : "")
|
||||
],
|
||||
[movement.swim, `${game.i18n.localize("SW5E.MovementSwim")} ${movement.swim}`]
|
||||
];
|
||||
if (largestPrimary) {
|
||||
speeds.push([movement.walk, `${game.i18n.localize("SW5E.MovementWalk")} ${movement.walk}`]);
|
||||
}
|
||||
|
||||
// Filter and sort speeds on their values
|
||||
speeds = speeds.filter((s) => !!s[0]).sort((a, b) => b[0] - a[0]);
|
||||
|
||||
// Case 1: Largest as primary
|
||||
if (largestPrimary) {
|
||||
let primary = speeds.shift();
|
||||
return {
|
||||
primary: `${primary ? primary[1] : "0"} ${movement.units}`,
|
||||
special: speeds.map((s) => s[1]).join(", ")
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: Walk as primary
|
||||
else {
|
||||
return {
|
||||
primary: `${movement.walk || 0} ${movement.units}`,
|
||||
special: speeds.length ? speeds.map((s) => s[1]).join(", ") : ""
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
_getSenses(actorData) {
|
||||
const senses = actorData.data.attributes.senses || {};
|
||||
const tags = {};
|
||||
for (let [k, label] of Object.entries(CONFIG.SW5E.senses)) {
|
||||
const v = senses[k] ?? 0;
|
||||
if (v === 0) continue;
|
||||
tags[k] = `${game.i18n.localize(label)} ${v} ${senses.units}`;
|
||||
}
|
||||
if (!!senses.special) tags["special"] = senses.special;
|
||||
return tags;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare the data structure for traits data like languages, resistances & vulnerabilities, and proficiencies
|
||||
* @param {object} traits The raw traits data object from the actor data
|
||||
* @private
|
||||
*/
|
||||
_prepareTraits(traits) {
|
||||
const map = {
|
||||
dr: CONFIG.SW5E.damageResistanceTypes,
|
||||
di: CONFIG.SW5E.damageResistanceTypes,
|
||||
dv: CONFIG.SW5E.damageResistanceTypes,
|
||||
ci: CONFIG.SW5E.conditionTypes,
|
||||
languages: CONFIG.SW5E.languages,
|
||||
armorProf: CONFIG.SW5E.armorProficiencies,
|
||||
weaponProf: CONFIG.SW5E.weaponProficiencies,
|
||||
toolProf: CONFIG.SW5E.toolProficiencies
|
||||
};
|
||||
for (let [t, choices] of Object.entries(map)) {
|
||||
const trait = traits[t];
|
||||
if (!trait) continue;
|
||||
let values = [];
|
||||
if (trait.value) {
|
||||
values = trait.value instanceof Array ? trait.value : [trait.value];
|
||||
}
|
||||
trait.selected = values.reduce((obj, t) => {
|
||||
obj[t] = choices[t];
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
// Add custom entry
|
||||
if (trait.custom) {
|
||||
trait.custom.split(";").forEach((c, i) => (trait.selected[`custom${i + 1}`] = c.trim()));
|
||||
}
|
||||
trait.cssClass = !isObjectEmpty(trait.selected) ? "" : "inactive";
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Insert a power into the powerbook object when rendering the character sheet
|
||||
* @param {Object} data The Actor data being prepared
|
||||
* @param {Array} powers The power data being prepared
|
||||
* @private
|
||||
*/
|
||||
_preparePowerbook(data, powers) {
|
||||
const owner = this.actor.isOwner;
|
||||
const levels = data.data.powers;
|
||||
const powerbook = {};
|
||||
|
||||
// Define some mappings
|
||||
const sections = {
|
||||
atwill: -20,
|
||||
innate: -10,
|
||||
pact: 0.5
|
||||
};
|
||||
|
||||
// Label power slot uses headers
|
||||
const useLabels = {
|
||||
"-20": "-",
|
||||
"-10": "-",
|
||||
"0": "∞"
|
||||
};
|
||||
|
||||
// Format a powerbook entry for a certain indexed level
|
||||
const registerSection = (sl, i, label, {prepMode = "prepared", value, max, override} = {}) => {
|
||||
powerbook[i] = {
|
||||
order: i,
|
||||
label: label,
|
||||
usesSlots: i > 0,
|
||||
canCreate: owner,
|
||||
canPrepare: data.actor.type === "character" && i >= 1,
|
||||
powers: [],
|
||||
uses: useLabels[i] || value || 0,
|
||||
slots: useLabels[i] || max || 0,
|
||||
override: override || 0,
|
||||
dataset: {"type": "power", "level": prepMode in sections ? 1 : i, "preparation.mode": prepMode},
|
||||
prop: sl
|
||||
};
|
||||
};
|
||||
|
||||
// Determine the maximum power level which has a slot
|
||||
const maxLevel = Array.fromRange(10).reduce((max, i) => {
|
||||
if (i === 0) return max;
|
||||
const level = levels[`power${i}`];
|
||||
if ((level.max || level.override) && i > max) max = i;
|
||||
return max;
|
||||
}, 0);
|
||||
|
||||
// Level-based powercasters have cantrips and leveled slots
|
||||
if (maxLevel > 0) {
|
||||
registerSection("power0", 0, CONFIG.SW5E.powerLevels[0]);
|
||||
for (let lvl = 1; lvl <= maxLevel; lvl++) {
|
||||
const sl = `power${lvl}`;
|
||||
registerSection(sl, lvl, CONFIG.SW5E.powerLevels[lvl], levels[sl]);
|
||||
}
|
||||
}
|
||||
|
||||
// Pact magic users have cantrips and a pact magic section
|
||||
// TODO: Check if this is needed, we've removed pacts everywhere else
|
||||
if (levels.pact && levels.pact.max) {
|
||||
if (!powerbook["0"]) registerSection("power0", 0, CONFIG.SW5E.powerLevels[0]);
|
||||
const l = levels.pact;
|
||||
const config = CONFIG.SW5E.powerPreparationModes.pact;
|
||||
const level = game.i18n.localize(`SW5E.PowerLevel${levels.pact.level}`);
|
||||
const label = `${config} — ${level}`;
|
||||
registerSection("pact", sections.pact, label, {
|
||||
prepMode: "pact",
|
||||
value: l.value,
|
||||
max: l.max,
|
||||
override: l.override
|
||||
});
|
||||
}
|
||||
|
||||
// Iterate over every power item, adding powers to the powerbook by section
|
||||
powers.forEach((power) => {
|
||||
const mode = power.data.preparation.mode || "prepared";
|
||||
let s = power.data.level || 0;
|
||||
const sl = `power${s}`;
|
||||
|
||||
// Specialized powercasting modes (if they exist)
|
||||
if (mode in sections) {
|
||||
s = sections[mode];
|
||||
if (!powerbook[s]) {
|
||||
const l = levels[mode] || {};
|
||||
const config = CONFIG.SW5E.powerPreparationModes[mode];
|
||||
registerSection(mode, s, config, {
|
||||
prepMode: mode,
|
||||
value: l.value,
|
||||
max: l.max,
|
||||
override: l.override
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sections for higher-level powers which the caster "should not" have, but power items exist for
|
||||
else if (!powerbook[s]) {
|
||||
registerSection(sl, s, CONFIG.SW5E.powerLevels[s], {levels: levels[sl]});
|
||||
}
|
||||
|
||||
// Add the power to the relevant heading
|
||||
powerbook[s].powers.push(power);
|
||||
});
|
||||
|
||||
// Sort the powerbook by section level
|
||||
const sorted = Object.values(powerbook);
|
||||
sorted.sort((a, b) => a.order - b.order);
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine whether an Owned Item will be shown based on the current set of filters
|
||||
* @return {boolean}
|
||||
* @private
|
||||
*/
|
||||
_filterItems(items, filters) {
|
||||
return items.filter((item) => {
|
||||
const data = item.data;
|
||||
|
||||
// Action usage
|
||||
for (let f of ["action", "bonus", "reaction"]) {
|
||||
if (filters.has(f)) {
|
||||
if (data.activation && data.activation.type !== f) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Power-specific filters
|
||||
if (filters.has("ritual")) {
|
||||
if (data.components.ritual !== true) return false;
|
||||
}
|
||||
if (filters.has("concentration")) {
|
||||
if (data.components.concentration !== true) return false;
|
||||
}
|
||||
if (filters.has("prepared")) {
|
||||
if (data.level === 0 || ["innate", "always"].includes(data.preparation.mode)) return true;
|
||||
if (this.actor.data.type === "npc") return true;
|
||||
return data.preparation.prepared;
|
||||
}
|
||||
|
||||
// Equipment-specific filters
|
||||
if (filters.has("equipped")) {
|
||||
if (data.equipped !== true) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the font-awesome icon used to display a certain level of skill proficiency
|
||||
* @private
|
||||
*/
|
||||
_getProficiencyIcon(level) {
|
||||
const icons = {
|
||||
0: '<i class="far fa-circle"></i>',
|
||||
0.5: '<i class="fas fa-adjust"></i>',
|
||||
1: '<i class="fas fa-check"></i>',
|
||||
2: '<i class="fas fa-check-double"></i>'
|
||||
};
|
||||
return icons[level] || icons[0];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
// Activate Item Filters
|
||||
const filterLists = html.find(".filter-list");
|
||||
filterLists.each(this._initializeFilterItemList.bind(this));
|
||||
filterLists.on("click", ".filter-item", this._onToggleFilter.bind(this));
|
||||
|
||||
// Item summaries
|
||||
html.find(".item .item-name.rollable h4").click((event) => this._onItemSummary(event));
|
||||
|
||||
// View Item Sheets
|
||||
html.find(".item-edit").click(this._onItemEdit.bind(this));
|
||||
|
||||
// Editable Only Listeners
|
||||
if (this.isEditable) {
|
||||
// Input focus and update
|
||||
const inputs = html.find("input");
|
||||
inputs.focus((ev) => ev.currentTarget.select());
|
||||
inputs.addBack().find('[data-dtype="Number"]').change(this._onChangeInputDelta.bind(this));
|
||||
|
||||
// Ability Proficiency
|
||||
html.find(".ability-proficiency").click(this._onToggleAbilityProficiency.bind(this));
|
||||
|
||||
// Toggle Skill Proficiency
|
||||
html.find(".skill-proficiency").on("click contextmenu", this._onCycleSkillProficiency.bind(this));
|
||||
|
||||
// Trait Selector
|
||||
html.find(".trait-selector").click(this._onTraitSelector.bind(this));
|
||||
|
||||
// Configure Special Flags
|
||||
html.find(".config-button").click(this._onConfigMenu.bind(this));
|
||||
|
||||
// Owned Item management
|
||||
html.find(".item-create").click(this._onItemCreate.bind(this));
|
||||
html.find(".item-delete").click(this._onItemDelete.bind(this));
|
||||
html.find(".item-uses input")
|
||||
.click((ev) => ev.target.select())
|
||||
.change(this._onUsesChange.bind(this));
|
||||
html.find(".slot-max-override").click(this._onPowerSlotOverride.bind(this));
|
||||
|
||||
// Active Effect management
|
||||
html.find(".effect-control").click((ev) => onManageActiveEffect(ev, this.actor));
|
||||
}
|
||||
|
||||
// Owner Only Listeners
|
||||
if (this.actor.isOwner) {
|
||||
// Ability Checks
|
||||
html.find(".ability-name").click(this._onRollAbilityTest.bind(this));
|
||||
|
||||
// Roll Skill Checks
|
||||
html.find(".skill-name").click(this._onRollSkillCheck.bind(this));
|
||||
|
||||
// Item Rolling
|
||||
html.find(".item .item-image").click((event) => this._onItemRoll(event));
|
||||
html.find(".item .item-recharge").click((event) => this._onItemRecharge(event));
|
||||
}
|
||||
|
||||
// Otherwise remove rollable classes
|
||||
else {
|
||||
html.find(".rollable").each((i, el) => el.classList.remove("rollable"));
|
||||
}
|
||||
|
||||
// Handle default listeners last so system listeners are triggered first
|
||||
super.activateListeners(html);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Iinitialize Item list filters by activating the set of filters which are currently applied
|
||||
* @private
|
||||
*/
|
||||
_initializeFilterItemList(i, ul) {
|
||||
const set = this._filters[ul.dataset.filter];
|
||||
const filters = ul.querySelectorAll(".filter-item");
|
||||
for (let li of filters) {
|
||||
if (set.has(li.dataset.filter)) li.classList.add("active");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle input changes to numeric form fields, allowing them to accept delta-typed inputs
|
||||
* @param event
|
||||
* @private
|
||||
*/
|
||||
_onChangeInputDelta(event) {
|
||||
const input = event.target;
|
||||
const value = input.value;
|
||||
if (["+", "-"].includes(value[0])) {
|
||||
let delta = parseFloat(value);
|
||||
input.value = getProperty(this.actor.data, input.name) + delta;
|
||||
} else if (value[0] === "=") {
|
||||
input.value = value.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle spawning the TraitSelector application which allows a checkbox of multiple trait options
|
||||
* @param {Event} event The click event which originated the selection
|
||||
* @private
|
||||
*/
|
||||
_onConfigMenu(event) {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget;
|
||||
let app;
|
||||
switch (button.dataset.action) {
|
||||
case "hit-dice":
|
||||
app = new ActorHitDiceConfig(this.object);
|
||||
break;
|
||||
case "movement":
|
||||
app = new ActorMovementConfig(this.object);
|
||||
break;
|
||||
case "flags":
|
||||
app = new ActorSheetFlags(this.object);
|
||||
break;
|
||||
case "senses":
|
||||
app = new ActorSensesConfig(this.object);
|
||||
break;
|
||||
case "type":
|
||||
new ActorTypeConfig(this.object).render(true);
|
||||
break;
|
||||
}
|
||||
app?.render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle cycling proficiency in a Skill
|
||||
* @param {Event} event A click or contextmenu event which triggered the handler
|
||||
* @private
|
||||
*/
|
||||
_onCycleSkillProficiency(event) {
|
||||
event.preventDefault();
|
||||
const field = $(event.currentTarget).siblings('input[type="hidden"]');
|
||||
|
||||
// Get the current level and the array of levels
|
||||
const level = parseFloat(field.val());
|
||||
const levels = [0, 1, 0.5, 2];
|
||||
let idx = levels.indexOf(level);
|
||||
|
||||
// Toggle next level - forward on click, backwards on right
|
||||
if (event.type === "click") {
|
||||
field.val(levels[idx === levels.length - 1 ? 0 : idx + 1]);
|
||||
} else if (event.type === "contextmenu") {
|
||||
field.val(levels[idx === 0 ? levels.length - 1 : idx - 1]);
|
||||
}
|
||||
|
||||
// Update the field value and save the form
|
||||
this._onSubmit(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onDropActor(event, data) {
|
||||
const canPolymorph = game.user.isGM || (this.actor.isOwner && game.settings.get("sw5e", "allowPolymorphing"));
|
||||
if (!canPolymorph) return false;
|
||||
|
||||
// Get the target actor
|
||||
let sourceActor = null;
|
||||
if (data.pack) {
|
||||
const pack = game.packs.find((p) => p.collection === data.pack);
|
||||
sourceActor = await pack.getEntity(data.id);
|
||||
} else {
|
||||
sourceActor = game.actors.get(data.id);
|
||||
}
|
||||
if (!sourceActor) return;
|
||||
|
||||
// Define a function to record polymorph settings for future use
|
||||
const rememberOptions = (html) => {
|
||||
const options = {};
|
||||
html.find("input").each((i, el) => {
|
||||
options[el.name] = el.checked;
|
||||
});
|
||||
const settings = mergeObject(game.settings.get("sw5e", "polymorphSettings") || {}, options);
|
||||
game.settings.set("sw5e", "polymorphSettings", settings);
|
||||
return settings;
|
||||
};
|
||||
|
||||
// Create and render the Dialog
|
||||
return new Dialog(
|
||||
{
|
||||
title: game.i18n.localize("SW5E.PolymorphPromptTitle"),
|
||||
content: {
|
||||
options: game.settings.get("sw5e", "polymorphSettings"),
|
||||
i18n: SW5E.polymorphSettings,
|
||||
isToken: this.actor.isToken
|
||||
},
|
||||
default: "accept",
|
||||
buttons: {
|
||||
accept: {
|
||||
icon: '<i class="fas fa-check"></i>',
|
||||
label: game.i18n.localize("SW5E.PolymorphAcceptSettings"),
|
||||
callback: (html) => this.actor.transformInto(sourceActor, rememberOptions(html))
|
||||
},
|
||||
wildshape: {
|
||||
icon: '<i class="fas fa-paw"></i>',
|
||||
label: game.i18n.localize("SW5E.PolymorphWildShape"),
|
||||
callback: (html) =>
|
||||
this.actor.transformInto(sourceActor, {
|
||||
keepBio: true,
|
||||
keepClass: true,
|
||||
keepMental: true,
|
||||
mergeSaves: true,
|
||||
mergeSkills: true,
|
||||
transformTokens: rememberOptions(html).transformTokens
|
||||
})
|
||||
},
|
||||
polymorph: {
|
||||
icon: '<i class="fas fa-pastafarianism"></i>',
|
||||
label: game.i18n.localize("SW5E.Polymorph"),
|
||||
callback: (html) =>
|
||||
this.actor.transformInto(sourceActor, {
|
||||
transformTokens: rememberOptions(html).transformTokens
|
||||
})
|
||||
},
|
||||
cancel: {
|
||||
icon: '<i class="fas fa-times"></i>',
|
||||
label: game.i18n.localize("Cancel")
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
classes: ["dialog", "sw5e"],
|
||||
width: 600,
|
||||
template: "systems/sw5e/templates/apps/polymorph-prompt.html"
|
||||
}
|
||||
).render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onDropItemCreate(itemData) {
|
||||
// Check to make sure items of this type are allowed on this actor
|
||||
if (this.constructor.unsupportedItemTypes.has(itemData.type)) {
|
||||
return ui.notifications.warn(
|
||||
game.i18n.format("SW5E.ActorWarningInvalidItem", {
|
||||
itemType: game.i18n.localize(CONFIG.Item.typeLabels[itemData.type]),
|
||||
actorType: game.i18n.localize(CONFIG.Actor.typeLabels[this.actor.type])
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Create a Consumable power scroll on the Inventory tab
|
||||
// TODO: This is pretty non functional as the base items for the scrolls, and the powers, are not defined, maybe consider using holocrons
|
||||
if (itemData.type === "power" && this._tabs[0].active === "inventory") {
|
||||
const scroll = await Item5e.createScrollFromPower(itemData);
|
||||
itemData = scroll.data;
|
||||
}
|
||||
|
||||
if (itemData.data) {
|
||||
// Ignore certain statuses
|
||||
["equipped", "proficient", "prepared"].forEach((k) => delete itemData.data[k]);
|
||||
|
||||
// Downgrade ATTUNED to REQUIRED
|
||||
itemData.data.attunement = Math.min(itemData.data.attunement, CONFIG.SW5E.attunementTypes.REQUIRED);
|
||||
}
|
||||
|
||||
// Stack identical consumables
|
||||
if (itemData.type === "consumable" && itemData.flags.core?.sourceId) {
|
||||
const similarItem = this.actor.items.find((i) => {
|
||||
const sourceId = i.getFlag("core", "sourceId");
|
||||
return sourceId && sourceId === itemData.flags.core?.sourceId && i.type === "consumable";
|
||||
});
|
||||
if (similarItem) {
|
||||
return similarItem.update({
|
||||
"data.quantity": similarItem.data.data.quantity + Math.max(itemData.data.quantity, 1)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create the owned item as normal
|
||||
return super._onDropItemCreate(itemData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle enabling editing for a power slot override value
|
||||
* @param {MouseEvent} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
async _onPowerSlotOverride(event) {
|
||||
const span = event.currentTarget.parentElement;
|
||||
const level = span.dataset.level;
|
||||
const override = this.actor.data.data.powers[level].override || span.dataset.slots;
|
||||
const input = document.createElement("INPUT");
|
||||
input.type = "text";
|
||||
input.name = `data.powers.${level}.override`;
|
||||
input.value = override;
|
||||
input.placeholder = span.dataset.slots;
|
||||
input.dataset.dtype = "Number";
|
||||
|
||||
// Replace the HTML
|
||||
const parent = span.parentElement;
|
||||
parent.removeChild(span);
|
||||
parent.appendChild(input);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Change the uses amount of an Owned Item within the Actor
|
||||
* @param {Event} event The triggering click event
|
||||
* @private
|
||||
*/
|
||||
async _onUsesChange(event) {
|
||||
event.preventDefault();
|
||||
const itemId = event.currentTarget.closest(".item").dataset.itemId;
|
||||
const item = this.actor.items.get(itemId);
|
||||
const uses = Math.clamped(0, parseInt(event.target.value), item.data.data.uses.max);
|
||||
event.target.value = uses;
|
||||
return item.update({"data.uses.value": uses});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle rolling of an item from the Actor sheet, obtaining the Item instance and dispatching to it's roll method
|
||||
* @private
|
||||
*/
|
||||
_onItemRoll(event) {
|
||||
event.preventDefault();
|
||||
const itemId = event.currentTarget.closest(".item").dataset.itemId;
|
||||
const item = this.actor.items.get(itemId);
|
||||
return item.roll();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle attempting to recharge an item usage by rolling a recharge check
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onItemRecharge(event) {
|
||||
event.preventDefault();
|
||||
const itemId = event.currentTarget.closest(".item").dataset.itemId;
|
||||
const item = this.actor.items.get(itemId);
|
||||
return item.rollRecharge();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle rolling of an item from the Actor sheet, obtaining the Item instance and dispatching to it's roll method
|
||||
* @private
|
||||
*/
|
||||
_onItemSummary(event) {
|
||||
event.preventDefault();
|
||||
let li = $(event.currentTarget).parents(".item"),
|
||||
item = this.actor.items.get(li.data("item-id")),
|
||||
chatData = item.getChatData({secrets: this.actor.isOwner});
|
||||
|
||||
// Toggle summary
|
||||
if (li.hasClass("expanded")) {
|
||||
let summary = li.children(".item-summary");
|
||||
summary.slideUp(200, () => summary.remove());
|
||||
} else {
|
||||
let div = $(`<div class="item-summary">${chatData.description.value}</div>`);
|
||||
let props = $(`<div class="item-properties"></div>`);
|
||||
chatData.properties.forEach((p) => props.append(`<span class="tag">${p}</span>`));
|
||||
div.append(props);
|
||||
li.append(div.hide());
|
||||
div.slideDown(200);
|
||||
}
|
||||
li.toggleClass("expanded");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle creating a new Owned Item for the actor using initial data defined in the HTML dataset
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onItemCreate(event) {
|
||||
event.preventDefault();
|
||||
const header = event.currentTarget;
|
||||
const type = header.dataset.type;
|
||||
const itemData = {
|
||||
name: game.i18n.format("SW5E.ItemNew", {type: game.i18n.localize(`SW5E.ItemType${type.capitalize()}`)}),
|
||||
type: type,
|
||||
data: foundry.utils.deepClone(header.dataset)
|
||||
};
|
||||
delete itemData.data["type"];
|
||||
return this.actor.createEmbeddedDocuments("Item", [itemData]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle editing an existing Owned Item for the Actor
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onItemEdit(event) {
|
||||
event.preventDefault();
|
||||
const li = event.currentTarget.closest(".item");
|
||||
const item = this.actor.items.get(li.dataset.itemId);
|
||||
return item.sheet.render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle deleting an existing Owned Item for the Actor
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onItemDelete(event) {
|
||||
event.preventDefault();
|
||||
const li = event.currentTarget.closest(".item");
|
||||
const item = this.actor.items.get(li.dataset.itemId);
|
||||
if (item) return item.delete();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle rolling an Ability check, either a test or a saving throw
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onRollAbilityTest(event) {
|
||||
event.preventDefault();
|
||||
let ability = event.currentTarget.parentElement.dataset.ability;
|
||||
return this.actor.rollAbility(ability, {event: event});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle rolling a Skill check
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onRollSkillCheck(event) {
|
||||
event.preventDefault();
|
||||
const skill = event.currentTarget.parentElement.dataset.skill;
|
||||
return this.actor.rollSkill(skill, {event: event});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle toggling Ability score proficiency level
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onToggleAbilityProficiency(event) {
|
||||
event.preventDefault();
|
||||
const field = event.currentTarget.previousElementSibling;
|
||||
return this.actor.update({[field.name]: 1 - parseInt(field.value)});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle toggling of filters to display a different set of owned items
|
||||
* @param {Event} event The click event which triggered the toggle
|
||||
* @private
|
||||
*/
|
||||
_onToggleFilter(event) {
|
||||
event.preventDefault();
|
||||
const li = event.currentTarget;
|
||||
const set = this._filters[li.parentElement.dataset.filter];
|
||||
const filter = li.dataset.filter;
|
||||
if (set.has(filter)) set.delete(filter);
|
||||
else set.add(filter);
|
||||
return this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle spawning the TraitSelector application which allows a checkbox of multiple trait options
|
||||
* @param {Event} event The click event which originated the selection
|
||||
* @private
|
||||
*/
|
||||
_onTraitSelector(event) {
|
||||
event.preventDefault();
|
||||
const a = event.currentTarget;
|
||||
const label = a.parentElement.querySelector("label");
|
||||
const choices = CONFIG.SW5E[a.dataset.options];
|
||||
const options = {name: a.dataset.target, title: label.innerText, choices};
|
||||
return new TraitSelector(this.actor, options).render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getHeaderButtons() {
|
||||
let buttons = super._getHeaderButtons();
|
||||
if (this.actor.isPolymorphed) {
|
||||
buttons.unshift({
|
||||
label: "SW5E.PolymorphRestoreTransformation",
|
||||
class: "restore-transformation",
|
||||
icon: "fas fa-backward",
|
||||
onclick: () => this.actor.revertOriginalForm()
|
||||
});
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
}
|
368
module/actor/sheets/oldSheets/character.js
Normal file
|
@ -0,0 +1,368 @@
|
|||
import ActorSheet5e from "./base.js";
|
||||
import Actor5e from "../../entity.js";
|
||||
|
||||
/**
|
||||
* An Actor sheet for player character type actors in the SW5E system.
|
||||
* Extends the base ActorSheet5e class.
|
||||
* @type {ActorSheet5e}
|
||||
*/
|
||||
export default class ActorSheet5eCharacter extends ActorSheet5e {
|
||||
/**
|
||||
* Define default rendering options for the NPC sheet
|
||||
* @return {Object}
|
||||
*/
|
||||
static get defaultOptions() {
|
||||
return mergeObject(super.defaultOptions, {
|
||||
classes: ["sw5e", "sheet", "actor", "character"],
|
||||
width: 720,
|
||||
height: 736
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add some extra data when rendering the sheet to reduce the amount of logic required within the template.
|
||||
*/
|
||||
getData() {
|
||||
const sheetData = super.getData();
|
||||
|
||||
// Temporary HP
|
||||
let hp = sheetData.data.attributes.hp;
|
||||
if (hp.temp === 0) delete hp.temp;
|
||||
if (hp.tempmax === 0) delete hp.tempmax;
|
||||
|
||||
// Resources
|
||||
sheetData["resources"] = ["primary", "secondary", "tertiary"].reduce((arr, r) => {
|
||||
const res = sheetData.data.resources[r] || {};
|
||||
res.name = r;
|
||||
res.placeholder = game.i18n.localize("SW5E.Resource" + r.titleCase());
|
||||
if (res && res.value === 0) delete res.value;
|
||||
if (res && res.max === 0) delete res.max;
|
||||
return arr.concat([res]);
|
||||
}, []);
|
||||
|
||||
// Experience Tracking
|
||||
sheetData["disableExperience"] = game.settings.get("sw5e", "disableExperienceTracking");
|
||||
sheetData["classLabels"] = this.actor.itemTypes.class.map((c) => c.name).join(", ");
|
||||
sheetData["multiclassLabels"] = this.actor.itemTypes.class
|
||||
.map((c) => {
|
||||
return [c.data.data.archetype, c.name, c.data.data.levels].filterJoin(" ");
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
// Return data for rendering
|
||||
return sheetData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Organize and classify Owned Items for Character sheets
|
||||
* @private
|
||||
*/
|
||||
_prepareItems(data) {
|
||||
// Categorize items as inventory, powerbook, features, and classes
|
||||
const inventory = {
|
||||
weapon: {label: "SW5E.ItemTypeWeaponPl", items: [], dataset: {type: "weapon"}},
|
||||
equipment: {label: "SW5E.ItemTypeEquipmentPl", items: [], dataset: {type: "equipment"}},
|
||||
consumable: {label: "SW5E.ItemTypeConsumablePl", items: [], dataset: {type: "consumable"}},
|
||||
tool: {label: "SW5E.ItemTypeToolPl", items: [], dataset: {type: "tool"}},
|
||||
backpack: {label: "SW5E.ItemTypeContainerPl", items: [], dataset: {type: "backpack"}},
|
||||
loot: {label: "SW5E.ItemTypeLootPl", items: [], dataset: {type: "loot"}}
|
||||
};
|
||||
|
||||
// Partition items by category
|
||||
let [
|
||||
items,
|
||||
powers,
|
||||
feats,
|
||||
classes,
|
||||
species,
|
||||
archetypes,
|
||||
classfeatures,
|
||||
backgrounds,
|
||||
fightingstyles,
|
||||
fightingmasteries,
|
||||
lightsaberforms
|
||||
] = data.items.reduce(
|
||||
(arr, item) => {
|
||||
// Item details
|
||||
item.img = item.img || CONST.DEFAULT_TOKEN;
|
||||
item.isStack = Number.isNumeric(item.data.quantity) && item.data.quantity !== 1;
|
||||
item.attunement = {
|
||||
[CONFIG.SW5E.attunementTypes.REQUIRED]: {
|
||||
icon: "fa-sun",
|
||||
cls: "not-attuned",
|
||||
title: "SW5E.AttunementRequired"
|
||||
},
|
||||
[CONFIG.SW5E.attunementTypes.ATTUNED]: {
|
||||
icon: "fa-sun",
|
||||
cls: "attuned",
|
||||
title: "SW5E.AttunementAttuned"
|
||||
}
|
||||
}[item.data.attunement];
|
||||
|
||||
// Item usage
|
||||
item.hasUses = item.data.uses && item.data.uses.max > 0;
|
||||
item.isOnCooldown =
|
||||
item.data.recharge && !!item.data.recharge.value && item.data.recharge.charged === false;
|
||||
item.isDepleted = item.isOnCooldown && item.data.uses.per && item.data.uses.value > 0;
|
||||
item.hasTarget = !!item.data.target && !["none", ""].includes(item.data.target.type);
|
||||
|
||||
// Item toggle state
|
||||
this._prepareItemToggleState(item);
|
||||
|
||||
// Primary Class
|
||||
if (item.type === "class")
|
||||
item.isOriginalClass = item._id === this.actor.data.data.details.originalClass;
|
||||
|
||||
// Classify items into types
|
||||
if (item.type === "power") arr[1].push(item);
|
||||
else if (item.type === "feat") arr[2].push(item);
|
||||
else if (item.type === "class") arr[3].push(item);
|
||||
else if (item.type === "species") arr[4].push(item);
|
||||
else if (item.type === "archetype") arr[5].push(item);
|
||||
else if (item.type === "classfeature") arr[6].push(item);
|
||||
else if (item.type === "background") arr[7].push(item);
|
||||
else if (item.type === "fightingstyle") arr[8].push(item);
|
||||
else if (item.type === "fightingmastery") arr[9].push(item);
|
||||
else if (item.type === "lightsaberform") arr[10].push(item);
|
||||
else if (Object.keys(inventory).includes(item.type)) arr[0].push(item);
|
||||
return arr;
|
||||
},
|
||||
[[], [], [], [], [], [], [], [], [], [], []]
|
||||
);
|
||||
|
||||
// Apply active item filters
|
||||
items = this._filterItems(items, this._filters.inventory);
|
||||
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 = (i.data.quantity * i.data.weight).toNearest(0.1);
|
||||
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 Features
|
||||
const features = {
|
||||
classes: {
|
||||
label: "SW5E.ItemTypeClassPl",
|
||||
items: [],
|
||||
hasActions: false,
|
||||
dataset: {type: "class"},
|
||||
isClass: true
|
||||
},
|
||||
classfeatures: {
|
||||
label: "SW5E.ItemTypeClassFeats",
|
||||
items: [],
|
||||
hasActions: true,
|
||||
dataset: {type: "classfeature"},
|
||||
isClassfeature: true
|
||||
},
|
||||
archetype: {
|
||||
label: "SW5E.ItemTypeArchetype",
|
||||
items: [],
|
||||
hasActions: false,
|
||||
dataset: {type: "archetype"},
|
||||
isArchetype: true
|
||||
},
|
||||
species: {
|
||||
label: "SW5E.ItemTypeSpecies",
|
||||
items: [],
|
||||
hasActions: false,
|
||||
dataset: {type: "species"},
|
||||
isSpecies: true
|
||||
},
|
||||
background: {
|
||||
label: "SW5E.ItemTypeBackground",
|
||||
items: [],
|
||||
hasActions: false,
|
||||
dataset: {type: "background"},
|
||||
isBackground: true
|
||||
},
|
||||
fightingstyles: {
|
||||
label: "SW5E.ItemTypeFightingStylePl",
|
||||
items: [],
|
||||
hasActions: false,
|
||||
dataset: {type: "fightingstyle"},
|
||||
isFightingstyle: true
|
||||
},
|
||||
fightingmasteries: {
|
||||
label: "SW5E.ItemTypeFightingMasteryPl",
|
||||
items: [],
|
||||
hasActions: false,
|
||||
dataset: {type: "fightingmastery"},
|
||||
isFightingmastery: true
|
||||
},
|
||||
lightsaberforms: {
|
||||
label: "SW5E.ItemTypeLightsaberFormPl",
|
||||
items: [],
|
||||
hasActions: false,
|
||||
dataset: {type: "lightsaberform"},
|
||||
isLightsaberform: true
|
||||
},
|
||||
active: {
|
||||
label: "SW5E.FeatureActive",
|
||||
items: [],
|
||||
hasActions: true,
|
||||
dataset: {"type": "feat", "activation.type": "action"}
|
||||
},
|
||||
passive: {label: "SW5E.FeaturePassive", items: [], hasActions: false, dataset: {type: "feat"}}
|
||||
};
|
||||
for (let f of feats) {
|
||||
if (f.data.activation.type) features.active.items.push(f);
|
||||
else features.passive.items.push(f);
|
||||
}
|
||||
classes.sort((a, b) => b.data.levels - a.data.levels);
|
||||
features.classes.items = classes;
|
||||
features.classfeatures.items = classfeatures;
|
||||
features.archetype.items = archetypes;
|
||||
features.species.items = species;
|
||||
features.background.items = backgrounds;
|
||||
features.fightingstyles.items = fightingstyles;
|
||||
features.fightingmasteries.items = fightingmasteries;
|
||||
features.lightsaberforms.items = lightsaberforms;
|
||||
|
||||
// Assign and return
|
||||
data.inventory = Object.values(inventory);
|
||||
data.powerbook = powerbook;
|
||||
data.preparedPowers = nPrepared;
|
||||
data.features = Object.values(features);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A helper method to establish the displayed preparation state for an item
|
||||
* @param {Item} item
|
||||
* @private
|
||||
*/
|
||||
_prepareItemToggleState(item) {
|
||||
if (item.type === "power") {
|
||||
const isAlways = getProperty(item.data, "preparation.mode") === "always";
|
||||
const isPrepared = getProperty(item.data, "preparation.prepared");
|
||||
item.toggleClass = isPrepared ? "active" : "";
|
||||
if (isAlways) item.toggleClass = "fixed";
|
||||
if (isAlways) item.toggleTitle = CONFIG.SW5E.powerPreparationModes.always;
|
||||
else if (isPrepared) item.toggleTitle = CONFIG.SW5E.powerPreparationModes.prepared;
|
||||
else item.toggleTitle = game.i18n.localize("SW5E.PowerUnprepared");
|
||||
} else {
|
||||
const isActive = getProperty(item.data, "equipped");
|
||||
item.toggleClass = isActive ? "active" : "";
|
||||
item.toggleTitle = game.i18n.localize(isActive ? "SW5E.Equipped" : "SW5E.Unequipped");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate event listeners using the prepared sheet HTML
|
||||
* @param html {jQuery} The prepared HTML object ready to be rendered into the DOM
|
||||
*/
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
if (!this.isEditable) return;
|
||||
|
||||
// Item State Toggling
|
||||
html.find(".item-toggle").click(this._onToggleItem.bind(this));
|
||||
|
||||
// Short and Long Rest
|
||||
html.find(".short-rest").click(this._onShortRest.bind(this));
|
||||
html.find(".long-rest").click(this._onLongRest.bind(this));
|
||||
|
||||
// Rollable sheet actions
|
||||
html.find(".rollable[data-action]").click(this._onSheetAction.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse click events for character sheet actions
|
||||
* @param {MouseEvent} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onSheetAction(event) {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget;
|
||||
switch (button.dataset.action) {
|
||||
case "rollDeathSave":
|
||||
return this.actor.rollDeathSave({event: event});
|
||||
case "rollInitiative":
|
||||
return this.actor.rollInitiative({createCombatants: true});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle toggling the state of an Owned Item within the Actor
|
||||
* @param {Event} event The triggering click event
|
||||
* @private
|
||||
*/
|
||||
_onToggleItem(event) {
|
||||
event.preventDefault();
|
||||
const itemId = event.currentTarget.closest(".item").dataset.itemId;
|
||||
const item = this.actor.items.get(itemId);
|
||||
const attr = item.data.type === "power" ? "data.preparation.prepared" : "data.equipped";
|
||||
return item.update({[attr]: !getProperty(item.data, attr)});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Take a short rest, calling the relevant function on the Actor instance
|
||||
* @param {Event} event The triggering click event
|
||||
* @private
|
||||
*/
|
||||
async _onShortRest(event) {
|
||||
event.preventDefault();
|
||||
await this._onSubmit(event);
|
||||
return this.actor.shortRest();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Take a long rest, calling the relevant function on the Actor instance
|
||||
* @param {Event} event The triggering click event
|
||||
* @private
|
||||
*/
|
||||
async _onLongRest(event) {
|
||||
event.preventDefault();
|
||||
await this._onSubmit(event);
|
||||
return this.actor.longRest();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onDropItemCreate(itemData) {
|
||||
// Increment the number of class levels a character instead of creating a new item
|
||||
if (itemData.type === "class") {
|
||||
const cls = this.actor.itemTypes.class.find((c) => c.name === itemData.name);
|
||||
let priorLevel = cls?.data.data.levels ?? 0;
|
||||
if (!!cls) {
|
||||
const next = Math.min(priorLevel + 1, 20 + priorLevel - this.actor.data.data.details.level);
|
||||
if (next > priorLevel) {
|
||||
itemData.levels = next;
|
||||
return cls.update({"data.levels": next});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default drop handling if levels were not added
|
||||
return super._onDropItemCreate(itemData);
|
||||
}
|
||||
}
|
144
module/actor/sheets/oldSheets/npc.js
Normal file
|
@ -0,0 +1,144 @@
|
|||
import ActorSheet5e from "./base.js";
|
||||
|
||||
/**
|
||||
* An Actor sheet for NPC type characters in the SW5E system.
|
||||
* Extends the base ActorSheet5e class.
|
||||
* @extends {ActorSheet5e}
|
||||
*/
|
||||
export default class ActorSheet5eNPC extends ActorSheet5e {
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return mergeObject(super.defaultOptions, {
|
||||
classes: ["sw5e", "sheet", "actor", "npc"],
|
||||
width: 600,
|
||||
height: 680
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static unsupportedItemTypes = new Set(["class"]);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Organize Owned Items for rendering the NPC sheet
|
||||
* @private
|
||||
*/
|
||||
_prepareItems(data) {
|
||||
// Categorize Items as Features and Powers
|
||||
const features = {
|
||||
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 || CONST.DEFAULT_TOKEN;
|
||||
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;
|
||||
item.hasTarget = !!item.data.target && !["none", ""].includes(item.data.target.type);
|
||||
if (item.type === "power") arr[0].push(item);
|
||||
else arr[1].push(item);
|
||||
return arr;
|
||||
},
|
||||
[[], []]
|
||||
);
|
||||
|
||||
// Apply item filters
|
||||
powers = this._filterItems(powers, this._filters.powerbook);
|
||||
other = this._filterItems(other, this._filters.features);
|
||||
|
||||
// Organize Powerbook
|
||||
const powerbook = this._preparePowerbook(data, powers);
|
||||
|
||||
// Organize Features
|
||||
for (let item of other) {
|
||||
if (item.type === "weapon") features.weapons.items.push(item);
|
||||
else if (item.type === "feat") {
|
||||
if (item.data.activation.type) features.actions.items.push(item);
|
||||
else features.passive.items.push(item);
|
||||
} else features.equipment.items.push(item);
|
||||
}
|
||||
|
||||
// Assign and return
|
||||
data.features = Object.values(features);
|
||||
data.powerbook = powerbook;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options) {
|
||||
const data = super.getData(options);
|
||||
|
||||
// Challenge Rating
|
||||
const cr = parseFloat(data.data.details.cr || 0);
|
||||
const crLabels = {0: "0", 0.125: "1/8", 0.25: "1/4", 0.5: "1/2"};
|
||||
data.labels["cr"] = cr >= 1 ? String(cr) : crLabels[cr] || 1;
|
||||
|
||||
// Creature Type
|
||||
data.labels["type"] = this.actor.labels.creatureType;
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Object Updates */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
// Format NPC Challenge Rating
|
||||
const crs = {"1/8": 0.125, "1/4": 0.25, "1/2": 0.5};
|
||||
let crv = "data.details.cr";
|
||||
let cr = formData[crv];
|
||||
cr = crs[cr] || parseFloat(cr);
|
||||
if (cr) formData[crv] = cr < 1 ? cr : parseInt(cr);
|
||||
|
||||
// Parent ActorSheet update steps
|
||||
return super._updateObject(event, formData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find(".health .rollable").click(this._onRollHPFormula.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle rolling NPC health values using the provided formula
|
||||
* @param {Event} event The original click event
|
||||
* @private
|
||||
*/
|
||||
_onRollHPFormula(event) {
|
||||
event.preventDefault();
|
||||
const formula = this.actor.data.data.attributes.hp.formula;
|
||||
if (!formula) return;
|
||||
const hp = new Roll(formula).roll().total;
|
||||
AudioHelper.play({src: CONFIG.sounds.dice});
|
||||
this.actor.update({"data.attributes.hp.value": hp, "data.attributes.hp.max": hp});
|
||||
}
|
||||
}
|
432
module/actor/sheets/oldSheets/vehicle.js
Normal file
|
@ -0,0 +1,432 @@
|
|||
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
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static unsupportedItemTypes = new Set(["class"]);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Creates a new cargo entry for a vehicle Actor.
|
||||
*/
|
||||
static get newCargo() {
|
||||
return {
|
||||
name: "",
|
||||
quantity: 1
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Compute the total weight of the vehicle's cargo.
|
||||
* @param {Number} totalWeight The cumulative item weight from inventory items
|
||||
* @param {Object} actorData The data object for the Actor being rendered
|
||||
* @returns {{max: number, value: number, pct: number}}
|
||||
* @private
|
||||
*/
|
||||
_computeEncumbrance(totalWeight, actorData) {
|
||||
// Compute currency weight
|
||||
const totalCoins = Object.values(actorData.data.currency).reduce((acc, denom) => acc + denom, 0);
|
||||
totalWeight += totalCoins / CONFIG.SW5E.encumbrance.currencyPerWeight;
|
||||
|
||||
// Vehicle weights are an order of magnitude greater.
|
||||
totalWeight /= CONFIG.SW5E.encumbrance.vehicleWeightMultiplier;
|
||||
|
||||
// Compute overall encumbrance
|
||||
const max = actorData.data.attributes.capacity.cargo;
|
||||
const pct = Math.clamped((totalWeight * 100) / max, 0, 100);
|
||||
return {value: totalWeight.toNearest(0.1), max, pct};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getMovementSpeed(actorData, largestPrimary = true) {
|
||||
return super._getMovementSpeed(actorData, largestPrimary);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare items that are mounted to a vehicle and require one or more crew
|
||||
* to operate.
|
||||
* @private
|
||||
*/
|
||||
_prepareCrewedItem(item) {
|
||||
// Determine crewed status
|
||||
const isCrewed = item.data.crewed;
|
||||
item.toggleClass = isCrewed ? "active" : "";
|
||||
item.toggleTitle = game.i18n.localize(`SW5E.${isCrewed ? "Crewed" : "Uncrewed"}`);
|
||||
|
||||
// Handle crew actions
|
||||
if (item.type === "feat" && item.data.activation.type === "crew") {
|
||||
item.crew = item.data.activation.cost;
|
||||
item.cover = game.i18n.localize(`SW5E.${item.data.cover ? "CoverTotal" : "None"}`);
|
||||
if (item.data.cover === 0.5) item.cover = "½";
|
||||
else if (item.data.cover === 0.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"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// Classify items owned by the vehicle and compute total cargo weight
|
||||
let totalWeight = 0;
|
||||
for (const item of data.items) {
|
||||
this._prepareCrewedItem(item);
|
||||
|
||||
// Handle cargo explicitly
|
||||
const isCargo = item.flags.sw5e?.vehicleCargo === true;
|
||||
if (isCargo) {
|
||||
totalWeight += (item.data.weight || 0) * item.data.quantity;
|
||||
cargo.cargo.items.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle non-cargo item types
|
||||
switch (item.type) {
|
||||
case "weapon":
|
||||
features.weapons.items.push(item);
|
||||
break;
|
||||
case "equipment":
|
||||
features.equipment.items.push(item);
|
||||
break;
|
||||
case "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);
|
||||
break;
|
||||
default:
|
||||
totalWeight += (item.data.weight || 0) * item.data.quantity;
|
||||
cargo.cargo.items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the rendering context data
|
||||
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.isEditable) 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 = foundry.utils.deepClone(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 = foundry.utils.deepClone(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 = foundry.utils.deepClone(this.actor.data.data.cargo[type]).filter((_, i) => i !== idx);
|
||||
return this.actor.update({[`data.cargo.${type}`]: cargo});
|
||||
}
|
||||
|
||||
return super._onItemDelete(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onDropItemCreate(itemData) {
|
||||
const cargoTypes = ["weapon", "equipment", "consumable", "tool", "loot", "backpack"];
|
||||
const isCargo = cargoTypes.includes(itemData.type) && this._tabs[0].active === "cargo";
|
||||
foundry.utils.setProperty(itemData, "flags.sw5e.vehicleCargo", isCargo);
|
||||
return super._onDropItemCreate(itemData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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});
|
||||
}
|
||||
}
|
227
module/apps/ability-use-dialog.js
Normal file
|
@ -0,0 +1,227 @@
|
|||
/**
|
||||
* A specialized Dialog subclass for ability usage
|
||||
* @type {Dialog}
|
||||
*/
|
||||
export default class AbilityUseDialog extends Dialog {
|
||||
constructor(item, dialogData = {}, options = {}) {
|
||||
super(dialogData, options);
|
||||
this.options.classes = ["sw5e", "dialog"];
|
||||
|
||||
/**
|
||||
* Store a reference to the Item entity being used
|
||||
* @type {Item5e}
|
||||
*/
|
||||
this.item = item;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendering */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A constructor function which displays the Power Cast Dialog app for a given Actor and Item.
|
||||
* Returns a Promise which resolves to the dialog FormData once the workflow has been completed.
|
||||
* @param {Item5e} item
|
||||
* @return {Promise}
|
||||
*/
|
||||
static async create(item) {
|
||||
if (!item.isOwned) throw new Error("You cannot display an ability usage dialog for an unowned item");
|
||||
|
||||
// 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;
|
||||
const sufficientUses = (quantity > 0 && !uses.value) || uses.value > 0;
|
||||
|
||||
// Prepare dialog form data
|
||||
const data = {
|
||||
item: item.data,
|
||||
title: game.i18n.format("SW5E.AbilityUseHint", {
|
||||
type: game.i18n.localize(`SW5E.ItemType${item.type.capitalize()}`),
|
||||
name: item.name
|
||||
}),
|
||||
note: this._getAbilityUseNote(item.data, uses, recharge),
|
||||
consumePowerSlot: false,
|
||||
consumeRecharge: recharges,
|
||||
consumeResource: !!itemData.consume.target,
|
||||
consumeUses: uses.per && uses.max > 0,
|
||||
canUse: recharges ? recharge.charged : sufficientUses,
|
||||
createTemplate: game.user.can("TEMPLATE_CREATE") && item.hasAreaTarget,
|
||||
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 data as a Promise
|
||||
const icon = data.isPower ? "fa-magic" : "fa-fist-raised";
|
||||
const label = game.i18n.localize("SW5E.AbilityUse" + (data.isPower ? "Cast" : "Use"));
|
||||
return new Promise((resolve) => {
|
||||
const dlg = new this(item, {
|
||||
title: `${item.name}: ${game.i18n.localize("SW5E.AbilityUseConfig")}`,
|
||||
content: html,
|
||||
buttons: {
|
||||
use: {
|
||||
icon: `<i class="fas ${icon}"></i>`,
|
||||
label: label,
|
||||
callback: (html) => {
|
||||
const fd = new FormDataExtended(html[0].querySelector("form"));
|
||||
resolve(fd.toObject());
|
||||
}
|
||||
}
|
||||
},
|
||||
default: "use",
|
||||
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 consumePowerSlot = lvl > 0 && CONFIG.SW5E.powerUpcastModes.includes(itemData.preparation.mode);
|
||||
|
||||
// If can't upcast, return early and don't bother calculating available power slots
|
||||
if (!consumePowerSlot) {
|
||||
mergeObject(data, {isPower: true, consumePowerSlot});
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the levels which are feasible
|
||||
let lmax = 0;
|
||||
let points;
|
||||
let powerType;
|
||||
switch (itemData.school) {
|
||||
case "lgt":
|
||||
case "uni":
|
||||
case "drk": {
|
||||
powerType = "force";
|
||||
points = actorData.attributes.force.points.value + actorData.attributes.force.points.temp;
|
||||
break;
|
||||
}
|
||||
case "tec": {
|
||||
powerType = "tech";
|
||||
points = actorData.attributes.tech.points.value + actorData.attributes.tech.points.temp;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// eliminate point usage for innate casters
|
||||
if (actorData.attributes.powercasting === "innate") points = 999;
|
||||
|
||||
let powerLevels;
|
||||
if (powerType === "force") {
|
||||
powerLevels = Array.fromRange(10)
|
||||
.reduce((arr, i) => {
|
||||
if (i < lvl) return arr;
|
||||
const label = CONFIG.SW5E.powerLevels[i];
|
||||
const l = actorData.powers["power" + i] || {fmax: 0, foverride: null};
|
||||
let max = parseInt(l.foverride || l.fmax || 0);
|
||||
let slots = Math.clamped(parseInt(l.fvalue || 0), 0, max);
|
||||
if (max > 0) lmax = i;
|
||||
if (max > 0 && slots > 0 && points > i) {
|
||||
arr.push({
|
||||
level: i,
|
||||
label: i > 0 ? game.i18n.format("SW5E.PowerLevelSlot", {level: label, n: slots}) : label,
|
||||
canCast: max > 0,
|
||||
hasSlots: slots > 0
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
}, [])
|
||||
.filter((sl) => sl.level <= lmax);
|
||||
} else if (powerType === "tech") {
|
||||
powerLevels = Array.fromRange(10)
|
||||
.reduce((arr, i) => {
|
||||
if (i < lvl) return arr;
|
||||
const label = CONFIG.SW5E.powerLevels[i];
|
||||
const l = actorData.powers["power" + i] || {tmax: 0, toverride: null};
|
||||
let max = parseInt(l.override || l.tmax || 0);
|
||||
let slots = Math.clamped(parseInt(l.tvalue || 0), 0, max);
|
||||
if (max > 0) lmax = i;
|
||||
if (max > 0 && slots > 0 && points > i) {
|
||||
arr.push({
|
||||
level: i,
|
||||
label: i > 0 ? game.i18n.format("SW5E.PowerLevelSlot", {level: label, n: slots}) : label,
|
||||
canCast: max > 0,
|
||||
hasSlots: slots > 0
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
}, [])
|
||||
.filter((sl) => sl.level <= lmax);
|
||||
}
|
||||
|
||||
const canCast = powerLevels.some((l) => l.hasSlots);
|
||||
if (!canCast)
|
||||
data.errors.push(
|
||||
game.i18n.format("SW5E.PowerCastNoSlots", {
|
||||
level: CONFIG.SW5E.powerLevels[lvl],
|
||||
name: data.item.name
|
||||
})
|
||||
);
|
||||
|
||||
// Merge power casting data
|
||||
return foundry.utils.mergeObject(data, {isPower: true, consumePowerSlot, powerLevels});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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: game.i18n.localize(`SW5E.ItemType${item.type.capitalize()}`)
|
||||
});
|
||||
}
|
||||
|
||||
// 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: game.i18n.localize(`SW5E.Consumable${item.data.consumableType.capitalize()}`),
|
||||
value: uses.value,
|
||||
quantity: item.data.quantity,
|
||||
max: uses.max,
|
||||
per: CONFIG.SW5E.limitedUsePeriods[uses.per]
|
||||
});
|
||||
}
|
||||
|
||||
// Other Items
|
||||
else {
|
||||
return game.i18n.format("SW5E.AbilityUseNormalHint", {
|
||||
type: game.i18n.localize(`SW5E.ItemType${item.type.capitalize()}`),
|
||||
value: uses.value,
|
||||
max: uses.max,
|
||||
per: CONFIG.SW5E.limitedUsePeriods[uses.per]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
139
module/apps/actor-flags.js
Normal file
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* An application class which provides advanced configuration for special character flags which modify an Actor
|
||||
* @implements {DocumentSheet}
|
||||
*/
|
||||
export default class ActorSheetFlags extends DocumentSheet {
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "actor-flags",
|
||||
classes: ["sw5e"],
|
||||
template: "systems/sw5e/templates/apps/actor-flags.html",
|
||||
width: 500,
|
||||
closeOnSubmit: true
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get title() {
|
||||
return `${game.i18n.localize("SW5E.FlagsTitle")}: ${this.object.name}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData() {
|
||||
const data = {};
|
||||
data.actor = this.object;
|
||||
data.classes = this._getClasses();
|
||||
data.flags = this._getFlags();
|
||||
data.bonuses = this._getBonuses();
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare an object of sorted classes.
|
||||
* @return {object}
|
||||
* @private
|
||||
*/
|
||||
_getClasses() {
|
||||
const classes = this.object.items.filter((i) => i.type === "class");
|
||||
return classes
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.reduce((obj, i) => {
|
||||
obj[i.id] = i.name;
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare an object of flags data which groups flags by section
|
||||
* Add some additional data for rendering
|
||||
* @return {object}
|
||||
* @private
|
||||
*/
|
||||
_getFlags() {
|
||||
const flags = {};
|
||||
const baseData = this.document.toJSON();
|
||||
for (let [k, v] of Object.entries(CONFIG.SW5E.characterFlags)) {
|
||||
if (!flags.hasOwnProperty(v.section)) flags[v.section] = {};
|
||||
let flag = foundry.utils.deepClone(v);
|
||||
flag.type = v.type.name;
|
||||
flag.isCheckbox = v.type === Boolean;
|
||||
flag.isSelect = v.hasOwnProperty("choices");
|
||||
flag.value = getProperty(baseData.flags, `sw5e.${k}`);
|
||||
flags[v.section][`flags.sw5e.${k}`] = flag;
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the bonuses fields and their localization strings
|
||||
* @return {Array<object>}
|
||||
* @private
|
||||
*/
|
||||
_getBonuses() {
|
||||
const bonuses = [
|
||||
{name: "data.bonuses.mwak.attack", label: "SW5E.BonusMWAttack"},
|
||||
{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.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"},
|
||||
{name: "data.bonuses.power.dc", label: "SW5E.BonusPowerDC"},
|
||||
{name: "data.bonuses.power.forceLightDC", label: "SW5E.BonusForceLightPowerDC"},
|
||||
{name: "data.bonuses.power.forceDarkDC", label: "SW5E.BonusForceDarkPowerDC"},
|
||||
{name: "data.bonuses.power.forceUnivDC", label: "SW5E.BonusForceUnivPowerDC"},
|
||||
{name: "data.bonuses.power.techDC", label: "SW5E.BonusTechPowerDC"}
|
||||
];
|
||||
for (let b of bonuses) {
|
||||
b.value = getProperty(this.object._data, b.name) || "";
|
||||
}
|
||||
return bonuses;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
const actor = this.object;
|
||||
let updateData = expandObject(formData);
|
||||
|
||||
// Unset any flags which are "false"
|
||||
let unset = false;
|
||||
const flags = updateData.flags.sw5e;
|
||||
//clone flags to dnd5e for module compatability
|
||||
updateData.flags.dnd5e = updateData.flags.sw5e;
|
||||
for (let [k, v] of Object.entries(flags)) {
|
||||
if ([undefined, null, "", false, 0].includes(v)) {
|
||||
delete flags[k];
|
||||
if (hasProperty(actor._data.flags, `sw5e.${k}`)) {
|
||||
unset = true;
|
||||
flags[`-=${k}`] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
await actor.update(updateData, {diff: false});
|
||||
}
|
||||
}
|
|
@ -5,7 +5,6 @@ import Actor5e from "../actor/entity.js";
|
|||
* @extends {FormApplication}
|
||||
*/
|
||||
export default class ActorTypeConfig extends FormApplication {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
|
@ -32,23 +31,23 @@ export default class ActorTypeConfig extends FormApplication {
|
|||
|
||||
/** @override */
|
||||
getData(options) {
|
||||
|
||||
// Get current value or new default
|
||||
let attr = foundry.utils.getProperty(this.object.data.data, 'details.type');
|
||||
if ( foundry.utils.getType(attr) !== "Object" ) attr = {
|
||||
value: (attr in CONFIG.SW5E.creatureTypes) ? attr : "humanoid",
|
||||
subtype: "",
|
||||
swarm: "",
|
||||
custom: ""
|
||||
};
|
||||
let attr = foundry.utils.getProperty(this.object.data.data, "details.type");
|
||||
if (foundry.utils.getType(attr) !== "Object")
|
||||
attr = {
|
||||
value: attr in CONFIG.SW5E.creatureTypes ? attr : "humanoid",
|
||||
subtype: "",
|
||||
swarm: "",
|
||||
custom: ""
|
||||
};
|
||||
|
||||
// Populate choices
|
||||
const types = {};
|
||||
for ( let [k, v] of Object.entries(CONFIG.SW5E.creatureTypes) ) {
|
||||
for (let [k, v] of Object.entries(CONFIG.SW5E.creatureTypes)) {
|
||||
types[k] = {
|
||||
label: game.i18n.localize(v),
|
||||
chosen: attr.value === k
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Return data for rendering
|
||||
|
@ -61,12 +60,14 @@ export default class ActorTypeConfig extends FormApplication {
|
|||
},
|
||||
subtype: attr.subtype,
|
||||
swarm: attr.swarm,
|
||||
sizes: Array.from(Object.entries(CONFIG.SW5E.actorSizes)).reverse().reduce((obj, e) => {
|
||||
obj[e[0]] = e[1];
|
||||
return obj;
|
||||
}, {}),
|
||||
sizes: Array.from(Object.entries(CONFIG.SW5E.actorSizes))
|
||||
.reverse()
|
||||
.reduce((obj, e) => {
|
||||
obj[e[0]] = e[1];
|
||||
return obj;
|
||||
}, {}),
|
||||
preview: Actor5e.formatCreatureType(attr) || "–"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
@ -74,7 +75,7 @@ export default class ActorTypeConfig extends FormApplication {
|
|||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
const typeObject = foundry.utils.expandObject(formData);
|
||||
return this.object.update({ 'data.details.type': typeObject });
|
||||
return this.object.update({"data.details.type": typeObject});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
|
@ -3,7 +3,6 @@
|
|||
* @implements {DocumentSheet}
|
||||
*/
|
||||
export default class ActorHitDiceConfig extends DocumentSheet {
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
|
@ -26,20 +25,22 @@ export default class ActorHitDiceConfig extends DocumentSheet {
|
|||
/** @override */
|
||||
getData(options) {
|
||||
return {
|
||||
classes: this.object.items.reduce((classes, item) => {
|
||||
if (item.data.type === "class") {
|
||||
// Add the appropriate data only if this item is a "class"
|
||||
classes.push({
|
||||
classItemId: item.data._id,
|
||||
name: item.data.name,
|
||||
diceDenom: item.data.data.hitDice,
|
||||
currentHitDice: item.data.data.levels - item.data.data.hitDiceUsed,
|
||||
maxHitDice: item.data.data.levels,
|
||||
canRoll: (item.data.data.levels - item.data.data.hitDiceUsed) > 0
|
||||
});
|
||||
}
|
||||
return classes;
|
||||
}, []).sort((a, b) => parseInt(b.diceDenom.slice(1)) - parseInt(a.diceDenom.slice(1)))
|
||||
classes: this.object.items
|
||||
.reduce((classes, item) => {
|
||||
if (item.data.type === "class") {
|
||||
// Add the appropriate data only if this item is a "class"
|
||||
classes.push({
|
||||
classItemId: item.data._id,
|
||||
name: item.data.name,
|
||||
diceDenom: item.data.data.hitDice,
|
||||
currentHitDice: item.data.data.levels - item.data.data.hitDiceUsed,
|
||||
maxHitDice: item.data.data.levels,
|
||||
canRoll: item.data.data.levels - item.data.data.hitDiceUsed > 0
|
||||
});
|
||||
}
|
||||
return classes;
|
||||
}, [])
|
||||
.sort((a, b) => parseInt(b.diceDenom.slice(1)) - parseInt(a.diceDenom.slice(1)))
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -50,7 +51,7 @@ export default class ActorHitDiceConfig extends DocumentSheet {
|
|||
super.activateListeners(html);
|
||||
|
||||
// Hook up -/+ buttons to adjust the current value in the form
|
||||
html.find("button.increment,button.decrement").click(event => {
|
||||
html.find("button.increment,button.decrement").click((event) => {
|
||||
const button = event.currentTarget;
|
||||
const current = button.parentElement.querySelector(".current");
|
||||
const max = button.parentElement.querySelector(".max");
|
||||
|
@ -67,8 +68,8 @@ export default class ActorHitDiceConfig extends DocumentSheet {
|
|||
async _updateObject(event, formData) {
|
||||
const actorItems = this.object.items;
|
||||
const classUpdates = Object.entries(formData).map(([id, hd]) => ({
|
||||
_id: id,
|
||||
"data.hitDiceUsed": actorItems.get(id).data.data.levels - hd,
|
||||
"_id": id,
|
||||
"data.hitDiceUsed": actorItems.get(id).data.data.levels - hd
|
||||
}));
|
||||
return this.object.updateEmbeddedDocuments("Item", classUpdates);
|
||||
}
|
67
module/apps/long-rest.js
Normal file
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* 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: game.i18n.localize("SW5E.LongRest"),
|
||||
buttons: {
|
||||
rest: {
|
||||
icon: '<i class="fas fa-bed"></i>',
|
||||
label: game.i18n.localize("SW5E.Rest"),
|
||||
callback: (html) => {
|
||||
let newDay = true;
|
||||
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: game.i18n.localize("Cancel"),
|
||||
callback: reject
|
||||
}
|
||||
},
|
||||
default: "rest",
|
||||
close: reject
|
||||
});
|
||||
dlg.render(true);
|
||||
});
|
||||
}
|
||||
}
|
38
module/apps/movement-config.js
Normal file
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* A simple form to set actor movement speeds
|
||||
* @extends {DocumentSheet}
|
||||
*/
|
||||
export default class ActorMovementConfig extends DocumentSheet {
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["sw5e"],
|
||||
template: "systems/sw5e/templates/apps/movement-config.html",
|
||||
width: 300,
|
||||
height: "auto"
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get title() {
|
||||
return `${game.i18n.localize("SW5E.MovementConfig")}: ${this.document.name}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData(options) {
|
||||
const sourceMovement = foundry.utils.getProperty(this.document.data._source, "data.attributes.movement") || {};
|
||||
const data = {
|
||||
movement: foundry.utils.deepClone(sourceMovement),
|
||||
units: CONFIG.SW5E.movementUnits
|
||||
};
|
||||
for (let [k, v] of Object.entries(data.movement)) {
|
||||
if (["units", "hover"].includes(k)) continue;
|
||||
data.movement[k] = Number.isNumeric(v) ? v.toNearest(0.1) : 0;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@
|
|||
* @type {Dialog}
|
||||
*/
|
||||
export default class SelectItemsPrompt extends Dialog {
|
||||
constructor(items, dialogData={}, options={}) {
|
||||
constructor(items, dialogData = {}, options = {}) {
|
||||
super(dialogData, options);
|
||||
this.options.classes = ["sw5e", "dialog", "select-items-prompt", "sheet"];
|
||||
|
||||
|
@ -18,11 +18,11 @@ export default class SelectItemsPrompt extends Dialog {
|
|||
super.activateListeners(html);
|
||||
|
||||
// render the item's sheet if its image is clicked
|
||||
html.on('click', '.item-image', (event) => {
|
||||
html.on("click", ".item-image", (event) => {
|
||||
const item = this.items.find((feature) => feature.id === event.currentTarget.dataset?.itemId);
|
||||
|
||||
item?.sheet.render(true);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -33,29 +33,27 @@ export default class SelectItemsPrompt extends Dialog {
|
|||
* @param {string} options.hint - Localized hint to display at the top of the prompt
|
||||
* @return {Promise<string[]>} - list of item ids which the user has selected
|
||||
*/
|
||||
static async create(items, {
|
||||
hint
|
||||
}) {
|
||||
static async create(items, {hint}) {
|
||||
// Render the ability usage template
|
||||
const html = await renderTemplate("systems/sw5e/templates/apps/select-items-prompt.html", {items, hint});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const dlg = new this(items, {
|
||||
title: game.i18n.localize('SW5E.SelectItemsPromptTitle'),
|
||||
title: game.i18n.localize("SW5E.SelectItemsPromptTitle"),
|
||||
content: html,
|
||||
buttons: {
|
||||
apply: {
|
||||
icon: `<i class="fas fa-user-plus"></i>`,
|
||||
label: game.i18n.localize('SW5E.Apply'),
|
||||
callback: html => {
|
||||
label: game.i18n.localize("SW5E.Apply"),
|
||||
callback: (html) => {
|
||||
const fd = new FormDataExtended(html[0].querySelector("form")).toObject();
|
||||
const selectedIds = Object.keys(fd).filter(itemId => fd[itemId]);
|
||||
const selectedIds = Object.keys(fd).filter((itemId) => fd[itemId]);
|
||||
resolve(selectedIds);
|
||||
}
|
||||
},
|
||||
cancel: {
|
||||
icon: '<i class="fas fa-forward"></i>',
|
||||
label: game.i18n.localize('SW5E.Skip'),
|
||||
label: game.i18n.localize("SW5E.Skip"),
|
||||
callback: () => resolve([])
|
||||
}
|
||||
},
|
43
module/apps/senses-config.js
Normal file
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* A simple form to set Actor movement speeds.
|
||||
* @extends {DocumentSheet}
|
||||
*/
|
||||
export default class ActorSensesConfig extends DocumentSheet {
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["sw5e"],
|
||||
template: "systems/sw5e/templates/apps/senses-config.html",
|
||||
width: 300,
|
||||
height: "auto"
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get title() {
|
||||
return `${game.i18n.localize("SW5E.SensesConfig")}: ${this.document.name}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options) {
|
||||
const senses = foundry.utils.getProperty(this.document.data._source, "data.attributes.senses") || {};
|
||||
const data = {
|
||||
senses: {},
|
||||
special: senses.special ?? "",
|
||||
units: senses.units,
|
||||
movementUnits: CONFIG.SW5E.movementUnits
|
||||
};
|
||||
for (let [name, label] of Object.entries(CONFIG.SW5E.senses)) {
|
||||
const v = senses[name];
|
||||
data.senses[name] = {
|
||||
label: game.i18n.localize(label),
|
||||
value: Number.isNumeric(v) ? v.toNearest(0.1) : 0
|
||||
};
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
134
module/apps/short-rest.js
Normal file
|
@ -0,0 +1,134 @@
|
|||
import LongRestDialog from "./long-rest.js";
|
||||
|
||||
/**
|
||||
* A helper Dialog subclass for rolling Hit Dice on short rest
|
||||
* @extends {Dialog}
|
||||
*/
|
||||
export default class ShortRestDialog extends Dialog {
|
||||
constructor(actor, dialogData = {}, options = {}) {
|
||||
super(dialogData, options);
|
||||
|
||||
/**
|
||||
* Store a reference to the Actor entity which is resting
|
||||
* @type {Actor}
|
||||
*/
|
||||
this.actor = actor;
|
||||
|
||||
/**
|
||||
* Track the most recently used HD denomination for re-rendering the form
|
||||
* @type {string}
|
||||
*/
|
||||
this._denom = null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return mergeObject(super.defaultOptions, {
|
||||
template: "systems/sw5e/templates/apps/short-rest.html",
|
||||
classes: ["sw5e", "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.data;
|
||||
const denom = d.hitDice || "d6";
|
||||
const available = parseInt(d.levels || 1) - parseInt(d.hitDiceUsed || 0);
|
||||
hd[denom] = denom in hd ? hd[denom] + available : available;
|
||||
}
|
||||
return hd;
|
||||
}, {});
|
||||
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;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
let btn = html.find("#roll-hd");
|
||||
btn.click(this._onRollHitDie.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle rolling a Hit Die as part of a Short Rest action
|
||||
* @param {Event} event The triggering click event
|
||||
* @private
|
||||
*/
|
||||
async _onRollHitDie(event) {
|
||||
event.preventDefault();
|
||||
const btn = event.currentTarget;
|
||||
this._denom = btn.form.hd.value;
|
||||
await this.actor.rollHitDie(this._denom);
|
||||
this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A helper constructor function which displays the Short Rest dialog and returns a Promise once it's workflow has
|
||||
* been resolved.
|
||||
* @param {Actor5e} actor
|
||||
* @return {Promise}
|
||||
*/
|
||||
static async shortRestDialog({actor} = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const dlg = new this(actor, {
|
||||
title: game.i18n.localize("SW5E.ShortRest"),
|
||||
buttons: {
|
||||
rest: {
|
||||
icon: '<i class="fas fa-bed"></i>',
|
||||
label: game.i18n.localize("SW5E.Rest"),
|
||||
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: game.i18n.localize("Cancel"),
|
||||
callback: reject
|
||||
}
|
||||
},
|
||||
close: reject
|
||||
});
|
||||
dlg.render(true);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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} = {}) {
|
||||
console.warn(
|
||||
"WARNING! ShortRestDialog.longRestDialog has been deprecated, use LongRestDialog.longRestDialog instead."
|
||||
);
|
||||
return LongRestDialog.longRestDialog(...arguments);
|
||||
}
|
||||
}
|
87
module/apps/trait-selector.js
Normal file
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* A specialized form used to select from a checklist of attributes, traits, or properties
|
||||
* @extends {DocumentSheet}
|
||||
*/
|
||||
export default class TraitSelector extends DocumentSheet {
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "trait-selector",
|
||||
classes: ["sw5e", "trait-selector", "subconfig"],
|
||||
title: "Actor Trait Selection",
|
||||
template: "systems/sw5e/templates/apps/trait-selector.html",
|
||||
width: 320,
|
||||
height: "auto",
|
||||
choices: {},
|
||||
allowCustom: true,
|
||||
minimum: 0,
|
||||
maximum: null,
|
||||
valueKey: "value",
|
||||
customKey: "custom"
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to the target attribute
|
||||
* @type {string}
|
||||
*/
|
||||
get attribute() {
|
||||
return this.options.name;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData() {
|
||||
const attr = foundry.utils.getProperty(this.object.data, this.attribute);
|
||||
const o = this.options;
|
||||
const value = o.valueKey ? attr[o.valueKey] ?? [] : attr;
|
||||
const custom = o.customKey ? attr[o.customKey] ?? "" : "";
|
||||
|
||||
// Populate choices
|
||||
const choices = Object.entries(o.choices).reduce((obj, e) => {
|
||||
let [k, v] = e;
|
||||
obj[k] = {label: v, chosen: attr ? value.includes(k) : false};
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
// Return data
|
||||
return {
|
||||
allowCustom: o.allowCustom,
|
||||
choices: choices,
|
||||
custom: custom
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
const o = this.options;
|
||||
|
||||
// Obtain choices
|
||||
const chosen = [];
|
||||
for (let [k, v] of Object.entries(formData)) {
|
||||
if (k !== "custom" && v) chosen.push(k);
|
||||
}
|
||||
|
||||
// Object including custom data
|
||||
const updateData = {};
|
||||
if (o.valueKey) updateData[`${this.attribute}.${o.valueKey}`] = chosen;
|
||||
else updateData[this.attribute] = chosen;
|
||||
if (o.allowCustom) updateData[`${this.attribute}.${o.customKey}`] = formData.custom;
|
||||
|
||||
// Validate the number chosen
|
||||
if (o.minimum && chosen.length < o.minimum) {
|
||||
return ui.notifications.error(`You must choose at least ${o.minimum} options`);
|
||||
}
|
||||
if (o.maximum && chosen.length > o.maximum) {
|
||||
return ui.notifications.error(`You may choose no more than ${o.maximum} options`);
|
||||
}
|
||||
|
||||
// Update the object
|
||||
this.object.update(updateData);
|
||||
}
|
||||
}
|
38
module/canvas.js
Normal file
|
@ -0,0 +1,38 @@
|
|||
/** @override */
|
||||
export const measureDistances = function (segments, options = {}) {
|
||||
if (!options.gridSpaces) return BaseGrid.prototype.measureDistances.call(this, segments, options);
|
||||
|
||||
// Track the total number of diagonals
|
||||
let nDiagonal = 0;
|
||||
const rule = this.parent.diagonalRule;
|
||||
const d = canvas.dimensions;
|
||||
|
||||
// Iterate over measured segments
|
||||
return segments.map((s) => {
|
||||
let r = s.ray;
|
||||
|
||||
// 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;
|
||||
});
|
||||
};
|
326
module/characterImporter.js
Normal file
|
@ -0,0 +1,326 @@
|
|||
export default class CharacterImporter {
|
||||
// transform JSON from sw5e.com to Foundry friendly format
|
||||
// and insert new actor
|
||||
static async transform(rawCharacter) {
|
||||
const sourceCharacter = JSON.parse(rawCharacter); //source character
|
||||
|
||||
const details = {
|
||||
species: sourceCharacter.attribs.find((e) => e.name == "race").current,
|
||||
background: sourceCharacter.attribs.find((e) => e.name == "background").current,
|
||||
alignment: sourceCharacter.attribs.find((e) => e.name == "alignment").current
|
||||
};
|
||||
|
||||
const hp = {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "hp").current,
|
||||
min: 0,
|
||||
max: sourceCharacter.attribs.find((e) => e.name == "hp").current,
|
||||
temp: sourceCharacter.attribs.find((e) => e.name == "hp_temp").current
|
||||
};
|
||||
|
||||
const abilities = {
|
||||
str: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "strength").current,
|
||||
proficient: sourceCharacter.attribs.find((e) => e.name == "strength_save_prof").current ? 1 : 0
|
||||
},
|
||||
dex: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "dexterity").current,
|
||||
proficient: sourceCharacter.attribs.find((e) => e.name == "dexterity_save_prof").current ? 1 : 0
|
||||
},
|
||||
con: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "constitution").current,
|
||||
proficient: sourceCharacter.attribs.find((e) => e.name == "constitution_save_prof").current ? 1 : 0
|
||||
},
|
||||
int: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "intelligence").current,
|
||||
proficient: sourceCharacter.attribs.find((e) => e.name == "intelligence_save_prof").current ? 1 : 0
|
||||
},
|
||||
wis: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "wisdom").current,
|
||||
proficient: sourceCharacter.attribs.find((e) => e.name == "wisdom_save_prof").current ? 1 : 0
|
||||
},
|
||||
cha: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "charisma").current,
|
||||
proficient: sourceCharacter.attribs.find((e) => e.name == "charisma_save_prof").current ? 1 : 0
|
||||
}
|
||||
};
|
||||
|
||||
/* ----------------------------------------------------------------- */
|
||||
/* character.data.skills.<skill_name>.value is all that matters
|
||||
/* values can be 0, 0.5, 1 or 2
|
||||
/* 0 = regular
|
||||
/* 0.5 = half-proficient
|
||||
/* 1 = proficient
|
||||
/* 2 = expertise
|
||||
/* foundry takes care of calculating the rest
|
||||
/* ----------------------------------------------------------------- */
|
||||
const skills = {
|
||||
acr: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "acrobatics_type").current
|
||||
},
|
||||
ani: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "animal_handling_type").current
|
||||
},
|
||||
ath: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "athletics_type").current
|
||||
},
|
||||
dec: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "deception_type").current
|
||||
},
|
||||
ins: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "insight_type").current
|
||||
},
|
||||
inv: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "investigation_type").current
|
||||
},
|
||||
itm: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "intimidation_type").current
|
||||
},
|
||||
lor: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "lore_type").current
|
||||
},
|
||||
med: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "medicine_type").current
|
||||
},
|
||||
nat: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "nature_type").current
|
||||
},
|
||||
per: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "persuasion_type").current
|
||||
},
|
||||
pil: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "piloting_type").current
|
||||
},
|
||||
prc: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "perception_type").current
|
||||
},
|
||||
prf: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "performance_type").current
|
||||
},
|
||||
slt: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "sleight_of_hand_type").current
|
||||
},
|
||||
ste: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "stealth_type").current
|
||||
},
|
||||
sur: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "survival_type").current
|
||||
},
|
||||
tec: {
|
||||
value: sourceCharacter.attribs.find((e) => e.name == "technology_type").current
|
||||
}
|
||||
};
|
||||
|
||||
const targetCharacter = {
|
||||
name: sourceCharacter.name,
|
||||
type: "character",
|
||||
data: {
|
||||
abilities: abilities,
|
||||
details: details,
|
||||
skills: skills,
|
||||
attributes: {
|
||||
hp: hp
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let actor = await Actor.create(targetCharacter);
|
||||
CharacterImporter.addProfessions(sourceCharacter, actor);
|
||||
}
|
||||
|
||||
// Parse all classes and add them to already created actor.
|
||||
// "class" is a reserved word, therefore I use profession where I can.
|
||||
static async addProfessions(sourceCharacter, actor) {
|
||||
let result = [];
|
||||
|
||||
// parse all class and multiclassX items
|
||||
// couldn't get Array.filter to work here for some reason
|
||||
// result = array of objects. each object is a separate class
|
||||
sourceCharacter.attribs.forEach((e) => {
|
||||
if (CharacterImporter.classOrMulticlass(e.name)) {
|
||||
var t = {
|
||||
profession: CharacterImporter.capitalize(e.current),
|
||||
type: CharacterImporter.baseOrMulti(e.name),
|
||||
level: CharacterImporter.getLevel(e, sourceCharacter)
|
||||
};
|
||||
result.push(t);
|
||||
}
|
||||
});
|
||||
|
||||
// pull classes directly from system compendium and add them to current actor
|
||||
const professionsPack = await game.packs.get("sw5e.classes").getDocuments();
|
||||
result.forEach((prof) => {
|
||||
let assignedProfession = professionsPack.find((o) => o.name === prof.profession);
|
||||
assignedProfession.data.data.levels = prof.level;
|
||||
actor.createEmbeddedDocuments("Item", [assignedProfession.data], {displaySheet: false});
|
||||
});
|
||||
|
||||
this.addSpecies(sourceCharacter.attribs.find((e) => e.name == "race").current, actor);
|
||||
|
||||
this.addPowers(
|
||||
sourceCharacter.attribs
|
||||
.filter((e) => e.name.search(/repeating_power.+_powername/g) != -1)
|
||||
.map((e) => e.current),
|
||||
actor
|
||||
);
|
||||
|
||||
const discoveredItems = sourceCharacter.attribs.filter(
|
||||
(e) => e.name.search(/repeating_inventory.+_itemname/g) != -1
|
||||
);
|
||||
const items = discoveredItems.map((item) => {
|
||||
const id = item.name.match(/-\w{19}/g);
|
||||
|
||||
return {
|
||||
name: item.current,
|
||||
quantity: sourceCharacter.attribs.find((e) => e.name === `repeating_inventory_${id}_itemcount`).current
|
||||
};
|
||||
});
|
||||
|
||||
this.addItems(items, actor);
|
||||
}
|
||||
|
||||
static async addClasses(profession, level, actor) {
|
||||
let classes = await game.packs.get("sw5e.classes").getDocuments();
|
||||
let assignedClass = classes.find((c) => c.name === profession);
|
||||
assignedClass.data.data.levels = level;
|
||||
await actor.createEmbeddedDocuments("Item", [assignedClass.data], {displaySheet: false});
|
||||
}
|
||||
|
||||
static classOrMulticlass(name) {
|
||||
return name === "class" || (name.includes("multiclass") && name.length <= 12);
|
||||
}
|
||||
|
||||
static baseOrMulti(name) {
|
||||
if (name === "class") {
|
||||
return "base_class";
|
||||
} else {
|
||||
return "multi_class";
|
||||
}
|
||||
}
|
||||
|
||||
static getLevel(item, sourceCharacter) {
|
||||
if (item.name === "class") {
|
||||
let result = sourceCharacter.attribs.find((e) => e.name === "base_level").current;
|
||||
return parseInt(result);
|
||||
} else {
|
||||
let result = sourceCharacter.attribs.find((e) => e.name === `${item.name}_lvl`).current;
|
||||
return parseInt(result);
|
||||
}
|
||||
}
|
||||
|
||||
static capitalize(str) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
static async addSpecies(race, actor) {
|
||||
const species = await game.packs.get("sw5e.species").getDocuments();
|
||||
const assignedSpecies = species.find((c) => c.name === race);
|
||||
const activeEffects = [...assignedSpecies.data.effects][0].data.changes;
|
||||
const actorData = {data: {abilities: {...actor.data.data.abilities}}};
|
||||
|
||||
activeEffects.map((effect) => {
|
||||
switch (effect.key) {
|
||||
case "data.abilities.str.value":
|
||||
actorData.data.abilities.str.value -= effect.value;
|
||||
break;
|
||||
|
||||
case "data.abilities.dex.value":
|
||||
actorData.data.abilities.dex.value -= effect.value;
|
||||
break;
|
||||
|
||||
case "data.abilities.con.value":
|
||||
actorData.data.abilities.con.value -= effect.value;
|
||||
break;
|
||||
|
||||
case "data.abilities.int.value":
|
||||
actorData.data.abilities.int.value -= effect.value;
|
||||
break;
|
||||
|
||||
case "data.abilities.wis.value":
|
||||
actorData.data.abilities.wis.value -= effect.value;
|
||||
break;
|
||||
|
||||
case "data.abilities.cha.value":
|
||||
actorData.data.abilities.cha.value -= effect.value;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
actor.update(actorData);
|
||||
|
||||
await actor.createEmbeddedDocuments("Item", [assignedSpecies.data], {displaySheet: false});
|
||||
}
|
||||
|
||||
static async addPowers(powers, actor) {
|
||||
const forcePowers = await game.packs.get("sw5e.forcepowers").getDocuments();
|
||||
const techPowers = await game.packs.get("sw5e.techpowers").getDocuments();
|
||||
|
||||
for (const power of powers) {
|
||||
const createdPower = forcePowers.find((c) => c.name === power) || techPowers.find((c) => c.name === power);
|
||||
|
||||
if (createdPower) {
|
||||
await actor.createEmbeddedDocuments("Item", [createdPower.data], {displaySheet: false});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async addItems(items, actor) {
|
||||
const weapons = await game.packs.get("sw5e.weapons").getDocuments();
|
||||
const armors = await game.packs.get("sw5e.armor").getDocuments();
|
||||
const adventuringGear = await game.packs.get("sw5e.adventuringgear").getDocuments();
|
||||
|
||||
for (const item of items) {
|
||||
const createdItem =
|
||||
weapons.find((c) => c.name.toLowerCase() === item.name.toLowerCase()) ||
|
||||
armors.find((c) => c.name.toLowerCase() === item.name.toLowerCase()) ||
|
||||
adventuringGear.find((c) => c.name.toLowerCase() === item.name.toLowerCase());
|
||||
|
||||
if (createdItem) {
|
||||
if (item.quantity != 1) {
|
||||
createdItem.data.data.quantity = item.quantity;
|
||||
}
|
||||
|
||||
await actor.createEmbeddedDocuments("Item", [createdItem.data], {displaySheet: false});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static addImportButton(html) {
|
||||
const actionButtons = html.find(".header-actions");
|
||||
actionButtons[0].insertAdjacentHTML(
|
||||
"afterend",
|
||||
`<div class="header-actions action-buttons flexrow"><button class="create-entity cs-import-button"><i class="fas fa-upload"></i> Import Character</button></div>`
|
||||
);
|
||||
|
||||
let characterImportButton = $(".cs-import-button");
|
||||
characterImportButton.click(() => {
|
||||
let content = `<h1>Saved Character JSON Import</h1>
|
||||
<label for="character-json">Paste character JSON here:</label>
|
||||
</br>
|
||||
<textarea id="character-json" name="character-json" rows="10" cols="50"></textarea>`;
|
||||
let importDialog = new Dialog({
|
||||
title: "Import Character from SW5e.com",
|
||||
content: content,
|
||||
buttons: {
|
||||
Import: {
|
||||
icon: `<i class="fas fa-file-import"></i>`,
|
||||
label: "Import Character",
|
||||
callback: () => {
|
||||
let characterData = $("#character-json").val();
|
||||
console.log("Parsing Character JSON");
|
||||
CharacterImporter.transform(characterData);
|
||||
}
|
||||
},
|
||||
Cancel: {
|
||||
icon: `<i class="fas fa-times-circle"></i>`,
|
||||
label: "Cancel",
|
||||
callback: () => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
importDialog.render(true);
|
||||
});
|
||||
}
|
||||
}
|
120
module/chat.js
Normal file
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* Highlight critical success or failure on d20 rolls
|
||||
*/
|
||||
export const highlightCriticalSuccessFailure = function (message, html, data) {
|
||||
if (!message.isRoll || !message.isContentVisible) return;
|
||||
|
||||
// Highlight rolls where the first part is a d20 roll
|
||||
const roll = message.roll;
|
||||
if (!roll.dice.length) return;
|
||||
const d = roll.dice[0];
|
||||
|
||||
// Ensure it is an un-modified d20 roll
|
||||
const isD20 = d.faces === 20 && d.values.length === 1;
|
||||
if (!isD20) return;
|
||||
const isModifiedRoll = "success" in d.results[0] || d.options.marginSuccess || d.options.marginFailure;
|
||||
if (isModifiedRoll) return;
|
||||
|
||||
// Highlight successes and failures
|
||||
const critical = d.options.critical || 20;
|
||||
const fumble = d.options.fumble || 1;
|
||||
if (d.total >= critical) html.find(".dice-total").addClass("critical");
|
||||
else if (d.total <= fumble) html.find(".dice-total").addClass("fumble");
|
||||
else if (d.options.target) {
|
||||
if (roll.total >= d.options.target) html.find(".dice-total").addClass("success");
|
||||
else html.find(".dice-total").addClass("failure");
|
||||
}
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Optionally hide the display of chat card action buttons which cannot be performed by the user
|
||||
*/
|
||||
export const displayChatActionButtons = function (message, html, data) {
|
||||
const chatCard = html.find(".sw5e.chat-card");
|
||||
if (chatCard.length > 0) {
|
||||
const flavor = html.find(".flavor-text");
|
||||
if (flavor.text() === html.find(".item-name").text()) flavor.remove();
|
||||
|
||||
// If the user is the message author or the actor owner, proceed
|
||||
let actor = game.actors.get(data.message.speaker.actor);
|
||||
if (actor && actor.isOwner) return;
|
||||
else if (game.user.isGM || data.author.id === game.user.id) return;
|
||||
|
||||
// Otherwise conceal action buttons except for saving throw
|
||||
const buttons = chatCard.find("button[data-action]");
|
||||
buttons.each((i, btn) => {
|
||||
if (btn.dataset.action === "save") return;
|
||||
btn.style.display = "none";
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* This function is used to hook into the Chat Log context menu to add additional options to each message
|
||||
* These options make it easy to conveniently apply damage to controlled tokens based on the value of a Roll
|
||||
*
|
||||
* @param {HTMLElement} html The Chat Message being rendered
|
||||
* @param {Array} options The Array of Context Menu options
|
||||
*
|
||||
* @return {Array} The extended options Array including new context choices
|
||||
*/
|
||||
export const addChatMessageContextOptions = function (html, options) {
|
||||
let canApply = (li) => {
|
||||
const message = game.messages.get(li.data("messageId"));
|
||||
return message?.isRoll && message?.isContentVisible && canvas.tokens?.controlled.length;
|
||||
};
|
||||
options.push(
|
||||
{
|
||||
name: game.i18n.localize("SW5E.ChatContextDamage"),
|
||||
icon: '<i class="fas fa-user-minus"></i>',
|
||||
condition: canApply,
|
||||
callback: (li) => applyChatCardDamage(li, 1)
|
||||
},
|
||||
{
|
||||
name: game.i18n.localize("SW5E.ChatContextHealing"),
|
||||
icon: '<i class="fas fa-user-plus"></i>',
|
||||
condition: canApply,
|
||||
callback: (li) => applyChatCardDamage(li, -1)
|
||||
},
|
||||
{
|
||||
name: game.i18n.localize("SW5E.ChatContextDoubleDamage"),
|
||||
icon: '<i class="fas fa-user-injured"></i>',
|
||||
condition: canApply,
|
||||
callback: (li) => applyChatCardDamage(li, 2)
|
||||
},
|
||||
{
|
||||
name: game.i18n.localize("SW5E.ChatContextHalfDamage"),
|
||||
icon: '<i class="fas fa-user-shield"></i>',
|
||||
condition: canApply,
|
||||
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} li The chat entry which contains the roll data
|
||||
* @param {Number} multiplier A damage multiplier to apply to the rolled damage.
|
||||
* @return {Promise}
|
||||
*/
|
||||
function applyChatCardDamage(li, multiplier) {
|
||||
const message = game.messages.get(li.data("messageId"));
|
||||
const roll = message.roll;
|
||||
return Promise.all(
|
||||
canvas.tokens.controlled.map((t) => {
|
||||
const a = t.actor;
|
||||
return a.applyDamage(roll.total, multiplier);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
1
module/classFeatures.js
Normal file
|
@ -0,0 +1 @@
|
|||
export const ClassFeatures = {};
|
31
module/combat.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Override the default Initiative formula to customize special behaviors of the SW5e system.
|
||||
* Apply advantage, proficiency, or bonuses where appropriate
|
||||
* Apply the dexterity score as a decimal tiebreaker if requested
|
||||
* See Combat._getInitiativeFormula for more detail.
|
||||
*/
|
||||
export const _getInitiativeFormula = function () {
|
||||
const actor = this.actor;
|
||||
if (!actor) return "1d20";
|
||||
const init = actor.data.data.attributes.init;
|
||||
|
||||
// Construct initiative formula parts
|
||||
let nd = 1;
|
||||
let mods = "";
|
||||
if (actor.getFlag("sw5e", "halflingLucky")) mods += "r1=1";
|
||||
if (actor.getFlag("sw5e", "initiativeAdv")) {
|
||||
nd = 2;
|
||||
mods += "kh";
|
||||
}
|
||||
const parts = [
|
||||
`${nd}d20${mods}`,
|
||||
init.mod,
|
||||
init.prof !== 0 ? init.prof : null,
|
||||
init.bonus !== 0 ? init.bonus : null
|
||||
];
|
||||
|
||||
// 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(" + ");
|
||||
};
|
1783
module/config.js
Normal file
313
module/dice.js
Normal file
|
@ -0,0 +1,313 @@
|
|||
export {default as D20Roll} from "./dice/d20-roll.js";
|
||||
export {default as DamageRoll} from "./dice/damage-roll.js";
|
||||
|
||||
/**
|
||||
* A standardized helper function for simplifying the constant parts of a multipart roll formula
|
||||
*
|
||||
* @param {string} formula The original Roll formula
|
||||
* @param {Object} data Actor or item data against which to parse the roll
|
||||
* @param {Object} options Formatting options
|
||||
* @param {boolean} options.constantFirst Puts the constants before the dice terms in the resulting formula
|
||||
*
|
||||
* @return {string} The resulting simplified formula
|
||||
*/
|
||||
export function simplifyRollFormula(formula, data, {constantFirst = false} = {}) {
|
||||
const roll = new Roll(formula, data); // Parses the formula and replaces any @properties
|
||||
const terms = roll.terms;
|
||||
|
||||
// Some terms are "too complicated" for this algorithm to simplify
|
||||
// In this case, the original formula is returned.
|
||||
if (terms.some(_isUnsupportedTerm)) return roll.formula;
|
||||
|
||||
const rollableTerms = []; // Terms that are non-constant, and their associated operators
|
||||
const constantTerms = []; // Terms that are constant, and their associated operators
|
||||
let operators = []; // Temporary storage for operators before they are moved to one of the above
|
||||
|
||||
for (let term of terms) {
|
||||
// For each term
|
||||
if (term instanceof OperatorTerm) operators.push(term);
|
||||
// If the term is an addition/subtraction operator, push the term into the operators array
|
||||
else {
|
||||
// Otherwise the term is not an operator
|
||||
if (term instanceof DiceTerm) {
|
||||
// If the term is something rollable
|
||||
rollableTerms.push(...operators); // Place all the operators into the rollableTerms array
|
||||
rollableTerms.push(term); // Then place this rollable term into it as well
|
||||
} //
|
||||
else {
|
||||
// Otherwise, this must be a constant
|
||||
constantTerms.push(...operators); // Place the operators into the constantTerms array
|
||||
constantTerms.push(term); // Then also add this constant term to that array.
|
||||
} //
|
||||
operators = []; // Finally, the operators have now all been assigend to one of the arrays, so empty this before the next iteration.
|
||||
}
|
||||
}
|
||||
|
||||
const constantFormula = Roll.getFormula(constantTerms); // Cleans up the constant terms and produces a new formula string
|
||||
const rollableFormula = Roll.getFormula(rollableTerms); // Cleans up the non-constant terms and produces a new formula string
|
||||
|
||||
// Mathematically evaluate the constant formula to produce a single constant term
|
||||
let constantPart = undefined;
|
||||
if (constantFormula) {
|
||||
try {
|
||||
constantPart = Roll.safeEval(constantFormula);
|
||||
} catch (err) {
|
||||
console.warn(`Unable to evaluate constant term ${constantFormula} in simplifyRollFormula`);
|
||||
}
|
||||
}
|
||||
|
||||
// Order the rollable and constant terms, either constant first or second depending on the optional argument
|
||||
const parts = constantFirst ? [constantPart, rollableFormula] : [rollableFormula, constantPart];
|
||||
|
||||
// Join the parts with a + sign, pass them to `Roll` once again to clean up the formula
|
||||
return new Roll(parts.filterJoin(" + ")).formula;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Only some terms are supported by simplifyRollFormula, this method returns true when the term is not supported.
|
||||
* @param {*} term - A single Dice term to check support on
|
||||
* @return {Boolean} True when unsupported, false if supported
|
||||
*/
|
||||
function _isUnsupportedTerm(term) {
|
||||
const diceTerm = term instanceof DiceTerm;
|
||||
const operator = term instanceof OperatorTerm && ["+", "-"].includes(term.operator);
|
||||
const number = term instanceof NumericTerm;
|
||||
|
||||
return !(diceTerm || operator || number);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* D20 Roll */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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 {string[]} parts The dice roll component parts, excluding the initial d20
|
||||
* @param {object} data Actor or item data against which to parse the roll
|
||||
*
|
||||
* @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} [chooseModifier=false] Choose the ability modifier that should be used when the roll is made
|
||||
* @param {boolean} [fastForward=false] Allow fast-forward advantage selection
|
||||
* @param {Event} [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} [template] The HTML template used to render the roll dialog
|
||||
* @param {string} [title] The dialog window title
|
||||
* @param {Object} [dialogOptions] Modal dialog options
|
||||
*
|
||||
* @param {boolean} [chatMessage=true] 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
|
||||
* @param {string} [rollMode] A specific roll mode to apply as the default for the resulting roll
|
||||
* @param {object} [speaker] The ChatMessage speaker to pass when creating the chat
|
||||
* @param {string} [flavor] Flavor text to use in the posted chat message
|
||||
*
|
||||
* @return {Promise<D20Roll|null>} The evaluated D20Roll, or null if the workflow was cancelled
|
||||
*/
|
||||
export async function d20Roll({
|
||||
parts = [],
|
||||
data = {}, // Roll creation
|
||||
advantage,
|
||||
disadvantage,
|
||||
fumble = 1,
|
||||
critical = 20,
|
||||
targetValue,
|
||||
elvenAccuracy,
|
||||
halflingLucky,
|
||||
reliableTalent, // Roll customization
|
||||
chooseModifier = false,
|
||||
fastForward = false,
|
||||
event,
|
||||
template,
|
||||
title,
|
||||
dialogOptions, // Dialog configuration
|
||||
chatMessage = true,
|
||||
messageData = {},
|
||||
rollMode,
|
||||
speaker,
|
||||
flavor // Chat Message customization
|
||||
} = {}) {
|
||||
// Handle input arguments
|
||||
const formula = ["1d20"].concat(parts).join(" + ");
|
||||
const {advantageMode, isFF} = _determineAdvantageMode({advantage, disadvantage, fastForward, event});
|
||||
const defaultRollMode = rollMode || game.settings.get("core", "rollMode");
|
||||
if (chooseModifier && !isFF) data["mod"] = "@mod";
|
||||
|
||||
// Construct the D20Roll instance
|
||||
const roll = new CONFIG.Dice.D20Roll(formula, data, {
|
||||
flavor: flavor || title,
|
||||
advantageMode,
|
||||
defaultRollMode,
|
||||
critical,
|
||||
fumble,
|
||||
targetValue,
|
||||
elvenAccuracy,
|
||||
halflingLucky,
|
||||
reliableTalent
|
||||
});
|
||||
|
||||
// Prompt a Dialog to further configure the D20Roll
|
||||
if (!isFF) {
|
||||
const configured = await roll.configureDialog(
|
||||
{
|
||||
title,
|
||||
chooseModifier,
|
||||
defaultRollMode: defaultRollMode,
|
||||
defaultAction: advantageMode,
|
||||
defaultAbility: data?.item?.ability,
|
||||
template
|
||||
},
|
||||
dialogOptions
|
||||
);
|
||||
if (configured === null) return null;
|
||||
}
|
||||
|
||||
// Evaluate the configured roll
|
||||
await roll.evaluate({async: true});
|
||||
|
||||
// Create a Chat Message
|
||||
if (speaker) {
|
||||
console.warn(
|
||||
`You are passing the speaker argument to the d20Roll function directly which should instead be passed as an internal key of messageData`
|
||||
);
|
||||
messageData.speaker = speaker;
|
||||
}
|
||||
if (roll && chatMessage) await roll.toMessage(messageData);
|
||||
return roll;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determines whether this d20 roll should be fast-forwarded, and whether advantage or disadvantage should be applied
|
||||
* @returns {{isFF: boolean, advantageMode: number}} Whether the roll is fast-forward, and its advantage mode
|
||||
*/
|
||||
function _determineAdvantageMode({event, advantage = false, disadvantage = false, fastForward = false} = {}) {
|
||||
const isFF = fastForward || (event && (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey));
|
||||
let advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.NORMAL;
|
||||
if (advantage || event?.altKey) advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.ADVANTAGE;
|
||||
else if (disadvantage || event?.ctrlKey || event?.metaKey)
|
||||
advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.DISADVANTAGE;
|
||||
return {isFF, advantageMode};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Damage Roll */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A standardized helper function for managing core 5e damage 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, Critical, or no bonus respectively
|
||||
*
|
||||
* @param {string[]} parts The dice roll component parts, excluding the initial d20
|
||||
* @param {object} [data] Actor or item data against which to parse the roll
|
||||
*
|
||||
* @param {boolean} [critical=false] Flag this roll as a critical hit for the purposes of fast-forward or default dialog action
|
||||
* @param {number} [criticalBonusDice=0] A number of bonus damage dice that are added for critical hits
|
||||
* @param {number} [criticalMultiplier=2] A critical hit multiplier which is applied to critical hits
|
||||
* @param {boolean} [multiplyNumeric=false] Multiply numeric terms by the critical multiplier
|
||||
* @param {boolean} [powerfulCritical=false] Apply the "powerful criticals" house rule to critical hits
|
||||
|
||||
* @param {boolean} [fastForward=false] Allow fast-forward advantage selection
|
||||
* @param {Event}[event] The triggering event which initiated the roll
|
||||
* @param {boolean} [allowCritical=true] Allow the opportunity for a critical hit to be rolled
|
||||
* @param {string} [template] The HTML template used to render the roll dialog
|
||||
* @param {string} [title] The dice roll UI window title
|
||||
* @param {object} [dialogOptions] Configuration dialog options
|
||||
*
|
||||
* @param {boolean} [chatMessage=true] 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
|
||||
* @param {string} [rollMode] A specific roll mode to apply as the default for the resulting roll
|
||||
* @param {object} [speaker] The ChatMessage speaker to pass when creating the chat
|
||||
* @param {string} [flavor] Flavor text to use in the posted chat message
|
||||
*
|
||||
* @return {Promise<DamageRoll|null>} The evaluated DamageRoll, or null if the workflow was canceled
|
||||
*/
|
||||
export async function damageRoll({
|
||||
parts = [],
|
||||
data, // Roll creation
|
||||
critical = false,
|
||||
criticalBonusDice,
|
||||
criticalMultiplier,
|
||||
multiplyNumeric,
|
||||
powerfulCritical, // Damage customization
|
||||
fastForward = false,
|
||||
event,
|
||||
allowCritical = true,
|
||||
template,
|
||||
title,
|
||||
dialogOptions, // Dialog configuration
|
||||
chatMessage = true,
|
||||
messageData = {},
|
||||
rollMode,
|
||||
speaker,
|
||||
flavor // Chat Message customization
|
||||
} = {}) {
|
||||
// Handle input arguments
|
||||
const defaultRollMode = rollMode || game.settings.get("core", "rollMode");
|
||||
|
||||
// Construct the DamageRoll instance
|
||||
const formula = parts.join(" + ");
|
||||
const {isCritical, isFF} = _determineCriticalMode({critical, fastForward, event});
|
||||
const roll = new CONFIG.Dice.DamageRoll(formula, data, {
|
||||
flavor: flavor || title,
|
||||
critical: isCritical,
|
||||
criticalBonusDice,
|
||||
criticalMultiplier,
|
||||
multiplyNumeric,
|
||||
powerfulCritical
|
||||
});
|
||||
|
||||
// Prompt a Dialog to further configure the DamageRoll
|
||||
if (!isFF) {
|
||||
const configured = await roll.configureDialog(
|
||||
{
|
||||
title,
|
||||
defaultRollMode: defaultRollMode,
|
||||
defaultCritical: isCritical,
|
||||
template,
|
||||
allowCritical
|
||||
},
|
||||
dialogOptions
|
||||
);
|
||||
if (configured === null) return null;
|
||||
}
|
||||
|
||||
// Evaluate the configured roll
|
||||
await roll.evaluate({async: true});
|
||||
|
||||
// Create a Chat Message
|
||||
if (speaker) {
|
||||
console.warn(
|
||||
`You are passing the speaker argument to the damageRoll function directly which should instead be passed as an internal key of messageData`
|
||||
);
|
||||
messageData.speaker = speaker;
|
||||
}
|
||||
if (roll && chatMessage) await roll.toMessage(messageData);
|
||||
return roll;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determines whether this d20 roll should be fast-forwarded, and whether advantage or disadvantage should be applied
|
||||
* @returns {{isFF: boolean, isCritical: boolean}} Whether the roll is fast-forward, and whether it is a critical hit
|
||||
*/
|
||||
function _determineCriticalMode({event, critical = false, fastForward = false} = {}) {
|
||||
const isFF = fastForward || (event && (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey));
|
||||
if (event?.altKey) critical = true;
|
||||
return {isFF, isCritical: critical};
|
||||
}
|
|
@ -16,7 +16,7 @@
|
|||
export default class D20Roll extends Roll {
|
||||
constructor(formula, data, options) {
|
||||
super(formula, data, options);
|
||||
if ( !((this.terms[0] instanceof Die) && (this.terms[0].faces === 20)) ) {
|
||||
if (!(this.terms[0] instanceof Die && this.terms[0].faces === 20)) {
|
||||
throw new Error(`Invalid D20Roll formula provided ${this._formula}`);
|
||||
}
|
||||
this.configureModifiers();
|
||||
|
@ -31,8 +31,8 @@ export default class D20Roll extends Roll {
|
|||
static ADV_MODE = {
|
||||
NORMAL: 0,
|
||||
ADVANTAGE: 1,
|
||||
DISADVANTAGE: -1,
|
||||
}
|
||||
DISADVANTAGE: -1
|
||||
};
|
||||
|
||||
/**
|
||||
* The HTML template path used to configure evaluation of this Roll
|
||||
|
@ -71,28 +71,26 @@ export default class D20Roll extends Roll {
|
|||
d20.modifiers = [];
|
||||
|
||||
// Halfling Lucky
|
||||
if ( this.options.halflingLucky ) d20.modifiers.push("r1=1");
|
||||
if (this.options.halflingLucky) d20.modifiers.push("r1=1");
|
||||
|
||||
// Reliable Talent
|
||||
if ( this.options.reliableTalent ) d20.modifiers.push("min10");
|
||||
if (this.options.reliableTalent) d20.modifiers.push("min10");
|
||||
|
||||
// Handle Advantage or Disadvantage
|
||||
if ( this.hasAdvantage ) {
|
||||
if (this.hasAdvantage) {
|
||||
d20.number = this.options.elvenAccuracy ? 3 : 2;
|
||||
d20.modifiers.push("kh");
|
||||
d20.options.advantage = true;
|
||||
}
|
||||
else if ( this.hasDisadvantage ) {
|
||||
} else if (this.hasDisadvantage) {
|
||||
d20.number = 2;
|
||||
d20.modifiers.push("kl");
|
||||
d20.options.disadvantage = true;
|
||||
}
|
||||
else d20.number = 1;
|
||||
} else d20.number = 1;
|
||||
|
||||
// Assign critical and fumble thresholds
|
||||
if ( this.options.critical ) d20.options.critical = this.options.critical;
|
||||
if ( this.options.fumble ) d20.options.fumble = this.options.fumble;
|
||||
if ( this.options.targetValue ) d20.options.target = this.options.targetValue;
|
||||
if (this.options.critical) d20.options.critical = this.options.critical;
|
||||
if (this.options.fumble) d20.options.fumble = this.options.fumble;
|
||||
if (this.options.targetValue) d20.options.target = this.options.targetValue;
|
||||
|
||||
// Re-compile the underlying formula
|
||||
this._formula = this.constructor.getFormula(this.terms);
|
||||
|
@ -101,22 +99,21 @@ export default class D20Roll extends Roll {
|
|||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async toMessage(messageData={}, options={}) {
|
||||
|
||||
async toMessage(messageData = {}, options = {}) {
|
||||
// Evaluate the roll now so we have the results available to determine whether reliable talent came into play
|
||||
if ( !this._evaluated ) await this.evaluate({async: true});
|
||||
if (!this._evaluated) await this.evaluate({async: true});
|
||||
|
||||
// Add appropriate advantage mode message flavor and sw5e roll flags
|
||||
messageData.flavor = messageData.flavor || this.options.flavor;
|
||||
if ( this.hasAdvantage ) messageData.flavor += ` (${game.i18n.localize("SW5E.Advantage")})`;
|
||||
else if ( this.hasDisadvantage ) messageData.flavor += ` (${game.i18n.localize("SW5E.Disadvantage")})`;
|
||||
if (this.hasAdvantage) messageData.flavor += ` (${game.i18n.localize("SW5E.Advantage")})`;
|
||||
else if (this.hasDisadvantage) messageData.flavor += ` (${game.i18n.localize("SW5E.Disadvantage")})`;
|
||||
|
||||
// Add reliable talent to the d20-term flavor text if it applied
|
||||
if ( this.options.reliableTalent ) {
|
||||
if (this.options.reliableTalent) {
|
||||
const d20 = this.dice[0];
|
||||
const isRT = d20.results.every(r => !r.active || (r.result < 10));
|
||||
const isRT = d20.results.every((r) => !r.active || r.result < 10);
|
||||
const label = `(${game.i18n.localize("SW5E.FlagsReliableTalent")})`;
|
||||
if ( isRT ) d20.options.flavor = d20.options.flavor ? `${d20.options.flavor} (${label})` : label;
|
||||
if (isRT) d20.options.flavor = d20.options.flavor ? `${d20.options.flavor} (${label})` : label;
|
||||
}
|
||||
|
||||
// Record the preferred rollMode
|
||||
|
@ -140,8 +137,17 @@ export default class D20Roll extends Roll {
|
|||
* @param {object} options Additional Dialog customization options
|
||||
* @returns {Promise<D20Roll|null>} A resulting D20Roll object constructed with the dialog, or null if the dialog was closed
|
||||
*/
|
||||
async configureDialog({title, defaultRollMode, defaultAction=D20Roll.ADV_MODE.NORMAL, chooseModifier=false, defaultAbility, template}={}, options={}) {
|
||||
|
||||
async configureDialog(
|
||||
{
|
||||
title,
|
||||
defaultRollMode,
|
||||
defaultAction = D20Roll.ADV_MODE.NORMAL,
|
||||
chooseModifier = false,
|
||||
defaultAbility,
|
||||
template
|
||||
} = {},
|
||||
options = {}
|
||||
) {
|
||||
// Render the Dialog inner HTML
|
||||
const content = await renderTemplate(template ?? this.constructor.EVALUATION_TEMPLATE, {
|
||||
formula: `${this.formula} + @bonus`,
|
||||
|
@ -154,32 +160,39 @@ export default class D20Roll extends Roll {
|
|||
|
||||
let defaultButton = "normal";
|
||||
switch (defaultAction) {
|
||||
case D20Roll.ADV_MODE.ADVANTAGE: defaultButton = "advantage"; break;
|
||||
case D20Roll.ADV_MODE.DISADVANTAGE: defaultButton = "disadvantage"; break;
|
||||
case D20Roll.ADV_MODE.ADVANTAGE:
|
||||
defaultButton = "advantage";
|
||||
break;
|
||||
case D20Roll.ADV_MODE.DISADVANTAGE:
|
||||
defaultButton = "disadvantage";
|
||||
break;
|
||||
}
|
||||
|
||||
// Create the Dialog window and await submission of the form
|
||||
return new Promise(resolve => {
|
||||
new Dialog({
|
||||
title,
|
||||
content,
|
||||
buttons: {
|
||||
advantage: {
|
||||
label: game.i18n.localize("SW5E.Advantage"),
|
||||
callback: html => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.ADVANTAGE))
|
||||
return new Promise((resolve) => {
|
||||
new Dialog(
|
||||
{
|
||||
title,
|
||||
content,
|
||||
buttons: {
|
||||
advantage: {
|
||||
label: game.i18n.localize("SW5E.Advantage"),
|
||||
callback: (html) => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.ADVANTAGE))
|
||||
},
|
||||
normal: {
|
||||
label: game.i18n.localize("SW5E.Normal"),
|
||||
callback: (html) => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.NORMAL))
|
||||
},
|
||||
disadvantage: {
|
||||
label: game.i18n.localize("SW5E.Disadvantage"),
|
||||
callback: (html) => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.DISADVANTAGE))
|
||||
}
|
||||
},
|
||||
normal: {
|
||||
label: game.i18n.localize("SW5E.Normal"),
|
||||
callback: html => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.NORMAL))
|
||||
},
|
||||
disadvantage: {
|
||||
label: game.i18n.localize("SW5E.Disadvantage"),
|
||||
callback: html => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.DISADVANTAGE))
|
||||
}
|
||||
default: defaultButton,
|
||||
close: () => resolve(null)
|
||||
},
|
||||
default: defaultButton,
|
||||
close: () => resolve(null)
|
||||
}, options).render(true);
|
||||
options
|
||||
).render(true);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -195,16 +208,16 @@ export default class D20Roll extends Roll {
|
|||
const form = html[0].querySelector("form");
|
||||
|
||||
// Append a situational bonus term
|
||||
if ( form.bonus.value ) {
|
||||
if (form.bonus.value) {
|
||||
const bonus = new Roll(form.bonus.value, this.data);
|
||||
if ( !(bonus.terms[0] instanceof OperatorTerm) ) this.terms.push(new OperatorTerm({operator: "+"}));
|
||||
if (!(bonus.terms[0] instanceof OperatorTerm)) this.terms.push(new OperatorTerm({operator: "+"}));
|
||||
this.terms = this.terms.concat(bonus.terms);
|
||||
}
|
||||
|
||||
// Customize the modifier
|
||||
if ( form.ability?.value ) {
|
||||
if (form.ability?.value) {
|
||||
const abl = this.data.abilities[form.ability.value];
|
||||
this.terms.findSplice(t => t.term === "@mod", new NumericTerm({number: abl.mod}));
|
||||
this.terms.findSplice((t) => t.term === "@mod", new NumericTerm({number: abl.mod}));
|
||||
this.options.flavor += ` (${CONFIG.SW5E.abilities[form.ability.value]})`;
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ export default class DamageRoll extends Roll {
|
|||
constructor(formula, data, options) {
|
||||
super(formula, data, options);
|
||||
// For backwards compatibility, skip rolls which do not have the "critical" option defined
|
||||
if ( this.options.critical !== undefined ) this.configureDamage();
|
||||
if (this.options.critical !== undefined) this.configureDamage();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -42,44 +42,44 @@ export default class DamageRoll extends Roll {
|
|||
*/
|
||||
configureDamage() {
|
||||
let flatBonus = 0;
|
||||
for ( let [i, term] of this.terms.entries() ) {
|
||||
|
||||
for (let [i, term] of this.terms.entries()) {
|
||||
// Multiply dice terms
|
||||
if ( term instanceof DiceTerm ) {
|
||||
if (term instanceof DiceTerm) {
|
||||
term.options.baseNumber = term.options.baseNumber ?? term.number; // Reset back
|
||||
term.number = term.options.baseNumber;
|
||||
if ( this.isCritical ) {
|
||||
if (this.isCritical) {
|
||||
let cm = this.options.criticalMultiplier ?? 2;
|
||||
|
||||
// Powerful critical - maximize damage and reduce the multiplier by 1
|
||||
if ( this.options.powerfulCritical ) {
|
||||
flatBonus += (term.number * term.faces);
|
||||
cm = Math.max(1, cm-1);
|
||||
if (this.options.powerfulCritical) {
|
||||
flatBonus += term.number * term.faces;
|
||||
cm = Math.max(1, cm - 1);
|
||||
}
|
||||
|
||||
// Alter the damage term
|
||||
let cb = (this.options.criticalBonusDice && (i === 0)) ? this.options.criticalBonusDice : 0;
|
||||
let cb = this.options.criticalBonusDice && i === 0 ? this.options.criticalBonusDice : 0;
|
||||
term.alter(cm, cb);
|
||||
term.options.critical = true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Multiply numeric terms
|
||||
else if ( this.options.multiplyNumeric && (term instanceof NumericTerm) ) {
|
||||
else if (this.options.multiplyNumeric && term instanceof NumericTerm) {
|
||||
term.options.baseNumber = term.options.baseNumber ?? term.number; // Reset back
|
||||
term.number = term.options.baseNumber;
|
||||
if ( this.isCritical ) {
|
||||
term.number *= (this.options.criticalMultiplier ?? 2);
|
||||
if (this.isCritical) {
|
||||
term.number *= this.options.criticalMultiplier ?? 2;
|
||||
term.options.critical = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add powerful critical bonus
|
||||
if ( this.options.powerfulCritical && (flatBonus > 0) ) {
|
||||
if (this.options.powerfulCritical && flatBonus > 0) {
|
||||
this.terms.push(new OperatorTerm({operator: "+"}));
|
||||
this.terms.push(new NumericTerm({number: flatBonus}, {flavor: game.i18n.localize("SW5E.PowerfulCritical")}));
|
||||
this.terms.push(
|
||||
new NumericTerm({number: flatBonus}, {flavor: game.i18n.localize("SW5E.PowerfulCritical")})
|
||||
);
|
||||
}
|
||||
|
||||
// Re-compile the underlying formula
|
||||
|
@ -89,9 +89,9 @@ export default class DamageRoll extends Roll {
|
|||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
toMessage(messageData={}, options={}) {
|
||||
toMessage(messageData = {}, options = {}) {
|
||||
messageData.flavor = messageData.flavor || this.options.flavor;
|
||||
if ( this.isCritical ) {
|
||||
if (this.isCritical) {
|
||||
const label = game.i18n.localize("SW5E.CriticalHit");
|
||||
messageData.flavor = messageData.flavor ? `${messageData.flavor} (${label})` : label;
|
||||
}
|
||||
|
@ -114,34 +114,39 @@ export default class DamageRoll extends Roll {
|
|||
* @param {object} options Additional Dialog customization options
|
||||
* @returns {Promise<D20Roll|null>} A resulting D20Roll object constructed with the dialog, or null if the dialog was closed
|
||||
*/
|
||||
async configureDialog({title, defaultRollMode, defaultCritical=false, template, allowCritical=true}={}, options={}) {
|
||||
|
||||
async configureDialog(
|
||||
{title, defaultRollMode, defaultCritical = false, template, allowCritical = true} = {},
|
||||
options = {}
|
||||
) {
|
||||
// Render the Dialog inner HTML
|
||||
const content = await renderTemplate(template ?? this.constructor.EVALUATION_TEMPLATE, {
|
||||
formula: `${this.formula} + @bonus`,
|
||||
defaultRollMode,
|
||||
rollModes: CONFIG.Dice.rollModes,
|
||||
rollModes: CONFIG.Dice.rollModes
|
||||
});
|
||||
|
||||
// Create the Dialog window and await submission of the form
|
||||
return new Promise(resolve => {
|
||||
new Dialog({
|
||||
title,
|
||||
content,
|
||||
buttons: {
|
||||
critical: {
|
||||
condition: allowCritical,
|
||||
label: game.i18n.localize("SW5E.CriticalHit"),
|
||||
callback: html => resolve(this._onDialogSubmit(html, true))
|
||||
return new Promise((resolve) => {
|
||||
new Dialog(
|
||||
{
|
||||
title,
|
||||
content,
|
||||
buttons: {
|
||||
critical: {
|
||||
condition: allowCritical,
|
||||
label: game.i18n.localize("SW5E.CriticalHit"),
|
||||
callback: (html) => resolve(this._onDialogSubmit(html, true))
|
||||
},
|
||||
normal: {
|
||||
label: game.i18n.localize(allowCritical ? "SW5E.Normal" : "SW5E.Roll"),
|
||||
callback: (html) => resolve(this._onDialogSubmit(html, false))
|
||||
}
|
||||
},
|
||||
normal: {
|
||||
label: game.i18n.localize(allowCritical ? "SW5E.Normal" : "SW5E.Roll"),
|
||||
callback: html => resolve(this._onDialogSubmit(html, false))
|
||||
}
|
||||
default: defaultCritical ? "critical" : "normal",
|
||||
close: () => resolve(null)
|
||||
},
|
||||
default: defaultCritical ? "critical" : "normal",
|
||||
close: () => resolve(null)
|
||||
}, options).render(true);
|
||||
options
|
||||
).render(true);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -157,9 +162,9 @@ export default class DamageRoll extends Roll {
|
|||
const form = html[0].querySelector("form");
|
||||
|
||||
// Append a situational bonus term
|
||||
if ( form.bonus.value ) {
|
||||
if (form.bonus.value) {
|
||||
const bonus = new Roll(form.bonus.value, this.data);
|
||||
if ( !(bonus.terms[0] instanceof OperatorTerm) ) this.terms.push(new OperatorTerm({operator: "+"}));
|
||||
if (!(bonus.terms[0] instanceof OperatorTerm)) this.terms.push(new OperatorTerm({operator: "+"}));
|
||||
this.terms = this.terms.concat(bonus.terms);
|
||||
}
|
||||
|
64
module/effects.js
vendored
Normal file
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Manage Active Effect instances through the Actor Sheet via effect control buttons.
|
||||
* @param {MouseEvent} event The left-click event on the effect control
|
||||
* @param {Actor|Item} owner The owning entity which manages this effect
|
||||
*/
|
||||
export function onManageActiveEffect(event, owner) {
|
||||
event.preventDefault();
|
||||
const a = event.currentTarget;
|
||||
const li = a.closest("li");
|
||||
const effect = li.dataset.effectId ? owner.effects.get(li.dataset.effectId) : null;
|
||||
switch (a.dataset.action) {
|
||||
case "create":
|
||||
return owner.createEmbeddedDocuments("ActiveEffect", [
|
||||
{
|
||||
"label": game.i18n.localize("SW5E.EffectNew"),
|
||||
"icon": "icons/svg/aura.svg",
|
||||
"origin": owner.uuid,
|
||||
"duration.rounds": li.dataset.effectType === "temporary" ? 1 : undefined,
|
||||
"disabled": li.dataset.effectType === "inactive"
|
||||
}
|
||||
]);
|
||||
case "edit":
|
||||
return effect.sheet.render(true);
|
||||
case "delete":
|
||||
return effect.delete();
|
||||
case "toggle":
|
||||
return effect.update({disabled: !effect.data.disabled});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data structure for Active Effects which are currently applied to an Actor or Item.
|
||||
* @param {ActiveEffect[]} effects The array of Active Effect instances to prepare sheet data for
|
||||
* @return {object} Data for rendering
|
||||
*/
|
||||
export function prepareActiveEffectCategories(effects) {
|
||||
// Define effect header categories
|
||||
const categories = {
|
||||
temporary: {
|
||||
type: "temporary",
|
||||
label: game.i18n.localize("SW5E.EffectTemporary"),
|
||||
effects: []
|
||||
},
|
||||
passive: {
|
||||
type: "passive",
|
||||
label: game.i18n.localize("SW5E.EffectPassive"),
|
||||
effects: []
|
||||
},
|
||||
inactive: {
|
||||
type: "inactive",
|
||||
label: game.i18n.localize("SW5E.EffectInactive"),
|
||||
effects: []
|
||||
}
|
||||
};
|
||||
|
||||
// Iterate over active effects, classifying them into categories
|
||||
for (let e of effects) {
|
||||
e._getSourceName(); // Trigger a lookup for the source name
|
||||
if (e.data.disabled) categories.inactive.effects.push(e);
|
||||
else if (e.isTemporary) categories.temporary.effects.push(e);
|
||||
else categories.passive.effects.push(e);
|
||||
}
|
||||
return categories;
|
||||
}
|
1671
module/item/entity.js
Normal file
370
module/item/sheet.js
Normal file
|
@ -0,0 +1,370 @@
|
|||
import TraitSelector from "../apps/trait-selector.js";
|
||||
import {onManageActiveEffect, prepareActiveEffectCategories} from "../effects.js";
|
||||
|
||||
/**
|
||||
* Override and extend the core ItemSheet implementation to handle specific item types
|
||||
* @extends {ItemSheet}
|
||||
*/
|
||||
export default class ItemSheet5e extends ItemSheet {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
// Expand the default size of the class sheet
|
||||
if (this.object.data.type === "class") {
|
||||
this.options.width = this.position.width = 600;
|
||||
this.options.height = this.position.height = 680;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
width: 560,
|
||||
height: 400,
|
||||
classes: ["sw5e", "sheet", "item"],
|
||||
resizable: true,
|
||||
scrollY: [".tab.details"],
|
||||
tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get template() {
|
||||
const path = "systems/sw5e/templates/items/";
|
||||
return `${path}/${this.item.data.type}.html`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData(options) {
|
||||
const data = super.getData(options);
|
||||
const itemData = data.data;
|
||||
data.labels = this.item.labels;
|
||||
data.config = CONFIG.SW5E;
|
||||
|
||||
// Item Type, Status, and Details
|
||||
data.itemType = game.i18n.localize(`ITEM.Type${data.item.type.titleCase()}`);
|
||||
data.itemStatus = this._getItemStatus(itemData);
|
||||
data.itemProperties = this._getItemProperties(itemData);
|
||||
data.isPhysical = itemData.data.hasOwnProperty("quantity");
|
||||
|
||||
// Potential consumption targets
|
||||
data.abilityConsumptionTargets = this._getItemConsumptionTargets(itemData);
|
||||
|
||||
// Action Details
|
||||
data.hasAttackRoll = this.item.hasAttack;
|
||||
data.isHealing = itemData.data.actionType === "heal";
|
||||
data.isFlatDC = getProperty(itemData, "data.save.scaling") === "flat";
|
||||
data.isLine = ["line", "wall"].includes(itemData.data.target?.type);
|
||||
|
||||
// Original maximum uses formula
|
||||
const sourceMax = foundry.utils.getProperty(this.item.data._source, "data.uses.max");
|
||||
if (sourceMax) itemData.data.uses.max = sourceMax;
|
||||
|
||||
// Vehicles
|
||||
data.isCrewed = itemData.data.activation?.type === "crew";
|
||||
data.isMountable = this._isItemMountable(itemData);
|
||||
|
||||
// Prepare Active Effects
|
||||
data.effects = prepareActiveEffectCategories(this.item.effects);
|
||||
|
||||
// Re-define the template data references (backwards compatible)
|
||||
data.item = itemData;
|
||||
data.data = itemData.data;
|
||||
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;
|
||||
},
|
||||
{[item._id]: `${item.name} (${item.data.quantity})`}
|
||||
);
|
||||
}
|
||||
|
||||
// Attributes
|
||||
else if (consume.type === "attribute") {
|
||||
const attributes = TokenDocument.getTrackedAttributes(actor.data.data);
|
||||
attributes.bar.forEach((a) => a.push("value"));
|
||||
return attributes.bar.concat(attributes.value).reduce((obj, a) => {
|
||||
let k = a.join(".");
|
||||
obj[k] = k;
|
||||
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) => {
|
||||
// Limited-use items
|
||||
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;
|
||||
}
|
||||
|
||||
// Recharging items
|
||||
const recharge = i.data.data.recharge || {};
|
||||
if (recharge.value) obj[i.id] = `${i.name} (${game.i18n.format("SW5E.Recharge")})`;
|
||||
return obj;
|
||||
}, {});
|
||||
} else return {};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the text item status which is shown beneath the Item type in the top-right corner of the sheet
|
||||
* @return {string}
|
||||
* @private
|
||||
*/
|
||||
_getItemStatus(item) {
|
||||
if (item.type === "power") {
|
||||
return CONFIG.SW5E.powerPreparationModes[item.data.preparation];
|
||||
} else if (["weapon", "equipment"].includes(item.type)) {
|
||||
return game.i18n.localize(item.data.equipped ? "SW5E.Equipped" : "SW5E.Unequipped");
|
||||
} else if (item.type === "tool") {
|
||||
return game.i18n.localize(item.data.proficient ? "SW5E.Proficient" : "SW5E.NotProficient");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the Array of item properties which are used in the small sidebar of the description tab
|
||||
* @return {Array}
|
||||
* @private
|
||||
*/
|
||||
_getItemProperties(item) {
|
||||
const props = [];
|
||||
const labels = this.item.labels;
|
||||
|
||||
if (item.type === "weapon") {
|
||||
props.push(
|
||||
...Object.entries(item.data.properties)
|
||||
.filter((e) => e[1] === true)
|
||||
.map((e) => CONFIG.SW5E.weaponProperties[e[0]])
|
||||
);
|
||||
} else if (item.type === "power") {
|
||||
props.push(
|
||||
labels.materials,
|
||||
item.data.components.concentration ? game.i18n.localize("SW5E.Concentration") : null,
|
||||
item.data.components.ritual ? game.i18n.localize("SW5E.Ritual") : null
|
||||
);
|
||||
} else if (item.type === "equipment") {
|
||||
props.push(CONFIG.SW5E.equipmentTypes[item.data.armor.type]);
|
||||
props.push(labels.armor);
|
||||
} else if (item.type === "feat") {
|
||||
props.push(labels.featType);
|
||||
//TODO: Work out these
|
||||
} else if (item.type === "species") {
|
||||
//props.push(labels.species);
|
||||
} else if (item.type === "archetype") {
|
||||
//props.push(labels.archetype);
|
||||
} else if (item.type === "background") {
|
||||
//props.push(labels.background);
|
||||
} else if (item.type === "classfeature") {
|
||||
//props.push(labels.classfeature);
|
||||
} else if (item.type === "deployment") {
|
||||
//props.push(labels.deployment);
|
||||
} else if (item.type === "venture") {
|
||||
//props.push(labels.venture);
|
||||
} else if (item.type === "fightingmastery") {
|
||||
//props.push(labels.fightingmastery);
|
||||
} else if (item.type === "fightingstyle") {
|
||||
//props.push(labels.fightingstyle);
|
||||
} else if (item.type === "lightsaberform") {
|
||||
//props.push(labels.lightsaberform);
|
||||
}
|
||||
|
||||
// Action type
|
||||
if (item.data.actionType) {
|
||||
props.push(CONFIG.SW5E.itemActionTypes[item.data.actionType]);
|
||||
}
|
||||
|
||||
// Action usage
|
||||
if (item.type !== "weapon" && item.data.activation && !isObjectEmpty(item.data.activation)) {
|
||||
props.push(labels.activation, labels.range, labels.target, labels.duration);
|
||||
}
|
||||
return props.filter((p) => !!p);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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")
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
setPosition(position = {}) {
|
||||
if (!(this._minimized || position.height)) {
|
||||
position.height = this._tabs[0].active === "details" ? "auto" : this.options.height;
|
||||
}
|
||||
return super.setPosition(position);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Form Submission */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_getSubmitData(updateData = {}) {
|
||||
// Create the expanded update data object
|
||||
const fd = new FormDataExtended(this.form, {editors: this.editors});
|
||||
let data = fd.toObject();
|
||||
if (updateData) data = mergeObject(data, updateData);
|
||||
else data = expandObject(data);
|
||||
|
||||
// Handle Damage array
|
||||
const damage = data.data?.damage;
|
||||
if (damage) damage.parts = Object.values(damage?.parts || {}).map((d) => [d[0] || "", d[1] || ""]);
|
||||
|
||||
// Return the flattened submission data
|
||||
return flattenObject(data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
if (this.isEditable) {
|
||||
html.find(".damage-control").click(this._onDamageControl.bind(this));
|
||||
html.find(".trait-selector.class-skills").click(this._onConfigureTraits.bind(this));
|
||||
html.find(".effect-control").click((ev) => {
|
||||
if (this.item.isOwned)
|
||||
return ui.notifications.warn(
|
||||
"Managing Active Effects within an Owned Item is not currently supported and will be added in a subsequent update."
|
||||
);
|
||||
onManageActiveEffect(ev, this.item);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add or remove a damage part from the damage formula
|
||||
* @param {Event} event The original click event
|
||||
* @return {Promise}
|
||||
* @private
|
||||
*/
|
||||
async _onDamageControl(event) {
|
||||
event.preventDefault();
|
||||
const a = event.currentTarget;
|
||||
|
||||
// Add new damage component
|
||||
if (a.classList.contains("add-damage")) {
|
||||
await this._onSubmit(event); // Submit any unsaved changes
|
||||
const damage = this.item.data.data.damage;
|
||||
return this.item.update({"data.damage.parts": damage.parts.concat([["", ""]])});
|
||||
}
|
||||
|
||||
// Remove a damage component
|
||||
if (a.classList.contains("delete-damage")) {
|
||||
await this._onSubmit(event); // Submit any unsaved changes
|
||||
const li = a.closest(".damage-part");
|
||||
const damage = foundry.utils.deepClone(this.item.data.data.damage);
|
||||
damage.parts.splice(Number(li.dataset.damagePart), 1);
|
||||
return this.item.update({"data.damage.parts": damage.parts});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle spawning the TraitSelector application for selection various options.
|
||||
* @param {Event} event The click event which originated the selection
|
||||
* @private
|
||||
*/
|
||||
_onConfigureTraits(event) {
|
||||
event.preventDefault();
|
||||
const a = event.currentTarget;
|
||||
|
||||
const options = {
|
||||
name: a.dataset.target,
|
||||
title: a.parentElement.innerText,
|
||||
choices: [],
|
||||
allowCustom: false
|
||||
};
|
||||
|
||||
switch (a.dataset.options) {
|
||||
case "saves":
|
||||
options.choices = CONFIG.SW5E.abilities;
|
||||
options.valueKey = null;
|
||||
break;
|
||||
case "skills":
|
||||
const skills = this.item.data.data.skills;
|
||||
const choiceSet =
|
||||
skills.choices && skills.choices.length ? skills.choices : Object.keys(CONFIG.SW5E.skills);
|
||||
options.choices = Object.fromEntries(
|
||||
Object.entries(CONFIG.SW5E.skills).filter((skill) => choiceSet.includes(skill[0]))
|
||||
);
|
||||
options.maximum = skills.number;
|
||||
break;
|
||||
}
|
||||
new TraitSelector(this.item, options).render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _onSubmit(...args) {
|
||||
if (this._tabs[0].active === "details") this.position.height = "auto";
|
||||
await super._onSubmit(...args);
|
||||
}
|
||||
}
|
60
module/macros.js
Normal 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
|
||||
return item.roll();
|
||||
}
|
710
module/migration.js
Normal file
|
@ -0,0 +1,710 @@
|
|||
/**
|
||||
* Perform a system migration for the entire World, applying migrations for Actors, Items, and Compendium packs
|
||||
* @return {Promise} A Promise which resolves once the migration is completed
|
||||
*/
|
||||
export const migrateWorld = async function () {
|
||||
ui.notifications.info(
|
||||
`Applying SW5e System Migration for version ${game.system.data.version}. Please be patient and do not close your game or shut down your server.`,
|
||||
{permanent: true}
|
||||
);
|
||||
|
||||
// Migrate World Actors
|
||||
for await (let a of game.actors.contents) {
|
||||
try {
|
||||
console.log(`Checking Actor entity ${a.name} for migration needs`);
|
||||
const updateData = await migrateActorData(a.data);
|
||||
if (!foundry.utils.isObjectEmpty(updateData)) {
|
||||
console.log(`Migrating Actor entity ${a.name}`);
|
||||
await a.update(updateData, {enforceTypes: false});
|
||||
}
|
||||
} catch (err) {
|
||||
err.message = `Failed sw5e system migration for Actor ${a.name}: ${err.message}`;
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate World Items
|
||||
for (let i of game.items.contents) {
|
||||
try {
|
||||
const updateData = migrateItemData(i.toObject());
|
||||
if (!foundry.utils.isObjectEmpty(updateData)) {
|
||||
console.log(`Migrating Item entity ${i.name}`);
|
||||
await i.update(updateData, {enforceTypes: false});
|
||||
}
|
||||
} catch (err) {
|
||||
err.message = `Failed sw5e system migration for Item ${i.name}: ${err.message}`;
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate Actor Override Tokens
|
||||
for (let s of game.scenes.contents) {
|
||||
try {
|
||||
const updateData = await migrateSceneData(s.data);
|
||||
if (!foundry.utils.isObjectEmpty(updateData)) {
|
||||
console.log(`Migrating Scene entity ${s.name}`);
|
||||
await s.update(updateData, {enforceTypes: false});
|
||||
// If we do not do this, then synthetic token actors remain in cache
|
||||
// with the un-updated actorData.
|
||||
s.tokens.contents.forEach((t) => (t._actor = null));
|
||||
}
|
||||
} catch (err) {
|
||||
err.message = `Failed sw5e system migration for Scene ${s.name}: ${err.message}`;
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate World Compendium Packs
|
||||
for (let p of game.packs) {
|
||||
if (p.metadata.package !== "world") continue;
|
||||
if (!["Actor", "Item", "Scene"].includes(p.metadata.entity)) continue;
|
||||
await migrateCompendium(p);
|
||||
}
|
||||
|
||||
// Set the migration as complete
|
||||
game.settings.set("sw5e", "systemMigrationVersion", game.system.data.version);
|
||||
ui.notifications.info(`SW5e System Migration to version ${game.system.data.version} completed!`, {permanent: true});
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply migration rules to all Entities within a single Compendium pack
|
||||
* @param pack
|
||||
* @return {Promise}
|
||||
*/
|
||||
export const migrateCompendium = async function (pack) {
|
||||
const entity = pack.metadata.entity;
|
||||
if (!["Actor", "Item", "Scene"].includes(entity)) return;
|
||||
|
||||
// Unlock the pack for editing
|
||||
const wasLocked = pack.locked;
|
||||
await pack.configure({locked: false});
|
||||
|
||||
// Begin by requesting server-side data model migration and get the migrated content
|
||||
await pack.migrate();
|
||||
const documents = await pack.getDocuments();
|
||||
|
||||
// Iterate over compendium entries - applying fine-tuned migration functions
|
||||
for await (let doc of documents) {
|
||||
let updateData = {};
|
||||
try {
|
||||
switch (entity) {
|
||||
case "Actor":
|
||||
updateData = await migrateActorData(doc.data);
|
||||
break;
|
||||
case "Item":
|
||||
updateData = migrateItemData(doc.toObject());
|
||||
break;
|
||||
case "Scene":
|
||||
updateData = await migrateSceneData(doc.data);
|
||||
break;
|
||||
}
|
||||
if (foundry.utils.isObjectEmpty(updateData)) continue;
|
||||
|
||||
// Save the entry, if data was changed
|
||||
await doc.update(updateData);
|
||||
console.log(`Migrated ${entity} entity ${doc.name} in Compendium ${pack.collection}`);
|
||||
} catch (err) {
|
||||
// Handle migration failures
|
||||
err.message = `Failed sw5e system migration for entity ${doc.name} in pack ${pack.collection}: ${err.message}`;
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the original locked status for the pack
|
||||
await pack.configure({locked: wasLocked});
|
||||
console.log(`Migrated all ${entity} entities from Compendium ${pack.collection}`);
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Entity Type Migration Helpers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Migrate a single Actor entity to incorporate latest data model changes
|
||||
* Return an Object of updateData to be applied
|
||||
* @param {object} actor The actor data object to update
|
||||
* @return {Object} The updateData to apply
|
||||
*/
|
||||
export const migrateActorData = async function (actor) {
|
||||
const updateData = {};
|
||||
|
||||
// Actor Data Updates
|
||||
if (actor.data) {
|
||||
_migrateActorMovement(actor, updateData);
|
||||
_migrateActorSenses(actor, updateData);
|
||||
_migrateActorType(actor, updateData);
|
||||
}
|
||||
|
||||
// Migrate Owned Items
|
||||
if (!!actor.items) {
|
||||
const items = await actor.items.reduce(async (memo, i) => {
|
||||
const results = await memo;
|
||||
|
||||
// Migrate the Owned Item
|
||||
const itemData = i instanceof CONFIG.Item.documentClass ? i.toObject() : i;
|
||||
let itemUpdate = await migrateActorItemData(itemData, actor);
|
||||
|
||||
// Prepared, Equipped, and Proficient for NPC actors
|
||||
if (actor.type === "npc") {
|
||||
if (getProperty(itemData.data, "preparation.prepared") === false)
|
||||
itemUpdate["data.preparation.prepared"] = true;
|
||||
if (getProperty(itemData.data, "equipped") === false) itemUpdate["data.equipped"] = true;
|
||||
if (getProperty(itemData.data, "proficient") === false) itemUpdate["data.proficient"] = true;
|
||||
}
|
||||
|
||||
// Update the Owned Item
|
||||
if (!isObjectEmpty(itemUpdate)) {
|
||||
itemUpdate._id = itemData._id;
|
||||
console.log(`Migrating Actor ${actor.name}'s ${i.name}`);
|
||||
results.push(expandObject(itemUpdate));
|
||||
}
|
||||
|
||||
return results;
|
||||
}, []);
|
||||
|
||||
if (items.length > 0) updateData.items = items;
|
||||
}
|
||||
|
||||
// Update NPC data with new datamodel information
|
||||
if (actor.type === "npc") {
|
||||
_updateNPCData(actor);
|
||||
}
|
||||
|
||||
// migrate powers last since it relies on item classes being migrated first.
|
||||
_migrateActorPowers(actor, updateData);
|
||||
|
||||
return updateData;
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Scrub an Actor's system data, removing all keys which are not explicitly defined in the system template
|
||||
* @param {Object} actorData The data object for an Actor
|
||||
* @return {Object} The scrubbed Actor data
|
||||
*/
|
||||
function cleanActorData(actorData) {
|
||||
// Scrub system data
|
||||
const model = game.system.model.Actor[actorData.type];
|
||||
actorData.data = filterObject(actorData.data, model);
|
||||
|
||||
// Scrub system flags
|
||||
const allowedFlags = CONFIG.SW5E.allowedActorFlags.reduce((obj, f) => {
|
||||
obj[f] = null;
|
||||
return obj;
|
||||
}, {});
|
||||
if (actorData.flags.sw5e) {
|
||||
actorData.flags.sw5e = filterObject(actorData.flags.sw5e, allowedFlags);
|
||||
}
|
||||
|
||||
// Return the scrubbed data
|
||||
return actorData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Migrate a single Item entity to incorporate latest data model changes
|
||||
*
|
||||
* @param {object} item Item data to migrate
|
||||
* @return {object} The updateData to apply
|
||||
*/
|
||||
export const migrateItemData = function (item) {
|
||||
const updateData = {};
|
||||
_migrateItemClassPowerCasting(item, updateData);
|
||||
_migrateItemAttunement(item, updateData);
|
||||
return updateData;
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Migrate a single owned actor Item entity to incorporate latest data model changes
|
||||
* @param item
|
||||
* @param actor
|
||||
*/
|
||||
export const migrateActorItemData = async function (item, actor) {
|
||||
const updateData = {};
|
||||
_migrateItemClassPowerCasting(item, updateData);
|
||||
_migrateItemAttunement(item, updateData);
|
||||
await _migrateItemPower(item, actor, updateData);
|
||||
return updateData;
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Migrate a single Scene entity to incorporate changes to the data model of it's actor data overrides
|
||||
* Return an Object of updateData to be applied
|
||||
* @param {Object} scene The Scene data to Update
|
||||
* @return {Object} The updateData to apply
|
||||
*/
|
||||
export const migrateSceneData = async function (scene) {
|
||||
const tokens = await Promise.all(
|
||||
scene.tokens.map(async (token) => {
|
||||
const t = token.toJSON();
|
||||
if (!t.actorId || t.actorLink) {
|
||||
t.actorData = {};
|
||||
} else if (!game.actors.has(t.actorId)) {
|
||||
t.actorId = null;
|
||||
t.actorData = {};
|
||||
} else if (!t.actorLink) {
|
||||
const actorData = duplicate(t.actorData);
|
||||
actorData.type = token.actor?.type;
|
||||
const update = migrateActorData(actorData);
|
||||
["items", "effects"].forEach((embeddedName) => {
|
||||
if (!update[embeddedName]?.length) return;
|
||||
const updates = new Map(update[embeddedName].map((u) => [u._id, u]));
|
||||
t.actorData[embeddedName].forEach((original) => {
|
||||
const update = updates.get(original._id);
|
||||
if (update) mergeObject(original, update);
|
||||
});
|
||||
delete update[embeddedName];
|
||||
});
|
||||
|
||||
mergeObject(t.actorData, update);
|
||||
}
|
||||
return t;
|
||||
})
|
||||
);
|
||||
return {tokens};
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Low level migration utilities
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update an NPC Actor's data based on compendium
|
||||
* @param {Object} actor The data object for an Actor
|
||||
* @return {Object} The updated Actor
|
||||
*/
|
||||
function _updateNPCData(actor) {
|
||||
let actorData = actor.data;
|
||||
const updateData = {};
|
||||
// check for flag.core, if not there is no compendium monster so exit
|
||||
const hasSource = actor?.flags?.core?.sourceId !== undefined;
|
||||
if (!hasSource) return actor;
|
||||
// shortcut out if dataVersion flag is set to 1.2.4 or higher
|
||||
const hasDataVersion = actor?.flags?.sw5e?.dataVersion !== undefined;
|
||||
if (
|
||||
hasDataVersion &&
|
||||
(actor.flags.sw5e.dataVersion === "1.2.4" || isNewerVersion("1.2.4", actor.flags.sw5e.dataVersion))
|
||||
)
|
||||
return actor;
|
||||
// Check to see what the source of NPC is
|
||||
const sourceId = actor.flags.core.sourceId;
|
||||
const coreSource = sourceId.substr(0, sourceId.length - 17);
|
||||
const core_id = sourceId.substr(sourceId.length - 16, 16);
|
||||
if (coreSource === "Compendium.sw5e.monsters") {
|
||||
game.packs
|
||||
.get("sw5e.monsters")
|
||||
.getEntity(core_id)
|
||||
.then((monster) => {
|
||||
const monsterData = monster.data.data;
|
||||
// copy movement[], senses[], powercasting, force[], tech[], powerForceLevel, powerTechLevel
|
||||
updateData["data.attributes.movement"] = monsterData.attributes.movement;
|
||||
updateData["data.attributes.senses"] = monsterData.attributes.senses;
|
||||
updateData["data.attributes.powercasting"] = monsterData.attributes.powercasting;
|
||||
updateData["data.attributes.force"] = monsterData.attributes.force;
|
||||
updateData["data.attributes.tech"] = monsterData.attributes.tech;
|
||||
updateData["data.details.powerForceLevel"] = monsterData.details.powerForceLevel;
|
||||
updateData["data.details.powerTechLevel"] = monsterData.details.powerTechLevel;
|
||||
// push missing powers onto actor
|
||||
let newPowers = [];
|
||||
for (let i of monster.items) {
|
||||
const itemData = i.data;
|
||||
if (itemData.type === "power") {
|
||||
const itemCompendium_id = itemData.flags?.core?.sourceId.split(".").slice(-1)[0];
|
||||
let hasPower = !!actor.items.find(
|
||||
(item) => item.flags?.core?.sourceId.split(".").slice(-1)[0] === itemCompendium_id
|
||||
);
|
||||
if (!hasPower) {
|
||||
// Clone power to new object. Don't know if it is technically needed, but seems to prevent some weirdness.
|
||||
const newPower = JSON.parse(JSON.stringify(itemData));
|
||||
|
||||
newPowers.push(newPower);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// get actor to create new powers
|
||||
const liveActor = game.actors.get(actor._id);
|
||||
// create the powers on the actor
|
||||
liveActor.createEmbeddedEntity("OwnedItem", newPowers);
|
||||
|
||||
// set flag to check to see if migration has been done so we don't do it again.
|
||||
liveActor.setFlag("sw5e", "dataVersion", "1.2.4");
|
||||
});
|
||||
}
|
||||
|
||||
//merge object
|
||||
actorData = mergeObject(actorData, updateData);
|
||||
// Return the scrubbed data
|
||||
return actor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate the actor speed string to movement object
|
||||
* @private
|
||||
*/
|
||||
function _migrateActorMovement(actorData, updateData) {
|
||||
const ad = actorData.data;
|
||||
|
||||
// Work is needed if old data is present
|
||||
const old = actorData.type === "vehicle" ? ad?.attributes?.speed : ad?.attributes?.speed?.value;
|
||||
const hasOld = old !== undefined;
|
||||
if (hasOld) {
|
||||
// If new data is not present, migrate the old data
|
||||
const hasNew = ad?.attributes?.movement?.walk !== undefined;
|
||||
if (!hasNew && typeof old === "string") {
|
||||
const s = (old || "").split(" ");
|
||||
if (s.length > 0)
|
||||
updateData["data.attributes.movement.walk"] = Number.isNumeric(s[0]) ? parseInt(s[0]) : null;
|
||||
}
|
||||
|
||||
// Remove the old attribute
|
||||
updateData["data.attributes.-=speed"] = null;
|
||||
}
|
||||
return updateData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Migrate the actor speed string to movement object
|
||||
* @private
|
||||
*/
|
||||
function _migrateActorPowers(actorData, updateData) {
|
||||
const ad = actorData.data;
|
||||
|
||||
// If new Force & Tech data is not present, create it
|
||||
let hasNewAttrib = ad?.attributes?.force?.level !== undefined;
|
||||
if (!hasNewAttrib) {
|
||||
updateData["data.attributes.force.known.value"] = 0;
|
||||
updateData["data.attributes.force.known.max"] = 0;
|
||||
updateData["data.attributes.force.points.value"] = 0;
|
||||
updateData["data.attributes.force.points.min"] = 0;
|
||||
updateData["data.attributes.force.points.max"] = 0;
|
||||
updateData["data.attributes.force.points.temp"] = null;
|
||||
updateData["data.attributes.force.points.tempmax"] = null;
|
||||
updateData["data.attributes.force.level"] = 0;
|
||||
updateData["data.attributes.tech.known.value"] = 0;
|
||||
updateData["data.attributes.tech.known.max"] = 0;
|
||||
updateData["data.attributes.tech.points.value"] = 0;
|
||||
updateData["data.attributes.tech.points.min"] = 0;
|
||||
updateData["data.attributes.tech.points.max"] = 0;
|
||||
updateData["data.attributes.tech.points.temp"] = null;
|
||||
updateData["data.attributes.tech.points.tempmax"] = null;
|
||||
updateData["data.attributes.tech.level"] = 0;
|
||||
}
|
||||
|
||||
// If new Power F/T split data is not present, create it
|
||||
const hasNewLimit = ad?.powers?.power1?.foverride !== undefined;
|
||||
if (!hasNewLimit) {
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
// add new
|
||||
updateData["data.powers.power" + i + ".fvalue"] = getProperty(ad.powers, "power" + i + ".value");
|
||||
updateData["data.powers.power" + i + ".fmax"] = getProperty(ad.powers, "power" + i + ".max");
|
||||
updateData["data.powers.power" + i + ".foverride"] = null;
|
||||
updateData["data.powers.power" + i + ".tvalue"] = getProperty(ad.powers, "power" + i + ".value");
|
||||
updateData["data.powers.power" + i + ".tmax"] = getProperty(ad.powers, "power" + i + ".max");
|
||||
updateData["data.powers.power" + i + ".toverride"] = null;
|
||||
//remove old
|
||||
updateData["data.powers.power" + i + ".-=value"] = null;
|
||||
updateData["data.powers.power" + i + ".-=override"] = null;
|
||||
}
|
||||
}
|
||||
// If new Bonus Power DC data is not present, create it
|
||||
const hasNewBonus = ad?.bonuses?.power?.forceLightDC !== undefined;
|
||||
if (!hasNewBonus) {
|
||||
updateData["data.bonuses.power.forceLightDC"] = "";
|
||||
updateData["data.bonuses.power.forceDarkDC"] = "";
|
||||
updateData["data.bonuses.power.forceUnivDC"] = "";
|
||||
updateData["data.bonuses.power.techDC"] = "";
|
||||
}
|
||||
|
||||
// Remove the Power DC Bonus
|
||||
updateData["data.bonuses.power.-=dc"] = null;
|
||||
|
||||
return updateData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Migrate the actor traits.senses string to attributes.senses object
|
||||
* @private
|
||||
*/
|
||||
function _migrateActorSenses(actor, updateData) {
|
||||
const ad = actor.data;
|
||||
if (ad?.traits?.senses === undefined) return;
|
||||
const original = ad.traits.senses || "";
|
||||
if (typeof original !== "string") return;
|
||||
|
||||
// Try to match old senses with the format like "Darkvision 60 ft, Blindsight 30 ft"
|
||||
const pattern = /([A-z]+)\s?([0-9]+)\s?([A-z]+)?/;
|
||||
let wasMatched = false;
|
||||
|
||||
// Match each comma-separated term
|
||||
for (let s of original.split(",")) {
|
||||
s = s.trim();
|
||||
const match = s.match(pattern);
|
||||
if (!match) continue;
|
||||
const type = match[1].toLowerCase();
|
||||
if (type in CONFIG.SW5E.senses) {
|
||||
updateData[`data.attributes.senses.${type}`] = Number(match[2]).toNearest(0.5);
|
||||
wasMatched = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing was matched, but there was an old string - put the whole thing in "special"
|
||||
if (!wasMatched && !!original) {
|
||||
updateData["data.attributes.senses.special"] = original;
|
||||
}
|
||||
|
||||
// Remove the old traits.senses string once the migration is complete
|
||||
updateData["data.traits.-=senses"] = null;
|
||||
return updateData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Migrate the actor details.type string to object
|
||||
* @private
|
||||
*/
|
||||
function _migrateActorType(actor, updateData) {
|
||||
const ad = actor.data;
|
||||
const original = ad.details?.type;
|
||||
if (typeof original !== "string") return;
|
||||
|
||||
// New default data structure
|
||||
let data = {
|
||||
value: "",
|
||||
subtype: "",
|
||||
swarm: "",
|
||||
custom: ""
|
||||
};
|
||||
|
||||
// Specifics
|
||||
// (Some of these have weird names, these need to be addressed individually)
|
||||
if (original === "force entity") {
|
||||
data.value = "force";
|
||||
data.subtype = "storm";
|
||||
} else if (original === "human") {
|
||||
data.value = "humanoid";
|
||||
data.subtype = "human";
|
||||
} else if (["humanoid (any)", "humanoid (Villainous"].includes(original)) {
|
||||
data.value = "humanoid";
|
||||
} else if (original === "tree") {
|
||||
data.value = "plant";
|
||||
data.subtype = "tree";
|
||||
} else if (original === "(humanoid) or Large (beast) force entity") {
|
||||
data.value = "force";
|
||||
} else if (original === "droid (appears human)") {
|
||||
data.value = "droid";
|
||||
} else {
|
||||
// Match the existing string
|
||||
const pattern = /^(?:swarm of (?<size>[\w\-]+) )?(?<type>[^(]+?)(?:\((?<subtype>[^)]+)\))?$/i;
|
||||
const match = original.trim().match(pattern);
|
||||
if (match) {
|
||||
// Match a known creature type
|
||||
const typeLc = match.groups.type.trim().toLowerCase();
|
||||
const typeMatch = Object.entries(CONFIG.SW5E.creatureTypes).find(([k, v]) => {
|
||||
return (
|
||||
typeLc === k ||
|
||||
typeLc === game.i18n.localize(v).toLowerCase() ||
|
||||
typeLc === game.i18n.localize(`${v}Pl`).toLowerCase()
|
||||
);
|
||||
});
|
||||
if (typeMatch) data.value = typeMatch[0];
|
||||
else {
|
||||
data.value = "custom";
|
||||
data.custom = match.groups.type.trim().titleCase();
|
||||
}
|
||||
data.subtype = match.groups.subtype?.trim().titleCase() || "";
|
||||
|
||||
// Match a swarm
|
||||
const isNamedSwarm = actor.name.startsWith(game.i18n.localize("SW5E.CreatureSwarm"));
|
||||
if (match.groups.size || isNamedSwarm) {
|
||||
const sizeLc = match.groups.size ? match.groups.size.trim().toLowerCase() : "tiny";
|
||||
const sizeMatch = Object.entries(CONFIG.SW5E.actorSizes).find(([k, v]) => {
|
||||
return sizeLc === k || sizeLc === game.i18n.localize(v).toLowerCase();
|
||||
});
|
||||
data.swarm = sizeMatch ? sizeMatch[0] : "tiny";
|
||||
} else data.swarm = "";
|
||||
}
|
||||
|
||||
// No match found
|
||||
else {
|
||||
data.value = "custom";
|
||||
data.custom = original;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the actor data
|
||||
updateData["data.details.type"] = data;
|
||||
return updateData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
function _migrateItemClassPowerCasting(item, updateData) {
|
||||
if (item.type === "class") {
|
||||
switch (item.name) {
|
||||
case "Consular":
|
||||
updateData["data.powercasting"] = {
|
||||
progression: "consular",
|
||||
ability: ""
|
||||
};
|
||||
break;
|
||||
case "Engineer":
|
||||
updateData["data.powercasting"] = {
|
||||
progression: "engineer",
|
||||
ability: ""
|
||||
};
|
||||
break;
|
||||
case "Guardian":
|
||||
updateData["data.powercasting"] = {
|
||||
progression: "guardian",
|
||||
ability: ""
|
||||
};
|
||||
break;
|
||||
case "Scout":
|
||||
updateData["data.powercasting"] = {
|
||||
progression: "scout",
|
||||
ability: ""
|
||||
};
|
||||
break;
|
||||
case "Sentinel":
|
||||
updateData["data.powercasting"] = {
|
||||
progression: "sentinel",
|
||||
ability: ""
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
return updateData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update an Power Item's data based on compendium
|
||||
* @param {Object} item The data object for an item
|
||||
* @param {Object} actor The data object for the actor owning the item
|
||||
* @private
|
||||
*/
|
||||
async function _migrateItemPower(item, actor, updateData) {
|
||||
// if item is not a power shortcut out
|
||||
if (item.type !== "power") return updateData;
|
||||
|
||||
console.log(`Checking Actor ${actor.name}'s ${item.name} for migration needs`);
|
||||
// check for flag.core, if not there is no compendium power so exit
|
||||
const hasSource = item?.flags?.core?.sourceId !== undefined;
|
||||
if (!hasSource) return updateData;
|
||||
|
||||
// shortcut out if dataVersion flag is set to 1.2.4 or higher
|
||||
const hasDataVersion = item?.flags?.sw5e?.dataVersion !== undefined;
|
||||
if (
|
||||
hasDataVersion &&
|
||||
(item.flags.sw5e.dataVersion === "1.2.4" || isNewerVersion("1.2.4", item.flags.sw5e.dataVersion))
|
||||
)
|
||||
return updateData;
|
||||
|
||||
// Check to see what the source of Power is
|
||||
const sourceId = item.flags.core.sourceId;
|
||||
const coreSource = sourceId.substr(0, sourceId.length - 17);
|
||||
const core_id = sourceId.substr(sourceId.length - 16, 16);
|
||||
|
||||
//if power type is not force or tech exit out
|
||||
let powerType = "none";
|
||||
if (coreSource === "Compendium.sw5e.forcepowers") powerType = "sw5e.forcepowers";
|
||||
if (coreSource === "Compendium.sw5e.techpowers") powerType = "sw5e.techpowers";
|
||||
if (powerType === "none") return updateData;
|
||||
|
||||
const corePower = duplicate(await game.packs.get(powerType).getEntity(core_id));
|
||||
console.log(`Updating Actor ${actor.name}'s ${item.name} from compendium`);
|
||||
const corePowerData = corePower.data;
|
||||
// copy Core Power Data over original Power
|
||||
updateData["data"] = corePowerData;
|
||||
updateData["flags"] = {sw5e: {dataVersion: "1.2.4"}};
|
||||
|
||||
return updateData;
|
||||
|
||||
//game.packs.get(powerType).getEntity(core_id).then(corePower => {
|
||||
|
||||
//})
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Delete the old data.attuned boolean
|
||||
*
|
||||
* @param {object} item Item data to migrate
|
||||
* @param {object} updateData Existing update to expand upon
|
||||
* @return {object} The updateData to apply
|
||||
* @private
|
||||
*/
|
||||
function _migrateItemAttunement(item, updateData) {
|
||||
if (item.data?.attuned === undefined) return updateData;
|
||||
updateData["data.attunement"] = CONFIG.SW5E.attunementTypes.NONE;
|
||||
updateData["data.-=attuned"] = null;
|
||||
return updateData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A general tool to purge flags from all entities in a Compendium pack.
|
||||
* @param {Compendium} pack The compendium pack to clean
|
||||
* @private
|
||||
*/
|
||||
export async function purgeFlags(pack) {
|
||||
const cleanFlags = (flags) => {
|
||||
const flags5e = flags.sw5e || null;
|
||||
return flags5e ? {sw5e: flags5e} : {};
|
||||
};
|
||||
await pack.configure({locked: false});
|
||||
const content = await pack.getContent();
|
||||
for (let entity of content) {
|
||||
const update = {_id: entity.id, flags: cleanFlags(entity.data.flags)};
|
||||
if (pack.entity === "Actor") {
|
||||
update.items = entity.data.items.map((i) => {
|
||||
i.flags = cleanFlags(i.flags);
|
||||
return i;
|
||||
});
|
||||
}
|
||||
await pack.updateEntity(update, {recursive: false});
|
||||
console.log(`Purged flags from ${entity.name}`);
|
||||
}
|
||||
await pack.configure({locked: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Purge the data model of any inner objects which have been flagged as _deprecated.
|
||||
* @param {object} data The data to clean
|
||||
* @private
|
||||
*/
|
||||
export function removeDeprecatedObjects(data) {
|
||||
for (let [k, v] of Object.entries(data)) {
|
||||
if (getType(v) === "Object") {
|
||||
if (v._deprecated === true) {
|
||||
console.log(`Deleting deprecated object key ${k}`);
|
||||
delete data[k];
|
||||
} else removeDeprecatedObjects(v);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
132
module/pixi/ability-template.js
Normal file
|
@ -0,0 +1,132 @@
|
|||
import {SW5E} from "../config.js";
|
||||
|
||||
/**
|
||||
* A helper class for building MeasuredTemplates for 5e powers and abilities
|
||||
* @extends {MeasuredTemplate}
|
||||
*/
|
||||
export default class AbilityTemplate extends MeasuredTemplate {
|
||||
/**
|
||||
* A factory method to create an AbilityTemplate instance using provided data from an Item5e instance
|
||||
* @param {Item5e} item The Item object for which to construct the template
|
||||
* @return {AbilityTemplate|null} The template object, or null if the item does not produce a template
|
||||
*/
|
||||
static fromItem(item) {
|
||||
const target = getProperty(item.data, "data.target") || {};
|
||||
const templateShape = SW5E.areaTargetTypes[target.type];
|
||||
if (!templateShape) return null;
|
||||
|
||||
// Prepare template data
|
||||
const templateData = {
|
||||
t: templateShape,
|
||||
user: game.user.data._id,
|
||||
distance: target.value,
|
||||
direction: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
fillColor: game.user.color
|
||||
};
|
||||
|
||||
// Additional type-specific data
|
||||
switch (templateShape) {
|
||||
case "cone":
|
||||
templateData.angle = CONFIG.MeasuredTemplate.defaults.angle;
|
||||
break;
|
||||
case "rect": // 5e rectangular AoEs are always cubes
|
||||
templateData.distance = Math.hypot(target.value, target.value);
|
||||
templateData.width = target.value;
|
||||
templateData.direction = 45;
|
||||
break;
|
||||
case "ray": // 5e rays are most commonly 1 square (5 ft) in width
|
||||
templateData.width = target.width ?? canvas.dimensions.distance;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Return the template constructed from the item data
|
||||
const cls = CONFIG.MeasuredTemplate.documentClass;
|
||||
const template = new cls(templateData, {parent: canvas.scene});
|
||||
const object = new this(template);
|
||||
object.item = item;
|
||||
object.actorSheet = item.actor?.sheet || null;
|
||||
return object;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Creates a preview of the power template
|
||||
*/
|
||||
drawPreview() {
|
||||
const initialLayer = canvas.activeLayer;
|
||||
|
||||
// Draw the template and switch to the template layer
|
||||
this.draw();
|
||||
this.layer.activate();
|
||||
this.layer.preview.addChild(this);
|
||||
|
||||
// Hide the sheet that originated the preview
|
||||
if (this.actorSheet) this.actorSheet.minimize();
|
||||
|
||||
// Activate interactivity
|
||||
this.activatePreviewListeners(initialLayer);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate listeners for the template preview
|
||||
* @param {CanvasLayer} initialLayer The initially active CanvasLayer to re-activate after the workflow is complete
|
||||
*/
|
||||
activatePreviewListeners(initialLayer) {
|
||||
const handlers = {};
|
||||
let moveTime = 0;
|
||||
|
||||
// Update placement (mouse-move)
|
||||
handlers.mm = (event) => {
|
||||
event.stopPropagation();
|
||||
let now = Date.now(); // Apply a 20ms throttle
|
||||
if (now - moveTime <= 20) return;
|
||||
const center = event.data.getLocalPosition(this.layer);
|
||||
const snapped = canvas.grid.getSnappedPosition(center.x, center.y, 2);
|
||||
this.data.update({x: snapped.x, y: snapped.y});
|
||||
this.refresh();
|
||||
moveTime = now;
|
||||
};
|
||||
|
||||
// Cancel the workflow (right-click)
|
||||
handlers.rc = (event) => {
|
||||
this.layer.preview.removeChildren();
|
||||
canvas.stage.off("mousemove", handlers.mm);
|
||||
canvas.stage.off("mousedown", handlers.lc);
|
||||
canvas.app.view.oncontextmenu = null;
|
||||
canvas.app.view.onwheel = null;
|
||||
initialLayer.activate();
|
||||
this.actorSheet.maximize();
|
||||
};
|
||||
|
||||
// Confirm the workflow (left-click)
|
||||
handlers.lc = (event) => {
|
||||
handlers.rc(event);
|
||||
const destination = canvas.grid.getSnappedPosition(this.data.x, this.data.y, 2);
|
||||
this.data.update(destination);
|
||||
canvas.scene.createEmbeddedDocuments("MeasuredTemplate", [this.data]);
|
||||
};
|
||||
|
||||
// Rotate the template by 3 degree increments (mouse-wheel)
|
||||
handlers.mw = (event) => {
|
||||
if (event.ctrlKey) event.preventDefault(); // Avoid zooming the browser window
|
||||
event.stopPropagation();
|
||||
let delta = canvas.grid.type > CONST.GRID_TYPES.SQUARE ? 30 : 15;
|
||||
let snap = event.shiftKey ? delta : 5;
|
||||
this.data.update({direction: this.data.direction + snap * Math.sign(event.deltaY)});
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
// Activate listeners
|
||||
canvas.stage.on("mousemove", handlers.mm);
|
||||
canvas.stage.on("mousedown", handlers.lc);
|
||||
canvas.app.view.oncontextmenu = handlers.rc;
|
||||
canvas.app.view.onwheel = handlers.mw;
|
||||
}
|
||||
}
|
144
module/settings.js
Normal file
|
@ -0,0 +1,144 @@
|
|||
export const registerSystemSettings = function () {
|
||||
/**
|
||||
* Track the system version upon which point a migration was last applied
|
||||
*/
|
||||
game.settings.register("sw5e", "systemMigrationVersion", {
|
||||
name: "System Migration Version",
|
||||
scope: "world",
|
||||
config: false,
|
||||
type: String,
|
||||
default: game.system.data.version
|
||||
});
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
game.settings.register("sw5e", "diagonalMovement", {
|
||||
name: "SETTINGS.5eDiagN",
|
||||
hint: "SETTINGS.5eDiagL",
|
||||
scope: "world",
|
||||
config: true,
|
||||
default: "555",
|
||||
type: String,
|
||||
choices: {
|
||||
555: "SETTINGS.5eDiagPHB",
|
||||
5105: "SETTINGS.5eDiagDMG",
|
||||
EUCL: "SETTINGS.5eDiagEuclidean"
|
||||
},
|
||||
onChange: (rule) => (canvas.grid.diagonalRule = rule)
|
||||
});
|
||||
|
||||
/**
|
||||
* Register Initiative formula setting
|
||||
*/
|
||||
game.settings.register("sw5e", "initiativeDexTiebreaker", {
|
||||
name: "SETTINGS.5eInitTBN",
|
||||
hint: "SETTINGS.5eInitTBL",
|
||||
scope: "world",
|
||||
config: true,
|
||||
default: false,
|
||||
type: Boolean
|
||||
});
|
||||
|
||||
/**
|
||||
* Require Currency Carrying Weight
|
||||
*/
|
||||
game.settings.register("sw5e", "currencyWeight", {
|
||||
name: "SETTINGS.5eCurWtN",
|
||||
hint: "SETTINGS.5eCurWtL",
|
||||
scope: "world",
|
||||
config: true,
|
||||
default: true,
|
||||
type: Boolean
|
||||
});
|
||||
|
||||
/**
|
||||
* Option to disable XP bar for session-based or story-based advancement.
|
||||
*/
|
||||
game.settings.register("sw5e", "disableExperienceTracking", {
|
||||
name: "SETTINGS.5eNoExpN",
|
||||
hint: "SETTINGS.5eNoExpL",
|
||||
scope: "world",
|
||||
config: true,
|
||||
default: false,
|
||||
type: Boolean
|
||||
});
|
||||
|
||||
/**
|
||||
* Option to automatically collapse Item Card descriptions
|
||||
*/
|
||||
game.settings.register("sw5e", "autoCollapseItemCards", {
|
||||
name: "SETTINGS.5eAutoCollapseCardN",
|
||||
hint: "SETTINGS.5eAutoCollapseCardL",
|
||||
scope: "client",
|
||||
config: true,
|
||||
default: false,
|
||||
type: Boolean,
|
||||
onChange: (s) => {
|
||||
ui.chat.render();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Option to allow GMs to restrict polymorphing to GMs only.
|
||||
*/
|
||||
game.settings.register("sw5e", "allowPolymorphing", {
|
||||
name: "SETTINGS.5eAllowPolymorphingN",
|
||||
hint: "SETTINGS.5eAllowPolymorphingL",
|
||||
scope: "world",
|
||||
config: true,
|
||||
default: false,
|
||||
type: Boolean
|
||||
});
|
||||
|
||||
/**
|
||||
* Remember last-used polymorph settings.
|
||||
*/
|
||||
game.settings.register("sw5e", "polymorphSettings", {
|
||||
scope: "client",
|
||||
default: {
|
||||
keepPhysical: false,
|
||||
keepMental: false,
|
||||
keepSaves: false,
|
||||
keepSkills: false,
|
||||
mergeSaves: false,
|
||||
mergeSkills: false,
|
||||
keepClass: false,
|
||||
keepFeats: false,
|
||||
keepPowers: false,
|
||||
keepItems: false,
|
||||
keepBio: false,
|
||||
keepVision: true,
|
||||
transformTokens: true
|
||||
}
|
||||
});
|
||||
game.settings.register("sw5e", "colorTheme", {
|
||||
name: "SETTINGS.SWColorN",
|
||||
hint: "SETTINGS.SWColorL",
|
||||
scope: "world",
|
||||
config: true,
|
||||
default: "light",
|
||||
type: String,
|
||||
choices: {
|
||||
light: "SETTINGS.SWColorLight",
|
||||
dark: "SETTINGS.SWColorDark"
|
||||
}
|
||||
});
|
||||
};
|
35
module/templates.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Define a set of template paths to pre-load
|
||||
* Pre-loaded templates are compiled and cached for fast access when rendering
|
||||
* @return {Promise}
|
||||
*/
|
||||
export const preloadHandlebarsTemplates = async function () {
|
||||
return loadTemplates([
|
||||
// Shared Partials
|
||||
"systems/sw5e/templates/actors/parts/active-effects.html",
|
||||
|
||||
// Actor Sheet Partials
|
||||
"systems/sw5e/templates/actors/oldActor/parts/actor-traits.html",
|
||||
"systems/sw5e/templates/actors/oldActor/parts/actor-inventory.html",
|
||||
"systems/sw5e/templates/actors/oldActor/parts/actor-features.html",
|
||||
"systems/sw5e/templates/actors/oldActor/parts/actor-powerbook.html",
|
||||
"systems/sw5e/templates/actors/oldActor/parts/actor-notes.html",
|
||||
|
||||
"systems/sw5e/templates/actors/newActor/parts/swalt-biography.html",
|
||||
"systems/sw5e/templates/actors/newActor/parts/swalt-core.html",
|
||||
"systems/sw5e/templates/actors/newActor/parts/swalt-crew.html",
|
||||
"systems/sw5e/templates/actors/newActor/parts/swalt-active-effects.html",
|
||||
"systems/sw5e/templates/actors/newActor/parts/swalt-features.html",
|
||||
"systems/sw5e/templates/actors/newActor/parts/swalt-inventory.html",
|
||||
"systems/sw5e/templates/actors/newActor/parts/swalt-force-powerbook.html",
|
||||
"systems/sw5e/templates/actors/newActor/parts/swalt-tech-powerbook.html",
|
||||
"systems/sw5e/templates/actors/newActor/parts/swalt-resources.html",
|
||||
"systems/sw5e/templates/actors/newActor/parts/swalt-traits.html",
|
||||
|
||||
// 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-mountable.html"
|
||||
]);
|
||||
};
|
|
@ -3,11 +3,10 @@
|
|||
* @extends {TokenDocument}
|
||||
*/
|
||||
export class TokenDocument5e extends TokenDocument {
|
||||
|
||||
/** @inheritdoc */
|
||||
getBarAttribute(...args) {
|
||||
const data = super.getBarAttribute(...args);
|
||||
if ( data && (data.attribute === "attributes.hp") ) {
|
||||
if (data && data.attribute === "attributes.hp") {
|
||||
data.value += parseInt(getProperty(this.actor.data, "data.attributes.hp.temp") || 0);
|
||||
data.max += parseInt(getProperty(this.actor.data, "data.attributes.hp.tempmax") || 0);
|
||||
}
|
||||
|
@ -15,19 +14,16 @@ export class TokenDocument5e extends TokenDocument {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
|
||||
/**
|
||||
* Extend the base Token class to implement additional system-specific logic.
|
||||
* @extends {Token}
|
||||
*/
|
||||
export class Token5e extends Token {
|
||||
|
||||
/** @inheritdoc */
|
||||
_drawBar(number, bar, data) {
|
||||
if ( data.attribute === "attributes.hp" ) return this._drawHPBar(number, bar, data);
|
||||
if (data.attribute === "attributes.hp") return this._drawHPBar(number, bar, data);
|
||||
return super._drawBar(number, bar, data);
|
||||
}
|
||||
|
||||
|
@ -41,7 +37,6 @@ export class Token5e extends Token {
|
|||
* @private
|
||||
*/
|
||||
_drawHPBar(number, bar, data) {
|
||||
|
||||
// Extract health data
|
||||
let {value, max, temp, tempmax} = this.document.actor.data.data.attributes.hp;
|
||||
temp = Number(temp || 0);
|
||||
|
@ -58,42 +53,50 @@ export class Token5e extends Token {
|
|||
|
||||
// Determine colors to use
|
||||
const blk = 0x000000;
|
||||
const hpColor = PIXI.utils.rgb2hex([(1-(colorPct/2)), colorPct, 0]);
|
||||
const hpColor = PIXI.utils.rgb2hex([1 - colorPct / 2, colorPct, 0]);
|
||||
const c = CONFIG.SW5E.tokenHPColors;
|
||||
|
||||
// Determine the container size (logic borrowed from core)
|
||||
const w = this.w;
|
||||
let h = Math.max((canvas.dimensions.size / 12), 8);
|
||||
if ( this.data.height >= 2 ) h *= 1.6;
|
||||
let h = Math.max(canvas.dimensions.size / 12, 8);
|
||||
if (this.data.height >= 2) h *= 1.6;
|
||||
const bs = Math.clamped(h / 8, 1, 2);
|
||||
const bs1 = bs+1;
|
||||
const bs1 = bs + 1;
|
||||
|
||||
// Overall bar container
|
||||
bar.clear()
|
||||
bar.clear();
|
||||
bar.beginFill(blk, 0.5).lineStyle(bs, blk, 1.0).drawRoundedRect(0, 0, w, h, 3);
|
||||
|
||||
// Temporary maximum HP
|
||||
if (tempmax > 0) {
|
||||
const pct = max / effectiveMax;
|
||||
bar.beginFill(c.tempmax, 1.0).lineStyle(1, blk, 1.0).drawRoundedRect(pct*w, 0, (1-pct)*w, h, 2);
|
||||
bar.beginFill(c.tempmax, 1.0)
|
||||
.lineStyle(1, blk, 1.0)
|
||||
.drawRoundedRect(pct * w, 0, (1 - pct) * w, h, 2);
|
||||
}
|
||||
|
||||
// Maximum HP penalty
|
||||
else if (tempmax < 0) {
|
||||
const pct = (max + tempmax) / max;
|
||||
bar.beginFill(c.negmax, 1.0).lineStyle(1, blk, 1.0).drawRoundedRect(pct*w, 0, (1-pct)*w, h, 2);
|
||||
bar.beginFill(c.negmax, 1.0)
|
||||
.lineStyle(1, blk, 1.0)
|
||||
.drawRoundedRect(pct * w, 0, (1 - pct) * w, h, 2);
|
||||
}
|
||||
|
||||
// Health bar
|
||||
bar.beginFill(hpColor, 1.0).lineStyle(bs, blk, 1.0).drawRoundedRect(0, 0, valuePct*w, h, 2)
|
||||
bar.beginFill(hpColor, 1.0)
|
||||
.lineStyle(bs, blk, 1.0)
|
||||
.drawRoundedRect(0, 0, valuePct * w, h, 2);
|
||||
|
||||
// Temporary hit points
|
||||
if ( temp > 0 ) {
|
||||
bar.beginFill(c.temp, 1.0).lineStyle(0).drawRoundedRect(bs1, bs1, (tempPct*w)-(2*bs1), h-(2*bs1), 1);
|
||||
if (temp > 0) {
|
||||
bar.beginFill(c.temp, 1.0)
|
||||
.lineStyle(0)
|
||||
.drawRoundedRect(bs1, bs1, tempPct * w - 2 * bs1, h - 2 * bs1, 1);
|
||||
}
|
||||
|
||||
// Set position
|
||||
let posY = (number === 0) ? (this.h - h) : 0;
|
||||
let posY = number === 0 ? this.h - h : 0;
|
||||
bar.position.set(0, posY);
|
||||
}
|
||||
}
|
99
package-lock.json
generated
|
@ -1025,17 +1025,6 @@
|
|||
"requires": {
|
||||
"graceful-fs": "^4.1.11",
|
||||
"through2": "^2.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"through2": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
|
||||
"integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
|
||||
"requires": {
|
||||
"readable-stream": "~2.3.6",
|
||||
"xtend": "~4.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fs.realpath": {
|
||||
|
@ -1216,17 +1205,6 @@
|
|||
"replace-ext": "^1.0.0",
|
||||
"through2": "^2.0.0",
|
||||
"vinyl-sourcemaps-apply": "^0.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"through2": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
|
||||
"integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
|
||||
"requires": {
|
||||
"readable-stream": "~2.3.6",
|
||||
"xtend": "~4.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"gulplog": {
|
||||
|
@ -1288,9 +1266,9 @@
|
|||
}
|
||||
},
|
||||
"hosted-git-info": {
|
||||
"version": "2.8.8",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz",
|
||||
"integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg=="
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
|
||||
"integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw=="
|
||||
},
|
||||
"image-size": {
|
||||
"version": "0.5.5",
|
||||
|
@ -2278,17 +2256,6 @@
|
|||
"remove-bom-buffer": "^3.0.0",
|
||||
"safe-buffer": "^5.1.0",
|
||||
"through2": "^2.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"through2": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
|
||||
"integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
|
||||
"requires": {
|
||||
"readable-stream": "~2.3.6",
|
||||
"xtend": "~4.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"remove-trailing-separator": {
|
||||
|
@ -2723,23 +2690,12 @@
|
|||
}
|
||||
},
|
||||
"through2": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
|
||||
"integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==",
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
|
||||
"integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
|
||||
"requires": {
|
||||
"readable-stream": "3"
|
||||
},
|
||||
"dependencies": {
|
||||
"readable-stream": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
|
||||
"integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
|
||||
"requires": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
}
|
||||
}
|
||||
"readable-stream": "~2.3.6",
|
||||
"xtend": "~4.0.1"
|
||||
}
|
||||
},
|
||||
"through2-filter": {
|
||||
|
@ -2749,17 +2705,6 @@
|
|||
"requires": {
|
||||
"through2": "~2.0.0",
|
||||
"xtend": "~4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"through2": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
|
||||
"integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
|
||||
"requires": {
|
||||
"readable-stream": "~2.3.6",
|
||||
"xtend": "~4.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"time-stamp": {
|
||||
|
@ -2820,17 +2765,6 @@
|
|||
"integrity": "sha1-/JKtq6ByZHvAtn1rA2ZKoZUJOvY=",
|
||||
"requires": {
|
||||
"through2": "^2.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"through2": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
|
||||
"integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
|
||||
"requires": {
|
||||
"readable-stream": "~2.3.6",
|
||||
"xtend": "~4.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tslib": {
|
||||
|
@ -3052,17 +2986,6 @@
|
|||
"value-or-function": "^3.0.0",
|
||||
"vinyl": "^2.0.0",
|
||||
"vinyl-sourcemap": "^1.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"through2": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
|
||||
"integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
|
||||
"requires": {
|
||||
"readable-stream": "~2.3.6",
|
||||
"xtend": "~4.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"vinyl-sourcemap": {
|
||||
|
@ -3145,9 +3068,9 @@
|
|||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
|
||||
},
|
||||
"y18n": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz",
|
||||
"integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE="
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz",
|
||||
"integrity": "sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ=="
|
||||
},
|
||||
"yargs": {
|
||||
"version": "7.1.1",
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
"main": "sw5e.js",
|
||||
"dependencies": {
|
||||
"gulp": "^4.0.2",
|
||||
"gulp-less": "^4.0.1",
|
||||
"through2": "^4.0.2"
|
||||
"gulp-less": "^4.0.1"
|
||||
}
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 970 B After Width: | Height: | Size: 970 B |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.4 KiB |
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.7 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |