diff --git a/icons/LICENSE b/icons/LICENSE index 947aba4310..044e3669eb 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 - "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 new file mode 100644 index 0000000000..96cfadbc63 --- /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..e06abb6c33 --- /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 || !subclass ) return ""; + return subclass.toAnchor().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..ea42de9ddd 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}}