├── versions.json ├── img ├── kyr.PNG ├── goblin.png ├── ythanar.PNG ├── encounter.PNG ├── player-setup.PNG ├── encounter-tooltip.PNG ├── multiple_monsters.PNG ├── initiative-tracker.PNG └── initiative-tracker-integration.png ├── .gitignore ├── manifest-beta.json ├── manifest.json ├── @types └── index.d.ts ├── tsconfig.json ├── rollup.config.js ├── package.json ├── src ├── svelte │ ├── Encounter.svelte │ └── Monster.svelte ├── lib │ ├── constants.ts │ ├── encounter-difficulty.ts │ └── monster.ts └── main.ts ├── .github └── workflows │ └── release.yml └── README.md /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.1": "0.9.12", 3 | "1.0.0": "0.9.7" 4 | } 5 | -------------------------------------------------------------------------------- /img/kyr.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-bauer/obsidian-quick-monsters/HEAD/img/kyr.PNG -------------------------------------------------------------------------------- /img/goblin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-bauer/obsidian-quick-monsters/HEAD/img/goblin.png -------------------------------------------------------------------------------- /img/ythanar.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-bauer/obsidian-quick-monsters/HEAD/img/ythanar.PNG -------------------------------------------------------------------------------- /img/encounter.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-bauer/obsidian-quick-monsters/HEAD/img/encounter.PNG -------------------------------------------------------------------------------- /img/player-setup.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-bauer/obsidian-quick-monsters/HEAD/img/player-setup.PNG -------------------------------------------------------------------------------- /img/encounter-tooltip.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-bauer/obsidian-quick-monsters/HEAD/img/encounter-tooltip.PNG -------------------------------------------------------------------------------- /img/multiple_monsters.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-bauer/obsidian-quick-monsters/HEAD/img/multiple_monsters.PNG -------------------------------------------------------------------------------- /img/initiative-tracker.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-bauer/obsidian-quick-monsters/HEAD/img/initiative-tracker.PNG -------------------------------------------------------------------------------- /img/initiative-tracker-integration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-bauer/obsidian-quick-monsters/HEAD/img/initiative-tracker-integration.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | package-lock.json 8 | 9 | # build 10 | main.js 11 | *.js.map 12 | 13 | # obsidian 14 | data.json 15 | -------------------------------------------------------------------------------- /manifest-beta.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "quick-monsters-5e", 3 | "name": "Quick Monsters 5e", 4 | "version": "0.6.1", 5 | "minAppVersion": "0.9.12", 6 | "description": "Build Simple Monster Stat Blocks and Encounters from Challenge Rating.", 7 | "author": "g-bauer", 8 | "isDesktopOnly": false 9 | } 10 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "quick-monsters-5e", 3 | "name": "Quick Monsters 5e", 4 | "version": "0.6.1", 5 | "minAppVersion": "0.9.12", 6 | "description": "Build Simple Monster Stat Blocks and Encounters from Challenge Rating.", 7 | "author": "g-bauer", 8 | "isDesktopOnly": false 9 | } 10 | -------------------------------------------------------------------------------- /@types/index.d.ts: -------------------------------------------------------------------------------- 1 | export type XpBudget = { easy: number; medium: number; hard: number; deadly: number }; 2 | 3 | export type DifficultyReport = { 4 | difficulty: string; 5 | group: Record, 6 | totalXp: number; 7 | adjustedXp: number; 8 | multiplier: number; 9 | budget: XpBudget; 10 | }; 11 | 12 | export interface BudgetDict { 13 | [index: number]: XpBudget; 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "es6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "types": ["svelte", "node"], 13 | "lib": [ 14 | "dom", 15 | "es5", 16 | "scripthost", 17 | "es2019" 18 | ] 19 | }, 20 | "include": ["**/*.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import {nodeResolve} from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | 5 | import svelte from "rollup-plugin-svelte"; 6 | import process from "svelte-preprocess"; 7 | 8 | const banner = 9 | `/* 10 | THIS IS A GENERATED/BUNDLED FILE BY ROLLUP 11 | if you want to view the source visit the plugins github repository 12 | */ 13 | `; 14 | 15 | export default { 16 | input: './src/main.ts', 17 | output: { 18 | dir: '.', 19 | sourcemap: 'inline', 20 | sourcemapExcludeSources: true, 21 | format: 'cjs', 22 | exports: 'default', 23 | banner, 24 | }, 25 | external: ['obsidian'], 26 | plugins: [ 27 | typescript(), 28 | svelte({ emitCss: false, preprocess: process() }), 29 | nodeResolve({browser: true}), 30 | commonjs(), 31 | ] 32 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quick-monsters-5e", 3 | "version": "0.6.1", 4 | "description": "Build Simple Monster Stat Blocks from Challenge Rating.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "rollup --config rollup.config.js -w", 8 | "build": "rollup --config rollup.config.js --environment BUILD:production" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@rollup/plugin-commonjs": "^18.0.0", 15 | "@rollup/plugin-node-resolve": "^11.2.1", 16 | "@rollup/plugin-typescript": "^8.2.1", 17 | "@tsconfig/svelte": "^2.0.1", 18 | "@types/node": "^14.14.37", 19 | "obsidian": "^0.12.0", 20 | "rollup": "^2.32.1", 21 | "rollup-plugin-svelte": "^7.1.0", 22 | "svelte": "^3.42.6", 23 | "svelte-loader": "^3.1.2", 24 | "svelte-preprocess": "^4.9.4", 25 | "tslib": "^2.2.0", 26 | "typescript": "^4.2.4" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/svelte/Encounter.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 |
27 |
28 | {#if tracker} 29 | 32 |
33 | {/if} 34 | Difficulty: 36 | {difficulty.difficulty.toUpperCase()} 37 | 38 | 39 |
40 |
41 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import type { BudgetDict } from "@types"; 2 | import { addIcon } from "obsidian"; 3 | import { ICONS } from "src/lib/icons"; 4 | 5 | addIcon("begin_encounter", ICONS["crossed-swords"]); 6 | 7 | export const XP_PER_CR: Record = { 8 | "0": 0, 9 | "1/8": 25, 10 | "1/4": 50, 11 | "1/2": 100, 12 | "1": 200, 13 | "2": 450, 14 | "3": 700, 15 | "4": 1100, 16 | "5": 1800, 17 | "6": 2300, 18 | "7": 2900, 19 | "8": 3900, 20 | "9": 5000, 21 | "10": 5900, 22 | "11": 7200, 23 | "12": 8400, 24 | "13": 10000, 25 | "14": 11500, 26 | "15": 13000, 27 | "16": 15000, 28 | "17": 18000, 29 | "18": 20000, 30 | "19": 22000, 31 | "20": 25000, 32 | "21": 33000, 33 | "22": 41000, 34 | "23": 50000, 35 | "24": 62000, 36 | "25": 75000, 37 | "26": 90000, 38 | "27": 105000, 39 | "28": 120000, 40 | "29": 135000, 41 | "30": 155000 42 | }; 43 | 44 | export const DIFFICULTY_THRESHOLDS: BudgetDict = { 45 | 1: { easy: 25, medium: 50, hard: 75, deadly: 100 }, 46 | 2: { easy: 50, medium: 100, hard: 150, deadly: 200 }, 47 | 3: { easy: 75, medium: 150, hard: 225, deadly: 400 }, 48 | 4: { easy: 125, medium: 250, hard: 375, deadly: 500 }, 49 | 5: { easy: 250, medium: 500, hard: 750, deadly: 1100 }, 50 | 6: { easy: 300, medium: 600, hard: 900, deadly: 1400 }, 51 | 7: { easy: 350, medium: 750, hard: 1100, deadly: 1700 }, 52 | 8: { easy: 450, medium: 900, hard: 1400, deadly: 2100 }, 53 | 9: { easy: 550, medium: 1100, hard: 1600, deadly: 2400 }, 54 | 10: { easy: 600, medium: 1200, hard: 1900, deadly: 2800 }, 55 | 11: { easy: 800, medium: 1600, hard: 2400, deadly: 3600 }, 56 | 12: { easy: 1000, medium: 2000, hard: 3000, deadly: 4500 }, 57 | 13: { easy: 1100, medium: 2200, hard: 3400, deadly: 5100 }, 58 | 14: { easy: 1250, medium: 2500, hard: 3800, deadly: 5700 }, 59 | 15: { easy: 1400, medium: 2800, hard: 4300, deadly: 6400 }, 60 | 16: { easy: 1600, medium: 3200, hard: 4800, deadly: 7200 }, 61 | 17: { easy: 2000, medium: 3900, hard: 5900, deadly: 8800 }, 62 | 18: { easy: 2100, medium: 4200, hard: 6300, deadly: 9500 }, 63 | 19: { easy: 2400, medium: 4900, hard: 7300, deadly: 10900 }, 64 | 20: { easy: 2800, medium: 5700, hard: 8500, deadly: 12700 } 65 | }; -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | env: 9 | PLUGIN_NAME: obsidian-quick-monsters 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: "16.x" 21 | - name: Build 22 | id: build 23 | run: | 24 | npm install 25 | npm run build --if-present 26 | mkdir ${{ env.PLUGIN_NAME }} 27 | cp main.js manifest.json ${{ env.PLUGIN_NAME }} 28 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 29 | ls 30 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 31 | - name: Create Release 32 | id: create_release 33 | uses: actions/create-release@v1 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | VERSION: ${{ github.ref }} 37 | with: 38 | tag_name: ${{ github.ref }} 39 | release_name: ${{ github.ref }} 40 | draft: false 41 | prerelease: false 42 | - name: Upload zip file 43 | id: upload-zip 44 | uses: actions/upload-release-asset@v1 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | with: 48 | upload_url: ${{ steps.create_release.outputs.upload_url }} 49 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 50 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 51 | asset_content_type: application/zip 52 | - name: Upload main.js 53 | id: upload-main 54 | uses: actions/upload-release-asset@v1 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | with: 58 | upload_url: ${{ steps.create_release.outputs.upload_url }} 59 | asset_path: ./main.js 60 | asset_name: main.js 61 | asset_content_type: text/javascript 62 | - name: Upload manifest.json 63 | id: upload-manifest 64 | uses: actions/upload-release-asset@v1 65 | env: 66 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | with: 68 | upload_url: ${{ steps.create_release.outputs.upload_url }} 69 | asset_path: ./manifest.json 70 | asset_name: manifest.json 71 | asset_content_type: application/json 72 | -------------------------------------------------------------------------------- /src/svelte/Monster.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 | {#if displayType === "list"} 11 |
    12 | {#each monsters as monster} 13 |
  • 14 | 15 | {monster.amount}x {monster.name} (CR {monster.cr}) 16 | {@html ICONS["health"]} 17 | {monster.hp} 18 | {@html ICONS["shield"]} 19 | {monster.ac} 20 | {@html ICONS["player-thunder-struck"]} 21 | {monster.save} 22 | {@html ICONS["archery-target"]} 23 | {monster.toHit} 24 | {@html ICONS["bowie-knife"]} 25 | {@html monster.damageToDiceCode()} 26 | {@html ICONS["dragon-breath"]} 27 | {monster.dc} 28 | 29 |
  • 30 | {/each} 31 |
32 | {:else} 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 43 | 44 | 45 | 46 | 47 | 48 | 51 | 52 | 53 | 54 | 55 | {#each monsters as monster} 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | {/each} 68 |
Name{@html ICONS["health"]}{@html ICONS["shield"]}{@html ICONS["player-thunder-struck"]}{@html ICONS["archery-target"]}{@html ICONS["bowie-knife"]}{@html ICONS["dragon-breath"]}CR#
{monster.name}{monster.hp}{monster.ac}{monster.save}{monster.toHit}{@html monster.damageToDiceCode().readable} {monster.dc}{monster.cr}{monster.amount}
69 | {/if} 70 |
71 | 72 | 78 | -------------------------------------------------------------------------------- /src/lib/encounter-difficulty.ts: -------------------------------------------------------------------------------- 1 | import { XP_PER_CR, DIFFICULTY_THRESHOLDS } from "src/lib/constants"; 2 | import type { XpBudget, DifficultyReport } from "@types"; 3 | 4 | function xpBudget(characterLevels: number[]): XpBudget { 5 | const easy = characterLevels.reduce( 6 | (acc, lvl) => acc + DIFFICULTY_THRESHOLDS[lvl].easy, 7 | 0 8 | ); 9 | const medium = characterLevels.reduce( 10 | (acc, lvl) => acc + DIFFICULTY_THRESHOLDS[lvl].medium, 11 | 0 12 | ); 13 | const hard = characterLevels.reduce( 14 | (acc, lvl) => acc + DIFFICULTY_THRESHOLDS[lvl].hard, 15 | 0 16 | ); 17 | const deadly = characterLevels.reduce( 18 | (acc, lvl) => acc + DIFFICULTY_THRESHOLDS[lvl].deadly, 19 | 0 20 | ); 21 | return { easy: easy, medium: medium, hard: hard, deadly: deadly }; 22 | } 23 | 24 | export function formatDifficultyReport(report: DifficultyReport): string { 25 | const group = Object.entries(report.group).map((c) => `${c[1]} level ${c[0]} character${c[1] === 1 ? "" : "s"}`).join(", ") 26 | return `${[ 27 | `Encounter is ${report.difficulty}`, 28 | `Total XP: ${report.totalXp}`, 29 | `Adjusted XP: ${report.adjustedXp} (x${report.multiplier})`, 30 | ` `, 31 | `Threshold (${group})`, 32 | `Easy: ${report.budget.easy}`, 33 | `Medium: ${report.budget.medium}`, 34 | `Hard: ${report.budget.hard}`, 35 | `Deadly: ${report.budget.deadly}` 36 | ].join("\n")}`; 37 | } 38 | 39 | export function encounterDifficulty( 40 | characterLevels: number[], 41 | monsterXp: number[] 42 | ): DifficultyReport { 43 | if (!characterLevels?.length || !monsterXp?.length) return; 44 | const xp: number = monsterXp.reduce((acc, xp) => acc + xp, 0); 45 | const numberOfMonsters = monsterXp.length; 46 | let numberMultiplier: number; 47 | let group = {}; 48 | characterLevels.forEach((level) => { group[level] = group[level] ? group[level] += 1 : 1 }); 49 | if (numberOfMonsters === 1) { 50 | numberMultiplier = 1; 51 | } else if (numberOfMonsters === 2) { 52 | numberMultiplier = 1.5; 53 | } else if (numberOfMonsters < 7) { 54 | numberMultiplier = 2.0; 55 | } else if (numberOfMonsters < 11) { 56 | numberMultiplier = 2.5; 57 | } else if (numberOfMonsters < 15) { 58 | numberMultiplier = 3.0; 59 | } else { 60 | numberMultiplier = 4.0; 61 | } 62 | const adjustedXp = numberMultiplier * xp; 63 | const budget = xpBudget(characterLevels); 64 | let difficulty = "easy"; 65 | if (adjustedXp >= budget.deadly) { 66 | difficulty = "deadly"; 67 | } else if (adjustedXp >= budget.hard) { 68 | difficulty = "hard"; 69 | } else if (adjustedXp >= budget.medium) { 70 | difficulty = "medium"; 71 | } 72 | let result = { 73 | difficulty: difficulty, 74 | group: group, 75 | totalXp: xp, 76 | adjustedXp: adjustedXp, 77 | multiplier: numberMultiplier, 78 | budget: budget 79 | }; 80 | return result; 81 | } 82 | -------------------------------------------------------------------------------- /src/lib/monster.ts: -------------------------------------------------------------------------------- 1 | import { XP_PER_CR } from "src/lib/constants"; 2 | 3 | const specialCrs = new Set([0, "0", "1/8", "1/4", "1/2"]); 4 | type specialCr = typeof specialCrs[]; 5 | 6 | export class QuickMonster { 7 | name: string; 8 | cr: string | number; 9 | crNumeric: number; 10 | hp: number; 11 | ac: number; 12 | save: number; 13 | toHit: number; 14 | damage: number; 15 | dc: number; 16 | damageDice: number; 17 | multiAttack: number; 18 | amount: number; 19 | modifier: number; 20 | xp: number; 21 | 22 | constructor(name: string, cr: string | number, damageDice?: number, multiAttack?: number, amount?: number, ini?: number) { 23 | this.name = name; 24 | this.cr = cr; 25 | this.xp = XP_PER_CR[cr]; 26 | 27 | if (specialCrs.has(cr)) { 28 | if (cr === "0" || cr === 0) { 29 | this.crNumeric = -3; 30 | this.hp = 3; 31 | this.damage = 1; 32 | } else if (cr === "1/8") { 33 | this.crNumeric = -2; 34 | this.hp = 9; 35 | this.damage = 3; 36 | } else if (cr === "1/4") { 37 | this.crNumeric = -1; 38 | this.hp = 15; 39 | this.damage = 5; 40 | } else if (cr === "1/2") { 41 | this.crNumeric = 0; 42 | this.hp = 24; 43 | this.damage = 8; 44 | } 45 | } else if (typeof cr === "number") { 46 | if (!Number.isInteger(cr) && cr > 0) { 47 | throw `cr: "${cr}" cannot be parsed! Please use a positive integer or one of '1/8', '1/4, '1/2'`; 48 | } 49 | this.crNumeric = cr; 50 | this.damage = 5 + 5 * this.crNumeric; 51 | this.hp = 3 * this.damage; 52 | } else { 53 | throw `cr: "${cr}" cannot be parsed! Please use a positive integer or one of '1/8', '1/4, '1/2'`; 54 | } 55 | this.toHit = Math.round(4 + 0.5 * this.crNumeric); 56 | this.dc = Math.round(11 + 0.5 * this.crNumeric); 57 | this.ac = Math.round(13 + 1.0 / 3.0 * this.crNumeric); 58 | this.save = Math.round(3 + 0.5 * this.crNumeric); 59 | 60 | this.multiAttack = multiAttack ?? 1; 61 | this.damageDice = damageDice ?? 6; 62 | this.amount = amount ?? 1; 63 | // initiative modifier for combat tracker 64 | this.modifier = ini ?? 0; 65 | } 66 | 67 | damageToDiceCode(): { readable: string, diceCode: string } { 68 | const damagePerAttack = this.damage / this.multiAttack; 69 | const diceMean = 0.5 * (this.damageDice + 1); 70 | let numberOfDice = Math.round(damagePerAttack / diceMean); 71 | let rem = Math.round(damagePerAttack - numberOfDice * diceMean); 72 | if (rem < 0) { 73 | numberOfDice -= 1; 74 | rem = Math.round(damagePerAttack - numberOfDice * diceMean); 75 | } 76 | const diceResult = numberOfDice === 0 ? '' : `${numberOfDice}d${this.damageDice}`; 77 | const staticResult = rem === 0 ? '' : `${Math.abs(rem)}`; 78 | const sign = diceResult === '' || staticResult === '' ? '' : '+'; 79 | const maString = this.multiAttack === 1 ? '' : `Multiattack(${this.multiAttack}), each `; 80 | const readable = `${maString} ${Math.round( 81 | damagePerAttack 82 | )} (${diceResult}${sign}${staticResult})`; 83 | return { 84 | readable: readable, 85 | diceCode: `${diceResult}${sign}${staticResult}` 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Create Monster Stat Blocks and Encounters Quickly - for Obsidian.md 2 | 3 | This plugin can be used to: 4 | 5 | 1. create simplified stat blocks for 5e monsters given their challenge rating (CR) 6 | 2. and compute encounter difficulties, and 7 | 3. run the encounter if you have [initiative-tracker](https://github.com/valentine195/obsidian-initiative-tracker) installed and enabled. 8 | 9 | The created stats are based on the analysis of published 5e monsters done by Paul Hughes. Please, read the original post on his blog "Blog of Holding" titled **["5e monster manual on a business card"](http://blogofholding.com/?p=7338)**. 10 | 11 | ## Usage in Obsidian: `quick-monster` 12 | 13 | In your note, use 14 | 15 | ````yaml 16 | ```quick-monster 17 | - { name: Goblin, cr: 1/4 } 18 | ``` 19 | ```` 20 | 21 | which will render as 22 | 23 | 24 | 25 | The stats shown are: 26 | - Name 27 | - Hit Points (HP) 28 | - Armor Class (AC)git 29 | - Best Saving Throw 30 | - To Hit Bonus 31 | - Damage 32 | - DC (of any ability or spell) 33 | - CR (Challenge Rating) 34 | - Number of monsters (important for encounters) 35 | 36 | The same result can be achieved via 37 | 38 | ````yaml 39 | ```quick-monster 40 | - name: Goblin 41 | cr: 1/4 42 | ``` 43 | ```` 44 | 45 | The input must be a YAML array. You can add multiple monster like so: 46 | 47 | ````yaml 48 | ```quick-monster 49 | - { name: Goblin, cr: 1/4 } 50 | - { name: Goblin Boss, cr: 1/2 } 51 | - { name: Dire Wolf, cr: 1 } 52 | ``` 53 | ```` 54 | 55 | 56 | 57 | ### Turning your list of monsters into an encounter 58 | 59 | You can turn the list of monsters into an encounter by adding a line with your character's levels. 60 | Let's add more Goblins (setting `amount: 4`) and see how difficult that encounter would be for our group of three level 3 adventures. 61 | 62 | ````yaml 63 | ```quick-encounter 64 | - levels: [3, 3, 3] 65 | - { name: Goblin, cr: 1/4, amount: 4 } 66 | - { name: Goblin Boss, cr: 1/2 } 67 | - { name: Dire Wolf, cr: 1 } 68 | ``` 69 | ```` 70 | This yields: 71 | 72 | 73 | 74 | If you hover over the difficulty, you'll see a breakdown of the difficulty threshold. 75 | 76 | 77 | 78 | ### Running an encounter with the `initiative-tracker` plugin 79 | 80 | If you have the [obsidian-initiative-tracker](https://github.com/valentine195/obsidian-initiative-tracker) plugin installed and enabled in the settings, instead of adding `levels` to your encounter, the players that are registered within the initiative-tracker settings will be used. You can start the encounter by clicking on the button. 81 | 82 | 83 | 84 | ### Options 85 | 86 | You can add additional information: 87 | - `damageDice` defines which dice are shown for damage, and 88 | - `multiAttack` can be used to split damage into multiple attacks. 89 | - `amount` can be used to add multiple monsters to an encounter. 90 | 91 | For example, using 92 | 93 | ````yaml 94 | ```quick-monster 95 | - name: Kyr, the Shadow 96 | cr: 5 97 | damageDice: 8 98 | multiAttack: 2 99 | ``` 100 | ```` 101 | 102 | yields 103 | 104 | 105 | 106 | The total damage is split into two "attacks" and instead of the default 6-sided die, 8-sided dice are used. 107 | 108 | ## The Stats are your Starting Point 109 | 110 | These stat blocks should be used as a solid base to come up quickly with your own monsters - **they are not set in stone**. 111 | You can use them directly at the table and improvise attacks and spells or as a starting point to build your own monster. 112 | 113 | Consider this stat block: 114 | 115 | 116 | 117 | We can use these statistics and improvise this creature's attacks (may be we make some notes below the block): 118 | 119 | - **Multiattack**: Ythanar attacks once with his *Claw* and once with his *Tail*: 120 | - *Claw*: 18 (2d6 slashing + 3d6 necrotic) damage 121 | - *Tail*: 18 (5d6) bludgeoning damage. Target must make a Strenght Saving Throw (DC 14) or is knocked prone. 122 | - **Necrotic Breath** (1 charge): 60 ft cone. 36 (6d6 + 15) necrotic damage on a failed Constitution Saving Throw (DC 14), half on a success. When Ythanar drops below 50% HP, he regains 1 charge and can use his reaction to use this ability. 123 | 124 | It's easy to run. You only need one type of die to roll damage and all attacks have the same to Hit bonus and the same DC. 125 | If you can do the math in your head, you can always change the dice from `5d6` to `3d6 + 8` if you prefer a smaller variance. 126 | 127 | ### Adjusting the Statistics 128 | 129 | The author of the formulas (see [here](http://blogofholding.com/?p=7338)) evaluated how much stats spread for each CR and derived some heuristics. Adjustments *should be informed by the concept/type of your monster*. 130 | 131 | **Defensive** 132 | 133 | - **AC**: ± 3 134 | - **HP**: ± 50% 135 | - **Best Save**: adjust according to monster theme 136 | 137 | **Offensive** 138 | - **Hit**: ± 2 139 | - **Damage**: ± 50% 140 | - **DC**: ± 2 141 | 142 | ## Useful Additions 143 | 144 | - [ ] Add options for modifiers ("add 50% damage", "+2 AC") 145 | - [ ] Add monster roles (a role defines a set of modifications to the base stats) 146 | - [ ] Better scaling for higher CR monsters. 147 | 148 | # Installation 149 | 150 | ## From GitHub 151 | 152 | - Download the file named `obsidian-quick-monsters-version.zip` (not the source, `version` stands for the current release's version) from the latest release. 153 | - Extract the directory into your plugin directory. You can find this directory in `your-vault/.obsidian/plugins` (where `your-vault` is the name of the directory that acts as you Obsidian vault). 154 | - Activate the plugin in Obsidian (you might have to restart or reload Obsidian). 155 | 156 | ## Using the BRAT plugin 157 | 158 | You can use the [BRAT](https://github.com/TfTHacker/obsidian42-brat) plugin to install this plugin. 159 | - Simply open the command palette and select the `BRAT: Add a beta plugin for testing`. 160 | - Paste the URL of this repository: `https://github.com/g-bauer/obsidian-quick-monsters`. 161 | - You might need to activate the plugin as usual. 162 | 163 | > Please note that this code comes without any warranty. I am new to developing plugins for Obsidian. **Please, consider backing up your data** before using this plugin. 164 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Notice, 3 | Plugin, 4 | parseYaml, 5 | PluginSettingTab, 6 | App, 7 | Setting, 8 | EventRef, 9 | } from "obsidian"; 10 | import { QuickMonster } from "./lib/monster"; 11 | import Monster from "./svelte/Monster.svelte"; 12 | import Encounter from "./svelte/Encounter.svelte"; 13 | import type InitiativeTrackerData from "../../obsidian-initiative-tracker/src/main"; 14 | 15 | 16 | interface QuickMonstersSetting { 17 | displayType: "table" | "list"; 18 | displayBudget: boolean; 19 | useInitiativeTracker: boolean; 20 | } 21 | 22 | const DefaultSetting: QuickMonstersSetting = { 23 | displayType: "table", 24 | displayBudget: true, 25 | useInitiativeTracker: true, 26 | }; 27 | 28 | declare module "obsidian" { 29 | interface App { 30 | plugins: { 31 | isEnabled(name: string): boolean; 32 | plugins: { 33 | "obsidian-dice-roller": { 34 | parseDice(text: string): Promise<{ result: number }>; 35 | }; 36 | "initiative-tracker": { 37 | data: InitiativeTrackerData 38 | } 39 | } 40 | }; 41 | 42 | } 43 | interface Workspace { 44 | on( 45 | name: "initiative-tracker:start-encounter", 46 | creatures: InitiativeTrackerCreature[] 47 | ): EventRef; 48 | } 49 | } 50 | 51 | interface InitiativeTrackerCreature { 52 | name: string; 53 | hp?: number; 54 | ac?: number; 55 | modifier?: number; 56 | } 57 | 58 | export default class QuickMonsters extends Plugin { 59 | settings: QuickMonstersSetting; 60 | 61 | get canUseInitiativeTracker() { 62 | return "initiative-tracker" in this.app.plugins.plugins; 63 | } 64 | 65 | get canUseDiceRoller() { 66 | return "obsidian-dice-roller" in this.app.plugins.plugins; 67 | } 68 | 69 | get initiativeTracker(): InitiativeTrackerData { 70 | if (this.canUseInitiativeTracker) { 71 | return this.app.plugins.plugins["initiative-tracker"].data; 72 | } 73 | } 74 | 75 | async onload() { 76 | console.log("loading quick-monsters-5e plugin"); 77 | await this.loadSettings(); 78 | 79 | this.addSettingTab(new QuickMonstersSettingTab(this.app, this)); 80 | 81 | this.registerMarkdownCodeBlockProcessor( 82 | "quick-monster", 83 | (src, el, ctx) => { 84 | const div1 = el.createDiv("monster-div"); 85 | const data = parseYaml(src); 86 | let playerLevels: any = []; 87 | let monsters: QuickMonster[] = []; 88 | try { 89 | data.forEach((obj: any) => { 90 | if ("name" in obj) { 91 | monsters.push( 92 | new QuickMonster( 93 | obj.name, 94 | obj.cr, 95 | obj.damageDice, 96 | obj.multiAttack, 97 | obj.amount, 98 | obj.ini 99 | ) 100 | ); 101 | } 102 | }); 103 | } catch (e) { 104 | new Notice(e); 105 | } 106 | const svelteComponent = new Monster({ 107 | target: div1, 108 | props: { 109 | monsters: monsters, 110 | displayType: this.settings.displayType, 111 | } 112 | }); 113 | } 114 | ); 115 | 116 | this.registerMarkdownCodeBlockProcessor( 117 | "quick-encounter", 118 | (src, el, ctx) => { 119 | const div1 = el.createDiv("encounter-div"); 120 | const data = parseYaml(src); 121 | let playerLevels: any = []; 122 | let monsters: QuickMonster[] = []; 123 | try { 124 | data.forEach((obj: any) => { 125 | if ("name" in obj) { 126 | monsters.push( 127 | new QuickMonster( 128 | obj.name, 129 | obj.cr, 130 | obj.damageDice, 131 | obj.multiAttack, 132 | obj.amount, 133 | obj.ini 134 | ) 135 | ); 136 | } else if ("levels" in obj) { 137 | playerLevels.push(obj.levels); 138 | } 139 | }); 140 | } catch (e) { 141 | new Notice(e); 142 | } 143 | // Get player levels from initiative tracker 144 | if (this.settings.useInitiativeTracker) { 145 | if (playerLevels.length && this.initiativeTracker.players.length) { 146 | new Notice('You specified "levels" and players in the initiative-tracker settings. Consider removing "levels".'); 147 | } 148 | playerLevels = this.initiativeTracker.players.map((p) => p.level); 149 | } 150 | if (!playerLevels.flat().length) { 151 | const svelteComponent = new Monster({ 152 | target: div1, 153 | props: { 154 | monsters: monsters, 155 | displayType: this.settings.displayType, 156 | } 157 | }); 158 | } else { 159 | const svelteComponent = new Encounter({ 160 | target: div1, 161 | props: { 162 | tracker: (this.canUseInitiativeTracker && this.settings.useInitiativeTracker), 163 | monsters: monsters, 164 | levels: playerLevels.flat(), 165 | displayType: this.settings.displayType, 166 | displayBudget: this.settings.displayBudget, 167 | } 168 | }); 169 | /** Add begin encounter hook from Svelte Component */ 170 | svelteComponent.$on("begin-encounter", () => { 171 | let entities: any = []; 172 | monsters.filter((m) => "amount" in m).forEach((m) => { entities.push(Array(m.amount).fill(m)) }); 173 | this.app.workspace.trigger( 174 | "initiative-tracker:start-encounter", 175 | entities.flat() 176 | ); 177 | }); 178 | }; 179 | } 180 | ); 181 | } 182 | 183 | onunload() { 184 | console.log("unloading quick-monsters-5e plugin"); 185 | } 186 | 187 | async loadSettings() { 188 | this.settings = Object.assign( 189 | {}, 190 | DefaultSetting, 191 | await this.loadData() 192 | ); 193 | } 194 | 195 | async saveSettings() { 196 | await this.saveData(this.settings); 197 | } 198 | } 199 | 200 | class QuickMonstersSettingTab extends PluginSettingTab { 201 | plugin: QuickMonsters; 202 | 203 | constructor(app: App, plugin: QuickMonsters) { 204 | super(app, plugin); 205 | this.plugin = plugin; 206 | } 207 | 208 | display(): void { 209 | let { containerEl } = this; 210 | 211 | containerEl.empty(); 212 | 213 | containerEl.createEl("h2", { text: "Quick Monsters 5e Settings" }); 214 | 215 | new Setting(containerEl) 216 | .setName("Monster Display Type") 217 | .setDesc("Select the way monsters are listed.") 218 | .addDropdown((d) => { 219 | d.addOption("list", "list"); 220 | d.addOption("table", "table"); 221 | d.setValue(this.plugin.settings.displayType); 222 | d.onChange(async (v: "list" | "table") => { 223 | this.plugin.settings.displayType = v; 224 | await this.plugin.saveSettings(); 225 | }); 226 | }); 227 | 228 | if (this.plugin.canUseInitiativeTracker) { 229 | new Setting(containerEl) 230 | .setName("Enable Inititative Tracker") 231 | .setDesc("Uses player data from initiative tracker and option to start encounters.") 232 | .addToggle((t) => { 233 | t.setValue(this.plugin.settings.useInitiativeTracker); 234 | t.onChange(async (v) => { 235 | this.plugin.settings.useInitiativeTracker = v; 236 | await this.plugin.saveSettings(); 237 | }); 238 | }); 239 | } 240 | } 241 | } 242 | --------------------------------------------------------------------------------