From 6f5119582c7e1d47bf84d4746b4cddbcbecf6488 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 29 Aug 2024 08:22:11 -0700 Subject: [PATCH 1/2] [#1407] Add Subclass advancement & selection interface on sheet Introduces a new subclass advancement! This advancement doesn't do anything on its own other than indicate the level at which the class takes a subclass due to limitations in the advancement system. When a player reaches the level of the subclass advancement, their features tab adds a "Add Subclass" button where the subclass icon will eventually appear. Clicking this opens the compendium browser with the subclass list locked to the current class's identifier. While this doesn't fully implement what was originally desired from the subclass advancement, this workflow should work pretty well to fill in a major gap in the system's level-up workflow. Closes #1017 Closes #1407 --- icons/LICENSE | 1 + icons/svg/subclass.svg | 6 +++ lang/en.json | 6 +++ less/v2/actors.less | 8 ++++ .../applications/actor/character-sheet-2.mjs | 16 ++++--- module/applications/actor/character-sheet.mjs | 6 ++- module/applications/advancement/_module.mjs | 1 + .../advancement/advancement-flow.mjs | 1 + .../advancement/subclass-flow.mjs | 13 ++++++ module/config.mjs | 4 ++ module/documents/advancement/_module.mjs | 1 + module/documents/advancement/subclass.mjs | 42 +++++++++++++++++++ packs/_source/classes/barbarian.json | 10 +++++ packs/_source/classes/bard.json | 10 +++++ packs/_source/classes/cleric.json | 10 +++++ packs/_source/classes/druid.json | 10 +++++ packs/_source/classes/fighter.json | 10 +++++ packs/_source/classes/monk.json | 10 +++++ packs/_source/classes/paladin.json | 10 +++++ packs/_source/classes/ranger.json | 10 +++++ packs/_source/classes/rogue.json | 10 +++++ packs/_source/classes/sorcerer.json | 10 +++++ packs/_source/classes/warlock.json | 10 +++++ packs/_source/classes/wizard.json | 10 +++++ templates/actors/parts/actor-classes.hbs | 6 +++ templates/advancement/advancement-flow.hbs | 2 +- templates/journal/page-class-view.hbs | 2 +- 27 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 icons/svg/subclass.svg create mode 100644 module/applications/advancement/subclass-flow.mjs create mode 100644 module/documents/advancement/subclass.mjs diff --git a/icons/LICENSE b/icons/LICENSE index 947aba4310..b358e56b84 100644 --- a/icons/LICENSE +++ b/icons/LICENSE @@ -14,6 +14,7 @@ The dnd5e system for Foundry Virtual Tabletop includes icon artwork licensed fro /svg/original-class.svg - "Barbute" by Lorc under CC BY 3.0 /svg/scale-value.svg - "Dice target" by Delapouite under CC BY 3.0 /svg/size.svg - "Body height" by Delapouite under CC BY 3.0 +/svg/subclass.svg - "Test tubes" by Lorc under CC BY 3.0 /svg/trait.svg - "Scroll unfurled" by Lorc under CC BY 3.0 /svg/trait-armor-proficiencies.svg - "Leather armor" by Delapouite under CC BY 3.0 /svg/trait-damage-immunities.svg - "Aura" by Lorc under CC BY 3.0 diff --git a/icons/svg/subclass.svg b/icons/svg/subclass.svg new file mode 100644 index 0000000000..c05f8ad4b5 --- /dev/null +++ b/icons/svg/subclass.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/lang/en.json b/lang/en.json index 628da85830..ab0efe3c3b 100644 --- a/lang/en.json +++ b/lang/en.json @@ -264,6 +264,11 @@ "Level": "Must be at least level {level} to take this feat.", "Type": "Only features with the \"feat\" type can be selected." } + }, + "Subclass": { + "Title": "Subclass", + "Hint": "Specify what level this class receives its subclass.", + "FlowHint": "Select a subclass in the Features tab after level up has completed." } }, @@ -2432,6 +2437,7 @@ "Hint": "Formula in GP that can be used in place of starting equipment." } }, +"DND5E.SubclassAdd": "Add Subclass", "DND5E.SubclassIdentifierHint": "This identifier should match the identifier on the parent class to ensure they are properly linked.", "DND5E.SubclassAssignmentError": "{class} already has a subclass. Remove the existing '{subclass}' subclass before adding a new one.", "DND5E.SubclassDuplicateError": "A subclass with the identifier {identifier} already exists on this actor.", diff --git a/less/v2/actors.less b/less/v2/actors.less index 221e1ef473..aefc2cc12e 100644 --- a/less/v2/actors.less +++ b/less/v2/actors.less @@ -160,6 +160,14 @@ bottom: 2px; } } + + .subclass-icon { + display: flex; + align-items: center; + justify-content: center; + color: var(--dnd5e-color-card); + font-size: var(--font-size-20); + } } .name-stacked { flex: 1; } diff --git a/module/applications/actor/character-sheet-2.mjs b/module/applications/actor/character-sheet-2.mjs index d9ea0a0755..3b9297a69c 100644 --- a/module/applications/actor/character-sheet-2.mjs +++ b/module/applications/actor/character-sheet-2.mjs @@ -1,6 +1,6 @@ import CharacterData from "../../data/actor/character.mjs"; import * as Trait from "../../documents/actor/trait.mjs"; -import { formatNumber, simplifyBonus } from "../../utils.mjs"; +import { formatNumber } from "../../utils.mjs"; import CompendiumBrowser from "../compendium-browser.mjs"; import ContextMenu5e from "../context-menu.mjs"; import SheetConfig5e from "../sheet-config.mjs"; @@ -385,7 +385,9 @@ export default class ActorSheet5eCharacter2 extends ActorSheetV2Mixin(ActorSheet _onAction(event) { const target = event.currentTarget; switch ( target.dataset.action ) { - case "findItem": this._onFindItem(target.dataset.itemType); break; + case "findItem": + this._onFindItem(target.dataset.itemType, { classIdentifier: target.dataset.classIdentifier }); + break; case "removeFavorite": this._onRemoveFavorite(event); break; case "spellcasting": this._onToggleSpellcasting(event); break; case "toggleInspiration": this._onToggleInspiration(); break; @@ -419,11 +421,15 @@ export default class ActorSheet5eCharacter2 extends ActorSheetV2Mixin(ActorSheet /** * Show available items of a given type. - * @param {string} type The item type. + * @param {string} type The item type. + * @param {object} [options={}] + * @param {string} [options.classIdentifier] Identifier of the class when finding a subclass. * @protected */ - async _onFindItem(type) { - const result = await CompendiumBrowser.selectOne({ filters: { locked: { types: new Set([type]) } } }); + async _onFindItem(type, { classIdentifier }={}) { + const filters = { locked: { types: new Set([type]) } }; + if ( classIdentifier ) filters.locked.additional = { class: { [classIdentifier]: 1 } }; + const result = await CompendiumBrowser.selectOne({ filters }); if ( result ) this._onDropItemCreate(await fromUuid(result)); } diff --git a/module/applications/actor/character-sheet.mjs b/module/applications/actor/character-sheet.mjs index 89116d53c2..f24cf77b91 100644 --- a/module/applications/actor/character-sheet.mjs +++ b/module/applications/actor/character-sheet.mjs @@ -63,7 +63,7 @@ export default class ActorSheet5eCharacter extends ActorSheet5e { // Partition items by category let {items, spells, feats, races, backgrounds, classes, subclasses} = context.items.reduce((obj, item) => { - const {quantity, uses} = item.system; + const { quantity } = item.system; // Item details const ctx = context.itemContext[item.id] ??= {}; @@ -142,6 +142,10 @@ export default class ActorSheet5eCharacter extends ActorSheet5e { const identifier = cls.system.identifier || cls.name.slugify({strict: true}); const subclass = subclasses.findSplice(s => s.system.classIdentifier === identifier); if ( subclass ) arr.push(subclass); + else { + const subclassAdvancement = cls.advancement.byType.Subclass?.[0]; + if ( subclassAdvancement && (subclassAdvancement.level <= cls.system.levels) ) ctx.needsSubclass = true; + } return arr; }, []); for ( const subclass of subclasses ) { diff --git a/module/applications/advancement/_module.mjs b/module/applications/advancement/_module.mjs index 9ef0fce0e5..d39e98ecb2 100644 --- a/module/applications/advancement/_module.mjs +++ b/module/applications/advancement/_module.mjs @@ -17,5 +17,6 @@ export {default as ScaleValueConfig} from "./scale-value-config.mjs"; export {default as ScaleValueFlow} from "./scale-value-flow.mjs"; export {default as SizeConfig} from "./size-config.mjs"; export {default as SizeFlow} from "./size-flow.mjs"; +export {default as SubclassFlow} from "./subclass-flow.mjs"; export {default as TraitConfig} from "./trait-config.mjs"; export {default as TraitFlow} from "./trait-flow.mjs"; diff --git a/module/applications/advancement/advancement-flow.mjs b/module/applications/advancement/advancement-flow.mjs index 29761a88bb..b4f81a3dfd 100644 --- a/module/applications/advancement/advancement-flow.mjs +++ b/module/applications/advancement/advancement-flow.mjs @@ -92,6 +92,7 @@ export default class AdvancementFlow extends FormApplication { advancement: this.advancement, type: this.advancement.constructor.typeName, title: this.title, + hint: this.advancement.hint, summary: this.advancement.summaryForLevel(this.level), level: this.level }; diff --git a/module/applications/advancement/subclass-flow.mjs b/module/applications/advancement/subclass-flow.mjs new file mode 100644 index 0000000000..e00245be65 --- /dev/null +++ b/module/applications/advancement/subclass-flow.mjs @@ -0,0 +1,13 @@ +import AdvancementFlow from "./advancement-flow.mjs"; + +/** + * Inline application that presents the player with the subclass hint. + */ +export default class SubclassFlow extends AdvancementFlow { + /** @inheritDoc */ + async getData() { + return foundry.utils.mergeObject(super.getData(), { + hint: `${this.advancement.hint ?? ""} ${game.i18n.localize("DND5E.ADVANCEMENT.Subclass.FlowHint")}` + }); + } +} diff --git a/module/config.mjs b/module/config.mjs index 7d56294ae3..1a9e0404b6 100644 --- a/module/config.mjs +++ b/module/config.mjs @@ -3496,6 +3496,10 @@ DND5E.advancementTypes = { documentClass: advancement.SizeAdvancement, validItemTypes: new Set(["race"]) }, + Subclass: { + documentClass: advancement.SubclassAdvancement, + validItemTypes: new Set(["class"]) + }, Trait: { documentClass: advancement.TraitAdvancement, validItemTypes: new Set(_ALL_ITEM_TYPES) diff --git a/module/documents/advancement/_module.mjs b/module/documents/advancement/_module.mjs index ae7596dcad..9dbd71ddd0 100644 --- a/module/documents/advancement/_module.mjs +++ b/module/documents/advancement/_module.mjs @@ -6,4 +6,5 @@ export {default as ItemChoiceAdvancement} from "./item-choice.mjs"; export {default as ItemGrantAdvancement} from "./item-grant.mjs"; export {default as ScaleValueAdvancement} from "./scale-value.mjs"; export {default as SizeAdvancement} from "./size.mjs"; +export {default as SubclassAdvancement} from "./subclass.mjs"; export {default as TraitAdvancement} from "./trait.mjs"; diff --git a/module/documents/advancement/subclass.mjs b/module/documents/advancement/subclass.mjs new file mode 100644 index 0000000000..934b575131 --- /dev/null +++ b/module/documents/advancement/subclass.mjs @@ -0,0 +1,42 @@ +import Advancement from "./advancement.mjs"; +import SubclassFlow from "../../applications/advancement/subclass-flow.mjs"; + +/** + * Advancement that indicates when a class takes a subclass. Only allowed on class items and can only be taken once. + */ +export default class SubclassAdvancement extends Advancement { + + /** @inheritDoc */ + static get metadata() { + return foundry.utils.mergeObject(super.metadata, { + order: 70, + icon: "icons/skills/trades/mining-pickaxe-yellow-blue.webp", + typeIcon: "systems/dnd5e/icons/svg/subclass.svg", + title: game.i18n.localize("DND5E.ADVANCEMENT.Subclass.Title"), + hint: game.i18n.localize("DND5E.ADVANCEMENT.Subclass.Hint"), + apps: { + flow: SubclassFlow + } + }); + } + + /* -------------------------------------------- */ + /* Display Methods */ + /* -------------------------------------------- */ + + /** @inheritdoc */ + summaryforLevel(level, { configMode=false }={}) { + const subclass = this.item.subclass; + if ( configMode || !item ) return ""; + return subclass.toAnchor({ classes: ["content-link"] }).outerHTML; + } + + /* -------------------------------------------- */ + /* Editing Methods */ + /* -------------------------------------------- */ + + /** @inheritDoc */ + static availableForItem(item) { + return !item.advancement.byType.Subclass?.length; + } +} diff --git a/packs/_source/classes/barbarian.json b/packs/_source/classes/barbarian.json index 78fb163734..ee294c3a3b 100644 --- a/packs/_source/classes/barbarian.json +++ b/packs/_source/classes/barbarian.json @@ -522,6 +522,16 @@ }, "level": 19, "title": "" + }, + { + "_id": "ok09058sgbpt0w6b", + "type": "Subclass", + "configuration": {}, + "value": {}, + "level": 3, + "title": "Primal Path", + "icon": "", + "classRestriction": "" } ], "spellcasting": { diff --git a/packs/_source/classes/bard.json b/packs/_source/classes/bard.json index 1646d8060c..5f5b77ea17 100644 --- a/packs/_source/classes/bard.json +++ b/packs/_source/classes/bard.json @@ -661,6 +661,16 @@ }, "level": 19, "title": "" + }, + { + "_id": "wjdgb3r8pyeix83o", + "type": "Subclass", + "configuration": {}, + "value": {}, + "level": 3, + "title": "Bard College", + "icon": "", + "classRestriction": "" } ], "spellcasting": { diff --git a/packs/_source/classes/cleric.json b/packs/_source/classes/cleric.json index 60b8d25e41..8daa283f34 100644 --- a/packs/_source/classes/cleric.json +++ b/packs/_source/classes/cleric.json @@ -385,6 +385,16 @@ }, "level": 19, "title": "" + }, + { + "_id": "8k7cukgzwwl67h6c", + "type": "Subclass", + "configuration": {}, + "value": {}, + "level": 1, + "title": "Divine Domain", + "icon": "", + "classRestriction": "" } ], "spellcasting": { diff --git a/packs/_source/classes/druid.json b/packs/_source/classes/druid.json index 9867ed1680..cdfc8f60c3 100644 --- a/packs/_source/classes/druid.json +++ b/packs/_source/classes/druid.json @@ -463,6 +463,16 @@ "value": {}, "title": "Wild Shape Uses", "icon": null + }, + { + "_id": "p9otj81aosxpn6wn", + "type": "Subclass", + "configuration": {}, + "value": {}, + "level": 2, + "title": "Druid Circle", + "icon": "", + "classRestriction": "" } ], "spellcasting": { diff --git a/packs/_source/classes/fighter.json b/packs/_source/classes/fighter.json index 2f3b47dd1c..c56606c5c0 100644 --- a/packs/_source/classes/fighter.json +++ b/packs/_source/classes/fighter.json @@ -474,6 +474,16 @@ }, "level": 14, "title": "" + }, + { + "_id": "aqampk2fc5jk5jqh", + "type": "Subclass", + "configuration": {}, + "value": {}, + "level": 3, + "title": "Martial Archetype", + "icon": "", + "classRestriction": "" } ], "spellcasting": { diff --git a/packs/_source/classes/monk.json b/packs/_source/classes/monk.json index 5bd74d9fc6..ed05768d86 100644 --- a/packs/_source/classes/monk.json +++ b/packs/_source/classes/monk.json @@ -596,6 +596,16 @@ }, "level": 19, "title": "" + }, + { + "_id": "0awj2yq115ev9u9o", + "type": "Subclass", + "configuration": {}, + "value": {}, + "level": 3, + "title": "Monastic Tradition", + "icon": "", + "classRestriction": "" } ], "spellcasting": { diff --git a/packs/_source/classes/paladin.json b/packs/_source/classes/paladin.json index f735e73da9..f243ae0ddd 100644 --- a/packs/_source/classes/paladin.json +++ b/packs/_source/classes/paladin.json @@ -488,6 +488,16 @@ }, "value": {}, "title": "Aura Radius" + }, + { + "_id": "n22j60yn24fqr3sy", + "type": "Subclass", + "configuration": {}, + "value": {}, + "level": 3, + "title": "Sacred Oath", + "icon": "", + "classRestriction": "" } ], "spellcasting": { diff --git a/packs/_source/classes/ranger.json b/packs/_source/classes/ranger.json index 093b77f35f..ff03ef26e0 100644 --- a/packs/_source/classes/ranger.json +++ b/packs/_source/classes/ranger.json @@ -527,6 +527,16 @@ }, "level": 19, "title": "" + }, + { + "_id": "gb53865sgbtx8xr2", + "type": "Subclass", + "configuration": {}, + "value": {}, + "level": 3, + "title": "Ranger Archetype", + "icon": "", + "classRestriction": "" } ], "spellcasting": { diff --git a/packs/_source/classes/rogue.json b/packs/_source/classes/rogue.json index a9f71adb35..56ff1431b5 100644 --- a/packs/_source/classes/rogue.json +++ b/packs/_source/classes/rogue.json @@ -645,6 +645,16 @@ "level": 15, "title": "", "value": {} + }, + { + "_id": "n0yoxbkyz32yd942", + "type": "Subclass", + "configuration": {}, + "value": {}, + "level": 3, + "title": "Roguish Archetype", + "icon": "", + "classRestriction": "" } ], "spellcasting": { diff --git a/packs/_source/classes/sorcerer.json b/packs/_source/classes/sorcerer.json index 088c0d1f4e..839a03da58 100644 --- a/packs/_source/classes/sorcerer.json +++ b/packs/_source/classes/sorcerer.json @@ -413,6 +413,16 @@ }, "level": 19, "title": "" + }, + { + "_id": "f06tge0vsvdhb0sw", + "type": "Subclass", + "configuration": {}, + "value": {}, + "level": 1, + "title": "Sorcerous Origin", + "icon": "", + "classRestriction": "" } ], "spellcasting": { diff --git a/packs/_source/classes/warlock.json b/packs/_source/classes/warlock.json index c21fd7e53f..137eedff58 100644 --- a/packs/_source/classes/warlock.json +++ b/packs/_source/classes/warlock.json @@ -624,6 +624,16 @@ }, "level": 19, "title": "" + }, + { + "_id": "vibumq2vxjl9tmk4", + "type": "Subclass", + "configuration": {}, + "value": {}, + "level": 1, + "title": "Otherworldly Patron", + "icon": "", + "classRestriction": "" } ], "spellcasting": { diff --git a/packs/_source/classes/wizard.json b/packs/_source/classes/wizard.json index 1b3f7f3c36..038470bc0d 100644 --- a/packs/_source/classes/wizard.json +++ b/packs/_source/classes/wizard.json @@ -344,6 +344,16 @@ }, "level": 19, "title": "" + }, + { + "_id": "yfvhv74yw5zdz640", + "type": "Subclass", + "configuration": {}, + "value": {}, + "level": 2, + "title": "Arcane Tradition", + "icon": "", + "classRestriction": "" } ], "spellcasting": { diff --git a/templates/actors/parts/actor-classes.hbs b/templates/actors/parts/actor-classes.hbs index 67a7025f90..a4ebada807 100644 --- a/templates/actors/parts/actor-classes.hbs +++ b/templates/actors/parts/actor-classes.hbs @@ -9,6 +9,12 @@ {{#if cls.subclass}} {{ cls.subclass.name }} + {{else if ctx.needsSubclass}} + + + {{/if}} {{#if @root.editable}}
diff --git a/templates/advancement/advancement-flow.hbs b/templates/advancement/advancement-flow.hbs index b83775d6a5..b0635ec601 100644 --- a/templates/advancement/advancement-flow.hbs +++ b/templates/advancement/advancement-flow.hbs @@ -1,5 +1,5 @@

{{{ title }}}

- {{#if advancement.hint}}

{{ advancement.hint }}

{{/if}} + {{#if hint}}

{{ hint }}

{{/if}} {{{ summary }}}
diff --git a/templates/journal/page-class-view.hbs b/templates/journal/page-class-view.hbs index 5e35aea0d8..5b86e6cdb5 100644 --- a/templates/journal/page-class-view.hbs +++ b/templates/journal/page-class-view.hbs @@ -12,7 +12,7 @@ {{ localize "JOURNALENTRYPAGE.DND5E.Class.FeaturesHeader" }} {{#if primaryAbility}} - {{ localize "DND5E.CLASS.FIELDS.primaryAbility.label" }}: {{ primaryAbility }} + {{ localize "DND5E.CLASS.FIELDS.primaryAbility.value.label" }}: {{ primaryAbility }} {{/if}}

From 4b89f50ea1fa87eedd78b21255f0cd8cd31c366b Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Fri, 30 Aug 2024 07:52:37 -0700 Subject: [PATCH 2/2] [#1407] Swap icon, fix link, adjust comment --- icons/LICENSE | 2 +- icons/svg/subclass.svg | 2 +- module/documents/advancement/subclass.mjs | 6 +++--- templates/actors/parts/actor-classes.hbs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/icons/LICENSE b/icons/LICENSE index b358e56b84..044e3669eb 100644 --- a/icons/LICENSE +++ b/icons/LICENSE @@ -14,7 +14,7 @@ The dnd5e system for Foundry Virtual Tabletop includes icon artwork licensed fro /svg/original-class.svg - "Barbute" by Lorc under CC BY 3.0 /svg/scale-value.svg - "Dice target" by Delapouite under CC BY 3.0 /svg/size.svg - "Body height" by Delapouite under CC BY 3.0 -/svg/subclass.svg - "Test tubes" by Lorc under CC BY 3.0 +/svg/subclass.svg - "Elf helmet" by Kier Heyl under CC BY 3.0 /svg/trait.svg - "Scroll unfurled" by Lorc under CC BY 3.0 /svg/trait-armor-proficiencies.svg - "Leather armor" by Delapouite under CC BY 3.0 /svg/trait-damage-immunities.svg - "Aura" by Lorc under CC BY 3.0 diff --git a/icons/svg/subclass.svg b/icons/svg/subclass.svg index c05f8ad4b5..96cfadbc63 100644 --- a/icons/svg/subclass.svg +++ b/icons/svg/subclass.svg @@ -1,6 +1,6 @@ - + diff --git a/module/documents/advancement/subclass.mjs b/module/documents/advancement/subclass.mjs index 934b575131..e06abb6c33 100644 --- a/module/documents/advancement/subclass.mjs +++ b/module/documents/advancement/subclass.mjs @@ -24,11 +24,11 @@ export default class SubclassAdvancement extends Advancement { /* Display Methods */ /* -------------------------------------------- */ - /** @inheritdoc */ + /** @inheritDoc */ summaryforLevel(level, { configMode=false }={}) { const subclass = this.item.subclass; - if ( configMode || !item ) return ""; - return subclass.toAnchor({ classes: ["content-link"] }).outerHTML; + if ( configMode || !subclass ) return ""; + return subclass.toAnchor().outerHTML; } /* -------------------------------------------- */ diff --git a/templates/actors/parts/actor-classes.hbs b/templates/actors/parts/actor-classes.hbs index a4ebada807..ea42de9ddd 100644 --- a/templates/actors/parts/actor-classes.hbs +++ b/templates/actors/parts/actor-classes.hbs @@ -11,7 +11,7 @@ data-action="edit" data-item-id="{{ cls.subclass.id }}"> {{else if ctx.needsSubclass}}