diff --git a/dnd5e.mjs b/dnd5e.mjs index 6781e886e7..c243e55ca6 100644 --- a/dnd5e.mjs +++ b/dnd5e.mjs @@ -49,9 +49,10 @@ Hooks.once("init", function() { console.log(`D&D 5e | Initializing the D&D Fifth Game System - Version ${dnd5e.version}\n${DND5E.ASCII}`); // TODO: Remove when v11 support is dropped. - CONFIG.compatibility.excludePatterns.push(/Math\.clamped/); CONFIG.compatibility.excludePatterns.push(/\{\{filePicker}}/); CONFIG.compatibility.excludePatterns.push(/foundry\.dice\.terms/); + CONFIG.compatibility.excludePatterns.push(/core\.sourceId/); + if ( game.release.generation < 12 ) Math.clamp = Math.clamped; // Record Configuration Values CONFIG.DND5E = DND5E; @@ -150,7 +151,7 @@ Hooks.once("init", function() { }); DocumentSheetConfig.registerSheet(JournalEntryPage, "dnd5e", applications.journal.JournalClassPageSheet, { label: "DND5E.SheetClassClassSummary", - types: ["class"] + types: ["class", "subclass"] }); DocumentSheetConfig.registerSheet(JournalEntryPage, "dnd5e", applications.journal.JournalMapLocationPageSheet, { label: "DND5E.SheetClassMapLocation", @@ -160,6 +161,10 @@ Hooks.once("init", function() { label: "DND5E.SheetClassRule", types: ["rule"] }); + DocumentSheetConfig.registerSheet(JournalEntryPage, "dnd5e", applications.journal.JournalSpellListPageSheet, { + label: "DND5E.SheetClassSpellList", + types: ["spells"] + }); CONFIG.Token.prototypeSheetClass = applications.TokenConfig5e; DocumentSheetConfig.unregisterSheet(TokenDocument, "core", TokenConfig); @@ -194,11 +199,16 @@ function _configureTrackableAttributes() { ] }; + const altSpells = Object.entries(DND5E.spellPreparationModes).reduce((acc, [k, v]) => { + if ( !["prepared", "always"].includes(k) && v.upcast ) acc.push(`spells.${k}`); + return acc; + }, []); + const creature = { bar: [ ...common.bar, "attributes.hp", - "spells.pact", + ...altSpells, ...Array.fromRange(Object.keys(DND5E.spellLevels).length - 1, 1).map(l => `spells.spell${l}`) ], value: [ @@ -236,6 +246,11 @@ function _configureTrackableAttributes() { * @internal */ function _configureConsumableAttributes() { + const altSpells = Object.entries(DND5E.spellPreparationModes).reduce((acc, [k, v]) => { + if ( !["prepared", "always"].includes(k) && v.upcast ) acc.push(`spells.${k}.value`); + return acc; + }, []); + CONFIG.DND5E.consumableResources = [ ...Object.keys(DND5E.abilities).map(ability => `abilities.${ability}.value`), "attributes.ac.flat", @@ -246,7 +261,7 @@ function _configureConsumableAttributes() { "details.xp.value", "resources.primary.value", "resources.secondary.value", "resources.tertiary.value", "resources.legact.value", "resources.legres.value", - "spells.pact.value", + ...altSpells, ...Array.fromRange(Object.keys(DND5E.spellLevels).length - 1, 1).map(level => `spells.spell${level}.value`) ]; } @@ -345,8 +360,7 @@ Hooks.once("setup", function() { .forEach(p => p.applicationClass = applications.item.ItemCompendium5e); // Configure token rings - CONFIG.DND5E.tokenRings.shaderClass ??= game.release.generation < 12 - ? canvas.TokenRingSamplerShaderV11 : canvas.TokenRingSamplerShader; + CONFIG.DND5E.tokenRings.shaderClass ??= canvas.TokenRingSamplerShaderV11; CONFIG.Token.ringClass.initialize(); }); @@ -411,8 +425,10 @@ Hooks.once("ready", function() { /* -------------------------------------------- */ Hooks.on("canvasInit", gameCanvas => { - gameCanvas.grid.diagonalRule = game.settings.get("dnd5e", "diagonalMovement"); - SquareGrid.prototype.measureDistances = canvas.measureDistances; + if ( game.release.generation < 12 ) { + gameCanvas.grid.diagonalRule = game.settings.get("dnd5e", "diagonalMovement"); + SquareGrid.prototype.measureDistances = canvas.measureDistances; + } CONFIG.Token.ringClass.pushToLoad(gameCanvas.loadTexturesOptions.additionalSources); }); @@ -489,11 +505,16 @@ Hooks.on("renderChatLog", (app, html, data) => { }); Hooks.on("renderChatPopout", (app, html, data) => documents.Item5e.chatListeners(html)); -Hooks.on("chatMessage", (app, message, data) => dnd5e.applications.Award.chatMessage(message)); +Hooks.on("chatMessage", (app, message, data) => applications.Award.chatMessage(message)); Hooks.on("renderActorDirectory", (app, html, data) => documents.Actor5e.onRenderActorDirectory(html)); Hooks.on("getActorDirectoryEntryContext", documents.Actor5e.addDirectoryContextOptions); +Hooks.on("getCompendiumEntryContext", documents.Item5e.addCompendiumContextOptions); +Hooks.on("getItemDirectoryEntryContext", documents.Item5e.addDirectoryContextOptions); + +Hooks.on("renderJournalPageSheet", applications.journal.JournalSheet5e.onRenderJournalPageSheet); + Hooks.on("applyTokenStatusEffect", canvas.Token5e.onApplyTokenStatusEffect); Hooks.on("targetToken", canvas.Token5e.onTargetToken); diff --git a/icons/LICENSE b/icons/LICENSE index 5891970d45..8a43d2955f 100644 --- a/icons/LICENSE +++ b/icons/LICENSE @@ -74,6 +74,7 @@ The dnd5e system for Foundry Virtual Tabletop includes icon artwork licensed fro /currency/gold.webp /currency/platinum.webp /currency/silver.webp +/svg/damage/poison.svg /svg/statuses/bleeding.svg /svg/statuses/blinded.svg /svg/statuses/charmed.svg diff --git a/icons/svg/damage/acid.svg b/icons/svg/damage/acid.svg index a6491f4f40..fa574f1e8e 100644 --- a/icons/svg/damage/acid.svg +++ b/icons/svg/damage/acid.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/icons/svg/damage/bludgeoning.svg b/icons/svg/damage/bludgeoning.svg index 3b71bc51a7..c51f92e537 100644 --- a/icons/svg/damage/bludgeoning.svg +++ b/icons/svg/damage/bludgeoning.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/icons/svg/damage/cold.svg b/icons/svg/damage/cold.svg index eb35195a68..842ae1ee7d 100644 --- a/icons/svg/damage/cold.svg +++ b/icons/svg/damage/cold.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/icons/svg/damage/fire.svg b/icons/svg/damage/fire.svg index e805b895b7..b405a028d6 100644 --- a/icons/svg/damage/fire.svg +++ b/icons/svg/damage/fire.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/icons/svg/damage/force.svg b/icons/svg/damage/force.svg index 1b927feffa..8733461500 100644 --- a/icons/svg/damage/force.svg +++ b/icons/svg/damage/force.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/icons/svg/damage/healing.svg b/icons/svg/damage/healing.svg index c01a9680d5..a01cfe61b6 100644 --- a/icons/svg/damage/healing.svg +++ b/icons/svg/damage/healing.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/icons/svg/damage/lightning.svg b/icons/svg/damage/lightning.svg index 49adaf0152..c8fb115bce 100644 --- a/icons/svg/damage/lightning.svg +++ b/icons/svg/damage/lightning.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/icons/svg/damage/necrotic.svg b/icons/svg/damage/necrotic.svg index fd3546faf6..2ff6dc8b5b 100644 --- a/icons/svg/damage/necrotic.svg +++ b/icons/svg/damage/necrotic.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/icons/svg/damage/piercing.svg b/icons/svg/damage/piercing.svg index e40d9901f4..2777885ce5 100644 --- a/icons/svg/damage/piercing.svg +++ b/icons/svg/damage/piercing.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/icons/svg/damage/poison.svg b/icons/svg/damage/poison.svg new file mode 100644 index 0000000000..baa58604ae --- /dev/null +++ b/icons/svg/damage/poison.svg @@ -0,0 +1 @@ + diff --git a/icons/svg/damage/psychic.svg b/icons/svg/damage/psychic.svg index 5c0842e31c..af39e4829d 100644 --- a/icons/svg/damage/psychic.svg +++ b/icons/svg/damage/psychic.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/icons/svg/damage/radiant.svg b/icons/svg/damage/radiant.svg index 1477ac4f67..94484ff55e 100644 --- a/icons/svg/damage/radiant.svg +++ b/icons/svg/damage/radiant.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/icons/svg/damage/slashing.svg b/icons/svg/damage/slashing.svg index a2c0138dd3..e50be35730 100644 --- a/icons/svg/damage/slashing.svg +++ b/icons/svg/damage/slashing.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/icons/svg/damage/temphp.svg b/icons/svg/damage/temphp.svg index 0d7d5a4b45..508f2e1444 100644 --- a/icons/svg/damage/temphp.svg +++ b/icons/svg/damage/temphp.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/icons/svg/damage/thunder.svg b/icons/svg/damage/thunder.svg index 609e505d8d..4d406872b6 100644 --- a/icons/svg/damage/thunder.svg +++ b/icons/svg/damage/thunder.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/lang/en.json b/lang/en.json index 86002522d6..057c959c1c 100644 --- a/lang/en.json +++ b/lang/en.json @@ -110,6 +110,7 @@ "DND5E.AbilityUseConsumableDestroyHint": "Using this {type} will consume its final charge and it will be destroyed.", "DND5E.AbilityUseConsumableDestroyResourceHint": "Using this {type} will consume the final charge off {name} and destroy it.", "DND5E.AbilityUseConsume": "Consume Available Usage?", +"DND5E.AbilityUseConsumeAction": "Consume Usage", "DND5E.AbilityUseChargesLabel": "{value} Charges", "DND5E.AbilityUseConsumableLabel": "{max} per {per}", "DND5E.AbilityUseCast": "Cast Spell", @@ -128,7 +129,8 @@ "DND5E.ActionAbbr": "A", "DND5E.ActionPl": "Actions", "DND5E.ActionAbil": "Ability Check", -"DND5E.ActionHeal": "Healing", +"DND5E.ActionEnch": "Enchant", +"DND5E.ActionHeal": "Heal", "DND5E.ActionMSAK": "Melee Spell Attack", "DND5E.ActionMWAK": "Melee Weapon Attack", "DND5E.ActionOther": "Other", @@ -144,6 +146,7 @@ "DND5E.ActiveEffectOverrideWarning": "This value is being modified by an Active Effect and cannot be edited. Disable the effect to edit it.", "DND5E.Add": "Add", "DND5E.AdditionalControls": "Additional Controls", +"DND5E.AdditionalSettings": "Additional Settings", "DND5E.AddEmbeddedItemPromptHint": "Do you want to add these items to your character sheet?", "DND5E.AdvancementAbilityScoreImprovementTitle": "Ability Score Improvement", "DND5E.AdvancementAbilityScoreImprovementHint": "Allow the player to increase one or more ability scores or take an optional feat.", @@ -152,6 +155,7 @@ "DND5E.AdvancementAbilityScoreImprovementCapDisplay.one": "Max {points} point per score", "DND5E.AdvancementAbilityScoreImprovementCapDisplay.other": "Max {points} points per score", "DND5E.AdvancementAbilityScoreImprovementFeatHint": "Drop a feat here to choose one instead of an ability score improvement.", +"DND5E.AdvancementAbilityScoreImprovementFeatLevelWarning": "Must be at least level {level} to take this feat.", "DND5E.AdvancementAbilityScoreImprovementFeatWarning": "Only features with the 'feat' type can be selected.", "DND5E.AdvancementAbilityScoreImprovementFixed": "Fixed Improvement", "DND5E.AdvancementAbilityScoreImprovementLockedHint": "Scores cannot be modified with a feat selected.", @@ -195,15 +199,22 @@ "DND5E.AdvancementHitPointsRollButton": "Roll {die}", "DND5E.AdvancementItemChoiceTitle": "Choose Items", "DND5E.AdvancementItemChoiceHint": "Present the player with a choice of items (such as equipment, features, or spells) that they can choose for their character at one or more levels.", +"DND5E.AdvancementItemChoiceChoices": "Choices", +"DND5E.AdvancementItemChoiceChoose": "choose {count}", "DND5E.AdvancementItemChoiceChosen": "Chosen: {current} of {max}", +"DND5E.AdvancementItemChoiceFeatureLevelWarning": "Must be at least level {level} to take this feature.", "DND5E.AdvancementItemChoiceLevelsHint": "Specify how many choices are allowed at each level.", +"DND5E.AdvancementItemChoiceNoOriginalError": "Previously selected choice no longer available for replacement.", "DND5E.AdvancementItemChoicePreviouslyChosenWarning": "This item has already been chosen at a previous level.", +"DND5E.AdvancementItemChoiceReplacement": "Allow Replacement", +"DND5E.AdvancementItemChoiceReplacementNone": "No Replacement", +"DND5E.AdvancementItemChoiceReplacementTitle": "replace", "DND5E.AdvancementItemChoiceSpellLevelAvailable": "Any Available Level", "DND5E.AdvancementItemChoiceSpellLevelAvailableWarning": "Only {level} or lower spells can be chosen for this advancement.", "DND5E.AdvancementItemChoiceSpellLevelSpecificWarning": "Only {level} spells can be chosen for this advancement.", "DND5E.AdvancementItemChoiceSpellLevelHint": "Only allow choices from spells of this level.", "DND5E.AdvancementItemChoiceType": "Item Type", -"DND5E.AdvancementItemChoiceTypeHint": "Restrict what Item types can be choosen.", +"DND5E.AdvancementItemChoiceTypeHint": "Restrict what Item types can be chosen.", "DND5E.AdvancementItemChoiceTypeAny": "Anything", "DND5E.AdvancementItemChoiceTypeWarning": "Only {type} items can be selected for this choice.", "DND5E.AdvancementItemGrantTitle": "Grant Items", @@ -213,7 +224,7 @@ "DND5E.AdvancementItemGrantDuplicateWarning": "That item already exists on this advancement.", "DND5E.AdvancementItemGrantRecursiveWarning": "You cannot grant an item in its own advancement.", "DND5E.AdvancementItemGrantOptional": "Optional", -"DND5E.AdvancementItemGrantOptionalHint": "If optional, players will be given the option to opt out of any of the following items, otherwise all of them are granted.", +"DND5E.AdvancementItemGrantOptionalHint": "If the whole advancement is marked optional, players may opt out of any of the following items, otherwise all non-optional items are granted.", "DND5E.AdvancementItemTypeInvalidWarning": "{type} items cannot be added with this advancement type.", "DND5E.AdvancementLevelHeader": "Level {level}", "DND5E.AdvancementLevelAnyHeader": "Any Level", @@ -301,6 +312,7 @@ "DND5E.Attunement": "Attunement", "DND5E.AttunementMax": "Maximum Attuned Items", "DND5E.AttunementNone": "Attunement Not Required", +"DND5E.AttunementOptional": "Optional Attunement", "DND5E.AttunementRequired": "Attunement Required", "DND5E.AttunementAttuned": "Attuned", "DND5E.AttunementOverride": "Override attunement", @@ -462,6 +474,10 @@ "DND5E.ConcentrationConfigurationHint": "Configure concentration modifiers and bonuses which apply to this creature.", "DND5E.ConsumeTitle": "Resource Consumption", "DND5E.ConsumeAmount": "Consumption Amount", +"DND5E.ConsumeHint": { + "Attribute": "Attribute to consume (e.g. currency.gp)", + "Item": "UUID of target in compendium" +}, "DND5E.ConsumeTarget": "Consumption Target", "DND5E.ConsumeType": "Consumption Category", "DND5E.ConsumeAmmunition": "Ammunition", @@ -478,6 +494,7 @@ "DND5E.ConsumeWarningNoQuantity": "{name} has run out of its designated {type}!", "DND5E.ConsumeWarningZeroAttribute": "{name} has run out of its designated attribute resource pool!", "DND5E.ConsumeResource": "Consume Resource?", +"DND5E.ConsumeResourceAction": "Consume Resource", "DND5E.ConsumeRecharge": "Consume Recharge?", "DND5E.ConsumeScaling": "Resource Scaling", "DND5E.ConsumeScalingLabel": "Use Resources", @@ -660,6 +677,7 @@ "DND5E.DistMiAbbr": "mi", "DND5E.DistSelf": "Self", "DND5E.DistTouch": "Touch", +"DND5E.DocumentUseWarn": "You lack permission to use an item on this document.", "DND5E.DocumentViewWarn": "You lack permission to view this document.", "DND5E.Duration": "Duration", "DND5E.DurationPermanent": "Permanent", @@ -674,6 +692,80 @@ "DND5E.EffectApplyWarningConcentration": "Applying an effect that is being concentrated on by another character requires GM permissions.", "DND5E.EffectApplyWarningOwnership": "Effects cannot be applied to tokens you are not the owner of.", "DND5E.EffectsSearch": "Search effects", +"DND5E.Enchantment": { + "Action": { + "Apply": "Apply Enchantment", + "Configure": "Configure Enchantment", + "Create": "Create Enchantment", + "Delete": "Delete Enchantment", + "Disable": "Disable Enchantment", + "Edit": "Edit Enchantment", + "Enable": "Enable Enchantment", + "Remove": "Remove Enchantment" + }, + "Category": { + "Active": "Active Enchantments", + "Empty": "No enchantments have been created, use the button above to create one.", + "General": "Enchantments", + "Inactive": "Inactive Enchantments" + }, + "Configuration": "Enchantment Configuration", + "DropArea": "Place item here to enchant it…", + "FIELDS": { + "enchantment": { + "label": "Enchantment Configuration", + "classIdentifier": { + "hint": "Identifier used to determine whether the character level or a specific class level should be used for enchantment level limits." + }, + "items": { + "max": { + "label": "Item Limit", + "hint": "Formula for the maximum number of enchantments of this type that can be active at a time." + }, + "period": { + "label": "Replacement Period", + "hint": "How frequently the enchantments of this type can be re-bound to different items." + } + }, + "restrictions": { + "label": "Restrictions", + "hint": "Restrictions on the type of item to which this enchantment can be applied.", + "allowMagical": { + "label": "Allow Magical", + "hint": "Allow items that are already magical to be enchanted." + }, + "type": { + "label": "Item Type", + "hint": "Type of item to which this enchantment can be applied." + } + } + } + }, + "Label": "Enchantment", + "Level": { + "Hint": "Range of levels required to use this enchantment." + }, + "Items": { + "Entry": "{item} on {actor}" + }, + "Riders": { + "Effect": { + "Label": "Additional Effects", + "Hint": "These additional effects will be added to the enchanted item when this enchantment is added, and removed when the enchantment is removed." + }, + "Item": { + "Label": "Additional Items", + "Hint": "These additional items will be added to the creature when one of its items is enchanted, and will be removed if the enchantment is ever removed." + } + }, + "Warning": { + "ConcentrationEnded": "Cannot apply this enchantment because concentration has ended.", + "NoMagicalItems": "Items that are already magical cannot be enchanted.", + "NotOnActor": "Enchantments can only be added to items, not directly to actors.", + "Override": "This value is being modified by an Enchantment and cannot be edited. Disable the enchantment in the effects tab to edit it.", + "WrongType": "{incorrectType} items cannot be enchanted by this enchantment, only {allowedType} items are allowed." + } +}, "DND5E.Environment": "Environment", "DND5E.EquipmentBonus": "Magical Bonus", "DND5E.EquipmentClothing": "Clothing", @@ -757,6 +849,12 @@ "DND5E.EffectPassive": "Passive Effects", "DND5E.EffectInactive": "Inactive Effects", "DND5E.EffectNew": "New Effect", +"DND5E.EffectType": { + "Inactive": "Inactive", + "Passive": "Passive", + "Temporary": "Temporary", + "Unavailable": "Unavailable" +}, "DND5E.EffectUnavailable": "Unavailable Effects", "DND5E.EffectUnavailableInfo": "Source item must be equipped or attuned to activate these", "DND5E.Encumbrance": "Encumbrance", @@ -840,6 +938,7 @@ "one": "Member", "other": "Members" }, + "PlaceMembers": "Place Members", "Primary": { "Remove": "Remove as Primary Party", "Set": "Set as Primary Party" @@ -856,6 +955,7 @@ "DND5E.Hair": "Hair", "DND5E.HalfProficient": "Half Proficient", "DND5E.Healing": "Healing", +"DND5E.HealingRoll": "Healing Roll", "DND5E.HealingTemp": "Healing (Temporary)", "DND5E.Height": "Height", "DND5E.HitPoints": "Hit Points", @@ -887,6 +987,7 @@ "DND5E.HitDiceRoll": "Roll Hit Dice", "DND5E.HitDiceUsed": "Hit Dice Used", "DND5E.HitDiceWarn": "{name} has no available {formula} Hit Dice remaining!", +"DND5E.HitDiceNPCWarn": "{name} has no available Hit Dice remaining!", "DND5E.Ideals": "Ideals", "DND5E.Identified": "Identified", "DND5E.Identifier": "Identifier", @@ -990,10 +1091,12 @@ "DND5E.ItemRequiredStr": "Required Strength", "DND5E.ItemToolBase": "Base Tool", "DND5E.ItemToolBonus": "Tool Bonus", +"DND5E.ItemToolDetails": "Tool Details", "DND5E.ItemToolProficiency": "Tool Proficiency", "DND5E.ItemToolProperties": "Tool Properties", "DND5E.ItemToolStatus": "Tool Status", "DND5E.ItemToolType": "Tool Type", +"DND5E.ItemToolUsage": "Tool Usage", "DND5E.ItemView": "View Item", "DND5E.ItemWeaponAttack": "Weapon Attack", "DND5E.ItemWeaponBase": "Base Weapon", @@ -1046,6 +1149,11 @@ "DND5E.LevelActionDecrease": "Level Down", "DND5E.LevelActionIncrease": "Level Up", "DND5E.LevelCount": "{ordinal} Level", +"DND5E.LevelLimit": { + "Label": "Level Limit", + "Max": "Maximum Level", + "Min": "Minimum Level" +}, "DND5E.LevelNumber": "Level {level}", "DND5E.LevelScaling": "Level Scaling", "DND5E.LimitedUses": "Limited Uses", @@ -1136,6 +1244,17 @@ "DND5E.PolymorphWildShape": "Wild Shape", "DND5E.Portrait": "Portrait", "DND5E.Prepared": "Prepared", +"DND5E.Prerequisites": { + "Header": "Feature Prerequisites", + "FIELDS": { + "prerequisites": { + "level": { + "label": "Required Level", + "hint": "Character or class level required to select this feature when levelling up." + } + } + } +}, "DND5E.Price": "Price", "DND5E.Proficiency": "Proficiency", "DND5E.ProficiencyBonus": "Proficiency Bonus", @@ -1211,8 +1330,18 @@ "DND5E.NewDayHint": "Recover limited use abilities which recharge \"per day\"?", "DND5E.SaveBonus": "Saving Throw Bonus", "DND5E.SaveGlobalBonusHint": "This bonus applies to all saving throws made by this actor.", -"DND5E.ScrollDetails": "Scroll Details", -"DND5E.ScrollRequiresConcentration": "Requires Concentration", +"DND5E.Scroll": { + "CreateFrom": "Create Scroll from {spell}", + "CreateScroll": "Create Scroll", + "Details": "Scroll Details", + "Explanation": { + "Label": "Explanation", + "Hint": "Amount of the rules on using spell scrolls to include in the created scroll.", + "Complete": "Complete", + "Reference": "Reference" + }, + "RequiresConcentration": "Requires Concentration" +}, "DND5E.Senses": "Senses", "DND5E.SensesConfig": "Configure Senses", "DND5E.SensesConfigHint": "Configure any special sensory perception abilities that this actor possesses.", @@ -1232,6 +1361,7 @@ "DND5E.SheetClassMapLocation": "Default 5e Map Location Sheet", "DND5E.SheetClassNPC": "Default 5e NPC Sheet", "DND5E.SheetClassRule": "Default 5e Rule Sheet", +"DND5E.SheetClassSpellList": "Default 5e Spell List Sheet", "DND5E.SheetClassToken": "Default 5e Token Sheet", "DND5E.SheetClassVehicle": "Default 5e Vehicle Sheet", "DND5E.SheetModeEdit": "Edit", @@ -1375,6 +1505,7 @@ "DND5E.SpellNone": "None", "DND5E.SpellPrepAtWill": "At-Will", "DND5E.SpellPrepInnate": "Innate Spellcasting", +"DND5E.SpellPrepRitual": "Ritual Only", "DND5E.SpellPrepPrepared": "Prepared", "DND5E.SpellPrepAlways": "Always Prepared", "DND5E.SpellPreparation": "Spell Preparation", @@ -1451,7 +1582,8 @@ "Configure": "Configure Summons", "Place": "Place Summons", "Remove": "Remove Profile", - "Summon": "Summon" + "Summon": "Summon", + "View": "View Summon" }, "Bonuses": { "ArmorClass": { @@ -1466,6 +1598,10 @@ "Label": "Bonus Healing", "Hint": "Additional healing provided by healing abilities." }, + "HitDice": { + "Label": "Bonus Hit Dice", + "Hint": "Additional hit dice added to the creature on top of what is derived from the HP formula in their statblock. Can only be used when summoning NPC actors." + }, "HitPoints": { "Label": "Bonus Hit Points", "Hint": "Additional hit points added to the creature on top of what is specified in their statblock." @@ -1476,16 +1612,31 @@ } }, "Configuration": "Summoning Configuration", + "Count": { + "Label": "Count" + }, "CreatureChanges": { "Label": "Creature Changes", - "Hint": "Changes that will be made to the creature being summoned. Any @ references used in the following formulas will be based on the summoner's stats." + "Hint": "Changes that will be made to the creature being summoned. Any @ references used in the following formulas will be based on the summoner's stats. Summoned creature's stats can be referenced using @summon (e.g. @summon.attributes.hd.max to reference the creature's hit dice count)." + }, + "CreatureSizes": { + "Label": "Creature Sizes", + "Hint": "Summoned creature and token will be changed to this size. If more than one size is selected, then the player will be able to choose from these sizes when summoning." }, - "DisplayName": "Display Name", + "CreatureTypes": { + "Label": "Creature Types", + "Hint": "Summoned creature will be changed to this type. If more than one type is selected, then the player will be able to choose from these types when summoning." + }, + "DisplayName": "Profile Name", "DropHint": "Drop creature here", "ItemChanges": { "Label": "Item Changes", "Hint": "Changes made to items on the summoned creature." }, + "Level": { + "Hint": "Range of levels required to use this profile.", + "IdentifierHint": "Identifier used to determine whether the character level or a specific class level should be used for profile level limits." + }, "Match": { "Attacks": { "Label": "Match Attacks", @@ -1582,7 +1733,7 @@ "ScaleCorrection": "Scale Correction", "Subject": { "Label": "Subject Path", - "Hint": "Explicitly specify a path for the artwork placed over the dynamic token ring. If not provided, the subject image will attempt to be inferred by adding “-subject” to the end of the normal token image path." + "Hint": "Explicitly specify a path for the artwork placed over the dynamic token ring. If not provided, the subject image will be set to the normal token artwork." }, "Title": "Dynamic Ring" }, @@ -1652,6 +1803,7 @@ "DND5E.UsesMax": "Maximum Uses", "DND5E.UsesPeriod": "Recovery Period", "DND5E.UsesPeriods": { + "AtWill": "At Will", "Charges": "Charges", "ChargesAbbreviation": "Charges", "Dawn": "Dawn", @@ -1662,6 +1814,7 @@ "DuskAbbreviation": "Dusk", "Lr": "Long Rest", "LrAbbreviation": "LR", + "Never": "Never", "Sr": "Short Rest", "SrAbbreviation": "SR" }, @@ -1713,6 +1866,25 @@ "DND5E.WeaponSimpleProficiency": "Simple", "DND5E.WeaponSimpleR": "Simple Ranged", "DND5E.Weight": "Weight", +"DND5E.WeightUnit": { + "Label": "Weight Units", + "Kilograms": { + "Label": "Kilograms", + "Abbreviation": "kg" + }, + "Megagrams": { + "Label": "Tonnes", + "Abbreviation": "t" + }, + "Pounds": { + "Label": "Pounds", + "Abbreviation": "lb" + }, + "Tons": { + "Label": "Tons", + "Abbreviation": "tn" + } +}, "DND5E.WhisperedTo": "Whispered to", "DND5E.Wiki": "Wiki", "DND5E.available": "available", @@ -1736,7 +1908,11 @@ "RollRequest": "Roll Request", "SaveShort": "{save}", "SaveLong": "{save} saving throw", - "SpecificCheck": "{ability} ({type})" + "SpecificCheck": "{ability} ({type})", + "Warning": { + "NoActor": "No selected or assigned actor could be found to execute this roll.", + "NoItemOnActor": "{actor} does not have an Item with name {item}." + } }, "EFFECT.DND5E": { @@ -1764,39 +1940,75 @@ "TYPES.JournalEntryPage.class": "Class Summary", "TYPES.JournalEntryPage.map": "Map Location", "TYPES.JournalEntryPage.rule": "Rule", -"JOURNALENTRYPAGE.DND5E.Class.AdditionalEquipment": "Equipment", -"JOURNALENTRYPAGE.DND5E.Class.AdditionalEquipmentHint": "List of equipment granted by this class if taken at first level.", -"JOURNALENTRYPAGE.DND5E.Class.AdditionalHitPoints": "Additional Hit Points Description", -"JOURNALENTRYPAGE.DND5E.Class.AdditionalHitPointsHint": "Additional descriptive text displayed beneath the auto-generated hit points section.", -"JOURNALENTRYPAGE.DND5E.Class.AdditionalTraits": "Additional Proficiencies Description", -"JOURNALENTRYPAGE.DND5E.Class.AdditionalTraitsHint": "Additional descriptive text displayed beneath list of proficiencies granted by this class.", -"JOURNALENTRYPAGE.DND5E.Class.Description": "Introduction", -"JOURNALENTRYPAGE.DND5E.Class.DescriptionHint": "Primary description of the class that will appear first.", -"JOURNALENTRYPAGE.DND5E.Class.EquipmentHeader": "Equipment", -"JOURNALENTRYPAGE.DND5E.Class.FeaturesHeader": "Class Features", -"JOURNALENTRYPAGE.DND5E.Class.FeaturesDescription": "As a {lowercaseName}, you gain the following class features, which are summarized in the {name} table.", -"JOURNALENTRYPAGE.DND5E.Class.HitDice": "Hit Dice: {dice} per {class} level", -"JOURNALENTRYPAGE.DND5E.Class.HitPointsHeader": "Hit Points", -"JOURNALENTRYPAGE.DND5E.Class.HitPointsLevel1": "Hit Points at 1st Level: {max} + your Constitution modifier", -"JOURNALENTRYPAGE.DND5E.Class.HitPointsLevelX": "Hit Points at Higher Levels: {dice} (or {average}) + your Constitution modifier per {class} level after 1st", -"JOURNALENTRYPAGE.DND5E.Class.Item": "Selected Class", -"JOURNALENTRYPAGE.DND5E.Class.ItemHint": "Drop a class here", -"JOURNALENTRYPAGE.DND5E.Class.NoValidClass": "No valid class selected, press the edit button to add a class.", -"JOURNALENTRYPAGE.DND5E.Class.OptionalFeaturesCaption": "Optional Class Features", -"JOURNALENTRYPAGE.DND5E.Class.OptionalFeaturesDescription": "The following section contains options {class} features. These features are not granted automatically, and you can choose one, some, or all of them at your DM's discretion.", -"JOURNALENTRYPAGE.DND5E.Class.SpellSlotLevel": "Slot Level", -"JOURNALENTRYPAGE.DND5E.Class.SpellSlots": "Spell Slots", -"JOURNALENTRYPAGE.DND5E.Class.SpellSlotsPerSpellLevel": "—Spell Slots per Spell Level—", -"JOURNALENTRYPAGE.DND5E.Class.SubclassHeader": "Subclass Header", -"JOURNALENTRYPAGE.DND5E.Class.SubclassHint": "Drop subclasses here", -"JOURNALENTRYPAGE.DND5E.Class.SubclassDescription": "Subclass Introduction", -"JOURNALENTRYPAGE.DND5E.Class.SubclassDescriptionHint": "Introduction that will be displayed before this class's subclasses.", -"JOURNALENTRYPAGE.DND5E.Class.SubclassItems": "Subclasses", -"JOURNALENTRYPAGE.DND5E.Class.TableCaption": "The {class}", -"JOURNALENTRYPAGE.DND5E.Class.TableOptionalCaption": "Optional {class} Features", -"JOURNALENTRYPAGE.DND5E.Class.TraitsHeader": "Proficiencies", -"JOURNALENTRYPAGE.DND5E.EditDescription": "Edit", -"JOURNALENTRYPAGE.DND5E.TableTOC": "Table: {caption}", +"TYPES.JournalEntryPage.spells": "Spell List", +"TYPES.JournalEntryPage.subclass": "Subclass Summary", +"JOURNALENTRYPAGE.DND5E": { + "Class": { + "AdditionalEquipment": "Additional Equipment Description", + "AdditionalEquipmentHint": "Additional descriptive text displayed beneath the starting equipment section.", + "AdditionalHitPoints": "Additional Hit Points Description", + "AdditionalHitPointsHint": "Additional descriptive text displayed beneath the auto-generated hit points section.", + "AdditionalTraits": "Additional Proficiencies Description", + "AdditionalTraitsHint": "Additional descriptive text displayed beneath list of proficiencies granted by this class.", + "Description": "Introduction", + "DescriptionHint": "Primary description of the class that will appear first.", + "EquipmentHeader": "Equipment", + "EquipmentDescription": "You start with the following equipment, in addition to the equipment granted by your background:", + "FeaturesHeader": "Class Features", + "FeaturesDescription": "As a {lowercaseName}, you gain the following class features, which are summarized in the {name} table.", + "HitDice": "Hit Dice: {dice} per {class} level", + "HitPointsHeader": "Hit Points", + "HitPointsLevel1": "Hit Points at 1st Level: {max} + your Constitution modifier", + "HitPointsLevelX": "Hit Points at Higher Levels: {dice} (or {average}) + your Constitution modifier per {class} level after 1st", + "Item": "Selected Class", + "ItemHint": "Drop a class here", + "NoValidClass": "No valid class selected, press the edit button to add a class.", + "OptionalFeaturesCaption": "Optional Class Features", + "OptionalFeaturesDescription": "The following section contains options {class} features. These features are not granted automatically, and you can choose one, some, or all of them at your DM's discretion.", + "SpellSlotLevel": "Slot Level", + "SpellSlots": "Spell Slots", + "SpellSlotsPerSpellLevel": "—Spell Slots per Spell Level—", + "SubclassHeader": "Subclass Header", + "SubclassHint": "Drop subclasses here", + "SubclassDescription": "Subclass Introduction", + "SubclassDescriptionHint": "Introduction that will be displayed before this class's subclasses.", + "SubclassItems": "Subclasses", + "TableCaption": "The {class}", + "TableOptionalCaption": "Optional {class} Features", + "TraitsHeader": "Proficiencies" + }, + "EditDescription": "Edit", + "TableTOC": "Table: {caption}", + "SpellList": { + "DropHint": "Drop spells or folders of spells here to add to the list", + "Grouping": { + "Label": "Grouping Mode", + "Hint": "Controls how the spells will be grouped by default in the spell list.", + "Alphabetical": "By First Letter", + "Level": "By Level", + "None": "No Grouping", + "School": "By School" + }, + "IdentifierHint": "Identifier should match that defined on the associated document, if applicable. For example, when creating a spell list for the Wizard class, the identifier should be 'wizard'.", + "Type": { + "Label": "Spell List Type", + "Other": "Uncategorized" + }, + "UnlinkedSpells": { + "Label": "Unlinked Spells", + "Add": "Add Unlinked Spell", + "Configuration": "Spell Configuration", + "Edit": "Edit Unlinked Spell" + } + }, + "Subclass": { + "Description": "Introduction", + "DescriptionHint": "Description of the subclass that will appear before any listed features.", + "Item": "Selected Subclass", + "ItemHint": "Drop a subclass here", + "NoValidSubclass": "No valid subclass selected, press the edit button to add a subclass." + } +}, "MACRO.5eMissingTargetWarn": "Your controlled actor '{actor}' does not have an {type} with name '{name}'.", "MACRO.5eMultipleTargetsWarn": "Your controlled actor '{actor}' has more than one {type} with name '{name}'. The first match will be chosen.", @@ -1815,7 +2027,7 @@ "SETTINGS.5eAutoSpellTemplateN": "Always place Spell Template", "SETTINGS.5eChallengeVisibility": { "Name": "Challenge Visibility", - "Hint": "Control what roll DCs are visible to the players and whether successess/failures are highlighted.", + "Hint": "Control what roll DCs are visible to the players and whether successes/failures are highlighted.", "All": "Show all", "None": "Hide all", "Player": "Show only from other players" @@ -1876,6 +2088,13 @@ "Name": "Allow Summoning", "Hint": "Allow players to use summoning abilities to summon actors. Players must also have the Create Token core permission for this to work." }, + "COLLAPSETRAYS": { + "Name": "Collapse Trays in Chat", + "Hint": "Automatically collapse damage, hit, and effect trays that appear in chat cards.", + "Always": "Collapse All", + "Older": "Collapse Older Trays", + "Never": "Expand All" + }, "THEME": { "Name": "Theme", "Hint": "Theme that will apply to the UI and all sheets by default. Automatic will be determined by your browser or operating system settings." @@ -1891,5 +2110,6 @@ } }, -"SOURCE.BOOK.SRD": "System Reference Document 5.1" +"SOURCE.BOOK.SRD": "System Reference Document 5.1", +"SIDEBAR.SortModePriority": "Sort by priority" } diff --git a/less/elements.less b/less/elements.less index 75d8a883aa..2544dac3b7 100644 --- a/less/elements.less +++ b/less/elements.less @@ -183,3 +183,21 @@ item-list-controls search { .filter-control[data-action="sort"] { padding-left: .625rem; } } + +/* ---------------------------------- */ +/* Multi Select */ +/* ---------------------------------- */ + +.dnd5e multi-select .tags { + flex-wrap: wrap; + .tag { cursor: pointer; } +} + +/* ---------------------------------- */ +/* Document Tags */ +/* ---------------------------------- */ + +document-tags:has(> .content-link) { + display: flex; + flex-wrap: wrap; +} diff --git a/less/v1/advancement.less b/less/v1/advancement.less index 01c9194f16..e483be57c1 100644 --- a/less/v1/advancement.less +++ b/less/v1/advancement.less @@ -43,19 +43,14 @@ } /* ----------------------------------------- */ - /* Two Column Configurations */ + /* Column Configurations */ /* ----------------------------------------- */ - &.two-column { - --grid-two-column-left-size: 1fr; - --grid-two-column-right-size: 1fr; - + &.two-column, &.three-column { form { display: grid; - grid-template-columns: var(--grid-two-column-left-size) var(--grid-two-column-right-size); - grid-template-areas: "left right"; - grid-gap: 0.4em; + gap: 0.4em; - .left-column { + > .left-column { grid-area: left; display: flex; flex-direction: column; @@ -63,26 +58,59 @@ > .form-group { flex: none; } > .drop-target { flex: 1; } } - .right-column { + > .right-column { grid-area: right; - - &.level-list { - label { - flex: 0.5; - padding-right: 0.5rem; - text-align: end; - } - :is(input[type="text"], input[type="number"])::placeholder { - opacity: 0.5; - } + } + > .level-list { + label { + flex: 0.5; + padding-right: 0.5rem; + text-align: end; + } + :is(input[type="text"], input[type="number"])::placeholder { + opacity: 0.5; } } + } + } + + &.two-column { + --grid-two-column-left-size: 1fr; + --grid-two-column-right-size: 1fr; + + form { + grid-template-columns: var(--grid-two-column-left-size) var(--grid-two-column-right-size); + grid-template-areas: "left right"; + button[type="submit"] { grid-column-end: span 2; } } } + &.three-column { + --grid-three-column-left-size: 1fr; + --grid-three-column-center-size: 1fr; + --grid-three-column-right-size: 1fr; + + form { + grid-template-columns: + var(--grid-two-column-left-size) var(--grid-two-column-center-size) var(--grid-two-column-right-size); + grid-template-areas: "left center right"; + + > .center-column { + grid-area: center; + } + button[type="submit"] { + grid-column-end: span 3; + } + } + } + + /* ----------------------------------------- */ + /* Ability Score Improvement */ + /* ----------------------------------------- */ + form[data-type="AbilityScoreImprovement"] { .ability-scores { contain: layout; @@ -174,14 +202,33 @@ } } + /* ----------------------------------------- */ + /* Item Grant */ + /* ----------------------------------------- */ + &.item-grant { + .items-list { + h3 { + margin: 0; + } + .item-optional { + flex: 0 0 65px; + } + } + } + /* ----------------------------------------- */ /* Item Choice */ /* ----------------------------------------- */ &.item-choice { + --grid-two-column-left-size: 1fr; + --grid-two-column-center-size: 0.7fr; --grid-two-column-right-size: 0.5fr; - .level-list .hint { - text-align: end; + .level-list { + .items-header { padding-inline: 2px; } + .level-header { flex: 0.5; } + .choices-header { flex: 3; } + .replacement-header { flex: 0 0 20px; } } .form-group:has(textarea) { @@ -193,7 +240,7 @@ border: 1px solid var(--color-border-light-tertiary); font-family: var(--font-primary); font-size: var(--font-size-12); - height: 100px; + height: 140px; } } @@ -377,6 +424,18 @@ flex: 0 0 20px; margin-inline-end: 1px; } + input[type="radio"] { + flex: 0 0 20px; + width: 20px; + height: 20px; + margin: 3px 5px; + } + h4.form-header { + margin-block-start: 0.25em; + } + .replaced h4 { + text-decoration: rgb(200 0 0) line-through 2px; + } } form[data-type="ScaleValue"] { diff --git a/less/v1/items.less b/less/v1/items.less index 277a6477a1..7d7112c528 100644 --- a/less/v1/items.less +++ b/less/v1/items.less @@ -137,7 +137,7 @@ font-size: var(--font-size-13); } - [name="system.price.denomination"] { + :is([name="system.price.denomination"], [name="system.weight.units"]) { border: none; } } @@ -183,8 +183,13 @@ inset-inline-end: 0; padding: 6px; } + + &[aria-disabled] { + cursor: not-allowed; + &:hover { text-shadow: none; } + } } - &:hover .description-edit { + &:hover .description-edit:not[aria-disabled] { opacity: 100%; } } @@ -250,18 +255,21 @@ } } - .form-group.uses-per, .form-group.consumption { + .form-group.uses-per { .form-fields { flex-wrap: nowrap; } - &.consumption input { - flex: 0 0 32px; - } span { flex: 0 0 16px; margin: 0 4px 0 0; } } + .form-group.consumption .form-fields { + flex-wrap: wrap; + > * { margin: 0; } + [name="system.consume.amount"] { flex: 0 0 32px; } + [name="system.consume.type"] { flex: 0 0 100%; } + } span.sep { flex: 0 0 8px; } @@ -349,11 +357,27 @@ } } - .summoning.form-group { + :is(.enchantment, .summoning).form-group { .config-button { opacity: 1; font-size: var(--font-size-12); } + .separated-list { + flex: 1 0 100%; + .gold-icon { + flex: 0 0 32px; + width: 32px; + height: 32px; + } + .name { + flex: 1 0 175px; + text-align: start; + } + .details { + align-items: center; + } + .list-controls button { --size: 32px; } + } } /* ----------------------------------------- */ @@ -541,10 +565,12 @@ } /* ----------------------------------------- */ -/* Summoning Configuration */ +/* Enchantment & Summoning Configuration */ /* ----------------------------------------- */ -.dnd5e.summoning-config { +.dnd5e:is(.enchantment-config, .summoning-config) { + max-block-size: 90vh; + .unbutton { width: unset; border: none; @@ -555,45 +581,53 @@ &:hover { text-shadow: 0 0 8px var(--color-shadow-primary); } &:focus-visible { outline: 2px solid black; } } - .form-header { justify-content: space-between; button { flex: unset; } } - ul.profiles { - padding: 0; - list-style: none; - gap: 12px; - } - li.profile { - position: relative; - padding: 8px; - background: var(--dnd5e-color-card); - border: 2px solid var(--dnd5e-color-gold); - border-radius: 4px; - box-shadow: 0 0 4px var(--dnd5e-shadow-45); - - .details { - gap: 4px; - input { height: unset; } - input::placeholder { opacity: .5; } - } - [data-action="delete-profile"] { - --size: 26px; - flex: 0 0 var(--size); - block-size: var(--size); - inline-size: var(--size); - } - .content-link, .drop-area { + .separated-list { + .content-link, .drop-area, .name { flex: 0 0 175px; display: flex; align-items: center; + align-content: center; } + .content-link { + display: block; + overflow: hidden; + text-overflow: ellipsis; + } + .name { flex: 1 0 175px; } .drop-area { border: 1px dashed black; border-radius: 4px; padding-inline: 4px; } + input::placeholder { opacity: .5; } + } + .additional-tray { + margin-block-start: 8px; + + > label { + cursor: pointer; + display: flex; + justify-content: center; + gap: .25rem; + font-size: var(--font-size-11); + + > span { flex: none; } + .fa-gears { color: var(--color-text-light-6); } + + &::before, &::after { + content: ""; + flex-basis: 50%; + border-top: 1px dotted var(--dnd5e-color-gold); + align-self: center; + } + } + .form-group:last-child { + margin-block-end: -3px; + } } } diff --git a/less/v1/journal.less b/less/v1/journal.less index b9db4f1f80..e4287e7fbb 100644 --- a/less/v1/journal.less +++ b/less/v1/journal.less @@ -146,29 +146,15 @@ p:empty:has(+ .content-embed), .content-embed + p:empty { } } -.journal-entry-page.class, .class-journal { - h4 { - border-block-end: 1px solid var(--color-underline-header); - font-weight: bold; - } - - table { - th[scope="col"] { - padding-inline: 0.25em; - } - - td:is(.level, .prof, .scale, .spell-slots) { - text-align: center; - } - } +.journal-entry-page.sheet:is(.class-journal, .spells) { + min-block-size: 600px; + min-inline-size: 400px; form { .form-group { align-items: start; - } - - button.launch-text-editor { - flex: 0.35; + &:has(button.launch-text-editor) .form-fields { flex: 0 0 125px; } + button.launch-text-editor { flex: unset; } } .items-list .item { @@ -178,11 +164,70 @@ p:empty:has(+ .content-embed), .content-embed + p:empty { .item-controls { flex: 0; + flex-wrap: nowrap; + gap: 8px; } } .items-list .item:not(:last-of-type) { border-bottom: 1px solid var(--color-border-light-secondary); } + + h3 { + justify-content: space-between; + a { + flex: 0; + padding-inline-end: 12px; + font-size: var(--font-size-14); + } + } + } +} + +.journal-entry-page.sheet.spells { + min-inline-size: 550px; + + form { + display: grid; + grid-template-columns: auto 250px; + overflow: hidden; + + > .right { overflow-y: auto; } + h3 { margin-block-start: 1rem; } + .editor { margin-inline-end: .5rem; } + + .items-list { + margin-block-end: 1em; + margin-inline-end: 10px; + padding-inline-start: 0; + list-style: none; + + .empty { + min-block-size: 120px; + display: flex; + align-items: center; + justify-content: center; + border: 1px dashed black; + border-radius: 10px; + padding: 12px; + } + } + } +} + +.journal-entry-page.class { + h4 { + border-block-end: 1px solid var(--color-underline-header); + font-weight: bold; + } + + table { + th[scope="col"] { + padding-inline: 0.25em; + } + + td:is(.level, .prof, .scale, .spell-slots) { + text-align: center; + } } } @@ -229,3 +274,24 @@ p:empty:has(+ .content-embed), .content-embed + p:empty { } } } + +.journal-entry-page.spells, .content-embed.spells { + .grouping { + font-size: var(--font-size-12); + + select { + margin-inline-start: 1em; + height: 1.75em; + } + } + + ul { + column-width: 200px; + padding-inline-start: 0; + + li { + list-style: none; + line-height: 1.8em; + } + } +} diff --git a/less/v2/apps.less b/less/v2/apps.less index 1d1d3e5c65..aa7dbfabad 100644 --- a/less/v2/apps.less +++ b/less/v2/apps.less @@ -123,6 +123,20 @@ } } + .collapsible { + &.collapsed { + label .fa-caret-down { transform: rotate(-90deg); } + .collapsible-content { grid-template-rows: 0fr; } + } + .fa-caret-down { transition: transform 250ms ease; } + .collapsible-content { + display: grid; + grid-template-rows: 1fr; + transition: grid-template-rows 250ms ease; + > .wrapper { overflow: hidden; } + } + } + .unlist { list-style: none; padding: 0; @@ -274,6 +288,8 @@ box-shadow: 0 0 4px var(--dnd5e-shadow-45); border-radius: 0; background-color: var(--dnd5e-color-light-gray); + object-fit: cover; + object-position: top; } .name-stacked { @@ -381,6 +397,44 @@ } } + /* ---------------------------------- */ + /* Lists */ + /* ---------------------------------- */ + + .separated-list, &.separated-list { + padding: 0; + list-style: none; + gap: 12px; + + > li:not(.empty) { + position: relative; + padding: 8px; + background: var(--dnd5e-color-card); + border: 2px solid var(--dnd5e-color-gold); + border-radius: 4px; + box-shadow: 0 0 4px var(--dnd5e-shadow-45); + + .details { + gap: 4px; + input { height: unset; } + input::placeholder { opacity: .5; } + } + .list-controls { + flex: 0; + flex-wrap: nowrap; + justify-content: flex-end; + gap: inherit; + + button { + --size: 26px; + flex: 0 0 var(--size); + block-size: var(--size); + inline-size: var(--size); + } + } + } + } + /* ---------------------------------- */ /* Form Elements */ /* ---------------------------------- */ @@ -419,8 +473,6 @@ button { background: var(--dnd5e-background-card); - font-family: var(--dnd5e-font-roboto); - font-weight: bold; font-size: var(--font-size-13); text-transform: uppercase; padding: 3px; @@ -431,6 +483,10 @@ justify-content: center; gap: .25rem; + &:not(.fas, .far, .fa-solid, .fa-regular, .fa-light, .fa-duotone, .fa-thin) { + font-family: var(--dnd5e-font-roboto); + font-weight: bold; + } &:disabled { cursor: default; color: var(--color-text-dark-inactive); diff --git a/less/v2/chat.less b/less/v2/chat.less index 025fa319ac..48f9b8abdd 100644 --- a/less/v2/chat.less +++ b/less/v2/chat.less @@ -255,6 +255,9 @@ .dice-roll { cursor: pointer; + + .dice-flavor { display: none; } + &.expanded { .dice-total::after { transform: rotate(-90deg); } .dice-tooltip-collapser { grid-template-rows: 1fr; } @@ -340,8 +343,6 @@ > img { width: 32px; height: 32px; - object-fit: cover; - background-color: var(--dnd5e-color-light-gray); } .name-stacked { @@ -453,18 +454,6 @@ } } - &.collapsed { - label .fa-caret-down { transform: rotate(-90deg); } - .collapsible-content { grid-template-rows: 0fr; } - } - - .collapsible-content { - display: grid; - grid-template-rows: 1fr; - transition: grid-template-rows 250ms ease; - > .wrapper { overflow: hidden; } - } - .target-source-control { &:not([hidden]) { display: flex; } justify-content: space-evenly; @@ -527,9 +516,16 @@ .targets .target { flex-wrap: wrap; + .subtitle { + display: flex; + gap: 4px; + } + .change-source { - width: 16px; - padding: 2px; + width: 13px; + height: 13px; + --icon-size: 13px; + padding: 0; border-radius: 4px; display: grid; grid-template-areas: "overlay"; @@ -546,10 +542,14 @@ } } - .calculated-damage { + .calculated { padding-inline-end: 4px; font-size: var(--font-size-14); font-weight: bold; + color: var(--dnd5e-color-application-damage); + + &.healing { color: var(--dnd5e-color-application-healing); } + &.temp { color: var(--dnd5e-color-application-temp); } } .damage-multipliers { @@ -594,7 +594,7 @@ } } - .targets + button { + .wrapper > button { margin: 3px; width: calc(100% - 6px); margin-block-start: 6px; @@ -608,6 +608,53 @@ .visible-dc { display: contents; } } +/* ---------------------------------- */ +/* Enchantment */ +/* ---------------------------------- */ + +enchantment-application { + .drop-area { + display: grid; + gap: 4px; + min-block-size: 40px; + margin: 4px; + border: 1px dashed var(--dnd5e-color-crimson); + border-radius: 4px; + padding: 4px; + background: var(--dnd5e-color-card); + + p { + place-content: center; + text-align: center; + } + + .preview { + display: flex; + align-items: center; + gap: 6px; + + &:not(:last-child) { + border-block-end: 1px dotted var(--color-border-light-2); + padding-block-end: 4px; + } + + > img { + block-size: 32px; + inline-size: 32px; + flex: 0 0 32px; + } + > .name { + flex: 1; + font-family: var(--dnd5e-font-roboto-slab); + font-weight: bold; + } + > a { + flex: 0 0 20px; + } + } + } +} + /* ---------------------------------- */ /* Target Evaluations */ /* ---------------------------------- */ diff --git a/less/v2/dark/apps.less b/less/v2/dark/apps.less index 3bead7c2f4..c682274b8a 100644 --- a/less/v2/dark/apps.less +++ b/less/v2/dark/apps.less @@ -1,4 +1,4 @@ -.dnd5e-theme-dark .dnd5e2, +:is(.dnd5e-theme-dark, .theme-dark) .dnd5e2, .dnd5e2.dnd5e-theme-dark { &.sheet { /* TODO: Move these out once dark themes have been ported to all apps, not just the character sheet. */ @@ -94,7 +94,7 @@ /* Custom Elements */ /* ---------------------------------- */ -.dnd5e-theme-dark item-list-controls search { +:is(.dnd5e-theme-dark, .theme-dark) item-list-controls search { .filter-list { border: 1px solid var(--dnd5e-color-blue-gray-1); @@ -112,8 +112,9 @@ } } -.dnd5e-theme-dark .effects-element { - .conditions-list .condition { +:is(.dnd5e-theme-dark, .theme-dark) .dnd5e2.sheet.actor.character .effects-element { + .conditions-list .condition.content-link { + background: unset; border-color: var(--dnd5e-color-blue-gray-3); &:hover { background: var(--dnd5e-color-blue-gray-3); } } diff --git a/less/v2/dark/character.less b/less/v2/dark/character.less index 8a6cd1700c..0274c60d9f 100644 --- a/less/v2/dark/character.less +++ b/less/v2/dark/character.less @@ -1,4 +1,4 @@ -.dnd5e-theme-dark .dnd5e2.sheet.actor.character, +:is(.dnd5e-theme-dark, .theme-dark) .dnd5e2.sheet.actor.character, .dnd5e2.sheet.actor.character.dnd5e-theme-dark { &:not(.minimized) { background: none; } @@ -56,4 +56,9 @@ .label { font-weight: bold; } .mod { color: var(--dnd5e-color-blue-white); } } + + .content-link, .roll-link a, .reference-link a, .inline-roll, .award-link { + background: var(--dnd5e-color-dark-gray); + border-color: var(--dnd5e-border-dark) + } } diff --git a/less/v2/dark/inventory.less b/less/v2/dark/inventory.less index 9298414e5c..4ae4340598 100644 --- a/less/v2/dark/inventory.less +++ b/less/v2/dark/inventory.less @@ -1,4 +1,4 @@ -.dnd5e-theme-dark .dnd5e2, +:is(.dnd5e-theme-dark, .theme-dark) .dnd5e2, .dnd5e2.dnd5e-theme-dark { .items-section { border: none; diff --git a/less/v2/high-contrast/apps.less b/less/v2/high-contrast/apps.less index 7b5b58ad6d..0f7952f018 100644 --- a/less/v2/high-contrast/apps.less +++ b/less/v2/high-contrast/apps.less @@ -42,7 +42,7 @@ /* Light Mode */ /* ---------------------------------- */ -.dnd5e-flag-high-contrast.dnd5e-theme-light .dnd5e2, +.dnd5e-flag-high-contrast:is(.dnd5e-theme-light, .theme-light) .dnd5e2, .dnd5e2.dnd5e-flag-high-contrast.dnd5e-theme-light { &.sheet { --filigree-background-color: #ffffff; @@ -64,7 +64,7 @@ /* Dark Mode */ /* ---------------------------------- */ -.dnd5e-flag-high-contrast.dnd5e-theme-dark .dnd5e2, +.dnd5e-flag-high-contrast:is(.dnd5e-theme-dark, .theme-dark) .dnd5e2, .dnd5e2.dnd5e-flag-high-contrast.dnd5e-theme-dark { &.sheet { --color-text-dark-primary: #fff; @@ -83,6 +83,6 @@ .card { border: var(--dnd5e-border-dark); } } -.dnd5e-flag-high-contrast.dnd5e-theme-dark item-list-controls search { +.dnd5e-flag-high-contrast:is(.dnd5e-theme-dark, .theme-dark) item-list-controls search { .filter-control:not(.active) { color: var(--color-text-light-6); } } diff --git a/less/v2/high-contrast/character.less b/less/v2/high-contrast/character.less index 56f1b7df9e..b9dfe1508f 100644 --- a/less/v2/high-contrast/character.less +++ b/less/v2/high-contrast/character.less @@ -38,7 +38,7 @@ /* Light Mode */ /* ---------------------------------- */ -.dnd5e-flag-high-contrast.dnd5e-theme-light .dnd5e2.sheet.actor.character, +.dnd5e-flag-high-contrast:is(.dnd5e-theme-light, .theme-dark) .dnd5e2.sheet.actor.character, .dnd5e2.sheet.actor.character.dnd5e-flag-high-contrast.dnd5e-theme-light { .sheet-header { background: var(--dnd5e-color-maroon); } .sidebar .favorites > h3 { color: var(--dnd5e-color-dark); } @@ -49,7 +49,7 @@ /* Dark Mode */ /* ---------------------------------- */ -.dnd5e-flag-high-contrast.dnd5e-theme-dark .dnd5e2.sheet.actor.character, +.dnd5e-flag-high-contrast:is(.dnd5e-theme-dark, .theme-dark) .dnd5e2.sheet.actor.character, .dnd5e2.sheet.actor.character.dnd5e-flag-high-contrast.dnd5e-theme-dark { .sheet-header { background: none; } .sidebar .favorites > h3 { color: var(--dnd5e-color-light); } diff --git a/less/v2/high-contrast/inventory.less b/less/v2/high-contrast/inventory.less index 892aa8c509..a2c0ad4c7f 100644 --- a/less/v2/high-contrast/inventory.less +++ b/less/v2/high-contrast/inventory.less @@ -13,7 +13,7 @@ /* Light Mode */ /* ---------------------------------- */ -.dnd5e-flag-high-contrast.dnd5e-theme-light .dnd5e2, +.dnd5e-flag-high-contrast:is(.dnd5e-theme-light, .theme-light) .dnd5e2, .dnd5e2.dnd5e-flag-high-contrast.dnd5e-theme-light { .inventory-element { .encumbrance .meter { border-color: var(--dnd5e-color-dark); } @@ -33,7 +33,7 @@ /* Dark Mode */ /* ---------------------------------- */ -.dnd5e-flag-high-contrast.dnd5e-theme-dark .dnd5e2, +.dnd5e-flag-high-contrast:is(.dnd5e-theme-dark, .theme-dark) .dnd5e2, .dnd5e2.dnd5e-flag-high-contrast.dnd5e-theme-dark { .inventory-element { .middle .attunement { diff --git a/less/v2/inventory.less b/less/v2/inventory.less index b0e24a838b..32f42fedac 100644 --- a/less/v2/inventory.less +++ b/less/v2/inventory.less @@ -155,7 +155,7 @@ width: 22px; text-align: left; font-weight: bold; - background: none; + background: color-mix(in oklab, var(--dnd5e-color-card), black 7%); border: none; padding: 0; transition: box-shadow 250ms ease; @@ -342,6 +342,7 @@ } input { text-align: end; } } + .spell-uses { display: none; } /* Item Recovery */ .item-recovery { width: 60px; } @@ -410,17 +411,6 @@ /* Effect Duration */ .effect-name { .name { flex: 1; } - .duration { - padding: .25rem; - border: 1px dashed var(--color-border-light-1); - border-radius: 4px; - font-family: var(--dnd5e-font-roboto-condensed); - text-transform: uppercase; - font-size: var(--font-size-11); - color: var(--color-text-dark-5); - - .separator, .least-significant { color: var(--color-text-light-6); } - } } /* Effect Source */ @@ -470,10 +460,12 @@ padding-right: .75rem; justify-content: end; color: var(--color-text-light-0); + align-items: center; } @container (min-width: 600px) { - .item-price, .item-formula { display: flex; } + .item-price, .item-formula, .spell-uses { display: flex; } + .subtitle-uses { display: none; } } @container (min-width: 650px) { @@ -500,6 +492,18 @@ &:last-child { border: none; } } } + + .effect-name .duration, &.effect-tooltip .duration { + padding: .25rem; + border: 1px dashed var(--color-border-light-1); + border-radius: 4px; + font-family: var(--dnd5e-font-roboto-condensed); + text-transform: uppercase; + font-size: var(--font-size-11); + color: var(--color-text-dark-5); + + .separator, .least-significant { color: var(--color-text-light-6); } + } } /* ---------------------------------- */ diff --git a/less/v2/journal.less b/less/v2/journal.less index 68376f755b..2448f14c4b 100644 --- a/less/v2/journal.less +++ b/less/v2/journal.less @@ -2,13 +2,13 @@ /* Journal v2 Styles */ /* ---------------------------------- */ -.sheet.journal-entry.dnd5e2-journal { +.sheet.dnd5e2-journal { .window-resizable-handle { bottom: 1px; right: 1px; } - .scrollable { + &.journal-entry .scrollable, &.journal-entry-page.editor-content { scrollbar-width: thin; scrollbar-color: var(--dnd5e-color-gold) transparent; @@ -24,7 +24,7 @@ } /* Background Texture & Headings */ - .journal-entry-content { + &.journal-entry .journal-entry-content, &.journal-entry-page .window-content { background: url("ui/texture1.webp") no-repeat top center / auto 770px, var(--dnd5e-color-parchment) url("ui/texture2.webp") no-repeat bottom center / auto 704px; @@ -46,7 +46,7 @@ } /* Page Title */ - .journal-header .title { + &.journal-entry .journal-header .title { border: 1px transparent; transition: all 250ms ease; font-size: var(--font-size-46); @@ -66,7 +66,7 @@ } /* Edit Button */ - .edit-container .editor-edit { + &.journal-entry .edit-container .editor-edit { background: transparent; border: none; box-shadow: none; diff --git a/less/variables.less b/less/variables.less index 127825d062..5a3382fb8d 100644 --- a/less/variables.less +++ b/less/variables.less @@ -58,6 +58,9 @@ --dnd5e-color-failure: #6e0000; --dnd5e-color-failure-background: #ffdddd; --dnd5e-color-failure-critical: red; + --dnd5e-color-application-damage: #9c5b47; + --dnd5e-color-application-healing: #3c7f58; + --dnd5e-color-application-temp: #007F7F; --dnd5e-background-10: rgb(0 0 0 / 10%); --dnd5e-background-5: rgb(0 0 0 / 5%); --dnd5e-background-card: var(--dnd5e-color-card); diff --git a/module/applications/actor/base-config.mjs b/module/applications/actor/base-config.mjs index f5f397e715..28cc2b4466 100644 --- a/module/applications/actor/base-config.mjs +++ b/module/applications/actor/base-config.mjs @@ -1,3 +1,5 @@ +import ActiveEffect5e from "../../documents/active-effect.mjs"; + /** * An abstract class containing common functionality between actor sheet configuration apps. * @extends {DocumentSheet} @@ -48,9 +50,6 @@ export default class BaseConfigSheet extends DocumentSheet { * @internal */ _addOverriddenChoices(prefix, path, overrides) { - const source = new Set(foundry.utils.getProperty(this.document._source, path) ?? []); - const current = foundry.utils.getProperty(this.document, path) ?? new Set(); - const delta = current.symmetricDifference(source); - for ( const choice of delta ) overrides.push(`${prefix}.${choice}`); + ActiveEffect5e.addOverriddenChoices(this.document, prefix, path, overrides); } } diff --git a/module/applications/actor/base-sheet.mjs b/module/applications/actor/base-sheet.mjs index a324e522a6..061c6fa019 100644 --- a/module/applications/actor/base-sheet.mjs +++ b/module/applications/actor/base-sheet.mjs @@ -168,14 +168,20 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { } // Skills & tools. + const baseAbility = (prop, key) => { + let src = source.system[prop]?.[key]?.ability; + if ( src ) return src; + if ( prop === "skills" ) src = CONFIG.DND5E.skills[key]?.ability; + return src ?? "int"; + }; ["skills", "tools"].forEach(prop => { for ( const [key, entry] of Object.entries(context[prop]) ) { entry.abbreviation = CONFIG.DND5E.abilities[entry.ability]?.abbreviation; entry.icon = this._getProficiencyIcon(entry.value); entry.hover = CONFIG.DND5E.proficiencyLevels[entry.value]; - entry.label = prop === "skills" ? CONFIG.DND5E.skills[key]?.label : Trait.keyLabel(key, {trait: "tool"}); + entry.label = (prop === "skills") ? CONFIG.DND5E.skills[key]?.label : Trait.keyLabel(key, {trait: "tool"}); entry.baseValue = source.system[prop]?.[key]?.value ?? 0; - entry.baseAbility = source.system[prop]?.[key]?.ability ?? "int"; + entry.baseAbility = baseAbility(prop, key); } }); @@ -390,17 +396,17 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { if ( Number.isNumeric(order) ) acc[k] = Number(order); return acc; }, {}); - const useLabels = {"-20": "-", "-10": "-", 0: "∞"}; + const useLabels = {"-30": "-", "-20": "-", "-10": "-", 0: "∞"}; // Format a spellbook entry for a certain indexed level - const registerSection = (sl, i, label, {prepMode="prepared", value, max, override}={}) => { + const registerSection = (sl, i, label, {prepMode="prepared", value, max, override, config}={}) => { const aeOverride = foundry.utils.hasProperty(this.actor.overrides, `system.spells.spell${i}.override`); spellbook[i] = { order: i, label: label, usesSlots: i > 0, canCreate: owner, - canPrepare: (context.actor.type === "character") && (i >= 1), + canPrepare: ((context.actor.type === "character") && (i >= 1)) || config?.prepares, spells: [], uses: useLabels[i] || value || 0, slots: useLabels[i] || max || 0, @@ -427,20 +433,20 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { } } - // Pact magic users have cantrips and a pact magic section + // Create spellbook sections for all alternative spell preparation modes that have spell slots. for ( const [k, v] of Object.entries(CONFIG.DND5E.spellPreparationModes) ) { if ( !(k in levels) || !v.upcast || !levels[k].max ) continue; if ( !spellbook["0"] && v.cantrips ) registerSection("spell0", 0, CONFIG.DND5E.spellLevels[0]); const l = levels[k]; - const config = CONFIG.DND5E.spellPreparationModes[k]; const level = game.i18n.localize(`DND5E.SpellLevel${l.level}`); - const label = `${config.label} — ${level}`; + const label = `${v.label} — ${level}`; registerSection(k, sections[k], label, { prepMode: k, value: l.value, max: l.max, - override: l.override + override: l.override, + config: v }); } @@ -460,7 +466,8 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { prepMode: mode, value: l.value, max: l.max, - override: l.override + override: l.override, + config: config }); } } @@ -984,7 +991,7 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { if ( (itemData.type === "spell") && (this._tabs[0].active === "inventory" || this.actor.type === "vehicle") ) { const scroll = await Item5e.createScrollFromSpell(itemData); - return scroll.toObject(); + return scroll?.toObject?.(); } // Clean up data @@ -1029,10 +1036,7 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { */ _onDropResetData(itemData) { if ( !itemData.system ) return; - ["equipped", "proficient", "prepared"].forEach(k => delete itemData.system[k]); - if ( "attunement" in itemData.system ) { - itemData.system.attunement = Math.min(itemData.system.attunement, CONFIG.DND5E.attunementTypes.REQUIRED); - } + ["attuned", "equipped", "proficient", "prepared"].forEach(k => delete itemData.system[k]); } /* -------------------------------------------- */ @@ -1053,36 +1057,41 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { const { level, preparationMode } = header?.closest("[data-level]")?.dataset ?? {}; // Determine the actor's spell slot progressions, if any. + const spellcastKeys = Object.keys(CONFIG.DND5E.spellcastingTypes); const progs = Object.values(this.document.classes).reduce((acc, cls) => { - if ( cls.spellcasting?.type === "pact" ) acc.pact = true; - else if ( cls.spellcasting?.type === "leveled" ) acc.leveled = true; + const type = cls.spellcasting?.type; + if ( spellcastKeys.includes(type) ) acc.add(type); return acc; - }, {pact: false, leveled: false}); + }, new Set()); + + const prep = itemData.system.preparation; // Case 1: Drop a cantrip. if ( itemData.system.level === 0 ) { - if ( ["pact", "prepared"].includes(preparationMode) ) { - itemData.system.preparation.mode = "prepared"; + const modes = CONFIG.DND5E.spellPreparationModes; + if ( modes[preparationMode]?.cantrips ) { + prep.mode = "prepared"; } else if ( !preparationMode ) { - const isCaster = this.document.system.details.spellLevel || progs.pact || progs.leveled; - itemData.system.preparation.mode = isCaster ? "prepared" : "innate"; + const isCaster = this.document.system.details.spellLevel || progs.size; + prep.mode = isCaster ? "prepared" : "innate"; } else { - itemData.system.preparation.mode = preparationMode; + prep.mode = preparationMode; } - if ( itemData.system.preparation.mode === "prepared" ) itemData.system.preparation.prepared = true; + if ( modes[prep.mode]?.prepares ) prep.prepared = true; } // Case 2: Drop a leveled spell in a section without a mode. else if ( (level === "0") || !preparationMode ) { if ( this.document.type === "npc" ) { - itemData.system.preparation.mode = this.document.system.details.spellLevel ? "prepared" : "innate"; + prep.mode = this.document.system.details.spellLevel ? "prepared" : "innate"; } else { - itemData.system.preparation.mode = progs.leveled ? "prepared" : progs.pact ? "pact" : "innate"; + const m = progs.has("leveled") ? "prepared" : (progs.first() ?? "innate"); + prep.mode = progs.has(prep.mode) ? prep.mode : m; } } // Case 3: Drop a leveled spell in a specific section. - else itemData.system.preparation.mode = preparationMode; + else prep.mode = preparationMode; } /* -------------------------------------------- */ diff --git a/module/applications/actor/character-sheet-2.mjs b/module/applications/actor/character-sheet-2.mjs index 666f0787ca..fb0f42156b 100644 --- a/module/applications/actor/character-sheet-2.mjs +++ b/module/applications/actor/character-sheet-2.mjs @@ -1,6 +1,5 @@ import CharacterData from "../../data/actor/character.mjs"; import * as Trait from "../../documents/actor/trait.mjs"; -import { setTheme } from "../../settings.mjs"; import { formatNumber, simplifyBonus, staticID } from "../../utils.mjs"; import ContextMenu5e from "../context-menu.mjs"; import SheetConfig5e from "../sheet-config.mjs"; @@ -245,8 +244,7 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { }, { value: 0, label: CONFIG.DND5E.movementTypes.walk }); // Hit Dice - context.hd = { value: attributes.hd, max: this.actor.system.details.level }; - context.hd.pct = Math.clamped(context.hd.max ? (context.hd.value / context.hd.max) * 100 : 0, 0, 100); + context.hd = attributes.hd; // Death Saves const plurals = new Intl.PluralRules(game.i18n.lang, { type: "ordinal" }); @@ -345,9 +343,6 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { }); if ( foundry.utils.isEmpty(context.senses) ) delete context.senses; - // Inventory - this._prepareItems(context); - // Spellcasting context.spellcasting = []; const msak = simplifyBonus(this.actor.system.bonuses.msak.attack, context.rollData); @@ -485,7 +480,7 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { const total = simplifyBonus(v, rollData); if ( !total ) return null; const value = { - label: `${CONFIG.DND5E.damageTypes[k]?.label ?? key} ${formatNumber(total, { signDisplay: "always" })}`, + label: `${CONFIG.DND5E.damageTypes[k]?.label ?? k} ${formatNumber(total, { signDisplay: "always" })}`, color: total > 0 ? "maroon" : "green" }; const icons = value.icons = []; @@ -599,7 +594,8 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { // Prepared const mode = system.preparation?.mode; - if ( (mode === "always") || (mode === "prepared") ) { + const config = CONFIG.DND5E.spellPreparationModes[mode] ?? {}; + if ( config.prepares ) { const isAlways = mode === "always"; const prepared = isAlways || system.preparation.prepared; ctx.preparation = { @@ -754,16 +750,18 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { requestAnimationFrame(() => game.tooltip.deactivate()); game.tooltip.deactivate(); + const modes = CONFIG.DND5E.spellPreparationModes; + const { key } = event.target.closest("[data-key]")?.dataset ?? {}; const { level, preparationMode } = event.target.closest("[data-level]")?.dataset ?? {}; const isSlots = event.target.closest("[data-favorite-id]") || event.target.classList.contains("spell-header"); let type; if ( key in CONFIG.DND5E.skills ) type = "skill"; else if ( key in CONFIG.DND5E.toolIds ) type = "tool"; - else if ( preparationMode && (level !== "0") && isSlots ) type = "slots"; + else if ( modes[preparationMode]?.upcast && (level !== "0") && isSlots ) type = "slots"; if ( !type ) return super._onDragStart(event); const dragData = { dnd5e: { action: "favorite", type } }; - if ( type === "slots" ) dragData.dnd5e.id = preparationMode === "pact" ? "pact" : `spell${level}`; + if ( type === "slots" ) dragData.dnd5e.id = (preparationMode === "prepared") ? `spell${level}` : preparationMode; else dragData.dnd5e.id = key; event.dataTransfer.setData("application/json", JSON.stringify(dragData)); } @@ -967,9 +965,9 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { */ _onFindItem(type) { switch ( type ) { - case "class": game.packs.get("dnd5e.classes").render(true); break; - case "race": game.packs.get("dnd5e.races").render(true); break; - case "background": game.packs.get("dnd5e.backgrounds").render(true); break; + case "class": game.packs.get(CONFIG.DND5E.sourcePacks.CLASSES)?.render(true); break; + case "race": game.packs.get(CONFIG.DND5E.sourcePacks.RACES)?.render(true); break; + case "background": game.packs.get(CONFIG.DND5E.sourcePacks.BACKGROUNDS)?.render(true); break; } } @@ -1004,11 +1002,15 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { */ _applyItemTooltips(element) { if ( "tooltip" in element.dataset ) return; - const target = element.closest("[data-item-id], [data-uuid]"); + const target = element.closest("[data-item-id], [data-effect-id], [data-uuid]"); let uuid = target.dataset.uuid; - if ( !uuid ) { + if ( !uuid && target.dataset.itemId ) { const item = this.actor.items.get(target.dataset.itemId); uuid = item?.uuid; + } else if ( !uuid && target.dataset.effectId ) { + const { effectId, parentId } = target.dataset; + const collection = parentId ? this.actor.items.get(parentId).effects : this.actor.effects; + uuid = collection.get(effectId)?.uuid; } if ( !uuid ) return; element.dataset.tooltip = ` @@ -1212,11 +1214,12 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { // Legacy resources const resources = Object.entries(this.actor.system.resources).reduce((arr, [k, r]) => { const { value, max, sr, lr, label } = r; + const source = this.actor._source.system.resources[k]; if ( label && max ) arr.push({ id: `resources.${k}`, type: "resource", img: "icons/svg/upgrade.svg", - resource: { value, max }, + resource: { value, max, source }, css: "uses", title: label, subtitle: [ @@ -1270,7 +1273,7 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { itemId: type === "item" ? favorite.id : null, effectId: type === "effect" ? favorite.id : null, parentId: (type === "effect") && (favorite.parent !== favorite.target) ? favorite.parent.id: null, - preparationMode: type === "slots" ? id === "pact" ? "pact" : "prepared" : null, + preparationMode: (type === "slots") ? (/spell\d+/.test(id) ? "prepared" : id) : null, key: (type === "skill") || (type === "tool") ? id : null, toggle: toggle === undefined ? null : { applicable: true, value: toggle }, quantity: quantity > 1 ? quantity : "", @@ -1297,19 +1300,23 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { if ( type === "slots" ) { const { value, max, level } = this.actor.system.spells[id] ?? {}; const uses = { value, max, name: `system.spells.${id}.value` }; - if ( id === "pact" ) return { + if ( !/spell\d+/.test(id) ) return { uses, level, - title: game.i18n.localize("DND5E.SpellSlotsPact"), - subtitle: [game.i18n.localize(`DND5E.SpellLevel${level}`), game.i18n.localize("DND5E.AbbreviationSR")], - img: "icons/magic/unholy/silhouette-robe-evil-power.webp" + title: game.i18n.localize(`DND5E.SpellSlots${id.capitalize()}`), + subtitle: [ + game.i18n.localize(`DND5E.SpellLevel${level}`), + game.i18n.localize(`DND5E.Abbreviation${CONFIG.DND5E.spellcastingTypes[id]?.shortRest ? "SR" : "LR"}`) + ], + img: CONFIG.DND5E.spellcastingTypes[id]?.img || CONFIG.DND5E.spellcastingTypes.pact.img }; const plurals = new Intl.PluralRules(game.i18n.lang, { type: "ordinal" }); + const isSR = CONFIG.DND5E.spellcastingTypes.leveled.shortRest; return { uses, level, title: game.i18n.format(`DND5E.SpellSlotsN.${plurals.select(level)}`, { n: level }), - subtitle: game.i18n.localize("DND5E.AbbreviationLR"), - img: `systems/dnd5e/icons/spell-tiers/${id}.webp` + subtitle: game.i18n.localize(`DND5E.Abbreviation${isSR ? "SR" : "LR"}`), + img: CONFIG.DND5E.spellcastingTypes.leveled.img.replace("{id}", id) }; } diff --git a/module/applications/actor/character-sheet.mjs b/module/applications/actor/character-sheet.mjs index 3ed7624eb8..590b2d3283 100644 --- a/module/applications/actor/character-sheet.mjs +++ b/module/applications/actor/character-sheet.mjs @@ -54,7 +54,10 @@ export default class ActorSheet5eCharacter extends ActorSheet5e { // Categorize items as inventory, spellbook, features, and classes const inventory = {}; - for ( const type of ["weapon", "equipment", "consumable", "tool", "container", "loot"] ) { + const inventoryTypes = Object.entries(CONFIG.Item.dataModels) + .filter(([, model]) => model.metadata?.inventoryItem) + .sort(([, lhs], [, rhs]) => (lhs.metadata.inventoryOrder - rhs.metadata.inventoryOrder)); + for ( const [type] of inventoryTypes ) { inventory[type] = {label: `${CONFIG.Item.typeLabels[type]}Pl`, items: [], dataset: {type}}; } @@ -65,18 +68,15 @@ export default class ActorSheet5eCharacter extends ActorSheet5e { // Item details const ctx = context.itemContext[item.id] ??= {}; ctx.isStack = Number.isNumeric(quantity) && (quantity !== 1); - ctx.attunement = { - [CONFIG.DND5E.attunementTypes.REQUIRED]: { - icon: "fa-sun", - cls: "not-attuned", - title: "DND5E.AttunementRequired" - }, - [CONFIG.DND5E.attunementTypes.ATTUNED]: { - icon: "fa-sun", - cls: "attuned", - title: "DND5E.AttunementAttuned" - } - }[item.system.attunement]; + if ( item.system.attunement ) ctx.attunement = item.system.attuned ? { + icon: "fa-sun", + cls: "attuned", + title: "DND5E.AttunementAttuned" + } : { + icon: "fa-sun", + cls: "not-attuned", + title: CONFIG.DND5E.attunementTypes[item.system.attunement] + }; // Prepare data needed to display expanded sections ctx.isExpanded = this._expanded.has(item.id); diff --git a/module/applications/actor/group-sheet.mjs b/module/applications/actor/group-sheet.mjs index 5d91fa94d7..8990af5c39 100644 --- a/module/applications/actor/group-sheet.mjs +++ b/module/applications/actor/group-sheet.mjs @@ -142,7 +142,7 @@ export default class GroupActorSheet extends ActorSheetMixin(ActorSheet) { const displayXP = !game.settings.get("dnd5e", "disableExperienceTracking"); for ( const [index, memberData] of this.object.system.members.entries() ) { const member = memberData.actor; - const multiplier = type === "encounter" ? memberData.quantity.value : 1; + const multiplier = type === "encounter" ? (memberData.quantity.value ?? 1) : 1; const m = { index, @@ -159,7 +159,7 @@ export default class GroupActorSheet extends ActorSheetMixin(ActorSheet) { const hp = member.system.attributes.hp; m.hp.current = hp.value + (hp.temp || 0); m.hp.max = Math.max(0, hp.effectiveMax); - m.hp.pct = Math.clamped((m.hp.current / m.hp.max) * 100, 0, 100).toFixed(2); + m.hp.pct = Math.clamp((m.hp.current / m.hp.max) * 100, 0, 100).toFixed(2); m.hp.color = dnd5e.documents.Actor5e.getHPColor(m.hp.current, m.hp.max).css; stats.currentHP += (m.hp.current * multiplier); stats.maxHP += (m.hp.max * multiplier); @@ -170,8 +170,8 @@ export default class GroupActorSheet extends ActorSheetMixin(ActorSheet) { if ( displayXP ) m.xp = formatNumber(member.system.details.xp.value * multiplier); } - if ( member.type === "vehicle" ) stats.nVehicles++; - else stats.nMembers++; + if ( member.type === "vehicle" ) stats.nVehicles += multiplier; + else stats.nMembers += multiplier; sections[member.type].members.push(m); } for ( const [k, section] of Object.entries(sections) ) { @@ -295,22 +295,25 @@ export default class GroupActorSheet extends ActorSheetMixin(ActorSheet) { const award = new Award(this.object, { savedDestinations: this.actor.getFlag("dnd5e", "awardDestinations") }); award.render(true); break; - case "removeMember": - const removeMemberId = button.closest("li.group-member").dataset.actorId; - this.object.system.removeMember(removeMemberId); - break; case "longRest": - this.object.longRest({ advanceTime: true }); + this.actor.longRest({ advanceTime: true }); break; case "movementConfig": const movementConfig = new ActorMovementConfig(this.object); movementConfig.render(true); break; + case "placeMembers": + this.actor.system.placeMembers(); + break; + case "removeMember": + const removeMemberId = button.closest("li.group-member").dataset.actorId; + this.actor.system.removeMember(removeMemberId); + break; case "rollQuantities": - this.object.system.rollQuantities(); + this.actor.system.rollQuantities(); break; case "shortRest": - this.object.shortRest({ advanceTime: true }); + this.actor.shortRest({ advanceTime: true }); break; } } @@ -410,7 +413,7 @@ export default class GroupActorSheet extends ActorSheetMixin(ActorSheet) { // Create a Consumable spell scroll on the Inventory tab if ( itemData.type === "spell" ) { const scroll = await Item5e.createScrollFromSpell(itemData); - return scroll.toObject(); + return scroll?.toObject?.(); } // Stack identical consumables diff --git a/module/applications/actor/hit-dice-config.mjs b/module/applications/actor/hit-dice-config.mjs index 408a25122a..e046dcd16b 100644 --- a/module/applications/actor/hit-dice-config.mjs +++ b/module/applications/actor/hit-dice-config.mjs @@ -26,20 +26,18 @@ export default class ActorHitDiceConfig extends BaseConfigSheet { /** @inheritDoc */ getData(options) { + const classes = this.object.system.attributes.hd.classes; return { - classes: this.object.items.reduce((classes, item) => { - if (item.type === "class") { - classes.push({ - classItemId: item.id, - name: item.name, - diceDenom: item.system.hitDice, - currentHitDice: item.system.levels - item.system.hitDiceUsed, - maxHitDice: item.system.levels, - canRoll: (item.system.levels - item.system.hitDiceUsed) > 0 - }); - } - return classes; - }, []).sort((a, b) => parseInt(b.diceDenom.slice(1)) - parseInt(a.diceDenom.slice(1))) + classes: Array.from(classes).map(item => { + return { + classItemId: item.id, + name: item.name, + diceDenom: item.system.hitDice, + currentHitDice: item.system.levels - item.system.hitDiceUsed, + maxHitDice: item.system.levels, + canRoll: (item.system.levels - item.system.hitDiceUsed) > 0 + }; + }).sort((a, b) => parseInt(b.diceDenom.slice(1)) - parseInt(a.diceDenom.slice(1))) }; } @@ -50,12 +48,12 @@ export default class ActorHitDiceConfig extends BaseConfigSheet { super.activateListeners(html); // Hook up -/+ buttons to adjust the current value in the form - html.find("button.increment,button.decrement").click(event => { + html.find("button.increment, button.decrement").click(event => { const button = event.currentTarget; const current = button.parentElement.querySelector(".current"); const max = button.parentElement.querySelector(".max"); const direction = button.classList.contains("increment") ? 1 : -1; - current.value = Math.clamped(parseInt(current.value) + direction, 0, parseInt(max.value)); + current.value = Math.clamp(parseInt(current.value) + direction, 0, parseInt(max.value)); }); html.find("button.roll-hd").click(this._onRollHitDie.bind(this)); diff --git a/module/applications/actor/npc-sheet.mjs b/module/applications/actor/npc-sheet.mjs index 177ca0993a..1b91249d82 100644 --- a/module/applications/actor/npc-sheet.mjs +++ b/module/applications/actor/npc-sheet.mjs @@ -26,6 +26,9 @@ export default class ActorSheet5eNPC extends ActorSheet5e { const cr = parseFloat(context.system.details.cr ?? 0); const crLabels = {0: "0", 0.125: "1/8", 0.25: "1/4", 0.5: "1/2"}; + // Class Spellcasting + context.classSpellcasting = Object.values(this.actor.classes).some(c => c.spellcasting?.levels); + return foundry.utils.mergeObject(context, { labels: { cr: cr >= 1 ? String(cr) : crLabels[cr] ?? 1, diff --git a/module/applications/actor/short-rest.mjs b/module/applications/actor/short-rest.mjs index fb3e27724f..7eacff06bc 100644 --- a/module/applications/actor/short-rest.mjs +++ b/module/applications/actor/short-rest.mjs @@ -28,7 +28,8 @@ export default class ShortRestDialog extends Dialog { static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { template: "systems/dnd5e/templates/apps/short-rest.hbs", - classes: ["dnd5e", "dialog"] + classes: ["dnd5e", "dialog"], + height: "auto" }); } @@ -39,19 +40,20 @@ export default class ShortRestDialog extends Dialog { const context = super.getData(); context.isGroup = this.actor.type === "group"; - if ( foundry.utils.hasProperty(this.actor, "system.attributes.hd") ) { + if ( this.actor.type === "npc" ) { + const hd = this.actor.system.attributes.hd; + context.availableHD = { [`d${hd.denomination}`]: hd.value }; + context.canRoll = hd.value > 0; + context.denomination = `d${hd.denomination}`; + } + + else if ( foundry.utils.hasProperty(this.actor, "system.attributes.hd") ) { // Determine Hit Dice - context.availableHD = this.actor.items.reduce((hd, item) => { - if ( item.type === "class" ) { - const {levels, hitDice, hitDiceUsed} = item.system; - const denom = hitDice ?? "d6"; - const available = parseInt(levels ?? 1) - parseInt(hitDiceUsed ?? 0); - hd[denom] = denom in hd ? hd[denom] + available : available; - } - return hd; - }, {}); - context.canRoll = this.actor.system.attributes.hd > 0; - context.denomination = this._denom; + context.availableHD = this.actor.system.attributes.hd.bySize; + context.canRoll = this.actor.system.attributes.hd.value > 0; + + const dice = Object.entries(context.availableHD); + context.denomination = (context.availableHD[this._denom] > 0) ? this._denom : dice.find(([k, v]) => v > 0)?.[0]; } // Determine rest type diff --git a/module/applications/actor/vehicle-sheet.mjs b/module/applications/actor/vehicle-sheet.mjs index b843b38c4d..d7c54f5eb1 100644 --- a/module/applications/actor/vehicle-sheet.mjs +++ b/module/applications/actor/vehicle-sheet.mjs @@ -48,14 +48,9 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { : CONFIG.DND5E.encumbrance.currencyPerWeight.imperial; totalWeight += totalCoins / currencyPerWeight; - // Vehicle weights are an order of magnitude greater. - totalWeight /= game.settings.get("dnd5e", "metricWeightUnits") - ? CONFIG.DND5E.encumbrance.vehicleWeightMultiplier.metric - : CONFIG.DND5E.encumbrance.vehicleWeightMultiplier.imperial; - // Compute overall encumbrance const max = actorData.system.attributes.capacity.cargo; - const pct = Math.clamped((totalWeight * 100) / max, 0, 100); + const pct = Math.clamp((totalWeight * 100) / max, 0, 100); return {value: totalWeight.toNearest(0.1), max, pct}; } @@ -210,12 +205,15 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { }, { label: game.i18n.localize("DND5E.Weight"), css: "item-weight", - property: "system.weight", + property: "system.weight.value", editable: "Number" }] } }; + const baseUnits = CONFIG.DND5E.encumbrance.baseUnits[this.actor.type] ?? CONFIG.DND5E.encumbrance.baseUnits.default; + const units = game.settings.get("dnd5e", "metricWeightUnits") ? baseUnits.metric : baseUnits.imperial; + // Classify items owned by the vehicle and compute total cargo weight let totalWeight = 0; for ( const item of context.items ) { @@ -225,7 +223,7 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { // Handle cargo explicitly const isCargo = item.flags.dnd5e?.vehicleCargo === true; if ( isCargo ) { - totalWeight += item.system.totalWeight ?? 0; + totalWeight += item.system.totalWeightin?.(units) ?? 0; cargo.cargo.items.push(item); continue; } @@ -245,7 +243,7 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { else features.actions.items.push(item); break; default: - totalWeight += item.system.totalWeight ?? 0; + totalWeight += item.system.totalWeightIn?.(units) ?? 0; cargo.cargo.items.push(item); } } diff --git a/module/applications/advancement/ability-score-improvement-flow.mjs b/module/applications/advancement/ability-score-improvement-flow.mjs index c4035c4f24..7ffdda072d 100644 --- a/module/applications/advancement/ability-score-improvement-flow.mjs +++ b/module/applications/advancement/ability-score-improvement-flow.mjs @@ -113,7 +113,7 @@ export default class AbilityScoreImprovementFlow extends AdvancementFlow { if ( isNaN(input.valueAsNumber) ) this.assignments[key] = 0; else { this.assignments[key] = Math.min( - Math.clamped(input.valueAsNumber, Number(input.min), Number(input.max)) - Number(input.dataset.initial), + Math.clamp(input.valueAsNumber, Number(input.min), Number(input.max)) - Number(input.dataset.initial), this.advancement.configuration.cap ?? Infinity ); } @@ -203,6 +203,14 @@ export default class AbilityScoreImprovementFlow extends AdvancementFlow { return null; } + // If a feat has a level pre-requisite, make sure it is less than or equal to current character level + if ( (item.system.prerequisites?.level ?? -Infinity) > this.advancement.actor.system.details.level ) { + ui.notifications.error(game.i18n.format("DND5E.AdvancementAbilityScoreImprovementFeatLevelWarning", { + level: item.system.prerequisites.level + })); + return null; + } + this.feat = item; this.render(); } diff --git a/module/applications/advancement/advancement-config.mjs b/module/applications/advancement/advancement-config.mjs index 7a1150044f..d6e16b45ba 100644 --- a/module/applications/advancement/advancement-config.mjs +++ b/module/applications/advancement/advancement-config.mjs @@ -79,6 +79,7 @@ export default class AdvancementConfig extends FormApplication { if ( ["class", "subclass"].includes(this.item.type) ) delete levels[0]; else levels[0] = game.i18n.localize("DND5E.AdvancementLevelAnyHeader"); const context = { + appId: this.id, CONFIG: CONFIG.DND5E, ...this.advancement.toObject(false), src: this.advancement.toObject(), @@ -112,6 +113,10 @@ export default class AdvancementConfig extends FormApplication { // Remove an item from the list if ( this.options.dropKeyPath ) html.on("click", "[data-action='delete']", this._onItemDelete.bind(this)); + + for ( const element of html[0].querySelectorAll("multi-select") ) { + element.addEventListener("change", this._onChangeInput.bind(this)); + } } /* -------------------------------------------- */ @@ -142,7 +147,11 @@ export default class AdvancementConfig extends FormApplication { */ static _cleanedObject(object) { return Object.entries(object).reduce((obj, [key, value]) => { - if ( value ) obj[key] = value; + let keep = false; + if ( foundry.utils.getType(value) === "Object" ) { + keep = Object.values(value).some(v => v); + } else if ( value ) keep = true; + if ( keep ) obj[key] = value; else obj[`-=${key}`] = null; return obj; }, {}); diff --git a/module/applications/advancement/advancement-manager.mjs b/module/applications/advancement/advancement-manager.mjs index 825a28bcb6..1b3586a94a 100644 --- a/module/applications/advancement/advancement-manager.mjs +++ b/module/applications/advancement/advancement-manager.mjs @@ -330,9 +330,10 @@ export default class AdvancementManager extends Application { * @private */ createLevelChangeSteps(classItem, levelDelta) { + const raceItem = this.clone.system?.details?.race; const pushSteps = (flows, data) => this.steps.push(...flows.map(flow => ({ flow, ...data }))); const getItemFlows = characterLevel => this.clone.items.contents.flatMap(i => { - if ( ["class", "subclass"].includes(i.type) ) return []; + if ( ["class", "subclass", "race"].includes(i.type) ) return []; return this.constructor.flowsForLevel(i, characterLevel); }); @@ -341,6 +342,7 @@ export default class AdvancementManager extends Application { const classLevel = classItem.system.levels + offset; const characterLevel = (this.actor.system.details.level ?? 0) + offset; const stepData = { type: "forward", class: {item: classItem, level: classLevel} }; + pushSteps(this.constructor.flowsForLevel(raceItem, characterLevel), stepData); pushSteps(this.constructor.flowsForLevel(classItem, classLevel), stepData); pushSteps(this.constructor.flowsForLevel(classItem.subclass, classLevel), stepData); pushSteps(getItemFlows(characterLevel), stepData); @@ -354,6 +356,7 @@ export default class AdvancementManager extends Application { pushSteps(getItemFlows(characterLevel).reverse(), stepData); pushSteps(this.constructor.flowsForLevel(classItem.subclass, classLevel).reverse(), stepData); pushSteps(this.constructor.flowsForLevel(classItem, classLevel).reverse(), stepData); + pushSteps(this.constructor.flowsForLevel(raceItem, characterLevel).reverse(), stepData); if ( classLevel === 1 ) this.steps.push({ type: "delete", item: classItem, automatic: true }); } diff --git a/module/applications/advancement/item-choice-config.mjs b/module/applications/advancement/item-choice-config.mjs index 26354041b9..4e1694481e 100644 --- a/module/applications/advancement/item-choice-config.mjs +++ b/module/applications/advancement/item-choice-config.mjs @@ -5,24 +5,28 @@ import AdvancementConfig from "./advancement-config.mjs"; */ export default class ItemChoiceConfig extends AdvancementConfig { - /** @inheritdoc */ + /** @inheritDoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { - classes: ["dnd5e", "advancement", "item-choice", "two-column"], + classes: ["dnd5e", "advancement", "item-choice", "three-column"], dragDrop: [{ dropSelector: ".drop-target" }], dropKeyPath: "pool", template: "systems/dnd5e/templates/advancement/item-choice-config.hbs", - width: 540 + width: 780 }); } /* -------------------------------------------- */ - /** @inheritdoc */ + /** @inheritDoc */ getData(options={}) { const indexes = this.advancement.configuration.pool.map(i => fromUuidSync(i.uuid)); const context = { ...super.getData(options), + abilities: Object.entries(CONFIG.DND5E.abilities).reduce((obj, [k, c]) => { + obj[k] = { label: c.label, selected: this.advancement.configuration.spell?.ability.has(k) ? "selected" : "" }; + return obj; + }, {}), showContainerWarning: indexes.some(i => i?.type === "container"), showSpellConfig: this.advancement.configuration.type === "spell", validTypes: this.advancement.constructor.VALID_TYPES.reduce((obj, type) => { @@ -30,6 +34,10 @@ export default class ItemChoiceConfig extends AdvancementConfig { return obj; }, {}) }; + context.choices = Object.entries(context.levels).reduce((obj, [level, label]) => { + obj[level] = { label, ...this.advancement.configuration.choices[level] }; + return obj; + }, {}); if ( this.advancement.configuration.type === "feat" ) { const selectedType = CONFIG.DND5E.featureTypes[this.advancement.configuration.restriction.type]; context.typeRestriction = { @@ -44,9 +52,10 @@ export default class ItemChoiceConfig extends AdvancementConfig { /* -------------------------------------------- */ - /** @inheritdoc */ + /** @inheritDoc */ async prepareConfigurationUpdate(configuration) { if ( configuration.choices ) configuration.choices = this.constructor._cleanedObject(configuration.choices); + if ( configuration.spell ) configuration.spell.ability ??= []; // Ensure items are still valid if type restriction or spell restriction are changed const pool = []; @@ -62,7 +71,7 @@ export default class ItemChoiceConfig extends AdvancementConfig { /* -------------------------------------------- */ - /** @inheritdoc */ + /** @inheritDoc */ _validateDroppedItem(event, item) { this.advancement._validateItemType(item); } diff --git a/module/applications/advancement/item-choice-flow.mjs b/module/applications/advancement/item-choice-flow.mjs index 1c31533d68..41b868b39f 100644 --- a/module/applications/advancement/item-choice-flow.mjs +++ b/module/applications/advancement/item-choice-flow.mjs @@ -6,6 +6,12 @@ import ItemGrantFlow from "./item-grant-flow.mjs"; */ export default class ItemChoiceFlow extends ItemGrantFlow { + /** + * Currently selected ability. + * @type {string} + */ + ability; + /** * Set of selected UUIDs. * @type {Set} @@ -18,6 +24,12 @@ export default class ItemChoiceFlow extends ItemGrantFlow { */ pool; + /** + * UUID of item to be replaced. + * @type {string} + */ + replacement; + /** * List of dropped items. * @type {Item5e[]} @@ -36,12 +48,19 @@ export default class ItemChoiceFlow extends ItemGrantFlow { /* -------------------------------------------- */ + /** @inheritdoc */ + async retainData(data) { + await super.retainData(data); + this.replacement = data.replaced?.original; + this.selected = new Set(data.items.map(i => foundry.utils.getProperty(i, "flags.dnd5e.sourceId"))); + } + + /* -------------------------------------------- */ + /** @inheritdoc */ async getContext() { - this.selected ??= new Set( - this.retainedData?.items.map(i => foundry.utils.getProperty(i, "flags.dnd5e.sourceId")) - ?? Object.values(this.advancement.value[this.level] ?? {}) - ); + const context = {}; + this.selected ??= new Set(Object.values(this.advancement.value.added?.[this.level] ?? {})); this.pool ??= await Promise.all(this.advancement.configuration.pool.map(i => fromUuid(i.uuid))); if ( !this.dropped ) { this.dropped = []; @@ -54,25 +73,54 @@ export default class ItemChoiceFlow extends ItemGrantFlow { } } - const max = this.advancement.configuration.choices[this.level]; - const choices = { max, current: this.selected.size, full: this.selected.size >= max }; + const levelConfig = this.advancement.configuration.choices[this.level]; + let max = levelConfig.count ?? 0; + context.replaceable = levelConfig.replacement; + context.noReplacement = !this.advancement.actor.items.has(this.replacement); + if ( context.replaceable && !context.noReplacement ) max++; + if ( this.selected.size > max ) { + this.selected = new Set(Array.from(this.selected).slice(0, max)); + } + context.choices = { max, current: this.selected.size, full: this.selected.size >= max }; - const previousLevels = {}; + context.previousLevels = {}; const previouslySelected = new Set(); - for ( const [level, data] of Object.entries(this.advancement.value.added ?? {}) ) { - if ( level > this.level ) continue; - previousLevels[level] = await Promise.all(Object.values(data).map(uuid => fromUuid(uuid))); - Object.values(data).forEach(uuid => previouslySelected.add(uuid)); + for ( const level of Array.fromRange(this.level - 1, 1) ) { + const added = this.advancement.value.added[level]; + if ( added ) context.previousLevels[level] = Object.entries(added).map(([id, uuid]) => { + const item = fromUuidSync(uuid); + previouslySelected.add(uuid); + return { + ...item, id, uuid, + checked: id === this.replacement, + replaced: false + }; + }); + const replaced = this.advancement.value.replaced[level]; + if ( replaced ) { + const match = context.previousLevels[replaced.level].find(v => v.id === replaced.original); + if ( match ) { + match.replaced = true; + previouslySelected.delete(match.uuid); + } + } } - const items = [...this.pool, ...this.dropped].reduce((items, i) => { - i.checked = this.selected.has(i.uuid); - i.disabled = !i.checked && choices.full; - if ( !previouslySelected.has(i.uuid) ) items.push(i); + context.items = [...this.pool, ...this.dropped].reduce((items, i) => { + if ( i ) { + i.checked = this.selected.has(i.uuid); + i.disabled = !i.checked && context.choices.full; + const validLevel = (i.system.prerequisites?.level ?? -Infinity) <= this.level; + if ( !previouslySelected.has(i.uuid) && validLevel ) items.push(i); + } return items; }, []); - return { choices, items, previousLevels }; + context.abilities = this.getSelectAbilities(); + context.abilities.disabled = previouslySelected.size; + this.ability ??= context.abilities.selected; + + return context; } /* -------------------------------------------- */ @@ -87,8 +135,12 @@ export default class ItemChoiceFlow extends ItemGrantFlow { /** @inheritdoc */ _onChangeInput(event) { - if ( event.target.checked ) this.selected.add(event.target.name); - else this.selected.delete(event.target.name); + if ( event.target.type === "checkbox" ) { + if ( event.target.checked ) this.selected.add(event.target.name); + else this.selected.delete(event.target.name); + } + else if ( event.target.type === "radio" ) this.replacement = event.target.value; + else if ( event.target.name === "ability" ) this.ability = event.target.value; this.render(); } @@ -112,7 +164,7 @@ export default class ItemChoiceFlow extends ItemGrantFlow { /** @inheritdoc */ async _onDrop(event) { - if ( this.selected.size >= this.advancement.configuration.choices[this.level] ) return false; + if ( this.selected.size >= this.advancement.configuration.choices[this.level].count ) return false; // Try to extract the data let data; @@ -144,6 +196,14 @@ export default class ItemChoiceFlow extends ItemGrantFlow { } } + // If a feature has a level pre-requisite, make sure it is less than or equal to current level + if ( (item.system.prerequisites?.level ?? -Infinity) > this.level ) { + ui.notifications.error(game.i18n.format("DND5E.AdvancementItemChoiceFeatureLevelWarning", { + level: item.system.prerequisites.level + })); + return null; + } + // If spell level is restricted to available level, ensure the spell is of the appropriate level const spellLevel = this.advancement.configuration.restriction.level; if ( (this.advancement.configuration.type === "spell") && spellLevel === "available" ) { @@ -190,12 +250,9 @@ export default class ItemChoiceFlow extends ItemGrantFlow { // For all other items, use the largest slot possible else spells = this.advancement.actor.system.spells; - const largestSlot = Object.entries(spells).reduce((slot, [key, data]) => { - if ( data.max === 0 ) return slot; - const level = parseInt(key.replace("spell", "")); - if ( !Number.isNaN(level) && level > slot ) return level; - return slot; - }, -1); - return Math.max(spells.pact?.level ?? 0, largestSlot); + return Object.values(spells).reduce((slot, { max, level }) => { + if ( !max ) return slot; + return Math.max(slot, level || -1); + }, 0); } } diff --git a/module/applications/advancement/item-grant-config.mjs b/module/applications/advancement/item-grant-config.mjs index 018188446c..3b008bba17 100644 --- a/module/applications/advancement/item-grant-config.mjs +++ b/module/applications/advancement/item-grant-config.mjs @@ -5,7 +5,7 @@ import AdvancementConfig from "./advancement-config.mjs"; */ export default class ItemGrantConfig extends AdvancementConfig { - /** @inheritdoc */ + /** @inheritDoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { classes: ["dnd5e", "advancement", "item-grant"], @@ -17,10 +17,14 @@ export default class ItemGrantConfig extends AdvancementConfig { /* -------------------------------------------- */ - /** @inheritdoc */ + /** @inheritDoc */ getData(options={}) { const context = super.getData(options); const indexes = context.configuration.items.map(i => fromUuidSync(i.uuid)); + context.abilities = Object.entries(CONFIG.DND5E.abilities).reduce((obj, [k, c]) => { + obj[k] = { label: c.label, selected: context.configuration.spell?.ability.has(k) ? "selected" : "" }; + return obj; + }, {}); context.showContainerWarning = indexes.some(i => i?.type === "container"); context.showSpellConfig = indexes.some(i => i?.type === "spell"); return context; @@ -28,7 +32,15 @@ export default class ItemGrantConfig extends AdvancementConfig { /* -------------------------------------------- */ - /** @inheritdoc */ + /** @inheritDoc */ + async prepareConfigurationUpdate(configuration) { + if ( configuration.spell ) configuration.spell.ability ??= []; + return configuration; + } + + /* -------------------------------------------- */ + + /** @inheritDoc */ _validateDroppedItem(event, item) { this.advancement._validateItemType(item); } diff --git a/module/applications/advancement/item-grant-flow.mjs b/module/applications/advancement/item-grant-flow.mjs index 3ae8229251..1c4db42d22 100644 --- a/module/applications/advancement/item-grant-flow.mjs +++ b/module/applications/advancement/item-grant-flow.mjs @@ -19,18 +19,20 @@ export default class ItemGrantFlow extends AdvancementFlow { * @returns {object} */ async getContext() { - const config = this.advancement.configuration.items; + const config = this.advancement.configuration; const added = this.retainedData?.items.map(i => foundry.utils.getProperty(i, "flags.dnd5e.sourceId")) ?? this.advancement.value.added; const checked = new Set(Object.values(added ?? {})); return { optional: this.advancement.configuration.optional, - items: (await Promise.all(config.map(i => fromUuid(i.uuid)))).reduce((arr, item) => { - if ( !item ) return arr; - item.checked = added ? checked.has(item.uuid) : true; - arr.push(item); - return arr; - }, []) + items: config.items.map(i => { + const item = foundry.utils.deepClone(fromUuidSync(i.uuid)); + if ( !item ) return null; + item.checked = added ? checked.has(item.uuid) : (config.optional && !i.optional); + item.optional = config.optional || i.optional; + return item; + }, []).filter(i => i), + abilities: this.getSelectAbilities() }; } @@ -43,6 +45,24 @@ export default class ItemGrantFlow extends AdvancementFlow { /* -------------------------------------------- */ + /** + * Get the context information for selected spell abilities. + * @returns {object} + */ + getSelectAbilities() { + const config = this.advancement.configuration; + return { + options: config.spell?.ability.size > 1 ? config.spell.ability.reduce((obj, k) => { + obj[k] = CONFIG.DND5E.abilities[k]?.label; + return obj; + }, {}) : null, + selected: this.ability ?? this.retainedData?.ability ?? this.advancement.value.ability + ?? config.spell?.ability.first() + }; + } + + /* -------------------------------------------- */ + /** @inheritdoc */ activateListeners(html) { super.activateListeners(html); diff --git a/module/applications/award.mjs b/module/applications/award.mjs index 7bb124a10c..b00e098f44 100644 --- a/module/applications/award.mjs +++ b/module/applications/award.mjs @@ -178,7 +178,7 @@ export default class Award extends DialogMixin(FormApplication) { for ( let [key, amount] of Object.entries(amounts) ) { if ( !amount ) continue; - amount = Math.clamped( + amount = Math.clamp( // Divide amount between remaining destinations Math.floor(amount / remainingDestinations), // Ensure negative amounts aren't more than is contained in destination diff --git a/module/applications/components/_module.mjs b/module/applications/components/_module.mjs index fea46d9aab..8e20ebc37f 100644 --- a/module/applications/components/_module.mjs +++ b/module/applications/components/_module.mjs @@ -1,5 +1,6 @@ import DamageApplicationElement from "./damage-application.mjs"; import EffectsElement from "./effects.mjs"; +import EnchantmentApplicationElement from "./enchantment-application.mjs"; import FiligreeBoxElement from "./filigree-box.mjs"; import IconElement from "./icon.mjs"; import InventoryElement from "./inventory.mjs"; @@ -12,12 +13,13 @@ window.customElements.define("damage-application", DamageApplicationElement); window.customElements.define("dnd5e-effects", EffectsElement); window.customElements.define("dnd5e-icon", IconElement); window.customElements.define("dnd5e-inventory", InventoryElement); +window.customElements.define("enchantment-application", EnchantmentApplicationElement); window.customElements.define("filigree-box", FiligreeBoxElement); window.customElements.define("item-list-controls", ItemListControlsElement); window.customElements.define("proficiency-cycle", ProficiencyCycleElement); window.customElements.define("slide-toggle", SlideToggleElement); export { - AdoptedStyleSheetMixin, DamageApplicationElement, EffectsElement, IconElement, InventoryElement, - ItemListControlsElement, FiligreeBoxElement, ProficiencyCycleElement, SlideToggleElement + AdoptedStyleSheetMixin, DamageApplicationElement, EffectsElement, EnchantmentApplicationElement, IconElement, + InventoryElement, ItemListControlsElement, FiligreeBoxElement, ProficiencyCycleElement, SlideToggleElement }; diff --git a/module/applications/components/adopted-stylesheet-mixin.mjs b/module/applications/components/adopted-stylesheet-mixin.mjs index 0cd7ebbc9f..07f67434ca 100644 --- a/module/applications/components/adopted-stylesheet-mixin.mjs +++ b/module/applications/components/adopted-stylesheet-mixin.mjs @@ -30,6 +30,7 @@ export default function AdoptedStyleSheetMixin(Base) { /** * Retrieves the cached stylesheet, or generates a new one. + * @returns {CSSStyleSheet} * @protected */ _getStyleSheet() { @@ -50,5 +51,5 @@ export default function AdoptedStyleSheetMixin(Base) { * @abstract */ _adoptStyleSheet(sheet) {} - } + }; } diff --git a/module/applications/components/chat-tray-element.mjs b/module/applications/components/chat-tray-element.mjs new file mode 100644 index 0000000000..885e1fe2c9 --- /dev/null +++ b/module/applications/components/chat-tray-element.mjs @@ -0,0 +1,61 @@ +/** + * Custom element designed to display as a collapsible tray in chat. + */ +export default class ChatTrayElement extends HTMLElement { + + static observedAttributes = ["open"]; + + /* -------------------------------------------- */ + /* Properties */ + /* -------------------------------------------- */ + + /** + * Is the tray expanded or collapsed? + * @type {boolean} + */ + get open() { + return this.hasAttribute("open"); + } + + set open(open) { + if ( open ) this.setAttribute("open", ""); + else this.removeAttribute("open"); + } + + /* -------------------------------------------- */ + /* Event Handlers */ + /* -------------------------------------------- */ + + /** @override */ + attributeChangedCallback(name, oldValue, newValue) { + if ( name === "open" ) this._handleToggleOpen(newValue !== null); + } + + /* -------------------------------------------- */ + + /** + * Handle clicks to the collapsible header. + * @param {PointerEvent} event Triggering click event. + */ + _handleClickHeader(event) { + event.preventDefault(); + event.stopImmediatePropagation(); + if ( !event.target.closest(".collapsible-content") ) this.toggleAttribute("open"); + } + + /* -------------------------------------------- */ + + /** + * Handle changing the collapsed state of this element. + * @param {boolean} open Is the element open? + */ + _handleToggleOpen(open) { + this.dispatchEvent(new Event("toggle")); + + this.querySelector(".collapsible")?.classList.toggle("collapsed", !open); + + // Clear the height from the chat popout container so that it appropriately resizes. + const popout = this.closest(".chat-popout"); + if ( popout ) popout.style.height = ""; + } +} diff --git a/module/applications/components/damage-application.mjs b/module/applications/components/damage-application.mjs index 6f9ce3fcba..51cfeaaf50 100644 --- a/module/applications/components/damage-application.mjs +++ b/module/applications/components/damage-application.mjs @@ -1,3 +1,6 @@ +import { formatNumber } from "../../utils.mjs"; +import ChatTrayElement from "./chat-tray-element.mjs"; + /** * List of multiplier options as tuples containing their numeric value and rendered text. * @type {[number, string][]} @@ -7,7 +10,7 @@ const MULTIPLIERS = [[-1, "-1"], [0, "0"], [.25, "¼"], [.5, "½"], [1, "1"], [2 /** * Application to handle applying damage from a chat card. */ -export default class DamageApplicationElement extends HTMLElement { +export default class DamageApplicationElement extends ChatTrayElement { /* -------------------------------------------- */ /* Properties */ @@ -117,7 +120,8 @@ export default class DamageApplicationElement extends HTMLElement { // Build the frame HTML only once if ( !this.targetList ) { const div = document.createElement("div"); - div.classList.add("card-tray", "damage-tray", "collapsible", "collapsed"); + div.classList.add("card-tray", "damage-tray", "collapsible"); + if ( !this.open ) div.classList.add("collapsed"); div.innerHTML = `