diff --git a/lang/en.json b/lang/en.json index 85fc0ce..cabcfff 100644 --- a/lang/en.json +++ b/lang/en.json @@ -177,6 +177,26 @@ "enable": "Show biography", "disable": "Hide biography" }, + + "Spellcasting": { + "preparedSpellcasting": "Spellcasting", + "pactSpellcasting": "Spellcasting", + "innateSpellcasting": "Innate Spellcasting", + "psionicSpellcasting": "Spellcasting (Psionics)", + "Cantrip": "Cantrip", + "CantripPl": "Cantrips", + "AtWill": "at will", + "preparedCasterText": + "The {{name}} is {{#if (or (eq level 8) (eq level 18))}}an{{else}}a{{/if}} {{{nth}}}-level spellcaster. Its spellcasting ability is {{{ability}}} (spell save DC {{dc}}, {{tohit}} to hit with spell attacks). The {{name}} {{#if hasAtWill}}can cast {{{spells}}} at will and {{/if}}has the following spells prepared:", + "pactCasterText": + "The {{name}} is {{#if (or (eq level 8) (eq level 18))}}an{{else}}a{{/if}} {{{nth}}}-level spellcaster. Its spellcasting ability is {{{ability}}} (spell save DC {{dc}}, {{tohit}} to hit with spell attacks). It regains its expended spell slots when it finishes a short or long rest. It knows the following warlock spells:", + "innateCasterText": + "The {{name}} spellcasting ability is {{{ability}}} (spell save DC {{dc}}). It can innately cast the following spells, requiring no material components:", + "fullCasterText": + "The {{name}} is {{#if (or (eq level 8) (eq level 18))}}an{{else}}a{{/if}} {{{nth}}}-level spellcaster. Its spellcasting ability is {{{ability}}} (spell save DC {{dc}}, {{tohit}} to hit with spell attacks). The {{name}} {{#if hasAtWill}}can cast {{{spells}}} at will and {{/if}}has the following spells prepared:", + "spellcastingActionText": + "The {{name}} casts one of the following spells, using {{{ability}}} as the spellcasting ability (spell save DC {{dc}}):" + }, "MonsterBlocks": "Monster Blocks", diff --git a/monsterblock.js b/monsterblock.js index c51cc7f..06db26a 100644 --- a/monsterblock.js +++ b/monsterblock.js @@ -66,8 +66,15 @@ Hooks.once("ready", () => { // This is how the box sizing is corrected to fit the statblock // eslint-disable-next-line no-unused-vars Hooks.on("renderMonsterBlock5e", (monsterblock, html, data) => { // When the sheet is rendered - if (debug.INFO) console.log("Monster Block | Rendering sheet"); - if (debug.DEBUG) console.debug(`Monster Block |`, monsterblock, html, data); + const label = "Monster Block | Rendering sheet"; + if (debug.DEBUG) { + console.group(label); + console.log("Sheet: ", monsterblock); + console.log("HTML: ", html); + console.log("Template Data:", data); + console.groupEnd(label); + } + else if (debug.INFO) console.log(label); if (html.parent().hasClass("grid-cell-content")) return; diff --git a/scripts/dnd5e/CastingPreper.js b/scripts/dnd5e/CastingPreper.js index f902584..84fa90f 100644 --- a/scripts/dnd5e/CastingPreper.js +++ b/scripts/dnd5e/CastingPreper.js @@ -116,7 +116,7 @@ export default class CastingPreper extends ItemPreper { * @override * @memberof CastingPreper */ - prepare() { + prepare() { this.data.castingType = this.constructor.isSpellcasting(this.item) ? (this.constructor.isPactMagic(this.item) ? this.cts.pact : this.cts.standard) : this.cts.innate; diff --git a/scripts/dnd5e/ItemPrep.js b/scripts/dnd5e/ItemPrep.js index a6655b3..f43b415 100644 --- a/scripts/dnd5e/ItemPrep.js +++ b/scripts/dnd5e/ItemPrep.js @@ -5,6 +5,9 @@ import ActionPreper from "./ActionPreper.js"; import ItemPreper from "./ItemPreper.js"; import InnateSpellbookPrep from "./InnateSpellbookPrep.js" +import SpellBook from "./SpellBook.js"; +import { debug } from "../utilities.js"; + /** * @typedef {import{"../../../../systems/dnd5e/module/item/sheet.js"}.Item5e} Item5e */ @@ -38,11 +41,13 @@ export default class ItemPrep { /** @type {Object.} A set of item classifications by type */ features = { + spells: { prep: ItemPreper, filter: item => item.type === "spell", items: [], dataset: {type: "feat"} }, legResist: { prep: ItemPreper, filter: MonsterBlock5e.isLegendaryResistance, items: [], dataset: {type: "feat"} }, legendary: { prep: ActionPreper, filter: MonsterBlock5e.isLegendaryAction, items: [], dataset: {type: "feat"} }, lair: { prep: ActionPreper, filter: MonsterBlock5e.isLairAction, items: [], dataset: {type: "feat"} }, multiattack: { prep: ActionPreper, filter: MonsterBlock5e.isMultiAttack, items: [], dataset: {type: "feat"} }, - casting: { prep: CastingPreper, filter: CastingPreper.isCasting.bind(CastingPreper), items: [], dataset: {type: "feat"} }, + //casting: { prep: CastingPreper, filter: CastingPreper.isCasting.bind(CastingPreper), items: [], dataset: {type: "feat"} }, + casting: { prep: ItemPreper, filter: CastingPreper.isCasting.bind(CastingPreper), items: [], dataset: {type: "feat"} }, reaction: { prep: ActionPreper, filter: MonsterBlock5e.isReaction, items: [], dataset: {type: "feat"} }, bonusActions: { prep: ActionPreper, filter: MonsterBlock5e.isBonusAction, items: [], dataset: {type: "feat"} }, attacks: { prep: AttackPreper, filter: item => item.type === "weapon", items: [], dataset: {type: "weapon"} }, @@ -57,20 +62,44 @@ export default class ItemPrep { * @memberof ItemPrep */ prepareItems() { - const [other, spells] = this.data.items.partition(item => item.type === "spell"); - this.organizeSpellbooks(spells); - this.organizeFeatures(other); + this.organizeFeatures(this.data.items); + + if (this.data.features.spells.items.length) + this.organizeSpellbooks(this.data.features.spells.items); } /** - * Prepares and organizes the regular and innate spellbooks + * Prepares and organizes the spellbooks * * @param {array} spells - All the spell items * @memberof ItemPrep */ organizeSpellbooks(spells) { - this.data.spellbook = this.sheet._prepareSpellbook(this.data, spells); - this.data.innateSpellbook = new InnateSpellbookPrep(this.data.spellbook, this.sheet).prepare(); + const spellbook = new SpellBook(this.sheet, spells, null, "full"); + + const spellbooks = { + "full": spellbook, + "prepared": spellbook.getPrepared(), + "innate": spellbook.getInnate(), + "pact": spellbook.getPact(), + } + + Object.values(spellbooks).forEach(book => { + if (!book.hasPages) book.show = false; + }); + spellbooks.full.show = false; + + this.data.spellbooks = spellbooks; + + if (debug.DEBUG) { + const label = "Monster Blocks | Spellbook"; + console.group(label); + console.log("Full: ", this.data.spellbooks.full); + console.log("Prepared:", this.data.spellbooks.prepared); + console.log("Innate ", this.data.spellbooks.innate); + console.log("Pact ", this.data.spellbooks.pact); + console.groupEnd(label); + } } /** diff --git a/scripts/dnd5e/MonsterBlock5e.js b/scripts/dnd5e/MonsterBlock5e.js index 174283d..62e5945 100644 --- a/scripts/dnd5e/MonsterBlock5e.js +++ b/scripts/dnd5e/MonsterBlock5e.js @@ -5,6 +5,7 @@ import { debug, ContentEditableAdapter, getTranslationArray } from "../utilities import { inputExpression } from "../../input-expressions/handler.js"; import ItemPrep from "./ItemPrep.js"; import Flags from "./Flags5e.js"; +import StatGetter from "./StatGetter.js"; /* global QuickInsert:readonly */ @@ -22,6 +23,7 @@ export default class MonsterBlock5e extends ActorSheet5eNPC { this.position.default = true; this.flagManager = new Flags(this); + this.statGetter = new StatGetter(this, this.actor); //this.flagManager.prep().then((p) => { this.options.classes.push(this.themes[this.currentTheme].class); @@ -1253,6 +1255,11 @@ export default class MonsterBlock5e extends ActorSheet5eNPC { "modules/monsterblock/templates/dnd5e/parts/main/legendaryActs.hbs", "modules/monsterblock/templates/dnd5e/parts/main/lairActs.hbs", + "modules/monsterblock/templates/dnd5e/parts/spellcasting/spellcasting-redux.hbs", + "modules/monsterblock/templates/dnd5e/parts/spellcasting/spellbook.hbs", + "modules/monsterblock/templates/dnd5e/parts/spellcasting/spellbook-page.hbs", + "modules/monsterblock/templates/dnd5e/parts/spellcasting/spell-list.hbs", + "modules/monsterblock/templates/dnd5e/parts/menuItem.hbs", "modules/monsterblock/templates/dnd5e/parts/resource.hbs", "modules/monsterblock/templates/dnd5e/parts/featureBlock.hbs", diff --git a/scripts/dnd5e/SpellBook.js b/scripts/dnd5e/SpellBook.js new file mode 100644 index 0000000..db5a543 --- /dev/null +++ b/scripts/dnd5e/SpellBook.js @@ -0,0 +1,319 @@ +import Helpers from "./Helpers5e.js"; +import { debug, getTranslationArray } from "../utilities.js"; +import ItemPreper from "./ItemPreper.js"; +import * as Templates from "./templates.js"; + +/** + * @typedef {import("../../../../systems/dnd5e/module/item/sheet.js").Item5e} Item5e + */ +/** + * @typedef {import("./MonsterBlock5e.js").MonsterBlock5e} MonsterBlock5e + */ +/** + * @typedef {("always"|"atwill"|"innate"|"pact"|"prepared")} PrepMode + */ +/** + * @typedef {object} PageIdentity + * @property {PrepMode} mode - The mode of the page + * @property {number} level - The level of the page + * @property {number} uses - The maximum number of uses of the page + */ + +export default class SpellBook { + /** + * Create a new spellbook from the pages of an existing one + * + * @static + * @param {SpellBook} book + * @param {Object} pages + * @return {*} + * @memberof SpellBook + */ + static fromPages(book, pages, type) { + return new SpellBook(book.sheet, [].concat(...Object.values(pages).map(page => page.spells)), pages, type); + } + + static getPrepared(book) { + const entries = book.pageEntries; + const pages = entries.filter(([name, page]) => page.isPrepared); + + if (book.hasAtWillSpells && !book.hasInnateSpells) { + const will = entries.find(([name, page]) => page.isAtWill); + if (will) pages.push(will); + } + + return this.fromPages(book, Object.fromEntries(pages), "prepared"); + } + static getInnate(book) { + const entries = book.pageEntries; + const pages = entries.filter(([name, page]) => page.isInnate || page.isAtWill); + return this.fromPages(book, Object.fromEntries(pages), "innate"); + } + static getPact(book) { + const entries = book.pageEntries; + const pages = entries.filter(([name, page]) => page.isPact); + + if (!book.hasPreparedSpells) { + const cantrips = entries.find(([name, page]) => page.isCantrip) + if (cantrips) pages.push(cantrips); + } + + return this.fromPages(book, Object.fromEntries(pages), "pact"); + } + + /** + * Creates an instance of SpellBook. + * @param {MonsterBlock5e} sheet - The sheet instance + * @param {Item5e[]} items - All the items in the spellbook + * @param {Object} pages - Pages from another spellbook + * @memberof SpellBook + */ + constructor(sheet, items, pages, type) { + /** + * @type {MonsterBlock5e} The sheet this spellbook is for. + */ + this.sheet = sheet; + this.items = items; + this.type = type; + + this.stats = this.sheet.statGetter; + this.editing = this.sheet.flags.editing; + + this.show = true; + + if (!pages) this.fillPages(); + else this.pages = pages; + } + + /** @type {Object} */ + pages = {}; + + /** + * Whether or not this book has been used + * @type {boolean} + */ + shown = false; + + /** + * A spellcasting feature associated with this book + * @type {Item5e} + */ + feature = null; + + get pageEntries() { + return Object.entries(this.pages); + } + get pageValues() { + return Object.values(this.pages); + } + get title() { + return game.i18n.localize(`MOBLOKS5E.Spellcasting.${this.type}Spellcasting`); + } + + fillPages() { + this.items.forEach(this.processItem.bind(this)); + } + + processItem(item) { + const mode = item.data.preparation.mode; + const level = item.data.level; + const uses = item.data.uses.max; + + const page = this.getOrCreatePage({ mode, level, uses }); + + page.add(item); + } + + getOrCreatePage(pageIdent) { + const name = SpellPage.getPageName(pageIdent); + return this.pages[name] || (this.pages[name] = new SpellPage(pageIdent)); + } + + get hasPreparedSpells() { + return this.pageValues.some(page => page.isPrepared); + } + get hasAtWillSpells() { + return this.pageValues.some(page => page.isAtWill); + } + get hasInnateSpells() { + return this.pageValues.some(page => page.isInnate); + } + get hasPactSpells() { + return this.pageValues.some(page => page.isPact); + } + get hasPages() { + return Boolean(Object.keys(this.pages).length) + } + + get introText() { + const stats = this.stats; + const template = game.i18n.localize(`MOBLOKS5E.Spellcasting.${this.type}CasterText`); + const builder = Handlebars.compile(template); + console.log(stats.castingAbility, stats.castingAbilityLabel) + return builder({ + name: this.sheet.actor.name, + level: stats.spellLevel, + nth: Templates.editable({ + key: "data.details.spellLevel", + value: stats.spellLevel, + className: "caster-level", + dtype: "Number", + placeholder: "0", + enabled: this.editing + }) + Helpers.getOrdinalSuffix(stats.spellLevel), + ability: Templates.selectField({ + key: "data.attributes.spellcasting", + value: stats.castingAbility, + label: stats.castingAbilityLabel, + listClass: "actor-size", + options: Object.entries(CONFIG.DND5E.abilities) + .map(([key, value]) => ({ + value: key, + label: value + })) + .filter(opt => opt.value != stats.castingAbility), + enabled: this.editing + }), + tohit: `${stats.spellAttackBonus > -1 ? "+" : ""}${stats.spellAttackBonus}`, + dc: stats.spellDc, + hasAtWill: this.hasAtWillSpells, + spells: "" + }); + } + + getPrepared() { + return this.constructor.getPrepared(this); + } + getInnate() { + return this.constructor.getInnate(this); + } + getPact() { + return this.constructor.getPact(this); + } +} + +export class SpellPage { + /** + * @static + * @param {PageIdentity} pageIdent + * @return {string} + * @memberof SpellPage + */ + static getPageName({ mode, level, uses }) { + if (this.isPact(mode) && this.isCantrip(level)) return "p0"; + if (this.isPrepared(mode)) return `l${level}`; + if (this.isInnate(mode)) return `u${uses}`; + if (this.isPact(mode)) return `pact`; + if (this.isAtWill(mode, uses)) return `will`; + + throw("The specified spell page is not valid"); + } + + /** + * @static + * @param {PageIdentity} pageIdent + * @return {string} + * @memberof SpellPage + */ + static getPageLabel({ mode, level, uses }) { + if (this.isPact(mode) && this.isCantrip(level)) return "MOBLOKS5E.Spellcasting.CantripPl"; + if (this.isPrepared(mode) && this.isCantrip(level)) return "MOBLOKS5E.Spellcasting.CantripPl"; + if (this.isPrepared(mode)) return `${level}th level`; + if (this.isInnate(mode)) return `${uses}/day`; + if (this.isPact(mode)) return `levels 1-${level}`; + if (this.isAtWill(mode, uses)) return "MOBLOKS5E.Spellcasting.AtWill"; + + throw("The specified spell page is not valid"); + } + + static isPrepared(mode) { + return mode === "prepared" || mode === "always" + } + static isPact(mode) { + return mode === "pact"; + } + static isInnate(mode) { + return mode === "innate"; + } + static isAtWill(mode, uses) { + return mode === "atwill" || (this.isInnate(mode) && uses === 0); + } + static isCantrip(level) { + return level === 0; + } + + /** + * + * @param {PageIdentity} pageIdent + */ + constructor({ mode, level, uses }={}) { + /** + * The spell preperation mode for this page + * @type {PrepMode} + */ + this.mode = CONFIG.DND5E.spellPreparationModes[mode] ? mode : "innate"; + /** + * The spell level for this page + * @type {number} + */ + this.level = level || 0; + /** + * The maximum number of uses for spells in this page + * @type {number} + */ + this.uses = uses || 0; + } + + /** + * All of the spells on this page + * @type {Item5e[]} + */ + spells = []; + + /** + * Whether or not this page has been used + * @type {boolean} + */ + shown = false; + + get name() { + return this.constructor.getPageName(this); + } + get label() { + return game.i18n.localize(this.constructor.getPageLabel(this)); + } + + get isInnate() { + return this.constructor.isInnate(this.mode); + } + get isPrepared() { + return this.constructor.isPrepared(this.mode); + } + get isPact() { + return this.constructor.isPact(this.mode); + } + get isAtWill() { + return this.constructor.isAtWill(this.mode, this.uses); + } + get isCantrip() { + return this.constructor.isCantrip(this.mode, this.level); + } + + get any() { + return this.spells.length > 0; + } + + get spellCount() { + return this.spells.length; + } + + /** + * Add a spell to this page + * + * @param {Item5e} spells + * @memberof SpellPage + */ + add(...spells) { + this.spells.push(...spells); + } +} \ No newline at end of file diff --git a/scripts/dnd5e/StatGetter.js b/scripts/dnd5e/StatGetter.js new file mode 100644 index 0000000..86f6af6 --- /dev/null +++ b/scripts/dnd5e/StatGetter.js @@ -0,0 +1,73 @@ +/** + * A class to handle looking up statistics about the actor + * that the associated sheet is for. + * + * @export + * @class StatGetter + */ +export default class StatGetter { + constructor(sheet, actor) { + this.sheet = sheet; + this.actor = actor; + } + + /** + * Calculates the spell attack bonus of this actor + * + * @return {number} + * @memberof StatGetter + */ + get spellAttackBonus() { + const data = this.actor.data.data; + const abilityBonus = data.abilities[this.castingAbility]?.mod; + const profBonus = data.attributes?.prof; + + return abilityBonus + profBonus ?? 0; + } + + /** + * Get which ability score is the the casting ability of this actor + * If none found, use 'int' + * + * @return {string} + * @memberof StatGetter + */ + get castingAbility() { + return this.actor.data.data?.attributes?.spellcasting || "int"; + } + + /** + * Get the label for the ability that is the the casting ability of this actor + * If none found, use 'Intelligence' (localized) + * + * @return {string} + * @memberof StatGetter + */ + get castingAbilityLabel() { + const abl = this.castingAbility; + return game.i18n.localize( + // Capitalize the first letter of the ability + `DND5E.Ability${abl.charAt(0).toUpperCase() + abl.slice(1)}` + ); + } + + /** + * Get the spell level of this actor + * + * @return {number} + * @memberof StatGetter + */ + get spellLevel() { + return this.actor.data.data.details.spellLevel; + } + + /** + * Get the spell DC of this actor + * + * @return {number} + * @memberof StatGetter + */ + get spellDc() { + return this.actor.data.data?.attributes?.spelldc; + } +} \ No newline at end of file diff --git a/scripts/dnd5e/templates.js b/scripts/dnd5e/templates.js index fbd22f5..e4cd71f 100644 --- a/scripts/dnd5e/templates.js +++ b/scripts/dnd5e/templates.js @@ -22,7 +22,7 @@ export let selectField = ({ labelClass="", label, listClass="", options, enabled=true -}) => `\ +}) => /* html */`\
@@ -53,7 +53,7 @@ export let selectField = ({ */ export let editable = ({ key, className="", dtype="Text", placeholder="", value="", enabled=true -}) => `\ +}) => /* html */`\ `\ +}) => /* html */`\ -` \ No newline at end of file +`; \ No newline at end of file diff --git a/templates/dnd5e/main.hbs b/templates/dnd5e/main.hbs index 9d6f50e..6cdcf69 100644 --- a/templates/dnd5e/main.hbs +++ b/templates/dnd5e/main.hbs @@ -19,6 +19,12 @@ {{/if}} {{/each}} + {{#each spellbooks as |spellbook|}} + {{#if spellbook.show}} + {{> "modules/monsterblock/templates/dnd5e/parts/spellcasting/spellcasting-redux.hbs" spellbook=spellbook}} + {{/if}} + {{/each}} + {{! Non-action Features }} {{#each features.features.items as |item iid|}} {{#unless item.is.specialAction}} @@ -41,6 +47,9 @@ {{> "modules/monsterblock/templates/dnd5e/parts/featureBlock.hbs" item=features.multiattack.items.[0]}} {{/if}} + {{! Spellcasting Action }} + {{> "modules/monsterblock/templates/dnd5e/parts/spellcasting/spellcasting-redux.hbs" spellbook=spellbooks.full}} + {{! Attacks }} {{#each features.attacks.items as |item iid|}} {{#unless item.is.specialAction}} diff --git a/templates/dnd5e/parts/spellcasting/spell-list.hbs b/templates/dnd5e/parts/spellcasting/spell-list.hbs new file mode 100644 index 0000000..ab394c2 --- /dev/null +++ b/templates/dnd5e/parts/spellcasting/spell-list.hbs @@ -0,0 +1,15 @@ +
    +{{#each spells as |spell id|}} +
  • + {{#if @root.flags.show-delete}} + + + + {{/if}} + {{spell.name}} + {{~#if spell.hasresource~}} + {{> "modules/monsterblock/templates/dnd5e/parts/resource.hbs" resource=spell.resource}} + {{~/if~}} +
  • +{{/each}} +
\ No newline at end of file diff --git a/templates/dnd5e/parts/spellcasting/spellbook-page.hbs b/templates/dnd5e/parts/spellcasting/spellbook-page.hbs new file mode 100644 index 0000000..879cbdf --- /dev/null +++ b/templates/dnd5e/parts/spellcasting/spellbook-page.hbs @@ -0,0 +1,21 @@ +
  • + + {{page.label~}} + + {{~#if page.slotLabel}} + + ({{~#if (and @root.flags.show-resources page.dataset.level)~}} + + {{~page.uses~}} + / + {{~/if~}} + {{{page.slotLabel}}}){{~" "~}} + + {{~/if~}} + {{~localize "MOBLOKS5E.Colon"}} + {{> "modules/monsterblock/templates/dnd5e/parts/spellcasting/spell-list.hbs" spells=page.spells level=page.level}} +
  • \ No newline at end of file diff --git a/templates/dnd5e/parts/spellcasting/spellbook.hbs b/templates/dnd5e/parts/spellcasting/spellbook.hbs new file mode 100644 index 0000000..8ff1edc --- /dev/null +++ b/templates/dnd5e/parts/spellcasting/spellbook.hbs @@ -0,0 +1,7 @@ +
      +{{#each spellbook.pages as |page id|}} + {{#if page.spells}} + {{> "modules/monsterblock/templates/dnd5e/parts/spellcasting/spellbook-page.hbs" page=page id=id}} + {{/if}} +{{/each}} +
    \ No newline at end of file diff --git a/templates/dnd5e/parts/spellcasting/spellcasting-redux.hbs b/templates/dnd5e/parts/spellcasting/spellcasting-redux.hbs new file mode 100644 index 0000000..a083233 --- /dev/null +++ b/templates/dnd5e/parts/spellcasting/spellcasting-redux.hbs @@ -0,0 +1,9 @@ +
    +
    + {{spellbook.title}}. + {{{spellbook.introText}}} + + {{> "modules/monsterblock/templates/dnd5e/parts/spellcasting/spellbook.hbs" spellbook=spellbook}} + +
    +
    \ No newline at end of file