diff --git a/lang/en.json b/lang/en.json index de15c066..03a969fc 100644 --- a/lang/en.json +++ b/lang/en.json @@ -43,8 +43,10 @@ "SETTINGS.5eInitTBN": "Initiative Dexterity Tiebreaker", "SETTINGS.5eNoExpL": "Remove experience bars from character sheets.", "SETTINGS.5eNoExpN": "Disable Experience Tracking", + "SETTINGS.5eReset": "Reset", "SETTINGS.5eRestEpic": "Epic Heroism (LR: 1 hour, SR: 1 min)", "SETTINGS.5eRestGritty": "Gritty Realism (LR: 7 days, SR: 8 hours)", + "SETTINGS.5eUndoChanges": "Undo Changes", "SETTINGS.5eRestL": "Configure which rest variant should be used for games within this system.", "SETTINGS.5eRestN": "Rest Variant", "SETTINGS.5eRestPHB": "Player's Handbook (LR: 8 hours, SR: 1 hour)", @@ -104,7 +106,10 @@ "SW5E.ActionUtil": "Utility", "SW5E.ActionWarningNoItem": "The requested item {item} no longer exists on Actor {name}", "SW5E.ActionWarningNoToken": "You must have one or more controlled Tokens in order to use this option.", + "SW5E.ActorWarningInvalidItem": "{itemType} items cannot be added to a {actorType}.", "SW5E.Add": "Add", + "SW5E.AddEmbeddedItemPromptHint": "Do you want to add these items to your character sheet?", + "SW5E.SelectItemsPromptTitle": "Select Items", "SW5E.AdditionalNotes": "Additional Notes", "SW5E.Advantage": "Advantage", "SW5E.Alignment": "Alignment", @@ -118,6 +123,7 @@ "SW5E.AlignmentND": "Neutral Dark", "SW5E.AlignmentNL": "Neutral Light", "SW5E.Appearance": "Appearance", + "SW5E.Apply": "Apply", "SW5E.ArchetypeName": "Archetype Name", "SW5E.Archetypes": "Archetypes", "SW5E.ArmorClass": "Armor Class", @@ -199,7 +205,11 @@ "SW5E.ChatContextHealing": "Apply Healing", "SW5E.ChatFlavor": "Chat Message Flavor", "SW5E.ClassLevels": "Class Levels", + "SW5E.ClassMakeOriginal": "Original Class", + "SW5E.ClassMakeOriginalHint": "First class taken by character used to determine certain class traits when multiclassing.", "SW5E.ClassName": "Class Name", + "SW5E.ClassOriginal": "Original Class", + "SW5E.ClassSaves": "Saving Throws", "SW5E.ClassSkillsChosen": "Chosen Class Skills", "SW5E.ClassSkillsNumber": "Number of Starting Skills", "SW5E.Collapse": "Collapse/Expand", @@ -257,6 +267,33 @@ "SW5E.ConUnconscious": "Unconscious", "SW5E.Core": "Core", "SW5E.CostGP": "Cost (CR)", + "SW5E.CreatureAberration": "Aberration", + "SW5E.CreatureAberrationPl": "Aberrations", + "SW5E.CreatureBeast": "Beast", + "SW5E.CreatureBeastPl": "Beasts", + "SW5E.CreatureConstruct": "Construct", + "SW5E.CreatureConstructPl": "Constructs", + "SW5E.CreatureDroid": "Droid", + "SW5E.CreatureDroidPl": "Droids", + "SW5E.CreatureElemental": "Elemental", + "SW5E.CreatureElementalPl": "Elementals", + "SW5E.CreatureHuman": "Human", + "SW5E.CreatureHumanPl": "Humans", + "SW5E.CreatureHumanoid": "Humanoid", + "SW5E.CreatureHumanoidPl": "Humanoids", + "SW5E.CreaturePlant": "Plant", + "SW5E.CreaturePlantPl": "Plants", + "SW5E.CreatureUndead": "Undead", + "SW5E.CreatureUndeadPl": "Undead", + "SW5E.CreatureType": "Creature Type", + "SW5E.CreatureTypeTitle": "Configure Creature Type", + "SW5E.CreatureSwarm": "Swarm", + "SW5E.CreatureSwarmSize": "Swarm Size", + "SW5E.CreatureSwarmPhrase": "Swarm of {size} {type}", + "SW5E.CreatureTypeConfig": "Configure Creature Type", + "SW5E.CreatureTypeSelectorCustom": "Custom Type", + "SW5E.CreatureTypeSelectorSubtype": "Subtype", + "SW5E.Crewed": "Crewed", "SW5E.Cover": "Cover", "SW5E.CoverHalf": "Half", "SW5E.CoverThreeQuarters": "Three Quarters", @@ -264,6 +301,7 @@ "SW5E.CrewCap": "Crew Capacity", "SW5E.Critical": "Critical", "SW5E.CriticalHit": "Critical Hit", + "SW5E.PowerfulCritical": "Powerful Critical", "SW5E.Currency": "Currency", "SW5E.CurrencyConvert": "Convert All Currency", "SW5E.CurrencyConvertHint": "Convert all carried currency to the highest possible denomination to reduce the amount of coinage carried by the character. Be wary, this action cannot be undone.", @@ -493,6 +531,10 @@ "SW5E.HealthConditions": "Health Conditions", "SW5E.HealthFormula": "Health Formula", "SW5E.HitDice": "Hit Dice", + "SW5E.HitDiceConfig": "Adjust Hit Dice", + "SW5E.HitDiceConfigHint": "Adjust remaining hit dice levels for each class.", + "SW5E.HitDiceMax": "Maximum Hit Dice", + "SW5E.HitDiceRemaining": "Remaining Hit Dice", "SW5E.HitDiceRoll": "Roll Hit Dice", "SW5E.HitDiceUsed": "Hit Dice Used", "SW5E.HitDiceWarn": "{name} has no available {formula} Hit Dice remaining!", @@ -866,7 +908,7 @@ "SW5E.Role": "Role", "SW5E.RolePl": "Roles", "SW5E.Roll": "Roll", - "SW5E.RollExample": "e.g. +1d4", + "SW5E.RollExample": "e.g. 1d4", "SW5E.RollMode": "Roll Mode", "SW5E.RollSituationalBonus": "Situational Bonus?", "SW5E.Save": "Save", @@ -935,6 +977,7 @@ "SW5E.SkillPrc": "Perception", "SW5E.SkillPrf": "Performance", "SW5E.SkillPromptTitle": "{skill} Skill Check", + "SW5E.Skip": "Skip", "SW5E.Skills": "Skills", "SW5E.SkillSlt": "Sleight of Hand", "SW5E.SkillSte": "Stealth", diff --git a/less/original/actors.less b/less/original/actors.less index 84553cae..eef86c31 100644 --- a/less/original/actors.less +++ b/less/original/actors.less @@ -71,7 +71,7 @@ } // Movement Configuration - .movement { + .movement, .hit-dice { h4.attribute-name { position: relative; } @@ -655,6 +655,15 @@ // Empty powerbook controls .powerbook-empty .item-controls { flex: 1; } + /* ----------------------------------------- */ + /* Features Tab */ + /* ----------------------------------------- */ + + // Original class icon + .features i.original-class { + color: #4b4a44 + } + /* ----------------------------------------- */ /* TinyMCE */ /* ----------------------------------------- */ @@ -678,4 +687,4 @@ overflow-y: auto; scrollbar-width: thin; } -} \ No newline at end of file +} diff --git a/less/original/apps.less b/less/original/apps.less index 67701da2..67ca5cac 100644 --- a/less/original/apps.less +++ b/less/original/apps.less @@ -10,9 +10,7 @@ .sw5e { .window-content { - background: @sheetBackground; font-size: 13px; - color: @colorDark; } /* ----------------------------------------- */ @@ -44,6 +42,8 @@ select:disabled, textarea:disabled { color: @colorOlive; + border: 1px solid transparent !important; + outline: none !important; &:hover, &:focus { box-shadow: none !important; @@ -58,28 +58,6 @@ border: @borderGroove; } - // Checkbox Labels - // TODO: THIS CAN BE MOSTLY REMOVED NOW THAT IT IS IN CORE, see core forms.less - label.checkbox { - flex: auto; - padding: 0; - margin: 0; - height: 22px; - line-height: 22px; - font-size: 11px; - > input[type="checkbox"] { - width: 16px; - height: 16px; - margin: 0 2px 0 0; - position: relative; - top: 4px; - } - &.right > input[type="checkbox"] { - margin: 0 0 0 2px; - } - } - - /* Form Groups */ .form-group { label { @@ -98,11 +76,12 @@ // Stacked Groups .form-group.stacked { - label { + > label { flex: 0 0 100%; margin: 0; } - label.checkbox { + label.checkbox, + label.radio { flex: auto; text-align: left; } @@ -131,6 +110,34 @@ } +/* ----------------------------------------- */ +/* Hit Dice Config Sheet Specifically */ +/* ----------------------------------------- */ + +.sw5e.hd-config { + .form-group { + button.increment, button.decrement { + flex: 0 0 1rem; + line-height: 1rem; + } + + button.decrement { + margin-right: 0; + } + + span.sep { + margin: 0; + } + + input { + flex: 0 0 2rem; + text-align: center; + margin-left: 2px; + margin-right: 2px; + } + } +} + /* ----------------------------------------- */ /* Entity Sheets Specifically */ /* ----------------------------------------- */ @@ -475,7 +482,7 @@ /* Trait Selector /* ----------------------------------------- */ -#trait-selector { +.trait-selector { .trait-list { list-style: none; margin: 0; @@ -488,6 +495,59 @@ } } +/* ----------------------------------------- */ +/* Actor Type Config Sheet Specifically */ +/* ----------------------------------------- */ + +.actor-type { + .trait-list { + display: flex; + flex-wrap: wrap; + li { + flex-basis: 50%; + flex-grow: 1; + } + li.form-group { + flex-basis: 100%; + } + } + label.radio { + display: flex; + flex: auto; + font-size: 12px; + line-height: 20px; + font-weight: normal; + > input[type="radio"] { + margin: 0 5px 0 0; + } + } + li.custom-type input[type="radio"] { + display: none; + } +} + +/* ----------------------------------------- */ +/* Add Feature Prompt Specifically */ +/* ----------------------------------------- */ + +.sw5e.select-items-prompt { + .dialog-content { + margin-bottom: 1em; + } + + .items-list { + margin-top: 0.5em; + } + + .item-name > label, .item-image, input { + cursor: pointer; + } + + .item-name > label { + align-items: center; + } +} + /* ----------------------------------------- */ /* HUD /* ----------------------------------------- */ diff --git a/less/original/character.less b/less/original/character.less index 358b1d4a..e08a754f 100644 --- a/less/original/character.less +++ b/less/original/character.less @@ -89,7 +89,7 @@ // Custom Resources .resource .attribute-value { - input { + > input { flex: 0 0 25%; } label.recharge { @@ -99,6 +99,7 @@ font-size: 11px; text-align: center; color: @colorOlive; + align-items: center; input[type="checkbox"] { height: 14px; width: 14px; diff --git a/less/original/npc.less b/less/original/npc.less index 506bf8de..21cb7be1 100644 --- a/less/original/npc.less +++ b/less/original/npc.less @@ -30,5 +30,28 @@ .summary { font-size: 18px; + + li.creature-type { + display: flex; + justify-content: space-between; + width: 1em; + padding: 0 3px; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .config-button { + display: none; + font-size: 12px; + font-weight: normal; + line-height: 2em; + } + &:hover .config-button { + display: block; + } + } } } \ No newline at end of file diff --git a/less/update/components/actor-global.less b/less/update/components/actor-global.less index 907906f4..b37cd430 100644 --- a/less/update/components/actor-global.less +++ b/less/update/components/actor-global.less @@ -140,6 +140,7 @@ height: auto; .russoOne(17px); line-height: 24px; + width: 100%; } .proficiency { @@ -1053,10 +1054,34 @@ h1.character-name { align-self: auto; } - .npc-size { + .npc-size, .creature-type { .russoOne(18px); line-height: 28px; } + + div.creature-type { + display: flex; + justify-content: space-between; + padding: 1px 4px; + border: 1px solid transparent; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .config-button { + display: none; + font-size: 12px; + font-weight: normal; + line-height: 2em; + } + &:hover .config-button { + display: block; + } + } + .attributes { grid-template-columns: repeat(3, 1fr); footer { diff --git a/less/update/components/actor-themes.less b/less/update/components/actor-themes.less index 3f1aa997..40ee3c84 100644 --- a/less/update/components/actor-themes.less +++ b/less/update/components/actor-themes.less @@ -408,6 +408,9 @@ &.npc { .swalt-sheet { header { + div.creature-type:hover { + border-color: @inputBorderFocus; + } .experience { color: @actorProficiencyTextColor; } diff --git a/less/update/components/forms-global.less b/less/update/components/forms-global.less index f5c1aee6..c6355041 100644 --- a/less/update/components/forms-global.less +++ b/less/update/components/forms-global.less @@ -1,4 +1,4 @@ -input[type="text"], input[type="number"], input[type="password"], input[type="date"], input[type="time"], select, textarea { +input[type="text"], input[type="number"], input[type="password"], input[type="date"], input[type="time"], select, textarea, .roundTransition { border-radius: 4px; transition: all 0.3s; &:hover { diff --git a/module/actor/entity.js b/module/actor/entity.js index 981d1aec..014c30c3 100644 --- a/module/actor/entity.js +++ b/module/actor/entity.js @@ -1,21 +1,62 @@ import { d20Roll, damageRoll } from "../dice.js"; +import SelectItemsPrompt from "../apps/select-items-prompt.js"; import ShortRestDialog from "../apps/short-rest.js"; import LongRestDialog from "../apps/long-rest.js"; import {SW5E} from '../config.js'; +import Item5e from "../item/entity.js"; /** * Extend the base Actor class to implement additional system-specific logic for SW5e. + * @extends {Actor} */ export default class Actor5e extends Actor { + /** + * The data source for Actor5e.classes allowing it to be lazily computed. + * @type {Object} + * @private + */ + _classes = undefined; + + /* -------------------------------------------- */ + /* Properties */ + /* -------------------------------------------- */ + + /** + * A mapping of classes belonging to this Actor. + * @type {Object} + */ + get classes() { + if ( this._classes !== undefined ) return this._classes; + if ( this.data.type !== "character" ) return this._classes = {}; + return this._classes = this.items.filter((item) => item.type === "class").reduce((obj, cls) => { + obj[cls.name.slugify({strict: true})] = cls; + return obj; + }, {}); + } + + /* -------------------------------------------- */ + /** * Is this Actor currently polymorphed into some other creature? - * @return {boolean} + * @type {boolean} */ get isPolymorphed() { return this.getFlag("sw5e", "isPolymorphed") || false; } + /* -------------------------------------------- */ + /* Methods */ + /* -------------------------------------------- */ + + /** @override */ + prepareData() { + super.prepareData(); + + // iterate over owned items and recompute attributes that depend on prepared actor data + this.items.forEach(item => item.prepareFinalAttributes()); + } + /* -------------------------------------------- */ /** @override */ @@ -81,6 +122,9 @@ export default class Actor5e extends Actor { // Prepare skills this._prepareSkills(actorData, bonuses, checkBonus, originalSkills); + // Reset class store to ensure it is updated with any changes + this._classes = undefined; + // Determine Initiative Modifier const init = data.attributes.init; const athlete = flags.remarkableAthlete; @@ -93,18 +137,14 @@ export default class Actor5e extends Actor { init.bonus = init.value + (flags.initiativeAlert ? 5 : 0); init.total = init.mod + init.prof + init.bonus; - // Prepare power-casting data - data.attributes.powerForceLightDC = 8 + data.abilities.wis.mod + data.attributes.prof ?? 10; - data.attributes.powerForceDarkDC = 8 + data.abilities.cha.mod + data.attributes.prof ?? 10; - data.attributes.powerForceUnivDC = Math.max(data.attributes.powerForceLightDC,data.attributes.powerForceDarkDC) ?? 10; - data.attributes.powerTechDC = 8 + data.abilities.int.mod + data.attributes.prof ?? 10; - this._computePowercastingProgression(this.data); + // Cache labels + this.labels = {}; + if ( this.type === "npc" ) { + this.labels["creatureType"] = this.constructor.formatCreatureType(data.details.type); + } - // Compute owned item attributes which depend on prepared Actor data - this.items.forEach(item => { - item.getSaveDC(); - item.getAttackToHit(); - }); + // Prepare power-casting data + this._computePowercastingProgression(this.data); } /* -------------------------------------------- */ @@ -133,21 +173,63 @@ export default class Actor5e extends Actor { /* -------------------------------------------- */ - /** @override */ + /** @inheritdoc */ getRollData() { const data = super.getRollData(); - data.classes = this.data.items.reduce((obj, i) => { - if ( i.type === "class" ) { - obj[i.name.slugify({strict: true})] = i.data; - } + data.prof = this.data.data.attributes.prof || 0; + data.classes = Object.entries(this.classes).reduce((obj, e) => { + const [slug, cls] = e; + obj[slug] = cls.data.data; return obj; }, {}); - data.prof = this.data.data.attributes.prof || 0; return data; } /* -------------------------------------------- */ + /** + * Given a list of items to add to the Actor, optionally prompt the + * user for which they would like to add. + * @param {Array.} items - The items being added to the Actor. + * @param {boolean} [prompt=true] - Whether or not to prompt the user. + * @returns {Promise} + */ + async addEmbeddedItems(items, prompt=true) { + let itemsToAdd = items; + if ( !items.length ) return []; + + // Obtain the array of item creation data + let toCreate = []; + if (prompt) { + const itemIdsToAdd = await SelectItemsPrompt.create(items, { + hint: game.i18n.localize('SW5E.AddEmbeddedItemPromptHint') + }); + for (let item of items) { + if (itemIdsToAdd.includes(item.id)) toCreate.push(item.toObject()); + } + } else { + toCreate = items.map(item => item.toObject()); + } + + // Create the requested items + if (itemsToAdd.length === 0) return []; + return Item5e.createDocuments(toCreate, {parent: this}); + } + + /* -------------------------------------------- */ + + /** + * Get a list of features to add to the Actor when a class item is updated. + * Optionally prompt the user for which they would like to add. + */ + async getClassFeatures({className, archetypeName, level}={}) { + const existing = new Set(this.items.map(i => i.name)); + const features = await Actor5e.loadClassFeatures({className, archetypeName, level}); + return features.filter(f => !existing.has(f.name)) || []; + } + + /* -------------------------------------------- */ + /** * Return the features which a character is awarded for each class level * @param {string} className The class name being added @@ -156,7 +238,7 @@ export default class Actor5e extends Actor { * @param {number} priorLevel The previous level of the added class * @return {Promise} Array of Item5e entities */ - static async getClassFeatures({className="", archetypeName="", level=1, priorLevel=0}={}) { + static async loadClassFeatures({className="", archetypeName="", level=1, priorLevel=0}={}) { className = className.toLowerCase(); archetypeName = archetypeName.slugify(); @@ -195,52 +277,6 @@ export default class Actor5e extends Actor { return features; } - /* -------------------------------------------- */ - - /** @override */ - async updateEmbeddedEntity(embeddedName, data, options={}) { - const createItems = embeddedName === "OwnedItem" ? await this._createClassFeatures(data) : []; - let updated = await super.updateEmbeddedEntity(embeddedName, data, options); - if ( createItems.length ) await this.createEmbeddedEntity("OwnedItem", createItems); - return updated; - } - - /* -------------------------------------------- */ - - /** - * Create additional class features in the Actor when a class item is updated. - * @private - */ - async _createClassFeatures(updated) { - let toCreate = []; - for (let u of updated instanceof Array ? updated : [updated]) { - const item = this.items.get(u._id); - if (!item || (item.data.type !== "class")) continue; - const updateData = expandObject(u); - const config = { - className: updateData.name || item.data.name, - archetypeName: getProperty(updateData, "data.archetype") || item.data.data.archetype, - level: getProperty(updateData, "data.levels"), - priorLevel: item ? item.data.data.levels : 0 - } - - // Get and create features for an increased class level - let changed = false; - if ( config.level && (config.level > config.priorLevel)) changed = true; - if ( config.archetypeName !== item.data.data.archetype ) changed = true; - - // Get features to create - if ( changed ) { - const existing = new Set(this.items.map(i => i.name)); - const features = await Actor5e.getClassFeatures(config); - for ( let f of features ) { - if ( !existing.has(f.name) ) toCreate.push(f); - } - } - } - return toCreate - } - /* -------------------------------------------- */ /* Data Preparation Helpers */ /* -------------------------------------------- */ @@ -252,11 +288,11 @@ export default class Actor5e extends Actor { const data = actorData.data; // Determine character level and available hit dice based on owned Class items - const [level, hd] = actorData.items.reduce((arr, item) => { + const [level, hd] = this.items.reduce((arr, item) => { if ( item.type === "class" ) { - const classLevels = parseInt(item.data.levels) || 1; + const classLevels = parseInt(item.data.data.levels) || 1; arr[0] += classLevels; - arr[1] += classLevels - (parseInt(item.data.hitDiceUsed) || 0); + arr[1] += classLevels - (parseInt(item.data.data.hitDiceUsed) || 0); } return arr; }, [0, 0]); @@ -317,7 +353,7 @@ export default class Actor5e extends Actor { const data = actorData.data; // Proficiency - data.attributes.prof = Math.floor((Math.max(data.details.tier, 1) + 7) / 4); + data.attributes.prof = Math.floor((Math.max(data.details.tier, 1) + 7) / 4); // Link hull to hp and shields to temp hp data.attributes.hull.value = data.attributes.hp.value; @@ -388,9 +424,17 @@ export default class Actor5e extends Actor { */ _computePowercastingProgression (actorData) { if (actorData.type === 'vehicle' || actorData.type === 'starship') return; - const powers = actorData.data.powers; + const ad = actorData.data; + const powers = ad.powers; const isNPC = actorData.type === 'npc'; + // Powercasting DC + // TODO: Consider an option for using the variant rule of all powers use the same value + ad.attributes.powerForceLightDC = 8 + ad.abilities.wis.mod + ad.attributes.prof ?? 10; + ad.attributes.powerForceDarkDC = 8 + ad.abilities.cha.mod + ad.attributes.prof ?? 10; + ad.attributes.powerForceUnivDC = Math.max(ad.attributes.powerForceLightDC,ad.attributes.powerForceDarkDC) ?? 10 + ad.attributes.powerTechDC = 8 + ad.abilities.int.mod + ad.attributes.prof ?? 10; + // Translate the list of classes into force and tech power-casting progression const forceProgression = { classes: 0, @@ -419,11 +463,11 @@ export default class Actor5e extends Actor { const classes = this.data.items.filter(i => i.type === "class"); let priority = 0; for ( let cls of classes ) { - const d = cls.data; - if ( d.powercasting === "none" ) continue; + const d = cls.data.data; + if ( d.powercasting.progression === "none" ) continue; const levels = d.levels; - const prog = d.powercasting; - + const prog = d.powercasting.progression; + // TODO: Consider a more dynamic system switch (prog) { case 'consular': priority = 3; @@ -504,6 +548,7 @@ export default class Actor5e extends Actor { } // EXCEPTION: multi-classed progression uses multi rounded down rather than levels + // TODO: This could be cleaned up a little, one change at a time though if (!isNPC && forceProgression.classes > 1) { forceProgression.levels = Math.floor(forceProgression.multi); forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['multi'][forceProgression.levels - 1]; @@ -514,16 +559,16 @@ export default class Actor5e extends Actor { } // EXCEPTION: NPC with an explicit power-caster level - if (isNPC && actorData.data.details.powerForceLevel) { - forceProgression.levels = actorData.data.details.powerForceLevel; - actorData.data.attributes.force.level = forceProgression.levels; - forceProgression.maxClass = actorData.data.attributes.powercasting; + if (isNPC && ad.details.powerForceLevel) { + forceProgression.levels = ad.details.powerForceLevel; + ad.attributes.force.level = forceProgression.levels; + forceProgression.maxClass = ad.attributes.powercasting; forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel[forceProgression.maxClass][Math.clamped((forceProgression.levels - 1), 0, 20)]; } - if (isNPC && actorData.data.details.powerTechLevel) { - techProgression.levels = actorData.data.details.powerTechLevel; - actorData.data.attributes.tech.level = techProgression.levels; - techProgression.maxClass = actorData.data.attributes.powercasting; + if (isNPC && ad.details.powerTechLevel) { + techProgression.levels = ad.details.powerTechLevel; + ad.attributes.tech.level = techProgression.levels; + techProgression.maxClass = ad.attributes.powercasting; techProgression.maxClassPowerLevel = SW5E.powerMaxLevel[techProgression.maxClass][Math.clamped((techProgression.levels - 1), 0, 20)]; } @@ -563,15 +608,16 @@ export default class Actor5e extends Actor { } // Set Force and tech power for PC Actors + // TODO: Can join these !NPCs to save a whole comparison if (!isNPC && forceProgression.levels){ - actorData.data.attributes.force.known.max = forceProgression.powersKnown; - actorData.data.attributes.force.points.max = forceProgression.points + Math.max(actorData.data.abilities.wis.mod,actorData.data.abilities.cha.mod); - actorData.data.attributes.force.level = forceProgression.levels; + ad.attributes.force.known.max = forceProgression.powersKnown; + ad.attributes.force.points.max = forceProgression.points + Math.max(ad.abilities.wis.mod,ad.abilities.cha.mod); + ad.attributes.force.level = forceProgression.levels; } if (!isNPC && techProgression.levels){ - actorData.data.attributes.tech.known.max = techProgression.powersKnown; - actorData.data.attributes.tech.points.max = techProgression.points + actorData.data.abilities.int.mod; - actorData.data.attributes.tech.level = techProgression.levels; + ad.attributes.tech.known.max = techProgression.powersKnown; + ad.attributes.tech.points.max = techProgression.points + ad.abilities.int.mod; + ad.attributes.tech.level = techProgression.levels; } // Tally Powers Known and check for migration first to avoid errors @@ -596,8 +642,8 @@ export default class Actor5e extends Actor { } continue; } - actorData.data.attributes.force.known.value = knownForcePowers; - actorData.data.attributes.tech.known.value = knownTechPowers; + ad.attributes.force.known.value = knownForcePowers; + ad.attributes.tech.known.value = knownTechPowers; } } @@ -613,13 +659,13 @@ export default class Actor5e extends Actor { * @private */ _computeEncumbrance(actorData) { - + // TODO: Maybe add an option for variant encumbrance // Get the total weight from items const physicalItems = ["weapon", "equipment", "consumable", "tool", "backpack", "loot"]; let weight = actorData.items.reduce((weight, i) => { if ( !physicalItems.includes(i.type) ) return weight; - const q = i.data.quantity || 0; - const w = i.data.weight || 0; + const q = i.data.data.quantity || 0; + const w = i.data.data.weight || 0; return weight + (q * w); }, 0); @@ -649,128 +695,58 @@ export default class Actor5e extends Actor { } /* -------------------------------------------- */ - /* Socket Listeners and Handlers + /* Event Handlers /* -------------------------------------------- */ - /** @override */ - static async create(data, options={}) { - data.token = data.token || {}; - if ( data.type === "character" ) { - mergeObject(data.token, { - vision: true, - dimSight: 30, - brightSight: 0, - actorLink: true, - disposition: 1 - }, {overwrite: false}); + /** @inheritDoc */ + async _preCreate(data, options, user) { + await super._preCreate(data, options, user); + + // Token size category + const s = CONFIG.SW5E.tokenSizes[this.data.data.traits.size || "med"]; + this.data.token.update({width: s, height: s}); + + // Player character configuration + if ( this.type === "character" ) { + this.data.token.update({vision: true, actorLink: true, disposition: 1}); } - return super.create(data, options); } /* -------------------------------------------- */ - /** @override */ - async update(data, options={}) { + /** @inheritdoc */ + async _preUpdate(changed, options, user) { + await super._preUpdate(changed, options, user); // Apply changes in Actor size to Token width/height - const newSize = getProperty(data, "data.traits.size"); - if ( newSize && (newSize !== getProperty(this.data, "data.traits.size")) ) { + const newSize = foundry.utils.getProperty(changed, "data.traits.size"); + if ( newSize && (newSize !== foundry.utils.getProperty(this.data, "data.traits.size")) ) { let size = CONFIG.SW5E.tokenSizes[newSize]; - if ( this.isToken ) this.token.update({height: size, width: size}); - else if ( !data["token.width"] && !hasProperty(data, "token.width") ) { - data["token.height"] = size; - data["token.width"] = size; + if ( !foundry.utils.hasProperty(changed, "token.width") ) { + changed.token = changed.token || {}; + changed.token.height = size; + changed.token.width = size; } } // Reset death save counters - if ( (this.data.data.attributes.hp.value <= 0) && (getProperty(data, "data.attributes.hp.value") > 0) ) { - setProperty(data, "data.attributes.death.success", 0); - setProperty(data, "data.attributes.death.failure", 0); + const isDead = this.data.data.attributes.hp.value <= 0; + if ( isDead && (foundry.utils.getProperty(changed, "data.attributes.hp.value") > 0) ) { + foundry.utils.setProperty(changed, "data.attributes.death.success", 0); + foundry.utils.setProperty(changed, "data.attributes.death.failure", 0); } - - // Perform the update - return super.update(data, options); - } - - /* -------------------------------------------- */ - - /** @override */ - async createEmbeddedEntity(embeddedName, itemData, options={}) { - - // Pre-creation steps for owned items - if ( embeddedName === "OwnedItem" ) this._preCreateOwnedItem(itemData, options); - - // Standard embedded entity creation - return super.createEmbeddedEntity(embeddedName, itemData, options); } /* -------------------------------------------- */ /** - * A temporary shim function which will eventually (in core fvtt version 0.8.0+) be migrated to the new abstraction layer - * @param itemData - * @param options - * @private + * Assign a class item as the original class for the Actor based on which class has the most levels + * @protected */ - _preCreateOwnedItem(itemData, options) { - if ( this.data.type === "vehicle" ) return; - const isNPC = this.data.type === 'npc'; - let initial = {}; - switch ( itemData.type ) { - - case "weapon": - if ( getProperty(itemData, "data.equipped") === undefined ) { - initial["data.equipped"] = isNPC; // NPCs automatically equip weapons - } - if ( getProperty(itemData, "data.proficient") === undefined ) { - if ( isNPC ) { - initial["data.proficient"] = true; // NPCs automatically have equipment proficiency - } else { - const weaponProf = { - "natural": true, - "simpleVW": "sim", - "simpleB": "sim", - "simpleLW": "sim", - "martialVW": "mar", - "martialB": "mar", - "martialLW": "mar" - }[itemData.data?.weaponType]; // Player characters check proficiency - const actorWeaponProfs = this.data.data.traits?.weaponProf?.value || []; - const hasWeaponProf = (weaponProf === true) || actorWeaponProfs.includes(weaponProf); - initial["data.proficient"] = hasWeaponProf; - } - } - break; - - case "equipment": - if ( getProperty(itemData, "data.equipped") === undefined ) { - initial["data.equipped"] = isNPC; // NPCs automatically equip equipment - } - if ( getProperty(itemData, "data.proficient") === undefined ) { - if ( isNPC ) { - initial["data.proficient"] = true; // NPCs automatically have equipment proficiency - } else { - const armorProf = { - "natural": true, - "clothing": true, - "light": "lgt", - "medium": "med", - "heavy": "hvy", - "shield": "shl" - }[itemData.data?.armor?.type]; // Player characters check proficiency - const actorArmorProfs = this.data.data.traits?.armorProf?.value || []; - const hasEquipmentProf = (armorProf === true) || actorArmorProfs.includes(armorProf); - initial["data.proficient"] = hasEquipmentProf; - } - } - break; - - case "power": - initial["data.prepared"] = true; // automatically prepare powers for everyone - break; - } - mergeObject(itemData, initial); + _assignPrimaryClass() { + const classes = this.itemTypes.class.sort((a, b) => b.data.data.levels - a.data.data.levels); + const newPC = classes[0]?.id || ""; + return this.update({"data.details.originalClass": newPC}); } /* -------------------------------------------- */ @@ -862,15 +838,17 @@ export default class Actor5e extends Actor { const reliableTalent = (skl.value >= 1 && this.getFlag("sw5e", "reliableTalent")); // Roll and return - const rollData = mergeObject(options, { + const rollData = foundry.utils.mergeObject(options, { parts: parts, data: data, title: game.i18n.format("SW5E.SkillPromptTitle", {skill: CONFIG.SW5E.skills[skillId] || CONFIG.SW5E.starshipSkills[skillId]}), halflingLucky: this.getFlag("sw5e", "halflingLucky"), reliableTalent: reliableTalent, - messageData: {"flags.sw5e.roll": {type: "skill", skillId }} + messageData: { + speaker: options.speaker || ChatMessage.getSpeaker({actor: this}), + "flags.sw5e.roll": {type: "skill", skillId } + } }); - rollData.speaker = options.speaker || ChatMessage.getSpeaker({actor: this}); return d20Roll(rollData); } @@ -941,14 +919,16 @@ export default class Actor5e extends Actor { } // Roll and return - const rollData = mergeObject(options, { + const rollData = foundry.utils.mergeObject(options, { parts: parts, data: data, title: game.i18n.format("SW5E.AbilityPromptTitle", {ability: label}), halflingLucky: feats.halflingLucky, - messageData: {"flags.sw5e.roll": {type: "ability", abilityId }} + messageData: { + speaker: options.speaker || ChatMessage.getSpeaker({actor: this}), + "flags.sw5e.roll": {type: "ability", abilityId } + } }); - rollData.speaker = options.speaker || ChatMessage.getSpeaker({actor: this}); return d20Roll(rollData); } @@ -988,14 +968,16 @@ export default class Actor5e extends Actor { } // Roll and return - const rollData = mergeObject(options, { + const rollData = foundry.utils.mergeObject(options, { parts: parts, data: data, title: game.i18n.format("SW5E.SavePromptTitle", {ability: label}), halflingLucky: this.getFlag("sw5e", "halflingLucky"), - messageData: {"flags.sw5e.roll": {type: "save", abilityId }} + messageData: { + speaker: options.speaker || ChatMessage.getSpeaker({actor: this}), + "flags.sw5e.roll": {type: "save", abilityId } + } }); - rollData.speaker = options.speaker || ChatMessage.getSpeaker({actor: this}); return d20Roll(rollData); } @@ -1018,26 +1000,26 @@ export default class Actor5e extends Actor { // Evaluate a global saving throw bonus const parts = []; const data = {}; - const speaker = options.speaker || ChatMessage.getSpeaker({actor: this}); // Include a global actor ability save bonus - const bonuses = getProperty(this.data.data, "bonuses.abilities") || {}; + const bonuses = foundry.utils.getProperty(this.data.data, "bonuses.abilities") || {}; if ( bonuses.save ) { parts.push("@saveBonus"); data.saveBonus = bonuses.save; } // Evaluate the roll - const rollData = mergeObject(options, { + const rollData = foundry.utils.mergeObject(options, { parts: parts, data: data, title: game.i18n.localize("SW5E.DeathSavingThrow"), - speaker: speaker, halflingLucky: this.getFlag("sw5e", "halflingLucky"), targetValue: 10, - messageData: {"flags.sw5e.roll": {type: "death"}} + messageData: { + speaker: options.speaker || ChatMessage.getSpeaker({actor: this}), + "flags.sw5e.roll": {type: "death"} + } }); - rollData.speaker = speaker; const roll = await d20Roll(rollData); if ( !roll ) return null; @@ -1045,6 +1027,8 @@ export default class Actor5e extends Actor { const success = roll.total >= 10; const d20 = roll.dice[0].total; + let chatString; + // Save success if ( success ) { let successes = (death.success || 0) + 1; @@ -1056,7 +1040,7 @@ export default class Actor5e extends Actor { "data.attributes.death.failure": 0, "data.attributes.hp.value": 1 }); - await ChatMessage.create({content: game.i18n.format("SW5E.DeathSaveCriticalSuccess", {name: this.name}), speaker}); + chatString = "SW5E.DeathSaveCriticalSuccess"; } // 3 Successes = survive and reset checks @@ -1065,7 +1049,7 @@ export default class Actor5e extends Actor { "data.attributes.death.success": 0, "data.attributes.death.failure": 0 }); - await ChatMessage.create({content: game.i18n.format("SW5E.DeathSaveSuccess", {name: this.name}), speaker}); + chatString = "SW5E.DeathSaveSuccess"; } // Increment successes @@ -1077,10 +1061,17 @@ export default class Actor5e extends Actor { let failures = (death.failure || 0) + (d20 === 1 ? 2 : 1); await this.update({"data.attributes.death.failure": Math.clamped(failures, 0, 3)}); if ( failures >= 3 ) { // 3 Failures = death - await ChatMessage.create({content: game.i18n.format("SW5E.DeathSaveFailure", {name: this.name}), speaker}); + chatString = "SW5E.DeathSaveFailure"; } } + // Display success/failure chat message + if ( chatString ) { + let chatData = { content: game.i18n.format(chatString, {name: this.name}), speaker }; + ChatMessage.applyRollMode(chatData, roll.options.rollMode); + await ChatMessage.create(chatData); + } + // Return the rolled result return roll; } @@ -1121,7 +1112,7 @@ export default class Actor5e extends Actor { // Prepare roll data const parts = [`1${denomination}`, "@abilities.con.mod"]; const title = game.i18n.localize("SW5E.HitDiceRoll"); - const rollData = duplicate(this.data.data); + const rollData = foundry.utils.deepClone(this.data.data); // Call the roll helper utility const roll = await damageRoll({ @@ -1129,11 +1120,13 @@ export default class Actor5e extends Actor { parts: parts, data: rollData, title: title, - speaker: ChatMessage.getSpeaker({actor: this}), - allowcritical: false, + allowCritical: false, fastForward: !dialog, dialogOptions: {width: 350}, - messageData: {"flags.sw5e.roll": {type: "hitDie"}} + messageData: { + speaker: ChatMessage.getSpeaker({actor: this}), + "flags.sw5e.roll": {type: "hitDie"} + } }); if ( !roll ) return null; @@ -1148,20 +1141,34 @@ export default class Actor5e extends Actor { /* -------------------------------------------- */ /** - * Cause this Actor to take a Short Rest and regain all Tech Points - * During a Short Rest resources and limited item uses may be recovered - * @param {boolean} dialog Present a dialog window which allows for rolling hit dice as part of the Short Rest - * @param {boolean} chat Summarize the results of the rest workflow as a chat message - * @param {boolean} autoHD Automatically spend Hit Dice if you are missing 3 or more hit points - * @param {boolean} autoHDThreshold A number of missing hit points which would trigger an automatic HD roll - * @return {Promise} A Promise which resolves once the short rest workflow has completed + * Results from a rest operation. + * + * @typedef {object} RestResult + * @property {number} dhp Hit points recovered during the rest. + * @property {number} dhd Hit dice recovered or spent during the rest. + * @property {object} updateData Updates applied to the actor. + * @property {Array.} updateItems Updates applied to actor's items. + * @property {boolean} newDay Whether a new day occurred during the rest. + */ + + /* -------------------------------------------- */ + + /** + * Take a short rest, possibly spending hit dice and recovering resources, item uses, and tech slots & points. + * + * @param {object} [options] + * @param {boolean} [options.dialog=true] Present a dialog window which allows for rolling hit dice as part + * of the Short Rest and selecting whether a new day has occurred. + * @param {boolean} [options.chat=true] Summarize the results of the rest workflow as a chat message. + * @param {boolean} [options.autoHD=false] Automatically spend Hit Dice if you are missing 3 or more hit points. + * @param {boolean} [options.autoHDThreshold=3] A number of missing hit points which would trigger an automatic HD roll. + * @return {Promise.} A Promise which resolves once the short rest workflow has completed. */ async shortRest({dialog=true, chat=true, autoHD=false, autoHDThreshold=3}={}) { // Take note of the initial hit points and number of hit dice the Actor has - const hp = this.data.data.attributes.hp; const hd0 = this.data.data.attributes.hd; - const hp0 = hp.value; + const hp0 = this.data.data.attributes.hp.value; let newDay = false; // Display a Dialog for rolling hit dice @@ -1175,96 +1182,24 @@ export default class Actor5e extends Actor { // Automatically spend hit dice else if ( autoHD ) { - while ( (hp.value + autoHDThreshold) <= hp.max ) { - const r = await this.rollHitDie(undefined, {dialog: false}); - if ( r === null ) break; - } + await this.autoSpendHitDice({ threshold: autoHDThreshold }); } - // Note the change in HP and HD and TP which occurred - const dhd = this.data.data.attributes.hd - hd0; - const dhp = this.data.data.attributes.hp.value - hp0; - const dtp = this.data.data.attributes.tech.points.max - this.data.data.attributes.tech.points.value; - - // Automatically Retore Tech Points - this.update({"data.attributes.tech.points.value": this.data.data.attributes.tech.points.max}); - - // Recover character resources - const updateData = {}; - for ( let [k, r] of Object.entries(this.data.data.resources) ) { - if ( r.max && r.sr ) { - updateData[`data.resources.${k}.value`] = r.max; - } - } - - // Recover item uses - const recovery = newDay ? ["sr", "day"] : ["sr"]; - const items = this.items.filter(item => item.data.data.uses && recovery.includes(item.data.data.uses.per)); - const updateItems = items.map(item => { - return { - _id: item._id, - "data.uses.value": item.data.data.uses.max - }; - }); - await this.updateEmbeddedEntity("OwnedItem", updateItems); - - // Display a Chat Message summarizing the rest effects - if ( chat ) { - - // Summarize the rest duration - let restFlavor; - switch (game.settings.get("sw5e", "restVariant")) { - case 'normal': restFlavor = game.i18n.localize("SW5E.ShortRestNormal"); break; - case 'gritty': restFlavor = game.i18n.localize(newDay ? "SW5E.ShortRestOvernight" : "SW5E.ShortRestGritty"); break; - case 'epic': restFlavor = game.i18n.localize("SW5E.ShortRestEpic"); break; - } - - // Summarize the health effects - let srMessage = "SW5E.ShortRestResultShort"; - if ((dhd !== 0) && (dhp !== 0)){ - if (dtp !== 0){ - srMessage = "SW5E.ShortRestResultWithTech"; - }else{ - srMessage = "SW5E.ShortRestResult"; - } - }else{ - if (dtp !== 0){ - srMessage = "SW5E.ShortRestResultOnlyTech"; - } - } - - // Create a chat message - ChatMessage.create({ - user: game.user._id, - speaker: {actor: this, alias: this.name}, - flavor: restFlavor, - content: game.i18n.format(srMessage, {name: this.name, dice: -dhd, health: dhp, tech: dtp}) - }); - } - - // Return data summarizing the rest effects - return { - dhd: dhd, - dhp: dhp, - dtp: dtp, - updateData: updateData, - updateItems: updateItems, - newDay: newDay - } + return this._rest(chat, newDay, false, this.data.data.attributes.hd - hd0, this.data.data.attributes.hp.value - hp0, this.data.data.attributes.tech.points.max - this.data.data.attributes.tech.points.value); } /* -------------------------------------------- */ /** - * Take a long rest, recovering HP, HD, resources, Force and Power points and power slots - * @param {boolean} dialog Present a confirmation dialog window whether or not to take a long rest - * @param {boolean} chat Summarize the results of the rest workflow as a chat message - * @param {boolean} newDay Whether the long rest carries over to a new day - * @return {Promise} A Promise which resolves once the long rest workflow has completed + * Take a long rest, recovering hit points, hit dice, resources, item uses, and tech & force power points & slots. + * + * @param {object} [options] + * @param {boolean} [options.dialog=true] Present a confirmation dialog window whether or not to take a long rest. + * @param {boolean} [options.chat=true] Summarize the results of the rest workflow as a chat message. + * @param {boolean} [options.newDay=true] Whether the long rest carries over to a new day. + * @return {Promise.} A Promise which resolves once the long rest workflow has completed. */ async longRest({dialog=true, chat=true, newDay=true}={}) { - const data = this.data.data; - // Maybe present a confirmation dialog if ( dialog ) { try { @@ -1274,109 +1209,304 @@ export default class Actor5e extends Actor { } } - // Recover hit, tech, and force points to full, and eliminate any existing temporary HP, TP, and FP - const dhp = data.attributes.hp.max - data.attributes.hp.value; - const dtp = data.attributes.tech.points.max - data.attributes.tech.points.value; - const dfp = data.attributes.force.points.max - data.attributes.force.points.value; - const updateData = { - "data.attributes.hp.value": data.attributes.hp.max, - "data.attributes.hp.temp": 0, - "data.attributes.hp.tempmax": 0, - "data.attributes.tech.points.value": data.attributes.tech.points.max, - "data.attributes.tech.points.temp": 0, - "data.attributes.tech.points.tempmax": 0, - "data.attributes.force.points.value": data.attributes.force.points.max, - "data.attributes.force.points.temp": 0, - "data.attributes.force.points.tempmax": 0 - }; - - // Recover character resources - for ( let [k, r] of Object.entries(data.resources) ) { - if ( r.max && (r.sr || r.lr) ) { - updateData[`data.resources.${k}.value`] = r.max; - } - } - - // Recover power slots - for ( let [k, v] of Object.entries(data.powers) ) { - updateData[`data.powers.${k}.fvalue`] = Number.isNumeric(v.foverride) ? v.foverride : (v.fmax ?? 0); - } - for ( let [k, v] of Object.entries(data.powers) ) { - updateData[`data.powers.${k}.tvalue`] = Number.isNumeric(v.toverride) ? v.toverride : (v.tmax ?? 0); - } - // Determine the number of hit dice which may be recovered - let recoverHD = Math.max(Math.floor(data.details.level / 2), 1); - let dhd = 0; - - // Sort classes which can recover HD, assuming players prefer recovering larger HD first. - const updateItems = this.items.filter(item => item.data.type === "class").sort((a, b) => { - let da = parseInt(a.data.data.hitDice.slice(1)) || 0; - let db = parseInt(b.data.data.hitDice.slice(1)) || 0; - return db - da; - }).reduce((updates, item) => { - const d = item.data.data; - if ( (recoverHD > 0) && (d.hitDiceUsed > 0) ) { - let delta = Math.min(d.hitDiceUsed || 0, recoverHD); - recoverHD -= delta; - dhd += delta; - updates.push({_id: item.id, "data.hitDiceUsed": d.hitDiceUsed - delta}); - } - return updates; - }, []); - - // Iterate over owned items, restoring uses per day and recovering Hit Dice - const recovery = newDay ? ["sr", "lr", "day"] : ["sr", "lr"]; - for ( let item of this.items ) { - const d = item.data.data; - if ( d.uses && recovery.includes(d.uses.per) ) { - updateItems.push({_id: item.id, "data.uses.value": d.uses.max}); - } - else if ( d.recharge && d.recharge.value ) { - updateItems.push({_id: item.id, "data.recharge.charged": true}); - } - } - - // Perform the updates - await this.update(updateData); - if ( updateItems.length ) await this.updateEmbeddedEntity("OwnedItem", updateItems); - - // Display a Chat Message summarizing the rest effects - let restFlavor; - switch (game.settings.get("sw5e", "restVariant")) { - case 'normal': restFlavor = game.i18n.localize(newDay ? "SW5E.LongRestOvernight" : "SW5E.LongRestNormal"); break; - case 'gritty': restFlavor = game.i18n.localize("SW5E.LongRestGritty"); break; - case 'epic': restFlavor = game.i18n.localize("SW5E.LongRestEpic"); break; - } - - // Determine the chat message to display - if ( chat ) { - let lrMessage = "SW5E.LongRestResult"; - if (dhp !== 0) lrMessage += "HP"; - if (dfp !== 0) lrMessage += "FP"; - if (dtp !== 0) lrMessage += "TP"; - if (dhd !== 0) lrMessage += "HD"; - ChatMessage.create({ - user: game.user._id, - speaker: {actor: this, alias: this.name}, - flavor: restFlavor, - content: game.i18n.format(lrMessage, {name: this.name, health: dhp, tech: dtp, force: dfp, dice: dhd}) - }); - } - - // Return data summarizing the rest effects - return { - dhd: dhd, - dhp: dhp, - dtp: dtp, - dfp: dfp, - updateData: updateData, - updateItems: updateItems, - newDay: newDay - } + return this._rest(chat, newDay, true, 0, 0, this.data.data.attributes.tech.points.max - this.data.data.attributes.tech.points.value, this.data.data.attributes.force.points.max - this.data.data.attributes.force.points.value); } /* -------------------------------------------- */ + /** + * Perform all of the changes needed for a short or long rest. + * + * @param {boolean} chat Summarize the results of the rest workflow as a chat message. + * @param {boolean} newDay Has a new day occurred during this rest? + * @param {boolean} longRest Is this a long rest? + * @param {number} [dhd=0] Number of hit dice spent during so far during the rest. + * @param {number} [dhp=0] Number of hit points recovered so far during the rest. + * @param {number} [dtp=0] Number of tech points recovered so far during the rest. + * @param {number} [dfp=0] Number of force points recovered so far during the rest. + * @return {Promise.} Consolidated results of the rest workflow. + * @private + */ + async _rest(chat, newDay, longRest, dhd=0, dhp=0, dtp=0, dfp=0) { + // TODO: Turn gritty realism into the SW5e longer rests variant rule https://sw5e.com/rules/variantRules/Longer%20Rests + let hitPointsRecovered = 0; + let hitPointUpdates = {}; + let hitDiceRecovered = 0; + let hitDiceUpdates = []; + + // Recover hit points & hit dice on long rest + if ( longRest ) { + ({ updates: hitPointUpdates, hitPointsRecovered } = this._getRestHitPointRecovery()); + ({ updates: hitDiceUpdates, hitDiceRecovered } = this._getRestHitDiceRecovery()); + } + + // Figure out the rest of the changes + const result = { + dhd: dhd + hitDiceRecovered, + dhp: dhp + hitPointsRecovered, + dtp: dtp, + dfp: dfp, + updateData: { + ...hitPointUpdates, + ...this._getRestResourceRecovery({ recoverShortRestResources: !longRest, recoverLongRestResources: longRest }), + ...this._getRestPowerRecovery({ recoverForcePowers: longRest }) + }, + updateItems: [ + ...hitDiceUpdates, + ...this._getRestItemUsesRecovery({ recoverLongRestUses: longRest, recoverDailyUses: newDay }) + ], + newDay: newDay + } + + // Perform updates + await this.update(result.updateData); + await this.updateEmbeddedDocuments("Item", result.updateItems); + + // Display a Chat Message summarizing the rest effects + if ( chat ) await this._displayRestResultMessage(result, longRest); + + // Return data summarizing the rest effects + return result; + } + + /* -------------------------------------------- */ + + /** + * Display a chat message with the result of a rest. + * + * @param {RestResult} result Result of the rest operation. + * @param {boolean} [longRest=false] Is this a long rest? + * @return {Promise.} Chat message that was created. + * @protected + */ + async _displayRestResultMessage(result, longRest=false) { + const { dhd, dhp, dtp, dfp, newDay } = result; + const diceRestored = dhd !== 0; + const healthRestored = dhp !== 0; + const length = longRest ? "Long" : "Short"; + + let restFlavor, message; + + // Summarize the rest duration + switch (game.settings.get("sw5e", "restVariant")) { + case 'normal': restFlavor = (longRest && newDay) ? "SW5E.LongRestOvernight" : `SW5E.${length}RestNormal`; break; + case 'gritty': restFlavor = (!longRest && newDay) ? "SW5E.ShortRestOvernight" : `SW5E.${length}RestGritty`; break; + case 'epic': restFlavor = `SW5E.${length}RestEpic`; break; + } + + // Determine the chat message to display + if (longRest) { + message = "SW5E.LongRestResult"; + if (dhp !== 0) message += "HP"; + if (dfp !== 0) message += "FP"; + if (dtp !== 0) message += "TP"; + if (dhd !== 0) message += "HD"; + } else { + message = "SW5E.ShortRestResultShort"; + if ((dhd !== 0) && (dhp !== 0)){ + if (dtp !== 0){ + message = "SW5E.ShortRestResultWithTech"; + }else{ + message = "SW5E.ShortRestResult"; + } + }else{ + if (dtp !== 0){ + message = "SW5E.ShortRestResultOnlyTech"; + } + } + } + + // Create a chat message + let chatData = { + user: game.user.id, + speaker: {actor: this, alias: this.name}, + flavor: game.i18n.localize(restFlavor), + content: game.i18n.format(message, { + name: this.name, + dice: longRest ? dhd : -dhd, + health: dhp, + tech: dtp, + force: dfp + }) + }; + ChatMessage.applyRollMode(chatData, game.settings.get("core", "rollMode")); + return ChatMessage.create(chatData); + } + + /* -------------------------------------------- */ + + /** + * Automatically spend hit dice to recover hit points up to a certain threshold. + * + * @param {object} [options] + * @param {number} [options.threshold=3] A number of missing hit points which would trigger an automatic HD roll. + * @return {Promise.} Number of hit dice spent. + */ + async autoSpendHitDice({ threshold=3 }={}) { + const max = this.data.data.attributes.hp.max + this.data.data.attributes.hp.tempmax; + + let diceRolled = 0; + while ( (this.data.data.attributes.hp.value + threshold) <= max ) { + const r = await this.rollHitDie(undefined, {dialog: false}); + if ( r === null ) break; + diceRolled += 1; + } + + return diceRolled; + } + + /* -------------------------------------------- */ + + /** + * Recovers actor hit points and eliminates any temp HP. + * + * @param {object} [options] + * @param {boolean} [options.recoverTemp=true] Reset temp HP to zero. + * @param {boolean} [options.recoverTempMax=true] Reset temp max HP to zero. + * @return {object} Updates to the actor and change in hit points. + * @protected + */ + _getRestHitPointRecovery({ recoverTemp=true, recoverTempMax=true }={}) { + const data = this.data.data; + let updates = {}; + let max = data.attributes.hp.max; + + if ( recoverTempMax ) { + updates["data.attributes.hp.tempmax"] = 0; + } else { + max += data.attributes.hp.tempmax; + } + updates["data.attributes.hp.value"] = max; + if ( recoverTemp ) { + updates["data.attributes.hp.temp"] = 0; + } + + return { updates, hitPointsRecovered: max - data.attributes.hp.value }; + } + + /* -------------------------------------------- */ + + /** + * Recovers actor resources. + * @param {object} [options] + * @param {boolean} [options.recoverShortRestResources=true] Recover resources that recharge on a short rest. + * @param {boolean} [options.recoverLongRestResources=true] Recover resources that recharge on a long rest. + * @return {object} Updates to the actor. + * @protected + */ + _getRestResourceRecovery({recoverShortRestResources=true, recoverLongRestResources=true}={}) { + let updates = {}; + for ( let [k, r] of Object.entries(this.data.data.resources) ) { + if ( Number.isNumeric(r.max) && ((recoverShortRestResources && r.sr) || (recoverLongRestResources && r.lr)) ) { + updates[`data.resources.${k}.value`] = Number(r.max); + } + } + return updates; + } + + /* -------------------------------------------- */ + + /** + * Recovers power slots. + * + * @param longRest = true It's a long rest + * @return {object} Updates to the actor. + * @protected + */ + _getRestPowerRecovery({ recoverTechPowers=true, recoverForcePowers=true }={}) { + let updates = {}; + + if (recoverTechPowers) { + updates["data.attributes.tech.points.value"] = this.data.data.attributes.tech.points.max; + updates["data.attributes.tech.points.temp"] = 0; + updates["data.attributes.tech.points.tempmax"] = 0; + + for (let [k, v] of Object.entries(this.data.data.powers)) { + updates[`data.powers.${k}.tvalue`] = Number.isNumeric(v.toverride) ? v.toverride : (v.tmax ?? 0); + } + } + + if (recoverForcePowers) { + updates["data.attributes.force.points.value"] = this.data.data.attributes.force.points.max; + updates["data.attributes.force.points.temp"] = 0; + updates["data.attributes.force.points.tempmax"] = 0; + + for ( let [k, v] of Object.entries(this.data.data.powers) ) { + updates[`data.powers.${k}.fvalue`] = Number.isNumeric(v.foverride) ? v.foverride : (v.fmax ?? 0); + } + } + + return updates; + } + + /* -------------------------------------------- */ + + /** + * Recovers class hit dice during a long rest. + * + * @param {object} [options] + * @param {number} [options.maxHitDice] Maximum number of hit dice to recover. + * @return {object} Array of item updates and number of hit dice recovered. + * @protected + */ + _getRestHitDiceRecovery({ maxHitDice=undefined }={}) { + // Determine the number of hit dice which may be recovered + if ( maxHitDice === undefined ) { + maxHitDice = Math.max(Math.floor(this.data.data.details.level / 2), 1); + } + + // Sort classes which can recover HD, assuming players prefer recovering larger HD first. + const sortedClasses = this.items.filter(item => item.data.type === "class").sort((a, b) => { + return (parseInt(a.data.data.hitDice.slice(1)) || 0) - (parseInt(a.data.data.hitDice.slice(1)) || 0); + }); + + let updates = []; + let hitDiceRecovered = 0; + for ( let item of sortedClasses ) { + const d = item.data.data; + if ( (hitDiceRecovered < maxHitDice) && (d.hitDiceUsed > 0) ) { + let delta = Math.min(d.hitDiceUsed || 0, maxHitDice - hitDiceRecovered); + hitDiceRecovered += delta; + updates.push({_id: item.id, "data.hitDiceUsed": d.hitDiceUsed - delta}); + } + } + + return { updates, hitDiceRecovered }; + } + + /* -------------------------------------------- */ + + /** + * Recovers item uses during short or long rests. + * + * @param {object} [options] + * @param {boolean} [options.recoverShortRestUses=true] Recover uses for items that recharge after a short rest. + * @param {boolean} [options.recoverLongRestUses=true] Recover uses for items that recharge after a long rest. + * @param {boolean} [options.recoverDailyUses=true] Recover uses for items that recharge on a new day. + * @return {Array.} Array of item updates. + * @protected + */ + _getRestItemUsesRecovery({ recoverShortRestUses=true, recoverLongRestUses=true, recoverDailyUses=true }={}) { + let recovery = []; + if ( recoverShortRestUses ) recovery.push("sr"); + if ( recoverLongRestUses ) recovery.push("lr"); + if ( recoverDailyUses ) recovery.push("day"); + + let updates = []; + for ( let item of this.items ) { + const d = item.data.data; + if ( d.uses && recovery.includes(d.uses.per) ) { + updates.push({_id: item.id, "data.uses.value": d.uses.max}); + } + if ( recoverLongRestUses && d.recharge && d.recharge.value ) { + updates.push({_id: item.id, "data.recharge.charged": true}); + } + } + + return updates; + } + + /* -------------------------------------------- */ /** * Transform this Actor into another one. @@ -1407,10 +1537,10 @@ export default class Actor5e extends Actor { } // Get the original Actor data and the new source data - const o = duplicate(this.toJSON()); + const o = this.toJSON(); o.flags.sw5e = o.flags.sw5e || {}; o.flags.sw5e.transformOptions = {mergeSkills, mergeSaves}; - const source = duplicate(target.toJSON()); + const source = target.toJSON(); // Prepare new data to merge from the source const d = { @@ -1419,40 +1549,38 @@ export default class Actor5e extends Actor { data: source.data, // Get the data model of your new form items: source.items, // Get the items of your new form effects: o.effects.concat(source.effects), // Combine active effects from both forms - token: source.token, // New token configuration img: source.img, // New appearance permission: o.permission, // Use the original actor permissions folder: o.folder, // Be displayed in the same sidebar folder flags: o.flags // Use the original actor flags }; - // Additional adjustments + // Specifically delete some data attributes delete d.data.resources; // Don't change your resource pools delete d.data.currency; // Don't lose currency delete d.data.bonuses; // Don't lose global bonuses - delete d.token.actorId; // Don't reference the old actor ID - d.token.actorLink = o.token.actorLink; // Keep your actor link - d.token.name = d.name; // Token name same as actor name + + // Specific additional adjustments d.data.details.alignment = o.data.details.alignment; // Don't change alignment d.data.attributes.exhaustion = o.data.attributes.exhaustion; // Keep your prior exhaustion level d.data.attributes.inspiration = o.data.attributes.inspiration; // Keep inspiration d.data.powers = o.data.powers; // Keep power slots - // Handle wildcard + // Token appearance updates + d.token = {name: d.name}; + for ( let k of ["width", "height", "scale", "img", "mirrorX", "mirrorY", "tint", "alpha", "lockRotation"] ) { + d.token[k] = source.token[k]; + } + if ( !keepVision ) { + for ( let k of ['dimSight', 'brightSight', 'dimLight', 'brightLight', 'vision', 'sightAngle'] ) { + d.token[k] = source.token[k]; + } + } if ( source.token.randomImg ) { const images = await target.getTokenImages(); d.token.img = images[Math.floor(Math.random() * images.length)]; } - // Keep Token configurations - const tokenConfig = ["displayName", "vision", "actorLink", "disposition", "displayBars", "bar1", "bar2"]; - if ( keepVision ) { - tokenConfig.push(...['dimSight', 'brightSight', 'dimLight', 'brightLight', 'vision', 'sightAngle']); - } - for ( let c of tokenConfig ) { - d.token[c] = o.token[c]; - } - // Transfer ability scores const abilities = d.data.abilities; for ( let k of Object.keys(abilities) ) { @@ -1519,13 +1647,13 @@ export default class Actor5e extends Actor { if ( !transformTokens ) return; const tokens = this.getActiveTokens(true); const updates = tokens.map(t => { - const newTokenData = duplicate(d.token); + const newTokenData = foundry.utils.deepClone(d.token); if ( !t.data.actorLink ) newTokenData.actorData = newActor.data; newTokenData._id = t.data._id; newTokenData.actorId = newActor.id; return newTokenData; }); - return canvas.scene?.updateEmbeddedEntity("Token", updates); + return canvas.scene?.updateEmbeddedDocuments("Token", updates); } /* -------------------------------------------- */ @@ -1537,16 +1665,19 @@ export default class Actor5e extends Actor { */ async revertOriginalForm() { if ( !this.isPolymorphed ) return; - if ( !this.owner ) { + if ( !this.isOwner ) { return ui.notifications.warn(game.i18n.localize("SW5E.PolymorphRevertWarn")); } // If we are reverting an unlinked token, simply replace it with the base actor prototype if ( this.isToken ) { const baseActor = game.actors.get(this.token.data.actorId); - const prototypeTokenData = duplicate(baseActor.token); - prototypeTokenData.actorData = null; - return this.token.update(prototypeTokenData); + const prototypeTokenData = await baseActor.getTokenData(); + const tokenUpdate = {actorData: {}}; + for ( let k of ["width", "height", "scale", "img", "mirrorX", "mirrorY", "tint", "alpha", "lockRotation"] ) { + tokenUpdate[k] = prototypeTokenData[k]; + } + return this.token.update(tokenUpdate, {recursive: false}); } // Obtain a reference to the original actor @@ -1557,18 +1688,19 @@ export default class Actor5e extends Actor { if ( canvas.ready ) { const tokens = this.getActiveTokens(true); const tokenUpdates = tokens.map(t => { - const tokenData = duplicate(original.data.token); + const tokenData = original.data.token.toJSON(); tokenData._id = t.id; tokenData.actorId = original.id; return tokenData; }); - canvas.scene.updateEmbeddedEntity("Token", tokenUpdates); + canvas.scene.updateEmbeddedDocuments("Token", tokenUpdates); } - // Delete the polymorphed Actor and maybe re-render the original sheet + // Delete the polymorphed version of the actor, if possible const isRendered = this.sheet.rendered; if ( game.user.isGM ) await this.delete(); - original.sheet.render(isRendered); + else if ( isRendered ) this.sheet.close(); + if ( isRendered ) original.sheet.render(isRendered); return original; } @@ -1596,6 +1728,33 @@ export default class Actor5e extends Actor { }); } + /* -------------------------------------------- */ + + /** + * Format a type object into a string. + * @param {object} typeData The type data to convert to a string. + * @returns {string} + */ + static formatCreatureType(typeData) { + if ( typeof typeData === "string" ) return typeData; // backwards compatibility + let localizedType; + if ( typeData.value === "custom" ) { + localizedType = typeData.custom; + } else { + let code = CONFIG.SW5E.creatureTypes[typeData.value]; + localizedType = game.i18n.localize(!!typeData.swarm ? `${code}Pl` : code); + } + let type = localizedType; + if ( !!typeData.swarm ) { + type = game.i18n.format('SW5E.CreatureSwarmPhrase', { + size: game.i18n.localize(CONFIG.SW5E.actorSizes[typeData.swarm]), + type: localizedType + }); + } + if (typeData.subtype) type = `${type} (${typeData.subtype})`; + return type; + } + /* -------------------------------------------- */ /* DEPRECATED METHODS */ /* -------------------------------------------- */ diff --git a/module/actor/sheets/newSheet/base.js b/module/actor/sheets/newSheet/base.js index 2575dad9..348dda4d 100644 --- a/module/actor/sheets/newSheet/base.js +++ b/module/actor/sheets/newSheet/base.js @@ -1,8 +1,10 @@ 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"; @@ -46,6 +48,14 @@ export default class ActorSheet5e extends ActorSheet { /* -------------------------------------------- */ + /** + * A set of item types that should be prevented from being dropped on this type of actor sheet. + * @type {Set} + */ + static unsupportedItemTypes = new Set(); + + /* -------------------------------------------- */ + /** @override */ get template() { if ( !game.user.isGM && this.actor.limited ) return "systems/sw5e/templates/actors/newActor/expanded-limited-sheet.html"; @@ -55,44 +65,51 @@ export default class ActorSheet5e extends ActorSheet { /* -------------------------------------------- */ /** @override */ - getData() { + getData(options) { // Basic data - let isOwner = this.entity.owner; + let isOwner = this.actor.isOwner; const data = { owner: isOwner, - limited: this.entity.limited, + limited: this.actor.limited, options: this.options, editable: this.isEditable, cssClass: isOwner ? "editable" : "locked", - isCharacter: this.entity.data.type === "character", - isNPC: this.entity.data.type === "npc", - isStarship: this.entity.data.type === "starship", - isVehicle: this.entity.data.type === 'vehicle', + isCharacter: this.actor.data.type === "character", + isNPC: this.actor.data.type === "npc", + isStarship: this.actor.data.type === "starship", + isVehicle: this.actor.data.type === 'vehicle', config: CONFIG.SW5E, + rollData: this.actor.getRollData.bind(this.actor) }; - // The Actor and its Items - data.actor = duplicate(this.actor.data); - data.items = this.actor.items.map(i => { - i.data.labels = i.labels; - return i.data; - }); + // 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)); - data.data = data.actor.data; + + // Labels and filters data.labels = this.actor.labels || {}; data.filters = this._filters; // Ability Scores - for ( let [a, abl] of Object.entries(data.actor.data.abilities)) { + 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 (data.actor.data.skills) { - for ( let [s, skl] of Object.entries(data.actor.data.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]; @@ -105,19 +122,19 @@ export default class ActorSheet5e extends ActorSheet { } // Movement speeds - data.movement = this._getMovementSpeed(data.actor); + data.movement = this._getMovementSpeed(actorData); // Senses - data.senses = this._getSenses(data.actor); + data.senses = this._getSenses(actorData); // Update traits - this._prepareTraits(data.actor.data.traits); + this._prepareTraits(actorData.data.traits); // Prepare owned items this._prepareItems(data); // Prepare active effects - data.effects = prepareActiveEffectCategories(this.entity.effects); + data.effects = prepareActiveEffectCategories(this.actor.effects); // Return data to the sheet return data @@ -223,12 +240,13 @@ export default class ActorSheet5e extends ActorSheet { /** * 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 {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.owner; + const owner = this.actor.isOwner; const levels = data.data.powers; const powerbook = {}; @@ -413,18 +431,18 @@ export default class ActorSheet5e extends ActorSheet { html.find('.item-create').click(this._onItemCreate.bind(this)); html.find('.item-edit').click(this._onItemEdit.bind(this)); html.find('.item-delete').click(this._onItemDelete.bind(this)); - html.find('.item-collapse').click(this._onItemCollapse.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)); + 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.entity)); + html.find(".effect-control").click(ev => onManageActiveEffect(ev, this.actor)); } // Owner Only Listeners - if ( this.actor.owner ) { + if ( this.actor.isOwner ) { // Ability Checks html.find('.ability-name').click(this._onRollAbilityTest.bind(this)); @@ -491,17 +509,25 @@ export default class ActorSheet5e extends ActorSheet { _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": - new ActorMovementConfig(this.object).render(true); + app = new ActorMovementConfig(this.object); break; case "flags": - new ActorSheetFlags(this.object).render(true); + app = new ActorSheetFlags(this.object); break; case "senses": - new ActorSensesConfig(this.object).render(true); + app = new ActorSensesConfig(this.object); + break; + case "type": + new ActorTypeConfig(this.object).render(true); break; } + app?.render(true); } /* -------------------------------------------- */ @@ -535,7 +561,7 @@ export default class ActorSheet5e extends ActorSheet { /** @override */ async _onDropActor(event, data) { - const canPolymorph = game.user.isGM || (this.actor.owner && game.settings.get('sw5e', 'allowPolymorphing')); + const canPolymorph = game.user.isGM || (this.actor.isOwner && game.settings.get('sw5e', 'allowPolymorphing')); if ( !canPolymorph ) return false; // Get the target actor @@ -610,15 +636,40 @@ export default class ActorSheet5e extends ActorSheet { /** @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; } - // Ignore certain statuses if ( itemData.data ) { - ["attunement", "equipped", "proficient", "prepared"].forEach(k => delete itemData.data[k]); + // 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 @@ -659,7 +710,7 @@ export default class ActorSheet5e extends ActorSheet { async _onUsesChange(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.getOwnedItem(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 }); @@ -674,7 +725,7 @@ export default class ActorSheet5e extends ActorSheet { _onItemRoll(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.getOwnedItem(itemId); + const item = this.actor.items.get(itemId); return item.roll(); } @@ -688,7 +739,7 @@ export default class ActorSheet5e extends ActorSheet { _onItemRecharge(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.getOwnedItem(itemId); + const item = this.actor.items.get(itemId); return item.rollRecharge(); }; @@ -701,8 +752,8 @@ export default class ActorSheet5e extends ActorSheet { _onItemSummary(event) { event.preventDefault(); let li = $(event.currentTarget).parents(".item"), - item = this.actor.getOwnedItem(li.data("item-id")), - chatData = item.getChatData({secrets: this.actor.owner}); + item = this.actor.items.get(li.data("item-id")), + chatData = item.getChatData({secrets: this.actor.isOwner}); // Toggle summary if ( li.hasClass("expanded") ) { @@ -733,10 +784,10 @@ export default class ActorSheet5e extends ActorSheet { const itemData = { name: game.i18n.format("SW5E.ItemNew", {type: type.capitalize()}), type: type, - data: duplicate(header.dataset) + data: foundry.utils.deepClone(header.dataset) }; delete itemData.data["type"]; - return this.actor.createEmbeddedEntity("OwnedItem", itemData); + return this.actor.createEmbeddedDocuments("Item", [itemData]); } /* -------------------------------------------- */ @@ -749,8 +800,8 @@ export default class ActorSheet5e extends ActorSheet { _onItemEdit(event) { event.preventDefault(); const li = event.currentTarget.closest(".item"); - const item = this.actor.getOwnedItem(li.dataset.itemId); - item.sheet.render(true); + const item = this.actor.items.get(li.dataset.itemId); + return item.sheet.render(true); } /* -------------------------------------------- */ @@ -763,7 +814,8 @@ export default class ActorSheet5e extends ActorSheet { _onItemDelete(event) { event.preventDefault(); const li = event.currentTarget.closest(".item"); - this.actor.deleteOwnedItem(li.dataset.itemId); + const item = this.actor.items.get(li.dataset.itemId); + if ( item ) return item.delete(); } /** @@ -803,12 +855,12 @@ _onItemCollapse(event) { const itemId = li.dataset.itemId; const actor = game.actors.get(actorId); - const item = actor.getOwnedItem(itemId); + const item = actor.items.get(itemId); let levels = item.data.data.levels; - const update = {_id: item._id, data: {levels: (levels + 1) }}; + const update = {_id: item.data._id, data: {levels: (levels + 1) }}; - actor.updateOwnedItem(update) + actor.updateEmbeddedDocuments("Item", [update]); } /** @@ -827,12 +879,12 @@ _onItemCollapse(event) { const itemId = li.dataset.itemId; const actor = game.actors.get(actorId); - const item = actor.getOwnedItem(itemId); + const item = actor.items.get(itemId); let levels = item.data.data.levels; - const update = {_id: item._id, data: {levels: (levels - 1) }}; + const update = {_id: item.data._id, data: {levels: (levels - 1) }}; - actor.updateOwnedItem(update) + actor.updateEmbeddedDocuments("Item", [update]); } /* -------------------------------------------- */ @@ -845,7 +897,7 @@ _onItemCollapse(event) { _onRollAbilityTest(event) { event.preventDefault(); let ability = event.currentTarget.parentElement.dataset.ability; - this.actor.rollAbility(ability, {event: event}); + return this.actor.rollAbility(ability, {event: event}); } /* -------------------------------------------- */ @@ -858,7 +910,7 @@ _onItemCollapse(event) { _onRollSkillCheck(event) { event.preventDefault(); const skill = event.currentTarget.parentElement.dataset.skill; - this.actor.rollSkill(skill, {event: event}); + return this.actor.rollSkill(skill, {event: event}); } /* -------------------------------------------- */ @@ -871,7 +923,7 @@ _onItemCollapse(event) { _onToggleAbilityProficiency(event) { event.preventDefault(); const field = event.currentTarget.previousElementSibling; - this.actor.update({[field.name]: 1 - parseInt(field.value)}); + return this.actor.update({[field.name]: 1 - parseInt(field.value)}); } /* -------------------------------------------- */ @@ -888,7 +940,7 @@ _onItemCollapse(event) { const filter = li.dataset.filter; if ( set.has(filter) ) set.delete(filter); else set.add(filter); - this.render(); + return this.render(); } /* -------------------------------------------- */ @@ -904,7 +956,7 @@ _onItemCollapse(event) { const label = a.parentElement.querySelector("label"); const choices = CONFIG.SW5E[a.dataset.options]; const options = { name: a.dataset.target, title: label.innerText, choices }; - new TraitSelector(this.actor, options).render(true) + return new TraitSelector(this.actor, options).render(true) } /* -------------------------------------------- */ @@ -912,15 +964,14 @@ _onItemCollapse(event) { /** @override */ _getHeaderButtons() { let buttons = super._getHeaderButtons(); - - // Add button to revert polymorph - if ( !this.actor.isPolymorphed || this.actor.isToken ) return buttons; - buttons.unshift({ - label: 'SW5E.PolymorphRestoreTransformation', - class: "restore-transformation", - icon: "fas fa-backward", - onclick: ev => this.actor.revertOriginalForm() - }); + if (this.actor.isPolymorphed) { + buttons.unshift({ + label: 'SW5E.PolymorphRestoreTransformation', + class: "restore-transformation", + icon: "fas fa-backward", + onclick: () => this.actor.revertOriginalForm() + }); + } return buttons; } -} \ No newline at end of file +} diff --git a/module/actor/sheets/newSheet/character.js b/module/actor/sheets/newSheet/character.js index 4b1a987f..d2382258 100644 --- a/module/actor/sheets/newSheet/character.js +++ b/module/actor/sheets/newSheet/character.js @@ -87,7 +87,7 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e { 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 || DEFAULT_TOKEN; + item.img = item.img || CONST.DEFAULT_TOKEN; item.isStack = Number.isNumeric(item.data.quantity) && (item.data.quantity !== 1); item.attunement = { [CONFIG.SW5E.attunementTypes.REQUIRED]: { @@ -111,6 +111,9 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e { // Item toggle state this._prepareItemToggleState(item); + // Primary Class + if ( item.type === "class" ) item.isOriginalClass = ( item.data._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); @@ -168,7 +171,7 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e { if ( f.data.activation.type ) features.active.items.push(f); else features.passive.items.push(f); } - classes.sort((a, b) => b.levels - a.levels); + classes.sort((a, b) => b.data.levels - a.data.levels); features.classes.items = classes; features.classfeatures.items = classfeatures; features.archetype.items = archetypes; @@ -260,10 +263,10 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e { if (["gmroll", "blindroll"].includes(rollMode)) rollWhisper = ChatMessage.getWhisperIDs("GM"); if (rollMode === "blindroll") rollBlind = true; ChatMessage.create({ - user: game.user._id, + user: game.user.data._id, content: content, speaker: { - actor: this.actor._id, + actor: this.actor.data._id, token: this.actor.token, alias: this.actor.name }, @@ -276,7 +279,7 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e { html.find('.item-delete').click(event => { let li = $(event.currentTarget).parents('.item'); let itemId = li.attr("data-item-id"); - let item = this.actor.getOwnedItem(itemId); + let item = this.actor.items.get(itemId); new Dialog({ title: `Deleting ${item.data.name}`, content: `

