Compare commits

..

40 commits

Author SHA1 Message Date
CK
2212e3c7d8
Merge pull request #239 from unrealkakeman89/Develop
Develop
2021-07-07 09:24:23 -04:00
CK
8c0ad582f7
Merge pull request #238 from unrealkakeman89/prettier
Prettier setup
2021-07-07 09:22:51 -04:00
TJ
584767b352 Formatted js files 2021-07-06 19:57:18 -05:00
TJ
d1b123100e Merge branch 'Develop' into prettier 2021-07-06 19:37:55 -05:00
Jacob Lucas
0a9c9f8ef0 Removed debug flag that spammed the console 2021-07-07 00:40:00 +01:00
CK
ee7418f552
Merge pull request #236 from unrealkakeman89/Develop
Develop
2021-07-06 06:10:24 -04:00
CK
c44ad926a5
Merge pull request #235 from unrealkakeman89/importer-fixes
Update calls to 0.8 api specifications
2021-07-05 10:44:40 -04:00
TJ
10ee20354b Update calls to 0.8 api specifications 2021-07-04 23:13:42 -05:00
CK
7214e3d260
Merge pull request #234 from unrealkakeman89/Develop
Various powercasting bugs
2021-07-02 20:46:59 -04:00
Jacob Lucas
97df236b54 Incremented version 2021-07-03 01:44:38 +01:00
Jacob Lucas
cac466462b Removed unnecessary calculation 2021-07-03 01:42:08 +01:00
Jacob Lucas
063d529f09 Fixed bug that prevented NPCs power DCs from having values 2021-07-03 01:41:26 +01:00
Jacob Lucas
b343a06ef6 Fixed bug that caused power points to not have the mods 2021-07-03 01:40:48 +01:00
CK
fc09308d11
Merge pull request #233 from unrealkakeman89/Develop
Develop
2021-07-01 21:54:52 -04:00
Jacob Lucas
7a18055c18 Bump max version to latest 2021-07-02 02:49:05 +01:00
Jacob Lucas
e271c41239 Fixed error that caused max force & tech points to evaluate to NaN 2021-07-02 02:48:55 +01:00
CK
5d879f99e2
Merge pull request #232 from unrealkakeman89/Develop
Develop
2021-07-01 16:31:37 -04:00
CK
df44ad0635
Merge pull request #231 from unrealkakeman89/professorbunbury-sw5e
"My Dudes" Update - 7/1/2021
2021-07-01 16:28:54 -04:00
Professor Bunbury
aff43d3e98 "My Dudes" Update - 7/1/2021
^ Updates Astrotech Engineering archetype in Archetypes compendium.
2021-07-01 16:05:37 -04:00
CK
211201caea Shoulder Cannon added 2021-06-30 17:10:13 -04:00
CK
7a29dfe600
Merge pull request #230 from unrealkakeman89/professorbunbury-sw5e
"My Dudes" Update - 6/24/2021
2021-06-29 15:35:12 -04:00
Professor Bunbury
c467738845 "My Dudes" Update - 6/24/2021
+ Adds new Force and Tech Powers and associated artwork to Force Powers and Tech Powers compendia.
2021-06-29 15:33:17 -04:00
CK
8a0940ccce Update Weapons Artwork pt2 2021-06-29 10:17:00 -04:00
CK
3b4300a8eb
Merge pull request #229 from unrealkakeman89/ammo-fix
Fix for blaster ammunition
2021-06-29 10:02:22 -04:00
CK
55bbb95cfb Updated Weapons Artwork 2021-06-29 10:02:02 -04:00
TJ
62e31afff2 Fix for blaster ammunition 2021-06-28 19:15:31 -05:00
TJ
da5223cab8 Prettier settings 2021-06-28 18:54:49 -05:00
CK
db286f7883
Merge pull request #228 from unrealkakeman89/split-powercasting
Split powercasting
2021-06-28 17:30:16 -04:00
supervj
25684173fa Update entity.js
looks good to me
2021-06-28 17:14:03 -04:00
supervj
74d841e9e1 split powercasting
Split where powercasting is done so that it can be changed by DAE.
2021-06-28 12:57:06 -04:00
CK
84ab6cf478
Merge pull request #203 from unrealkakeman89/dependabot/npm_and_yarn/hosted-git-info-2.8.9
Bump hosted-git-info from 2.8.8 to 2.8.9
2021-05-12 08:52:14 -04:00
dependabot[bot]
d39fa6acf2
Bump hosted-git-info from 2.8.8 to 2.8.9
Bumps [hosted-git-info](https://github.com/npm/hosted-git-info) from 2.8.8 to 2.8.9.
- [Release notes](https://github.com/npm/hosted-git-info/releases)
- [Changelog](https://github.com/npm/hosted-git-info/blob/v2.8.9/CHANGELOG.md)
- [Commits](https://github.com/npm/hosted-git-info/compare/v2.8.8...v2.8.9)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-11 22:42:24 +00:00
CK
0a4b6de0fa
Merge pull request #202 from unrealkakeman89/hotfix-ammo-usage
Fix for blasters not consuming ammo
2021-05-09 19:58:43 -04:00
TJ
c33982f97c Fix for blasters not consuming ammo 2021-05-09 18:53:05 -05:00
CK
4f0c6addcf
Merge pull request #185 from unrealkakeman89/dependabot/npm_and_yarn/y18n-3.2.2
Bump y18n from 3.2.1 to 3.2.2
2021-04-01 11:42:36 -04:00
dependabot[bot]
7c03cd4b04
Bump y18n from 3.2.1 to 3.2.2
Bumps [y18n](https://github.com/yargs/y18n) from 3.2.1 to 3.2.2.
- [Release notes](https://github.com/yargs/y18n/releases)
- [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yargs/y18n/commits)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-31 17:09:05 +00:00
CK
fee77e2172
Merge pull request #172 from unrealkakeman89/Develop
Develop
2021-03-16 10:15:16 -04:00
CK
87d615babc
Merge pull request #155 from ExileofBrokenSky/ExileOfBrokenSky
updated operative
2021-03-11 15:00:27 -05:00
Michael Burgess
04dcaf332d Added Guardian archetype: Aqinos Form
updated icons for scholar and made individual entries for discoveries and maneuvers
added (Maneuver) to fighter and scholar maneuver names, for ease of searching all maneuvers in the directory
2021-03-05 13:09:52 -05:00
Michael Burgess
4ee235566d updated operative
expanded options into class features for each
exploit including skills exploits
visited your mom
individualized the previously shared operative and monk ability score increases
added midi-qol superSaver effect to Evasion (Operative)
2021-02-25 10:40:41 -05:00
6916 changed files with 29868 additions and 997899 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

6
.gitignore vendored
View file

@ -28,10 +28,4 @@ hs_err_pid*
# IDE Folders
.idea/
.vs/
.vscode/
# Nodejs Modules
node_modules
# Compiled Files
dist/

14
.prettierrc Normal file
View 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
View file

@ -0,0 +1,3 @@
{
"editor.formatOnSave": true
}

View file

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

File diff suppressed because it is too large Load diff

1884
module/actor/entity.js Normal file

File diff suppressed because it is too large Load diff

2126
module/actor/old_entity.js Normal file

File diff suppressed because it is too large Load diff

View 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": "&infin;"
};
// Format a powerbook entry for a certain indexed level
const registerSection = (sl, i, label, {prepMode = "prepared", value, max, override} = {}) => {
powerbook[i] = {
order: i,
label: label,
usesSlots: i > 0,
canCreate: owner,
canPrepare: data.actor.type === "character" && i >= 1,
powers: [],
uses: useLabels[i] || value || 0,
slots: useLabels[i] || max || 0,
override: override || 0,
dataset: {
"type": "power",
"level": prepMode in sections ? 1 : i,
"preparation.mode": prepMode,
"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;
}
}

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

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

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

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

View 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": "&infin;"
};
// Format a powerbook entry for a certain indexed level
const registerSection = (sl, i, label, {prepMode = "prepared", value, max, override} = {}) => {
powerbook[i] = {
order: i,
label: label,
usesSlots: i > 0,
canCreate: owner,
canPrepare: data.actor.type === "character" && i >= 1,
powers: [],
uses: useLabels[i] || value || 0,
slots: useLabels[i] || max || 0,
override: override || 0,
dataset: {"type": "power", "level": prepMode in sections ? 1 : i, "preparation.mode": prepMode},
prop: sl
};
};
// Determine the maximum power level which has a slot
const maxLevel = Array.fromRange(10).reduce((max, i) => {
if (i === 0) return max;
const level = levels[`power${i}`];
if ((level.max || level.override) && i > max) max = i;
return max;
}, 0);
// Level-based powercasters have cantrips and leveled slots
if (maxLevel > 0) {
registerSection("power0", 0, CONFIG.SW5E.powerLevels[0]);
for (let lvl = 1; lvl <= maxLevel; lvl++) {
const sl = `power${lvl}`;
registerSection(sl, lvl, CONFIG.SW5E.powerLevels[lvl], levels[sl]);
}
}
// Pact magic users have cantrips and a pact magic section
// 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;
}
}

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

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

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

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

View file

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

View file

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

View 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;
}
}

View file

@ -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([])
}
},

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

View 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
View 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
View 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
View 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
View file

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

31
module/combat.js Normal file
View 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

File diff suppressed because it is too large Load diff

313
module/dice.js Normal file
View 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};
}

View file

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

View file

@ -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
View 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

File diff suppressed because it is too large Load diff

370
module/item/sheet.js Normal file
View 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
View file

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

710
module/migration.js Normal file
View 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;
}

View 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
View 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
View 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"
]);
};

View file

@ -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
View file

@ -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",

View file

@ -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"
}
}

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 970 B

After

Width:  |  Height:  |  Size: 970 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Some files were not shown because too many files have changed in this diff Show more