diff --git a/manifest-beta.json b/manifest-beta.json new file mode 100644 index 00000000..ec4237f7 --- /dev/null +++ b/manifest-beta.json @@ -0,0 +1,10 @@ +{ + "id": "obsidian-5e-statblocks", + "name": "5e Statblocks", + "version": "2.0.0", + "description": "Create 5e styled statblocks in Obsidian.md", + "minAppVersion": "0.12.0", + "author": "Jeremy Valentine", + "authorUrl": "", + "isDesktopOnly": false +} \ No newline at end of file diff --git a/src/data/constants.ts b/src/data/constants.ts index 0c5dec33..7d86f95a 100644 --- a/src/data/constants.ts +++ b/src/data/constants.ts @@ -2,6 +2,7 @@ import type { Monster } from "@types"; interface CR { cr: string; + value: number; xp: number; } @@ -12,7 +13,9 @@ export type StatblockItemType = | "property" | "table" | "saves" - | "spells"; + | "spells" + | "inline" + | "group"; export interface StatblockItem { type: StatblockItemType; @@ -31,48 +34,65 @@ export interface StatblockItem { conditioned?: boolean; parse?: boolean; }; + nested?: StatblockItem[]; + callback?: (monster: Monster) => string; } export const Statblock5e: StatblockItem[] = [ { - type: "heading", - properties: ["name"], - saveIcon: true, - downloadIcon: true, - hasRule: false, - conditioned: true - }, - { - type: "subheading", - properties: ["size", "type", "subtype", "alignment"], - hasRule: true, - conditioned: true - }, - { - type: "property", - properties: ["ac"], - display: "Armor Class", - conditioned: true - }, - { - type: "property", - properties: ["hp"], - display: "Hit Points", - dice: { - default: "hp", - text: "hit_dice", - conditioned: true, - parse: false - }, - conditioned: true + type: "group", + properties: ["name", "size", "type", "subtype", "alignment"], + nested: [ + { + type: "heading", + properties: ["name"], + saveIcon: true, + downloadIcon: true, + conditioned: true + }, + { + type: "subheading", + properties: ["size", "type", "subtype", "alignment"], + conditioned: true + } + ], + conditioned: true, + hasRule: true }, + { - type: "property", - display: "Speed", - properties: ["speed"], + type: "group", + properties: ["ac", "hp", "speed"], + nested: [ + { + type: "property", + properties: ["ac"], + display: "Armor Class", + conditioned: true + }, + { + type: "property", + properties: ["hp"], + display: "Hit Points", + dice: { + default: "hp", + text: "hit_dice", + conditioned: true, + parse: false + }, + conditioned: true + }, + { + type: "property", + display: "Speed", + properties: ["speed"], + conditioned: true + } + ], hasRule: true, conditioned: true }, + { type: "table", properties: ["stats"], @@ -80,56 +100,107 @@ export const Statblock5e: StatblockItem[] = [ hasRule: true, conditioned: true }, + { - type: "saves", - display: "Saves", - properties: ["saves"], - conditioned: true - }, - { - type: "saves", - display: "Skills", - properties: ["skillsaves"], - conditioned: true - }, - { - type: "property", - display: "Damage Immunities", - properties: ["damage_immunities"], - conditioned: true - }, - { - type: "property", - display: "Condition Immunities", - properties: ["condition_immunities"], - conditioned: true - }, - { - type: "property", - display: "Resistances", - properties: ["damage_resistances"], - conditioned: true - }, - { - type: "property", - display: "Damage Vulnerabilities", - properties: ["damage_vulnerabilities"], - conditioned: true - }, - { - type: "property", - display: "Senses", - properties: ["senses"], - conditioned: true - }, - { - type: "property", - display: "Languages", - properties: ["languages"], + type: "group", + properties: [ + "saves", + "skillsaves", + "damage_immunities", + "damage_resistances", + "damage_vulnerabilities", + "condition_immunities", + "cr", + "languages", + "senses" + ], + nested: [ + { + type: "saves", + display: "Saves", + properties: ["saves"], + conditioned: true + }, + { + type: "saves", + display: "Skills", + properties: ["skillsaves"], + conditioned: true + }, + { + type: "property", + display: "Damage Immunities", + properties: ["damage_immunities"], + conditioned: true + }, + { + type: "property", + display: "Condition Immunities", + properties: ["condition_immunities"], + conditioned: true + }, + { + type: "property", + display: "Resistances", + properties: ["damage_resistances"], + conditioned: true + }, + { + type: "property", + display: "Damage Vulnerabilities", + properties: ["damage_vulnerabilities"], + conditioned: true + }, + { + type: "property", + display: "Senses", + properties: ["senses"], + conditioned: true + }, + { + type: "property", + display: "Languages", + properties: ["languages"], + fallback: "-" + }, + { + type: "inline", + properties: [], + conditioned: true, + nested: [ + { + type: "property", + display: "Challenge", + properties: ["cr"], + callback: (monster) => { + if ("cr" in monster && monster.cr in CR) { + return `${monster.cr} (${CR[ + monster.cr + ].xp.toLocaleString()} XP)`; + } + return ""; + } + }, + { + type: "property", + display: "Proficiency Bonus", + properties: ["cr"], + callback: (monster) => { + if ("cr" in monster) { + return `+${Math.floor( + 2 + (CR[monster.cr].value - 1) / 4 + )}`; + } + return ""; + } + } + ] + } + ], conditioned: true, - fallback: "-", hasRule: true }, + { type: "section", properties: ["traits"], @@ -178,138 +249,173 @@ export const Statblock5e: StatblockItem[] = [ export const CR: { [key: string]: CR } = { "0": { cr: "0", + value: 0, xp: 0 }, "1/8": { cr: "1/8", + value: 0.125, xp: 25 }, "1/4": { cr: "1/4", + + value: 0.25, xp: 50 }, "1/2": { cr: "1/2", + value: 0.5, xp: 100 }, "1": { cr: "1", + value: 1, xp: 200 }, "2": { cr: "2", + value: 2, xp: 450 }, "3": { cr: "3", + value: 3, xp: 700 }, "4": { cr: "4", + value: 4, xp: 1100 }, "5": { cr: "5", + value: 5, xp: 1800 }, "6": { cr: "6", + value: 6, xp: 2300 }, "7": { cr: "7", + value: 7, xp: 2900 }, "8": { cr: "8", + value: 8, xp: 3900 }, "9": { cr: "9", + value: 9, xp: 5000 }, "10": { cr: "10", + value: 10, xp: 5900 }, "11": { cr: "11", + value: 11, xp: 7200 }, "12": { cr: "12", + value: 12, xp: 8400 }, "13": { cr: "13", + value: 13, xp: 10000 }, "14": { cr: "14", + value: 14, xp: 11500 }, "15": { cr: "15", + value: 15, xp: 13000 }, "16": { cr: "16", + value: 16, xp: 15000 }, "17": { cr: "17", + value: 17, xp: 18000 }, "18": { cr: "18", + value: 18, xp: 20000 }, "19": { cr: "19", + value: 19, xp: 22000 }, "20": { cr: "20", + value: 20, xp: 25000 }, "21": { cr: "21", + value: 21, xp: 33000 }, "22": { cr: "22", + value: 22, xp: 41000 }, "23": { cr: "23", + value: 23, xp: 50000 }, "24": { cr: "24", + value: 24, xp: 62000 }, "25": { cr: "25", + value: 25, xp: 75000 }, "26": { cr: "26", + value: 26, xp: 90000 }, "27": { cr: "27", + value: 27, xp: 105000 }, "28": { cr: "28", + value: 28, xp: 120000 }, "29": { cr: "29", + value: 29, xp: 135000 }, "30": { cr: "30", + value: 30, xp: 155000 } }; diff --git a/src/main.ts b/src/main.ts index 625a92f1..6e3fa647 100644 --- a/src/main.ts +++ b/src/main.ts @@ -210,12 +210,18 @@ export default class StatBlockPlugin extends Plugin { console.log("5e StatBlocks unloaded"); } - exportAsPng(name: string, containerEl: HTMLElement) { + exportAsPng(name: string, containerEl: Element) { function filter(node: HTMLElement) { return !node.hasClass || !node.hasClass("clickable-icon"); } + const content = + containerEl.querySelector(".statblock-content"); + if (content) delete content.style["boxShadow"]; domtoimage - .toPng(containerEl, { filter: filter, style: { height: "100%" } }) + .toPng(containerEl, { + filter: filter, + style: { height: "100%" } + }) .then((url) => { const link = document.createElement("a"); link.download = name + ".png"; diff --git a/src/view/Statblock.svelte b/src/view/Statblock.svelte index c3b5f670..9eb79f63 100644 --- a/src/view/Statblock.svelte +++ b/src/view/Statblock.svelte @@ -11,6 +11,8 @@ export let monster: Monster; export let plugin: StatBlockPlugin; export let statblock: StatblockItem[]; + export let canSave: boolean; + export let canExport: boolean; setContext("plugin", plugin); @@ -46,7 +48,16 @@ {#key columns} - + {/key} {:else} diff --git a/src/view/statblock.ts b/src/view/statblock.ts index e4e9b931..d8f23f3d 100644 --- a/src/view/statblock.ts +++ b/src/view/statblock.ts @@ -30,14 +30,26 @@ export default class StatBlockRenderer extends MarkdownRenderChild { ) { super(container); - new Statblock({ + const statblock = new Statblock({ target: this.containerEl, props: { monster: this.monster, statblock: this.statblock, - plugin: this.plugin + plugin: this.plugin, + canSave: this.canSave, + canExport: this.canExport } }); + statblock.$on("save", () => { + this.plugin.saveMonster({ ...this.monster, source: "Homebrew" }); + }); + + statblock.$on("export", () => { + this.plugin.exportAsPng( + this.monster.name, + this.containerEl.firstElementChild + ); + }); } old() { diff --git a/src/view/ui/Content.svelte b/src/view/ui/Content.svelte index 67c21832..0a1582e3 100644 --- a/src/view/ui/Content.svelte +++ b/src/view/ui/Content.svelte @@ -11,144 +11,194 @@ import SectionHeading from "./SectionHeading.svelte"; import Subheading from "./Subheading.svelte"; import Table from "./Table.svelte"; - import { getContext, onMount, tick } from "svelte"; + import { getContext, onMount, createEventDispatcher } from "svelte"; + + const dispatch = createEventDispatcher(); export let monster: Monster; export let statblock: StatblockItem[]; export let columns: number = 1; export let ready: boolean; + export let canExport: boolean; + export let canSave: boolean; - const plugin = getContext("plugin"); + const checkConditioned = (item: StatblockItem) => { + if (!item.conditioned) return true; + if (!item.properties.length) return true; + return item.properties.some((prop) => { + if (prop in monster) { + if ( + Array.isArray(monster[prop]) && + (monster[prop] as Array).length + ) { + return true; + } + if ( + typeof monster[prop] === "string" && + (monster[prop] as string).length + ) { + return true; + } + if (typeof monster[prop] === "number") { + return true; + } + } + return false; + }); + }; - const buildStatblock = (node: HTMLElement) => { - node.empty(); - let columnEl = node.createDiv("column"); - const targets: HTMLElement[] = []; + const plugin = getContext("plugin"); - for (let item of statblock) { - const target = createDiv("statblock-item-container"); + const getElementForStatblockItem = ( + item: StatblockItem, + container?: HTMLDivElement + ): HTMLDivElement[] => { + const targets: HTMLDivElement[] = []; + const target = container ?? createDiv("statblock-item-container"); - if (item.conditioned) { - if (!item.properties.some((prop) => prop in monster)) continue; + if (!checkConditioned(item)) { + return []; + } + targets.push(target); + switch (item.type) { + case "heading": { + const heading = new Heading({ + target, + props: { + monster, + item, + canSave, + canExport + }, + context: new Map([["plugin", plugin]]) + }); + heading.$on("save", (e) => dispatch("save", e.detail)); + heading.$on("export", (e) => dispatch("export", e.detail)); + break; } - switch (item.type) { - case "heading": { - new Heading({ - target, - props: { - monster, - item - }, - context: new Map([["plugin", plugin]]) - }); - break; - } - case "property": { - new PropertyLine({ - target, - props: { - monster, - item - }, - context: new Map([["plugin", plugin]]) - }); - break; - } - case "saves": { - new Saves({ + case "property": { + new PropertyLine({ + target, + props: { + monster, + item + }, + context: new Map([["plugin", plugin]]) + }); + break; + } + case "saves": { + new Saves({ + target, + props: { + monster, + item + }, + context: new Map([["plugin", plugin]]) + }); + break; + } + case "section": { + const blocks: Trait[] = monster[item.properties[0]] as Trait[]; + if (!Array.isArray(blocks) || !blocks.length) return []; + + if (item.heading) { + new SectionHeading({ target, props: { - monster, - item + header: item.heading }, context: new Map([["plugin", plugin]]) }); - break; } - case "section": { - const blocks: Trait[] = monster[ - item.properties[0] - ] as Trait[]; - if (!Array.isArray(blocks) || !blocks.length) continue; - - if (item.heading) { - new SectionHeading({ - target, + try { + for (const block of blocks) { + const prop = target.createDiv( + "statblock-item-container" + ); + new PropertyBlock({ + target: prop, props: { - header: item.heading + name: block.name, + desc: block.desc, + dice: item.dice && item.dice.parse }, context: new Map([["plugin", plugin]]) }); - targets.push(target); - } - try { - for (const block of blocks) { - const prop = createDiv("statblock-item-container"); - new PropertyBlock({ - target: prop, - props: { - name: block.name, - desc: block.desc, - dice: item.dice && item.dice.parse - }, - context: new Map([["plugin", plugin]]) - }); - targets.push(prop); - } - } catch (e) { - continue; + targets.push(prop); } - - break; + } catch (e) { + return []; } - case "spells": { - const blocks: Trait[] = monster[ - item.properties[0] - ] as Trait[]; - if (!Array.isArray(blocks) || !blocks.length) continue; + break; + } + case "spells": { + const blocks: Trait[] = monster[item.properties[0]] as Trait[]; + if (!Array.isArray(blocks) || !blocks.length) return; - new Spells({ - target, - props: { - monster - }, - context: new Map([["plugin", plugin]]) - }); - break; - } - case "subheading": { - new Subheading({ - target, - props: { - monster, - item - }, - context: new Map([["plugin", plugin]]) - }); - break; - } - case "table": { - new Table({ - target, - props: { - monster, - item - }, - context: new Map([["plugin", plugin]]) - }); - break; - } - default: { - continue; - } + new Spells({ + target, + props: { + monster + }, + context: new Map([["plugin", plugin]]) + }); + break; } - if (item.hasRule) { - new Rule({ - target + case "subheading": { + new Subheading({ + target, + props: { + monster, + item + }, + context: new Map([["plugin", plugin]]) }); + break; } + case "table": { + new Table({ + target, + props: { + monster, + item + }, + context: new Map([["plugin", plugin]]) + }); + break; + } + case "inline": { + const inline = target.createDiv("statblock-item-inline"); + for (const nested of item.nested ?? []) { + getElementForStatblockItem(nested, inline); + } + break; + } + case "group": { + for (const nested of item.nested ?? []) { + targets.push(...getElementForStatblockItem(nested)); + } + + break; + } + } + if (item.hasRule) { + const rule = createDiv("statblock-item-container"); + new Rule({ + target: rule + }); + targets.push(rule); + } + return targets; + }; - if (item.type != "section") targets.push(target); + const buildStatblock = (node: HTMLElement) => { + node.empty(); + let columnEl = node.createDiv("column"); + const targets: HTMLElement[] = []; + + for (let item of statblock) { + targets.push(...getElementForStatblockItem(item)); } const temp = document.body.createDiv("statblock-detached"); @@ -219,4 +269,9 @@ position: absolute; top: -9999px; } + + :global(.statblock-item-inline) { + display: flex; + justify-content: space-between; + } diff --git a/src/view/ui/Heading.svelte b/src/view/ui/Heading.svelte index 04a841a5..f4313a03 100644 --- a/src/view/ui/Heading.svelte +++ b/src/view/ui/Heading.svelte @@ -11,13 +11,15 @@ const dispatch = createEventDispatcher(); export let monster: Monster; export let item: StatblockItem; + export let canSave: boolean; + export let canExport: boolean; const save = (node: HTMLElement) => { new ExtraButtonComponent(node) .setIcon(SAVE_SYMBOL) .setTooltip("Save as Homebrew") .onClick(() => { - dispatch("save", { ...monster, source: "Homebrew" }); + dispatch("save"); }); }; const png = (node: HTMLElement) => { @@ -38,10 +40,10 @@ {/each} {#if item.saveIcon || item.downloadIcon}
- {#if item.saveIcon} + {#if item.saveIcon && canSave}
{/if} - {#if item.downloadIcon} + {#if item.downloadIcon && canExport}
{/if}
diff --git a/src/view/ui/PropertyLine.svelte b/src/view/ui/PropertyLine.svelte index 72ec9aef..6aaba6a3 100644 --- a/src/view/ui/PropertyLine.svelte +++ b/src/view/ui/PropertyLine.svelte @@ -6,8 +6,12 @@ export let monster: Monster; export let item: StatblockItem; - const property = monster[item.properties[0]]; - const display = item.display ?? item.properties[0]; + let property = monster[item.properties[0]]; + let display = item.display ?? item.properties[0]; + + if (item.callback) { + property = item.callback(monster) ?? property; + } let dice = false, def: number, text: string;