Are you sure you want to delete ${item.data.name}?

`, @@ -327,7 +330,7 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e { _onToggleItem(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.getOwnedItem(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)}); } @@ -390,7 +393,7 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e { // } // Default drop handling if levels were not added - super._onDropItemCreate(itemData); + return super._onDropItemCreate(itemData); } } async function addFavorites(app, html, data) { @@ -465,11 +468,11 @@ async function addFavorites(app, html, data) { if (app.options.editable) { let favBtn = $(``); favBtn.click(ev => { - app.actor.getOwnedItem(item._id).update({ + app.actor.items.get(item.data._id).update({ "flags.favtab.isFavourite": !item.flags.favtab.isFavourite }); }); - html.find(`.item[data-item-id="${item._id}"]`).find('.item-controls').prepend(favBtn); + html.find(`.item[data-item-id="${item.data._id}"]`).find('.item-controls').prepend(favBtn); } if (isFav) { @@ -543,12 +546,12 @@ async function addFavorites(app, html, data) { //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.getOwnedItem(itemId).sheet.render(true); + 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.getOwnedItem(itemId).data.flags.favtab.isFavourite - app.actor.getOwnedItem(itemId).update({ + let val = !app.actor.items.get(itemId).data.flags.favtab.isFavourite + app.actor.items.get(itemId).update({ "flags.favtab.isFavourite": val }); }); @@ -564,10 +567,10 @@ async function addFavorites(app, html, data) { let list = null; if (dropData.data.type === 'feat') list = favFeats; else list = favItems; - let dragSource = list.find(i => i._id === dropData.data._id); - let siblings = list.filter(i => i._id !== dropData.data._id); + 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._id === targetId); + let dragTarget = siblings.find(s => s.data._id === targetId); if (dragTarget === undefined) return; const sortUpdates = SortingHelpers.performIntegerSort(dragSource, { @@ -577,7 +580,7 @@ async function addFavorites(app, html, data) { }); const updateData = sortUpdates.map(u => { const update = u.update; - update._id = u.target._id; + update._id = u.target.data._id; return update; }); app.actor.updateEmbeddedEntity("OwnedItem", updateData); diff --git a/module/actor/sheets/newSheet/npc.js b/module/actor/sheets/newSheet/npc.js index 51c43006..dea59e8f 100644 --- a/module/actor/sheets/newSheet/npc.js +++ b/module/actor/sheets/newSheet/npc.js @@ -1,3 +1,4 @@ +import Actor5e from "../../entity.js"; import ActorSheet5e from "./base.js"; /** @@ -27,6 +28,11 @@ export default class ActorSheet5eNPCNew extends ActorSheet5e { /* -------------------------------------------- */ + /** @override */ + static unsupportedItemTypes = new Set(["class"]); + + /* -------------------------------------------- */ + /** * Organize Owned Items for rendering the NPC sheet * @private @@ -43,7 +49,7 @@ export default class ActorSheet5eNPCNew extends ActorSheet5e { // Start by classifying items into groups for rendering let [forcepowers, techpowers, other] = data.items.reduce((arr, item) => { - item.img = item.img || DEFAULT_TOKEN; + 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); @@ -80,17 +86,19 @@ export default class ActorSheet5eNPCNew extends ActorSheet5e { data.techPowerbook = techPowerbook; } - /* -------------------------------------------- */ /** @override */ - getData() { - const data = super.getData(); + 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; } @@ -99,7 +107,7 @@ export default class ActorSheet5eNPCNew extends ActorSheet5e { /* -------------------------------------------- */ /** @override */ - _updateObject(event, formData) { + async _updateObject(event, formData) { // Format NPC Challenge Rating const crs = {"1/8": 0.125, "1/4": 0.25, "1/2": 0.5}; @@ -109,7 +117,7 @@ export default class ActorSheet5eNPCNew extends ActorSheet5e { if ( cr ) formData[crv] = cr < 1 ? cr : parseInt(cr); // Parent ActorSheet update steps - super._updateObject(event, formData); + return super._updateObject(event, formData); } /* -------------------------------------------- */ diff --git a/module/actor/sheets/newSheet/starship.js b/module/actor/sheets/newSheet/starship.js index 3e08cdcf..e27354d6 100644 --- a/module/actor/sheets/newSheet/starship.js +++ b/module/actor/sheets/newSheet/starship.js @@ -44,7 +44,7 @@ export default class ActorSheet5eStarship extends ActorSheet5e { // Start by classifying items into groups for rendering let [forcepowers, techpowers, other] = data.items.reduce((arr, item) => { - item.img = item.img || DEFAULT_TOKEN; + 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); @@ -91,8 +91,8 @@ export default class ActorSheet5eStarship extends ActorSheet5e { /* -------------------------------------------- */ /** @override */ - getData() { - const data = super.getData(); + getData(options) { + const data = super.getData(options); // Add Size info data.isTiny = data.actor.data.traits.size === "tiny"; @@ -114,7 +114,7 @@ export default class ActorSheet5eStarship extends ActorSheet5e { /* -------------------------------------------- */ /** @override */ - _updateObject(event, formData) { + async _updateObject(event, formData) { // Format NPC Challenge Rating const crs = {"1/8": 0.125, "1/4": 0.25, "1/2": 0.5}; @@ -124,7 +124,7 @@ export default class ActorSheet5eStarship extends ActorSheet5e { if ( cr ) formData[crv] = cr < 1 ? cr : parseInt(cr); // Parent ActorSheet update steps - super._updateObject(event, formData); + return super._updateObject(event, formData); } /* -------------------------------------------- */ @@ -152,5 +152,4 @@ export default class ActorSheet5eStarship extends ActorSheet5e { AudioHelper.play({src: CONFIG.sounds.dice}); this.actor.update({"data.attributes.hp.value": hp, "data.attributes.hp.max": hp}); } - -} \ No newline at end of file +} diff --git a/module/actor/sheets/newSheet/vehicle.js b/module/actor/sheets/newSheet/vehicle.js index cba36ebd..804ce55f 100644 --- a/module/actor/sheets/newSheet/vehicle.js +++ b/module/actor/sheets/newSheet/vehicle.js @@ -20,6 +20,12 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { /* -------------------------------------------- */ + /** @override */ + static unsupportedItemTypes = new Set(["class"]); + + /* -------------------------------------------- */ + + /** * Creates a new cargo entry for a vehicle Actor. */ @@ -206,24 +212,39 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { } }; + // Classify items owned by the vehicle and compute total cargo weight let totalWeight = 0; for (const item of data.items) { this._prepareCrewedItem(item); - if (item.type === 'weapon') features.weapons.items.push(item); - else if (item.type === 'equipment') features.equipment.items.push(item); - else if (item.type === 'loot') { + + // 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 } - else if (item.type === 'feat') { - if (!item.data.activation.type || item.data.activation.type === 'none') { - features.passive.items.push(item); - } - else if (item.data.activation.type === 'reaction') features.reactions.items.push(item); - else features.actions.items.push(item); + + // 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); @@ -272,7 +293,7 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { const property = row.classList.contains('crew') ? 'crew' : 'passengers'; // Get the cargo entry - const cargo = duplicate(this.actor.data.data.cargo[property]); + const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[property]); const entry = cargo[idx]; if (!entry) return null; @@ -322,7 +343,7 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { const target = event.currentTarget; const type = target.dataset.type; if (type === 'crew' || type === 'passengers') { - const cargo = duplicate(this.actor.data.data.cargo[type]); + const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[type]); cargo.push(this.constructor.newCargo); return this.actor.update({[`data.cargo.${type}`]: cargo}); } @@ -343,13 +364,21 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { if (row.classList.contains('cargo-row')) { const idx = Number(row.dataset.itemId); const type = row.classList.contains('crew') ? 'crew' : 'passengers'; - const cargo = duplicate(this.actor.data.data.cargo[type]).filter((_, i) => i !== idx); + 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); + } + /* -------------------------------------------- */ /** diff --git a/module/actor/sheets/oldSheets/base.js b/module/actor/sheets/oldSheets/base.js index aaabc84e..ab5aef2c 100644 --- a/module/actor/sheets/oldSheets/base.js +++ b/module/actor/sheets/oldSheets/base.js @@ -1,8 +1,10 @@ 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"; @@ -44,6 +46,15 @@ export default class ActorSheet5e extends ActorSheet { /* -------------------------------------------- */ + /** + * A set of item types that should be prevented from being dropped on this type of actor sheet. + * @type {Set} + */ + static unsupportedItemTypes = new Set(); + + /* -------------------------------------------- */ + + /** @override */ get template() { if ( !game.user.isGM && this.actor.limited ) return "systems/sw5e/templates/actors/oldActor/limited-sheet.html"; @@ -53,44 +64,51 @@ export default class ActorSheet5e extends ActorSheet { /* -------------------------------------------- */ /** @override */ - getData() { + getData(options) { // Basic data - let isOwner = this.entity.owner; + let isOwner = this.actor.isOwner; const data = { owner: isOwner, - limited: this.entity.limited, + limited: this.actor.limited, options: this.options, editable: this.isEditable, cssClass: isOwner ? "editable" : "locked", - isCharacter: this.entity.data.type === "character", - isNPC: this.entity.data.type === "npc", - isStarship: this.entity.data.type === "starship", - isVehicle: this.entity.data.type === 'vehicle', + 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 and its Items - data.actor = duplicate(this.actor.data); - data.items = this.actor.items.map(i => { - i.data.labels = i.labels; - return i.data; - }); + // 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.data._id); + i.labels = item.labels; + } data.items.sort((a, b) => (a.sort || 0) - (b.sort || 0)); - data.data = data.actor.data; + + // Labels and filters data.labels = this.actor.labels || {}; data.filters = this._filters; // Ability Scores - for ( let [a, abl] of Object.entries(data.actor.data.abilities)) { + 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 (data.actor.data.skills) { - for ( let [s, skl] of Object.entries(data.actor.data.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]; @@ -99,19 +117,19 @@ export default class ActorSheet5e extends ActorSheet { } // Movement speeds - data.movement = this._getMovementSpeed(data.actor); + data.movement = this._getMovementSpeed(actorData); // Senses - data.senses = this._getSenses(data.actor); + data.senses = this._getSenses(actorData); // Update traits - this._prepareTraits(data.actor.data.traits); + this._prepareTraits(actorData.data.traits); // Prepare owned items this._prepareItems(data); // Prepare active effects - data.effects = prepareActiveEffectCategories(this.entity.effects); + data.effects = prepareActiveEffectCategories(this.actor.effects); // Return data to the sheet return data @@ -222,7 +240,7 @@ export default class ActorSheet5e extends ActorSheet { * @private */ _preparePowerbook(data, powers) { - const owner = this.actor.owner; + const owner = this.actor.isOwner; const levels = data.data.powers; const powerbook = {}; @@ -275,11 +293,14 @@ export default class ActorSheet5e extends ActorSheet { } // 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; - registerSection("pact", sections.pact, config, { + 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, @@ -424,11 +445,11 @@ export default class ActorSheet5e extends ActorSheet { html.find('.slot-max-override').click(this._onPowerSlotOverride.bind(this)); // Active Effect management - html.find(".effect-control").click(ev => onManageActiveEffect(ev, this.entity)); + html.find(".effect-control").click(ev => onManageActiveEffect(ev, this.actor)); } // Owner Only Listeners - if ( this.actor.owner ) { + if ( this.actor.isOwner ) { // Ability Checks html.find('.ability-name').click(this._onRollAbilityTest.bind(this)); @@ -495,17 +516,25 @@ export default class ActorSheet5e extends ActorSheet { _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": - new ActorMovementConfig(this.object).render(true); + app = new ActorMovementConfig(this.object); break; case "flags": - new ActorSheetFlags(this.object).render(true); + app = new ActorSheetFlags(this.object); break; case "senses": - new ActorSensesConfig(this.object).render(true); + app = new ActorSensesConfig(this.object); + break; + case "type": + new ActorTypeConfig(this.object).render(true); break; } + app?.render(true); } /* -------------------------------------------- */ @@ -539,7 +568,7 @@ export default class ActorSheet5e extends ActorSheet { /** @override */ async _onDropActor(event, data) { - const canPolymorph = game.user.isGM || (this.actor.owner && game.settings.get('sw5e', 'allowPolymorphing')); + const canPolymorph = game.user.isGM || (this.actor.isOwner && game.settings.get('sw5e', 'allowPolymorphing')); if ( !canPolymorph ) return false; // Get the target actor @@ -614,15 +643,41 @@ export default class ActorSheet5e extends ActorSheet { /** @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; } - // Ignore certain statuses if ( itemData.data ) { - ["attunement", "equipped", "proficient", "prepared"].forEach(k => delete itemData.data[k]); + // 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 @@ -663,7 +718,7 @@ export default class ActorSheet5e extends ActorSheet { async _onUsesChange(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.getOwnedItem(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 }); @@ -678,7 +733,7 @@ export default class ActorSheet5e extends ActorSheet { _onItemRoll(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.getOwnedItem(itemId); + const item = this.actor.items.get(itemId); return item.roll(); } @@ -692,7 +747,7 @@ export default class ActorSheet5e extends ActorSheet { _onItemRecharge(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.getOwnedItem(itemId); + const item = this.actor.items.get(itemId); return item.rollRecharge(); }; @@ -705,8 +760,8 @@ export default class ActorSheet5e extends ActorSheet { _onItemSummary(event) { event.preventDefault(); let li = $(event.currentTarget).parents(".item"), - item = this.actor.getOwnedItem(li.data("item-id")), - chatData = item.getChatData({secrets: this.actor.owner}); + item = this.actor.items.get(li.data("item-id")), + chatData = item.getChatData({secrets: this.actor.isOwner}); // Toggle summary if ( li.hasClass("expanded") ) { @@ -737,10 +792,10 @@ export default class ActorSheet5e extends ActorSheet { const itemData = { name: game.i18n.format("SW5E.ItemNew", {type: type.capitalize()}), type: type, - data: duplicate(header.dataset) + data: foundry.utils.deepClone(header.dataset) }; delete itemData.data["type"]; - return this.actor.createEmbeddedEntity("OwnedItem", itemData); + return this.actor.createEmbeddedDocuments("Item", [itemData]); } /* -------------------------------------------- */ @@ -753,8 +808,8 @@ export default class ActorSheet5e extends ActorSheet { _onItemEdit(event) { event.preventDefault(); const li = event.currentTarget.closest(".item"); - const item = this.actor.getOwnedItem(li.dataset.itemId); - item.sheet.render(true); + const item = this.actor.items.get(li.dataset.itemId); + return item.sheet.render(true); } /* -------------------------------------------- */ @@ -766,8 +821,8 @@ export default class ActorSheet5e extends ActorSheet { */ _onItemDelete(event) { event.preventDefault(); - const li = event.currentTarget.closest(".item"); - this.actor.deleteOwnedItem(li.dataset.itemId); + const item = this.actor.items.get(li.dataset.itemId); + if ( item ) return item.delete(); } /* -------------------------------------------- */ @@ -780,7 +835,7 @@ export default class ActorSheet5e extends ActorSheet { _onRollAbilityTest(event) { event.preventDefault(); let ability = event.currentTarget.parentElement.dataset.ability; - this.actor.rollAbility(ability, {event: event}); + return this.actor.rollAbility(ability, {event: event}); } /* -------------------------------------------- */ @@ -793,7 +848,7 @@ export default class ActorSheet5e extends ActorSheet { _onRollSkillCheck(event) { event.preventDefault(); const skill = event.currentTarget.parentElement.dataset.skill; - this.actor.rollSkill(skill, {event: event}); + return this.actor.rollSkill(skill, {event: event}); } /* -------------------------------------------- */ @@ -806,7 +861,7 @@ export default class ActorSheet5e extends ActorSheet { _onToggleAbilityProficiency(event) { event.preventDefault(); const field = event.currentTarget.previousElementSibling; - this.actor.update({[field.name]: 1 - parseInt(field.value)}); + return this.actor.update({[field.name]: 1 - parseInt(field.value)}); } /* -------------------------------------------- */ @@ -823,7 +878,7 @@ export default class ActorSheet5e extends ActorSheet { const filter = li.dataset.filter; if ( set.has(filter) ) set.delete(filter); else set.add(filter); - this.render(); + return this.render(); } /* -------------------------------------------- */ @@ -839,7 +894,7 @@ export default class ActorSheet5e extends ActorSheet { const label = a.parentElement.querySelector("label"); const choices = CONFIG.SW5E[a.dataset.options]; const options = { name: a.dataset.target, title: label.innerText, choices }; - new TraitSelector(this.actor, options).render(true) + return new TraitSelector(this.actor, options).render(true) } /* -------------------------------------------- */ @@ -847,15 +902,14 @@ export default class ActorSheet5e extends ActorSheet { /** @override */ _getHeaderButtons() { let buttons = super._getHeaderButtons(); - - // Add button to revert polymorph - if ( !this.actor.isPolymorphed || this.actor.isToken ) return buttons; - buttons.unshift({ - label: 'SW5E.PolymorphRestoreTransformation', - class: "restore-transformation", - icon: "fas fa-backward", - onclick: ev => this.actor.revertOriginalForm() - }); + if ( this.actor.isPolymorphed ) { + buttons.unshift({ + label: 'SW5E.PolymorphRestoreTransformation', + class: "restore-transformation", + icon: "fas fa-backward", + onclick: () => this.actor.revertOriginalForm() + }); + } return buttons; } } \ No newline at end of file diff --git a/module/actor/sheets/oldSheets/character.js b/module/actor/sheets/oldSheets/character.js index dc28b205..11219716 100644 --- a/module/actor/sheets/oldSheets/character.js +++ b/module/actor/sheets/oldSheets/character.js @@ -76,7 +76,7 @@ export default class ActorSheet5eCharacter extends ActorSheet5e { let [items, powers, feats, classes, species, archetypes, classfeatures, backgrounds, fightingstyles, fightingmasteries, lightsaberforms] = data.items.reduce((arr, item) => { // Item details - item.img = item.img || DEFAULT_TOKEN; + item.img = item.img || CONST.DEFAULT_TOKEN; item.isStack = Number.isNumeric(item.data.quantity) && (item.data.quantity !== 1); item.attunement = { [CONFIG.SW5E.attunementTypes.REQUIRED]: { @@ -100,6 +100,9 @@ export default class ActorSheet5eCharacter extends ActorSheet5e { // Item toggle state this._prepareItemToggleState(item); + // Primary Class + if ( item.type === "class" ) item.isOriginalClass = ( item.data._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); @@ -151,7 +154,7 @@ export default class ActorSheet5eCharacter extends ActorSheet5e { if ( f.data.activation.type ) features.active.items.push(f); else features.passive.items.push(f); } - classes.sort((a, b) => b.levels - a.levels); + classes.sort((a, b) => b.data.levels - a.data.levels); features.classes.items = classes; features.classfeatures.items = classfeatures; features.archetype.items = archetypes; @@ -243,7 +246,7 @@ export default class ActorSheet5eCharacter extends ActorSheet5e { _onToggleItem(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.getOwnedItem(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)}); } @@ -293,6 +296,6 @@ export default class ActorSheet5eCharacter extends ActorSheet5e { } // Default drop handling if levels were not added - super._onDropItemCreate(itemData); + return super._onDropItemCreate(itemData); } -} \ No newline at end of file +} diff --git a/module/actor/sheets/oldSheets/npc.js b/module/actor/sheets/oldSheets/npc.js index 366dc65e..12e85b1f 100644 --- a/module/actor/sheets/oldSheets/npc.js +++ b/module/actor/sheets/oldSheets/npc.js @@ -18,6 +18,11 @@ export default class ActorSheet5eNPC extends ActorSheet5e { /* -------------------------------------------- */ + /** @override */ + static unsupportedItemTypes = new Set(["class"]); + + /* -------------------------------------------- */ + /** * Organize Owned Items for rendering the NPC sheet * @private @@ -34,7 +39,7 @@ export default class ActorSheet5eNPC extends ActorSheet5e { // Start by classifying items into groups for rendering let [powers, other] = data.items.reduce((arr, item) => { - item.img = item.img || DEFAULT_TOKEN; + 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); @@ -70,14 +75,17 @@ export default class ActorSheet5eNPC extends ActorSheet5e { /* -------------------------------------------- */ - /** @override */ - getData() { - const data = super.getData(); + /** @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; } @@ -86,7 +94,7 @@ export default class ActorSheet5eNPC extends ActorSheet5e { /* -------------------------------------------- */ /** @override */ - _updateObject(event, formData) { + async _updateObject(event, formData) { // Format NPC Challenge Rating const crs = {"1/8": 0.125, "1/4": 0.25, "1/2": 0.5}; @@ -96,7 +104,7 @@ export default class ActorSheet5eNPC extends ActorSheet5e { if ( cr ) formData[crv] = cr < 1 ? cr : parseInt(cr); // Parent ActorSheet update steps - super._updateObject(event, formData); + return super._updateObject(event, formData); } /* -------------------------------------------- */ diff --git a/module/actor/sheets/oldSheets/vehicle.js b/module/actor/sheets/oldSheets/vehicle.js index cba36ebd..35570186 100644 --- a/module/actor/sheets/oldSheets/vehicle.js +++ b/module/actor/sheets/oldSheets/vehicle.js @@ -20,6 +20,12 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { /* -------------------------------------------- */ + /** @override */ + static unsupportedItemTypes = new Set(["class"]); + + /* -------------------------------------------- */ + + /** * Creates a new cargo entry for a vehicle Actor. */ @@ -206,24 +212,39 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { } }; + // Classify items owned by the vehicle and compute total cargo weight let totalWeight = 0; for (const item of data.items) { this._prepareCrewedItem(item); - if (item.type === 'weapon') features.weapons.items.push(item); - else if (item.type === 'equipment') features.equipment.items.push(item); - else if (item.type === 'loot') { + + // 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; } - else if (item.type === 'feat') { - if (!item.data.activation.type || item.data.activation.type === 'none') { - features.passive.items.push(item); - } - else if (item.data.activation.type === 'reaction') features.reactions.items.push(item); - else features.actions.items.push(item); + + // 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); @@ -272,7 +293,7 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { const property = row.classList.contains('crew') ? 'crew' : 'passengers'; // Get the cargo entry - const cargo = duplicate(this.actor.data.data.cargo[property]); + const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[property]); const entry = cargo[idx]; if (!entry) return null; @@ -322,7 +343,7 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { const target = event.currentTarget; const type = target.dataset.type; if (type === 'crew' || type === 'passengers') { - const cargo = duplicate(this.actor.data.data.cargo[type]); + const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[type]); cargo.push(this.constructor.newCargo); return this.actor.update({[`data.cargo.${type}`]: cargo}); } @@ -343,7 +364,7 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { if (row.classList.contains('cargo-row')) { const idx = Number(row.dataset.itemId); const type = row.classList.contains('crew') ? 'crew' : 'passengers'; - const cargo = duplicate(this.actor.data.data.cargo[type]).filter((_, i) => i !== idx); + const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[type]).filter((_, i) => i !== idx); return this.actor.update({[`data.cargo.${type}`]: cargo}); } @@ -352,6 +373,16 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { /* -------------------------------------------- */ + /** @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} diff --git a/module/apps/ability-use-dialog.js b/module/apps/ability-use-dialog.js index bcbc9b25..ca9067d3 100644 --- a/module/apps/ability-use-dialog.js +++ b/module/apps/ability-use-dialog.js @@ -44,7 +44,7 @@ export default class AbilityUseDialog extends Dialog { consumePowerSlot: false, consumeRecharge: recharges, consumeResource: !!itemData.consume.target, - consumeUses: uses.max, + consumeUses: uses.per && (uses.max > 0), canUse: recharges ? recharge.charged : sufficientUses, createTemplate: game.user.can("TEMPLATE_CREATE") && item.hasAreaTarget, errors: [] @@ -169,7 +169,7 @@ export default class AbilityUseDialog extends Dialog { })); // Merge power casting data - return mergeObject(data, { isPower: true, consumePowerSlot, powerLevels }); + return foundry.utils.mergeObject(data, { isPower: true, consumePowerSlot, powerLevels }); } /* -------------------------------------------- */ @@ -219,10 +219,4 @@ export default class AbilityUseDialog extends Dialog { }); } } - - /* -------------------------------------------- */ - - static _handleSubmit(formData, item) { - - } } diff --git a/module/apps/actor-flags.js b/module/apps/actor-flags.js index 86293dab..d4599996 100644 --- a/module/apps/actor-flags.js +++ b/module/apps/actor-flags.js @@ -1,11 +1,10 @@ /** * An application class which provides advanced configuration for special character flags which modify an Actor - * @implements {BaseEntitySheet} + * @extends {DocumentSheet} */ -export default class ActorSheetFlags extends BaseEntitySheet { +export default class ActorSheetFlags extends DocumentSheet { static get defaultOptions() { - const options = super.defaultOptions; - return mergeObject(options, { + return foundry.utils.mergeObject(super.defaultOptions, { id: "actor-flags", classes: ["sw5e"], template: "systems/sw5e/templates/apps/actor-flags.html", @@ -27,6 +26,7 @@ export default class ActorSheetFlags extends BaseEntitySheet { getData() { const data = {}; data.actor = this.object; + data.classes = this._getClasses(); data.flags = this._getFlags(); data.bonuses = this._getBonuses(); return data; @@ -34,17 +34,33 @@ export default class ActorSheetFlags extends BaseEntitySheet { /* -------------------------------------------- */ + /** + * 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.entity._data; + 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 = duplicate(v); + let flag = foundry.utils.deepClone(v); flag.type = v.type.name; flag.isCheckbox = v.type === Boolean; flag.isSelect = v.hasOwnProperty('choices'); diff --git a/module/apps/actor-type.js b/module/apps/actor-type.js new file mode 100644 index 00000000..ad56e72b --- /dev/null +++ b/module/apps/actor-type.js @@ -0,0 +1,110 @@ +import Actor5e from "../actor/entity.js"; + +/** + * A specialized form used to select from a checklist of attributes, traits, or properties + * @extends {FormApplication} + */ +export default class ActorTypeConfig extends FormApplication { + + /** @inheritdoc */ + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["sw5e", "actor-type", "trait-selector"], + template: "systems/sw5e/templates/apps/actor-type.html", + title: "SW5E.CreatureTypeTitle", + width: 280, + height: "auto", + choices: {}, + allowCustom: true, + minimum: 0, + maximum: null + }); + } + + /* -------------------------------------------- */ + + /** @override */ + get id() { + return `actor-type-${this.object.id}`; + } + + /* -------------------------------------------- */ + + /** @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: "" + }; + + // Populate choices + const types = {}; + 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 + return { + types: types, + custom: { + value: attr.custom, + label: game.i18n.localize("SW5E.CreatureTypeSelectorCustom"), + chosen: attr.value === "custom" + }, + 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; + }, {}), + preview: Actor5e.formatCreatureType(attr) || "–" + } + } + + /* -------------------------------------------- */ + + /** @override */ + async _updateObject(event, formData) { + const typeObject = foundry.utils.expandObject(formData); + return this.object.update({ 'data.details.type': typeObject }); + } + + /* -------------------------------------------- */ + /* Event Listeners and Handlers */ + /* -------------------------------------------- */ + + /** @inheritdoc */ + activateListeners(html) { + super.activateListeners(html); + html.find("input[name='custom']").focusin(this._onCustomFieldFocused.bind(this)); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + _onChangeInput(event) { + super._onChangeInput(event); + const typeObject = foundry.utils.expandObject(this._getSubmitData()); + this.form["preview"].value = Actor5e.formatCreatureType(typeObject) || "—"; + } + + /* -------------------------------------------- */ + + /** + * Select the custom radio button when the custom text field is focused. + * @param {FocusEvent} event The original focusin event + * @private + */ + _onCustomFieldFocused(event) { + this.form.querySelector("input[name='value'][value='custom']").checked = true; + this._onChangeInput(event); + } +} diff --git a/module/apps/hit-dice-config.js b/module/apps/hit-dice-config.js new file mode 100644 index 00000000..d36d6bc2 --- /dev/null +++ b/module/apps/hit-dice-config.js @@ -0,0 +1,91 @@ +/** + * A simple form to set actor hit dice amounts + * @implements {DocumentSheet} + */ +export default class ActorHitDiceConfig extends DocumentSheet { + + /** @override */ + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["sw5e", "hd-config", "dialog"], + template: "systems/sw5e/templates/apps/hit-dice-config.html", + width: 360, + height: "auto" + }); + } + + /* -------------------------------------------- */ + + /** @override */ + get title() { + return `${game.i18n.localize("SW5E.HitDiceConfig")}: ${this.object.name}`; + } + + /* -------------------------------------------- */ + + /** @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))) + }; + } + + /* -------------------------------------------- */ + + /** @override */ + activateListeners(html) { + super.activateListeners(html); + + // Hook up -/+ buttons to adjust the current value in the form + html.find("button.increment,button.decrement").click(event => { + const button = event.currentTarget; + const current = button.parentElement.querySelector(".current"); + const max = button.parentElement.querySelector(".max"); + const direction = button.classList.contains("increment") ? 1 : -1; + current.value = Math.clamped(parseInt(current.value) + direction, 0, parseInt(max.value)); + }); + + html.find("button.roll-hd").click(this._onRollHitDie.bind(this)); + } + + /* -------------------------------------------- */ + + /** @override */ + 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, + })); + return this.object.updateEmbeddedDocuments("Item", classUpdates); + } + + /* -------------------------------------------- */ + + /** + * Rolls the hit die corresponding with the class row containing the event's target button. + * @param {MouseEvent} event + * @private + */ + async _onRollHitDie(event) { + event.preventDefault(); + const button = event.currentTarget; + await this.object.rollHitDie(button.dataset.hdDenom); + + // Re-render dialog to reflect changed hit dice quantities + this.render(); + } +} diff --git a/module/apps/long-rest.js b/module/apps/long-rest.js index ce7e90d4..6287e0f8 100644 --- a/module/apps/long-rest.js +++ b/module/apps/long-rest.js @@ -46,11 +46,9 @@ export default class LongRestDialog extends Dialog { icon: '', label: "Rest", callback: html => { - let newDay = false; - if (game.settings.get("sw5e", "restVariant") === "normal") + let newDay = true; + if (game.settings.get("sw5e", "restVariant") !== "gritty") newDay = html.find('input[name="newDay"]')[0].checked; - else if(game.settings.get("sw5e", "restVariant") === "gritty") - newDay = true; resolve(newDay); } }, diff --git a/module/apps/movement-config.js b/module/apps/movement-config.js index a57de39c..c507965d 100644 --- a/module/apps/movement-config.js +++ b/module/apps/movement-config.js @@ -1,12 +1,12 @@ /** * A simple form to set actor movement speeds - * @implements {BaseEntitySheet} + * @extends {DocumentSheet} */ -export default class ActorMovementConfig extends BaseEntitySheet { +export default class ActorMovementConfig extends DocumentSheet { /** @override */ static get defaultOptions() { - return mergeObject(super.defaultOptions, { + return foundry.utils.mergeObject(super.defaultOptions, { classes: ["sw5e"], template: "systems/sw5e/templates/apps/movement-config.html", width: 300, @@ -18,17 +18,18 @@ export default class ActorMovementConfig extends BaseEntitySheet { /** @override */ get title() { - return `${game.i18n.localize("SW5E.MovementConfig")}: ${this.entity.name}`; + 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: duplicate(this.entity._data.data.attributes.movement), + 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; diff --git a/module/apps/select-items-prompt.js b/module/apps/select-items-prompt.js new file mode 100644 index 00000000..0eac6497 --- /dev/null +++ b/module/apps/select-items-prompt.js @@ -0,0 +1,68 @@ +/** + * A Dialog to prompt the user to select from a list of items. + * @type {Dialog} + */ +export default class SelectItemsPrompt extends Dialog { + constructor(items, dialogData={}, options={}) { + super(dialogData, options); + this.options.classes = ["sw5e", "dialog", "select-items-prompt", "sheet"]; + + /** + * Store a reference to the Item entities being used + * @type {Array} + */ + this.items = items; + } + + activateListeners(html) { + super.activateListeners(html); + + // render the item's sheet if its image is clicked + html.on('click', '.item-image', (event) => { + const item = this.items.find((feature) => feature.id === event.currentTarget.dataset?.itemId); + + item?.sheet.render(true); + }) + } + + /** + * A constructor function which displays the AddItemPrompt app for a given Actor and Item set. + * Returns a Promise which resolves to the dialog FormData once the workflow has been completed. + * @param {Array} items + * @param {Object} options + * @param {string} options.hint - Localized hint to display at the top of the prompt + * @return {Promise} - list of item ids which the user has selected + */ + 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'), + content: html, + buttons: { + apply: { + icon: ``, + 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]); + resolve(selectedIds); + } + }, + cancel: { + icon: '', + label: game.i18n.localize('SW5E.Skip'), + callback: () => resolve([]) + } + }, + default: "apply", + close: () => resolve([]) + }); + dlg.render(true); + }); + } +} diff --git a/module/apps/senses-config.js b/module/apps/senses-config.js index 71f99bf1..69309493 100644 --- a/module/apps/senses-config.js +++ b/module/apps/senses-config.js @@ -1,12 +1,12 @@ /** * A simple form to set actor movement speeds - * @implements {BaseEntitySheet} + * @extends {DocumentSheet} */ -export default class ActorSensesConfig extends BaseEntitySheet { +export default class ActorSensesConfig extends DocumentSheet { - /** @override */ + /** @inheritdoc */ static get defaultOptions() { - return mergeObject(super.defaultOptions, { + return foundry.utils.mergeObject(super.defaultOptions, { classes: ["sw5e"], template: "systems/sw5e/templates/apps/senses-config.html", width: 300, @@ -16,16 +16,16 @@ export default class ActorSensesConfig extends BaseEntitySheet { /* -------------------------------------------- */ - /** @override */ + /** @inheritdoc */ get title() { - return `${game.i18n.localize("SW5E.SensesConfig")}: ${this.entity.name}`; + return `${game.i18n.localize("SW5E.SensesConfig")}: ${this.document.name}`; } /* -------------------------------------------- */ - /** @override */ + /** @inheritdoc */ getData(options) { - const senses = this.entity._data.data.attributes?.senses ?? {}; + const senses = foundry.utils.getProperty(this.document.data._source, "data.attributes.senses") || {}; const data = { senses: {}, special: senses.special ?? "", diff --git a/module/apps/short-rest.js b/module/apps/short-rest.js index 6791cbac..8a164af7 100644 --- a/module/apps/short-rest.js +++ b/module/apps/short-rest.js @@ -40,7 +40,7 @@ export default class ShortRestDialog extends Dialog { // Determine Hit Dice data.availableHD = this.actor.data.items.reduce((hd, item) => { if ( item.type === "class" ) { - const d = item.data; + 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; diff --git a/module/apps/trait-selector.js b/module/apps/trait-selector.js index 5e63d63d..5550aa3e 100644 --- a/module/apps/trait-selector.js +++ b/module/apps/trait-selector.js @@ -1,14 +1,14 @@ /** * A specialized form used to select from a checklist of attributes, traits, or properties - * @implements {FormApplication} + * @extends {DocumentSheet} */ -export default class TraitSelector extends FormApplication { +export default class TraitSelector extends DocumentSheet { - /** @override */ - static get defaultOptions() { - return mergeObject(super.defaultOptions, { - id: "trait-selector", - classes: ["sw5e"], + /** @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, @@ -16,7 +16,9 @@ export default class TraitSelector extends FormApplication { choices: {}, allowCustom: true, minimum: 0, - maximum: null + maximum: null, + valueKey: "value", + customKey: "custom" }); } @@ -24,7 +26,7 @@ export default class TraitSelector extends FormApplication { /** * Return a reference to the target attribute - * @type {String} + * @type {string} */ get attribute() { return this.options.name; @@ -34,52 +36,50 @@ export default class TraitSelector extends FormApplication { /** @override */ getData() { - - // Get current values - let attr = getProperty(this.object._data, this.attribute); - if ( getType(attr) !== "Object" ) attr = {value: [], custom: ""}; + 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 = duplicate(this.options.choices); - for ( let [k, v] of Object.entries(choices) ) { - choices[k] = { - label: v, - chosen: attr ? attr.value.includes(k) : false - } - } + 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: this.options.allowCustom, - choices: choices, - custom: attr ? attr.custom : "" + allowCustom: o.allowCustom, + choices: choices, + custom: custom } } /* -------------------------------------------- */ /** @override */ - _updateObject(event, formData) { - const updateData = {}; + 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); } - updateData[`${this.attribute}.value`] = chosen; + + // 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 ( this.options.minimum && (chosen.length < this.options.minimum) ) { - return ui.notifications.error(`You must choose at least ${this.options.minimum} options`); + if ( o.minimum && (chosen.length < o.minimum) ) { + return ui.notifications.error(`You must choose at least ${o.minimum} options`); } - if ( this.options.maximum && (chosen.length > this.options.maximum) ) { - return ui.notifications.error(`You may choose no more than ${this.options.maximum} options`); - } - - // Include custom - if ( this.options.allowCustom ) { - updateData[`${this.attribute}.custom`] = formData.custom; + if ( o.maximum && (chosen.length > o.maximum) ) { + return ui.notifications.error(`You may choose no more than ${o.maximum} options`); } // Update the object diff --git a/module/canvas.js b/module/canvas.js index 1767d4d8..622e2a30 100644 --- a/module/canvas.js +++ b/module/canvas.js @@ -35,20 +35,4 @@ export const measureDistances = function(segments, options={}) { // Standard PHB Movement else return (ns + nd) * canvas.scene.data.gridDistance; }); -}; - -/* -------------------------------------------- */ - -/** - * Hijack Token health bar rendering to include temporary and temp-max health in the bar display - * TODO: This should probably be replaced with a formal Token class extension - */ -const _TokenGetBarAttribute = Token.prototype.getBarAttribute; -export const getBarAttribute = function(...args) { - const data = _TokenGetBarAttribute.bind(this)(...args); - if ( data && (data.attribute === "attributes.hp") ) { - data.value += parseInt(getProperty(this.actor.data, "data.attributes.hp.temp") || 0); - data.max += parseInt(getProperty(this.actor.data, "data.attributes.hp.tempmax") || 0); - } - return data; -}; +}; \ No newline at end of file diff --git a/module/chat.js b/module/chat.js index f8ab035f..d024d8aa 100644 --- a/module/chat.js +++ b/module/chat.js @@ -40,7 +40,7 @@ export const displayChatActionButtons = function(message, html, data) { // If the user is the message author or the actor owner, proceed let actor = game.actors.get(data.message.speaker.actor); - if ( actor && actor.owner ) return; + 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 @@ -66,7 +66,7 @@ export const displayChatActionButtons = function(message, html, data) { export const addChatMessageContextOptions = function(html, options) { let canApply = li => { const message = game.messages.get(li.data("messageId")); - return message?.isRoll && message?.isContentVisible && canvas?.tokens.controlled.length; + return message?.isRoll && message?.isContentVisible && canvas.tokens?.controlled.length; }; options.push( { diff --git a/module/combat.js b/module/combat.js index 8132a5ac..58535411 100644 --- a/module/combat.js +++ b/module/combat.js @@ -5,20 +5,19 @@ * Apply the dexterity score as a decimal tiebreaker if requested * See Combat._getInitiativeFormula for more detail. */ -export const _getInitiativeFormula = function(combatant) { - const actor = combatant.actor; +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 diff --git a/module/config.js b/module/config.js index 71c8a593..e4137d53 100644 --- a/module/config.js +++ b/module/config.js @@ -63,7 +63,7 @@ SW5E.attunementTypes = { NONE: 0, REQUIRED: 1, ATTUNED: 2, -} +}; /** * An enumeration of item attunement states @@ -77,7 +77,6 @@ SW5E.attunements = { /* -------------------------------------------- */ - SW5E.weaponProficiencies = { "blp": "SW5E.WeaponBlasterPistolProficiency", "chk": "SW5E.WeaponChakramProficiency", @@ -89,13 +88,13 @@ SW5E.weaponProficiencies = { "imp": "SW5E.WeaponImprovisedProficiency", "lfl": "SW5E.WeaponLightFoilProficiency", "lrg": "SW5E.WeaponLightRingProficiency", - "mar": "SW5E.WeaponMartialProficiency", + "mar": "SW5E.WeaponMartialProficiency", "mrb": "SW5E.WeaponMartialBlasterProficiency", "mlw": "SW5E.WeaponMartialLightweaponProficiency", "mvb": "SW5E.WeaponMartialVibroweaponProficiency", "ntl": "SW5E.WeaponNaturalProficiency", "swh": "SW5E.WeaponSaberWhipProficiency", - "sim": "SW5E.WeaponSimpleProficiency", + "sim": "SW5E.WeaponSimpleProficiency", "smb": "SW5E.WeaponSimpleBlasterProficiency", "slw": "SW5E.WeaponSimpleLightweaponProficiency", "svb": "SW5E.WeaponSimpleVibroweaponProficiency", @@ -104,6 +103,59 @@ SW5E.weaponProficiencies = { "vbw": "SW5E.WeaponVibrowhipProficiency" }; +// TODO: Check to see if this can be used +// It's not actually been used anywhere in DND5e 1.3.2 +// Note name mapped to ID in compendium +/** + * The basic weapon types in 5e. This enables specific weapon proficiencies or + * starting equipment provided by classes and backgrounds. + * + * @enum {string} + +SW5E.weaponIds = { + "battleaxe": "I0WocDSuNpGJayPb", + "blowgun": "wNWK6yJMHG9ANqQV", + "club": "nfIRTECQIG81CvM4", + "dagger": "0E565kQUBmndJ1a2", + "dart": "3rCO8MTIdPGSW6IJ", + "flail": "UrH3sMdnUDckIHJ6", + "glaive": "rOG1OM2ihgPjOvFW", + "greataxe": "1Lxk6kmoRhG8qQ0u", + "greatclub": "QRCsxkCwWNwswL9o", + "greatsword": "xMkP8BmFzElcsMaR", + "halberd": "DMejWAc8r8YvDPP1", + "handaxe": "eO7Fbv5WBk5zvGOc", + "handcrossbow": "qaSro7kFhxD6INbZ", + "heavycrossbow": "RmP0mYRn2J7K26rX", + "javelin": "DWLMnODrnHn8IbAG", + "lance": "RnuxdHUAIgxccVwj", + "lightcrossbow": "ddWvQRLmnnIS0eLF", + "lighthammer": "XVK6TOL4sGItssAE", + "longbow": "3cymOVja8jXbzrdT", + "longsword": "10ZP2Bu3vnCuYMIB", + "mace": "Ajyq6nGwF7FtLhDQ", + "maul": "DizirD7eqjh8n95A", + "morningstar": "dX8AxCh9o0A9CkT3", + "net": "aEiM49V8vWpWw7rU", + "pike": "tC0kcqZT9HHAO0PD", + "quarterstaff": "g2dWN7PQiMRYWzyk", + "rapier": "Tobce1hexTnDk4sV", + "scimitar": "fbC0Mg1a73wdFbqO", + "shortsword": "osLzOwQdPtrK3rQH", + "sickle": "i4NeNZ30ycwPDHMx", + "spear": "OG4nBBydvmfWYXIk", + "shortbow": "GJv6WkD7D2J6rP6M", + "sling": "3gynWO9sN4OLGMWD", + "trident": "F65ANO66ckP8FDMa", + "warpick": "2YdfjN1PIIrSHZii", + "warhammer": "F0Df164Xv1gWcYt0", + "whip": "QKTyxoO0YDnAsbYe" +}; + + */ + +/* -------------------------------------------- */ + SW5E.toolProficiencies = { "armor": "SW5E.ToolArmormech", "arms": "SW5E.ToolArmstech", @@ -137,6 +189,51 @@ SW5E.toolProficiencies = { "vehicle": "SW5E.ToolVehicle" }; +// TODO: Same as weapon IDs +// Also unused, and SW5E.toolProficiencies is already pretty verbose anyway +/** + * The basic tool types in 5e. This enables specific tool proficiencies or + * starting equipment provided by classes and backgrounds. + * + * @enum {string} +SW5E.toolIds = { + "alchemist": "SztwZhbhZeCqyAes", + "bagpipes": "yxHi57T5mmVt0oDr", + "brewer": "Y9S75go1hLMXUD48", + "calligrapher": "jhjo20QoiD5exf09", + "card": "YwlHI3BVJapz4a3E", + "carpenter": "8NS6MSOdXtUqD7Ib", + "cartographer": "fC0lFK8P4RuhpfaU", + "cobbler": "hM84pZnpCqKfi8XH", + "cook": "Gflnp29aEv5Lc1ZM", + "dice": "iBuTM09KD9IoM5L8", + "disg": "IBhDAr7WkhWPYLVn", + "drum": "69Dpr25pf4BjkHKb", + "dulcimer": "NtdDkjmpdIMiX7I2", + "flute": "eJOrPcAz9EcquyRQ", + "forg": "cG3m4YlHfbQlLEOx", + "glassblower": "rTbVrNcwApnuTz5E", + "herb": "i89okN7GFTWHsvPy", + "horn": "aa9KuBy4dst7WIW9", + "jeweler": "YfBwELTgPFHmQdHh", + "leatherworker": "PUMfwyVUbtyxgYbD", + "lute": "qBydtUUIkv520DT7", + "lyre": "EwG1EtmbgR3bM68U", + "mason": "skUih6tBvcBbORzA", + "navg": "YHCmjsiXxZ9UdUhU", + "painter": "ccm5xlWhx74d6lsK", + "panflute": "G5m5gYIx9VAUWC3J", + "pois": "il2GNi8C0DvGLL9P", + "potter": "hJS8yEVkqgJjwfWa", + "shawm": "G3cqbejJpfB91VhP", + "smith": "KndVe2insuctjIaj", + "thief": "woWZ1sO5IUVGzo58", + "tinker": "0d08g1i5WXnNrCNA", + "viol": "baoe3U5BfMMMxhCU", + "weaver": "ap9prThUB2y9lDyj", + "woodcarver": "xKErqkLo4ASYr5EP", +}; +*/ /* -------------------------------------------- */ @@ -210,6 +307,35 @@ SW5E.tokenSizes = { "grg": 4 }; +/** + * Colors used to visualize temporary and temporary maximum HP in token health bars + * @enum {number} + */ +SW5E.tokenHPColors = { + temp: 0x66CCFF, + tempmax: 0x440066, + negmax: 0x550000 +} + +/* -------------------------------------------- */ + +/** + * Creature types + * @type {Object} + */ +SW5E.creatureTypes = { + "Aberration": "SW5E.CreatureAberration", + "Beast": "SW5E.CreatureBeast", + "Construct": "SW5E.CreatureConstruct", + "Droid": "SW5E.CreatureDroid", + "Elemental": "SW5E.CreatureElemental", + "Human": "SW5E.CreatureHuman", + "Humanoid": "SW5E.CreatureHumanoid", + "Plant": "SW5E.CreaturePlant", + "Undead": "SW5E.CreatureUndead" +}; + + /* -------------------------------------------- */ /** @@ -254,7 +380,7 @@ SW5E.limitedUsePeriods = { /* -------------------------------------------- */ /** - * The set of equipment types for armor, clothing, and other objects which can ber worn by the character + * The set of equipment types for armor, clothing, and other objects which can be worn by the character * @type {Object} */ SW5E.equipmentTypes = { @@ -338,7 +464,7 @@ SW5E.damageTypes = { }; // Damage Resistance Types -SW5E.damageResistanceTypes = duplicate(SW5E.damageTypes); +SW5E.damageResistanceTypes = foundry.utils.deepClone(SW5E.damageTypes); /* -------------------------------------------- */ @@ -392,7 +518,7 @@ SW5E.movementTypes = { "swim": "SW5E.MovementSwim", "turn": "SW5E.MovementTurn", "walk": "SW5E.MovementWalk", -} +}; /** * The valid units of measure for movement distances in the game system. @@ -402,7 +528,7 @@ SW5E.movementTypes = { SW5E.movementUnits = { "ft": "SW5E.DistFt", "mi": "SW5E.DistMi" -} +}; /** * The valid units of measure for the range of an action or effect. @@ -495,7 +621,7 @@ SW5E.healingTypes = { /** * Enumerate the denominations of hit dice which can apply to classes in the SW5E system - * @type {Array.} + * @type {string[]} */ SW5E.hitDieTypes = ["d4", "d6", "d8", "d10", "d12", "d20"]; @@ -504,7 +630,7 @@ SW5E.hitDieTypes = ["d4", "d6", "d8", "d10", "d12", "d20"]; /** * Enumerate the denominations of power dice which can apply to starships in the SW5E system - * @type {Array.} + * @enum {string} */ SW5E.powerDieTypes = [1, "d4", "d6", "d8", "d10", "d12"]; @@ -896,6 +1022,32 @@ SW5E.powerLevels = { 9: "SW5E.PowerLevel9" }; +// TODO: This is used for spell scrolls, it maps the level to the compendium ID of the item the spell would be bound to +// We could use this wit, say, holocrons to produce scrolls +/* +// Power Scroll Compendium UUIDs +SW5E.powerScrollIds = { + 0: "rQ6sO7HDWzqMhSI3", + 1: "9GSfMg0VOA2b4uFN", + 2: "XdDp6CKh9qEvPTuS", + 3: "hqVKZie7x9w3Kqds", + 4: "DM7hzgL836ZyUFB1", + 5: "wa1VF8TXHmkrrR35", + 6: "tI3rWx4bxefNCexS", + 7: "mtyw4NS1s7j2EJaD", + 8: "aOrinPg7yuDZEuWr", + 9: "O4YbkJkLlnsgUszZ" +}; + */ + +/** + * Compendium packs used for localized items. + * @enum {string} + */ +SW5E.sourcePacks = { + ITEMS: "sw5e.items" +} + // Polymorph options. SW5E.polymorphSettings = { keepPhysical: 'SW5E.PolymorphKeepPhysical', diff --git a/module/dice.js b/module/dice.js index cea097fd..7d3c2489 100644 --- a/module/dice.js +++ b/module/dice.js @@ -1,3 +1,6 @@ +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 * @@ -20,28 +23,29 @@ export function simplifyRollFormula(formula, data, {constantFirst = false} = {}) const constantTerms = []; // Terms that are constant, and their associated operators let operators = []; // Temporary storage for operators before they are moved to one of the above - for (let term of terms) { // For each term - if (["+", "-"].includes(term)) operators.push(term); // If the term is an addition/subtraction operator, push the term into the operators array - else { // Otherwise the term is not an operator - if (term instanceof DiceTerm) { // If the term is something rollable - rollableTerms.push(...operators); // Place all the operators into the rollableTerms array - rollableTerms.push(term); // Then place this rollable term into it as well - } // - else { // Otherwise, this must be a constant - constantTerms.push(...operators); // Place the operators into the constantTerms array - constantTerms.push(term); // Then also add this constant term to that array. - } // - operators = []; // Finally, the operators have now all been assigend to one of the arrays, so empty this before the next iteration. + 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.cleanFormula(constantTerms); // Cleans up the constant terms and produces a new formula string - const rollableFormula = Roll.cleanFormula(rollableTerms); // Cleans up the non-constant terms and produces a new formula string + const 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 - const constantPart = roll._safeEval(constantFormula); // Mathematically evaluate the constant formula to produce a single constant term + // Mathematically evaluate the constant formula to produce a single constant term + const constantPart = constantFormula ? Roll.safeEval(constantFormula) : undefined; - const parts = constantFirst ? // Order the rollable and constant terms, either constant first or second depending on the optional argumen - [constantPart, rollableFormula] : [rollableFormula, constantPart]; + // 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; @@ -55,316 +59,203 @@ export function simplifyRollFormula(formula, data, {constantFirst = false} = {}) * @return {Boolean} True when unsupported, false if supported */ function _isUnsupportedTerm(term) { - const diceTerm = term instanceof DiceTerm; - const operator = ["+", "-"].includes(term); - const number = !isNaN(Number(term)); + const diceTerm = term instanceof DiceTerm; + const operator = term instanceof OperatorTerm && ["+", "-"].includes(term.operator); + const number = term instanceof NumericTerm; - return !(diceTerm || operator || number); + return !(diceTerm || operator || number); } +/* -------------------------------------------- */ +/* D20 Roll */ /* -------------------------------------------- */ /** - * A standardized helper function for managing core 5e "d20 rolls" - * + * A standardized helper function for managing core 5e d20 rolls. * Holding SHIFT, ALT, or CTRL when the attack is rolled will "fast-forward". * This chooses the default options of a normal attack with no bonus, Advantage, or Disadvantage respectively * - * @param {Array} parts The dice roll component parts, excluding the initial d20 - * @param {Object} data Actor or item data against which to parse the roll - * @param {Event|object} event The triggering event which initiated the roll - * @param {string} rollMode A specific roll mode to apply as the default for the resulting roll - * @param {string|null} template The HTML template used to render the roll dialog - * @param {string|null} title The dice roll UI window title - * @param {Object} speaker The ChatMessage speaker to pass when creating the chat - * @param {string|null} flavor Flavor text to use in the posted chat message - * @param {Boolean} fastForward Allow fast-forward advantage selection - * @param {Function} onClose Callback for actions to take when the dialog form is closed - * @param {Object} dialogOptions Modal dialog options - * @param {boolean} advantage Apply advantage to the roll (unless otherwise specified) - * @param {boolean} disadvantage Apply disadvantage to the roll (unless otherwise specified) - * @param {number} critical The value of d20 result which represents a critical success - * @param {number} fumble The value of d20 result which represents a critical failure - * @param {number} targetValue Assign a target value against which the result of this roll should be compared - * @param {boolean} elvenAccuracy Allow Elven Accuracy to modify this roll? - * @param {boolean} halflingLucky Allow Halfling Luck to modify this roll? - * @param {boolean} reliableTalent Allow Reliable Talent to modify this roll? - * @param {boolean} chatMessage Automatically create a Chat Message for the result of this roll - * @param {object} messageData Additional data which is applied to the created Chat Message, if any + * @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 * - * @return {Promise} A Promise which resolves once the roll workflow has completed + * @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} The evaluated D20Roll, or null if the workflow was cancelled */ -export async function d20Roll({parts=[], data={}, event={}, rollMode=null, template=null, title=null, speaker=null, - flavor=null, fastForward=null, dialogOptions, - advantage=null, disadvantage=null, critical=20, fumble=1, targetValue=null, - elvenAccuracy=false, halflingLucky=false, reliableTalent=false, - chatMessage=true, messageData={}}={}) { +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 + }={}) { - // Prepare Message Data - messageData.flavor = flavor || title; - messageData.speaker = speaker || ChatMessage.getSpeaker(); - const messageOptions = {rollMode: rollMode || game.settings.get("core", "rollMode")}; - parts = parts.concat(["@bonus"]); + // 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"; - // Handle fast-forward events - let adv = 0; - fastForward = fastForward ?? (event && (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)); - if (fastForward) { - if ( advantage ?? event.altKey ) adv = 1; - else if ( disadvantage ?? (event.ctrlKey || event.metaKey) ) adv = -1; + // 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; } - // Define the inner roll function - const _roll = (parts, adv, form) => { - - // Determine the d20 roll and modifiers - let nd = 1; - let mods = halflingLucky ? "r1=1" : ""; - - // Handle advantage - if (adv === 1) { - nd = elvenAccuracy ? 3 : 2; - messageData.flavor += ` (${game.i18n.localize("SW5E.Advantage")})`; - if ( "flags.sw5e.roll" in messageData ) messageData["flags.sw5e.roll"].advantage = true; - mods += "kh"; - } - - // Handle disadvantage - else if (adv === -1) { - nd = 2; - messageData.flavor += ` (${game.i18n.localize("SW5E.Disadvantage")})`; - if ( "flags.sw5e.roll" in messageData ) messageData["flags.sw5e.roll"].disadvantage = true; - mods += "kl"; - } - - // Prepend the d20 roll - let formula = `${nd}d20${mods}`; - if (reliableTalent) formula = `{${nd}d20${mods},10}kh`; - parts.unshift(formula); - - // Optionally include a situational bonus - if ( form ) { - data['bonus'] = form.bonus.value; - messageOptions.rollMode = form.rollMode.value; - } - if (!data["bonus"]) parts.pop(); - - // Optionally include an ability score selection (used for tool checks) - const ability = form ? form.ability : null; - if (ability && ability.value) { - data.ability = ability.value; - const abl = data.abilities[data.ability]; - if (abl) { - data.mod = abl.mod; - messageData.flavor += ` (${CONFIG.SW5E.abilities[data.ability]})`; - } - } - - // Execute the roll - let roll = new Roll(parts.join(" + "), data); - try { - roll.roll(); - } catch (err) { - console.error(err); - ui.notifications.error(`Dice roll evaluation failed: ${err.message}`); - return null; - } - - // Flag d20 options for any 20-sided dice in the roll - for (let d of roll.dice) { - if (d.faces === 20) { - d.options.critical = critical; - d.options.fumble = fumble; - if ( adv === 1 ) d.options.advantage = true; - else if ( adv === -1 ) d.options.disadvantage = true; - if (targetValue) d.options.target = targetValue; - } - } - - // If reliable talent was applied, add it to the flavor text - if (reliableTalent && roll.dice[0].total < 10) { - messageData.flavor += ` (${game.i18n.localize("SW5E.FlagsReliableTalent")})`; - } - return roll; - }; - - // Create the Roll instance - const roll = fastForward ? _roll(parts, adv) : - await _d20RollDialog({template, title, parts, data, rollMode: messageOptions.rollMode, dialogOptions, roll: _roll}); + // Evaluate the configured roll + await roll.evaluate({async: true}); // Create a Chat Message - if ( roll && chatMessage ) roll.toMessage(messageData, messageOptions); + 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; } /* -------------------------------------------- */ /** - * Present a Dialog form which creates a d20 roll once submitted - * @return {Promise} - * @private + * 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 */ -async function _d20RollDialog({template, title, parts, data, rollMode, dialogOptions, roll}={}) { - - // Render modal dialog - template = template || "systems/sw5e/templates/chat/roll-dialog.html"; - let dialogData = { - formula: parts.join(" + "), - data: data, - rollMode: rollMode, - rollModes: CONFIG.Dice.rollModes, - config: CONFIG.SW5E - }; - const html = await renderTemplate(template, dialogData); - - // Create the Dialog window - return new Promise(resolve => { - new Dialog({ - title: title, - content: html, - buttons: { - advantage: { - label: game.i18n.localize("SW5E.Advantage"), - callback: html => resolve(roll(parts, 1, html[0].querySelector("form"))) - }, - normal: { - label: game.i18n.localize("SW5E.Normal"), - callback: html => resolve(roll(parts, 0, html[0].querySelector("form"))) - }, - disadvantage: { - label: game.i18n.localize("SW5E.Disadvantage"), - callback: html => resolve(roll(parts, -1, html[0].querySelector("form"))) - } - }, - default: "normal", - close: () => resolve(null) - }, dialogOptions).render(true); - }); +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 "d20 rolls" + * 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 {Array} parts The dice roll component parts, excluding the initial d20 - * @param {Actor} actor The Actor making the damage roll - * @param {Object} data Actor or item data against which to parse the roll - * @param {Event|object}[event The triggering event which initiated the roll - * @param {string} rollMode A specific roll mode to apply as the default for the resulting roll - * @param {String} template The HTML template used to render the roll dialog - * @param {String} title The dice roll UI window title - * @param {Object} speaker The ChatMessage speaker to pass when creating the chat - * @param {string} flavor Flavor text to use in the posted chat message - * @param {boolean} allowCritical Allow the opportunity for a critical hit to be rolled - * @param {Boolean} critical Flag this roll as a critical hit for the purposes of fast-forward rolls - * @param {number} criticalBonusDice A number of bonus damage dice that are added for critical hits - * @param {number} criticalMultiplier A critical hit multiplier which is applied to critical hits - * @param {Boolean} fastForward Allow fast-forward advantage selection - * @param {Function} onClose Callback for actions to take when the dialog form is closed - * @param {Object} dialogOptions Modal dialog options - * @param {boolean} chatMessage Automatically create a Chat Message for the result of this roll - * @param {object} messageData Additional data which is applied to the created Chat Message, if any + * @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 * - * @return {Promise} A Promise which resolves once the roll workflow has completed + * @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} The evaluated DamageRoll, or null if the workflow was canceled */ -export async function damageRoll({parts, actor, data, event={}, rollMode=null, template, title, speaker, flavor, - allowCritical=true, critical=false, criticalBonusDice=0, criticalMultiplier=2, fastForward=null, - dialogOptions={}, chatMessage=true, messageData={}}={}) { +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 + }={}) { - // Prepare Message Data - messageData.flavor = flavor || title; - messageData.speaker = speaker || ChatMessage.getSpeaker(); - const messageOptions = {rollMode: rollMode || game.settings.get("core", "rollMode")}; - parts = parts.concat(["@bonus"]); + // Handle input arguments + const defaultRollMode = rollMode || game.settings.get("core", "rollMode"); - // Define inner roll function - const _roll = function(parts, crit, form) { - - // Optionally include a situational bonus - if ( form ) { - data['bonus'] = form.bonus.value; - messageOptions.rollMode = form.rollMode.value; - } - if (!data["bonus"]) parts.pop(); - - // Create the damage roll - let roll = new Roll(parts.join("+"), data); - - // Modify the damage formula for critical hits - if ( crit === true ) { - roll.alter(criticalMultiplier, 0); // Multiply all dice - if ( roll.terms[0] instanceof Die ) { // Add bonus dice for only the main dice term - roll.terms[0].alter(1, criticalBonusDice); - roll._formula = roll.formula; - } - messageData.flavor += ` (${game.i18n.localize("SW5E.Critical")})`; - if ( "flags.sw5e.roll" in messageData ) messageData["flags.sw5e.roll"].critical = true; - } - - // Execute the roll - try { - roll.evaluate() - if ( crit ) roll.dice.forEach(d => d.options.critical = true); // TODO workaround core bug which wipes Roll#options on roll - return roll; - } catch(err) { - console.error(err); - ui.notifications.error(`Dice roll evaluation failed: ${err.message}`); - return null; - } - }; - - // Create the Roll instance - const roll = fastForward ? _roll(parts, critical) : await _damageRollDialog({ - template, title, parts, data, allowCritical, rollMode: messageOptions.rollMode, dialogOptions, roll: _roll + // 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 }); - // Create a Chat Message - if ( roll && chatMessage ) roll.toMessage(messageData, messageOptions); - return roll; + // 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; } /* -------------------------------------------- */ /** - * Present a Dialog form which creates a damage roll once submitted - * @return {Promise} - * @private + * 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 */ -async function _damageRollDialog({template, title, parts, data, allowCritical, rollMode, dialogOptions, roll}={}) { - - // Render modal dialog - template = template || "systems/sw5e/templates/chat/roll-dialog.html"; - let dialogData = { - formula: parts.join(" + "), - data: data, - rollMode: rollMode, - rollModes: CONFIG.Dice.rollModes - }; - const html = await renderTemplate(template, dialogData); - - // Create the Dialog window - return new Promise(resolve => { - new Dialog({ - title: title, - content: html, - buttons: { - critical: { - condition: allowCritical, - label: game.i18n.localize("SW5E.CriticalHit"), - callback: html => resolve(roll(parts, true, html[0].querySelector("form"))) - }, - normal: { - label: game.i18n.localize(allowCritical ? "SW5E.Normal" : "SW5E.Roll"), - callback: html => resolve(roll(parts, false, html[0].querySelector("form"))) - }, - }, - default: "normal", - close: () => resolve(null) - }, dialogOptions).render(true); - }); +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}; } diff --git a/module/dice/d20-roll.js b/module/dice/d20-roll.js new file mode 100644 index 00000000..c4b40824 --- /dev/null +++ b/module/dice/d20-roll.js @@ -0,0 +1,217 @@ +/** + * A type of Roll specific to a d20-based check, save, or attack roll in the 5e system. + * @param {string} formula The string formula to parse + * @param {object} data The data object against which to parse attributes within the formula + * @param {object} [options={}] Extra optional arguments which describe or modify the D20Roll + * @param {number} [options.advantageMode] What advantage modifier to apply to the roll (none, advantage, disadvantage) + * @param {number} [options.critical] The value of d20 result which represents a critical success + * @param {number} [options.fumble] The value of d20 result which represents a critical failure + * @param {(number)} [options.targetValue] Assign a target value against which the result of this roll should be compared + * @param {boolean} [options.elvenAccuracy=false] Allow Elven Accuracy to modify this roll? + * @param {boolean} [options.halflingLucky=false] Allow Halfling Luck to modify this roll? + * @param {boolean} [options.reliableTalent=false] Allow Reliable Talent to modify this roll? + */ +// TODO: Check elven accuracy, halfling lucky, and reliable talent are required +// Elven Accuracy is Supreme accuracy feat, Reliable Talent is operative's Reliable Talent Class Feat +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)) ) { + throw new Error(`Invalid D20Roll formula provided ${this._formula}`); + } + this.configureModifiers(); + } + + /* -------------------------------------------- */ + + /** + * Advantage mode of a 5e d20 roll + * @enum {number} + */ + static ADV_MODE = { + NORMAL: 0, + ADVANTAGE: 1, + DISADVANTAGE: -1, + } + + /** + * The HTML template path used to configure evaluation of this Roll + * @type {string} + */ + static EVALUATION_TEMPLATE = "systems/sw5e/templates/chat/roll-dialog.html"; + + /* -------------------------------------------- */ + + /** + * A convenience reference for whether this D20Roll has advantage + * @type {boolean} + */ + get hasAdvantage() { + return this.options.advantageMode === D20Roll.ADV_MODE.ADVANTAGE; + } + + /** + * A convenience reference for whether this D20Roll has disadvantage + * @type {boolean} + */ + get hasDisadvantage() { + return this.options.advantageMode === D20Roll.ADV_MODE.DISADVANTAGE; + } + + /* -------------------------------------------- */ + /* D20 Roll Methods */ + /* -------------------------------------------- */ + + /** + * Apply optional modifiers which customize the behavior of the d20term + * @private + */ + configureModifiers() { + const d20 = this.terms[0]; + d20.modifiers = []; + + // Halfling Lucky + if ( this.options.halflingLucky ) d20.modifiers.push("r1=1"); + + // Reliable Talent + if ( this.options.reliableTalent ) d20.modifiers.push("min10"); + + // Handle Advantage or Disadvantage + if ( this.hasAdvantage ) { + d20.number = this.options.elvenAccuracy ? 3 : 2; + d20.modifiers.push("kh"); + d20.options.advantage = true; + } + else if ( this.hasDisadvantage ) { + d20.number = 2; + d20.modifiers.push("kl"); + d20.options.disadvantage = true; + } + 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; + + // Re-compile the underlying formula + this._formula = this.constructor.getFormula(this.terms); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + 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}); + + // 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")})`; + + // Add reliable talent to the d20-term flavor text if it applied + if ( this.options.reliableTalent ) { + const d20 = this.dice[0]; + 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; + } + + // Record the preferred rollMode + options.rollMode = options.rollMode ?? this.options.rollMode; + return super.toMessage(messageData, options); + } + + /* -------------------------------------------- */ + /* Configuration Dialog */ + /* -------------------------------------------- */ + + /** + * Create a Dialog prompt used to configure evaluation of an existing D20Roll instance. + * @param {object} data Dialog configuration data + * @param {string} [data.title] The title of the shown dialog window + * @param {number} [data.defaultRollMode] The roll mode that the roll mode select element should default to + * @param {number} [data.defaultAction] The button marked as default + * @param {boolean} [data.chooseModifier] Choose which ability modifier should be applied to the roll? + * @param {string} [data.defaultAbility] For tool rolls, the default ability modifier applied to the roll + * @param {string} [data.template] A custom path to an HTML template to use instead of the default + * @param {object} options Additional Dialog customization options + * @returns {Promise} 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={}) { + + // Render the Dialog inner HTML + const content = await renderTemplate(template ?? this.constructor.EVALUATION_TEMPLATE, { + formula: `${this.formula} + @bonus`, + defaultRollMode, + rollModes: CONFIG.Dice.rollModes, + chooseModifier, + defaultAbility, + abilities: CONFIG.SW5E.abilities + }); + + let defaultButton = "normal"; + switch (defaultAction) { + 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)) + }, + 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) + }, options).render(true); + }); + } + + /* -------------------------------------------- */ + + /** + * Handle submission of the Roll evaluation configuration Dialog + * @param {jQuery} html The submitted dialog content + * @param {number} advantageMode The chosen advantage mode + * @private + */ + _onDialogSubmit(html, advantageMode) { + const form = html[0].querySelector("form"); + + // Append a situational bonus term + 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: "+"})); + this.terms = this.terms.concat(bonus.terms); + } + + // Customize the modifier + 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.options.flavor += ` (${CONFIG.SW5E.abilities[form.ability.value]})`; + } + + // Apply advantage or disadvantage + this.options.advantageMode = advantageMode; + this.options.rollMode = form.rollMode.value; + this.configureModifiers(); + return this; + } +} diff --git a/module/dice/damage-roll.js b/module/dice/damage-roll.js new file mode 100644 index 00000000..cc901fb2 --- /dev/null +++ b/module/dice/damage-roll.js @@ -0,0 +1,181 @@ +/** + * A type of Roll specific to a damage (or healing) roll in the 5e system. + * @param {string} formula The string formula to parse + * @param {object} data The data object against which to parse attributes within the formula + * @param {object} [options={}] Extra optional arguments which describe or modify the DamageRoll + * @param {number} [options.criticalBonusDice=0] A number of bonus damage dice that are added for critical hits + * @param {number} [options.criticalMultiplier=2] A critical hit multiplier which is applied to critical hits + * @param {boolean} [options.multiplyNumeric=false] Multiply numeric terms by the critical multiplier + * @param {boolean} [options.powerfulCritical=false] Apply the "powerful criticals" house rule to critical hits + * + */ +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(); + } + + /** + * The HTML template path used to configure evaluation of this Roll + * @type {string} + */ + static EVALUATION_TEMPLATE = "systems/sw5e/templates/chat/roll-dialog.html"; + + /* -------------------------------------------- */ + + /** + * A convenience reference for whether this DamageRoll is a critical hit + * @type {boolean} + */ + get isCritical() { + return this.options.critical; + } + + /* -------------------------------------------- */ + /* Damage Roll Methods */ + /* -------------------------------------------- */ + + /** + * Apply optional modifiers which customize the behavior of the d20term + * @private + */ + configureDamage() { + let flatBonus = 0; + for ( let [i, term] of this.terms.entries() ) { + + // Multiply dice terms + if ( term instanceof DiceTerm ) { + term.options.baseNumber = term.options.baseNumber ?? term.number; // Reset back + term.number = term.options.baseNumber; + 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); + } + + // Alter the damage term + 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) ) { + term.options.baseNumber = term.options.baseNumber ?? term.number; // Reset back + term.number = term.options.baseNumber; + if ( this.isCritical ) { + term.number *= (this.options.criticalMultiplier ?? 2); + term.options.critical = true; + } + } + } + + // Add powerful critical bonus + 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")})); + } + + // Re-compile the underlying formula + this._formula = this.constructor.getFormula(this.terms); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + toMessage(messageData={}, options={}) { + messageData.flavor = messageData.flavor || this.options.flavor; + if ( this.isCritical ) { + const label = game.i18n.localize("SW5E.CriticalHit"); + messageData.flavor = messageData.flavor ? `${messageData.flavor} (${label})` : label; + } + options.rollMode = options.rollMode ?? this.options.rollMode; + return super.toMessage(messageData, options); + } + + /* -------------------------------------------- */ + /* Configuration Dialog */ + /* -------------------------------------------- */ + + /** + * Create a Dialog prompt used to configure evaluation of an existing D20Roll instance. + * @param {object} data Dialog configuration data + * @param {string} [data.title] The title of the shown dialog window + * @param {number} [data.defaultRollMode] The roll mode that the roll mode select element should default to + * @param {string} [data.defaultCritical] Should critical be selected as default + * @param {string} [data.template] A custom path to an HTML template to use instead of the default + * @param {boolean} [data.allowCritical=true] Allow critical hit to be chosen as a possible damage mode + * @param {object} options Additional Dialog customization options + * @returns {Promise} 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={}) { + + // Render the Dialog inner HTML + const content = await renderTemplate(template ?? this.constructor.EVALUATION_TEMPLATE, { + formula: `${this.formula} + @bonus`, + defaultRollMode, + 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)) + }, + normal: { + label: game.i18n.localize(allowCritical ? "SW5E.Normal" : "SW5E.Roll"), + callback: html => resolve(this._onDialogSubmit(html, false)) + } + }, + default: defaultCritical ? "critical" : "normal", + close: () => resolve(null) + }, options).render(true); + }); + } + + /* -------------------------------------------- */ + + /** + * Handle submission of the Roll evaluation configuration Dialog + * @param {jQuery} html The submitted dialog content + * @param {boolean} isCritical Is the damage a critical hit? + * @private + */ + _onDialogSubmit(html, isCritical) { + const form = html[0].querySelector("form"); + + // Append a situational bonus term + 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: "+"})); + this.terms = this.terms.concat(bonus.terms); + } + + // Apply advantage or disadvantage + this.options.critical = isCritical; + this.options.rollMode = form.rollMode.value; + this.configureDamage(); + return this; + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + static fromData(data) { + const roll = super.fromData(data); + roll._formula = this.getFormula(roll.terms); + return roll; + } +} diff --git a/module/dice/roll-dialog.js b/module/dice/roll-dialog.js new file mode 100644 index 00000000..724b3999 --- /dev/null +++ b/module/dice/roll-dialog.js @@ -0,0 +1,15 @@ +/** + * @deprecated since 1.3.0 + * @ignore + */ +async function d20Dialog(data, options) { + throw new Error(`The d20Dialog helper method is deprecated in favor of D20Roll#configureDialog`); +} + +/** + * @deprecated since 1.3.0 + * @ignore + */ +async function damageDialog(data, options) { + throw new Error(`The damageDialog helper method is deprecated in favor of DamageRoll#configureDialog`); +} diff --git a/module/effects.js b/module/effects.js index 0d957fdb..7a70430b 100644 --- a/module/effects.js +++ b/module/effects.js @@ -10,13 +10,13 @@ export function onManageActiveEffect(event, owner) { const effect = li.dataset.effectId ? owner.effects.get(li.dataset.effectId) : null; switch ( a.dataset.action ) { case "create": - return ActiveEffect.create({ + return owner.createEmbeddedDocuments("ActiveEffect", [{ label: "New Effect", icon: "icons/svg/aura.svg", origin: owner.uuid, "duration.rounds": li.dataset.effectType === "temporary" ? 1 : undefined, disabled: li.dataset.effectType === "inactive" - }, owner).create(); + }]); case "edit": return effect.sheet.render(true); case "delete": diff --git a/module/item/entity.js b/module/item/entity.js index 7709c71a..2f1f682b 100644 --- a/module/item/entity.js +++ b/module/item/entity.js @@ -2,7 +2,8 @@ import {simplifyRollFormula, d20Roll, damageRoll} from "../dice.js"; import AbilityUseDialog from "../apps/ability-use-dialog.js"; /** - * Override and extend the basic :class:`Item` implementation + * Override and extend the basic Item implementation + * @extends {Item} */ export default class Item5e extends Item { @@ -44,12 +45,15 @@ export default class Item5e extends Item { else if (this.data.type === "weapon") { const wt = itemData.weaponType; - // Melee weapons - Str or Dex if Finesse (PHB pg. 147) - if ( ["simpleVW", "martialVW", "simpleLW", "martialLW"].includes(wt) ) { - if (itemData.properties.fin === true) { // Finesse weapons - return (actorData.abilities["dex"].mod >= actorData.abilities["str"].mod) ? "dex" : "str"; - } - return "str"; + // Weapons using the powercasting modifier + // No current SW5e weapons use this, but it's worth checking just in case + if (["mpak", "rpak"].includes(itemData.actionType)) { + return actorData.attributes.powercasting || "int"; + } + + // Finesse weapons - Str or Dex (PHB pg. 147) + else if (itemData.properties.fin === true) { + return (actorData.abilities["dex"].mod >= actorData.abilities["str"].mod) ? "dex" : "str"; } // Ranged weapons - Dex (PH p.194) @@ -144,7 +148,7 @@ export default class Item5e extends Item { get hasLimitedUses() { let chg = this.data.data.recharge || {}; let uses = this.data.data.uses || {}; - return !!chg.value || (!!uses.per && (uses.max > 0)); + return !!chg.value || (uses.per && (uses.max > 0)); } /* -------------------------------------------- */ @@ -154,8 +158,8 @@ export default class Item5e extends Item { /** * Augment the basic Item data model with additional dynamic data. */ - prepareData() { - super.prepareData(); + prepareDerivedData() { + super.prepareDerivedData(); // Get the Item's data const itemData = this.data; @@ -190,6 +194,7 @@ export default class Item5e extends Item { else labels.featType = game.i18n.localize("SW5E.Passive"); } + // TODO: Something with all this // Species Items else if ( itemData.type === "species" ) { // labels.species = C.species[data.species]; @@ -268,34 +273,64 @@ export default class Item5e extends Item { // Item Actions if ( data.hasOwnProperty("actionType") ) { - // if this item is owned, we populate the label and saving throw during actor init - if (!this.isOwned) { - // Saving throws - this.getSaveDC(); - - // To Hit - this.getAttackToHit(); - } - // Damage let dam = data.damage || {}; - if ( dam.parts ) { + if (dam.parts) { labels.damage = dam.parts.map(d => d[0]).join(" + ").replace(/\+ -/g, "- "); labels.damageTypes = dam.parts.map(d => C.damageTypes[d[1]]).join(", "); } + } + + // if this item is owned, we prepareFinalAttributes() at the end of actor init + if (!this.isOwned) this.prepareFinalAttributes(); + } + + /* -------------------------------------------- */ + + /** + * Compute item attributes which might depend on prepared actor data. + */ + prepareFinalAttributes() { + if ( this.data.data.hasOwnProperty("actionType") ) { + // Saving throws + this.getSaveDC(); + + // To Hit + this.getAttackToHit(); // Limited Uses - if ( this.isOwned && !!data.uses?.max ) { - let max = data.uses.max; - if ( !Number.isNumeric(max) ) { - max = Roll.replaceFormulaData(max, this.actor.getRollData()); - if ( Roll.MATH_PROXY.safeEval ) max = Roll.MATH_PROXY.safeEval(max); - } - data.uses.max = Number(max); - } + this.prepareMaxUses(); + + // Damage Label + this.getDerivedDamageLabel(); } } + /* -------------------------------------------- */ + + /** + * Populate a label with the compiled and simplified damage formula + * based on owned item actor data. This is only used for display + * purposes and is not related to Item5e#rollDamage + * + * @returns {Array} array of objects with `formula` and `damageType` + */ + getDerivedDamageLabel() { + const itemData = this.data.data; + if ( !this.hasAttack || !itemData || !this.isOwned ) return []; + + const rollData = this.getRollData(); + + const derivedDamage = itemData.damage?.parts?.map((damagePart) => ({ + formula: simplifyRollFormula(damagePart[0], rollData, { constantFirst: false }), + damageType: damagePart[1], + })); + + this.labels.derivedDamage = derivedDamage + + return derivedDamage; + } + /* -------------------------------------------- */ /** @@ -409,6 +444,31 @@ export default class Item5e extends Item { /* -------------------------------------------- */ + /** + * Populates the max uses of an item. + * If the item is an owned item and the `max` is not numeric, calculate based on actor data. + */ + prepareMaxUses() { + const data = this.data.data; + if (!data.uses?.max) return; + let max = data.uses.max; + + // if this is an owned item and the max is not numeric, we need to calculate it + if (this.isOwned && !Number.isNumeric(max)) { + if (this.actor.data === undefined) return; + try { + max = Roll.replaceFormulaData(max, this.actor.getRollData(), {missing: 0, warn: true}); + max = Roll.safeEval(max); + } catch(e) { + console.error('Problem preparing Max uses for', this.data.name, e); + return; + } + } + data.uses.max = Number(max); + } + + /* -------------------------------------------- */ + /** * Roll the item to Chat, creating a chat card which contains follow up attack or damage roll options * @param {boolean} [configureDialog] Display a configuration dialog for the item roll, if applicable? @@ -419,16 +479,18 @@ export default class Item5e extends Item { */ async roll({configureDialog=true, rollMode, createMessage=true}={}) { let item = this; + const id = this.data.data; // Item system data const actor = this.actor; + const ad = actor.data.data; // Actor system data // Reference aspects of the item data necessary for usage - const id = this.data.data; // Item data const hasArea = this.hasAreaTarget; // Is the ability usage an AoE? const resource = id.consume || {}; // Resource consumption const recharge = id.recharge || {}; // Recharge mechanic const uses = id?.uses ?? {}; // Limited uses const isPower = this.type === "power"; // Does the item require a power slot? // TODO: Possibly Mod this to not consume slots based on class? + // We could use this for feats and architypes that let a character cast one slot every rest or so const requirePowerSlot = isPower && (id.level > 0) && CONFIG.SW5E.powerUpcastModes.includes(id.preparation.mode); // Define follow-up actions resulting from the item usage @@ -438,6 +500,8 @@ export default class Item5e extends Item { let consumePowerSlot = requirePowerSlot; // Consume a power slot let consumeUsage = !!uses.per; // Consume limited uses let consumeQuantity = uses.autoDestroy; // Consume quantity of the item in lieu of uses + let consumePowerLevel = null; // Consume a specific category of power slot + if ( requirePowerSlot ) consumePowerLevel = id.preparation.mode === "pact" ? "pact" : `power${id.level}`; // Display a configuration dialog to customize the usage const needsConfiguration = createMeasuredTemplate || consumeRecharge || consumeResource || consumePowerSlot || consumeUsage; @@ -455,28 +519,28 @@ export default class Item5e extends Item { // Handle power upcasting if ( requirePowerSlot ) { - const slotLevel = configuration.level; - const powerLevel = parseInt(slotLevel); - - if (powerLevel !== id.level) { - const upcastData = mergeObject(this.data, {"data.level": powerLevel}, {inplace: false}); - item = this.constructor.createOwned(upcastData, actor); // Replace the item with an upcast version + consumePowerLevel = `power${configuration.level}`; + if (consumePowerSlot === false) consumePowerLevel = null; + const upcastLevel = parseInt(configuration.level); + if (upcastLevel !== id.level) { + item = this.clone({"data.level": upcastLevel}, {keepId: true}); + item.data.update({_id: this.id}); // Retain the original ID (needed until 0.8.2+) + item.prepareFinalAttributes(); // Power save DC, etc... } - if ( consumePowerSlot ) consumePowerSlot = `power${powerLevel}`; } } // Determine whether the item can be used by testing for resource consumption - const usage = item._getUsageUpdates({consumeRecharge, consumeResource, consumePowerSlot, consumeUsage, consumeQuantity}); + const usage = item._getUsageUpdates({consumeRecharge, consumeResource, consumePowerLevel, consumeUsage, consumeQuantity}); if ( !usage ) return; const {actorUpdates, itemUpdates, resourceUpdates} = usage; // Commit pending data updates - if ( !isObjectEmpty(itemUpdates) ) await item.update(itemUpdates); - if ( consumeQuantity && (item.data.data.quantity === 0) ) await item.delete(); - if ( !isObjectEmpty(actorUpdates) ) await actor.update(actorUpdates); - if ( !isObjectEmpty(resourceUpdates) ) { + if ( !foundry.utils.isObjectEmpty(itemUpdates) ) await item.update(itemUpdates); + if ( consumeQuantity && (id.quantity === 0) ) await item.delete(); + if ( !foundry.utils.isObjectEmpty(actorUpdates) ) await actor.update(actorUpdates); + if ( !foundry.utils.isObjectEmpty(resourceUpdates) ) { const resource = actor.items.get(id.consume?.target); if ( resource ) await resource.update(resourceUpdates); } @@ -499,12 +563,12 @@ export default class Item5e extends Item { * @param {boolean} consumeQuantity Consume quantity of the item if other consumption modes are not available? * @param {boolean} consumeRecharge Whether the item consumes the recharge mechanic * @param {boolean} consumeResource Whether the item consumes a limited resource - * @param {string|boolean} consumePowerSlot A level of power slot consumed, or false + * @param {string|null} consumePowerLevel The category of power slot to consume, or null * @param {boolean} consumeUsage Whether the item consumes a limited usage * @returns {object|boolean} A set of data changes to apply when the item is used, or false * @private */ - _getUsageUpdates({consumeQuantity=false, consumeRecharge=false, consumeResource=false, consumePowerSlot=false, consumeUsage=false}) { + _getUsageUpdates({consumeQuantity, consumeRecharge, consumeResource, consumePowerLevel, consumeUsage}) { // Reference item data const id = this.data.data; @@ -529,8 +593,9 @@ export default class Item5e extends Item { } // Consume Power Slots and Force/Tech Points - if ( consumePowerSlot ) { - const level = this.actor?.data.data.powers[consumePowerSlot]; + if ( consumePowerLevel ) { + if ( Number.isNumeric(consumePowerLevel) ) consumePowerLevel = `power${consumePowerLevel}`; + const level = this.actor?.data.data.powers[consumePowerLevel]; const fp = this.actor.data.data.attributes.force.points; const tp = this.actor.data.data.attributes.tech.points; const powerCost = id.level + 1; @@ -546,7 +611,7 @@ export default class Item5e extends Item { ui.notifications.warn(game.i18n.format("SW5E.PowerCastNoSlots", {name: this.name, level: label})); return false; } - actorUpdates[`data.powers.${consumePowerSlot}.fvalue`] = Math.max(powers - 1, 0); + actorUpdates[`data.powers.${consumePowerLevel}.fvalue`] = Math.max(powers - 1, 0); if (fp.temp >= powerCost) { actorUpdates["data.attributes.force.points.temp"] = fp.temp - powerCost; }else{ @@ -562,7 +627,7 @@ export default class Item5e extends Item { ui.notifications.warn(game.i18n.format("SW5E.PowerCastNoSlots", {name: this.name, level: label})); return false; } - actorUpdates[`data.powers.${consumePowerSlot}.tvalue`] = Math.max(powers - 1, 0); + actorUpdates[`data.powers.${consumePowerLevel}.tvalue`] = Math.max(powers - 1, 0); if (tp.temp >= powerCost) { actorUpdates["data.attributes.tech.points.temp"] = tp.temp - powerCost; }else{ @@ -701,11 +766,11 @@ export default class Item5e extends Item { */ async displayCard({rollMode, createMessage=true}={}) { - // Basic template rendering data + // Render the chat card template const token = this.actor.token; const templateData = { actor: this.actor, - tokenId: token ? `${token.scene._id}.${token.id}` : null, + tokenId: token?.uuid || null, item: this.data, data: this.getChatData(), labels: this.labels, @@ -715,17 +780,14 @@ export default class Item5e extends Item { isVersatile: this.isVersatile, isPower: this.data.type === "power", hasSave: this.hasSave, - hasAreaTarget: this.hasAreaTarget + hasAreaTarget: this.hasAreaTarget, + isTool: this.data.type === "tool" }; - - // Render the chat card template - const templateType = ["tool"].includes(this.data.type) ? this.data.type : "item"; - const template = `systems/sw5e/templates/chat/${templateType}-card.html`; - const html = await renderTemplate(template, templateData); + const html = await renderTemplate("systems/sw5e/templates/chat/item-card.html", templateData); // Create the ChatMessage data object const chatData = { - user: game.user._id, + user: game.user.data._id, type: CONST.CHAT_MESSAGE_TYPES.OTHER, content: html, flavor: this.data.data.chatFlavor || this.name, @@ -755,7 +817,7 @@ export default class Item5e extends Item { * @return {Object} An object of chat data to render */ getChatData(htmlOptions={}) { - const data = duplicate(this.data.data); + const data = foundry.utils.deepClone(this.data.data); const labels = this.labels; // Rich text description @@ -949,12 +1011,11 @@ export default class Item5e extends Item { } // Elven Accuracy - if ( ["weapon", "power"].includes(this.data.type) ) { - if (flags.elvenAccuracy && ["dex", "int", "wis", "cha"].includes(this.abilityMod)) { - rollConfig.elvenAccuracy = true; - } + if ( flags.elvenAccuracy && ["dex", "int", "wis", "cha"].includes(this.abilityMod) ) { + rollConfig.elvenAccuracy = true; } + // Apply Halfling Lucky if ( flags.halflingLucky ) rollConfig.halflingLucky = true; @@ -1189,11 +1250,17 @@ export default class Item5e extends Item { const parts = [`@mod`, "@prof"]; const title = `${this.name} - ${game.i18n.localize("SW5E.ToolCheck")}`; + // Add global actor bonus + const bonuses = getProperty(this.actor.data.data, "bonuses.abilities") || {}; + if ( bonuses.check ) { + parts.push("@checkBonus"); + rollData.checkBonus = bonuses.check; + } + // Compose the roll data const rollConfig = mergeObject({ parts: parts, data: rollData, - template: "systems/sw5e/templates/chat/tool-roll-dialog.html", title: title, speaker: ChatMessage.getSpeaker({actor: this.actor}), flavor: title, @@ -1202,6 +1269,7 @@ export default class Item5e extends Item { top: options.event ? options.event.clientY - 80 : null, left: window.innerWidth - 710, }, + chooseModifier: true, halflingLucky: this.actor.getFlag("sw5e", "halflingLucky" ) || false, reliableTalent: (this.data.data.proficient >= 1) && this.actor.getFlag("sw5e", "reliableTalent"), messageData: {"flags.sw5e.roll": {type: "tool", itemId: this.id }} @@ -1221,7 +1289,7 @@ export default class Item5e extends Item { getRollData() { if ( !this.actor ) return null; const rollData = this.actor.getRollData(); - rollData.item = duplicate(this.data.data); + rollData.item = foundry.utils.deepClone(this.data.data); // Include an ability score modifier if one exists const abl = this.abilityMod; @@ -1269,12 +1337,12 @@ export default class Item5e extends Item { if ( !( isTargetted || game.user.isGM || message.isAuthor ) ) return; // Recover the actor for the chat card - const actor = this._getChatCardActor(card); + const actor = await this._getChatCardActor(card); if ( !actor ) return; // Get the Item from stored flag data or by the item ID on the Actor const storedData = message.getFlag("sw5e", "itemData"); - const item = storedData ? this.createOwned(storedData, actor) : actor.getOwnedItem(card.dataset.itemId); + const item = storedData ? new this.constructor(storedData, {parent: actor}) : actor.items.get(card.dataset.itemId); if ( !item ) { return ui.notifications.error(game.i18n.format("SW5E.ActionWarningNoItem", {item: card.dataset.itemId, name: actor.name})) } @@ -1337,17 +1405,12 @@ export default class Item5e extends Item { * @return {Actor|null} The Actor entity or null * @private */ - static _getChatCardActor(card) { + static async _getChatCardActor(card) { // Case 1 - a synthetic actor from a Token - const tokenKey = card.dataset.tokenId; - if (tokenKey) { - const [sceneId, tokenId] = tokenKey.split("."); - const scene = game.scenes.get(sceneId); - if (!scene) return null; - const tokenData = scene.getEmbeddedEntity("Token", tokenId); - if (!tokenData) return null; - const token = new Token(tokenData); + if ( card.dataset.tokenId ) { + const token = await fromUuid(card.dataset.tokenId); + if ( !token ) return null; return token.actor; } @@ -1361,7 +1424,7 @@ export default class Item5e extends Item { /** * Get the Actor which is the author of a chat card * @param {HTMLElement} card The chat card being used - * @return {Array.} An Array of Actor entities, if any + * @return {Actor[]} An Array of Actor entities, if any * @private */ static _getChatCardTargets(card) { @@ -1372,14 +1435,169 @@ export default class Item5e extends Item { } /* -------------------------------------------- */ - /* Factory Methods */ + /* Event Handlers */ /* -------------------------------------------- */ + /** @inheritdoc */ + async _preCreate(data, options, user) { + await super._preCreate(data, options, user); + if ( !this.isEmbedded || (this.parent.type === "vehicle") ) return; + const actorData = this.parent.data; + const isNPC = this.parent.type === "npc"; + switch (data.type) { + case "equipment": + return this._onCreateOwnedEquipment(data, actorData, isNPC); + case "weapon": + return this._onCreateOwnedWeapon(data, actorData, isNPC); + case "power": + return this._onCreateOwnedPower(data, actorData, isNPC); + } + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + _onCreate(data, options, userId) { + super._onCreate(data, options, userId); + + // The below options are only needed for character classes + if ( userId !== game.user.id ) return; + const isCharacterClass = this.parent && (this.parent.type !== "vehicle") && (this.type === "class"); + if ( !isCharacterClass ) return; + + // Assign a new primary class + const pc = this.parent.items.get(this.parent.data.data.details.originalClass); + if ( !pc ) this.parent._assignPrimaryClass(); + + // Prompt to add new class features + if (options.addFeatures === false) return; + this.parent.getClassFeatures({ + className: this.name, + archetypeName: this.data.data.archetype, + level: this.data.data.levels + }).then(features => { + return this.parent.addEmbeddedItems(features, options.promptAddFeatures); + }); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + _onUpdate(changed, options, userId) { + super._onUpdate(changed, options, userId); + + // The below options are only needed for character classes + if ( userId !== game.user.id ) return; + const isCharacterClass = this.parent && (this.parent.type !== "vehicle") && (this.type === "class"); + if ( !isCharacterClass ) return; + + // Prompt to add new class features + const addFeatures = changed["name"] || (changed.data && ["archetype", "levels"].some(k => k in changed.data)); + if ( !addFeatures || (options.addFeatures === false) ) return; + this.parent.getClassFeatures({ + className: changed.name || this.name, + archetypeName: changed.data?.archetype || this.data.data.archetype, + level: changed.data?.levels || this.data.data.levels + }).then(features => { + return this.parent.addEmbeddedItems(features, options.promptAddFeatures); + }); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + _onDelete(options, userId) { + super._onDelete(options, userId); + + // Assign a new primary class + if ( this.parent && (this.type === "class") && (userId === game.user.id) ) { + if ( this.id !== this.parent.data.data.details.originalClass ) return; + this.parent._assignPrimaryClass(); + } + } + + /* -------------------------------------------- */ + + /** + * Pre-creation logic for the automatic configuration of owned equipment type Items + * @private + */ + _onCreateOwnedEquipment(data, actorData, isNPC) { + const updates = {}; + if ( foundry.utils.getProperty(data, "data.equipped") === undefined ) { + updates["data.equipped"] = isNPC; // NPCs automatically equip equipment + } + if ( foundry.utils.getProperty(data, "data.proficient") === undefined ) { + if ( isNPC ) { + updates["data.proficient"] = true; // NPCs automatically have equipment proficiency + } else { + const armorProf = { + "natural": true, + "clothing": true, + "light": "lgt", + "medium": "med", + "heavy": "hvy", + "shield": "shl" + }[data.data?.armor?.type]; // Player characters check proficiency + const actorArmorProfs = actorData.data.traits?.armorProf?.value || []; + updates["data.proficient"] = (armorProf === true) || actorArmorProfs.includes(armorProf); + } + } + foundry.utils.mergeObject(data, updates); + } + + /* -------------------------------------------- */ + + /** + * Pre-creation logic for the automatic configuration of owned power type Items + * @private + */ + _onCreateOwnedPower(data, actorData, isNPC) { + const updates = {}; + updates["data.prepared"] = true; // Automatically prepare powers for everyone + foundry.utils.mergeObject(data, updates); + } + + /* -------------------------------------------- */ + + /** + * Pre-creation logic for the automatic configuration of owned weapon type Items + * @private + */ + _onCreateOwnedWeapon(data, actorData, isNPC) { + const updates = {}; + if ( foundry.utils.getProperty(data, "data.equipped") === undefined ) { + updates["data.equipped"] = isNPC; // NPCs automatically equip weapons + } + if ( foundry.utils.getProperty(data, "data.proficient") === undefined ) { + if ( isNPC ) { + updates["data.proficient"] = true; // NPCs automatically have equipment proficiency + } else { + // TODO: With the changes to make weapon proficiencies more verbose, this may need revising + const weaponProf = { + "natural": true, + "simpleVW": "sim", + "simpleB": "sim", + "simpleLW": "sim", + "martialVW": "mar", + "martialB": "mar", + "martialLW": "mar" + }[data.data?.weaponType]; // Player characters check proficiency + const actorWeaponProfs = actorData.data.traits?.weaponProf?.value || []; + updates["data.proficient"] = (weaponProf === true) || actorWeaponProfs.includes(weaponProf); + } + } + foundry.utils.mergeObject(data, updates); + } + + /* -------------------------------------------- */ + /* Factory Methods */ + /* -------------------------------------------- */ +// TODO: Make work properly /** * Create a consumable power scroll Item from a power Item. * @param {Item5e} power The power to be made into a scroll * @return {Item5e} The created scroll consumable item - * @private */ static async createScrollFromPower(power) { @@ -1388,7 +1606,7 @@ export default class Item5e extends Item { const {actionType, description, source, activation, duration, target, range, damage, save, level} = itemData.data; // Get scroll data - const scrollUuid = CONFIG.SW5E.powerScrollIds[level]; + const scrollUuid = `Compendium.${CONFIG.SW5E.sourcePacks.ITEMS}.${CONFIG.SW5E.powerScrollIds[level]}`; const scrollItem = await fromUuid(scrollUuid); const scrollData = scrollItem.data; delete scrollData._id; diff --git a/module/item/sheet.js b/module/item/sheet.js index 02e8a550..556032e7 100644 --- a/module/item/sheet.js +++ b/module/item/sheet.js @@ -18,9 +18,9 @@ export default class ItemSheet5e extends ItemSheet { /* -------------------------------------------- */ - /** @override */ + /** @inheritdoc */ static get defaultOptions() { - return mergeObject(super.defaultOptions, { + return foundry.utils.mergeObject(super.defaultOptions, { width: 560, height: 400, classes: ["sw5e", "sheet", "item"], @@ -32,7 +32,7 @@ export default class ItemSheet5e extends ItemSheet { /* -------------------------------------------- */ - /** @override */ + /** @inheritdoc */ get template() { const path = "systems/sw5e/templates/items/"; return `${path}/${this.item.data.type}.html`; @@ -43,33 +43,39 @@ export default class ItemSheet5e extends ItemSheet { /** @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(data.item); - data.itemProperties = this._getItemProperties(data.item); - data.isPhysical = data.item.data.hasOwnProperty("quantity"); + data.itemStatus = this._getItemStatus(itemData); + data.itemProperties = this._getItemProperties(itemData); + data.isPhysical = itemData.data.hasOwnProperty("quantity"); // Potential consumption targets - data.abilityConsumptionTargets = this._getItemConsumptionTargets(data.item); + data.abilityConsumptionTargets = this._getItemConsumptionTargets(itemData); // Action Detail data.hasAttackRoll = this.item.hasAttack; - data.isHealing = data.item.data.actionType === "heal"; - data.isFlatDC = getProperty(data.item.data, "save.scaling") === "flat"; - data.isLine = ["line", "wall"].includes(data.item.data.target?.type); + 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 - if (this.item._data.data?.uses?.max) data.data.uses.max = this.item._data.data.uses.max; + const sourceMax = foundry.utils.getProperty(this.item.data._source, "data.uses.max"); + if ( sourceMax ) itemData.data.uses.max = sourceMax; // Vehicles - data.isCrewed = data.item.data.activation?.type === "crew"; - data.isMountable = this._isItemMountable(data.item); + data.isCrewed = itemData.data.activation?.type === "crew"; + data.isMountable = this._isItemMountable(itemData); // Prepare Active Effects - data.effects = prepareActiveEffectCategories(this.entity.effects); + data.effects = prepareActiveEffectCategories(this.item.effects); + + // Re-define the template data references (backwards compatible) + data.item = itemData; + data.data = itemData.data; return data; } @@ -102,9 +108,11 @@ export default class ItemSheet5e extends ItemSheet { // Attributes else if (consume.type === "attribute") { - const attributes = Object.values(CombatTrackerConfig.prototype.getAttributeChoices())[0]; // Bit of a hack - return attributes.reduce((obj, a) => { - obj[a] = a; + 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; }, {}); } @@ -186,6 +194,7 @@ export default class ItemSheet5e extends ItemSheet { 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") { @@ -238,7 +247,7 @@ export default class ItemSheet5e extends ItemSheet { /* -------------------------------------------- */ - /** @override */ + /** @inheritdoc */ setPosition(position = {}) { if (!(this._minimized || position.height)) { position.height = this._tabs[0].active === "details" ? "auto" : this.options.height; @@ -250,7 +259,7 @@ export default class ItemSheet5e extends ItemSheet { /* Form Submission */ /* -------------------------------------------- */ - /** @override */ + /** @inheritdoc */ _getSubmitData(updateData = {}) { // Create the expanded update data object const fd = new FormDataExtended(this.form, { editors: this.editors }); @@ -268,17 +277,14 @@ export default class ItemSheet5e extends ItemSheet { /* -------------------------------------------- */ - /** @override */ + /** @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._onConfigureClassSkills.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." - ); + 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); }); } @@ -307,7 +313,7 @@ export default class ItemSheet5e extends ItemSheet { if (a.classList.contains("delete-damage")) { await this._onSubmit(event); // Submit any unsaved changes const li = a.closest(".damage-part"); - const damage = duplicate(this.item.data.data.damage); + 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 }); } @@ -316,33 +322,39 @@ export default class ItemSheet5e extends ItemSheet { /* -------------------------------------------- */ /** - * Handle spawning the TraitSelector application which allows a checkbox of multiple trait options + * Handle spawning the TraitSelector application for selection various options. * @param {Event} event The click event which originated the selection * @private */ - _onConfigureClassSkills(event) { + _onConfigureTraits(event) { event.preventDefault(); - const skills = this.item.data.data.skills; - const choices = skills.choices && skills.choices.length ? skills.choices : Object.keys(CONFIG.SW5E.skills); const a = event.currentTarget; - const label = a.parentElement; - // Render the Trait Selector dialog - new TraitSelector(this.item, { + const options = { name: a.dataset.target, - title: label.innerText, - choices: Object.entries(CONFIG.SW5E.skills).reduce((obj, e) => { - if (choices.includes(e[0])) obj[e[0]] = e[1]; - return obj; - }, {}), - minimum: skills.number, - maximum: skills.number - }).render(true); + 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); } /* -------------------------------------------- */ - /** @override */ + /** @inheritdoc */ async _onSubmit(...args) { if (this._tabs[0].active === "details") this.position.height = "auto"; await super._onSubmit(...args); diff --git a/module/migration.js b/module/migration.js index 60401489..27b877f5 100644 --- a/module/migration.js +++ b/module/migration.js @@ -1,3 +1,4 @@ +// TODO: Update /** * 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 diff --git a/module/pixi/ability-template.js b/module/pixi/ability-template.js index 702343b2..d72cb886 100644 --- a/module/pixi/ability-template.js +++ b/module/pixi/ability-template.js @@ -19,7 +19,7 @@ export default class AbilityTemplate extends MeasuredTemplate { // Prepare template data const templateData = { t: templateShape, - user: game.user._id, + user: game.user.data._id, distance: target.value, direction: 0, x: 0, @@ -45,10 +45,12 @@ export default class AbilityTemplate extends MeasuredTemplate { } // Return the template constructed from the item data - const template = new this(templateData); - template.item = item; - template.actorSheet = item.actor?.sheet || null; - return template; + 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; } /* -------------------------------------------- */ @@ -108,14 +110,9 @@ export default class AbilityTemplate extends MeasuredTemplate { // Confirm the workflow (left-click) handlers.lc = event => { handlers.rc(event); - - // Confirm final snapped position - const destination = canvas.grid.getSnappedPosition(this.x, this.y, 2); - this.data.x = destination.x; - this.data.y = destination.y; - - // Create the template - canvas.scene.createEmbeddedEntity("MeasuredTemplate", this.data); + 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) @@ -124,7 +121,7 @@ export default class AbilityTemplate extends MeasuredTemplate { event.stopPropagation(); let delta = canvas.grid.type > CONST.GRID_TYPES.SQUARE ? 30 : 15; let snap = event.shiftKey ? delta : 5; - this.data.direction += (snap * Math.sign(event.deltaY)); + this.data.update({direction: this.data.direction + (snap * Math.sign(event.deltaY))}); this.refresh(); }; diff --git a/module/token.js b/module/token.js new file mode 100644 index 00000000..db811603 --- /dev/null +++ b/module/token.js @@ -0,0 +1,99 @@ +/** + * Extend the base TokenDocument class to implement system-specific HP bar logic. + * @extends {TokenDocument} + */ +export class TokenDocument5e extends TokenDocument { + + /** @inheritdoc */ + getBarAttribute(...args) { + const data = super.getBarAttribute(...args); + 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); + } + return data; + } +} + + +/* -------------------------------------------- */ + + +/** + * 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); + return super._drawBar(number, bar, data); + } + + /* -------------------------------------------- */ + + /** + * Specialized drawing function for HP bars. + * @param {number} number The Bar number + * @param {PIXI.Graphics} bar The Bar container + * @param {object} data Resource data for this bar + * @private + */ + _drawHPBar(number, bar, data) { + + // Extract health data + let {value, max, temp, tempmax} = this.document.actor.data.data.attributes.hp; + temp = Number(temp || 0); + tempmax = Number(tempmax || 0); + + // Differentiate between effective maximum and displayed maximum + const effectiveMax = Math.max(0, max + tempmax); + let displayMax = max + (tempmax > 0 ? tempmax : 0); + + // Allocate percentages of the total + const tempPct = Math.clamped(temp, 0, displayMax) / displayMax; + const valuePct = Math.clamped(value, 0, effectiveMax) / displayMax; + const colorPct = Math.clamped(value, 0, effectiveMax) / displayMax; + + // Determine colors to use + const blk = 0x000000; + 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; + const bs = Math.clamped(h / 8, 1, 2); + const bs1 = bs+1; + + // Overall bar container + 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); + } + + // 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); + } + + // Health bar + 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); + } + + // Set position + let posY = (number === 0) ? (this.h - h) : 0; + bar.position.set(0, posY); + } +} diff --git a/sw5e-dark.css b/sw5e-dark.css index 9a0e1178..424da398 100644 --- a/sw5e-dark.css +++ b/sw5e-dark.css @@ -791,6 +791,9 @@ body.dark-theme .sw5e.sheet.actor .swalt-sheet .tab.notes section > input { color: #E81111; border-bottom: 2px solid #0d99cc; } +body.dark-theme .sw5e.sheet.actor.npc .swalt-sheet header div.creature-type:hover { + border-color: #E81111; +} body.dark-theme .sw5e.sheet.actor.npc .swalt-sheet header .experience { color: #4f4f4f; } diff --git a/sw5e-global.css b/sw5e-global.css index e2402c6c..b59b4414 100644 --- a/sw5e-global.css +++ b/sw5e-global.css @@ -206,7 +206,8 @@ input[type="password"], input[type="date"], input[type="time"], select, -textarea { +textarea, +.roundTransition { border-radius: 4px; transition: all 0.3s; } @@ -216,7 +217,8 @@ input[type="password"]:hover, input[type="date"]:hover, input[type="time"]:hover, select:hover, -textarea:hover { +textarea:hover, +.roundTransition:hover { box-shadow: none; } input[type="text"]:focus, @@ -225,7 +227,8 @@ input[type="password"]:focus, input[type="date"]:focus, input[type="time"]:focus, select:focus, -textarea:focus { +textarea:focus, +.roundTransition:focus { box-shadow: none; } input[type=range] { @@ -858,6 +861,7 @@ input[type="reset"]:disabled { font-weight: 400; letter-spacing: 0.5px; line-height: 24px; + width: 100%; } .sw5e.sheet.actor .swalt-sheet header .summary .proficiency { line-height: 26px; @@ -1686,13 +1690,34 @@ input[type="reset"]:disabled { .sw5e.sheet.actor.npc .swalt-sheet header h1.character-name { align-self: auto; } -.sw5e.sheet.actor.npc .swalt-sheet header .npc-size { +.sw5e.sheet.actor.npc .swalt-sheet header .npc-size, +.sw5e.sheet.actor.npc .swalt-sheet header .creature-type { font-family: 'Russo One'; font-size: 18px; font-weight: 400; letter-spacing: 0.5px; line-height: 28px; } +.sw5e.sheet.actor.npc .swalt-sheet header div.creature-type { + display: flex; + justify-content: space-between; + padding: 1px 4px; + border: 1px solid transparent; +} +.sw5e.sheet.actor.npc .swalt-sheet header div.creature-type span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.sw5e.sheet.actor.npc .swalt-sheet header div.creature-type .config-button { + display: none; + font-size: 12px; + font-weight: normal; + line-height: 2em; +} +.sw5e.sheet.actor.npc .swalt-sheet header div.creature-type:hover .config-button { + display: block; +} .sw5e.sheet.actor.npc .swalt-sheet header .attributes { grid-template-columns: repeat(3, 1fr); } diff --git a/sw5e-light.css b/sw5e-light.css index 3b240d7a..20f09b81 100644 --- a/sw5e-light.css +++ b/sw5e-light.css @@ -778,6 +778,9 @@ body.light-theme .sw5e.sheet.actor .swalt-sheet .tab.notes section > input { color: #c40f0f; border-bottom: 2px solid #0d99cc; } +body.light-theme .sw5e.sheet.actor.npc .swalt-sheet header div.creature-type:hover { + border-color: #c40f0f; +} body.light-theme .sw5e.sheet.actor.npc .swalt-sheet header .experience { color: #4f4f4f; } diff --git a/sw5e.css b/sw5e.css index 556d0642..b0a5e8b6 100644 --- a/sw5e.css +++ b/sw5e.css @@ -91,9 +91,7 @@ /* Tags */ } .sw5e .window-content { - background: linear-gradient(90deg, #afc6d6 0%, #D6D6D6 30%, #D6D6D6 70%, #afc6d6); font-size: 13px; - color: #191813; } .sw5e input[type="text"], .sw5e input[type="number"], @@ -114,6 +112,8 @@ .sw5e select:disabled, .sw5e textarea:disabled { color: #4b4a44; + border: 1px solid transparent !important; + outline: none !important; } .sw5e input:disabled:hover, .sw5e select:disabled:hover, @@ -129,24 +129,6 @@ background: rgba(0, 0, 0, 0.1); border: 2px groove #eeede0; } -.sw5e label.checkbox { - flex: auto; - padding: 0; - margin: 0; - height: 22px; - line-height: 22px; - font-size: 11px; -} -.sw5e label.checkbox > input[type="checkbox"] { - width: 16px; - height: 16px; - margin: 0 2px 0 0; - position: relative; - top: 4px; -} -.sw5e label.checkbox.right > input[type="checkbox"] { - margin: 0 0 0 2px; -} .sw5e .form-group label { flex: 2; color: #4b4a44; @@ -179,11 +161,12 @@ .sw5e .form-group .form-fields > *:last-child { margin-right: 0; } -.sw5e .form-group.stacked label { +.sw5e .form-group.stacked > label { flex: 0 0 100%; margin: 0; } -.sw5e .form-group.stacked label.checkbox { +.sw5e .form-group.stacked label.checkbox, +.sw5e .form-group.stacked label.radio { flex: auto; text-align: left; } @@ -207,6 +190,26 @@ background: rgba(0, 0, 0, 0.05); } /* ----------------------------------------- */ +/* Hit Dice Config Sheet Specifically */ +/* ----------------------------------------- */ +.sw5e.hd-config .form-group button.increment, +.sw5e.hd-config .form-group button.decrement { + flex: 0 0 1rem; + line-height: 1rem; +} +.sw5e.hd-config .form-group button.decrement { + margin-right: 0; +} +.sw5e.hd-config .form-group span.sep { + margin: 0; +} +.sw5e.hd-config .form-group input { + flex: 0 0 2rem; + text-align: center; + margin-left: 2px; + margin-right: 2px; +} +/* ----------------------------------------- */ /* Entity Sheets Specifically */ /* ----------------------------------------- */ .sw5e.sheet { @@ -499,16 +502,60 @@ /* ----------------------------------------- */ /* Trait Selector /* ----------------------------------------- */ -#trait-selector .trait-list { +.trait-selector .trait-list { list-style: none; margin: 0; padding: 0; } -#trait-selector input[type="text"] { +.trait-selector input[type="text"] { height: 24px; margin: 2px; } /* ----------------------------------------- */ +/* Actor Type Config Sheet Specifically */ +/* ----------------------------------------- */ +.actor-type .trait-list { + display: flex; + flex-wrap: wrap; +} +.actor-type .trait-list li { + flex-basis: 50%; + flex-grow: 1; +} +.actor-type .trait-list li.form-group { + flex-basis: 100%; +} +.actor-type label.radio { + display: flex; + flex: auto; + font-size: 12px; + line-height: 20px; + font-weight: normal; +} +.actor-type label.radio > input[type="radio"] { + margin: 0 5px 0 0; +} +.actor-type li.custom-type input[type="radio"] { + display: none; +} +/* ----------------------------------------- */ +/* Add Feature Prompt Specifically */ +/* ----------------------------------------- */ +.sw5e.select-items-prompt .dialog-content { + margin-bottom: 1em; +} +.sw5e.select-items-prompt .items-list { + margin-top: 0.5em; +} +.sw5e.select-items-prompt .item-name > label, +.sw5e.select-items-prompt .item-image, +.sw5e.select-items-prompt input { + cursor: pointer; +} +.sw5e.select-items-prompt .item-name > label { + align-items: center; +} +/* ----------------------------------------- */ /* HUD /* ----------------------------------------- */ .placeable-hud .control-icon { @@ -574,6 +621,9 @@ /* Powerbook */ /* ----------------------------------------- */ /* ----------------------------------------- */ + /* Features Tab */ + /* ----------------------------------------- */ + /* ----------------------------------------- */ /* TinyMCE */ /* ----------------------------------------- */ } @@ -627,10 +677,12 @@ height: 30px; line-height: 30px; } -.sw5e.sheet.actor .sheet-header .attributes .movement h4.attribute-name { +.sw5e.sheet.actor .sheet-header .attributes .movement h4.attribute-name, +.sw5e.sheet.actor .sheet-header .attributes .hit-dice h4.attribute-name { position: relative; } -.sw5e.sheet.actor .sheet-header .attributes .movement .config-button { +.sw5e.sheet.actor .sheet-header .attributes .movement .config-button, +.sw5e.sheet.actor .sheet-header .attributes .hit-dice .config-button { position: absolute; display: none; right: 0; @@ -638,7 +690,8 @@ font-size: 12px; font-weight: normal; } -.sw5e.sheet.actor .sheet-header .attributes .movement:hover .config-button { +.sw5e.sheet.actor .sheet-header .attributes .movement:hover .config-button, +.sw5e.sheet.actor .sheet-header .attributes .hit-dice:hover .config-button { display: block; } .sw5e.sheet.actor .sheet-header .attributes input.temphp { @@ -1113,6 +1166,9 @@ .sw5e.sheet.actor .powerbook-empty .item-controls { flex: 1; } +.sw5e.sheet.actor .features i.original-class { + color: #4b4a44; +} .sw5e.sheet.actor .editor { padding: 0 8px; } @@ -1745,7 +1801,7 @@ text-align: right; padding-right: 5px; } -.sw5e.sheet.actor.character .resource .attribute-value input { +.sw5e.sheet.actor.character .resource .attribute-value > input { flex: 0 0 25%; } .sw5e.sheet.actor.character .resource .attribute-value label.recharge { @@ -1755,6 +1811,7 @@ font-size: 11px; text-align: center; color: #4b4a44; + align-items: center; } .sw5e.sheet.actor.character .resource .attribute-value label.recharge input[type="checkbox"] { height: 14px; @@ -1853,6 +1910,26 @@ .sw5e.sheet.actor.npc .summary { font-size: 18px; } +.sw5e.sheet.actor.npc .summary li.creature-type { + display: flex; + justify-content: space-between; + width: 1em; + padding: 0 3px; +} +.sw5e.sheet.actor.npc .summary li.creature-type span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.sw5e.sheet.actor.npc .summary li.creature-type .config-button { + display: none; + font-size: 12px; + font-weight: normal; + line-height: 2em; +} +.sw5e.sheet.actor.npc .summary li.creature-type:hover .config-button { + display: block; +} .sw5e.sheet.actor.vehicle .features .item-controls { flex: 0 0 68px; } diff --git a/sw5e.js b/sw5e.js index e412f4f3..4f084c0d 100644 --- a/sw5e.js +++ b/sw5e.js @@ -12,12 +12,13 @@ import { SW5E } from "./module/config.js"; import { registerSystemSettings } from "./module/settings.js"; import { preloadHandlebarsTemplates } from "./module/templates.js"; import { _getInitiativeFormula } from "./module/combat.js"; -import { measureDistances, getBarAttribute } from "./module/canvas.js"; +import { measureDistances } from "./module/canvas.js"; -// Import Entities +// Import Documents import Actor5e from "./module/actor/entity.js"; import Item5e from "./module/item/entity.js"; import CharacterImporter from "./module/characterImporter.js"; +import { TokenDocument5e, Token5e } from "./module/token.js" // Import Applications import AbilityTemplate from "./module/pixi/ability-template.js"; @@ -45,6 +46,9 @@ import * as migrations from "./module/migration.js"; /* Foundry VTT Initialization */ /* -------------------------------------------- */ +// Keep on while migrating to Foundry version 0.8 +CONFIG.debug.hooks = true; + Hooks.once("init", function() { console.log(`SW5e | Initializing SW5E System\n${SW5E.ASCII}`); @@ -61,7 +65,8 @@ Hooks.once("init", function() { ItemSheet5e, ShortRestDialog, TraitSelector, - ActorMovementConfig + ActorMovementConfig, + ActorSensesConfig }, canvas: { AbilityTemplate @@ -71,6 +76,8 @@ Hooks.once("init", function() { entities: { Actor5e, Item5e, + TokenDocument5e, + Token5e, }, macros: macros, migrations: migrations, @@ -79,8 +86,10 @@ Hooks.once("init", function() { // Record Configuration Values CONFIG.SW5E = SW5E; - CONFIG.Actor.entityClass = Actor5e; - CONFIG.Item.entityClass = Item5e; + CONFIG.Actor.documentClass = Actor5e; + CONFIG.Item.documentClass = Item5e; + CONFIG.Token.documentClass = TokenDocument5e; + CONFIG.Token.objectClass = Token5e; CONFIG.time.roundTime = 6; CONFIG.fontFamilies = [ "Engli-Besh", @@ -88,6 +97,9 @@ Hooks.once("init", function() { "Russo One" ]; + CONFIG.Dice.DamageRoll = dice.DamageRoll; + CONFIG.Dice.D20Roll = dice.D20Roll; + // 5e cone RAW should be 53.13 degrees CONFIG.MeasuredTemplate.defaults.angle = 53.13; @@ -100,7 +112,11 @@ Hooks.once("init", function() { // Patch Core Functions CONFIG.Combat.initiative.formula = "1d20 + @attributes.init.mod + @attributes.init.prof + @attributes.init.bonus"; - Combat.prototype._getInitiativeFormula = _getInitiativeFormula; + Combatant.prototype._getInitiativeFormula = _getInitiativeFormula; + + // Register Roll Extensions + CONFIG.Dice.rolls.push(dice.D20Roll); + CONFIG.Dice.rolls.push(dice.DamageRoll); // Register sheet application classes Actors.unregisterSheet("core", ActorSheet); @@ -142,7 +158,7 @@ Hooks.once("init", function() { }); // Preload Handlebars Templates - preloadHandlebarsTemplates(); + return preloadHandlebarsTemplates(); }); @@ -191,7 +207,7 @@ Hooks.once("setup", function() { }); /* -------------------------------------------- */ - +//TODO: Setup Migration /** * Once the entire VTT framework is initialized, check to see if we should perform a data migration */ @@ -223,13 +239,9 @@ Hooks.once("ready", function() { /* -------------------------------------------- */ Hooks.on("canvasInit", function() { - // Extend Diagonal Measurement canvas.grid.diagonalRule = game.settings.get("sw5e", "diagonalMovement"); SquareGrid.prototype.measureDistances = measureDistances; - - // Extend Token Resource Bars - Token.prototype.getBarAttribute = getBarAttribute; }); @@ -272,7 +284,7 @@ Hooks.on("renderRollTableDirectory", (app, html, data)=> { Hooks.on("ActorSheet5eCharacterNew", (app, html, data) => { console.log("renderSwaltSheet"); }); -// TODO I should remove this +// FIXME: This helper is needed for the vehicle sheet. It should probably be refactored. Handlebars.registerHelper('getProperty', function (data, property) { return getProperty(data, property); }); diff --git a/system.json b/system.json index 54436f2c..d7b21d52 100644 --- a/system.json +++ b/system.json @@ -194,8 +194,8 @@ "gridUnits": "ft", "primaryTokenAttribute": "attributes.hp", "secondaryTokenAttribute": null, - "minimumCoreVersion": "0.7.7", - "compatibleCoreVersion": "0.7.9", + "minimumCoreVersion": "0.8.3", + "compatibleCoreVersion": "0.8.5", "url": "https://github.com/unrealkakeman89/sw5e", "manifest": "https://raw.githubusercontent.com/unrealkakeman89/sw5e/master/system.json", "download": "https://github.com/unrealkakeman89/sw5e/archive/master.zip" diff --git a/template.json b/template.json index 4df8e6b6..91c7e8c5 100644 --- a/template.json +++ b/template.json @@ -333,6 +333,7 @@ }, "details": { "background": "", + "originalClass": "", "xp": { "value": 0, "min": 0, @@ -382,7 +383,12 @@ "npc": { "templates": ["common", "creature"], "details": { - "type": "", + "type": { + "value": "", + "subtype": "", + "swarm": "", + "custom": "" + }, "environment": "", "cr": 1, "powerForceLevel": 0, @@ -880,13 +886,17 @@ "archetype": "", "hitDice": "d6", "hitDiceUsed": 0, + "saves": [], "skills": { "number": 2, "choices": [], "value": [] }, "source": "", - "powercasting": "none" + "powercasting": { + "progression": "none", + "ability": "" + } }, "classfeature": { "templates": ["itemDescription", "activatedEffect", "action"], diff --git a/templates/actors/newActor/character-sheet.html b/templates/actors/newActor/character-sheet.html index 0fac8d59..b9e12be5 100644 --- a/templates/actors/newActor/character-sheet.html +++ b/templates/actors/newActor/character-sheet.html @@ -63,7 +63,10 @@ {{!-- HIT DICE / SHORT & LONG REST BUTTONS --}}
-

