├── .gitattributes ├── LICENSE ├── .gitignore ├── styles ├── overview.css ├── keys.css ├── optionals.css ├── shared.css ├── sheet.css └── workshop.css ├── rollup.config.mjs ├── example-foundry-config.yaml ├── templates ├── sheet-navigation.hbs ├── sheet-configuration.hbs ├── sheet-header.hbs ├── sheet-description.hbs ├── sheet-filters.hbs ├── subapplications │ ├── applied-bonuses-dialog.hbs │ ├── keys-dialog.hbs │ ├── optional-selector.hbs │ └── character-sheet-tab.hbs ├── babonus-workshop.hbs ├── sheet-advanced.hbs └── sheet-bonuses.hbs ├── scripts ├── applications │ ├── _module.mjs │ ├── enrichers.mjs │ ├── applied-bonuses-dialog.mjs │ ├── keys-dialog.mjs │ ├── bonus-collection.mjs │ ├── injections.mjs │ ├── character-sheet-tab.mjs │ ├── babonus-workshop.mjs │ ├── token-aura.mjs │ └── bonus-collector.mjs ├── models │ ├── _module.mjs │ ├── aura-model.mjs │ ├── consumption-model.mjs │ └── modifiers-model.mjs ├── registry.mjs ├── fields │ ├── _module.mjs │ ├── custom-scripts-field.mjs │ ├── identifiers-field.mjs │ ├── source-classes-field.mjs │ ├── markers-field.mjs │ ├── feature-types-field.mjs │ ├── health-percentages-field.mjs │ ├── token-sizes-field.mjs │ ├── remaining-spell-slots-field.mjs │ ├── attack-modes-field.mjs │ ├── arbitrary-comparison-field.mjs │ ├── filter-mixin.mjs │ ├── checkbox-fields.mjs │ └── semicolon-fields.mjs ├── constants.mjs ├── hooks.mjs └── api.mjs ├── jsconfig.json ├── package.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── main.yml ├── tools └── create-symlinks.mjs ├── .eslintrc.json ├── module.json └── .vscode └── settings.json /.gitattributes: -------------------------------------------------------------------------------- 1 | *.mjs text eol=lf -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 zhell9201 2 | 3 | All rights reserved. 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | babonus.lock 2 | node_modules/ 3 | foundry-config.yaml 4 | foundry 5 | -------------------------------------------------------------------------------- /styles/overview.css: -------------------------------------------------------------------------------- 1 | .babonus.overview table :is(td, th) { 2 | text-align: center; 3 | } 4 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | input: "scripts/hooks.mjs", 3 | output: { 4 | file: "module.mjs", 5 | format: "esm" 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /example-foundry-config.yaml: -------------------------------------------------------------------------------- 1 | # Copy this file and rename it to "foundry-config.yaml" 2 | installPath: "C:\\Program Files\\Foundry Virtual Tabletop" # Root of your Foundry install, used for creating symlinks for type purposes 3 | -------------------------------------------------------------------------------- /templates/sheet-navigation.hbs: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /templates/sheet-configuration.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{#unless filters.length}} 3 |

{{localize "BABONUS.CurrentFiltersTooltip"}}

4 | {{/unless}} 5 | {{#each filters}} {{{this}}} {{/each}} 6 |
7 | -------------------------------------------------------------------------------- /scripts/applications/_module.mjs: -------------------------------------------------------------------------------- 1 | import BabonusSheet from "./babonus-sheet.mjs"; 2 | import BabonusWorkshop from "./babonus-workshop.mjs"; 3 | import KeysDialog from "./keys-dialog.mjs"; 4 | import TokenAura from "./token-aura.mjs"; 5 | 6 | export default { 7 | BabonusSheet, 8 | BabonusWorkshop, 9 | KeysDialog, 10 | TokenAura 11 | }; 12 | -------------------------------------------------------------------------------- /scripts/models/_module.mjs: -------------------------------------------------------------------------------- 1 | import AuraModel from "./aura-model.mjs"; 2 | import babonus from "./babonus-model.mjs"; 3 | import ConsumptionModel from "./consumption-model.mjs"; 4 | import ModifiersModel from "./modifiers-model.mjs"; 5 | 6 | export default { 7 | AuraModel, 8 | ConsumptionModel, 9 | ModifiersModel, 10 | Babonus: babonus 11 | }; 12 | -------------------------------------------------------------------------------- /templates/sheet-header.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{bonus.name}} 3 | 4 | 9 |
10 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ES2020", 4 | "target": "ES2020" 5 | }, 6 | "exclude": ["node_modules", "**/node_modules/*"], 7 | "include": [ 8 | "scripts/hooks.mjs", 9 | "../../systems/dnd5e/dnd5e.mjs", 10 | "foundry/client/**/*.js", 11 | "foundry/**/*.mjs" 12 | ], 13 | "typeAcquisition": {} 14 | } 15 | -------------------------------------------------------------------------------- /templates/sheet-description.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{fields.description.field.label}} 4 | {{formGroup fields.img.field value=fields.img.value}} 5 | 6 | {{#with fields.description}} 7 | {{formGroup field value=value enriched=enriched height=height button=true toggled=true}} 8 | {{/with}} 9 |
10 |
11 | -------------------------------------------------------------------------------- /templates/sheet-filters.hbs: -------------------------------------------------------------------------------- 1 |
2 | 9 |
10 | {{#each filterpickers}} 11 |
12 | {{field.label}} {{#if repeats}}({{repeats}}){{/if}} 13 |

{{{field.hint}}}

14 |
15 | {{/each}} 16 |
17 |
18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "babonus", 3 | "version": "1.0.0", 4 | "description": "Build-a-Bonus", 5 | "scripts": { 6 | "build": "rollup -c rollup.config.mjs", 7 | "createSymlinks": "node ./tools/create-symlinks.mjs", 8 | "postinstall": "npm run createSymlinks" 9 | }, 10 | "author": "Zhell", 11 | "bugs": { 12 | "url": "https://github.com/krbz999/babonus" 13 | }, 14 | "homepage": "https://github.com/krbz999/babonus", 15 | "devDependencies": { 16 | "@html-eslint/eslint-plugin": "^0.26.0", 17 | "@html-eslint/parser": "^0.26.0", 18 | "@rollup/plugin-node-resolve": "^15.2.3", 19 | "eslint": "^8.57.0", 20 | "rollup": "^4.17.2" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /scripts/registry.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility extension of Map to keep track of rolls and bonuses that apply to them. 3 | */ 4 | class RollRegistry extends Map { 5 | /** 6 | * Register an object of data with a generated id. 7 | * @param {object} config The data to store. 8 | * @returns {string} Randomly generated id to later retrieve the stored data. 9 | */ 10 | register(config) { 11 | const id = foundry.utils.randomID(); 12 | this.set(id, config); 13 | return id; 14 | } 15 | } 16 | 17 | /* -------------------------------------------------- */ 18 | 19 | /** 20 | * The registry of rolls being made. 21 | * @type {RollRegistry} 22 | */ 23 | export default new RollRegistry(); 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: zhell 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: zhell 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: File an issue about unexpected behaviour 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Setup** 27 | Describe your foundry setup. 28 | - Build-a-Bonus version: 29 | - Foundry core version and build: 30 | - Other modules disabled? Yes/No 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE REQUEST]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Setup** 20 | - Build-a-Bonus version: 21 | - Foundry core version and build: 22 | - [Does your feature request involve other modules? If so, list them here.] 23 | 24 | **Additional context** 25 | Add any other context or screenshots about the feature request here. 26 | -------------------------------------------------------------------------------- /templates/subapplications/applied-bonuses-dialog.hbs: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {{#each bonuses}} 12 | 13 | 14 | 19 | 24 | 25 | {{/each}} 26 | 27 |
{{localize "BABONUS.OverviewName"}}{{localize "BABONUS.OverviewImmediateParent"}}{{localize "BABONUS.OverviewActor"}}
{{name}} 15 | 16 | {{ifThen parent.name parent.name (localize "Template")}} 17 | 18 | 20 | 21 | {{actor.name}} 22 | 23 |
28 |
29 |
30 | 33 |
34 | -------------------------------------------------------------------------------- /styles/keys.css: -------------------------------------------------------------------------------- 1 | .babonus.keys-dialog { 2 | min-width: 400px; 3 | 4 | .dialog-content { 5 | 6 | .table { 7 | margin: 0; 8 | border-radius: 0; 9 | 10 | & header { 11 | display: grid; 12 | grid-template-columns: 2fr 3fr; 13 | 14 | .value, .state { 15 | flex: 1; 16 | border: none; 17 | font-size: 20px; 18 | margin: 10px auto; 19 | text-align: center; 20 | } 21 | } 22 | 23 | .body { 24 | max-height: 400px; 25 | overflow: hidden auto; 26 | 27 | .category { font-weight: bold; } 28 | 29 | .row { 30 | display: grid; 31 | grid-template-columns: 2fr 3fr; 32 | 33 | .value, .select { 34 | padding: 0; 35 | line-height: 2em; 36 | 37 | & img { height: 1.5em; border: none; } 38 | 39 | &.select { 40 | display: flex; 41 | align-items: center; 42 | gap: 1em; 43 | margin: 0 1em; 44 | } 45 | } 46 | } 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /scripts/fields/_module.mjs: -------------------------------------------------------------------------------- 1 | import ArbitraryComparisonField from "./arbitrary-comparison-field.mjs"; 2 | import AttackModesField from "./attack-modes-field.mjs"; 3 | import checkboxFields from "./checkbox-fields.mjs"; 4 | import CustomScriptsField from "./custom-scripts-field.mjs"; 5 | import FeatureTypesField from "./feature-types-field.mjs"; 6 | import HealthPercentagesField from "./health-percentages-field.mjs"; 7 | import IdentifiersField from "./identifiers-field.mjs"; 8 | import MarkersField from "./markers-field.mjs"; 9 | import RemainingSpellSlotsField from "./remaining-spell-slots-field.mjs"; 10 | import semicolonFields from "./semicolon-fields.mjs"; 11 | import SourceClassesField from "./source-classes-field.mjs"; 12 | import TokenSizesField from "./token-sizes-field.mjs"; 13 | 14 | export default Object.values({ 15 | ...checkboxFields, 16 | ...semicolonFields, 17 | ArbitraryComparisonField, 18 | AttackModesField, 19 | CustomScriptsField, 20 | FeatureTypesField, 21 | HealthPercentagesField, 22 | IdentifiersField, 23 | MarkersField, 24 | RemainingSpellSlotsField, 25 | SourceClassesField, 26 | TokenSizesField 27 | }).reduce((acc, field) => { 28 | acc[field.name] = field; 29 | return acc; 30 | }, {}); 31 | -------------------------------------------------------------------------------- /tools/create-symlinks.mjs: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import yaml from "js-yaml"; 3 | import path from "path"; 4 | 5 | console.log("Reforging Symlinks"); 6 | 7 | if (fs.existsSync("foundry-config.yaml")) { 8 | let fileRoot = ""; 9 | try { 10 | const fc = await fs.promises.readFile("foundry-config.yaml", "utf-8"); 11 | 12 | const foundryConfig = yaml.load(fc); 13 | 14 | fileRoot = path.join(foundryConfig.installPath, "resources", "app"); 15 | } catch (err) { 16 | console.error(`Error reading foundry-config.yaml: ${err}`); 17 | } 18 | 19 | try { 20 | await fs.promises.mkdir("foundry"); 21 | } catch (e) { 22 | if (e.code !== "EEXIST") throw e; 23 | } 24 | 25 | // Javascript files 26 | for (const p of ["client", "client-esm", "common"]) { 27 | try { 28 | await fs.promises.symlink(path.join(fileRoot, p), path.join("foundry", p)); 29 | } catch (e) { 30 | if (e.code !== "EEXIST") throw e; 31 | } 32 | } 33 | 34 | // Language files 35 | try { 36 | await fs.promises.symlink(path.join(fileRoot, "public", "lang"), path.join("foundry", "lang")); 37 | } catch (e) { 38 | if (e.code !== "EEXIST") throw e; 39 | } 40 | } else { 41 | console.log("Foundry config file did not exist."); 42 | } 43 | -------------------------------------------------------------------------------- /templates/subapplications/keys-dialog.hbs: -------------------------------------------------------------------------------- 1 |

{{{localize description}}}

2 |
3 |
4 |

{{localize "BABONUS.KeysDialogValue"}}

5 |

6 | {{localize "BABONUS.KeysDialogState"}} 7 |

8 |
9 |
10 | {{#each values}} 11 |
12 | 17 |
18 | 19 | 20 | 21 | 28 | 29 | 30 | 31 |
32 |
33 | {{/each}} 34 |
35 |
36 | -------------------------------------------------------------------------------- /scripts/applications/enrichers.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Register enrichers. 3 | */ 4 | export default function enricherSetup() { 5 | CONFIG.TextEditor.enrichers.push({ 6 | pattern: /@BAB\[(?[^\]]+)\]/g, 7 | enricher: enrichBabonus 8 | }); 9 | } 10 | 11 | /* -------------------------------------------------- */ 12 | 13 | /** 14 | * Enrich a content link. 15 | * @param {object} config Configuration for the enrichment. 16 | * @returns {HTMLElement} The created element. 17 | */ 18 | async function enrichBabonus(config) { 19 | const uuid = config.groups.uuid; 20 | const bonus = await babonus.fromUuid(uuid); 21 | if (!bonus) return; 22 | const anchor = document.createElement("A"); 23 | anchor.dataset.uuid = uuid; 24 | anchor.dataset.link = ""; 25 | anchor.classList.add("babonus", "content-link"); 26 | if (bonus.enabled) anchor.classList.add("enabled"); 27 | anchor.innerHTML = `${bonus.name}`; 28 | anchor.addEventListener("click", () => bonus.toggle()); 29 | return anchor; 30 | } 31 | 32 | /* -------------------------------------------------- */ 33 | 34 | /** 35 | * Add a click event listener to content links. 36 | */ 37 | document.addEventListener("click", async (event) => { 38 | const target = event.target.closest("a.babonus.content-link"); 39 | if (!target) return; 40 | if (event.detail > 1) event.preventDefault(); 41 | const bonus = await babonus.fromUuid(target.dataset.uuid); 42 | bonus.toggle(); 43 | }); 44 | -------------------------------------------------------------------------------- /scripts/fields/custom-scripts-field.mjs: -------------------------------------------------------------------------------- 1 | import FilterMixin from "./filter-mixin.mjs"; 2 | 3 | const {JavaScriptField, StringField} = foundry.data.fields; 4 | 5 | export default class CustomScriptsField extends FilterMixin(JavaScriptField) { 6 | /** @override */ 7 | static name = "customScripts"; 8 | 9 | /* -------------------------------------------------- */ 10 | 11 | /** @override */ 12 | static render(bonus) { 13 | const field = bonus.schema.getField(`filters.${this.name}`); 14 | const value = bonus.filters[this.name] ?? ""; 15 | 16 | const template = ` 17 |
18 | 19 | {{label}} 20 | 21 | 22 | 23 | 24 |

{{hint}}

25 |
26 |
27 | {{formInput field value=value}} 28 |
29 |
30 |
`; 31 | 32 | return Handlebars.compile(template)({ 33 | field: field, 34 | value: value, 35 | label: field.label, 36 | hint: field.hint 37 | }); 38 | } 39 | 40 | /* -------------------------------------------------- */ 41 | 42 | /** @override */ 43 | _validateType(value, options) { 44 | return StringField.prototype._validateType.call(this, value, options); 45 | } 46 | 47 | /* -------------------------------------------------- */ 48 | 49 | /** @override */ 50 | static storage(bonus) { 51 | return !!this.value(bonus)?.length; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /templates/subapplications/optional-selector.hbs: -------------------------------------------------------------------------------- 1 | {{#if reminders.length}} 2 |
3 | {{#each reminders}} 4 |
5 | {{{description}}} 6 |
7 | {{/each}} 8 |
9 | {{/if}} 10 | 11 | {{#if isV2}} 12 | 13 | {{#each bonuses}} 14 |
15 |
16 | {{{description}}} 17 |
18 | 19 |
20 | 21 |
22 | {{#if scales}} 23 | {{formInput scaleValue dataset=scaleDataset}} 24 | {{/if}} 25 | {{#if damageTypes}} 26 | {{formInput damageTypes dataset=damageTypeDataset}} 27 | {{/if}} 28 |
29 | 32 |
33 |
34 | 35 |
36 | {{/each}} 37 | 38 | {{else}} 39 | 40 | {{#each bonuses}} 41 |
42 | 43 |
44 | {{{description}}} 45 |
46 | 47 | {{#if scales}} 48 |
49 | 52 | {{formInput scaleValue dataset=scaleDataset}} 53 |
54 | {{/if}} 55 | 56 | 59 | 60 |
61 | {{/each}} 62 | 63 | {{/if}} 64 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "globals": {}, 3 | "env": { 4 | "browser": true, 5 | "es2021": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "ecmaVersion": "latest", 10 | "sourceType": "module" 11 | }, 12 | "plugins": [ 13 | "@html-eslint" 14 | ], 15 | "overrides": [{ 16 | "files": ["**/*.hbs", "**/*.html"], 17 | "parser": "@html-eslint/parser", 18 | "extends": ["plugin:@html-eslint/recommended"] 19 | }], 20 | "rules": { 21 | "indent": ["error", 2, {"SwitchCase": 1}], 22 | "linebreak-style": ["error", "unix"], 23 | "quotes": ["error", "double"], 24 | "semi": ["error", "always"], 25 | "quote-props": ["error", "as-needed"], 26 | "array-bracket-newline": ["error", "consistent"], 27 | "no-unused-vars": 0, 28 | "key-spacing": "error", 29 | "comma-dangle": "error", 30 | "space-in-parens": ["error", "never"], 31 | "space-infix-ops": 2, 32 | "keyword-spacing": 2, 33 | "semi-spacing": 2, 34 | "no-multi-spaces": 2, 35 | "no-extra-semi": 2, 36 | "no-whitespace-before-property": 2, 37 | "space-unary-ops": 2, 38 | "no-multiple-empty-lines": ["error", {"max": 1, "maxEOF": 0}], 39 | "object-curly-spacing": ["error", "never"], 40 | "comma-spacing": ["error"], 41 | "no-undef": "off", 42 | "space-before-blocks": 2, 43 | "arrow-spacing": 2, 44 | "eol-last": ["error", "always"], 45 | "no-mixed-operators": ["error", { 46 | "allowSamePrecedence": true, 47 | "groups": [ 48 | ["==", "!=", "===", "!==", ">", ">=", "<", "<=", "&&", "||", "in", "instanceof"] 49 | ] 50 | }], 51 | "@html-eslint/attrs-newline": ["off", { 52 | "closeStyle": "sameline", 53 | "ifAttrsMoreThan": 9 54 | }], 55 | "@html-eslint/indent": ["error", 2] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /scripts/fields/identifiers-field.mjs: -------------------------------------------------------------------------------- 1 | import FilterMixin from "./filter-mixin.mjs"; 2 | 3 | const {SchemaField, SetField, StringField} = foundry.data.fields; 4 | 5 | export default class IdentifiersField extends FilterMixin(SchemaField) { 6 | /** @override */ 7 | static name = "identifiers"; 8 | 9 | /* -------------------------------------------------- */ 10 | 11 | constructor(fields = {}, options = {}) { 12 | super({values: new SetField(new StringField(), {slug: true}), ...fields}, options); 13 | } 14 | 15 | /* -------------------------------------------------- */ 16 | 17 | /** @override */ 18 | static render(bonus) { 19 | const template = ` 20 |
21 | 22 | {{label}} 23 | 24 | 25 | 26 | 27 |

{{hint}}

28 |
29 |
30 | {{formInput field value=value slug=true placeholder=placeholder}} 31 |
32 |
33 |
`; 34 | 35 | const schema = bonus.schema.getField(`filters.${this.name}`); 36 | const field = bonus.schema.getField(`filters.${this.name}.values`); 37 | const value = bonus.filters.identifiers.values; 38 | 39 | const data = { 40 | label: schema.label, 41 | hint: schema.hint, 42 | field: field, 43 | value: value, 44 | placeholder: game.i18n.localize("BABONUS.FIELDS.filters.identifiers.value.placeholder") 45 | }; 46 | 47 | return Handlebars.compile(template)(data); 48 | } 49 | 50 | /* -------------------------------------------------- */ 51 | 52 | /** @override */ 53 | static storage(bonus) { 54 | return !!bonus.filters.identifiers?.values?.size; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /scripts/fields/source-classes-field.mjs: -------------------------------------------------------------------------------- 1 | import FilterMixin from "./filter-mixin.mjs"; 2 | 3 | const {SchemaField, SetField, StringField} = foundry.data.fields; 4 | 5 | export default class SourceClassesField extends FilterMixin(SchemaField) { 6 | /** @override */ 7 | static name = "sourceClasses"; 8 | 9 | /* -------------------------------------------------- */ 10 | 11 | constructor(fields = {}, options = {}) { 12 | super({values: new SetField(new StringField(), {slug: true}), ...fields}, options); 13 | } 14 | 15 | /* -------------------------------------------------- */ 16 | 17 | /** @override */ 18 | static render(bonus) { 19 | const template = ` 20 |
21 | 22 | {{label}} 23 | 24 | 25 | 26 | 27 |

{{hint}}

28 |
29 |
30 | {{formInput field value=value slug=true placeholder=placeholder}} 31 |
32 |
33 |
`; 34 | 35 | const schema = bonus.schema.getField(`filters.${this.name}`); 36 | const field = bonus.schema.getField(`filters.${this.name}.values`); 37 | const value = bonus.filters.sourceClasses.values; 38 | 39 | const data = { 40 | label: schema.label, 41 | hint: schema.hint, 42 | field: field, 43 | value: value, 44 | placeholder: game.i18n.localize("BABONUS.FIELDS.filters.sourceClasses.value.placeholder") 45 | }; 46 | 47 | return Handlebars.compile(template)(data); 48 | } 49 | 50 | /* -------------------------------------------------- */ 51 | 52 | /** @override */ 53 | static storage(bonus) { 54 | return !!bonus.filters.sourceClasses?.values?.size; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /module.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "babonus", 3 | "title": "Build-a-Bonus", 4 | "version": "AUTOMATIC", 5 | "authors": [ 6 | { 7 | "name": "Zhell", 8 | "url": "https://github.com/krbz999", 9 | "discord": "zhell9201", 10 | "ko-fi": "https://ko-fi.com/zhell", 11 | "patreon": "https://patreon.com/zhell" 12 | } 13 | ], 14 | "compatibility": { 15 | "minimum": 12, 16 | "maximum": 12, 17 | "verified": 12 18 | }, 19 | "esmodules": ["scripts/hooks.mjs"], 20 | "styles": [ 21 | "styles/keys.css", 22 | "styles/optionals.css", 23 | "styles/overview.css", 24 | "styles/sheet.css", 25 | "styles/workshop.css", 26 | "styles/shared.css" 27 | ], 28 | "relationships": { 29 | "systems": [ 30 | { 31 | "id": "dnd5e", 32 | "type": "system", 33 | "compatibility": { 34 | "minimum": "4.0.0", 35 | "maximum": "4.0.x" 36 | } 37 | } 38 | ] 39 | }, 40 | "languages": [ 41 | { 42 | "lang": "en", 43 | "name": "English", 44 | "path": "lang/en.json", 45 | "flags": {} 46 | }, 47 | { 48 | "lang": "fr", 49 | "name": "Français", 50 | "path": "lang/fr.json", 51 | "flags": {} 52 | }, 53 | { 54 | "lang": "pt-BR", 55 | "name": "Português (Brasil)", 56 | "path": "lang/pt-BR.json", 57 | "flags": {} 58 | } 59 | ], 60 | "flags": { 61 | "hotReload": { 62 | "extensions": ["json", "css"], 63 | "paths": ["styles/*.css", "lang/en.json"] 64 | } 65 | }, 66 | "url": "https://github.com/krbz999/babonus", 67 | "license": "https://raw.githubusercontent.com/krbz999/babonus/main/LICENSE", 68 | "manifest": "https://github.com/krbz999/babonus/releases/latest/download/module.json", 69 | "download": "AUTOMATIC", 70 | "description": "Make it easier to manage very particular and niche bonuses and auras on actors, items, effects, and templates." 71 | } 72 | -------------------------------------------------------------------------------- /scripts/constants.mjs: -------------------------------------------------------------------------------- 1 | export const MODULE = { 2 | ID: "babonus", 3 | NAME: "Build-a-Bonus", 4 | ICON: "fa-solid fa-otter", 5 | CONSUMPTION_TYPES: { 6 | currency: "DND5E.Currency", 7 | effect: "BABONUS.FIELDS.consume.type.optionEffect", 8 | health: "DND5E.HitPoints", 9 | hitdice: "DND5E.HitDice", 10 | inspiration: "DND5E.Inspiration", 11 | quantity: "DND5E.Quantity", 12 | slots: "BABONUS.FIELDS.consume.type.optionSlots", 13 | uses: "DND5E.LimitedUses" 14 | }, 15 | DISPOSITION_TYPES: { 16 | 2: "BABONUS.FIELDS.aura.disposition.optionAny", 17 | 1: "BABONUS.FIELDS.aura.disposition.optionAlly", 18 | "-1": "BABONUS.FIELDS.aura.disposition.optionEnemy" 19 | }, 20 | HEALTH_PERCENTAGES_CHOICES: { 21 | 0: "BABONUS.FIELDS.filters.healthPercentages.type.optionLT", 22 | 1: "BABONUS.FIELDS.filters.healthPercentages.type.optionGT" 23 | }, 24 | ATTACK_MODES_CHOICES: { 25 | offhand: "DND5E.ATTACK.Mode.Offhand", 26 | oneHanded: "DND5E.ATTACK.Mode.OneHanded", 27 | thrown: "DND5E.ATTACK.Mode.Thrown", 28 | "thrown-offhand": "DND5E.ATTACK.Mode.ThrownOffhand", 29 | twoHanded: "DND5E.ATTACK.Mode.TwoHanded" 30 | }, 31 | SPELL_COMPONENT_CHOICES: { 32 | ANY: "BABONUS.FIELDS.filters.spellComponents.match.optionAny", 33 | ALL: "BABONUS.FIELDS.filters.spellComponents.match.optionAll" 34 | }, 35 | TOKEN_SIZES_CHOICES: { 36 | 0: "BABONUS.FIELDS.filters.tokenSizes.type.optionGT", 37 | 1: "BABONUS.FIELDS.filters.tokenSizes.type.optionLT" 38 | }, 39 | MODIFIER_MODES: { 40 | 0: "BABONUS.MODIFIERS.FIELDS.mode.optionAdd", 41 | 1: "BABONUS.MODIFIERS.FIELDS.mode.optionMultiply" 42 | } 43 | }; 44 | 45 | /* -------------------------------------------------- */ 46 | 47 | export const SETTINGS = { 48 | AURA: "showAuraRanges", 49 | LABEL: "headerLabel", 50 | PLAYERS: "allowPlayers", 51 | SCRIPT: "disableCustomScriptFilter", 52 | FUMBLE: "allowFumbleNegation", 53 | SHEET_TAB: "showSheetTab", 54 | RADIUS: "padAuraRadius" 55 | }; 56 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript]": { 3 | "editor.defaultFormatter": "vscode.typescript-language-features", 4 | "editor.wordWrap": "wordWrapColumn", 5 | "editor.wordWrapColumn": 125, 6 | "editor.rulers": [ 7 | { 8 | "column": 125, 9 | "color": "#ff00ff9a" 10 | } 11 | ] 12 | }, 13 | "[json]": { 14 | "editor.defaultFormatter": "vscode.json-language-features" 15 | }, 16 | "javascript.format.semicolons": "insert", 17 | "css.format.spaceAroundSelectorSeparator": true, 18 | "css.lint.duplicateProperties": "warning", 19 | "css.lint.zeroUnits": "warning", 20 | "editor.tabSize": 2, 21 | "editor.formatOnSave": false, 22 | "editor.formatOnSaveMode": "modifications", 23 | "files.trimTrailingWhitespace": true, 24 | "javascript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": false, 25 | "javascript.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": false, 26 | "javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": false, 27 | "javascript.validate.enable": true, 28 | "javascript.updateImportsOnFileMove.enabled": "always", 29 | "typescript.autoClosingTags": false, 30 | "typescript.check.npmIsInstalled": false, 31 | "typescript.disableAutomaticTypeAcquisition": true, 32 | "typescript.format.enable": false, 33 | "typescript.format.insertSpaceAfterCommaDelimiter": false, 34 | "typescript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": false, 35 | "typescript.format.insertSpaceAfterKeywordsInControlFlowStatements": false, 36 | "typescript.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": false, 37 | "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": false, 38 | "typescript.suggest.enabled": false, 39 | "typescript.surveys.enabled": false, 40 | "typescript.validate.enable": false, 41 | "html.format.wrapLineLength": 125, 42 | "editor.codeActionsOnSave": { 43 | "source.fixAll.eslint": "always" 44 | }, 45 | "eslint.enable": true, 46 | "eslint.validate": ["javascript", "handlebars", "html"] 47 | } 48 | -------------------------------------------------------------------------------- /scripts/fields/markers-field.mjs: -------------------------------------------------------------------------------- 1 | import FilterMixin from "./filter-mixin.mjs"; 2 | 3 | const {SchemaField, SetField, StringField} = foundry.data.fields; 4 | 5 | export default class MarkersField extends FilterMixin(SchemaField) { 6 | /** @override */ 7 | static name = "markers"; 8 | 9 | /* -------------------------------------------------- */ 10 | 11 | constructor(fields = {}, options = {}) { 12 | super({ 13 | values: new SetField(new StringField(), {slug: true}), 14 | target: new SetField(new StringField(), {slug: true}), 15 | ...fields 16 | }, options); 17 | } 18 | 19 | /* -------------------------------------------------- */ 20 | 21 | /** @override */ 22 | static render(bonus) { 23 | const template = ` 24 |
25 | 26 | {{label}} 27 | 28 | 29 | 30 | 31 |

{{hint}}

32 | {{formGroup values.field value=values.value slug=true placeholder=placeholder}} 33 | {{formGroup target.field value=target.value slug=true placeholder=placeholder}} 34 |
`; 35 | 36 | const schema = bonus.schema.getField(`filters.${this.name}`); 37 | const field = bonus.schema.getField(`filters.${this.name}.values`); 38 | const target = bonus.schema.getField(`filters.${this.name}.target`); 39 | 40 | const data = { 41 | label: schema.label, 42 | hint: schema.hint, 43 | values: {field: field, value: bonus.filters[this.name].values}, 44 | target: {field: target, value: bonus.filters[this.name].target}, 45 | placeholder: game.i18n.localize(`BABONUS.FIELDS.filters.${this.name}.placeholder`) 46 | }; 47 | 48 | return Handlebars.compile(template)(data); 49 | } 50 | 51 | /* -------------------------------------------------- */ 52 | 53 | /** @override */ 54 | static storage(bonus) { 55 | const {values, target} = bonus.filters[this.name] ?? {}; 56 | return !!values?.size || !!target?.size; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /scripts/applications/applied-bonuses-dialog.mjs: -------------------------------------------------------------------------------- 1 | import {MODULE} from "../constants.mjs"; 2 | import registry from "../registry.mjs"; 3 | 4 | export default class AppliedBonusesDialog extends Dialog { 5 | constructor(options) { 6 | super({}, options); 7 | this.dialog = options.dialog; 8 | } 9 | 10 | /* -------------------------------------------------- */ 11 | 12 | /** @override */ 13 | get title() { 14 | return game.i18n.localize("BABONUS.OverviewTitle"); 15 | } 16 | 17 | /* -------------------------------------------------- */ 18 | 19 | /** @override */ 20 | get id() { 21 | return `${this.dialog.id}-bonuses-overview`; 22 | } 23 | 24 | /* -------------------------------------------------- */ 25 | 26 | /** @override */ 27 | static get defaultOptions() { 28 | return foundry.utils.mergeObject(super.defaultOptions, { 29 | width: 400, 30 | height: "auto", 31 | template: `modules/${MODULE.ID}/templates/subapplications/applied-bonuses-dialog.hbs`, 32 | classes: [MODULE.ID, "overview"] 33 | }); 34 | } 35 | 36 | /* -------------------------------------------------- */ 37 | 38 | /** @override */ 39 | async getData() { 40 | return {bonuses: registry.get(this.options.id).bonuses}; 41 | } 42 | 43 | /* -------------------------------------------------- */ 44 | 45 | /** @override */ 46 | activateListeners(html) { 47 | super.activateListeners(html); 48 | html[0].querySelectorAll("[data-uuid]").forEach(n => n.addEventListener("click", this._onClickUuid.bind(this))); 49 | } 50 | 51 | /* -------------------------------------------------- */ 52 | 53 | /** 54 | * When clicking a uuid tag, copy it. 55 | * @param {Event} event The initiating click event. 56 | */ 57 | async _onClickUuid(event) { 58 | await game.clipboard.copyPlainText(event.currentTarget.dataset.uuid); 59 | ui.notifications.info("BABONUS.OverviewCopied", {localize: true}); 60 | } 61 | 62 | /* -------------------------------------------------- */ 63 | 64 | /** @override */ 65 | _onClickButton(event) { 66 | this.close(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /scripts/fields/feature-types-field.mjs: -------------------------------------------------------------------------------- 1 | import FilterMixin from "./filter-mixin.mjs"; 2 | 3 | const {SchemaField, StringField} = foundry.data.fields; 4 | 5 | export default class FeatureTypesField extends FilterMixin(SchemaField) { 6 | /** @override */ 7 | static name = "featureTypes"; 8 | 9 | /* -------------------------------------------------- */ 10 | 11 | constructor(fields = {}, options = {}) { 12 | super({ 13 | type: new StringField({required: false}), 14 | subtype: new StringField({required: true}), 15 | ...fields 16 | }, options); 17 | } 18 | 19 | /* -------------------------------------------------- */ 20 | 21 | /** @override */ 22 | static render(bonus) { 23 | const schema = bonus.schema.getField("filters.featureTypes"); 24 | const {type, subtype} = schema.fields; 25 | 26 | const value1 = bonus.filters.featureTypes.type; 27 | const value2 = bonus.filters.featureTypes.subtype; 28 | const choices1 = CONFIG.DND5E.featureTypes; 29 | const choices2 = CONFIG.DND5E.featureTypes[value1]?.subtypes ?? {}; 30 | 31 | const template = ` 32 |
33 | 34 | {{label}} 35 | 36 | 37 | 38 | 39 |

{{hint}}

40 | {{formGroup type value=value1 sort=true choices=choices1}} 41 | {{#if choices2}} 42 | {{formGroup subtype value=value2 sort=true choices=choices2}} 43 | {{/if}} 44 |
`; 45 | 46 | const data = { 47 | type: type, 48 | subtype: subtype, 49 | value1: value1, 50 | value2: value2, 51 | choices1: choices1, 52 | choices2: foundry.utils.isEmpty(choices2) ? null : choices2, 53 | label: schema.label, 54 | hint: schema.hint 55 | }; 56 | 57 | return Handlebars.compile(template)(data); 58 | } 59 | 60 | /* -------------------------------------------------- */ 61 | 62 | /** @override */ 63 | static storage(bonus) { 64 | const value = this.value(bonus); 65 | return value.type in CONFIG.DND5E.featureTypes; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /scripts/fields/health-percentages-field.mjs: -------------------------------------------------------------------------------- 1 | import {MODULE} from "../constants.mjs"; 2 | import FilterMixin from "./filter-mixin.mjs"; 3 | 4 | const {SchemaField, NumberField} = foundry.data.fields; 5 | 6 | export default class HealthPercentagesField extends FilterMixin(SchemaField) { 7 | /** @override */ 8 | static name = "healthPercentages"; 9 | 10 | /* -------------------------------------------------- */ 11 | 12 | constructor(fields = {}, options = {}) { 13 | super({ 14 | value: new NumberField({ 15 | min: 0, 16 | max: 100, 17 | step: 1, 18 | integer: true, 19 | nullable: true, // nullable required to be able to remove it 20 | initial: 50 21 | }), 22 | type: new NumberField({ 23 | initial: null, 24 | nullable: true, // nullable required to be able to remove it 25 | choices: MODULE.HEALTH_PERCENTAGES_CHOICES 26 | }), 27 | ...fields 28 | }, options); 29 | } 30 | 31 | /* -------------------------------------------------- */ 32 | 33 | /** @override */ 34 | static render(bonus) { 35 | const template = ` 36 |
37 | 38 | {{label}} 39 | 40 | 41 | 42 | 43 |

{{hint}}

44 | {{formGroup valueField value=value}} 45 | {{formGroup typeField value=type}} 46 |
`; 47 | 48 | const schema = bonus.schema.getField(`filters.${this.name}`); 49 | const valueField = bonus.schema.getField(`filters.${this.name}.value`); 50 | const typeField = bonus.schema.getField(`filters.${this.name}.type`); 51 | const data = { 52 | valueField: valueField, 53 | typeField: typeField, 54 | value: bonus.filters[this.name].value, 55 | type: bonus.filters[this.name].type, 56 | hint: schema.hint, 57 | label: schema.label 58 | }; 59 | 60 | return Handlebars.compile(template)(data); 61 | } 62 | 63 | /* -------------------------------------------------- */ 64 | 65 | /** @override */ 66 | static storage(bonus) { 67 | return !Object.values(this.value(bonus)).includes(null); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /scripts/fields/token-sizes-field.mjs: -------------------------------------------------------------------------------- 1 | import {MODULE} from "../constants.mjs"; 2 | import FilterMixin from "./filter-mixin.mjs"; 3 | 4 | const {SchemaField, NumberField, BooleanField} = foundry.data.fields; 5 | 6 | export default class TokenSizesField extends FilterMixin(SchemaField) { 7 | /** @override */ 8 | static name = "tokenSizes"; 9 | 10 | /* -------------------------------------------------- */ 11 | 12 | constructor(fields = {}, options = {}) { 13 | super({ 14 | size: new NumberField({min: 0.5, step: 0.5}), 15 | type: new NumberField({ 16 | choices: MODULE.TOKEN_SIZES_CHOICES, 17 | initial: 0 18 | }), 19 | self: new BooleanField(), 20 | ...fields 21 | }, options); 22 | } 23 | 24 | /* -------------------------------------------------- */ 25 | 26 | /** @override */ 27 | static render(bonus) { 28 | const template = ` 29 |
30 | 31 | {{label}} 32 | 33 | 34 | 35 | 36 |

{{hint}}

37 |
38 | 39 |
40 | {{formInput typeField value=type}} 41 | {{formInput sizeField value=size placeholder=phSize}} 42 |
43 |
44 | {{formGroup selfField value=self}} 45 |
`; 46 | 47 | const schema = bonus.schema.getField(`filters.${this.name}`); 48 | const {type: typeField, size: sizeField, self: selfField} = schema.fields; 49 | const {type, size, self} = bonus.filters[this.name]; 50 | 51 | const data = { 52 | label: schema.label, 53 | hint: schema.hint, 54 | typeField, type, 55 | sizeField, size, 56 | selfField, self, 57 | phSize: game.i18n.localize("BABONUS.FIELDS.filters.tokenSizes.size.placeholder") 58 | }; 59 | 60 | return Handlebars.compile(template)(data); 61 | } 62 | 63 | /* -------------------------------------------------- */ 64 | 65 | /** @override */ 66 | static storage(bonus) { 67 | const {size, type, self} = this.value(bonus) ?? {}; 68 | return Number.isNumeric(size) && Number.isNumeric(type); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /scripts/fields/remaining-spell-slots-field.mjs: -------------------------------------------------------------------------------- 1 | import FilterMixin from "./filter-mixin.mjs"; 2 | 3 | const {SchemaField, NumberField, BooleanField} = foundry.data.fields; 4 | 5 | export default class RemainingSpellSlotsField extends FilterMixin(SchemaField) { 6 | /** @override */ 7 | static name = "remainingSpellSlots"; 8 | 9 | /* -------------------------------------------------- */ 10 | 11 | constructor(fields = {}, options = {}) { 12 | super({ 13 | min: new NumberField({min: 0, step: 1, integer: true}), 14 | max: new NumberField({min: 0, step: 1, integer: true}), 15 | size: new BooleanField(), 16 | ...fields 17 | }, options); 18 | } 19 | 20 | /* -------------------------------------------------- */ 21 | 22 | /** @override */ 23 | static render(bonus) { 24 | const template = ` 25 |
26 | 27 | {{label}} 28 | 29 | 30 | 31 | 32 |

{{hint}}

33 |
34 | 35 |
36 | {{formInput minField value=min placeholder=phmin}} 37 | — 38 | {{formInput maxField value=max placeholder=phmax}} 39 |
40 |
41 | {{formGroup sizeField value=size}} 42 |
`; 43 | 44 | const schema = bonus.schema.getField(`filters.${this.name}`); 45 | const {min: minField, max: maxField, size: sizeField} = schema.fields; 46 | const {min, max, size} = bonus.filters[this.name]; 47 | 48 | const data = { 49 | label: schema.label, 50 | hint: schema.hint, 51 | minField, min, 52 | maxField, max, 53 | sizeField, size, 54 | phmin: game.i18n.localize("Minimum"), 55 | phmax: game.i18n.localize("Maximum") 56 | }; 57 | 58 | return Handlebars.compile(template)(data); 59 | } 60 | 61 | /* -------------------------------------------------- */ 62 | 63 | /** @override */ 64 | static storage(bonus) { 65 | const {min, max} = bonus.filters[this.name]; 66 | return Number.isNumeric(min) || Number.isNumeric(max); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /scripts/fields/attack-modes-field.mjs: -------------------------------------------------------------------------------- 1 | import {MODULE} from "../constants.mjs"; 2 | import FilterMixin from "./filter-mixin.mjs"; 3 | 4 | const {SchemaField, SetField, StringField} = foundry.data.fields; 5 | 6 | /* -------------------------------------------------- */ 7 | 8 | export default class AttackModesField extends FilterMixin(SchemaField) { 9 | /** @override */ 10 | static name = "attackModes"; 11 | 12 | /* -------------------------------------------------- */ 13 | 14 | constructor(fields = {}, options = {}) { 15 | super({ 16 | value: new SetField(new StringField({choices: CONFIG.DND5E.attackTypes})), 17 | classification: new SetField(new StringField({choices: CONFIG.DND5E.attackClassifications})), 18 | mode: new SetField(new StringField({choices: MODULE.ATTACK_MODES_CHOICES})) 19 | }, options); 20 | } 21 | 22 | /* -------------------------------------------------- */ 23 | 24 | /** @override */ 25 | static render(bonus) { 26 | const schema = bonus.schema.getField("filters.attackModes"); 27 | const {value, mode, classification} = schema.fields; 28 | 29 | const context = { 30 | value: { 31 | field: value, 32 | value: bonus.filters.attackModes.value 33 | }, 34 | mode: { 35 | field: mode, 36 | value: bonus.filters.attackModes.mode 37 | }, 38 | classification: { 39 | field: classification, 40 | value: bonus.filters.attackModes.classification 41 | }, 42 | legend: schema.label, 43 | hint: schema.hint 44 | }; 45 | 46 | const template = ` 47 |
48 | 49 | {{legend}} 50 | 51 | 52 | 53 | 54 |

{{hint}}

55 | {{formGroup value.field value=value.value}} 56 | {{formGroup classification.field value=classification.value}} 57 | {{formGroup mode.field value=mode.value}} 58 |
`; 59 | 60 | return Handlebars.compile(template)(context); 61 | } 62 | 63 | /* -------------------------------------------------- */ 64 | 65 | /** @override */ 66 | static storage(bonus) { 67 | const value = this.value(bonus); 68 | return Object.values(value).some(v => v.size); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Release Creation 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v2 13 | 14 | # Combine CSS files into a single CSS file 15 | - name: Combine CSS files 16 | run: | 17 | cat styles/*.css > module.css 18 | 19 | # Get part of the tag after the `v`. 20 | - name: Extract tag version number 21 | id: get_version 22 | uses: battila7/get-version-action@v2 23 | 24 | # Remove HotReload and update version, download, and style properties. 25 | - name: Modify Manifest to remove HotReload 26 | uses: microsoft/variable-substitution@v1 27 | with: 28 | files: "module.json" 29 | env: 30 | flags.hotReload: false 31 | version: ${{steps.get_version.outputs.version-without-v}} 32 | download: https://github.com/${{github.repository}}/releases/download/${{github.event.release.tag_name}}/module.zip 33 | styles: "[\"module.css\"]" 34 | esmodules: "[\"module.mjs\"]" 35 | 36 | # Set up Node 37 | - name: Use Node.js ${{ matrix.node-version }} 38 | uses: actions/setup-node@v3 39 | with: 40 | node-version: '20.10.0' 41 | cache: 'npm' 42 | 43 | # `npm ci` is recommended: 44 | # https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 45 | - name: Install Dependencies 46 | run: npm ci 47 | 48 | # Run build scripts 49 | - name: Build All 50 | run: npm run build 51 | 52 | # Create zip file. 53 | - name: Create ZIP archive 54 | run: zip -r ./module.zip module.json module.mjs module.css lang/ templates/ 55 | 56 | # Create a release for this specific version. 57 | - name: Update Release with Files 58 | if: "!github.event.release.prerelease" 59 | id: create_version_release 60 | uses: ncipollo/release-action@v1 61 | with: 62 | allowUpdates: true 63 | name: ${{ github.event.release.name }} 64 | draft: false 65 | prerelease: false 66 | token: ${{ secrets.GITHUB_TOKEN }} 67 | artifacts: "./module.json, ./module.zip" 68 | tag: ${{ github.event.release.tag_name }} 69 | body: ${{ github.event.release.body }} 70 | -------------------------------------------------------------------------------- /templates/babonus-workshop.hbs: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | {{localize "BABONUS.ModuleTitle"}} 5 | {{parentName}} 6 | 7 | 8 |

9 | 10 |
11 | 12 |
13 |
14 | {{localize "BABONUS.SelectType"}} 15 | 16 |
17 |
18 | {{#each createButtons}} 19 | 20 | 21 | {{localize label}} 22 | 23 | {{/each}} 24 |
25 |
26 | 27 | 28 |
29 |
30 | {{localize "BABONUS.CurrentBonuses"}} 31 | 32 |
33 |
34 | 35 | {{#each currentBonuses}} 36 |
37 | 59 |
{{{context.description}}}
60 |
61 | {{/each}} 62 | 63 |
64 |
65 | 66 |
67 | -------------------------------------------------------------------------------- /styles/optionals.css: -------------------------------------------------------------------------------- 1 | div.babonus.optionals { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 0.5em; 5 | padding: 0.5em; 6 | border: 1px inset; 7 | border-radius: 0.5em; 8 | margin-bottom: 1em; 9 | max-height: 450px; 10 | overflow-y: auto; 11 | 12 | .optional { 13 | display: grid; 14 | gap: 0.5em; 15 | 16 | &:not(:last-child) { 17 | border-bottom: 2px groove; 18 | padding-bottom: 0.5em; 19 | } 20 | 21 | & button { 22 | display: flex; 23 | padding: 3px; 24 | justify-content: center; 25 | } 26 | 27 | &.active button { 28 | background: inherit; 29 | border: none; 30 | box-shadow: none; 31 | pointer-events: none; 32 | 33 | & > i:before { 34 | content: "\f00c"; 35 | } 36 | } 37 | 38 | &.active .consumption { 39 | pointer-events: none; 40 | 41 | & > * { 42 | color: gray; 43 | } 44 | } 45 | 46 | .bonus-text .name { 47 | font-weight: bold; 48 | font-style: italic; 49 | } 50 | 51 | .consumption { 52 | display: flex; 53 | align-items: center; 54 | 55 | .label { 56 | flex: 1; 57 | font-weight: bold; 58 | color: #4b4a44; 59 | } 60 | 61 | & select { 62 | flex: 2; 63 | } 64 | } 65 | } 66 | 67 | .reminders:not(:last-child) { 68 | border-bottom: 2px groove; 69 | padding-bottom: 0.5em; 70 | } 71 | } 72 | 73 | .babonus.optionals { 74 | .description:empty::before, 75 | .description:not(:empty) > :first-child::before { 76 | content: var(--bonus-name); 77 | font-weight: bold; 78 | margin-right: 5px; 79 | } 80 | 81 | .reminder::before { 82 | display: none; 83 | } 84 | } 85 | 86 | fieldset.babonus.optionals { 87 | .optional:last-of-type + hr { display: none; } 88 | 89 | .optional .form-group button { 90 | flex: 0 0 var(--form-field-height); 91 | height: var(--form-field-height); 92 | padding: 0; 93 | margin-left: 0.5rem; 94 | 95 | .fa-solid { 96 | margin: 0; 97 | } 98 | 99 | &:hover { 100 | box-shadow: 0 0 6px var(--dnd5e-color-gold); 101 | } 102 | } 103 | 104 | .optional.active .form-group :is(select, button) { 105 | pointer-events: none; 106 | background: inherit; 107 | color: gray; 108 | border-color: rgba(255, 255, 255, 0); 109 | box-shadow: none; 110 | } 111 | 112 | .optional.active .form-group button .fa-solid::before { 113 | content: "\f00c"; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /scripts/fields/arbitrary-comparison-field.mjs: -------------------------------------------------------------------------------- 1 | import FilterMixin from "./filter-mixin.mjs"; 2 | 3 | const {SchemaField, StringField, ArrayField} = foundry.data.fields; 4 | 5 | // ArrayField that filters invalid comparison fields. 6 | export default class ArbitraryComparisonField extends FilterMixin(ArrayField) { 7 | /** @override */ 8 | static name = "arbitraryComparisons"; 9 | 10 | /* -------------------------------------------------- */ 11 | 12 | /** @override */ 13 | static repeatable = true; 14 | 15 | /* -------------------------------------------------- */ 16 | 17 | /** @override */ 18 | constructor(options = {}) { 19 | super(new SchemaField({ 20 | one: new StringField(), 21 | other: new StringField(), 22 | operator: new StringField({ 23 | required: true, 24 | initial: "EQ", 25 | choices: {EQ: "=", LT: "<", GT: ">", LE: "<=", GE: ">="} 26 | }) 27 | }), options); 28 | } 29 | 30 | /* -------------------------------------------------- */ 31 | 32 | /** @override */ 33 | static render(bonus) { 34 | const template = ` 35 |
36 | {{label}} 37 |

{{hint}}

38 | {{#each comparisons as |c idx|}} 39 |
40 |
41 | {{formInput c.one.field value=c.one.value placeholder=../placeholder1 name=c.one.name}} 42 | {{formInput c.operator.field value=c.operator.value name=c.operator.name}} 43 | {{formInput c.other.field value=c.other.value placeholder=../placeholder2 name=c.other.name}} 44 |
45 | 46 | 47 | 48 |
49 | {{/each}} 50 |
`; 51 | 52 | const field = bonus.schema.getField("filters.arbitraryComparisons"); 53 | const {one, other, operator} = field.element.fields; 54 | const data = { 55 | label: field.label, 56 | hint: field.hint, 57 | placeholder1: game.i18n.localize("BABONUS.FIELDS.filters.arbitraryComparisons.one.placeholder"), 58 | placeholder2: game.i18n.localize("BABONUS.FIELDS.filters.arbitraryComparisons.other.placeholder"), 59 | comparisons: bonus.filters.arbitraryComparisons.map((c, i) => { 60 | return { 61 | one: {field: one, value: c.one, name: `filters.${this.name}.${i}.one`}, 62 | other: {field: other, value: c.other, name: `filters.${this.name}.${i}.other`}, 63 | operator: {field: operator, value: c.operator, name: `filters.${this.name}.${i}.operator`} 64 | }; 65 | }) 66 | }; 67 | 68 | return data.comparisons.length ? Handlebars.compile(template)(data) : ""; 69 | } 70 | 71 | /* -------------------------------------------------- */ 72 | 73 | /** @override */ 74 | static storage(bonus) { 75 | return this.value(bonus).filter(i => i).length > 0; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /templates/sheet-advanced.hbs: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | {{localize "BABONUS.Toggles"}} 5 | {{#with fields}} 6 | {{formGroup enabled.field value=enabled.value rootId=@root.rootId}} 7 | {{formGroup exclusive.field value=exclusive.value rootId=@root.rootId}} 8 | {{formGroup optional.field value=optional.value rootId=@root.rootId}} 9 | {{formGroup reminder.field value=reminder.value disabled=reminder.disabled rootId=@root.rootId}} 10 | {{/with}} 11 |
12 | 13 |
14 | {{localize "BABONUS.FIELDS.consume.label"}} 15 | {{#with consume}} 16 | {{formGroup enabled.field value=enabled.value rootId=@root.rootId disabled=enabled.disabled}} 17 | {{#if enabled.value}} 18 | {{formGroup type.field value=type.value sort=true blank="-"}} 19 | {{#unless scales.unavailable}} 20 | {{#if subtype.show}} 21 | {{formGroup subtype.field value=subtype.value sort=false blank="-" choices=subtype.choices label=subtype.label}} 22 | {{/if}} 23 | {{#unless scales.unavailable}} 24 | {{formGroup scales.field value=scales.value rootId=@root.rootId}} 25 | {{/unless}} 26 | 27 |
28 | 32 |
33 | {{formInput value.min.field value=value.min.value placeholder=value.min.placeholder}} 34 | {{#if scales.value}} 35 | 36 | {{formInput value.max.field value=value.max.value placeholder=value.max.placeholder}} 37 | {{/if}} 38 |
39 |

{{value.hint}}

40 |
41 | 42 | {{#if step.show}} 43 | {{formGroup step.field value=step.value placeholder=step.field.label}} 44 | {{/if}} 45 | 46 | {{#if formula.show}} 47 | {{formGroup formula.field value=formula.value placeholder=formula.placeholder}} 48 | {{/if}} 49 | 50 | {{/unless}} 51 | 52 | {{/if}} 53 | {{/with}} 54 |
55 | 56 |
57 | {{localize "BABONUS.FIELDS.aura.label"}} 58 | {{#with aura}} 59 | {{formGroup enabled.field value=enabled.value rootId=@root.rootId}} 60 | 61 | {{#if enabled.value}} 62 | {{formGroup template.field value=template.value rootId=@root.rootId}} 63 | {{#unless template.value}} 64 | {{formGroup range.field value=range.value label=range.label placeholder=range.field.label}} 65 | {{/unless}} 66 | {{formGroup disposition.field value=disposition.value sort=false}} 67 | {{formGroup self.field value=self.value rootId=@root.rootId}} 68 | {{formGroup blockers.field value=blockers.value}} 69 | {{#unless template.value}} 70 | {{#each requirements}} 71 | {{formGroup field value=value rootId=@root.rootId}} 72 | {{/each}} 73 | {{/unless}} 74 | {{/if}} 75 | {{/with}} 76 |
77 | 78 |
79 | -------------------------------------------------------------------------------- /scripts/fields/filter-mixin.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * A mixin function for base filter behaviour. 3 | * @param {Class} Base The base class. 4 | * @returns {Class} 5 | * @mixin 6 | */ 7 | export default function FilterMixin(Base) { 8 | return class BaseFilter extends Base { 9 | /** 10 | * The name of the filter. 11 | * @type {string} 12 | */ 13 | static name = null; 14 | 15 | /* -------------------------------------------------- */ 16 | 17 | /** 18 | * Whether this filter can be added more than once to a babonus. 19 | * @type {boolean} 20 | */ 21 | static repeatable = false; 22 | 23 | /* -------------------------------------------------- */ 24 | 25 | /** 26 | * What handlebars template to use when rendering this filter in the builder. 27 | * @type {string} 28 | */ 29 | static template = null; 30 | 31 | /* -------------------------------------------------- */ 32 | 33 | /** 34 | * Whether this filter has 'exclude' as an option in KeysDialog. 35 | * @type {boolean} 36 | */ 37 | static canExclude = false; 38 | 39 | /* -------------------------------------------------- */ 40 | 41 | /** 42 | * Should this filter display the trash button? 43 | * @type {boolean} 44 | */ 45 | static trash = true; 46 | 47 | /* -------------------------------------------------- */ 48 | 49 | /** 50 | * Get the current data values of this filter. 51 | * @param {Babonus} bonus The instance of the babonus on which this field lives. 52 | */ 53 | static value(bonus) { 54 | return foundry.utils.getProperty(bonus, `filters.${this.name}`); 55 | } 56 | 57 | /* -------------------------------------------------- */ 58 | 59 | /** 60 | * Render the filter. 61 | * @param {Babonus} bonus The bonus being rendered. 62 | * @returns {string} The rendered template. 63 | */ 64 | static render(bonus) { 65 | throw new Error("This must be subclassed!"); 66 | } 67 | 68 | /* -------------------------------------------------- */ 69 | 70 | /** 71 | * Determine whether this filter data should be saved on the document. 72 | * @param {Babonus} bonus The bonus being embedded. 73 | * @returns {boolean} Whether to save the filter. 74 | */ 75 | static storage(bonus) { 76 | return this.value(bonus).size > 0; 77 | } 78 | 79 | /* -------------------------------------------------- */ 80 | 81 | /** @override */ 82 | toFormGroup(formConfig, inputConfig) { 83 | const element = super.toFormGroup(formConfig, inputConfig); 84 | 85 | if (this.constructor.trash) { 86 | const trash = document.createElement("A"); 87 | trash.dataset.action = "deleteFilter"; 88 | trash.dataset.id = this.constructor.name; 89 | trash.innerHTML = ""; 90 | element.querySelector(".form-fields").after(trash); 91 | } 92 | 93 | return element; 94 | } 95 | }; 96 | } 97 | -------------------------------------------------------------------------------- /styles/shared.css: -------------------------------------------------------------------------------- 1 | .dnd5e-theme-dark .babonus.dnd5e2 { 2 | --dnd5e-background-card: var(--dnd5e-color-card); 3 | 4 | & form select option, form select optgroup { 5 | background: inherit; 6 | } 7 | } 8 | 9 | .babonus.dnd5e2 { 10 | background: 11 | url(../../../systems/dnd5e/ui/texture1.webp) no-repeat top center / auto 770px, 12 | url(../../../systems/dnd5e/ui/texture2.webp) no-repeat bottom center / auto 704px, 13 | var(--dnd5e-color-parchment); 14 | border: 1px solid var(--dnd5e-color-gold); 15 | 16 | &.minimized .header-button, 17 | .header-button.configure-sheet { 18 | display: none; 19 | } 20 | 21 | &:not(.minimized) .window-header .window-title { 22 | visibility: hidden; 23 | } 24 | 25 | .window-header { 26 | margin-left: 3px; 27 | margin-right: 3px; 28 | 29 | .window-title { 30 | color: initial; 31 | } 32 | } 33 | 34 | .window-content { 35 | background: transparent; 36 | overflow: hidden; 37 | margin-left: 3px; 38 | margin-right: 3px; 39 | } 40 | 41 | & input:disabled { 42 | border: gray; 43 | } 44 | 45 | &.create-document ol img { 46 | filter: invert(0); 47 | } 48 | } 49 | 50 | .babonus h1.otters { 51 | text-align: center; 52 | font-size: 50px; 53 | display: flex; 54 | justify-content: center; 55 | flex: none; 56 | 57 | .mirror { 58 | transform: scaleX(-1); 59 | transition: 0.5s; 60 | } 61 | 62 | .name-stacked { 63 | .title { 64 | font-size: 25px; 65 | border-bottom: 2px solid var(--dnd5e-color-gold); 66 | padding-left: 0.5em; 67 | padding-right: 0.5em; 68 | margin: 0 0.5em; 69 | } 70 | 71 | .subtitle { 72 | font-size: 14px; 73 | } 74 | } 75 | } 76 | 77 | .dnd5e-theme-dark .dnd5e2.sheet.actor h1.otters { 78 | color: var(--dnd5e-color-gold); 79 | } 80 | 81 | .dnd5e2.sheet.actor { 82 | & form.tab-babonus .create-child { 83 | display: block; 84 | } 85 | 86 | .tab.babonus { 87 | flex-direction: column; 88 | 89 | .items-section { 90 | .item { display: flex; } 91 | .bonus-source { 92 | width: 150px; 93 | border: none; 94 | 95 | &.item-detail { 96 | color: var(--color-text-dark-5); 97 | 98 | &.empty::after { 99 | content: "-"; 100 | color: var(--color-text-light-6); 101 | font-weight: normal; 102 | } 103 | } 104 | } 105 | 106 | [data-action=contextMenu] { 107 | position: absolute; 108 | inset-block: 0; 109 | inset-inline-end: 0; 110 | width: 1.5rem; 111 | display: flex; 112 | align-items: center; 113 | justify-content: center; 114 | } 115 | } 116 | } 117 | } 118 | 119 | .babonus.content-link[data-link][data-uuid] { 120 | .fa-otter { 121 | transition: transform 200ms ease; 122 | } 123 | 124 | &:not(.enabled) .fa-otter { 125 | transform: rotate(180deg); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /scripts/applications/keys-dialog.mjs: -------------------------------------------------------------------------------- 1 | import {MODULE} from "../constants.mjs"; 2 | 3 | export default class KeysDialog extends foundry.applications.api.DialogV2 { 4 | /** @override */ 5 | static DEFAULT_OPTIONS = { 6 | classes: [MODULE.ID, "keys-dialog"], 7 | modal: true, 8 | window: { 9 | resizable: false, 10 | icon: "fa-solid fa-otter" 11 | }, 12 | position: { 13 | height: "auto", 14 | width: 400 15 | }, 16 | actions: { 17 | cycle: this.#onCycleRight, 18 | cycleAll: this.#onCycleAll, 19 | cycleLeft: this.#onCycleLeft, 20 | cycleRight: this.#onCycleRight 21 | } 22 | }; 23 | 24 | /* -------------------------------------------------- */ 25 | 26 | /** @override */ 27 | static async prompt({canExclude, values, filterId, ...configuration} = {}) { 28 | const description = (filterId === "auraBlockers") 29 | ? "BABONUS.FIELDS.aura.blockers.hint" 30 | : `BABONUS.FIELDS.filters.${filterId}.hint`; 31 | 32 | configuration.content = await renderTemplate(`modules/${MODULE.ID}/templates/subapplications/keys-dialog.hbs`, { 33 | canExclude: canExclude, 34 | values: values, 35 | description: description 36 | }); 37 | configuration.filterId = filterId; 38 | configuration.rejectClose = false; 39 | return super.prompt(configuration); 40 | } 41 | 42 | /* -------------------------------------------------- */ 43 | 44 | /** @override */ 45 | get title() { 46 | const name = (this.options.filterId === "auraBlockers") 47 | ? "BABONUS.FIELDS.aura.blockers.label" 48 | : `BABONUS.FIELDS.filters.${this.options.filterId}.label`; 49 | return game.i18n.format("BABONUS.KeysDialogTitle", {name: game.i18n.localize(name)}); 50 | } 51 | 52 | /* -------------------------------------------------- */ 53 | 54 | /** 55 | * Cycle all selects in a column between the valid options. 56 | * @param {Event} event The initiating click event. 57 | */ 58 | static #onCycleAll(event, target) { 59 | const table = target.closest(".table"); 60 | const selects = table.querySelectorAll("select"); 61 | const newIndex = (selects[0].selectedIndex + 1) % selects[0].options.length; 62 | selects.forEach(select => select.selectedIndex = newIndex); 63 | } 64 | 65 | /* -------------------------------------------------- */ 66 | 67 | /** 68 | * Custom implementation for label-to-checkbox linking. 69 | * @param {Event} event The initiating click event. 70 | */ 71 | static #onCycleRight(event, target) { 72 | const select = target.closest(".row").querySelector(".select select"); 73 | const newIndex = (select.selectedIndex + 1) % select.options.length; 74 | select.selectedIndex = newIndex; 75 | } 76 | 77 | /* -------------------------------------------------- */ 78 | 79 | /** 80 | * Cycle backwards in the select options. 81 | * @param {Event} event The initiating click event. 82 | */ 83 | static #onCycleLeft(event, target) { 84 | const select = target.nextElementSibling; 85 | const n = select.selectedIndex - 1; 86 | const mod = select.options.length; 87 | const newIndex = ((n % mod) + mod) % mod; 88 | select.selectedIndex = newIndex; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /templates/subapplications/character-sheet-tab.hbs: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | 5 | {{localize "BABONUS.ModuleTitle"}} 6 | {{parentName}} 7 | 8 | 9 |

10 | 11 | 12 | 13 | 14 |
15 | 16 | {{#each sections as |section|}} 17 |
18 | 19 |
20 |

{{localize section.label}}

21 |
{{localize "DND5E.SOURCE.FIELDS.source.label"}}
22 |
23 |
24 | 25 |
    26 | {{#each bonuses}} 27 |
  1. 38 |
    39 | {{bonus.name}} 40 |
    41 | {{bonus.name}} 42 | {{{labels}}} 43 |
    44 |
    45 |
    46 | {{#if isEmbedded}} 47 | {{bonus.parent.name}} 48 | {{/if}} 49 |
    50 |
    51 | {{#if @root.isEdit}} 52 | 53 | 54 | 55 | 56 | 57 | 58 | {{else}} 59 | 60 | 61 | 62 | {{/if}} 63 | 64 | 65 | 66 |
    67 |
  2. 68 | {{/each}} 69 |
70 | 71 |
72 | {{/each}} 73 | 74 |
75 | 76 |
77 |
78 | -------------------------------------------------------------------------------- /styles/sheet.css: -------------------------------------------------------------------------------- 1 | .babonus.sheet { 2 | max-height: 95%; 3 | 4 | .sheet-tabs.tabs .item { 5 | flex-direction: column; 6 | 7 | .fa-solid { 8 | font-size: 20px; 9 | } 10 | } 11 | 12 | .notification.info { 13 | margin: 0.5em 0; 14 | padding: 6px 8px; 15 | } 16 | 17 | .tab.scrollable { 18 | min-height: 400px; 19 | 20 | &[data-tab=bonuses] { 21 | padding-bottom: 5rem; 22 | } 23 | 24 | &[data-tab=configuration] { 25 | padding-bottom: 5rem; 26 | } 27 | 28 | &[data-tab=advanced] { 29 | padding-bottom: 5rem; 30 | } 31 | 32 | &[data-tab=filters] .scrollable { 33 | padding-bottom: 5rem; 34 | } 35 | } 36 | 37 | .modifiers.example { 38 | position: sticky; 39 | top: 0; 40 | } 41 | 42 | & fieldset:hover > .hint { 43 | color: var(--color-form-hint-hover); 44 | } 45 | 46 | /* Arbitrary Comparisons */ 47 | & select[name^="filters.arbitraryComparisons"] { 48 | flex: 0; 49 | width: fit-content; 50 | } 51 | 52 | /* Custom Scripts */ 53 | & textarea[name^="filters.customScripts"] { 54 | resize: vertical; 55 | min-height: calc(3rem + var(--form-field-height)); 56 | overflow-y: hidden; 57 | } 58 | 59 | & [data-action="keysDialog"] { 60 | width: 100px; 61 | max-width: 100px; 62 | height: 26px; 63 | } 64 | 65 | & [data-action="deleteFilter"] { 66 | flex: none; 67 | padding-left: 0.5em; 68 | } 69 | 70 | .header.name-stacked { 71 | display: flex; 72 | padding: 0 5px; 73 | flex-direction: row; 74 | flex-wrap: wrap; 75 | margin-bottom: calc(3px - 1rem); 76 | 77 | & img { 78 | flex: none; 79 | width: 60px; 80 | height: 60px; 81 | } 82 | 83 | & input { 84 | flex: 1; 85 | height: 40px; 86 | margin: 10px 5px; 87 | font-family: "Modesto Condensed"; 88 | font-size: 32px; 89 | } 90 | 91 | .properties { 92 | width: 100%; 93 | list-style: none; 94 | margin: 0; 95 | padding: 0; 96 | display: flex; 97 | gap: 3px; 98 | margin-top: 3px; 99 | 100 | .property { 101 | margin: 0; 102 | padding: 2px 4px; 103 | background: rgba(0, 0, 0, 0.05); 104 | border: 1px groove var(--color-fieldset-border); 105 | border-radius: 3px; 106 | font-size: 12px; 107 | white-space: nowrap; 108 | } 109 | } 110 | } 111 | 112 | [data-tab=filters] { 113 | flex-direction: row; 114 | 115 | .toc { 116 | flex: 0 0 160px; 117 | list-style: none; 118 | padding: 0; 119 | 120 | & li { 121 | margin: 5px 0; 122 | border: 1px groove rgba(0, 0, 0, 0); 123 | border-radius: 3px; 124 | padding: 3px; 125 | 126 | &.viewed { 127 | border-color: var(--color-fieldset-border); 128 | } 129 | } 130 | } 131 | 132 | .picker .filter { 133 | margin-top: 1rem; 134 | cursor: pointer; 135 | transition: border-color 200ms ease; 136 | 137 | &:hover { 138 | border-color: var(--button-hover-background-color); 139 | } 140 | } 141 | } 142 | } 143 | 144 | .dnd5e-theme-light .babonus.sheet { 145 | .sheet-tabs .item { 146 | color: black; 147 | } 148 | 149 | .properties .property, 150 | .toc .viewed { 151 | --color-fieldset-border: black; 152 | } 153 | 154 | .picker .filter:hover { 155 | --button-hover-background-color: black; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /templates/sheet-bonuses.hbs: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | {{localize "BABONUS.BasicBonuses"}} 5 | {{#each bonuses}} 6 | {{#if isDamage}} 7 | {{formGroup field value=value options=options}} 8 | {{else}} 9 | {{formGroup field value=value}} 10 | {{/if}} 11 | {{/each}} 12 |
13 | 14 | {{#if hasModifiers}} 15 |
16 | {{localize "BABONUS.MODIFIERS.FIELDS.config.label"}} 17 | {{#with modifiers.config.first}} 18 | {{formGroup field value=value rootId=@root.rootId}} 19 | {{/with}} 20 | 21 | {{#with modifiers.enabled}} 22 | {{formGroup field value=value options=choices name="bonuses.modifiers.config.enabled" type="checkboxes" classes="stacked"}} 23 | {{/with}} 24 | 25 |
26 | 27 |
28 | 29 |
{{modifiers.config.example}}
30 |
31 | {{/if}} 32 | 33 | 34 | {{#if modifiers.amount.enabled}} 35 | {{#with modifiers.amount}} 36 |
37 | {{localize "BABONUS.MODIFIERS.FIELDS.amount.label"}} 38 | {{formGroup value.field value=value.value placeholder=value.field.label}} 39 | {{formGroup mode.field value=mode.value}} 40 |
41 | {{/with}} 42 | {{/if}} 43 | 44 | 45 | {{#if modifiers.size.enabled}} 46 | {{#with modifiers.size}} 47 |
48 | {{localize "BABONUS.MODIFIERS.FIELDS.size.label"}} 49 | {{formGroup value.field value=value.value placeholder=value.field.label}} 50 | {{formGroup mode.field value=mode.value}} 51 |
52 | {{/with}} 53 | {{/if}} 54 | 55 | 56 | {{#if modifiers.reroll.enabled}} 57 | {{#with modifiers.reroll}} 58 |
59 | {{localize "BABONUS.MODIFIERS.FIELDS.reroll.label"}} 60 | {{formGroup value.field value=value.value placeholder=value.field.label}} 61 | {{formGroup invert.field value=invert.value rootId=@root.rootId}} 62 | {{formGroup recursive.field value=recursive.value rootId=@root.rootId}} 63 | {{#if recursive.value}} 64 | {{formGroup limit.field value=limit.value placeholder=limit.field.label}} 65 | {{/if}} 66 |
67 | {{/with}} 68 | {{/if}} 69 | 70 | 71 | {{#if modifiers.explode.enabled}} 72 | {{#with modifiers.explode}} 73 |
74 | {{localize "BABONUS.MODIFIERS.FIELDS.explode.label"}} 75 | {{formGroup value.field value=value.value placeholder=value.field.label}} 76 | {{formGroup once.field value=once.value rootId=@root.rootId}} 77 | {{#unless once.value}} 78 | {{formGroup limit.field value=limit.value placeholder=limit.field.label}} 79 | {{/unless}} 80 |
81 | {{/with}} 82 | {{/if}} 83 | 84 | 85 | {{#if modifiers.minimum.enabled}} 86 | {{#with modifiers.minimum}} 87 |
88 | {{localize "BABONUS.MODIFIERS.FIELDS.minimum.label"}} 89 | {{formGroup maximize.field value=maximize.value rootId=@root.rootId}} 90 | {{#unless maximize.value}} 91 | {{formGroup value.field value=value.value placeholder=value.field.label}} 92 | {{/unless}} 93 |
94 | {{/with}} 95 | {{/if}} 96 | 97 | 98 | {{#if modifiers.maximum.enabled}} 99 | {{#with modifiers.maximum}} 100 |
101 | {{localize "BABONUS.MODIFIERS.FIELDS.maximum.label"}} 102 | {{formGroup value.field value=value.value placeholder=value.field.label}} 103 | {{formGroup zero.field value=zero.value rootId=@root.rootId}} 104 |
105 | {{/with}} 106 | {{/if}} 107 | 108 |
109 | -------------------------------------------------------------------------------- /styles/workshop.css: -------------------------------------------------------------------------------- 1 | .babonus.builder .locked a { 2 | color: gray; 3 | } 4 | .babonus.builder { 5 | min-height: 500px; 6 | min-width: 450px; 7 | } 8 | .babonus.builder .window-header { 9 | border: none; 10 | align-items: center; 11 | } 12 | .babonus.builder .window-content { 13 | overflow: hidden; 14 | } 15 | .babonus.builder .pages { 16 | overflow-y: hidden; 17 | flex: 1; 18 | display: flex; 19 | height: 100%; 20 | gap: 10px; 21 | } 22 | .babonus.builder .pages .header { 23 | height: 30px; 24 | border: 1px solid var(--color-border-light-tertiary); 25 | background: rgba(0, 0, 0, 0.1); 26 | padding-left: 0.5em; 27 | border-radius: 4px; 28 | font-family: var(--dnd5e-font-roboto-slab); 29 | font-size: var(--font-size-13); 30 | font-weight: bolder; 31 | line-height: 30px; 32 | } 33 | .babonus.builder .pages .select-type, 34 | .babonus.builder .pages .current-bonuses { 35 | display: flex; 36 | height: 100%; 37 | flex-direction: column; 38 | overflow: hidden; 39 | } 40 | .babonus.builder .pages .current-bonuses { 41 | flex: 1; 42 | } 43 | .babonus.builder .pages .select-type.hidden .types {} 44 | .babonus.builder .pages .select-type.hidden .types a { 45 | grid-template-columns: 100%; 46 | padding: 0; 47 | } 48 | .babonus.builder .pages .select-type.hidden .types a > i { 49 | font-size: 40px; 50 | } 51 | .babonus.builder .pages .select-type.hidden .types .label { 52 | display: none; 53 | } 54 | .babonus.builder .pages .select-type .types { 55 | display: flex; 56 | flex-direction: column; 57 | justify-content: space-around; 58 | font-size: 26px; 59 | min-width: 120px; 60 | flex: 1; 61 | max-height: calc(75px * 6); 62 | } 63 | .babonus.builder .pages .select-type .types a { 64 | display: grid; 65 | grid-template-columns: 50px 3fr; 66 | align-items: center; 67 | padding-left: 10px; 68 | } 69 | .babonus.builder .pages .select-type .types a > i { 70 | place-self: center; 71 | font-size: 25px; 72 | transition: font-size 200ms; 73 | } 74 | .babonus.builder .pages .current-bonuses .bonuses { 75 | height: 100%; 76 | overflow-y: auto; 77 | display: flex; 78 | flex: 1; 79 | flex-direction: column; 80 | padding: 5px; 81 | scrollbar-width: thin; 82 | scrollbar-color: var(--dnd5e-color-gold) transparent; 83 | } 84 | .babonus.builder .pages .current-bonuses .bonuses .bonus { 85 | border-bottom: 2px groove; 86 | padding: 0.5em 0; 87 | } 88 | .babonus.builder .pages .current-bonuses .bonuses .bonus .bonus-header { 89 | display: grid; 90 | grid-template-columns: 1fr 0fr; 91 | margin-bottom: 10px; 92 | gap: 5px; 93 | } 94 | .babonus.builder .pages .current-bonuses .bonuses .bonus .bonus-header .functions { 95 | display: flex; 96 | justify-content: space-evenly; 97 | align-items: center; 98 | font-size: 12px; 99 | gap: 8px; 100 | } 101 | .babonus.builder .pages .current-bonuses .bonuses .bonus .bonus-header .label { 102 | text-align: center; 103 | font-size: 25px; 104 | font-family: 'Modesto Condensed'; 105 | display: flex; 106 | justify-content: center; 107 | align-items: center; 108 | } 109 | .babonus.builder .pages .current-bonuses .bonuses .bonus .bonus-header .label.disabled { 110 | text-decoration: line-through; 111 | } 112 | .babonus.builder .pages .current-bonuses .bonuses .bonus .bonus-header .label .fa-solid { 113 | font-size: 18px; 114 | padding: 4px; 115 | } 116 | .babonus.builder .pages .current-bonuses .bonuses .bonus .bonus-header [data-action="current-id"] { 117 | color: gray; 118 | font-size: 12px; 119 | font-family: monospace; 120 | } 121 | .babonus.builder .pages .current-bonuses .bonuses .bonus .description { 122 | font-style: italic; 123 | border-radius: 5px; 124 | padding: 0.5em; 125 | background-color: rgba(181, 179, 164, 0.5); 126 | user-select: text; 127 | max-height: fit-content; 128 | overflow: hidden; 129 | opacity: 1; 130 | } 131 | .babonus.builder .pages .current-bonuses .bonuses .bonus.collapsed .description { 132 | display: none; 133 | } 134 | @keyframes rainbow { 135 | to { background-position: 0 -200% } 136 | } 137 | .babonus.builder .vomit { 138 | background: linear-gradient(rgba(255, 0, 0, 1) 0%, 139 | rgba(255, 154, 0, 1) 10%, 140 | rgba(208, 222, 33, 1) 20%, 141 | rgba(79, 220, 74, 1) 30%, 142 | rgba(63, 218, 216, 1) 40%, 143 | rgba(47, 201, 226, 1) 50%, 144 | rgba(28, 127, 238, 1) 60%, 145 | rgba(95, 21, 242, 1) 70%, 146 | rgba(186, 12, 248, 1) 80%, 147 | rgba(251, 7, 217, 1) 90%, 148 | rgba(255, 0, 0, 1) 100%) 0 0/100% 200%; 149 | animation: rainbow 5000ms linear infinite; 150 | } 151 | -------------------------------------------------------------------------------- /scripts/applications/bonus-collection.mjs: -------------------------------------------------------------------------------- 1 | import {Babonus} from "../models/babonus-model.mjs"; 2 | 3 | const {Collection} = foundry.utils; 4 | 5 | /** 6 | * Simple class for holding onto bonuses in a structured manner depending on their type and effect. 7 | * @param {Iterable} bonuses The bonuses to structure. 8 | */ 9 | export default class BonusCollection { 10 | constructor(bonuses) { 11 | this.#bonuses = bonuses; 12 | } 13 | 14 | /* -------------------------------------------------- */ 15 | 16 | /** 17 | * The bonuses to structure. 18 | * @type {Iterable} 19 | */ 20 | #bonuses = null; 21 | 22 | /* -------------------------------------------------- */ 23 | 24 | /** 25 | * Reference to the size of the collection, regardless of type of iterator. 26 | * @type {number} 27 | */ 28 | get size() { 29 | return this.all.size; 30 | } 31 | 32 | /* -------------------------------------------------- */ 33 | 34 | /** 35 | * All the bonuses regardless of bonus, type, or modifiers. 36 | * @type {Collection} 37 | */ 38 | get all() { 39 | if (!this.#all) { 40 | const collection = new Collection(); 41 | for (const bonus of this.#bonuses) { 42 | collection.set(bonus.uuid, bonus); 43 | } 44 | this.#all = collection; 45 | } 46 | return this.#all; 47 | } 48 | 49 | /* -------------------------------------------------- */ 50 | 51 | /** 52 | * All the bonuses regardless of bonus, type, or modifiers. 53 | * @type {Collection} 54 | */ 55 | #all = null; 56 | 57 | /* -------------------------------------------------- */ 58 | 59 | /** 60 | * All the bonuses that are just reminders. 61 | * @type {Collection} 62 | */ 63 | get reminders() { 64 | if (!this.#reminders) { 65 | const collection = new Collection(); 66 | for (const bonus of this.#bonuses) { 67 | if (bonus.isReminder) collection.set(bonus.uuid, bonus); 68 | } 69 | this.#reminders = collection; 70 | } 71 | return this.#reminders; 72 | } 73 | 74 | /* -------------------------------------------------- */ 75 | 76 | /** 77 | * All the bonuses that are just reminders. 78 | * @type {Collection} 79 | */ 80 | #reminders = null; 81 | 82 | /* -------------------------------------------------- */ 83 | 84 | /** 85 | * All the bonuses that have dice modifiers. 86 | * @type {Collection} 87 | */ 88 | get modifiers() { 89 | if (!this.#modifiers) { 90 | const collection = new Collection(); 91 | for (const bonus of this.#bonuses) { 92 | if (bonus.hasDiceModifiers) collection.set(bonus.uuid, bonus); 93 | } 94 | this.#modifiers = collection; 95 | } 96 | return this.#modifiers; 97 | } 98 | 99 | /* -------------------------------------------------- */ 100 | 101 | /** 102 | * All the bonuses that have dice modifiers. 103 | * @type {Collection} 104 | */ 105 | #modifiers = null; 106 | 107 | /* -------------------------------------------------- */ 108 | 109 | /** 110 | * All the bonuses that are optional. 111 | * @type {Collection} 112 | */ 113 | get optionals() { 114 | if (!this.#optionals) { 115 | const collection = new Collection(); 116 | for (const bonus of this.#bonuses) { 117 | if (bonus.isOptional) collection.set(bonus.uuid, bonus); 118 | } 119 | this.#optionals = collection; 120 | } 121 | return this.#optionals; 122 | } 123 | 124 | /* -------------------------------------------------- */ 125 | 126 | /** 127 | * All the bonuses that are optional. 128 | * @type {Collection} 129 | */ 130 | #optionals = null; 131 | 132 | /* -------------------------------------------------- */ 133 | 134 | /** 135 | * All the bonuses that apply immediately with no configuration. 136 | * @type {Collection} 137 | */ 138 | get nonoptional() { 139 | if (!this.#nonoptional) { 140 | const collection = new Collection(); 141 | for (const bonus of this.#bonuses) { 142 | if (bonus.isOptional || bonus.isReminder) continue; 143 | if (bonus.hasBonuses) collection.set(bonus.uuid, bonus); 144 | } 145 | this.#nonoptional = collection; 146 | } 147 | return this.#nonoptional; 148 | } 149 | 150 | /* -------------------------------------------------- */ 151 | 152 | /** 153 | * All the bonuses that apply immediately with no configuration. 154 | * @type {Collection} 155 | */ 156 | #nonoptional = null; 157 | } 158 | -------------------------------------------------------------------------------- /scripts/models/aura-model.mjs: -------------------------------------------------------------------------------- 1 | import {MODULE} from "../constants.mjs"; 2 | 3 | const {BooleanField, StringField, NumberField, SchemaField} = foundry.data.fields; 4 | 5 | export default class AuraModel extends foundry.abstract.DataModel { 6 | /** @override */ 7 | static defineSchema() { 8 | return { 9 | enabled: new BooleanField(), 10 | template: new BooleanField(), 11 | range: new StringField({required: true}), 12 | self: new BooleanField({initial: true}), 13 | disposition: new NumberField({ 14 | initial: 2, 15 | choices: MODULE.DISPOSITION_TYPES 16 | }), 17 | blockers: new babonus.abstract.DataFields.fields.auraBlockers(), 18 | require: new SchemaField(CONST.WALL_RESTRICTION_TYPES.reduce((acc, k) => { 19 | acc[k] = new BooleanField(); 20 | return acc; 21 | }, {})) 22 | }; 23 | } 24 | 25 | /* -------------------------------------------------- */ 26 | 27 | /** @override */ 28 | _initialize(...args) { 29 | super._initialize(...args); 30 | this.prepareDerivedData(); 31 | } 32 | 33 | /* -------------------------------------------------- */ 34 | 35 | /** @override */ 36 | static migrateData(source) { 37 | if (source.isTemplate) source.template = source.isTemplate; 38 | } 39 | 40 | /* -------------------------------------------------- */ 41 | 42 | /** @override */ 43 | prepareDerivedData() { 44 | // Prepare aura range. 45 | if (this.range) { 46 | const range = dnd5e.utils.simplifyBonus(this.range, this.getRollData()); 47 | this.range = range; 48 | } 49 | 50 | // Scene regions cannot be auras. 51 | if (this.bonus.region) this.enabled = false; 52 | } 53 | 54 | /* -------------------------------------------------- */ 55 | 56 | /** 57 | * Get applicable roll data from the origin. 58 | * @returns {object} The roll data. 59 | */ 60 | getRollData() { 61 | return this.parent.getRollData({deterministic: true}); 62 | } 63 | 64 | /* -------------------------------------------------- */ 65 | /* Properties */ 66 | /* -------------------------------------------------- */ 67 | 68 | /** 69 | * The babonus this lives on. 70 | * @type {Babonus} 71 | */ 72 | get bonus() { 73 | return this.parent; 74 | } 75 | 76 | /* -------------------------------------------------- */ 77 | 78 | /** 79 | * Get whether this has a range that matters. 80 | * @type {boolean} 81 | */ 82 | get _validRange() { 83 | return (this.range === -1) || (this.range > 0); 84 | } 85 | 86 | /* -------------------------------------------------- */ 87 | 88 | /** 89 | * Whether the babonus is an enabled and valid aura centered on a token. This is true if the property is enabled, the 90 | * template aura property is not enabled, and the range of the aura is valid. 91 | * @type {boolean} 92 | */ 93 | get isToken() { 94 | return this.enabled && !this.template && this._validRange && !this.bonus.isExclusive; 95 | } 96 | 97 | /* -------------------------------------------------- */ 98 | 99 | /** 100 | * Whether the babonus is a template aura. This is true if the aura property is enabled, along with the 'template' aura 101 | * property, and the item on which the babonus is embedded can create a measured template. 102 | * @type {boolean} 103 | */ 104 | get isTemplate() { 105 | const item = this.bonus.parent; 106 | if (!(item instanceof Item)) return false; 107 | return this.enabled && this.template && !this.bonus.isExclusive && !!item.system.activities?.some(a => { 108 | return a.target.template?.type; 109 | }); 110 | } 111 | 112 | /* -------------------------------------------------- */ 113 | 114 | /** 115 | * Whether the babonus aura is suppressed due to its originating actor having at least one of the blocker conditions. 116 | * @type {boolean} 117 | */ 118 | get isBlocked() { 119 | const actor = this.bonus.actor; 120 | const blockers = new Set(this.blockers); 121 | const ci = actor.system.traits?.ci?.value ?? new Set(); 122 | for (const c of ci) blockers.delete(c); 123 | return blockers.intersects(actor.statuses); 124 | } 125 | 126 | /* -------------------------------------------------- */ 127 | /* Bonus collection */ 128 | /* -------------------------------------------------- */ 129 | 130 | /** 131 | * Return whether this should be filtered out of token auras due to being blocked from affecting its owner. 132 | * @type {boolean} 133 | */ 134 | get isAffectingSelf() { 135 | if (!this.isToken) return true; 136 | return !this.isBlocked && this.self; 137 | } 138 | 139 | /* -------------------------------------------------- */ 140 | 141 | /** 142 | * Is this a token aura that is not blocked? 143 | * @type {boolean} 144 | */ 145 | get isActiveTokenAura() { 146 | return this.enabled && !this.template && this._validRange && !this.isBlocked; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /scripts/applications/injections.mjs: -------------------------------------------------------------------------------- 1 | import {MODULE, SETTINGS} from "../constants.mjs"; 2 | import AppliedBonusesDialog from "./applied-bonuses-dialog.mjs"; 3 | 4 | /** 5 | * Utility class for injecting header buttons onto actor, item, and effect sheets. 6 | */ 7 | class HeaderButton { 8 | constructor(application) { 9 | this.#application = application; 10 | } 11 | 12 | /* -------------------------------------------------- */ 13 | 14 | /** 15 | * The sheet that is having a header button or tab attached. 16 | */ 17 | #application = null; 18 | 19 | /* -------------------------------------------------- */ 20 | 21 | /** 22 | * Should the button be available for this user? 23 | * @type {boolean} 24 | */ 25 | get showButton() { 26 | return game.settings.get(MODULE.ID, SETTINGS.PLAYERS) || game.user.isGM; 27 | } 28 | 29 | /* -------------------------------------------------- */ 30 | 31 | /** 32 | * Should the label be shown in a header button or just icon? 33 | * @type {boolean} 34 | */ 35 | get showLabel() { 36 | switch (this.#application.constructor.name) { 37 | case "ActorSheet5eCharacter2": 38 | case "ActorSheet5eNPC2": 39 | case "ItemSheet5e2": 40 | case "ContainerSheet2": 41 | return true; 42 | default: 43 | return game.settings.get(MODULE.ID, SETTINGS.LABEL); 44 | } 45 | } 46 | 47 | /* -------------------------------------------------- */ 48 | 49 | /** 50 | * Does this application show a tab instead of a button? 51 | * @type {boolean} 52 | */ 53 | get showTab() { 54 | switch (this.#application.constructor.name) { 55 | case "ActorSheet5eCharacter2": 56 | case "ActorSheet5eNPC2": 57 | return game.settings.get(MODULE.ID, SETTINGS.SHEET_TAB); 58 | default: 59 | return false; 60 | } 61 | } 62 | 63 | /* -------------------------------------------------- */ 64 | 65 | /** 66 | * The invalid document types that should prevent the button from being shown. 67 | * @type {Set} 68 | */ 69 | get invalidTypes() { 70 | switch (this.#application.document.documentName) { 71 | case "Actor": 72 | return new Set(["group"]); 73 | default: 74 | return new Set(); 75 | } 76 | } 77 | 78 | /* -------------------------------------------------- */ 79 | 80 | /** 81 | * The button label. 82 | * @type {string} 83 | */ 84 | get label() { 85 | return game.i18n.localize("BABONUS.ModuleTitle"); 86 | } 87 | 88 | /* -------------------------------------------------- */ 89 | 90 | /** 91 | * Inject the button in the application's header. 92 | * @param {Application} application The rendered application. 93 | * @param {object[]} array The array of buttons. 94 | */ 95 | static inject(application, array) { 96 | const instance = new this(application); 97 | 98 | // Invalid document subtype. 99 | if (instance.invalidTypes.has(application.document.type)) return; 100 | 101 | // This application shows a tab instead of a header button. 102 | if (instance.showTab) return; 103 | 104 | // Header buttons are disabled. 105 | if (!instance.showButton) return; 106 | 107 | // Insert button. 108 | array.unshift({ 109 | class: MODULE.ID, 110 | icon: MODULE.ICON, 111 | onclick: () => babonus.openBabonusWorkshop(application.document), 112 | label: instance.showLabel ? instance.label : "" 113 | }); 114 | } 115 | } 116 | 117 | /* -------------------------------------------------- */ 118 | 119 | /** 120 | * Add a header button to display the source of all applied bonuses. 121 | * TODO: pending the roll refactor, redo this for new roll config dialog. 122 | */ 123 | class HeaderButtonDialog extends HeaderButton { 124 | /** @override */ 125 | static inject(application, array) { 126 | const id = application.options[MODULE.ID]?.registry; 127 | if (!id) return; 128 | 129 | const instance = new this(application); 130 | array.unshift({ 131 | class: MODULE.ID, 132 | icon: MODULE.ICON, 133 | onclick: () => new AppliedBonusesDialog({id, dialog: application}).render(true), 134 | label: instance.showLabel ? instance.label : "" 135 | }); 136 | } 137 | } 138 | 139 | /* -------------------------------------------------- */ 140 | 141 | /** Inject form element on scene region configs. */ 142 | function injectRegionConfigElement(config, element) { 143 | if (!config.isEditable) return; 144 | const fg = element.querySelector("[name=visibility]").closest(".form-group"); 145 | const div = document.createElement("FIELDSET"); 146 | div.classList.add("babonus"); 147 | div.innerHTML = ` 148 | ${game.i18n.localize("BABONUS.ModuleTitle")} 149 | 153 |

${game.i18n.localize("BABONUS.RegionConfigHint")}

`; 154 | div.querySelector("[data-action]").addEventListener("click", (event) => babonus.openBabonusWorkshop(config.document)); 155 | fg.after(div); 156 | } 157 | 158 | /* -------------------------------------------------- */ 159 | 160 | export default { 161 | HeaderButton, 162 | HeaderButtonDialog, 163 | injectRegionConfigElement 164 | }; 165 | -------------------------------------------------------------------------------- /scripts/fields/checkbox-fields.mjs: -------------------------------------------------------------------------------- 1 | import {MODULE} from "../constants.mjs"; 2 | import FilterMixin from "./filter-mixin.mjs"; 3 | 4 | const {SetField, NumberField, StringField, SchemaField} = foundry.data.fields; 5 | 6 | class BaseField extends FilterMixin(SetField) { 7 | /** @override */ 8 | static render(bonus) { 9 | const field = bonus.schema.getField(`filters.${this.name}`); 10 | const value = bonus.filters[this.name] ?? new Set(); 11 | const template = ` 12 |
13 | 14 | {{label}} 15 | 16 | 17 | 18 | 19 |

{{hint}}

20 | {{formInput field value=value}} 21 |
`; 22 | return Handlebars.compile(template)({ 23 | field: field, value: value, hint: field.hint, label: field.label 24 | }); 25 | } 26 | 27 | /* -------------------------------------------------- */ 28 | 29 | /** @override */ 30 | _cleanType(value, source) { 31 | const choices = (this.element.choices instanceof Function) ? this.element.choices() : this.element.choices; 32 | value = super._cleanType(value, source).filter(v => v in choices); 33 | return value; 34 | } 35 | } 36 | 37 | /* -------------------------------------------------- */ 38 | 39 | class ProficiencyLevelsField extends BaseField { 40 | /** @override */ 41 | static name = "proficiencyLevels"; 42 | 43 | /* -------------------------------------------------- */ 44 | 45 | constructor() { 46 | super(new NumberField({choices: CONFIG.DND5E.proficiencyLevels})); 47 | } 48 | } 49 | 50 | /* -------------------------------------------------- */ 51 | 52 | class ItemTypesField extends BaseField { 53 | /** @override */ 54 | static name = "itemTypes"; 55 | 56 | /* -------------------------------------------------- */ 57 | 58 | constructor() { 59 | super(new StringField({ 60 | choices: Object.keys(dnd5e.dataModels.item.config).reduce((acc, type) => { 61 | if (!dnd5e.dataModels.item.config[type].schema.getField("activities")) return acc; 62 | acc[type] = game.i18n.localize(`TYPES.Item.${type}`); 63 | return acc; 64 | }, {}) 65 | })); 66 | } 67 | } 68 | 69 | /* -------------------------------------------------- */ 70 | 71 | class SpellLevelsField extends BaseField { 72 | /** @override */ 73 | static name = "spellLevels"; 74 | 75 | /* -------------------------------------------------- */ 76 | 77 | constructor() { 78 | super(new NumberField({choices: CONFIG.DND5E.spellLevels})); 79 | } 80 | } 81 | 82 | /* -------------------------------------------------- */ 83 | 84 | class SpellComponentsField extends FilterMixin(SchemaField) { 85 | /** @override */ 86 | static name = "spellComponents"; 87 | 88 | /* -------------------------------------------------- */ 89 | 90 | constructor(fields = {}, options = {}) { 91 | super({ 92 | types: new BaseField(new StringField({ 93 | choices: () => CONFIG.DND5E.validProperties.spell.reduce((acc, p) => { 94 | const prop = CONFIG.DND5E.itemProperties[p]; 95 | if (prop) acc[p] = prop; 96 | return acc; 97 | }, {}) 98 | })), 99 | match: new StringField({ 100 | required: true, 101 | initial: "ANY", 102 | choices: MODULE.SPELL_COMPONENT_CHOICES 103 | }), 104 | ...fields 105 | }, options); 106 | } 107 | 108 | /* -------------------------------------------------- */ 109 | 110 | /** @override */ 111 | static render(bonus) { 112 | const template = ` 113 |
114 | 115 | {{types.parent.label}} 116 | 117 | 118 | 119 | 120 |

{{types.parent.hint}}

121 |
122 | 123 |
124 | {{formInput types value=typesValue}} 125 |
126 |
127 |
128 | 129 |
130 | {{formInput match value=matchValue sort=true}} 131 |
132 |
133 |
`; 134 | 135 | return Handlebars.compile(template)({ 136 | types: bonus.schema.getField("filters.spellComponents.types"), 137 | match: bonus.schema.getField("filters.spellComponents.match"), 138 | typesValue: bonus.filters.spellComponents.types, 139 | matchValue: bonus.filters.spellComponents.match 140 | }); 141 | } 142 | 143 | /* -------------------------------------------------- */ 144 | 145 | /** @override */ 146 | static storage(bonus) { 147 | return !!this.value(bonus).types?.filter(u => u).size; 148 | } 149 | } 150 | 151 | /* -------------------------------------------------- */ 152 | 153 | class ActorCreatureSizesField extends BaseField { 154 | /** @override */ 155 | static name = "actorCreatureSizes"; 156 | 157 | /* -------------------------------------------------- */ 158 | 159 | constructor() { 160 | super(new StringField({choices: CONFIG.DND5E.actorSizes})); 161 | } 162 | } 163 | 164 | /* -------------------------------------------------- */ 165 | 166 | class PreparationModesField extends BaseField { 167 | /** @override */ 168 | static name = "preparationModes"; 169 | 170 | /* -------------------------------------------------- */ 171 | 172 | constructor() { 173 | super(new StringField({choices: CONFIG.DND5E.spellPreparationModes})); 174 | } 175 | } 176 | 177 | /* -------------------------------------------------- */ 178 | 179 | export default { 180 | ActorCreatureSizesField, 181 | ItemTypesField, 182 | PreparationModesField, 183 | ProficiencyLevelsField, 184 | SpellComponentsField, 185 | SpellLevelsField 186 | }; 187 | -------------------------------------------------------------------------------- /scripts/models/consumption-model.mjs: -------------------------------------------------------------------------------- 1 | import {MODULE} from "../constants.mjs"; 2 | 3 | const {BooleanField, StringField, SchemaField, NumberField} = foundry.data.fields; 4 | 5 | export default class ConsumptionModel extends foundry.abstract.DataModel { 6 | /** @override */ 7 | static defineSchema() { 8 | return { 9 | enabled: new BooleanField(), 10 | type: new StringField({ 11 | required: true, 12 | initial: "", 13 | blank: true, 14 | choices: MODULE.CONSUMPTION_TYPES 15 | }), 16 | subtype: new StringField({ 17 | required: true, 18 | blank: true, 19 | initial: "" 20 | }), 21 | scales: new BooleanField(), 22 | formula: new StringField({required: true}), 23 | value: new SchemaField({ 24 | min: new StringField({required: true}), 25 | max: new StringField({required: true}), 26 | step: new NumberField({integer: true, min: 1, step: 1}) 27 | }) 28 | }; 29 | } 30 | 31 | /* -------------------------------------------------- */ 32 | /* Data preparation */ 33 | /* -------------------------------------------------- */ 34 | 35 | /** @override */ 36 | _initialize(...args) { 37 | super._initialize(...args); 38 | this.prepareDerivedData(); 39 | } 40 | 41 | /* -------------------------------------------------- */ 42 | 43 | /** @override */ 44 | prepareDerivedData() { 45 | const rollData = this.getRollData(); 46 | this.value.min = this.value.min ? dnd5e.utils.simplifyBonus(this.value.min, rollData) : 1; 47 | this.value.max = this.value.max ? dnd5e.utils.simplifyBonus(this.value.max, rollData) : null; 48 | if ((this.value.min > this.value.max) && (this.value.max !== null)) { 49 | const m = this.value.min; 50 | this.value.min = this.value.max; 51 | this.value.max = m; 52 | } 53 | } 54 | 55 | /* -------------------------------------------------- */ 56 | /* Migrations */ 57 | /* -------------------------------------------------- */ 58 | 59 | /** @override */ 60 | static migrateData(source) { 61 | // Resource as a consumption type is deprecated fully and without replacement. 62 | if (source.type === "resource") source.type = ""; 63 | } 64 | 65 | /* -------------------------------------------------- */ 66 | /* Properties */ 67 | /* -------------------------------------------------- */ 68 | 69 | /** 70 | * The babonus this lives on. 71 | * @type {Babonus} 72 | */ 73 | get bonus() { 74 | return this.parent; 75 | } 76 | 77 | /* -------------------------------------------------- */ 78 | 79 | /** 80 | * Whether the set up in consumption can be used to create something that consumes. 81 | * This looks only at the consumption data and not at anything else about the babonus. 82 | * @type {boolean} 83 | */ 84 | get isValidConsumption() { 85 | const {type, value} = this; 86 | if (!(type in MODULE.CONSUMPTION_TYPES) || ["save", "hitdie"].includes(this.parent.type)) return false; 87 | const invalidScale = this.scales && ((this.value.max ?? Infinity) < this.value.min); 88 | 89 | switch (type) { 90 | case "uses": 91 | if (!(this.bonus.parent instanceof Item) || invalidScale) return false; 92 | return this.bonus.parent.hasLimitedUses && (value.min > 0); 93 | case "quantity": 94 | if (!(this.bonus.parent instanceof Item) || invalidScale) return false; 95 | return this.bonus.parent.system.schema.has("quantity") && (value.min > 0); 96 | case "effect": 97 | return this.bonus.parent instanceof ActiveEffect; 98 | case "health": 99 | case "slots": 100 | if (invalidScale) return false; 101 | return value.min > 0; 102 | case "currency": 103 | if (invalidScale) return false; 104 | return Object.keys(CONFIG.DND5E.currencies).includes(this.subtype) && (value.min > 0); 105 | case "inspiration": 106 | return true; 107 | case "hitdice": 108 | if (invalidScale) return false; 109 | return ["smallest", "largest"].concat(CONFIG.DND5E.hitDieTypes).includes(this.subtype) && (value.min > 0); 110 | default: 111 | return false; 112 | } 113 | } 114 | 115 | /* -------------------------------------------------- */ 116 | /* Instance methods */ 117 | /* -------------------------------------------------- */ 118 | 119 | /** 120 | * Is the actor or user able to make the change when performing the consumption? 121 | * This checks for permission issues only as well as properties being existing on the actor. 122 | * It does not check for correct setup of the consumption data on the bonus. 123 | * This can be used to determine whether a bonus should appear in the Optional Selector, but 124 | * NOT due to lack of resources. 125 | * @param {Actor5e} actor The actor performing the roll. 126 | * @returns {boolean} 127 | */ 128 | canActorConsume(actor) { 129 | if (!this.isValidConsumption) return false; 130 | 131 | switch (this.type) { 132 | case "uses": 133 | case "quantity": 134 | case "effect": 135 | return this.bonus.parent.isOwner; 136 | case "slots": 137 | return !!actor.system.spells && actor.isOwner; 138 | case "health": 139 | return !!actor.system.attributes?.hp && actor.isOwner; 140 | case "currency": 141 | return !!actor.system.currency && actor.isOwner; 142 | case "inspiration": 143 | case "hitdice": 144 | return (actor.type === "character") && actor.isOwner; 145 | default: 146 | return false; 147 | } 148 | } 149 | 150 | /* -------------------------------------------------- */ 151 | 152 | /** 153 | * Whether there are enough remaining of the target to be consumed. 154 | * @param {Actor5e|Item5e|ActiveEffect5e} document The target of consumption. 155 | * @param {number} [min] A different minimum value to test against. 156 | * @returns {boolean} 157 | */ 158 | canBeConsumed(document, min) { 159 | if (!this.isValidConsumption) return false; 160 | 161 | min ??= this.value.min; 162 | const {hd, hp} = document.system?.attributes ?? {}; 163 | 164 | switch (this.type) { 165 | case "uses": 166 | return document.system.uses.value >= min; 167 | case "quantity": 168 | return document.system.quantity >= min; 169 | case "effect": 170 | return document.parent.effects.has(document.id); 171 | case "slots": 172 | return Object.values(document.system.spells).some(s => s.value && s.max && s.level && (s.level >= min)); 173 | case "health": 174 | return (hp.value + hp.temp) >= min; 175 | case "currency": 176 | return document.system.currency[this.subtype] >= min; 177 | case "inspiration": 178 | return document.system.attributes.inspiration; 179 | case "hitdice": 180 | return (["smallest", "largest"].includes(this.subtype) ? hd.value : hd.bySize[this.subtype] ?? 0) >= min; 181 | default: 182 | return false; 183 | } 184 | } 185 | 186 | /* -------------------------------------------------- */ 187 | 188 | /** 189 | * Get applicable roll data from the origin. 190 | * @returns {object} The roll data. 191 | */ 192 | getRollData() { 193 | return this.bonus.getRollData({deterministic: true}); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /scripts/hooks.mjs: -------------------------------------------------------------------------------- 1 | import {MODULE, SETTINGS} from "./constants.mjs"; 2 | import * as filterings from "./applications/filterings.mjs"; 3 | import api from "./api.mjs"; 4 | import applications from "./applications/_module.mjs"; 5 | import characterSheetTabSetup from "./applications/character-sheet-tab.mjs"; 6 | import enricherSetup from "./applications/enrichers.mjs"; 7 | import fields from "./fields/_module.mjs"; 8 | import injections from "./applications/injections.mjs"; 9 | import models from "./models/_module.mjs"; 10 | import mutators from "./mutators.mjs"; 11 | import OptionalSelector from "./applications/optional-selector.mjs"; 12 | import registry from "./registry.mjs"; 13 | 14 | // Setup API object. 15 | globalThis.babonus = { 16 | ...api, 17 | abstract: { 18 | DataModels: models.Babonus, 19 | DataFields: { 20 | fields: fields, 21 | models: models 22 | }, 23 | TYPES: Object.keys(models.Babonus), 24 | applications: applications 25 | }, 26 | filters: {...filterings.filters} 27 | }; 28 | 29 | /* -------------------------------------------------- */ 30 | 31 | /** 32 | * Render the optional bonus selector on a roll dialog. 33 | * @param {Dialog} dialog The dialog being rendered. 34 | */ 35 | async function _renderDialog(dialog) { 36 | const m = dialog.options.babonus; 37 | if (!m) return; 38 | const r = registry.get(m.registry); 39 | if (!r) return; 40 | r.dialog = dialog; 41 | new OptionalSelector(m.registry).render(); 42 | } 43 | 44 | /* -------------------------------------------------- */ 45 | 46 | /* Settings. */ 47 | function _createSettings() { 48 | game.settings.register(MODULE.ID, SETTINGS.PLAYERS, { 49 | name: "BABONUS.SettingsShowBuilderForPlayersName", 50 | hint: "BABONUS.SettingsShowBuilderForPlayersHint", 51 | scope: "world", 52 | config: true, 53 | type: Boolean, 54 | default: true 55 | }); 56 | 57 | game.settings.register(MODULE.ID, SETTINGS.LABEL, { 58 | name: "BABONUS.SettingsDisplayLabelName", 59 | hint: "BABONUS.SettingsDisplayLabelHint", 60 | scope: "world", 61 | config: true, 62 | type: Boolean, 63 | default: false 64 | }); 65 | 66 | game.settings.register(MODULE.ID, SETTINGS.SCRIPT, { 67 | name: "BABONUS.SettingsDisableCustomScriptFilterName", 68 | hint: "BABONUS.SettingsDisableCustomScriptFilterHint", 69 | scope: "world", 70 | config: true, 71 | type: Boolean, 72 | default: false, 73 | requiresReload: true 74 | }); 75 | 76 | game.settings.register(MODULE.ID, SETTINGS.AURA, { 77 | name: "BABONUS.SettingsShowAuraRangesName", 78 | hint: "BABONUS.SettingsShowAuraRangesHint", 79 | scope: "world", 80 | config: true, 81 | type: Boolean, 82 | default: false, 83 | requiresReload: false 84 | }); 85 | 86 | game.settings.register(MODULE.ID, SETTINGS.RADIUS, { 87 | name: "BABONUS.SettingsPadAuraRadius", 88 | hint: "BABONUS.SettingsPadAuraRadiusHint", 89 | scope: "world", 90 | config: true, 91 | type: Boolean, 92 | default: true, 93 | requiresReload: false 94 | }); 95 | 96 | // Allow for modifiers to the fumble range to go below 1? 97 | game.settings.register(MODULE.ID, SETTINGS.FUMBLE, { 98 | name: "BABONUS.SettingsAllowFumbleNegationName", 99 | hint: "BABONUS.SettingsAllowFumbleNegationHint", 100 | scope: "world", 101 | config: true, 102 | type: Boolean, 103 | default: false, 104 | requiresReload: false 105 | }); 106 | 107 | game.settings.register(MODULE.ID, SETTINGS.SHEET_TAB, { 108 | name: "BABONUS.SettingsShowSheetTab", 109 | hint: "BABONUS.SettingsShowSheetTabHint", 110 | scope: "world", 111 | config: true, 112 | type: Boolean, 113 | default: false, 114 | requiresReload: true 115 | }); 116 | } 117 | 118 | /* -------------------------------------------------- */ 119 | 120 | /** 121 | * On-drop handler for the hotbar. 122 | * @param {Hotbar} bar The hotbar application. 123 | * @param {object} dropData The drop data. 124 | * @param {string} dropData.type The type of the dropped document. 125 | * @param {string} dropData.uuid The uuid of the dropped document. 126 | * @param {number} slot The slot on the hotbar where it was dropped. 127 | */ 128 | async function _onHotbarDrop(bar, {type, uuid}, slot) { 129 | if (type !== "Babonus") return; 130 | const bonus = await babonus.fromUuid(uuid); 131 | const data = { 132 | img: bonus.img, 133 | command: `babonus.hotbarToggle("${uuid}");`, 134 | name: `${game.i18n.localize("BABONUS.ToggleBonus")}: ${bonus.name}`, 135 | type: CONST.MACRO_TYPES.SCRIPT 136 | }; 137 | const macro = game.macros.find(m => { 138 | return Object.entries(data).every(([k, v]) => m[k] === v) && m.isAuthor; 139 | }) ?? await Macro.implementation.create(data); 140 | return game.user.assignHotbarMacro(macro, slot); 141 | } 142 | 143 | /* -------------------------------------------------- */ 144 | 145 | /** Setup the global 'trees' for proficiency searching. */ 146 | async function setupTree() { 147 | const trees = {}; 148 | for (const k of ["languages", "weapon", "armor", "tool", "skills"]) { 149 | trees[k] = await dnd5e.documents.Trait.choices(k); 150 | } 151 | babonus.trees = trees; 152 | } 153 | 154 | /* -------------------------------------------------- */ 155 | 156 | // General setup. 157 | Hooks.once("init", _createSettings); 158 | Hooks.once("init", enricherSetup); 159 | Hooks.once("init", () => game.modules.get(MODULE.ID).api = globalThis.babonus); 160 | Hooks.on("hotbarDrop", _onHotbarDrop); 161 | Hooks.once("setup", () => characterSheetTabSetup()); 162 | 163 | // Any application injections. 164 | Hooks.on("getActiveEffectConfigHeaderButtons", (...T) => injections.HeaderButton.inject(...T)); 165 | Hooks.on("getActorSheetHeaderButtons", (...T) => injections.HeaderButton.inject(...T)); 166 | Hooks.on("getDialogHeaderButtons", (...T) => injections.HeaderButtonDialog.inject(...T)); 167 | Hooks.on("getItemSheetHeaderButtons", (...T) => injections.HeaderButton.inject(...T)); 168 | Hooks.on("renderDialog", _renderDialog); 169 | Hooks.on("renderRollConfigurationDialog", _renderDialog); 170 | Hooks.on("renderRegionConfig", injections.injectRegionConfigElement); 171 | 172 | // Roll hooks. Delay these to let other modules modify behaviour first. 173 | Hooks.once("ready", function() { 174 | Hooks.callAll("babonus.preInitializeRollHooks"); 175 | 176 | Hooks.on("dnd5e.postActivityConsumption", mutators.postActivityConsumption); 177 | Hooks.on("dnd5e.preRollAbilitySave", mutators.preRollAbilitySave); 178 | Hooks.on("dnd5e.preRollAbilityTest", mutators.preRollAbilityTest); 179 | Hooks.on("dnd5e.preRollAttackV2", mutators.preRollAttack); 180 | Hooks.on("dnd5e.preRollDamageV2", mutators.preRollDamage); 181 | Hooks.on("dnd5e.preRollDeathSave", mutators.preRollDeathSave); 182 | Hooks.on("dnd5e.preRollHitDieV2", mutators.preRollHitDie); 183 | Hooks.on("dnd5e.preRollSkill", mutators.preRollSkill); 184 | Hooks.on("dnd5e.preRollToolCheck", mutators.preRollToolCheck); 185 | Hooks.on("dnd5e.preCreateActivityTemplate", mutators.preCreateActivityTemplate); 186 | 187 | Hooks.callAll("babonus.initializeRollHooks"); 188 | }); 189 | 190 | Hooks.once("init", function() { 191 | const hook = game.modules.get("babele")?.active && (game.babele?.initialized === false) ? "babele.ready" : "ready"; 192 | Hooks.once(hook, () => setupTree()); 193 | }); 194 | 195 | Hooks.once("i18nInit", function() { 196 | for (const model of Object.values(babonus.abstract.DataFields.models.Babonus)) { 197 | Localization.localizeDataModel(model); 198 | } 199 | 200 | const localizeObject = object => { 201 | for (const [k, v] of Object.entries(object)) { 202 | object[k] = game.i18n.localize(v); 203 | } 204 | }; 205 | 206 | localizeObject(MODULE.ATTACK_MODES_CHOICES); 207 | localizeObject(MODULE.CONSUMPTION_TYPES); 208 | localizeObject(MODULE.DISPOSITION_TYPES); 209 | localizeObject(MODULE.HEALTH_PERCENTAGES_CHOICES); 210 | localizeObject(MODULE.MODIFIER_MODES); 211 | localizeObject(MODULE.SPELL_COMPONENT_CHOICES); 212 | localizeObject(MODULE.TOKEN_SIZES_CHOICES); 213 | }); 214 | -------------------------------------------------------------------------------- /scripts/applications/character-sheet-tab.mjs: -------------------------------------------------------------------------------- 1 | import {MODULE, SETTINGS} from "../constants.mjs"; 2 | 3 | const SHEET_MAPPINGS = new Map(); 4 | 5 | /** 6 | * Handle rendering a new tab on the v2 character sheet. 7 | * @param {ActorSheet} sheet The rendered sheet. 8 | * @param {HTMLElement} html The element of the sheet. 9 | */ 10 | async function _onRenderCharacterSheet2(sheet, [html]) { 11 | const template = "modules/babonus/templates/subapplications/character-sheet-tab.hbs"; 12 | 13 | const bonuses = {}; 14 | const uuids = new Set(); 15 | SHEET_MAPPINGS.set(sheet.document.uuid, new Map()); 16 | 17 | async function _prepareBonus(bonus, rollData) { 18 | SHEET_MAPPINGS.get(sheet.document.uuid).set(bonus.uuid, bonus); 19 | uuids.add(bonus.uuid); 20 | const section = bonuses[bonus.type] ??= {}; 21 | section.label ??= `BABONUS.${bonus.type.toUpperCase()}.Label`; 22 | section.key ??= bonus.type; 23 | section.bonuses ??= []; 24 | section.bonuses.push({ 25 | bonus: bonus, 26 | labels: bonus.sheet._prepareLabels().slice(1).filterJoin(" • "), 27 | tooltip: await TextEditor.enrichHTML(bonus.description, { 28 | rollData: rollData, relativeTo: bonus.origin 29 | }), 30 | isEmbedded: bonus.parent.isEmbedded, 31 | parentName: bonus.parent.name 32 | }); 33 | } 34 | 35 | const data = sheet.actor.getRollData(); 36 | for (const bonus of babonus.getCollection(sheet.actor)) await _prepareBonus(bonus, data); 37 | for (const item of sheet.actor.items) { 38 | const data = item.getRollData(); 39 | for (const bonus of babonus.getCollection(item)) await _prepareBonus(bonus, data); 40 | for (const effect of item.effects) for (const bonus of babonus.getCollection(effect)) { 41 | await _prepareBonus(bonus, data); 42 | } 43 | } 44 | for (const effect of sheet.actor.effects) { 45 | for (const bonus of babonus.getCollection(effect)) await _prepareBonus(bonus, data); 46 | } 47 | 48 | bonuses.all = {label: "BABONUS.Bonuses", key: "all", bonuses: []}; 49 | 50 | const div = document.createElement("DIV"); 51 | const isActive = (sheet._tabs[0].active === MODULE.ID) ? "active" : ""; 52 | const isEdit = sheet.constructor.MODES.EDIT === sheet._mode; 53 | 54 | sheet._filters[MODULE.ID] ??= {name: "", properties: new Set()}; 55 | div.innerHTML = await renderTemplate(template, { 56 | ICON: MODULE.ICON, 57 | parentName: sheet.document.name, 58 | isActive: isActive, 59 | isEdit: isEdit, 60 | sections: Object.values(bonuses).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang)) 61 | }); 62 | 63 | div.querySelectorAll("[data-action]").forEach(n => { 64 | n.addEventListener("click", async (event) => { 65 | const {clientX, clientY} = event; 66 | const target = event.currentTarget; 67 | const action = target.dataset.action; 68 | const uuid = target.closest("[data-item-uuid]")?.dataset.itemUuid; 69 | if (!uuid) return; 70 | switch (action) { 71 | case "toggle": 72 | return SHEET_MAPPINGS.get(sheet.document.uuid).get(uuid).toggle(); 73 | case "edit": 74 | return SHEET_MAPPINGS.get(sheet.document.uuid).get(uuid).sheet.render({force: true}); 75 | case "delete": 76 | return SHEET_MAPPINGS.get(sheet.document.uuid).get(uuid).deleteDialog(); 77 | case "contextMenu": 78 | event.preventDefault(); 79 | event.stopPropagation(); 80 | return target.dispatchEvent(new PointerEvent("contextmenu", { 81 | view: window, bubbles: true, cancelable: true, clientX, clientY 82 | })); 83 | default: 84 | return; 85 | } 86 | }); 87 | }); 88 | 89 | div.firstElementChild.addEventListener("drop", async (event) => { 90 | const data = TextEditor.getDragEventData(event); 91 | if (!sheet.isEditable) return; 92 | const bonus = await babonus.fromUuid(data.uuid); 93 | if (!bonus || uuids.has(bonus.uuid)) return; 94 | babonus.embedBabonus(sheet.document, bonus); 95 | }); 96 | 97 | div.querySelectorAll("[data-item-uuid][draggable]").forEach(n => { 98 | n.addEventListener("dragstart", async (event) => { 99 | const uuid = event.currentTarget.dataset.itemUuid; 100 | const bab = SHEET_MAPPINGS.get(sheet.document.uuid).get(uuid); 101 | const dragData = bab.toDragData(); 102 | if (!dragData) return; 103 | event.dataTransfer.setData("text/plain", JSON.stringify(dragData)); 104 | }); 105 | }); 106 | 107 | div.querySelector("[data-action='otter-dance']").addEventListener("click", (event) => { 108 | const spin = [{transform: "rotate(0)"}, {transform: "rotate(360deg)"}]; 109 | const time = {duration: 1000, iterations: 1}; 110 | if (!event.currentTarget.getAnimations().length) event.currentTarget.animate(spin, time); 111 | }); 112 | 113 | div.querySelectorAll("[data-action='bonus-source']").forEach(n => { 114 | n.addEventListener("click", async (event) => { 115 | const uuid = event.currentTarget.dataset.uuid; 116 | const item = await fromUuid(uuid); 117 | return item.sheet.render(true); 118 | }); 119 | }); 120 | 121 | const body = html.querySelector(".tab-body"); 122 | if (!body || body.querySelector(":scope > .tab.babonus")) return; 123 | 124 | body.appendChild(div.firstElementChild); 125 | html.querySelectorAll("button.create-child").forEach(button => { 126 | // Assigning listener to all buttons due to weirdness on npc sheet. 127 | button.addEventListener("click", _createChildBonus.bind(sheet)); 128 | }); 129 | 130 | new dnd5e.applications.ContextMenu5e(html, ".item[data-item-uuid]", [], { 131 | onOpen: _onOpenContextMenu.bind(sheet) 132 | }); 133 | } 134 | 135 | /* -------------------------------------------------- */ 136 | 137 | /** 138 | * Populate the context menu options. 139 | * @this {ActorSheet} 140 | * @param {HTMLElement} element The targeted element. 141 | */ 142 | function _onOpenContextMenu(element) { 143 | const bonus = SHEET_MAPPINGS.get(this.document.uuid).get(element.dataset.itemUuid); 144 | ui.context.menuItems = [{ 145 | name: "BABONUS.ContextMenu.Edit", 146 | icon: "", 147 | callback: () => bonus.sheet.render({force: true}) 148 | }, { 149 | name: "BABONUS.ContextMenu.Duplicate", 150 | icon: "", 151 | callback: () => babonus.duplicateBonus(bonus) 152 | }, { 153 | name: "BABONUS.ContextMenu.Delete", 154 | icon: "", 155 | callback: () => bonus.deleteDialog() 156 | }, { 157 | name: "BABONUS.ContextMenu.Enable", 158 | icon: "", 159 | condition: () => !bonus.enabled, 160 | callback: () => bonus.toggle(), 161 | group: "instance" 162 | }, { 163 | name: "BABONUS.ContextMenu.Disable", 164 | icon: "", 165 | condition: () => bonus.enabled, 166 | callback: () => bonus.toggle(), 167 | group: "instance" 168 | }]; 169 | } 170 | 171 | /* -------------------------------------------------- */ 172 | 173 | /** 174 | * Utility method that creates a popup dialog for a new bonus. 175 | * @this {ActorSheet} 176 | * @returns {Promise} 177 | */ 178 | async function _createChildBonus() { 179 | if (!this.isEditable || (this._tabs[0]?.active !== MODULE.ID)) return; 180 | const template = "systems/dnd5e/templates/apps/document-create.hbs"; 181 | const data = { 182 | folders: [], 183 | folder: null, 184 | hasFolders: false, 185 | type: babonus.abstract.TYPES[0], 186 | types: babonus.abstract.TYPES.reduce((acc, type) => { 187 | const label = game.i18n.localize(`BABONUS.${type.toUpperCase()}.Label`); 188 | acc.push({ 189 | type: type, 190 | label: label, 191 | icon: babonus.abstract.DataModels[type].metadata.defaultImg 192 | }); 193 | return acc; 194 | }, []).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang)) 195 | }; 196 | const title = game.i18n.localize("BABONUS.Create"); 197 | return Dialog.prompt({ 198 | content: await renderTemplate(template, data), 199 | label: title, 200 | title: title, 201 | render: (html) => { 202 | const app = html.closest(".app"); 203 | app.querySelectorAll(".window-header .header-button").forEach(btn => { 204 | const label = btn.innerText; 205 | const icon = btn.querySelector("i"); 206 | btn.innerHTML = icon.outerHTML; 207 | btn.dataset.tooltip = label; 208 | btn.setAttribute("aria-label", label); 209 | }); 210 | app.querySelector(".document-name").select(); 211 | }, 212 | callback: async (html) => { 213 | const data = new FormDataExtended(html.querySelector("FORM")).object; 214 | if (!data.name?.trim()) delete data.name; 215 | const bonus = babonus.createBabonus(data, this.document); 216 | return babonus.embedBabonus(this.document, bonus); 217 | }, 218 | rejectClose: false, 219 | options: {jQuery: false, width: 350, classes: ["dnd5e2", "create-document", "dialog", "babonus"]} 220 | }); 221 | } 222 | 223 | /* -------------------------------------------------- */ 224 | 225 | /** 226 | * Add a new tab to the v2 character sheet. 227 | */ 228 | function _addCharacterTab() { 229 | const classes = [ 230 | dnd5e.applications.actor.ActorSheet5eCharacter2, 231 | dnd5e.applications.actor.ActorSheet5eNPC2 232 | ]; 233 | for (const cls of classes) { 234 | cls.TABS.push({ 235 | tab: MODULE.ID, label: MODULE.NAME, icon: MODULE.ICON 236 | }); 237 | /*cls.FILTER_COLLECTIONS.babonus = function(c, f) { 238 | console.warn({c,f}) 239 | return Array.from(babonus.getCollection(this.document)); 240 | }; 241 | return;*/ 242 | const fn = cls.prototype._filterChildren; 243 | class sheet extends cls { 244 | /** @override */ 245 | _filterChildren(collection, filters) { 246 | if (collection !== "babonus") return fn.call(this, collection, filters); 247 | 248 | const embedded = babonus.findEmbeddedDocumentsWithBonuses(this.document); 249 | 250 | const actor = babonus.getCollection(this.document).contents; 251 | const items = embedded.items?.flatMap(item => babonus.getCollection(item).contents) ?? []; 252 | const effects = embedded.effects?.flatMap(effect => babonus.getCollection(effect).contents) ?? []; 253 | return actor.concat(items).concat(effects); 254 | } 255 | } 256 | cls.prototype._filterChildren = sheet.prototype._filterChildren; 257 | } 258 | } 259 | 260 | /* -------------------------------------------------- */ 261 | 262 | /** Initialize this part of the module. */ 263 | export default function characterSheetTabSetup() { 264 | if (!game.settings.get(MODULE.ID, SETTINGS.SHEET_TAB)) return; 265 | if (!game.user.isGM && !game.settings.get(MODULE.ID, SETTINGS.PLAYERS)) return; 266 | _addCharacterTab(); 267 | Hooks.on("renderActorSheet5eCharacter2", _onRenderCharacterSheet2); 268 | Hooks.on("renderActorSheet5eNPC2", _onRenderCharacterSheet2); 269 | } 270 | -------------------------------------------------------------------------------- /scripts/fields/semicolon-fields.mjs: -------------------------------------------------------------------------------- 1 | import FilterMixin from "./filter-mixin.mjs"; 2 | 3 | const {SetField, StringField} = foundry.data.fields; 4 | 5 | class BaseField extends FilterMixin(SetField) { 6 | /** @override */ 7 | static canExclude = true; 8 | 9 | /* -------------------------------------------------- */ 10 | 11 | /** @override */ 12 | static trash = false; 13 | 14 | /* -------------------------------------------------- */ 15 | 16 | /** 17 | * Encapsulate this in a fieldset when using the formGroup hbs helper? 18 | * @type {boolean} 19 | */ 20 | static fieldset = true; 21 | 22 | /* -------------------------------------------------- */ 23 | 24 | constructor(options = {}) { 25 | super(new StringField(), options); 26 | } 27 | 28 | /* -------------------------------------------------- */ 29 | 30 | /** @override */ 31 | _cast(value) { 32 | // If the given value is a string, split it at each ';' and trim the results to get an array. 33 | if (typeof value === "string") value = value.split(";").map(v => v.trim()); 34 | return super._cast(value); 35 | } 36 | 37 | /* -------------------------------------------------- */ 38 | 39 | /** @override */ 40 | _cleanType(value, source) { 41 | value = super._cleanType(value, source).reduce((acc, v) => { 42 | if (v) acc.add(v); 43 | return acc; 44 | }, new Set()); 45 | return Array.from(value); 46 | } 47 | 48 | /* -------------------------------------------------- */ 49 | 50 | /** @override */ 51 | _toInput(config) { 52 | if ((config.value instanceof Set) || Array.isArray(config.value)) { 53 | config.value = Array.from(config.value).join(";"); 54 | } 55 | return foundry.data.fields.StringField.prototype._toInput.call(this, config); 56 | } 57 | 58 | /* -------------------------------------------------- */ 59 | 60 | /** @override */ 61 | toFormGroup(formConfig, inputConfig) { 62 | const element = super.toFormGroup(formConfig, inputConfig); 63 | 64 | const input = element.querySelector("input"); 65 | const button = document.createElement("BUTTON"); 66 | button.dataset.action = "keysDialog"; 67 | button.dataset.property = input.name; 68 | button.dataset.id = this.constructor.name; 69 | button.type = "button"; 70 | button.innerHTML = ` ${game.i18n.localize("BABONUS.Keys")}`; 71 | input.after(button); 72 | 73 | if (this.constructor.fieldset) { 74 | const set = document.createElement("FIELDSET"); 75 | const label = element.querySelector("LABEL"); 76 | set.innerHTML = ` 77 | 78 | ${label.textContent} 79 | 80 | 81 | 82 | `; 83 | label.remove(); 84 | 85 | const hint = element.querySelector(".hint"); 86 | hint.remove(); 87 | set.appendChild(hint); 88 | 89 | set.appendChild(element); 90 | return set; 91 | } 92 | 93 | return element; 94 | } 95 | 96 | /* -------------------------------------------------- */ 97 | 98 | /** @override */ 99 | static render(bonus) { 100 | const template = "{{formGroup field value=value}}"; 101 | const data = { 102 | field: bonus.schema.getField(`filters.${this.name}`), 103 | value: bonus.filters[this.name] 104 | }; 105 | 106 | return Handlebars.compile(template)(data); 107 | } 108 | 109 | /* -------------------------------------------------- */ 110 | 111 | /** 112 | * Retrieve the choices for a Keys dialog when configuring this field. 113 | * @returns {{value: string, label: string}[]} 114 | */ 115 | static choices() { 116 | throw new Error("This must be subclassed!"); 117 | } 118 | } 119 | 120 | /* -------------------------------------------------- */ 121 | 122 | class AbilitiesField extends BaseField { 123 | /** @override */ 124 | static name = "abilities"; 125 | 126 | /* -------------------------------------------------- */ 127 | 128 | /** @override */ 129 | static canExclude = true; 130 | 131 | /* -------------------------------------------------- */ 132 | 133 | /** @override */ 134 | static choices() { 135 | const abilities = Object.entries(CONFIG.DND5E.abilities); 136 | return abilities.map(([value, {label}]) => ({value, label})); 137 | } 138 | } 139 | 140 | /* -------------------------------------------------- */ 141 | 142 | class SaveAbilitiesField extends AbilitiesField { 143 | /** @override */ 144 | static name = "saveAbilities"; 145 | } 146 | 147 | /* -------------------------------------------------- */ 148 | 149 | class ThrowTypesField extends AbilitiesField { 150 | /** @override */ 151 | static name = "throwTypes"; 152 | 153 | /* -------------------------------------------------- */ 154 | 155 | /** @override */ 156 | static canExclude = false; 157 | 158 | /* -------------------------------------------------- */ 159 | 160 | /** @override */ 161 | static choices() { 162 | const choices = super.choices(); 163 | 164 | choices.push({ 165 | value: "death", 166 | label: game.i18n.localize("DND5E.DeathSave") 167 | }, { 168 | value: "concentration", 169 | label: game.i18n.localize("DND5E.Concentration") 170 | }); 171 | 172 | return choices; 173 | } 174 | } 175 | 176 | /* -------------------------------------------------- */ 177 | 178 | class StatusEffectsField extends BaseField { 179 | /** @override */ 180 | static name = "statusEffects"; 181 | 182 | /* -------------------------------------------------- */ 183 | 184 | /** @override */ 185 | static choices() { 186 | return CONFIG.statusEffects.reduce((acc, {id, img, name}) => { 187 | if (id && img && name) acc.push({value: id, label: name, icon: img}); 188 | return acc; 189 | }, []); 190 | } 191 | } 192 | 193 | /* -------------------------------------------------- */ 194 | 195 | class TargetEffectsField extends StatusEffectsField { 196 | /** @override */ 197 | static name = "targetEffects"; 198 | } 199 | 200 | /* -------------------------------------------------- */ 201 | 202 | class AuraBlockersField extends StatusEffectsField { 203 | /** @override */ 204 | static name = "auraBlockers"; 205 | 206 | /* -------------------------------------------------- */ 207 | 208 | /** @override */ 209 | static canExclude = false; 210 | 211 | /* -------------------------------------------------- */ 212 | 213 | /** @override */ 214 | static trash = false; 215 | 216 | /* -------------------------------------------------- */ 217 | 218 | /** @override */ 219 | static fieldset = false; 220 | } 221 | 222 | /* -------------------------------------------------- */ 223 | 224 | class CreatureTypesField extends BaseField { 225 | /** @override */ 226 | static name = "creatureTypes"; 227 | 228 | /* -------------------------------------------------- */ 229 | 230 | /** @override */ 231 | static choices() { 232 | const types = Object.entries(CONFIG.DND5E.creatureTypes); 233 | return types.map(([k, v]) => { 234 | return {value: k, label: v.label}; 235 | }).sort((a, b) => a.label.localeCompare(b.label)); 236 | } 237 | } 238 | 239 | /* -------------------------------------------------- */ 240 | 241 | class ActorCreatureTypesField extends CreatureTypesField { 242 | /** @override */ 243 | static name = "actorCreatureTypes"; 244 | } 245 | 246 | /* -------------------------------------------------- */ 247 | 248 | class BaseArmorsField extends BaseField { 249 | /** @override */ 250 | static name = "baseArmors"; 251 | 252 | /* -------------------------------------------------- */ 253 | 254 | /** @override */ 255 | static choices() { 256 | return Array.from(babonus.trees.armor.asSet()).map(k => { 257 | return { 258 | value: k, 259 | label: dnd5e.documents.Trait.keyLabel(`armor:${k}`) 260 | }; 261 | }); 262 | } 263 | } 264 | 265 | /* -------------------------------------------------- */ 266 | 267 | class TargetArmorsField extends BaseArmorsField { 268 | /** @override */ 269 | static name = "targetArmors"; 270 | } 271 | 272 | /* -------------------------------------------------- */ 273 | 274 | class BaseToolsField extends BaseField { 275 | /** @override */ 276 | static name = "baseTools"; 277 | 278 | /* -------------------------------------------------- */ 279 | 280 | /** @override */ 281 | static choices() { 282 | return Array.from(babonus.trees.tool.asSet()).map(k => { 283 | return { 284 | value: k, 285 | label: dnd5e.documents.Trait.keyLabel(`tool:${k}`) 286 | }; 287 | }); 288 | } 289 | } 290 | 291 | /* -------------------------------------------------- */ 292 | 293 | class BaseWeaponsField extends BaseField { 294 | /** @override */ 295 | static name = "baseWeapons"; 296 | 297 | /* -------------------------------------------------- */ 298 | 299 | /** @override */ 300 | static choices() { 301 | return Array.from(babonus.trees.weapon.asSet()).map(k => { 302 | return { 303 | value: k, 304 | label: dnd5e.documents.Trait.keyLabel(`weapon:${k}`) 305 | }; 306 | }); 307 | } 308 | } 309 | 310 | /* -------------------------------------------------- */ 311 | 312 | class DamageTypesField extends BaseField { 313 | /** @override */ 314 | static name = "damageTypes"; 315 | 316 | /* -------------------------------------------------- */ 317 | 318 | /** @override */ 319 | static choices() { 320 | const damages = Object.entries(CONFIG.DND5E.damageTypes); 321 | const heals = Object.entries(CONFIG.DND5E.healingTypes); 322 | return [...damages, ...heals].map(([k, v]) => ({value: k, label: v.label})); 323 | } 324 | } 325 | 326 | /* -------------------------------------------------- */ 327 | 328 | class SkillIdsField extends BaseField { 329 | /** @override */ 330 | static name = "skillIds"; 331 | 332 | /* -------------------------------------------------- */ 333 | 334 | /** @override */ 335 | static choices() { 336 | return Array.from(babonus.trees.skills.asSet()).map(k => { 337 | return { 338 | value: k, 339 | label: dnd5e.documents.Trait.keyLabel(`skills:${k}`) 340 | }; 341 | }); 342 | } 343 | } 344 | 345 | /* -------------------------------------------------- */ 346 | 347 | class SpellSchoolsField extends BaseField { 348 | /** @override */ 349 | static name = "spellSchools"; 350 | 351 | /* -------------------------------------------------- */ 352 | 353 | /** @override */ 354 | static choices() { 355 | const schools = Object.entries(CONFIG.DND5E.spellSchools); 356 | return schools.map(([k, v]) => ({value: k, label: v.label})); 357 | } 358 | } 359 | 360 | /* -------------------------------------------------- */ 361 | 362 | class WeaponPropertiesField extends BaseField { 363 | /** @override */ 364 | static name = "weaponProperties"; 365 | 366 | /* -------------------------------------------------- */ 367 | 368 | /** @override */ 369 | static choices() { 370 | const keys = CONFIG.DND5E.validProperties.weapon; 371 | const labels = CONFIG.DND5E.itemProperties; 372 | return keys.reduce((acc, k) => { 373 | const label = labels[k]?.label; 374 | if (label) acc.push({value: k, label: label}); 375 | return acc; 376 | }, []); 377 | } 378 | } 379 | 380 | /* -------------------------------------------------- */ 381 | 382 | class ActorLanguagesField extends BaseField { 383 | /** @override */ 384 | static name = "actorLanguages"; 385 | 386 | /* -------------------------------------------------- */ 387 | 388 | /** @override */ 389 | static choices() { 390 | const trait = dnd5e.documents.Trait; 391 | const choices = babonus.trees.languages; 392 | 393 | const langs = new Set(); 394 | const cats = new Set(); 395 | 396 | const construct = (c) => { 397 | for (const [key, choice] of Object.entries(c)) { 398 | if (choice.children) { 399 | cats.add(key); 400 | construct(choice.children); 401 | } else langs.add(key); 402 | } 403 | }; 404 | 405 | construct(choices); 406 | 407 | const toLabel = (k, isCat = true) => ({value: k, label: trait.keyLabel(`languages:${k}`), isCategory: isCat}); 408 | 409 | return Array.from(cats.map(k => toLabel(k, true))).concat(Array.from(langs.map(k => toLabel(k, false)))); 410 | } 411 | } 412 | 413 | /* -------------------------------------------------- */ 414 | 415 | export default { 416 | AbilitiesField, 417 | ActorCreatureTypesField, 418 | ActorLanguagesField, 419 | AuraBlockersField, 420 | BaseArmorsField, 421 | BaseToolsField, 422 | BaseWeaponsField, 423 | CreatureTypesField, 424 | DamageTypesField, 425 | SaveAbilitiesField, 426 | SkillIdsField, 427 | SpellSchoolsField, 428 | StatusEffectsField, 429 | TargetArmorsField, 430 | TargetEffectsField, 431 | ThrowTypesField, 432 | WeaponPropertiesField 433 | }; 434 | -------------------------------------------------------------------------------- /scripts/applications/babonus-workshop.mjs: -------------------------------------------------------------------------------- 1 | import {MODULE} from "../constants.mjs"; 2 | 3 | export default class BabonusWorkshop extends dnd5e.applications.DialogMixin(Application) { 4 | constructor(object, options) { 5 | super(object, options); 6 | this.object = object; 7 | this.isItem = object.documentName === "Item"; 8 | this.isEffect = object.documentName === "ActiveEffect"; 9 | this.isActor = object.documentName === "Actor"; 10 | this.appId = `${this.document.uuid.replaceAll(".", "-")}-babonus-workshop`; 11 | } 12 | 13 | /* -------------------------------------------------- */ 14 | 15 | /** 16 | * The right-hand side bonuses that have a collapsed description. 17 | * @type {Set} 18 | */ 19 | #collapsedBonuses = new Set(); 20 | 21 | /* -------------------------------------------------- */ 22 | 23 | /** 24 | * The color of the left-side otter. 25 | * @type {string} 26 | */ 27 | #otterColor = "black"; 28 | 29 | /* -------------------------------------------------- */ 30 | 31 | /** 32 | * Number of times the left-side otter has been clicked. 33 | * @type {number} 34 | */ 35 | #otterVomits = 0; 36 | 37 | /* -------------------------------------------------- */ 38 | 39 | /** 40 | * A reference to the owner of the bonuses. 41 | * @type {Actor5e|Item5e|ActiveEffect5e|RegionDocument} 42 | */ 43 | get document() { 44 | return this.object; 45 | } 46 | 47 | /* -------------------------------------------------- */ 48 | 49 | /** @override */ 50 | get id() { 51 | return `${MODULE.ID}-${this.document.uuid.replaceAll(".", "-")}`; 52 | } 53 | 54 | /* -------------------------------------------------- */ 55 | 56 | /** @override */ 57 | get isEditable() { 58 | return this.document.sheet.isEditable; 59 | } 60 | 61 | /* -------------------------------------------------- */ 62 | 63 | /** @override */ 64 | get title() { 65 | return `${MODULE.NAME}: ${this.document.name}`; 66 | } 67 | 68 | /* -------------------------------------------------- */ 69 | 70 | /** 71 | * A reference to the collection of bonuses on this document. 72 | * @type {Collection} 73 | */ 74 | get collection() { 75 | return babonus.getCollection(this.document); 76 | } 77 | 78 | /* -------------------------------------------------- */ 79 | 80 | /** @override */ 81 | static get defaultOptions() { 82 | return foundry.utils.mergeObject(super.defaultOptions, { 83 | width: 510, 84 | height: 700, 85 | template: `modules/${MODULE.ID}/templates/babonus-workshop.hbs`, 86 | classes: [MODULE.ID, "builder", "dnd5e2"], 87 | scrollY: [".current-bonuses .bonuses"], 88 | dragDrop: [{dragSelector: "[data-action='current-collapse']", dropSelector: ".current-bonuses .bonuses"}], 89 | resizable: true 90 | }); 91 | } 92 | 93 | /* -------------------------------------------------- */ 94 | 95 | /** @override */ 96 | setPosition(pos = {}) { 97 | const w = parseInt(pos.width); 98 | if (w) { 99 | const el = this.element[0]?.querySelector(".babonus.builder .pages .select-type"); 100 | el?.classList.toggle("hidden", w < 510); 101 | } 102 | return super.setPosition(pos); 103 | } 104 | 105 | /* -------------------------------------------------- */ 106 | 107 | /** @override */ 108 | async getData() { 109 | const data = {}; 110 | data.isItem = this.isItem; 111 | data.isEffect = this.isEffect; 112 | data.isActor = this.isActor; 113 | data.parentName = this.document.name; 114 | 115 | // Get current bonuses on the document. 116 | data.currentBonuses = []; 117 | for (const bonus of this.collection) { 118 | data.currentBonuses.push({ 119 | bonus: bonus, 120 | context: { 121 | collapsed: this.#collapsedBonuses.has(bonus.id), 122 | description: await TextEditor.enrichHTML(bonus.description, { 123 | rollData: bonus.getRollData(), relativeTo: bonus.origin 124 | }), 125 | icon: bonus.icon, 126 | typeTooltip: `BABONUS.${bonus.type.toUpperCase()}.Label` 127 | } 128 | }); 129 | } 130 | // Sort the bonuses alphabetically by name 131 | data.currentBonuses.sort((a, b) => a.bonus.name.localeCompare(b.bonus.name)); 132 | 133 | // New babonus buttons. 134 | data.createButtons = Object.entries(babonus.abstract.DataModels).map(([type, cls]) => ({ 135 | type, icon: cls.metadata.icon, label: `BABONUS.${type.toUpperCase()}.Label` 136 | })); 137 | 138 | data.ICON = MODULE.ICON; 139 | data.otterColor = this.#otterColor; 140 | return data; 141 | } 142 | 143 | /* -------------------------------------------------- */ 144 | 145 | /** @override */ 146 | activateListeners(html) { 147 | const content = html[0].parentElement; 148 | // Listeners that are always active. 149 | content.querySelectorAll("[data-action]").forEach(n => { 150 | const action = n.dataset.action; 151 | switch (action) { 152 | case "otter-rainbow": 153 | n.addEventListener("click", this.#onOtterRainbow.bind(this)); 154 | break; 155 | case "otter-dance": 156 | n.addEventListener("click", this.#onOtterDance.bind(this)); 157 | break; 158 | case "current-collapse": 159 | n.addEventListener("click", this.#onCollapseBonus.bind(this)); 160 | break; 161 | case "current-id": 162 | n.addEventListener("click", this.#onClickId.bind(this)); 163 | n.addEventListener("contextmenu", this.#onClickId.bind(this)); 164 | break; 165 | } 166 | }); 167 | 168 | if (!this.isEditable) { 169 | content.querySelectorAll(".left-side, .right-side .functions").forEach(n => { 170 | n.style.pointerEvents = "none"; 171 | n.classList.add("locked"); 172 | }); 173 | return; 174 | } 175 | super.activateListeners(html); 176 | 177 | // Listeners that require ability to edit. 178 | content.querySelectorAll("[data-action]").forEach(n => { 179 | const action = n.dataset.action; 180 | switch (action) { 181 | case "pick-type": 182 | n.addEventListener("click", this.#onClickType.bind(this)); 183 | break; 184 | case "current-toggle": 185 | n.addEventListener("click", this.#onToggleBonus.bind(this)); 186 | break; 187 | case "current-copy": 188 | n.addEventListener("click", this.#onCopyBonus.bind(this)); 189 | break; 190 | case "current-edit": 191 | n.addEventListener("click", this.#onClickBonus.bind(this)); 192 | break; 193 | case "current-delete": 194 | n.addEventListener("click", this.#onDeleteBonus.bind(this)); 195 | break; 196 | } 197 | }); 198 | } 199 | 200 | /* -------------------------------------------------- */ 201 | 202 | /** @override */ 203 | _canDragDrop() { 204 | return this.isEditable; 205 | } 206 | 207 | /* -------------------------------------------------- */ 208 | 209 | /** @override */ 210 | _canDragStart() { 211 | return true; 212 | } 213 | 214 | /* -------------------------------------------------- */ 215 | 216 | /** @override */ 217 | _onDragStart(event) { 218 | const label = event.currentTarget.closest(".bonus, [data-item-id]"); 219 | let dragData; 220 | const id = label.dataset.id ?? label.dataset.itemId; 221 | if (id) { 222 | const bab = this.collection.get(id); 223 | dragData = bab.toDragData(); 224 | } 225 | if (!dragData) return; 226 | event.dataTransfer.setData("text/plain", JSON.stringify(dragData)); 227 | } 228 | 229 | /* -------------------------------------------------- */ 230 | 231 | /** @override */ 232 | async _onDrop(event) { 233 | if (!this.isEditable) return; 234 | let data = TextEditor.getDragEventData(event); 235 | if (!data.uuid || (data.type !== "Babonus")) return; 236 | 237 | let bonus = await babonus.fromUuid(data.uuid); 238 | if (!bonus || (bonus.parent === this.document)) return; 239 | 240 | data = bonus.toObject(); 241 | data.id = foundry.utils.randomID(); 242 | bonus = new babonus.abstract.DataModels[data.type](data, {parent: this.document}); 243 | babonus.embedBabonus(this.document, bonus); 244 | } 245 | 246 | /* -------------------------------------------------- */ 247 | 248 | /** 249 | * Handle creating a new bonus. 250 | * @param {Event} event The initiating click event. 251 | */ 252 | async #onClickType(event) { 253 | const type = event.currentTarget.dataset.type; 254 | const bonus = new babonus.abstract.DataModels[type]({}, {parent: this.document}); 255 | const id = await babonus.embedBabonus(this.document, bonus, {bonusId: true}); 256 | this.collection.get(id).sheet.render(true); 257 | } 258 | 259 | /* -------------------------------------------------- */ 260 | 261 | /** 262 | * Render the sheet of an existing bonus. 263 | * @param {Event} event The initiating click event. 264 | * @returns {BabonusSheet} The sheet of a babonus. 265 | */ 266 | #onClickBonus(event) { 267 | const id = event.currentTarget.closest(".bonus").dataset.id; 268 | const bonus = this.collection.get(id); 269 | return bonus.sheet.render({force: true}); 270 | } 271 | 272 | /* -------------------------------------------------- */ 273 | 274 | /** @override */ 275 | render(...T) { 276 | this.document.apps[this.appId] = this; 277 | return super.render(...T); 278 | } 279 | 280 | /* -------------------------------------------------- */ 281 | 282 | /** @override */ 283 | close(...T) { 284 | delete this.document.apps[this.appId]; 285 | return super.close(...T); 286 | } 287 | 288 | /* -------------------------------------------------- */ 289 | 290 | /** 291 | * Otter Rainbow. 292 | * @param {Event} event The initiating click event. 293 | */ 294 | #onOtterRainbow(event) { 295 | this.#otterColor = "#" + Math.floor(Math.random() * 16777215).toString(16); 296 | event.currentTarget.style.color = this.#otterColor; 297 | const count = this.#otterVomits++; 298 | const content = event.currentTarget.closest(".window-content"); 299 | if (count >= 50) content.classList.toggle("vomit", true); 300 | } 301 | 302 | /* -------------------------------------------------- */ 303 | 304 | /** 305 | * Otter Dance. 306 | * @param {Event} event The initiating click event. 307 | */ 308 | #onOtterDance(event) { 309 | const spin = [{transform: "rotate(0)"}, {transform: "rotate(360deg)"}]; 310 | const time = {duration: 1000, iterations: 1}; 311 | if (!event.currentTarget.getAnimations().length) event.currentTarget.animate(spin, time); 312 | } 313 | 314 | /* -------------------------------------------------- */ 315 | 316 | /** 317 | * Collapse or expand a babonus and its description. 318 | * @param {Event} event The initiating click event. 319 | */ 320 | #onCollapseBonus(event) { 321 | const bonus = event.currentTarget.closest(".bonus"); 322 | const id = bonus.dataset.id; 323 | const has = this.#collapsedBonuses.has(id); 324 | bonus.classList.toggle("collapsed", !has); 325 | if (has) this.#collapsedBonuses.delete(id); 326 | else this.#collapsedBonuses.add(id); 327 | } 328 | 329 | /* -------------------------------------------------- */ 330 | 331 | /** 332 | * Handle copying the id or uuid of a babonus. 333 | * @param {Event} event The initiating click event. 334 | */ 335 | async #onClickId(event) { 336 | const bonus = this.collection.get(event.currentTarget.closest(".bonus").dataset.id); 337 | const id = (event.type === "contextmenu") ? bonus.id : bonus.uuid; 338 | await game.clipboard.copyPlainText(id); 339 | ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", { 340 | id, label: "Babonus", type: (event.type === "contextmenu") ? "id" : "uuid" 341 | })); 342 | } 343 | 344 | /* -------------------------------------------------- */ 345 | 346 | /** 347 | * Delete a babonus on the builder when hitting its trashcan icon. 348 | * @param {Event} event The initiating click event. 349 | */ 350 | async #onDeleteBonus(event) { 351 | const id = event.currentTarget.closest(".bonus").dataset.id; 352 | const bonus = this.collection.get(id); 353 | bonus.deleteDialog(); 354 | } 355 | 356 | /* -------------------------------------------------- */ 357 | 358 | /** 359 | * Toggle the enabled property on a babonus. 360 | * @param {Event} event The initiating click event. 361 | */ 362 | async #onToggleBonus(event) { 363 | const id = event.currentTarget.closest(".bonus").dataset.id; 364 | const bonus = this.collection.get(id); 365 | bonus.toggle(); 366 | } 367 | 368 | /* -------------------------------------------------- */ 369 | 370 | /** 371 | * Copy a babonus on the document. 372 | * @param {Event} event The initiating click event. 373 | */ 374 | async #onCopyBonus(event) { 375 | const id = event.currentTarget.closest(".bonus").dataset.id; 376 | babonus.duplicateBonus(this.collection.get(id)); 377 | } 378 | } 379 | -------------------------------------------------------------------------------- /scripts/api.mjs: -------------------------------------------------------------------------------- 1 | import {default as applications} from "./applications/_module.mjs"; 2 | import {default as models} from "./models/babonus-model.mjs"; 3 | 4 | export default { 5 | applyMarkers: applyMarkers, 6 | createBabonus: createBabonus, 7 | duplicateBonus: duplicateBonus, 8 | embedBabonus: embedBabonus, 9 | findEmbeddedDocumentsWithBonuses: findEmbeddedDocumentsWithBonuses, 10 | fromUuid: babonusFromUuid, 11 | fromUuidSync: babonusFromUuidSync, 12 | getCollection: getCollection, 13 | hasArmorProficiency: hasArmorProficiency, 14 | hasToolProficiency: hasToolProficiency, 15 | hasWeaponProficiency: hasWeaponProficiency, 16 | hotbarToggle: hotbarToggle, 17 | openBabonusWorkshop: openBabonusWorkshop, 18 | proficiencyTree: proficiencyTree, 19 | speaksLanguage: speaksLanguage 20 | }; 21 | 22 | /* -------------------------------------------------- */ 23 | 24 | /** 25 | * Apply markers to a document for the 'Markers' filter. 26 | * @param {Document} document The target document. 27 | * @returns {Promise} A promise that resolves to the result of the dialog prompt. 28 | */ 29 | async function applyMarkers(document) { 30 | const {SetField, StringField} = foundry.data.fields; 31 | const field = new SetField(new StringField()); 32 | const value = document.getFlag("babonus", "markers") ?? []; 33 | const html = field.toFormGroup({ 34 | label: "BABONUS.MarkersDialog.field.label", 35 | hint: "BABONUS.MarkersDialog.field.hint", 36 | localize: true 37 | }, {value: value, name: "markers", slug: true}).outerHTML; 38 | 39 | return foundry.applications.api.DialogV2.prompt({ 40 | rejectClose: false, 41 | content: `
${html}
`, 42 | window: { 43 | icon: "fa-solid fa-tags", 44 | title: game.i18n.format("BABONUS.MarkersDialog.title", {name: document.name}) 45 | }, 46 | position: {width: 400}, 47 | ok: { 48 | callback: async (event, button) => { 49 | const markers = Array.from(button.form.elements.markers.value); 50 | await document.setFlag("babonus", "markers", markers); 51 | return document; 52 | } 53 | } 54 | }); 55 | } 56 | 57 | /* -------------------------------------------------- */ 58 | 59 | /** 60 | * Return an object of arrays of items and effects on the given document 61 | * that have one or more babonuses embedded in them. 62 | * @param {Document} object An actor or item with embedded documents. 63 | * @returns {object} An object with an array of effects and array of items. 64 | */ 65 | function findEmbeddedDocumentsWithBonuses(object) { 66 | const documents = {}; 67 | 68 | for (const [, e] of object.traverseEmbeddedDocuments()) { 69 | const bonuses = getCollection(e); 70 | const collection = e.constructor.metadata.collection; 71 | documents[collection] ??= []; 72 | if (bonuses.size) documents[collection].push(e); 73 | } 74 | 75 | return documents; 76 | } 77 | 78 | /* -------------------------------------------------- */ 79 | 80 | /** 81 | * Render the build-a-bonus application for a document. 82 | * @param {Document} object An actor, item, effect, or region. 83 | * @returns {BabonusWorkshop} The rendered workshop. 84 | */ 85 | function openBabonusWorkshop(object) { 86 | const validDocumentType = ["Actor", "Item", "ActiveEffect", "Region"].includes(object.documentName); 87 | if (!validDocumentType) throw new Error("The document provided is not a valid document type for Build-a-Bonus!"); 88 | return new applications.BabonusWorkshop(object).render(true); 89 | } 90 | 91 | /* -------------------------------------------------- */ 92 | 93 | /** 94 | * Create a babonus in memory with the given data. 95 | * @param {object} data An object of babonus data. 96 | * @param {Document} [parent] The document to act as parent of the babonus. 97 | * @returns {Babonus} The created babonus. 98 | */ 99 | function createBabonus(data, parent = null) { 100 | if (!(data.type in models)) throw new Error("INVALID BABONUS TYPE."); 101 | data.id = foundry.utils.randomID(); 102 | return new models[data.type](data, {parent}); 103 | } 104 | 105 | /* -------------------------------------------------- */ 106 | 107 | /** 108 | * Duplicate a bonus. 109 | * @param {Babonus} bonus The bonus to duplicate. 110 | * @returns {Promise} The duplicate. 111 | */ 112 | async function duplicateBonus(bonus) { 113 | const data = bonus.toObject(); 114 | data.name = game.i18n.format("BABONUS.BonusCopy", {name: data.name}); 115 | bonus = new bonus.constructor(data, {parent: bonus.parent}); 116 | const id = await embedBabonus(bonus.parent, bonus, {bonusId: true}); 117 | return getCollection(bonus.parent).get(id); 118 | } 119 | 120 | /* -------------------------------------------------- */ 121 | 122 | /** 123 | * Internal helper method for fromUuid and fromUuidSync. 124 | * @param {string} uuid Babonus uuid. 125 | * @returns {{parentUuid: string, id: string}} 126 | */ 127 | const _getParentUuidAndId = (uuid) => { 128 | const parts = uuid.split("."); 129 | const id = parts.pop(); 130 | parts.pop(); 131 | const parentUuid = parts.join("."); 132 | return {parentUuid, id}; 133 | }; 134 | 135 | /* -------------------------------------------------- */ 136 | 137 | /** 138 | * Return a babonus using its uuid. 139 | * @param {string} uuid The babonus uuid. 140 | * @returns {Promise} The found babonus. 141 | */ 142 | async function babonusFromUuid(uuid) { 143 | try { 144 | const ids = _getParentUuidAndId(uuid); 145 | const parent = await fromUuid(ids.parentUuid); 146 | const collection = getCollection(parent); 147 | return collection.get(ids.id); 148 | } catch (err) { 149 | return null; 150 | } 151 | } 152 | 153 | /* -------------------------------------------------- */ 154 | 155 | /** 156 | * Return a babonus using its uuid synchronously. 157 | * @param {string} uuid The babonus uuid. 158 | * @returns {Babonus|null} The found babonus. 159 | */ 160 | function babonusFromUuidSync(uuid) { 161 | try { 162 | const ids = _getParentUuidAndId(uuid); 163 | const parent = fromUuidSync(ids.parentUuid); 164 | const collection = getCollection(parent); 165 | return collection.get(ids.id); 166 | } catch (err) { 167 | return null; 168 | } 169 | } 170 | 171 | /* -------------------------------------------------- */ 172 | 173 | /** 174 | * Return the collection of bonuses on the document. 175 | * @param {Document} object An actor, item, effect, or template. 176 | * @returns {Collection} A collection of babonuses. 177 | */ 178 | function getCollection(object) { 179 | let bonuses = foundry.utils.getProperty(object, "flags.babonus.bonuses") ?? []; 180 | if (foundry.utils.getType(bonuses) === "Object") bonuses = Object.values(bonuses); 181 | 182 | const contents = []; 183 | for (const bonusData of bonuses) { 184 | try { 185 | if (!foundry.data.validators.isValidId(bonusData.id)) continue; 186 | const bonus = new models[bonusData.type](bonusData, {parent: object}); 187 | contents.push([bonus.id, bonus]); 188 | } catch (err) { 189 | console.warn(err); 190 | } 191 | } 192 | return new foundry.utils.Collection(contents); 193 | } 194 | 195 | /* -------------------------------------------------- */ 196 | 197 | /** 198 | * Embed a created babonus onto the target object. 199 | * @param {Document} object The actor, item, effect, or region that should have the babonus. 200 | * @param {Babonus} bonus The created babonus. 201 | * @param {object} [options] Creation and return options. 202 | * @param {boolean} [options.renderSheet] Render the sheet once created? 203 | * @returns {Promise} The actor, item, effect, or region that has received the babonus. 204 | */ 205 | async function embedBabonus(object, bonus, {renderSheet = true, ...options} = {}) { 206 | const validDocumentType = ["Actor", "Item", "ActiveEffect", "Region"].includes(object.documentName); 207 | if (!validDocumentType) throw new Error("The document provided is not a valid document type for Build-a-Bonus!"); 208 | if (!Object.values(models).some(t => bonus instanceof t)) return null; 209 | const id = await _embedBabonus(object, bonus); 210 | if (renderSheet) getCollection(object).get(id).sheet.render({force: true}); 211 | return options.bonusId ? id : object; 212 | } 213 | 214 | /* -------------------------------------------------- */ 215 | 216 | /** 217 | * Embed a created babonus onto the target object. 218 | * @param {Document} object The actor, item, effect, or region that should have the babonus. 219 | * @param {Babonus} bonus The created babonus. 220 | * @returns {Promise} The id of the bonus created. 221 | */ 222 | async function _embedBabonus(object, bonus) { 223 | const data = bonus.toObject(); 224 | for (const id of Object.keys(data.filters)) { 225 | if (!babonus.abstract.DataFields.fields[id].storage(bonus)) delete data.filters[id]; 226 | } 227 | data.id = foundry.utils.randomID(); 228 | let collection = babonus.getCollection(object); 229 | if (collection.has(data.id)) collection.delete(data.id); 230 | collection = collection.map(k => k.toObject()); 231 | collection.push(data); 232 | 233 | await object.setFlag("babonus", "bonuses", collection); 234 | return data.id; 235 | } 236 | 237 | /* -------------------------------------------------- */ 238 | 239 | /** 240 | * Hotbar method for toggling a bonus via uuid. 241 | * @param {string} uuid Uuid of the bonus to toggle. 242 | * @returns {Promise} 243 | */ 244 | async function hotbarToggle(uuid) { 245 | const bonus = await babonusFromUuid(uuid); 246 | if (!bonus) { 247 | ui.notifications.warn("BABONUS.BonusNotFound", {localize: true}); 248 | return; 249 | } 250 | return bonus.toggle(); 251 | } 252 | 253 | /* -------------------------------------------------- */ 254 | 255 | /** 256 | * Does this actor speak a given language? 257 | * @param {Actor5e} actor The actor to test. 258 | * @param {string} trait The language to test. 259 | * @returns {boolean} 260 | */ 261 | function speaksLanguage(actor, trait) { 262 | return _hasTrait(actor, trait, "languages"); 263 | } 264 | 265 | /* -------------------------------------------------- */ 266 | 267 | /** 268 | * Does this actor have a given weapon proficiency? 269 | * @param {Actor5e} actor The actor to test. 270 | * @param {string} trait The trait to test. 271 | * @returns {boolean} 272 | */ 273 | function hasWeaponProficiency(actor, trait) { 274 | return _hasTrait(actor, trait, "weapon"); 275 | } 276 | 277 | /* -------------------------------------------------- */ 278 | 279 | /** 280 | * Does this actor have a given armor proficiency? 281 | * @param {Actor5e} actor The actor to test. 282 | * @param {string} trait The trait to test. 283 | * @returns {boolean} 284 | */ 285 | function hasArmorProficiency(actor, trait) { 286 | return _hasTrait(actor, trait, "armor"); 287 | } 288 | 289 | /* -------------------------------------------------- */ 290 | 291 | /** 292 | * Does this actor have a given tool proficiency? 293 | * @param {Actor5e} actor The actor to test. 294 | * @param {string} trait The trait to test. 295 | * @returns {boolean} 296 | */ 297 | function hasToolProficiency(actor, trait) { 298 | return _hasTrait(actor, trait, "tool"); 299 | } 300 | 301 | /* -------------------------------------------------- */ 302 | 303 | /** 304 | * Internal method for proficiency checking. 305 | * @param {Actor5e} actor The actor to test. 306 | * @param {string} trait The trait to test. 307 | * @param {string} category The tree to scan. 308 | * @returns {boolean} 309 | */ 310 | function _hasTrait(actor, trait, category) { 311 | const path = CONFIG.DND5E.traits[category].actorKeyPath ?? `system.traits.${category}`; 312 | const set = foundry.utils.getProperty(actor, path)?.value ?? new Set(); 313 | if (set.has(trait)) return true; 314 | return set.some(v => { 315 | const [k, obj] = babonus.trees[category].find(v) ?? []; 316 | return (k === trait) || (obj.children && obj.children.find(trait)); 317 | }); 318 | } 319 | 320 | /* -------------------------------------------------- */ 321 | 322 | /** 323 | * Retrieve a path through nested proficiencies to find a specific proficiency in a category. 324 | * E.g., 'smith' and 'tool' will return ['art', 'smith'], and 'aquan' and 'languages' will 325 | * return ['exotic', 'primordial', 'aquan']. 326 | * @param {string} key The specific proficiency (can be a category), e.g., "smith" or "primordial". 327 | * @param {string} category The trait category, e.g., "tool", "weapon", "armor", "languages". 328 | * @returns {string[]} 329 | */ 330 | function proficiencyTree(key, category) { 331 | const root = babonus.trees[category]; 332 | const path = []; 333 | 334 | const find = (node) => { 335 | for (const [k, v] of Object.entries(node)) { 336 | if ((k === key)) { 337 | path.unshift(k); 338 | return true; 339 | } else if (v.children) { 340 | const result = find(v.children); 341 | if (result) { 342 | path.unshift(k); 343 | return true; 344 | } 345 | } 346 | } 347 | path.shift(); 348 | return false; 349 | }; 350 | 351 | find(root); 352 | return path; 353 | } 354 | -------------------------------------------------------------------------------- /scripts/models/modifiers-model.mjs: -------------------------------------------------------------------------------- 1 | import {MODULE} from "../constants.mjs"; 2 | 3 | const {SchemaField, BooleanField, NumberField, StringField} = foundry.data.fields; 4 | 5 | /* Child of Babonus#bonuses that holds all die modifiers. */ 6 | export default class ModifiersModel extends foundry.abstract.DataModel { 7 | /** @override */ 8 | static defineSchema() { 9 | return { 10 | amount: new SchemaField({ 11 | enabled: new BooleanField(), 12 | mode: new NumberField({initial: 0, choices: MODULE.MODIFIER_MODES}), 13 | value: new StringField({required: true}) 14 | }), 15 | size: new SchemaField({ 16 | enabled: new BooleanField(), 17 | mode: new NumberField({initial: 0, choices: MODULE.MODIFIER_MODES}), 18 | value: new StringField({required: true}) 19 | }), 20 | reroll: new SchemaField({ 21 | enabled: new BooleanField(), 22 | value: new StringField({required: true}), 23 | invert: new BooleanField(), 24 | recursive: new BooleanField(), 25 | limit: new StringField({required: true}) 26 | }), 27 | explode: new SchemaField({ 28 | enabled: new BooleanField(), 29 | value: new StringField({required: true}), 30 | once: new BooleanField(), 31 | limit: new StringField({required: true}) 32 | }), 33 | minimum: new SchemaField({ 34 | enabled: new BooleanField(), 35 | value: new StringField({required: true}), 36 | maximize: new BooleanField() 37 | }), 38 | maximum: new SchemaField({ 39 | enabled: new BooleanField(), 40 | value: new StringField({required: true}), 41 | zero: new BooleanField() 42 | }), 43 | config: new SchemaField({ 44 | first: new BooleanField() 45 | }) 46 | }; 47 | } 48 | 49 | /* -------------------------------------------------- */ 50 | 51 | /** @override */ 52 | static LOCALIZATION_PREFIXES = ["BABONUS.MODIFIERS"]; 53 | 54 | /* -------------------------------------------------- */ 55 | /* Data preparation */ 56 | /* -------------------------------------------------- */ 57 | 58 | /** @override */ 59 | _initialize(...args) { 60 | super._initialize(...args); 61 | this.prepareDerivedData(); 62 | } 63 | 64 | /* -------------------------------------------------- */ 65 | 66 | /** @override */ 67 | prepareDerivedData() { 68 | const rollData = this.parent.getRollData({deterministic: true}); 69 | for (const m of ["amount", "size", "reroll", "explode", "minimum", "maximum"]) { 70 | const value = this[m].value; 71 | if (!value) this[m].value = null; 72 | else { 73 | const bonus = dnd5e.utils.simplifyBonus(value, rollData); 74 | this[m].value = Number.isNumeric(bonus) ? Math.round(bonus) : null; 75 | } 76 | 77 | if (!("limit" in this[m])) continue; 78 | 79 | const limit = this[m].limit; 80 | if (!limit) this[m].limit = null; 81 | else { 82 | const bonus = Math.round(dnd5e.utils.simplifyBonus(limit, rollData)); 83 | this[m].limit = (Number.isNumeric(bonus) && (bonus > 0)) ? bonus : null; 84 | } 85 | } 86 | } 87 | 88 | /* -------------------------------------------------- */ 89 | /* Dice modifications */ 90 | /* -------------------------------------------------- */ 91 | 92 | /** 93 | * Regex to determine whether a die already has a modifier. 94 | */ 95 | static REGEX = Object.freeze({ 96 | reroll: /rr?([0-9]+)?([<>=]+)?([0-9]+)?/i, 97 | explode: /xo?([0-9]+)?([<>=]+)?([0-9]+)?/i, 98 | minimum: /(?:min)([0-9]+)/i, 99 | maximum: /(?:max)([0-9]+)/i 100 | }); 101 | 102 | /* -------------------------------------------------- */ 103 | 104 | /** 105 | * Append applicable modifiers to a die. 106 | * @param {DieTerm} die The die term that will be mutated. 107 | * @param {object} [options] Options object meant to specifically bypass certain modifications. 108 | */ 109 | modifyDie(die, options = {}) { 110 | if (options.amount !== false) this._modifyAmount(die); 111 | if (options.size !== false) this._modifySize(die); 112 | if (options.reroll !== false) this._modifyReroll(die); 113 | if (options.explode !== false) this._modifyExplode(die); 114 | if (options.minimum !== false) this._modifyMin(die); 115 | if (options.maximum !== false) this._modifyMax(die); 116 | } 117 | 118 | /* -------------------------------------------------- */ 119 | 120 | /** 121 | * Append applicable amount modifiers to a die. 122 | * @param {DieTerm} die The die term that will be mutated. 123 | */ 124 | _modifyAmount(die) { 125 | if (!this.hasAmount) return; 126 | const isMult = this.amount.mode === MODULE.MODIFIER_MODES.MULTIPLY; 127 | 128 | if ((die._number instanceof Roll) && die._number.isDeterministic) { 129 | const total = die._number.evaluateSync().total; 130 | die._number = total; 131 | } 132 | 133 | if (Number.isInteger(die._number)) { 134 | if (isMult) die._number = Math.max(0, die._number * this.amount.value); 135 | else die._number = Math.max(0, die._number + this.amount.value); 136 | } 137 | } 138 | 139 | /* -------------------------------------------------- */ 140 | 141 | /** 142 | * Append applicable size modifiers to a die. 143 | * @param {DieTerm} die The die term that will be mutated. 144 | */ 145 | _modifySize(die) { 146 | if (!this.hasSize) return; 147 | const isMult = this.size.mode === MODULE.MODIFIER_MODES.MULTIPLY; 148 | 149 | if ((die._faces instanceof Roll) && die._faces.isDeterministic) { 150 | const total = die._faces.evaluateSync().total; 151 | die._faces = total; 152 | } 153 | 154 | if (Number.isInteger(die._faces)) { 155 | if (isMult) die._faces = Math.max(0, die._faces * this.size.value); 156 | else die._faces = Math.max(0, die._faces + this.size.value); 157 | } 158 | } 159 | 160 | /* -------------------------------------------------- */ 161 | 162 | /** 163 | * Append applicable reroll modifiers to a die. 164 | * @param {DieTerm} die The die term that will be mutated. 165 | */ 166 | _modifyReroll(die) { 167 | if (!this.hasReroll || die.modifiers.some(m => m.match(this.constructor.REGEX.reroll))) return; 168 | const l = this.reroll.limit; 169 | const prefix = this.reroll.recursive ? (l ? `rr${l}` : "rr") : "r"; 170 | const v = this.reroll.value ?? 1; 171 | let mod; 172 | if (this.reroll.invert) { 173 | if (v > 0) { 174 | // reroll if strictly greater than x. 175 | mod = (v >= die.faces) ? `${prefix}=${die.faces}` : `${prefix}>${v}`; 176 | } else if (v === 0) { 177 | // reroll if max. 178 | mod = `${prefix}=${die.faces}`; 179 | } else { 180 | // reroll if strictly greater than (size-x). 181 | mod = (die.faces + v <= 1) ? `${prefix}=1` : `${prefix}>${die.faces + v}`; 182 | } 183 | } else { 184 | if (v > 0) { 185 | // reroll if strictly less than x. 186 | mod = (v === 1) ? `${prefix}=1` : `${prefix}<${Math.min(die.faces, v)}`; 187 | } else if (v === 0) { 188 | // reroll 1s. 189 | mod = `${prefix}=1`; 190 | } else { 191 | // reroll if strictly less than (size-x). 192 | mod = (die.faces + v <= 1) ? `${prefix}=1` : `${prefix}<${die.faces + v}`; 193 | } 194 | } 195 | if (die.faces > 1) die.modifiers.push(mod); 196 | } 197 | 198 | /* -------------------------------------------------- */ 199 | 200 | /** 201 | * Append applicable explode modifiers to a die. 202 | * @param {DieTerm} die The die term that will be mutated. 203 | */ 204 | _modifyExplode(die) { 205 | if (!this.hasExplode || die.modifiers.some(m => m.match(this.constructor.REGEX.explode))) return; 206 | const v = this.explode.value ?? 0; 207 | const l = this.explode.limit; 208 | const prefix = (this.explode.once || (l === 1)) ? "xo" : (l ? `x${l}` : "x"); 209 | const _prefix = () => /x\d+/.test(prefix) ? `${prefix}=${die.faces}` : prefix; 210 | let valid; 211 | let mod; 212 | if (v === 0) { 213 | mod = _prefix(); 214 | valid = (die.faces > 1) || (prefix === "xo"); 215 | } else if (v > 0) { 216 | mod = (v >= die.faces) ? _prefix() : `${prefix}>=${v}`; 217 | valid = (v <= die.faces) && (((v === 1) && (prefix === "xo")) || (v > 1)); 218 | } else if (v < 0) { 219 | const m = Math.max(1, die.faces + v); 220 | mod = `${prefix}>=${m}`; 221 | valid = (m > 1) || (prefix == "xo"); 222 | } 223 | if (valid || l) die.modifiers.push(mod); 224 | } 225 | 226 | /* -------------------------------------------------- */ 227 | 228 | /** 229 | * Append applicable minimum modifiers to a die. 230 | * @param {DieTerm} die The die term that will be mutated. 231 | */ 232 | _modifyMin(die) { 233 | if (!this.hasMin || die.modifiers.some(m => m.match(this.constructor.REGEX.minimum))) return; 234 | const f = die.faces; 235 | let mod; 236 | const min = this.minimum.value; 237 | if (this.minimum.maximize) mod = `min${f}`; 238 | else mod = `min${(min > 0) ? Math.min(min, f) : Math.max(1, f + min)}`; 239 | if (mod !== "min1") die.modifiers.push(mod); 240 | } 241 | 242 | /* -------------------------------------------------- */ 243 | 244 | /** 245 | * Append applicable maximum modifiers to a die. 246 | * @param {DieTerm} die The die term that will be mutated. 247 | */ 248 | _modifyMax(die) { 249 | if (!this.hasMax || die.modifiers.some(m => m.match(this.constructor.REGEX.maximum))) return; 250 | const zero = this.maximum.zero; 251 | const v = this.maximum.value; 252 | const max = (v === 0) ? (zero ? 0 : 1) : (v > 0) ? v : Math.max(zero ? 0 : 1, die.faces + v); 253 | if (max < die.faces) die.modifiers.push(`max${max}`); 254 | } 255 | 256 | /* -------------------------------------------------- */ 257 | 258 | /** 259 | * Append applicable modifiers to a roll part. 260 | * @param {string[]|number[]} parts The roll part. **will be mutated** 261 | * @param {object} [rollData] Roll data for roll construction. 262 | * @param {object} [options] 263 | * @param {boolean} [options.ignoreFirst] Whether to ignore the 'first' property'. 264 | * @returns {boolean} Whether all but the first die were skipped. 265 | */ 266 | modifyParts(parts, rollData = {}, options = {}) { 267 | if (!this.hasModifiers) return; 268 | const first = !options.ignoreFirst && this.config.first; 269 | for (let i = 0; i < parts.length; i++) { 270 | const part = String(parts[i]); 271 | const roll = new CONFIG.Dice.DamageRoll(part, rollData); 272 | if (!roll.dice.length) continue; 273 | 274 | for (const die of roll.dice) { 275 | this.modifyDie(die); 276 | if (first) break; 277 | } 278 | parts[i] = Roll.fromTerms(roll.terms).formula; 279 | if (first) return true; 280 | } 281 | return false; 282 | } 283 | 284 | /* -------------------------------------------------- */ 285 | /* Properties */ 286 | /* -------------------------------------------------- */ 287 | 288 | /** 289 | * The babonus this lives on. 290 | * @type {Babonus} 291 | */ 292 | get bonus() { 293 | return this.parent.parent; 294 | } 295 | 296 | /* -------------------------------------------------- */ 297 | 298 | /** 299 | * Does this bonus affect the dice amount? 300 | * @type {boolean} 301 | */ 302 | get hasAmount() { 303 | if (!this.amount.enabled) return false; 304 | return Number.isInteger(this.amount.value); 305 | } 306 | 307 | /* -------------------------------------------------- */ 308 | 309 | /** 310 | * Does this bonus affect explosive dice? 311 | * @type {boolean} 312 | */ 313 | get hasExplode() { 314 | if (!this.explode.enabled) return false; 315 | return (this.maximum.value === null) || Number.isInteger(this.explode.value); 316 | } 317 | 318 | /* -------------------------------------------------- */ 319 | 320 | /** 321 | * Does this bonus affect the maximum cap? 322 | * @type {boolean} 323 | */ 324 | get hasMax() { 325 | if (!this.maximum.enabled) return false; 326 | return Number.isInteger(this.maximum.value); 327 | } 328 | 329 | /* -------------------------------------------------- */ 330 | 331 | /** 332 | * Does this bonus affect the minimum cap? 333 | * @type {boolean} 334 | */ 335 | get hasMin() { 336 | if (!this.minimum.enabled) return false; 337 | if (this.minimum.maximize) return true; 338 | return Number.isInteger(this.minimum.value) && (this.minimum.value !== 0); 339 | } 340 | 341 | /* -------------------------------------------------- */ 342 | 343 | /** 344 | * Does this bonus have applicable modifiers for dice? 345 | * @type {boolean} 346 | */ 347 | get hasModifiers() { 348 | return this.hasAmount || this.hasSize || this.hasReroll || this.hasExplode || this.hasMin || this.hasMax; 349 | } 350 | 351 | /* -------------------------------------------------- */ 352 | 353 | /** 354 | * Does this bonus affect rerolling? 355 | * @type {boolean} 356 | */ 357 | get hasReroll() { 358 | if (!this.reroll.enabled) return false; 359 | return (this.reroll.value === null) || Number.isInteger(this.reroll.value); 360 | } 361 | 362 | /* -------------------------------------------------- */ 363 | 364 | /** 365 | * Does this bonus affect the die size? 366 | * @type {boolean} 367 | */ 368 | get hasSize() { 369 | if (!this.size.enabled) return false; 370 | return Number.isInteger(this.size.value); 371 | } 372 | } 373 | -------------------------------------------------------------------------------- /scripts/applications/token-aura.mjs: -------------------------------------------------------------------------------- 1 | import {MODULE, SETTINGS} from "../constants.mjs"; 2 | 3 | export default class TokenAura { 4 | /** 5 | * @constructor 6 | * @param {TokenDocument5e} token 7 | * @param {Babonus} bonus 8 | */ 9 | constructor(token, bonus) { 10 | this.#token = token; 11 | this.#bonus = bonus; 12 | this.showAuras = game.settings.get(MODULE.ID, SETTINGS.AURA); 13 | this.padRadius = !canvas.grid.isGridless || game.settings.get(MODULE.ID, SETTINGS.RADIUS); 14 | 15 | const auras = this.auras; 16 | const old = auras[bonus.uuid]; 17 | if (old) old.destroy({fadeOut: false}); 18 | auras[bonus.uuid] = this; 19 | } 20 | 21 | /* -------------------------------------------------- */ 22 | 23 | /** 24 | * The color of the aura when the target is not contained within it. 25 | * @type {Color} 26 | */ 27 | static RED = new Color(0xFF0000); 28 | 29 | /* -------------------------------------------------- */ 30 | 31 | /** 32 | * The color of the aura when the target is contained within it. 33 | * @type {Color} 34 | */ 35 | static GREEN = new Color(0x00FF00); 36 | 37 | /* -------------------------------------------------- */ 38 | 39 | /** 40 | * The untinted default color of the aura. 41 | * @type {Color} 42 | */ 43 | static WHITE = new Color(0xFFFFFF); 44 | 45 | /* -------------------------------------------------- */ 46 | 47 | /** 48 | * The collection of auras being kept track of. 49 | * @type {Record} 50 | */ 51 | get auras() { 52 | babonus._currentAuras ??= {}; 53 | return babonus._currentAuras; 54 | } 55 | 56 | /* -------------------------------------------------- */ 57 | 58 | /** 59 | * The default color of the aura (white). 60 | * @type {Color} 61 | */ 62 | get white() { 63 | return this.constructor.WHITE; 64 | } 65 | 66 | /* -------------------------------------------------- */ 67 | 68 | /** 69 | * The name of this aura. 70 | * @type {string} 71 | */ 72 | get name() { 73 | return `${this.bonus.uuid}-aura`; 74 | } 75 | 76 | /* -------------------------------------------------- */ 77 | 78 | /** 79 | * Do auras show and fade in and out? 80 | * @type {boolean} 81 | */ 82 | #showAuras = true; 83 | 84 | /* -------------------------------------------------- */ 85 | 86 | /** 87 | * Do auras show and fade in and out? 88 | * @type {boolean} 89 | */ 90 | get showAuras() { 91 | return this.#showAuras; 92 | } 93 | 94 | /* -------------------------------------------------- */ 95 | 96 | /** 97 | * Set whether auras show and fade in and out. 98 | * @param {boolean} bool Whether to show. 99 | */ 100 | set showAuras(bool) { 101 | this.#showAuras = bool; 102 | } 103 | 104 | /* -------------------------------------------------- */ 105 | 106 | /** 107 | * Do auras pad the radius due to token sizes? 108 | * @type {boolean} 109 | */ 110 | #padRadius = true; 111 | 112 | /* -------------------------------------------------- */ 113 | 114 | /** 115 | * Do auras pad the radius due to token sizes? 116 | * @type {boolean} 117 | */ 118 | get padRadius() { 119 | return this.#padRadius; 120 | } 121 | 122 | /* -------------------------------------------------- */ 123 | 124 | /** 125 | * Set whether auras are padded due to token size. 126 | * @param {boolean} bool Whether to pad. 127 | */ 128 | set padRadius(bool) { 129 | this.#padRadius = bool; 130 | } 131 | 132 | /* -------------------------------------------------- */ 133 | 134 | /** 135 | * The origin of the aura. 136 | * @type {TokenDocument5e} 137 | */ 138 | #token = null; 139 | 140 | /* -------------------------------------------------- */ 141 | 142 | /** 143 | * The origin of the aura. 144 | * @type {TokenDocument5e} 145 | */ 146 | get token() { 147 | return this.#token; 148 | } 149 | 150 | /* -------------------------------------------------- */ 151 | 152 | /** 153 | * The babonus from which to draw data. 154 | * @type {Babonus} 155 | */ 156 | #bonus = null; 157 | 158 | /* -------------------------------------------------- */ 159 | 160 | /** 161 | * The babonus from which to draw data. 162 | * @type {Babonus} 163 | */ 164 | get bonus() { 165 | return this.#bonus; 166 | } 167 | 168 | /* -------------------------------------------------- */ 169 | 170 | /** 171 | * The drawn pixi graphics. 172 | * @type {PIXI.Graphics|null} 173 | */ 174 | #element = null; 175 | 176 | /* -------------------------------------------------- */ 177 | 178 | /** 179 | * The drawn pixi graphics. 180 | * @type {PIXI.Graphics|null} 181 | */ 182 | get element() { 183 | return this.#element; 184 | } 185 | 186 | /* -------------------------------------------------- */ 187 | 188 | /** 189 | * Set the displayed pixi graphical element. 190 | * @param {PIXI.Graphics} 191 | */ 192 | set element(g) { 193 | this.#element = g; 194 | } 195 | 196 | /* -------------------------------------------------- */ 197 | 198 | /** 199 | * The container element for the aura. 200 | * @type {PIXI.Container} 201 | */ 202 | #container = null; 203 | 204 | /* -------------------------------------------------- */ 205 | 206 | /** 207 | * The container element for the aura. 208 | * @type {PIXI.Container} 209 | */ 210 | get container() { 211 | return this.#container; 212 | } 213 | 214 | /* -------------------------------------------------- */ 215 | 216 | /** 217 | * Set the container element for the aura. 218 | * @param {PIXI.Container} c The container. 219 | */ 220 | set container(c) { 221 | this.#container = c; 222 | } 223 | 224 | /* -------------------------------------------------- */ 225 | 226 | /** 227 | * A current token target this aura is being evaluated against. Not the origin of the aura. 228 | * @type {Token5e} 229 | */ 230 | #target = null; 231 | 232 | /* -------------------------------------------------- */ 233 | 234 | /** 235 | * A current token target this aura is being evaluated against. Not the origin of the aura. 236 | * @type {Token5e} 237 | */ 238 | get target() { 239 | return this.#target; 240 | } 241 | 242 | /* -------------------------------------------------- */ 243 | 244 | /** 245 | * Set the current token target of this aura. 246 | * @param {Token5e} 247 | */ 248 | set target(token) { 249 | this.#target = token; 250 | } 251 | 252 | /* -------------------------------------------------- */ 253 | 254 | /** 255 | * The type of wall restrictions that apply to this bonus. 256 | * @type {Set} 257 | */ 258 | get restrictions() { 259 | const r = new Set(); 260 | for (const [k, v] of Object.entries(this.bonus.aura.require)) { 261 | if (v) r.add(k); 262 | } 263 | return r; 264 | } 265 | 266 | /* -------------------------------------------------- */ 267 | 268 | /** 269 | * The radius of this aura, in grid measurement units. 270 | * @type {number} 271 | */ 272 | get radius() { 273 | return this.bonus.aura.range; 274 | } 275 | 276 | /* -------------------------------------------------- */ 277 | 278 | /** 279 | * Can this aura be drawn? 280 | * @type {boolean} 281 | */ 282 | get isDrawable() { 283 | return this.bonus.aura._validRange; 284 | } 285 | 286 | /* -------------------------------------------------- */ 287 | 288 | /** 289 | * Should this aura apply its bonus to the target? 290 | * @type {boolean} 291 | */ 292 | get isApplying() { 293 | return this.element?.tint === this.constructor.GREEN; 294 | } 295 | 296 | /* -------------------------------------------------- */ 297 | 298 | /** 299 | * Is this aura visible? 300 | * @type {boolean} 301 | */ 302 | get visible() { 303 | return this.token.object.visible && this.token.object.renderable; 304 | } 305 | 306 | /* -------------------------------------------------- */ 307 | 308 | /** 309 | * Initialize the aura. 310 | * @param {Token5e} target The target to test containment against. 311 | */ 312 | initialize(target) { 313 | this.target = target; 314 | this.refresh({fadeIn: true}); 315 | } 316 | 317 | /* -------------------------------------------------- */ 318 | 319 | /** 320 | * Refresh the drawn state of the container and the contained aura. 321 | * @param {object} [options] 322 | * @param {boolean} [options.fadeIn] Should the aura fade in? 323 | */ 324 | refresh({fadeIn = false} = {}) { 325 | // Create element. 326 | this.create(); 327 | 328 | // Create container if missing. 329 | this.draw(); 330 | 331 | // Color the element. 332 | this.colorize(); 333 | 334 | // Add element to container. 335 | if (!this.container) return; 336 | this.container.addChild(this.element); 337 | 338 | // Fade in the container. 339 | if (this.visible) { 340 | if (fadeIn) this.fadeIn(); 341 | else this.show(); 342 | } else this.hide(); 343 | } 344 | 345 | /* -------------------------------------------------- */ 346 | 347 | /** 348 | * Immediately hide this aura. 349 | */ 350 | hide() { 351 | if (!this.container) return; 352 | CanvasAnimation.terminateAnimation(this.name); 353 | this.container.alpha = 0; 354 | } 355 | 356 | /* -------------------------------------------------- */ 357 | 358 | /** 359 | * Immediately show this aura. 360 | */ 361 | show() { 362 | if (!this.container) return; 363 | CanvasAnimation.terminateAnimation(this.name); 364 | this.container.alpha = 1; 365 | } 366 | 367 | /* -------------------------------------------------- */ 368 | 369 | /** 370 | * Fade in the aura over a period of time. 371 | */ 372 | fadeIn() { 373 | if (!this.container || !this.showAuras) return; 374 | this.show(); 375 | CanvasAnimation.animate( 376 | [{attribute: "alpha", parent: this.container, to: 1, from: 0}], 377 | {name: this.name, duration: 200, easing: (x) => x * x} 378 | ); 379 | } 380 | 381 | /* -------------------------------------------------- */ 382 | 383 | /** 384 | * Create the inner pixi element and assign it. 385 | * @returns {PIXI.Graphics|null} 386 | */ 387 | create() { 388 | if (!this.isDrawable) return null; 389 | 390 | let radius = this.radius; 391 | if (this.padRadius) radius += canvas.grid.distance * Math.max(this.token.width, this.token.height) * 0.5; 392 | 393 | const center = this.token.object.center; 394 | const points = canvas.grid.getCircle(center, radius); 395 | 396 | let sweep = new PIXI.Polygon(points); 397 | for (const type of this.restrictions) { 398 | sweep = ClockwiseSweepPolygon.create(center, { 399 | includeDarkness: type === "sight", 400 | type: type, 401 | debug: false, 402 | useThreshold: type !== "move", 403 | boundaryShapes: [sweep] 404 | }); 405 | } 406 | 407 | if (this.element) this.element.destroy(); 408 | 409 | const g = new PIXI.Graphics(); 410 | g.lineStyle({width: 3, color: this.white, alpha: 0.75}); 411 | g.beginFill(0xFFFFFF, 0.03).drawPolygon(sweep).endFill(); 412 | 413 | this.element = g; 414 | 415 | return g; 416 | } 417 | 418 | /* -------------------------------------------------- */ 419 | 420 | /** 421 | * Create and assign a container if one is missing, 422 | * add the aura element to it, and add the container to the grid. 423 | * @returns {PIXI.Container|null} 424 | */ 425 | draw() { 426 | if (!this.element || !this.token.object) return null; 427 | 428 | if (!this.container) { 429 | const container = new PIXI.Container(); 430 | canvas.interface.grid.addChild(container); 431 | this.container = container; 432 | 433 | if (this.showAuras) this.show(); 434 | else this.hide(); 435 | } 436 | return this.container; 437 | } 438 | 439 | /* -------------------------------------------------- */ 440 | 441 | /** 442 | * Set the color of the aura to either white, red, or green. 443 | */ 444 | colorize() { 445 | if (!this.target) this.element.tint = this.white; 446 | else this.element.tint = this.contains(this.target) ? this.constructor.GREEN : this.constructor.RED; 447 | } 448 | 449 | /* -------------------------------------------------- */ 450 | 451 | /** 452 | * Does this aura contain a token within its bounds? 453 | * @param {Token5e} token A token placeable to test. 454 | * @returns {boolean} 455 | */ 456 | contains(token) { 457 | if (!this.element || !token) return false; 458 | 459 | const shape = token.shape; 460 | const [i, j, i1, j1] = canvas.grid.getOffsetRange(token.bounds); 461 | const delta = (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) ? canvas.dimensions.size : 1; 462 | const offset = (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) ? canvas.dimensions.size / 2 : 0; 463 | for (let x = i; x < i1; x += delta) { 464 | for (let y = j; y < j1; y += delta) { 465 | const point = canvas.grid.getCenterPoint({i: x + offset, j: y + offset}); 466 | const p = { 467 | x: point.x - token.document.x, 468 | y: point.y - token.document.y 469 | }; 470 | if (shape.contains(p.x, p.y) && this.element.containsPoint(point)) { 471 | return true; 472 | } 473 | } 474 | } 475 | return false; 476 | } 477 | 478 | /* -------------------------------------------------- */ 479 | 480 | /** 481 | * Destroy the aura and its container. 482 | * @param {object} [options] 483 | * @param {boolean} [options.fadeOut] Should the aura fade out or be destroyed immediately? 484 | * @param {number} [options.duration] Fade-out duration. 485 | */ 486 | destroy({fadeOut = true, duration = 500} = {}) { 487 | const remove = () => { 488 | this.container?.destroy(); 489 | delete this.auras[this.bonus.uuid]; 490 | }; 491 | 492 | if (this.container && fadeOut && this.showAuras && this.visible) { 493 | this.show(); 494 | CanvasAnimation.animate( 495 | [{attribute: "alpha", parent: this.container, to: 0, from: 1}], 496 | {name: this.name, duration, easing: (x) => x * x} 497 | ).then(() => remove()); 498 | } else remove(); 499 | } 500 | } 501 | -------------------------------------------------------------------------------- /scripts/applications/bonus-collector.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * A helper class that collects and then hangs onto the bonuses for one particular 3 | * roll. The bonuses are filtered here only with regards to: 4 | * - aura blockers, aura range, aura disposition 5 | * - the hidden state of tokens 6 | * - the hidden state of measured templates 7 | * - item exclusivity (babonus being item-only) 8 | * - item attunement/equipped state (isSuppressed) 9 | * - effects being unavailable 10 | */ 11 | export default class BonusCollector { 12 | constructor({activity, item, actor, type}) { 13 | this.activity = activity; 14 | this.item = item; 15 | this.actor = actor; 16 | this.type = type; 17 | 18 | // Set up canvas elements. 19 | this.token = this.actor.token?.object ?? this.actor.getActiveTokens()[0]; 20 | if (this.token) this.tokenCenters = this.constructor._collectTokenCenters(this.token); 21 | 22 | this.bonuses = this._collectBonuses(); 23 | } 24 | 25 | /* -------------------------------------------------- */ 26 | 27 | /** 28 | * The type of bonuses being collected. 29 | * @type {string} 30 | */ 31 | type = null; 32 | 33 | /* -------------------------------------------------- */ 34 | 35 | /** 36 | * Collected bonuses. 37 | * @type {Babonus[]} 38 | */ 39 | bonuses = []; 40 | 41 | /* -------------------------------------------------- */ 42 | 43 | /** 44 | * The activity being used. 45 | * @type {Activity|null} 46 | */ 47 | activity = null; 48 | 49 | /* -------------------------------------------------- */ 50 | 51 | /** 52 | * The item performing the roll, if any. 53 | * @type {Item5e|null} 54 | */ 55 | item = null; 56 | 57 | /* -------------------------------------------------- */ 58 | 59 | /** 60 | * The actor performing the roll or owning the item performing the roll. 61 | * @type {Actor5e} 62 | */ 63 | actor = null; 64 | 65 | /* -------------------------------------------------- */ 66 | 67 | /** 68 | * The token object of the actor performing the roll, if any. 69 | * @type {Token5e|null} 70 | */ 71 | token = null; 72 | 73 | /* -------------------------------------------------- */ 74 | 75 | /** 76 | * Center points of all occupied grid spaces of the token placeable. 77 | * @type {object[]} 78 | */ 79 | tokenCenters = []; 80 | 81 | /* -------------------------------------------------- */ 82 | 83 | /** 84 | * Token documents on the same scene which are valid, not a group, and not the same token. 85 | * @type {TokenDocument5e[]} 86 | */ 87 | tokens = []; 88 | 89 | /* -------------------------------------------------- */ 90 | 91 | /** 92 | * Reference to auras that are to be drawn later. 93 | * @type {Set} 94 | */ 95 | auras = new Set(); 96 | 97 | /* -------------------------------------------------- */ 98 | 99 | /** 100 | * A method that can be called at any point to retrieve the bonuses hung on to. 101 | * This returns a collection of uuids mapping to bonuses due to ids not necessarily changing. 102 | * @returns {Collection} The collection of bonuses. 103 | */ 104 | returnBonuses() { 105 | return new foundry.utils.Collection(this.bonuses.map(b => [b.uuid, b])); 106 | } 107 | 108 | /* -------------------------------------------------- */ 109 | 110 | /** 111 | * Main collection method that calls the below collectors for self, all tokens, and all templates. 112 | * This method also ensures that overlapping templates from one item do not apply twice. 113 | * @returns {Babonus[]} 114 | */ 115 | _collectBonuses() { 116 | const bonuses = { 117 | actor: this._collectFromSelf(), 118 | token: [], 119 | template: [], 120 | regions: [] 121 | }; 122 | 123 | // Token and template auras. 124 | if (this.token) { 125 | 126 | // Collect token auras. 127 | const _uuids = new Set(); 128 | for (const token of this.token.scene.tokens) { 129 | if (token.actor && (token.actor.type !== "group") && (token !== this.token.document) && !token.hidden) { 130 | if (_uuids.has(token.actor.uuid)) continue; 131 | bonuses.token.push(...this._collectFromToken(token)); 132 | _uuids.add(token.actor.uuid); 133 | } 134 | } 135 | 136 | // Special consideration for templates; allow overlapping without stacking the same bonus. 137 | const map = new Map(); 138 | for (const template of this.token.scene.templates) { 139 | const boni = this._collectFromTemplate(template); 140 | for (const b of boni) map.set(`${b.item.uuid}.Babonus.${b.id}`, b); 141 | } 142 | bonuses.template.push(...map.values()); 143 | 144 | // Collection from scene regions. 145 | for (const region of this.token.document.regions) { 146 | const collected = this._collectFromRegion(region); 147 | bonuses.regions.push(...collected); 148 | } 149 | } 150 | 151 | return bonuses.actor.concat(bonuses.token).concat(bonuses.template).concat(bonuses.regions); 152 | } 153 | 154 | /* -------------------------------------------------- */ 155 | 156 | /** 157 | * Destroy all auras that were created and drawn during this collection. 158 | */ 159 | destroyAuras() { 160 | for (const aura of this.auras) aura.destroy({fadeOut: true}); 161 | } 162 | 163 | /* -------------------------------------------------- */ 164 | 165 | /** 166 | * Get all bonuses that originate from yourself. 167 | * @returns {Babonus[]} The array of bonuses. 168 | */ 169 | _collectFromSelf() { 170 | 171 | // A filter for discarding blocked or suppressed auras, template auras, and auras that do not affect self. 172 | const validSelfAura = (bab) => { 173 | return !bab.aura.isTemplate && bab.aura.isAffectingSelf; 174 | }; 175 | 176 | const enchantments = []; 177 | if (this.item) { 178 | for (const effect of this.item.allApplicableEffects()) { 179 | if (effect.active) enchantments.push(...this._collectFromDocument(effect, [validSelfAura])); 180 | } 181 | } 182 | 183 | const actor = this._collectFromDocument(this.actor, [validSelfAura]); 184 | const items = this.actor.items.reduce((acc, item) => acc.concat(this._collectFromDocument(item, [validSelfAura])), []); 185 | const effects = this.actor.appliedEffects.reduce((acc, effect) => acc.concat(this._collectFromDocument(effect, [validSelfAura])), []); 186 | return [...enchantments, ...actor, ...items, ...effects]; 187 | } 188 | 189 | /* -------------------------------------------------- */ 190 | 191 | /** 192 | * Get all bonuses that originate from another token on the scene. 193 | * @param {TokenDocument5e} token The token. 194 | * @returns {Babonus[]} The array of aura bonuses that apply. 195 | */ 196 | _collectFromToken(token) { 197 | const bonuses = []; 198 | 199 | const checker = (object) => { 200 | const collection = babonus.getCollection(object); 201 | for (const bonus of collection) { 202 | if (this.type !== bonus.type) continue; // discard bonuses of the wrong type. 203 | if (!bonus.aura.isActiveTokenAura) continue; // discard blocked and suppressed auras. 204 | if (bonus.aura.isTemplate) continue; // discard template auras. 205 | if (!this._matchTokenDisposition(token, bonus)) continue; // discard invalid targeting bonuses. 206 | if (!this._generalFilter(bonus)) continue; 207 | 208 | // Skip creating pixi auras for infinite-range auras. 209 | if (bonus.aura.range === -1) { 210 | bonuses.push(bonus); 211 | } else { 212 | const aura = new babonus.abstract.applications.TokenAura(token, bonus); 213 | aura.initialize(this.token); 214 | if (aura.isApplying) bonuses.push(bonus); 215 | this.auras.add(aura); 216 | } 217 | } 218 | }; 219 | 220 | checker(token.actor); 221 | for (const item of token.actor.items) checker(item); 222 | for (const effect of token.actor.appliedEffects) checker(effect); 223 | 224 | return bonuses; 225 | } 226 | 227 | /* -------------------------------------------------- */ 228 | 229 | /** 230 | * Get all bonuses that originate from templates the rolling token is standing on. 231 | * @param {MeasuredTemplateDocument} template The template. 232 | * @returns {Babonus[]} The array of bonuses. 233 | */ 234 | _collectFromTemplate(template) { 235 | if (template.hidden) return []; 236 | if (!this._tokenWithinTemplate(template.object)) return []; 237 | 238 | // A filter for discarding template auras that are blocked or do not affect self (if they are your own). 239 | const templateAuraChecker = (bab) => { 240 | if (bab.aura.isBlocked) return false; 241 | const isOwn = this.token.actor === bab.actor; 242 | if (isOwn) return bab.aura.self; 243 | return this._matchTemplateDisposition(template, bab); 244 | }; 245 | 246 | const templates = this._collectFromDocument(template, [templateAuraChecker]); 247 | return templates; 248 | } 249 | 250 | /* -------------------------------------------------- */ 251 | 252 | /** 253 | * Collect bonuses from a scene region the token is standing in. 254 | * @param {RegionDocument} region The region. 255 | * @returns {Babonus[]} The array of bonuses. 256 | */ 257 | _collectFromRegion(region) { 258 | return this._collectFromDocument(region, []); 259 | } 260 | 261 | /* -------------------------------------------------- */ 262 | 263 | /** 264 | * General collection method that all other collection methods call in some fashion. 265 | * Gets an array of babonuses from that document. 266 | * @param {Document5e} document The token, actor, item, effect, or template. 267 | * @param {function[]} [filterings] An array of additional functions used to filter. 268 | * @returns {Babonus[]} An array of babonuses of the right type. 269 | */ 270 | _collectFromDocument(document, filterings = []) { 271 | const bonuses = babonus.getCollection(document).reduce((acc, bonus) => { 272 | if (this.type !== bonus.type) return acc; 273 | if (!this._generalFilter(bonus)) return acc; 274 | for (const fn of filterings) if (!fn(bonus)) return acc; 275 | acc.push(bonus); 276 | return acc; 277 | }, []); 278 | return bonuses; 279 | } 280 | 281 | /* -------------------------------------------------- */ 282 | 283 | /** 284 | * Some general filters that apply no matter where the babonus is located. 285 | * @param {Babonus} bonus A babonus to evaluate. 286 | * @returns {boolean} Whether it should immediately be discarded. 287 | */ 288 | _generalFilter(bonus) { 289 | if (!bonus.enabled) return false; 290 | if (bonus.isSuppressed) return false; 291 | 292 | // Filter for exclusivity. 293 | if (!bonus.isExclusive) return true; 294 | const item = bonus.item; 295 | return item ? (this.item?.uuid === item.uuid) : true; 296 | } 297 | 298 | /* -------------------------------------------------- */ 299 | 300 | /** 301 | * Get the centers of all grid spaces that overlap with a token document. 302 | * @param {Token5e} token The token document on the scene. 303 | * @returns {object[]} An array of xy coordinates. 304 | */ 305 | static _collectTokenCenters(token) { 306 | const points = []; 307 | const shape = token.shape; 308 | const [i, j, i1, j1] = canvas.grid.getOffsetRange(token.bounds); 309 | const delta = (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) ? canvas.dimensions.size : 1; 310 | const offset = (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) ? canvas.dimensions.size / 2 : 0; 311 | for (let x = i; x < i1; x += delta) { 312 | for (let y = j; y < j1; y += delta) { 313 | const point = canvas.grid.getCenterPoint({i: x + offset, j: y + offset}); 314 | const p = { 315 | x: point.x - token.document.x, 316 | y: point.y - token.document.y 317 | }; 318 | if (shape.contains(p.x, p.y)) points.push(point); 319 | } 320 | } 321 | return points; 322 | } 323 | 324 | /* -------------------------------------------------- */ 325 | 326 | /** 327 | * Get whether the rolling token has any grid center within a given template. 328 | * @param {MeasuredTemplate} template A measured template placeable. 329 | * @returns {boolean} Whether the rolling token is contained. 330 | */ 331 | _tokenWithinTemplate(template) { 332 | const {shape, x: tx, y: ty} = template; 333 | return this.tokenCenters.some(({x, y}) => shape.contains(x - tx, y - ty)); 334 | } 335 | 336 | /* -------------------------------------------------- */ 337 | 338 | /** 339 | * Get whether an aura can target the rolling actor's token depending on its targeting. 340 | * @param {TokenDocument5e} token The token on whom the aura was found. 341 | * @param {Babonus} bonus The babonus with the aura. 342 | * @returns {boolean} Whether the bonus can apply. 343 | */ 344 | _matchTokenDisposition(token, bonus) { 345 | const tisp = token.disposition; 346 | const bisp = bonus.aura.disposition; 347 | return this._matchDisposition(tisp, bisp); 348 | } 349 | 350 | /* -------------------------------------------------- */ 351 | 352 | /** 353 | * Get whether a template aura can target the contained token depending on its targeting. 354 | * @param {MeasuredTemplateDocument} template The containing template. 355 | * @param {Babonus} bonus The babonus with the aura. 356 | * @returns {boolean} Whether the bonus can apply. 357 | */ 358 | _matchTemplateDisposition(template, bonus) { 359 | const tisp = template.flags.babonus.templateDisposition; 360 | const bisp = bonus.aura.disposition; 361 | return this._matchDisposition(tisp, bisp); 362 | } 363 | 364 | /* -------------------------------------------------- */ 365 | 366 | /** 367 | * Given a disposition of a template/token and the targeting of of an aura, get whether the aura should apply. 368 | * @param {number} tisp Token or template disposition. 369 | * @param {number} bisp The targeting disposition of a babonus. 370 | * @returns {boolean} Whether the targeting applies. 371 | */ 372 | _matchDisposition(tisp, bisp) { 373 | if (bisp === 2) { // any 374 | // If the bonus targets everyone, immediately return true. 375 | return true; 376 | } else if (bisp === 1) { // allies 377 | // If the bonus targets allies, the roller and the source must match. 378 | return tisp === this.token.document.disposition; 379 | } else if (bisp === -1) { // enemies 380 | // If the bonus targets enemies, the roller and the source must have opposite dispositions. 381 | const modes = CONST.TOKEN_DISPOSITIONS; 382 | const set = new Set([tisp, this.token.document.disposition]); 383 | return set.has(modes.FRIENDLY) && set.has(modes.HOSTILE); 384 | } 385 | } 386 | } 387 | 388 | /* -------------------------------------------------- */ 389 | 390 | Hooks.on("refreshToken", function(token) { 391 | for (const aura of Object.values(babonus._currentAuras ?? {})) { 392 | if ((aura.target === token) || (aura.token === token.document)) aura.refresh(); 393 | } 394 | }); 395 | 396 | /* -------------------------------------------------- */ 397 | 398 | Hooks.on("deleteToken", function(tokenDoc) { 399 | for (const aura of Object.values(babonus._currentAuras ?? {})) { 400 | if (aura.token === tokenDoc) aura.destroy({fadeOut: false}); 401 | } 402 | }); 403 | 404 | /* -------------------------------------------------- */ 405 | 406 | Hooks.on("canvasTearDown", (canvas) => babonus._currentAuras = {}); 407 | --------------------------------------------------------------------------------