{{ localize "SW5E.HitDice" }}

+

+ {{ localize "SW5E.HitDice" }} + +

{{data.attributes.hd}} / diff --git a/templates/actors/newActor/item.hbs b/templates/actors/newActor/item.hbs index cdd84ff9..d94cd26b 100644 --- a/templates/actors/newActor/item.hbs +++ b/templates/actors/newActor/item.hbs @@ -1,4 +1,4 @@ -
  • +
  • {{item.name}}

    diff --git a/templates/actors/newActor/limited-sheet.html b/templates/actors/newActor/limited-sheet.html index 69ae83eb..40ca7a3c 100644 --- a/templates/actors/newActor/limited-sheet.html +++ b/templates/actors/newActor/limited-sheet.html @@ -13,11 +13,11 @@

    Description

    - {{editor content=data.details.description.value target="data.details.description.value" button=true owner=owner editable=editable}} + {{editor content=data.details.description.value target="data.details.description.value" button=true owner=owner editable=editable rollData=rollData}}

    Background

    - {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable}} + {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable rollData=rollData}}
    diff --git a/templates/actors/newActor/npc-sheet.html b/templates/actors/newActor/npc-sheet.html index 06ce97c5..367aa29a 100644 --- a/templates/actors/newActor/npc-sheet.html +++ b/templates/actors/newActor/npc-sheet.html @@ -21,8 +21,10 @@ {{lookup config.actorSizes data.traits.size}} - +
    + {{labels.type}} + +
    @@ -111,18 +113,20 @@

    {{localize "SW5E.Skills"}}

      - {{#each data.skills as |skill s|}} -
    1. - - - {{skill.label}} - {{skill.ability}} - {{numberFormat skill.total decimals=0 sign=true}} - {{!-- --}} - {{!-- ({{skill.passive}}) --}} -
    2. + {{#each config.skills as |label s|}} + {{#with (lookup ../data.skills s) as |skill|}} +
    3. + + + {{label}} + {{skill.ability}} + {{numberFormat skill.total decimals=0 sign=true}} + {{!-- --}} + {{!-- ({{skill.passive}}) --}} +
    4. + {{/with}} {{/each}}
    @@ -191,7 +195,7 @@

    {{localize "SW5E.Biography"}}

    - {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable}} + {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable rollData=rollData}}
    diff --git a/templates/actors/newActor/parts/swalt-biography.html b/templates/actors/newActor/parts/swalt-biography.html index 104e0aaa..03591c8b 100644 --- a/templates/actors/newActor/parts/swalt-biography.html +++ b/templates/actors/newActor/parts/swalt-biography.html @@ -1,22 +1,22 @@

    {{localize "SW5E.PersonalityTraits" }}

    - {{editor content=data.details.trait target="data.details.trait" button=true owner=owner editable=editable}} + {{editor content=data.details.trait target="data.details.trait" button=true owner=owner editable=editable rollData=rollData}}

    {{localize "SW5E.Ideals" }}

    - {{editor content=data.details.ideal target="data.details.ideal" button=true owner=owner editable=editable}} + {{editor content=data.details.ideal target="data.details.ideal" button=true owner=owner editable=editable rollData=rollData}}

    {{localize "SW5E.Bonds" }}

    - {{editor content=data.details.bond target="data.details.bond" button=true owner=owner editable=editable}} + {{editor content=data.details.bond target="data.details.bond" button=true owner=owner editable=editable rollData=rollData}}

    {{localize "SW5E.Flaws" }}

    - {{editor content=data.details.flaw target="data.details.flaw" button=true owner=owner editable=editable}} + {{editor content=data.details.flaw target="data.details.flaw" button=true owner=owner editable=editable rollData=rollData}}

    {{localize "SW5E.Description" }}

    - {{editor content=data.details.description.value target="data.details.description.value" button=true owner=owner editable=editable}} + {{editor content=data.details.description.value target="data.details.description.value" button=true owner=owner editable=editable rollData=rollData}}

    {{localize "SW5E.Background" }}

    - {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable}} + {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable rollData=rollData}}
    \ No newline at end of file diff --git a/templates/actors/newActor/parts/swalt-core.html b/templates/actors/newActor/parts/swalt-core.html index cc0861be..0e722c29 100644 --- a/templates/actors/newActor/parts/swalt-core.html +++ b/templates/actors/newActor/parts/swalt-core.html @@ -26,18 +26,20 @@

    {{localize "SW5E.Skills" }}

      - {{#each data.skills as |skill s|}} -
    1. - - - {{skill.label}} - {{skill.ability}} - {{numberFormat skill.total decimals=0 sign=true}} - {{!-- --}} - {{!-- ({{skill.passive}}) --}} -
    2. + {{#each config.skills as |label s|}} + {{#with (lookup ../data.skills s) as |skill|}} +
    3. + + + {{label}} + {{skill.ability}} + {{numberFormat skill.total decimals=0 sign=true}} + {{!-- --}} + {{!-- ({{skill.passive}}) --}} +
    4. + {{/with}} {{/each}}
    diff --git a/templates/actors/newActor/parts/swalt-notes.html b/templates/actors/newActor/parts/swalt-notes.html index 3d007d32..fe0d140e 100644 --- a/templates/actors/newActor/parts/swalt-notes.html +++ b/templates/actors/newActor/parts/swalt-notes.html @@ -3,30 +3,30 @@ - {{editor content=data.details.notes.value target="data.details.notes.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes.value target="data.details.notes.value" button=true owner=owner editable=editable rollData=rollData}}
  • - {{editor content=data.details.notes1.value target="data.details.notes1.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes1.value target="data.details.notes1.value" button=true owner=owner editable=editable rollData=rollData}}
    - {{editor content=data.details.notes2.value target="data.details.notes2.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes2.value target="data.details.notes2.value" button=true owner=owner editable=editable rollData=rollData}}
    - {{editor content=data.details.notes3.value target="data.details.notes3.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes3.value target="data.details.notes3.value" button=true owner=owner editable=editable rollData=rollData}}
    - {{editor content=data.details.notes4.value target="data.details.notes4.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes4.value target="data.details.notes4.value" button=true owner=owner editable=editable rollData=rollData}}
    diff --git a/templates/actors/newActor/parts/swalt-resources.html b/templates/actors/newActor/parts/swalt-resources.html index 521779f6..1297b8b7 100644 --- a/templates/actors/newActor/parts/swalt-resources.html +++ b/templates/actors/newActor/parts/swalt-resources.html @@ -9,28 +9,28 @@ - {{editor content=data.details.notes.value target="data.details.notes.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes.value target="data.details.notes.value" button=true owner=owner editable=editable rollData=rollData}}
    - {{editor content=data.details.notes1.value target="data.details.notes1.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes1.value target="data.details.notes1.value" button=true owner=owner editable=editable rollData=rollData}}
    - {{editor content=data.details.notes2.value target="data.details.notes2.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes2.value target="data.details.notes2.value" button=true owner=owner editable=editable rollData=rollData}}
    - {{editor content=data.details.notes3.value target="data.details.notes3.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes3.value target="data.details.notes3.value" button=true owner=owner editable=editable rollData=rollData}}
    - {{editor content=data.details.notes4.value target="data.details.notes4.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes4.value target="data.details.notes4.value" button=true owner=owner editable=editable rollData=rollData}}
    \ No newline at end of file diff --git a/templates/actors/newActor/vehicle-sheet.html b/templates/actors/newActor/vehicle-sheet.html index bbbff5d7..c0214b6e 100644 --- a/templates/actors/newActor/vehicle-sheet.html +++ b/templates/actors/newActor/vehicle-sheet.html @@ -158,7 +158,7 @@
    {{editor content=data.details.biography.value target='data.details.biography.value' - button=true owner=owner editable=editable}} + button=true owner=owner editable=editable rollData=rollData}}
    diff --git a/templates/actors/oldActor/character-sheet.html b/templates/actors/oldActor/character-sheet.html index fe66b89b..05f14052 100644 --- a/templates/actors/oldActor/character-sheet.html +++ b/templates/actors/oldActor/character-sheet.html @@ -60,8 +60,11 @@ -
  • -

    {{ localize "SW5E.HitDice" }}

    +
  • +

    + {{ localize "SW5E.HitDice" }} + +

    @@ -143,15 +146,17 @@ {{!-- Skills --}}
      - {{#each data.skills as |skill s|}} -
    • - - {{{skill.icon}}} -

      {{skill.label}}

      - {{skill.ability}} - {{numberFormat skill.total decimals=0 sign=true}} - ({{skill.passive}}) -
    • + {{#each config.skills as |label s|}} + {{#with (lookup ../data.skills s) as |skill|}} +
    • + + {{{skill.icon}}} +

      {{label}}

      + {{skill.ability}} + {{numberFormat skill.total decimals=0 sign=true}} + ({{skill.passive}}) +
    • + {{/with}} {{/each}}
    @@ -166,16 +171,16 @@ placeholder="{{res.placeholder}}" />
    -
  • @@ -251,7 +256,7 @@
    - {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable}} + {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable rollData=rollData}}
    diff --git a/templates/actors/oldActor/limited-sheet.html b/templates/actors/oldActor/limited-sheet.html index 3ece51c7..ac2f4250 100644 --- a/templates/actors/oldActor/limited-sheet.html +++ b/templates/actors/oldActor/limited-sheet.html @@ -14,7 +14,7 @@ {{!-- Sheet Body --}}
    - {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable}} + {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable rollData=rollData}}
    diff --git a/templates/actors/oldActor/npc-sheet.html b/templates/actors/oldActor/npc-sheet.html index bd1c6cc8..76001e43 100644 --- a/templates/actors/oldActor/npc-sheet.html +++ b/templates/actors/oldActor/npc-sheet.html @@ -27,8 +27,9 @@
  • -
  • - +
  • + {{labels.type}} +
  • @@ -108,15 +109,17 @@ {{!-- Skills --}}
      - {{#each data.skills as |skill s|}} -
    • - - {{{skill.icon}}} -

      {{skill.label}}

      - {{skill.ability}} - {{numberFormat skill.total decimals=0 sign=true}} - ({{skill.passive}}) -
    • + {{#each config.skills as |label s|}} + {{#with (lookup ../data.skills s) as |skill|}} +
    • + + {{{skill.icon}}} +

      {{label}}

      + {{skill.ability}} + {{numberFormat skill.total decimals=0 sign=true}} + ({{skill.passive}}) +
    • + {{/with}} {{/each}}
    @@ -172,7 +175,7 @@ {{!-- Biography Tab --}}
    - {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable}} + {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable rollData=rollData}}
    diff --git a/templates/actors/oldActor/parts/actor-features.html b/templates/actors/oldActor/parts/actor-features.html index 491181b7..c2a0c27b 100644 --- a/templates/actors/oldActor/parts/actor-features.html +++ b/templates/actors/oldActor/parts/actor-features.html @@ -38,7 +38,10 @@
  • -

    {{item.name}}

    +

    + {{item.name}} + {{#if item.isOriginalClass}} {{/if}} +

    {{#if section.hasActions}} @@ -52,7 +55,6 @@ / {{item.data.uses.max}} {{/if}} -
    {{#if item.data.activation.type }} {{item.labels.activation}} @@ -106,7 +108,7 @@ {{#if section.columns}} {{#each section.columns}}
    - {{#with (getProperty item property)}} + {{#with (lookup item property)}} {{#if ../editable}} diff --git a/templates/actors/oldActor/parts/actor-powerbook.html b/templates/actors/oldActor/parts/actor-powerbook.html index fa4bac9f..c51a4c27 100644 --- a/templates/actors/oldActor/parts/actor-powerbook.html +++ b/templates/actors/oldActor/parts/actor-powerbook.html @@ -1,7 +1,7 @@
    {{#unless isNPC}} - + {{else}} {{editor content=data.details.biography.value target='data.details.biography.value' - button=true owner=owner editable=editable}} + button=true owner=owner editable=editable rollData=rollData}}
    diff --git a/templates/apps/actor-flags.html b/templates/apps/actor-flags.html index 2cbeeff4..93d0c826 100644 --- a/templates/apps/actor-flags.html +++ b/templates/apps/actor-flags.html @@ -2,6 +2,15 @@

    {{localize 'SW5E.FlagsInstructions'}}

    +

    {{localize "SW5E.ItemTypeClass"}}

    +
    + + +

    {{localize "SW5E.ClassMakeOriginalHint"}}

    +
    + {{#each flags as |fs section|}}

    {{localize section}}

    {{#each fs as |flag key|}} diff --git a/templates/apps/actor-type.html b/templates/apps/actor-type.html new file mode 100644 index 00000000..53534ec2 --- /dev/null +++ b/templates/apps/actor-type.html @@ -0,0 +1,38 @@ +
    + + +
    + +
      + {{#each types as |type key|}} +
    • + +
    • + {{/each}} +
    • + + + +
    • +
    • + + +
    • +
    +
    + +
    + + +
    + + +
    + diff --git a/templates/apps/hit-dice-config.html b/templates/apps/hit-dice-config.html new file mode 100644 index 00000000..061c23da --- /dev/null +++ b/templates/apps/hit-dice-config.html @@ -0,0 +1,23 @@ +
    +

    {{ localize "SW5E.HitDiceConfigHint" }}

    + {{#each classes}} +
    + +
    + + + / + + + + +
    +
    + {{/each}} +
    + + +
    +
    diff --git a/templates/apps/select-items-promt.html b/templates/apps/select-items-promt.html new file mode 100644 index 00000000..f67e1bfd --- /dev/null +++ b/templates/apps/select-items-promt.html @@ -0,0 +1,18 @@ +
    +

    {{hint}}

    + +
      + {{#each items}} +
    • +
      +
      + + +
      +
    • + {{/each}} +
    +
    \ No newline at end of file diff --git a/templates/chat/item-card.html b/templates/chat/item-card.html index faafcceb..1c2b3016 100644 --- a/templates/chat/item-card.html +++ b/templates/chat/item-card.html @@ -1,4 +1,4 @@ -
    @@ -39,6 +39,11 @@ {{#if hasAreaTarget}} {{/if}} + + + {{#if isTool}} + + {{/if}}
    diff --git a/templates/chat/roll-dialog.html b/templates/chat/roll-dialog.html index 6070c11d..1c580746 100644 --- a/templates/chat/roll-dialog.html +++ b/templates/chat/roll-dialog.html @@ -3,6 +3,14 @@
    + {{#if chooseModifier}} +
    + + +
    + {{/if}}
    @@ -10,11 +18,7 @@
    \ No newline at end of file diff --git a/templates/chat/tool-card.html b/templates/chat/tool-card.html deleted file mode 100644 index 944cf982..00000000 --- a/templates/chat/tool-card.html +++ /dev/null @@ -1,18 +0,0 @@ -
    -
    - -

    {{item.name}}

    -
    - -
    {{{data.description.value}}}
    - -
    - -
    - -
    - {{#each data.properties}} - {{this}} - {{/each}} -
    -
    diff --git a/templates/chat/tool-roll-dialog.html b/templates/chat/tool-roll-dialog.html deleted file mode 100644 index cc47a637..00000000 --- a/templates/chat/tool-roll-dialog.html +++ /dev/null @@ -1,33 +0,0 @@ -
    -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    -
    \ No newline at end of file