diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ae6547f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "tabWidth": 2, + "useTabs": false, + "singleQuote": true, + "trailingComma": "all", + "semi": true, + "printWidth": 120, + "bracketSpacing": true +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6bf9842 --- /dev/null +++ b/LICENSE @@ -0,0 +1,395 @@ +Attribution 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More_considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution 4.0 International International License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution 4.0 International Public License ("Public License"). To the +extent this Public License may be interpreted as a contract, You are +granted the Licensed Rights in consideration of Your acceptance of +these terms and conditions, and the Licensor grants You such rights in +consideration of benefits the Licensor receives from making the +Licensed Material available under these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/README.md b/README.md index b67476c..2dd77f9 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,19 @@ This was created as a way of creating the interactive FVTT Tutorial on [The Forg # Installation -You can install this module by using the following manifest URL : `https://raw.githubusercontent.com/League-of-Foundry-Developers/fvtt-module-trigger-happy/master/module.json` +## Installation -As GM go to the `Manage Modules` options menu in your World Settings tab then enable the `Trigger Happy` module. +It's always easiest to install modules from the in game add-on browser. + +To install this module manually: +1. Inside the Foundry "Configuration and Setup" screen, click "Add-on Modules" +2. Click "Install Module" +3. In the "Manifest URL" field, paste the following url: + +`https://raw.githubusercontent.com/League-of-Foundry-Developers/fvtt-module-trigger-happy/master/module.json` + +4. Click 'Install' and wait for installation to complete +5. Don't forget to enable the module in game using the "Manage Module" button # Video and Step by Step instructions @@ -121,57 +131,14 @@ Here's an example of how these trigger options can be used together : ``` +## [Changelog](./changelog.md) -# Changelog -## 0.8.8 -- Fix so that unlocking a door does not triggere the door close trigger. - -## 0.8.6 - - Added a config setting to disable/enable the trigger happy active/inactive button on the context menu. - -## 0.8.5 -- Foundry vtt 0.8 compatible. -- New config setting edge collision. If set tokens will be captured at the edge of a drawing/token rather than the center. -- fix the silly packaging error - -## 0.8.3 -- Foundry vtt 0.8 compatible. -- New config setting edge collision. If set tokens will be captured at the edge of a drawing/token rather than the center. - -## v0.7 - -- Add support for labelled drawings as triggers - -## v0.4.1 - -- Fix issue causing click triggers to fail for players not owning the trigger token - -## v0.4 - -- Add support for capture triggers (@tposney) -- Add support for API changes in FVTT 0.5.4 - -## v0.3 - -- Add support for triggers when moving a token over a trigger token (@tposney) -- Fix a couple of bugs with regards to journal entries and chat messages (@tposney) -- Add the ability to trigger tokens by clicking on them even if they are hidden from the player -- Add support for `@Trigger[options]` links with options for move, click, stopMovement, ooc, emote, whisper, preload -- Add support for having multiple journals and journals within subfolders -- Fix new line detection when journal entry is written in preformatted text or div mode -- Add support for sending chat messages using an alias - +## Issues -## v0.2 -- Add support for `@Actor[name]` links instead of only drag&dropped `@Actor[id]` links -- Add support for Token trigger -- Add support for sending chat messages as trigger effects (useful with advanced macros) -- Add support for setting a token as controlled as a trigger effect +Any issues, bugs, or feature requests are always welcome to be reported directly to the [Issue Tracker](https://github.com/League-of-Foundry-Developers/fvtt-module-trigger-happy/issues ), or using the [Bug Reporter Module](https://foundryvtt.com/packages/bug-reporter/). -## v0.1 -- Initial release with support for Actor and Scene triggers +## License -# License -This Foundry VTT module, writen by KaKaRoTo, is licensed under a [Creative Commons Attribution 4.0 International License](http://creativecommons.org/licenses/by/4.0/). +This Foundry VTT module, writen by KaKaRoTo, is licensed under a [Creative Commons Attribution 4.0 International License](http://creativecommons.org/licenses/by/4.0/) and the [Foundry Virtual Tabletop Limited License Agreement for module development](https://foundryvtt.com/article/license/). This work is licensed under Foundry Virtual Tabletop [EULA - Limited License Agreement for module development v 0.1.6](http://foundryvtt.com/pages/license.html). diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..34dea65 --- /dev/null +++ b/changelog.md @@ -0,0 +1,58 @@ +# Changelog + +## 0.8.9 [2021-09-15] + +- Add prettier +- Add internationalization +- Add more intuitive workflow for the hooks +- Integration of the ecmascript module mechanism +- Update README.md +- Allow tokens to be found by ID (from TheGiddyLimit fork) + +## 0.8.8 +- Fix so that unlocking a door does not triggere the door close trigger. + +## 0.8.6 + - Added a config setting to disable/enable the trigger happy active/inactive button on the context menu. + +## 0.8.5 +- Foundry vtt 0.8 compatible. +- New config setting edge collision. If set tokens will be captured at the edge of a drawing/token rather than the center. +- fix the silly packaging error + +## 0.8.3 +- Foundry vtt 0.8 compatible. +- New config setting edge collision. If set tokens will be captured at the edge of a drawing/token rather than the center. + +## v0.7 + +- Add support for labelled drawings as triggers + +## v0.4.1 + +- Fix issue causing click triggers to fail for players not owning the trigger token + +## v0.4 + +- Add support for capture triggers (@tposney) +- Add support for API changes in FVTT 0.5.4 + +## v0.3 + +- Add support for triggers when moving a token over a trigger token (@tposney) +- Fix a couple of bugs with regards to journal entries and chat messages (@tposney) +- Add the ability to trigger tokens by clicking on them even if they are hidden from the player +- Add support for `@Trigger[options]` links with options for move, click, stopMovement, ooc, emote, whisper, preload +- Add support for having multiple journals and journals within subfolders +- Fix new line detection when journal entry is written in preformatted text or div mode +- Add support for sending chat messages using an alias + + +## v0.2 +- Add support for `@Actor[name]` links instead of only drag&dropped `@Actor[id]` links +- Add support for Token trigger +- Add support for sending chat messages as trigger effects (useful with advanced macros) +- Add support for setting a token as controlled as a trigger effect + +## v0.1 +- Initial release with support for Actor and Scene triggers diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..a7d40cf --- /dev/null +++ b/lang/en.json @@ -0,0 +1,11 @@ +{ + "trigger-happy.settings.journalName.name": "Name of the Trigger Journal to use", + "trigger-happy.settings.journalName.hint": "The name of the journal entry to use for listing triggers. There can only be one. Refer to README file in module website for how to configure triggers.", + "trigger-happy.settings.enableTriggers.name": "Enable triggers when running as GM", + "trigger-happy.settings.enableTriggers.hint": " ", + "trigger-happy.settings.edgeCollision.name": "Capture at edge of drawing/token", + "trigger-happy.settings.edgeCollision.hint": " ", + "trigger-happy.settings.enableTriggerButton.name": "Add enable/disable trigger happy button", + "trigger-happy.settings.enableTriggerButton.hint": " ", + "trigger-happy.labels.button.layer.enableTriggerHappy": "Enable Trigger Happy triggers" +} diff --git a/module.json b/module.json index 35885df..d5b8653 100644 --- a/module.json +++ b/module.json @@ -2,29 +2,70 @@ "name": "trigger-happy", "title": "Trigger Happy", "description": "Automate everything in your world by creating triggers for your players to spring traps or anything you can think of!", - "version": "0.8.8", + "version": "0.8.9", "author": "KaKaRoTo, tposney", - "scripts": ["trigger.js"], + "type": "module", + "socket": true, + "includes": [ + "./assets/**", + "./lang/**", + "./scripts/**", + "./styles/**", + "./templates/**", + "./module.json", + "./README.md", + "./icons/**", + "./packs/**" + ], + "media": [ + { + "type": "icon", + "location": "" + }, + { + "type": "cover", + "location": "" + }, + { + "type": "screenshot", + "location": "" + } + ], + "languages": [ + { + "lang": "en", + "name": "English", + "path": "lang/en.json" + } + ], + "scripts": [], + "systems": [], + "esmodules": ["trigger.js"], "styles": [], "packs": [ - { - "name": "actors", - "label": "Trigger Actors", - "path": "packs/actors.db", - "entity": "Actor", - "package": "trigger-happy" - }, - { - "name": "journals", - "label": "Trigger Happy Examples", - "path": "packs/journal.db", - "entity": "JournalEntry", - "package": "trigger-happy" - } + { + "name": "trigger-happy-actors", + "label": "Trigger Actors", + "path": "packs/trigger-happy-actors.db", + "entity": "Actor", + "package": "trigger-happy" + }, + { + "name": "trigger-happy-journals", + "label": "Trigger Happy Examples", + "path": "packs/trigger-happy-journal.db", + "entity": "JournalEntry", + "package": "trigger-happy" + } ], "url": "https://github.com/League-of-Foundry-Developers/fvtt-module-trigger-happy", "manifest": "https://raw.githubusercontent.com/League-of-Foundry-Developers/fvtt-module-trigger-happy/master/module.json", - "download": "https://github.com/League-of-Foundry-Developers/fvtt-module-trigger-happy/archive/v0.8.8.zip", + "download": "https://github.com/League-of-Foundry-Developers/fvtt-module-trigger-happy/archive/v0.8.9.zip", + "changelog": "https://raw.githubusercontent.com/League-of-Foundry-Developers/fvtt-module-trigger-happy/master/changelog.md", + "bugs": "https://github.com/League-of-Foundry-Developers/fvtt-module-trigger-happy/issues", "minimumCoreVersion": "0.8.3", - "compatibleCoreVersion": "0.8.8" + "compatibleCoreVersion": "0.8.9", + "allowBugReporter": true, + "manifestPlusVersion": "1.2.0", + "dependencies": [] } diff --git a/packs/actors.db b/packs/trigger-happy-actors.db similarity index 100% rename from packs/actors.db rename to packs/trigger-happy-actors.db diff --git a/packs/journal.db b/packs/trigger-happy-journal.db similarity index 100% rename from packs/journal.db rename to packs/trigger-happy-journal.db diff --git a/trigger.js b/trigger.js index deafa99..659390a 100644 --- a/trigger.js +++ b/trigger.js @@ -1,447 +1,582 @@ -class TriggerHappy { - constructor() { - game.settings.register("trigger-happy", "journalName", { - name: "Name of the Trigger Journal to use", - hint: "The name of the journal entry to use for listing triggers. There can only be one. Refer to README file in module website for how to configure triggers.", - scope: "world", - config: true, - default: "Trigger Happy", - type: String, - onChange: this._parseJournals.bind(this) - }); - game.settings.register("trigger-happy", "enableTriggers", { - name: "Enable triggers when running as GM", - scope: "client", - config: false, - default: true, - type: Boolean, - onChange: this._parseJournals.bind(this) - }); - game.settings.register("trigger-happy", "edgeCollision", { - name: "Capture at edge of drawing/token", - scope: "world", - config: true, - default: false, - type: Boolean - }); - game.settings.register("trigger-happy", "enableTriggerButton", { - name: "Add enable/disable trigger happy button", - scope: "world", - config: true, - default: true, - type: Boolean, - onChange: () => { - if (!game.settings.get("trigger-happy", "enableTriggerButton")) - game.settings.set("trigger-happy", "enableTriggers", true) - } - }); - - Hooks.on("ready", this._parseJournals.bind(this)); - Hooks.on("canvasReady", this._onCanvasReady.bind(this)); - Hooks.on('controlToken', this._onControlToken.bind(this)); - Hooks.on('createJournalEntry', this._parseJournals.bind(this)); - Hooks.on('updateJournalEntry', this._parseJournals.bind(this)); - Hooks.on('deleteJournalEntry', this._parseJournals.bind(this)); - Hooks.on("preUpdateToken", this._onPreUpdateToken.bind(this)); - Hooks.on("preUpdateWall", this._onPreUpdateWall.bind(this)); - - this.triggers = []; - } - - get journalName() { - return game.settings.get("trigger-happy", "journalName") || "Trigger Happy"; - } - get journals() { - const folders = game.folders.contents.filter(f => f.type === "JournalEntry" && f.name === this.journalName); - const journals = game.journal.contents.filter(j => j.name === this.journalName); - // Make sure there are no duplicates (journal name is within a folder with the trigger name) - return Array.from(new Set(this._getFoldersContentsRecursive(folders, journals))); - } - - _getFoldersContentsRecursive(folders, contents) { - return folders.reduce((contents, folder) => { - // Cannot use folder.content and folder.children because they are set on populate and only show what the user can see - const content = game.journal.contents.filter(j => j.data.folder === folder.id) - const children = game.folders.contents.filter(f => f.type === "JournalEntry" && f.data.parent === folder.id) - contents.push(...content) - return this._getFoldersContentsRecursive(children, contents); - }, contents); - } - - _parseJournals() { - this.triggers = [] - if (game.user.isGM && !game.settings.get("trigger-happy", "enableTriggers")) - return; - this.journals.forEach(journal => this._parseJournal(journal)); - } - _parseJournal(journal) { - const triggerLines = journal.data.content.replace(/(

|

|
)/gm, '\n').replace(/ /gm, ' ').split("\n"); - for (const line of triggerLines) { - const entityLinks = CONST.ENTITY_LINK_TYPES.concat(["ChatMessage", "Token", "Trigger", "Drawing", "Door"]) - const entityMatchRgx = `@(${entityLinks.join("|")})\\[([^\\]]+)\\](?:{([^}]+)})?`; - const rgx = new RegExp(entityMatchRgx, 'g'); - let trigger = null; - let options = []; - const effects = [] - for (let match of line.matchAll(rgx)) { - const [string, entity, id, label] = match; - if (entity === "Trigger") { - options = id.split(" "); - continue; - } - if (!trigger && !["Actor", "Token", "Scene", "Drawing", "Door"].includes(entity)) break; - let effect = null; - if (entity === "ChatMessage") { - effect = new ChatMessage({ content: id, speaker: {alias: label} }, {}); - } else if (entity === "Token") { - effect = new TokenDocument({ name: id }, {}); - } else if (!trigger && entity === "Drawing") { - effect = new DrawingDocument({ type: "r", text: id }, {}); - } else if (!trigger && entity === "Door") { - const coords = id.split(",").map(c => Number(c)) - effect = new WallDocument({ door: 1, c: coords }, {}); - } else { - const config = CONFIG[entity]; - if (!config) continue; - effect = config.collection.instance.get(id) - if (!effect) - effect = config.collection.instance.getName(id); - } - if (!trigger && !effect) break; - if (!trigger) { - trigger = effect; - continue; - } - if (!effect) continue; - effects.push(effect) - } - if (trigger) - this.triggers.push({ trigger, effects, options }) - } - } - - async _executeTriggers(triggers) { - if (!triggers.length) return; - for (const trigger of triggers) { - for (let effect of trigger.effects) { - if (effect.documentName === "Scene") { - if (trigger.options.includes("preload")) - await game.scenes.preload(effect.id); - else { - const scene = game.scenes.get(effect.id); - await scene.view(); - } - } else if (effect instanceof Macro) { - await effect.execute(); - } else if (effect instanceof RollTable) { - await effect.draw(); - } else if (effect instanceof ChatMessage) { - const chatData = duplicate(effect.data) - if (trigger.options.includes("ooc")) { - chatData.type = CONST.CHAT_MESSAGE_TYPES.OOC; - } else if (trigger.options.includes("emote")) { - chatData.type = CONST.CHAT_MESSAGE_TYPES.EMOTE; - } else if (trigger.options.includes("whisper")) { - chatData.type = CONST.CHAT_MESSAGE_TYPES.WHISPER; - chatData.whisper = ChatMessage.getWhisperRecipients("GM"); - } else if (trigger.options.includes("selfWhisper")) { - chatData.type = CONST.CHAT_MESSAGE_TYPES.WHISPER; - chatData.whisper = [game.user.id]; - } - await ChatMessage.create(chatData); - } else if (effect instanceof TokenDocument) { - const token = canvas.tokens.placeables.find(t => t.name === effect.name) - if (token) - await token.control(); - } else { - await effect.sheet.render(true); - } - } - } - } - /** - * Checks if a token is causing a trigger to be activated - * @param {Token} token The token to test - * @param {Object} trigger The trigger to test against - * @param {String} type Type of trigger, can be 'click' or 'move' - */ - _isTokenTrigger(token, trigger, type) { - const isTrigger = ((trigger.trigger instanceof Actor && trigger.trigger.id === token.data.actorId) || - (trigger.trigger instanceof TokenDocument && trigger.trigger.data.name === token.data.name)); - if (!isTrigger) return false; - if (type === "click") - return trigger.options.includes('click') || (!trigger.options.includes('move') && !token.data.hidden); - if (type === "move") - return trigger.options.includes('move') || (!trigger.options.includes('click') && token.data.hidden); - if (type === "capture") - return trigger.options.includes("capture"); - return true; - } - _isDrawingTrigger(drawing, trigger, type) { - const isTrigger = (trigger.trigger instanceof DrawingDocument && trigger.trigger.data.text === drawing.data.text); - if (!isTrigger) return false; - if (type === "click") - return trigger.options.includes('click') || (!trigger.options.includes('move') && !drawing.data.hidden); - if (type === "move") - return trigger.options.includes('move') || (!trigger.options.includes('click') && drawing.data.hidden); - if (type === "capture") - return trigger.options.includes("capture"); - return true; - } - _isSceneTrigger(scene, trigger) { - return trigger.trigger instanceof Scene && trigger.trigger.id === scene.id; - } - - _placeableContains(placeable, position) { - // Tokens have getter (since width/height is in grid increments) but drawings use data.width/height directly - const w = placeable.w || placeable.data.width; - const h = placeable.h || placeable.data.height; - return Number.between(position.x, placeable.data.x, placeable.data.x + w) - && Number.between(position.y, placeable.data.y, placeable.data.y + h) - } - - _getPlaceablesAt(placeables, position) { - return placeables.filter(placeable => this._placeableContains(placeable, position)); - } - - // return all tokens which have a token trigger - _getTokensFromTriggers(tokens, triggers, type) { - return tokens.filter(token => triggers.some(trigger => this._isTokenTrigger(token, trigger, type))); - } - _getDrawingsFromTriggers(drawings, triggers, type) { - return drawings.filter(drawing => triggers.some(trigger => this._isDrawingTrigger(drawing, trigger, type))); - } - - // return all triggers for the set of tokens - _getTriggersFromTokens(triggers, tokens, type) { - return triggers.filter(trigger => tokens.some(token => this._isTokenTrigger(token, trigger, type))); - } - - _getTriggersFromDrawings(triggers, drawings, type) { - // Don't trigger on drawings while on the drawing layer. - if (canvas.activeLayer === canvas.drawings) return []; - return triggers.filter(trigger => drawings.some(drawing => this._isDrawingTrigger(drawing, trigger, type))); - } - _onCanvasReady(canvas) { - const triggers = this.triggers.filter(trigger => this._isSceneTrigger(canvas.scene, trigger)); - this._executeTriggers(triggers); - canvas.stage.on('mousedown', (ev) => this._onMouseDown(ev)) - } - - _getMousePosition(event) { - let transform = canvas.tokens.worldTransform; - return { - x: (event.data.global.x - transform.tx) / canvas.stage.scale.x, - y: (event.data.global.y - transform.ty) / canvas.stage.scale.y - }; - } - _onMouseDown(event) { - const position = this._getMousePosition(event); - const clickTokens = this._getPlaceablesAt(canvas.tokens.placeables, position); - const clickDrawings = this._getPlaceablesAt(canvas.drawings.placeables, position); - if (clickTokens.length === 0 && clickDrawings.length == 0) return; - const downTriggers = this._getTriggersFromTokens(this.triggers, clickTokens, 'click'); - downTriggers.push(...this._getTriggersFromDrawings(this.triggers, clickDrawings, 'click')); - if (downTriggers.length === 0) return; - canvas.stage.once('mouseup', (ev) => this._onMouseUp(ev, clickTokens, clickDrawings, downTriggers)); - } - - _onMouseUp(event, tokens, drawings, downTriggers) { - const position = this._getMousePosition(event); - const upTokens = this._getPlaceablesAt(tokens, position); - const upDrawings = this._getPlaceablesAt(drawings, position); - if (upTokens.length === 0 && upDrawings.length === 0) return; - const triggers = this._getTriggersFromTokens(this.triggers, upTokens, 'click'); - triggers.push(...this._getTriggersFromDrawings(this.triggers, upDrawings, 'click')); - this._executeTriggers(triggers); - } - - _onControlToken(token, controlled) { - if (!controlled) return; - const tokens = [token]; - const triggers = this._getTriggersFromTokens(this.triggers, tokens, 'click'); - if (triggers.length === 0) return; - token.once('click', (ev) => this._onMouseUp(ev, tokens, [], triggers)); - } - - _doMoveTriggers(tokenDocument, scene, update) { - const token = tokenDocument.object; - const position = { - x: (update.x || token.x) + token.data.width * scene.data.grid / 2, - y: (update.y || token.y) + token.data.height * scene.data.grid / 2 - }; - const movementTokens = canvas.tokens.placeables.filter(tok => tok.data._id !== token.id); - const tokens = this._getPlaceablesAt(movementTokens, position); - const drawings = this._getPlaceablesAt(canvas.drawings.placeables, position); - if (tokens.length === 0 && drawings.length === 0) return true; - const triggers = this._getTriggersFromTokens(this.triggers, tokens, 'move'); - triggers.push(...this._getTriggersFromDrawings(this.triggers, drawings, 'move')); - - if (triggers.length === 0) return true; - if (triggers.some(trigger => trigger.options.includes("stopMovement"))) { - this._executeTriggers(triggers); - return false; - } - Hooks.once('updateToken', () => this._executeTriggers(triggers)); - return true; - } - - _doCaptureTriggers(tokenDocument, scene, update) { - // Get all trigger tokens in scene - const token = tokenDocument.object; - let targets = this._getTokensFromTriggers(canvas.tokens.placeables, this.triggers, 'capture'); - targets.push(...this._getDrawingsFromTriggers(canvas.drawings.placeables, this.triggers, 'capture')); - if (targets.length === 0) return; - - const finalX = update.x || token.x; - const finalY = update.y || token.y; - // need to calculate this by hand since token is just token data - const tokenWidth = token.data.width * canvas.scene.data.grid / 2; - const tokenHeight = token.data.height * canvas.scene.data.grid / 2; - - const motion = new Ray({x: token.x + tokenWidth, y: token.y + tokenHeight}, {x: finalX + tokenWidth, y: finalY + tokenHeight}); - - // don't consider targets if the token's start position is inside the target - targets = targets.filter(target => !this._placeableContains(target, {x: token.x + tokenWidth, y: token.y + tokenHeight})); - - // sort targets by distance from the token's start position - targets.sort((a , b) => targets.sort((a, b) => Math.hypot(token.x - a.x, token.y - a.y) - Math.hypot(token.x - b.x, token.y - b.y))) - - for (let target of targets) { - const tx = target.data.x; - const ty = target.data.y; - const tw = target.w || target.data.width; - const th = target.h || target.data.height; - // test motion vs token diagonals - if (tw > canvas.grid.w && th > canvas.grid.w && tw * th > 4 * canvas.grid.w * canvas.grid.w) { - // big token so do boundary lines - var intersects = ( motion.intersectSegment([tx, ty, tx + tw, ty ]) - || motion.intersectSegment([tx + tw, ty, tx + tw, ty + th]) - || motion.intersectSegment([tx + tw, ty + th, tx, ty + th]) - || motion.intersectSegment([tx, ty + th, tx, ty ])) - } else { - // just check the diagonals - var intersects = (motion.intersectSegment([tx, ty, tx + tw, ty + th]) - || motion.intersectSegment([tx, ty + th, tx + tw, ty ])); - } - if (intersects) { - update.x = target.center.x - tokenWidth; - update.y = target.center.y - tokenHeight; - return true; - } - } - return true; - } - // Arguments match the new prototype of FVTT 0.8.x - _onPreUpdateToken(tokenDocument, update, options, userId) { - if (!tokenDocument.object?.scene?.isView) return true; - if (update.x === undefined && update.y === undefined) return true; - let stop; - if (game.settings.get("trigger-happy", "edgeCollision")) - stop = this._doCaptureTriggersEdge(tokenDocument, tokenDocument.object.scene, update); - else - stop = this._doCaptureTriggers(tokenDocument, tokenDocument.object.scene, update); - if (stop === false) return false; - return this._doMoveTriggers(tokenDocument, tokenDocument.object.scene, update); - } - _onPreUpdateWall(wallDocument, update, options, userId) { - // Only trigger on door state changes - if (wallDocument.data.door === 0 || update.ds === undefined) return; - const triggers = this.triggers.filter(trigger => { - if (!(trigger.trigger instanceof WallDocument)) return false; - if (wallDocument.data.c.toString() !== trigger.trigger.data.c.toString()) return false; - const onClose = trigger.options.includes("doorClose"); - const onOpen = !trigger.options.includes("doorClose") || trigger.options.includes("doorOpen"); - return (update.ds === 1 && onOpen) || (update.ds === 0 && onClose && wallDocument.data.ds === 1); - }); - this._executeTriggers(triggers); - } - - static getSceneControlButtons(buttons) { - let tokenButton = buttons.find(b => b.name == "token") - - if (tokenButton && game.settings.get("trigger-happy", "enableTriggerButton")) { - tokenButton.tools.push({ - name: "triggers", - title: "Enable Trigger Happy triggers", - icon: "fas fa-grin-squint-tears", - toggle: true, - active: game.settings.get("trigger-happy", "enableTriggers"), - visible: game.user.isGM, - onClick: (value) => game.settings.set("trigger-happy", "enableTriggers", value) - }); - } - } - _doCaptureTriggersEdge (tokenDocument, scene, update) { - const token = tokenDocument.object; - // Get all trigger tokens in scene - let targets = this._getTokensFromTriggers(canvas.tokens.placeables, this.triggers, 'capture'); - targets.push(...this._getDrawingsFromTriggers(canvas.drawings.placeables, this.triggers, 'capture')); - - if (!targets) return; - - const finalX = update.x || token.x; - const finalY = update.y || token.y; - // need to calculate this by hand since token is just token data - const tokenWidth = token.data.width * canvas.scene.data.grid / 2; - const tokenHeight = token.data.height * canvas.scene.data.grid / 2; - - const motion = new Ray({x: token.x + tokenWidth, y: token.y + tokenHeight}, {x: finalX + tokenWidth, y: finalY + tokenHeight}); - - // don't trigger on tokens that are already captured - targets = targets.filter(target => !this._placeableContains(target, {x: token.x + tokenWidth, y: token.y + tokenHeight})); - - // sort list by distance from start token position - targets.sort((a , b) => targets.sort((a, b) => Math.hypot(token.x - a.x, token.y - a.y) - Math.hypot(token.x - b.x, token.y - b.y))) - const gridSize = canvas.grid.size; - - for (let target of targets) { - const tx = target.x; - const ty = target.y; - const tw = target.w || target.data.width; - const th = target.h || target.data.height; - const tgw = Math.ceil(target.data.width / gridSize); // target token width in grid units - const tgh = Math.ceil(target.data.height / gridSize); // target token height in grid units - // test motion vs token diagonals - if (tgw > 1 && tgh > 1 && tgw * tgh > 4) { - // big token so do boundary lines - var intersects = ( motion.intersectSegment([tx, ty, tx + tw, ty ]) - || motion.intersectSegment([tx + tw, ty, tx + tw, ty + th]) - || motion.intersectSegment([tx + tw, ty + th, tx, ty + th]) - || motion.intersectSegment([tx, ty + th, tx, ty ])) - } else { - // just check the diagonals - var intersects = (motion.intersectSegment([tx, ty, tx + tw, ty + th]) - || motion.intersectSegment([tx, ty + th, tx + tw, ty])); - } - if (intersects) { - if (tgw === 1 && tgh === 1) { // simple case size 1 target, return straight away. - update.x = target.center.x - tokenWidth; - update.y = target.center.y - tokenHeight; - return true; - } - // Create a grid of the squares covered by the target token - let corners = Array(tgw).fill(Array(tgh).fill(0)).map((v,i) => v.map((_,j) => {return {x:target.data.x + i * gridSize, y: target.data.y + j * gridSize}})).flat(); - - // Find the closest square to the token start position that intersets the motion - const closest = corners.sort((a, b) => - Math.hypot(token.x + tokenWidth - (a.x + gridSize / 2), token.y + tokenHeight - (a.y + gridSize / 2)) - Math.hypot(token.x + tokenWidth - (b.x + gridSize / 2), token.y + tokenHeight - (b.y + gridSize / 2))); - for (let corner of closest) { - if (motion.intersectSegment([corner.x, corner.y, corner.x + gridSize, corner.y + gridSize]) - || motion.intersectSegment([corner.x, corner.y + gridSize, corner.x + gridSize, corner.y])) { - update.x = corner.x; - update.y = corner.y;; - return true; - } - }; - console.warn("Ttrigger Happy | Help me the universe is non-euclidean"); - } - } - return true; - } - -} - - -Hooks.on('setup', () => game.triggers = new TriggerHappy()) -Hooks.on('getSceneControlButtons', TriggerHappy.getSceneControlButtons) +export const TRIGGER_HAPPY_MODULE_NAME = 'trigger-happy'; + +export const log = (...args) => console.log(`${TRIGGER_HAPPY_MODULE_NAME} | `, ...args); +export const warn = (...args) => { + console.warn(`${TRIGGER_HAPPY_MODULE_NAME} | `, ...args); +}; +export const error = (...args) => console.error(`${TRIGGER_HAPPY_MODULE_NAME} | `, ...args); +export const timelog = (...args) => warn(`${TRIGGER_HAPPY_MODULE_NAME} | `, Date.now(), ...args); + +export const i18n = (key) => { + return game.i18n.localize(key); +}; +export const i18nFormat = (key, data = {}) => { + return game.i18n.format(key, data); +}; + +/* ------------------------------------ */ +/* Initialize module */ +/* ------------------------------------ */ +Hooks.once('init', async () => { + log(`Initializing ${TRIGGER_HAPPY_MODULE_NAME}`); + + // Register settings + + game.settings.register(TRIGGER_HAPPY_MODULE_NAME, 'journalName', { + name: i18n(`${TRIGGER_HAPPY_MODULE_NAME}.settings.journalName.name`), + hint: i18n(`${TRIGGER_HAPPY_MODULE_NAME}.settings.journalName.hint`), + scope: 'world', + config: true, + default: 'Trigger Happy', + type: String, + onChange: () => { + // replace with hook 'renderSettingsConfig' on TriggerHappy constructor + //this._parseJournals.bind(this); + }, + }); + game.settings.register(TRIGGER_HAPPY_MODULE_NAME, 'enableTriggers', { + name: i18n(`${TRIGGER_HAPPY_MODULE_NAME}.settings.enableTriggers.name`), + hint: i18n(`${TRIGGER_HAPPY_MODULE_NAME}.settings.enableTriggers.hint`), + scope: 'client', + config: false, + default: true, + type: Boolean, + onChange: () => { + // replace with hook 'renderSettingsConfig' on TriggerHappy constructor + // this._parseJournals.bind(this); + }, + }); + game.settings.register(TRIGGER_HAPPY_MODULE_NAME, 'edgeCollision', { + name: i18n(`${TRIGGER_HAPPY_MODULE_NAME}.settings.edgeCollision.name`), + hint: i18n(`${TRIGGER_HAPPY_MODULE_NAME}.settings.edgeCollision.hint`), + scope: 'world', + config: true, + default: false, + type: Boolean, + }); + game.settings.register(TRIGGER_HAPPY_MODULE_NAME, 'enableTriggerButton', { + name: i18n(`${TRIGGER_HAPPY_MODULE_NAME}.settings.enableTriggerButton.name`), + hint: i18n(`${TRIGGER_HAPPY_MODULE_NAME}.settings.enableTriggerButton.hint`), + scope: 'world', + config: true, + default: true, + type: Boolean, + onChange: () => { + if (!game.settings.get(TRIGGER_HAPPY_MODULE_NAME, 'enableTriggerButton')) { + game.settings.set(TRIGGER_HAPPY_MODULE_NAME, 'enableTriggers', true); + } + }, + }); +}); + +/* ------------------------------------ */ +/* Setup module */ +/* ------------------------------------ */ +Hooks.once('setup', function () { + game.triggers = new TriggerHappy(); +}); + +/* ------------------------------------ */ +/* When ready */ +/* ------------------------------------ */ +Hooks.once('ready', () => { + Hooks.on('getSceneControlButtons', TriggerHappy.getSceneControlButtons); +}); + +// Add any additional hooks if necessary + +export const TRIGGERS = { + OOC: `ooc`, + EMOTE: `emote`, + WHISPER: `whisper`, + SELF_WHISPER: `selfWhisper`, + PRELOAD: `preload`, + CLICK: `click`, + MOVE: `move`, + STOP_MOVEMENT: `stopMovement`, + CAPTURE: `capture`, + DOOR_CLOSE: `doorClose`, + DOOR_OPEN: `doorOpen`, +}; + +export const TRIGGER_ENTITY_TYPES = { + ACTOR: 'Actor', + TOKEN: 'Token', + SCENE: 'Scene', + DRAWING: 'Drawing', + DOOR: 'Door', +}; + +export const TRIGGER_ENTITY_LINK_TYPES = { + CHAT_MESSAGE: 'ChatMessage', + TOKEN: 'Token', + TRIGGER: 'Trigger', + DRAWING: 'Drawing', + DOOR: 'Door', +}; + +export class TriggerHappy { + constructor() { + Hooks.on('ready', this._parseJournals.bind(this)); + Hooks.on('canvasReady', this._onCanvasReady.bind(this)); + Hooks.on('controlToken', this._onControlToken.bind(this)); + Hooks.on('createJournalEntry', this._parseJournals.bind(this)); + Hooks.on('updateJournalEntry', this._parseJournals.bind(this)); + Hooks.on('deleteJournalEntry', this._parseJournals.bind(this)); + Hooks.on('preUpdateToken', this._onPreUpdateToken.bind(this)); + Hooks.on('preUpdateWall', this._onPreUpdateWall.bind(this)); + Hooks.on('renderSettingsConfig', this._parseJournals.bind(this)); + + this.triggers = []; + } + + get journalName() { + return game.settings.get(TRIGGER_HAPPY_MODULE_NAME, 'journalName') || 'Trigger Happy'; + } + get journals() { + const folders = game.folders.contents.filter((f) => f.type === 'JournalEntry' && f.name === this.journalName); + const journals = game.journal.contents.filter((j) => j.name === this.journalName); + // Make sure there are no duplicates (journal name is within a folder with the trigger name) + return Array.from(new Set(this._getFoldersContentsRecursive(folders, journals))); + } + + _getFoldersContentsRecursive(folders, contents) { + return folders.reduce((contents, folder) => { + // Cannot use folder.content and folder.children because they are set on populate and only show what the user can see + const content = game.journal.contents.filter((j) => j.data.folder === folder.id); + const children = game.folders.contents.filter((f) => f.type === 'JournalEntry' && f.data.parent === folder.id); + contents.push(...content); + return this._getFoldersContentsRecursive(children, contents); + }, contents); + } + + _parseJournals() { + this.triggers = []; + if (game.user.isGM && !game.settings.get(TRIGGER_HAPPY_MODULE_NAME, 'enableTriggers')) return; + this.journals.forEach((journal) => this._parseJournal(journal)); + } + _parseJournal(journal) { + const triggerLines = journal.data.content + .replace(/(

|

|
)/gm, '\n') + .replace(/ /gm, ' ') + .split('\n'); + for (const line of triggerLines) { + const entityLinks = CONST.ENTITY_LINK_TYPES.concat([ + TRIGGER_ENTITY_LINK_TYPES.CHAT_MESSAGE, + TRIGGER_ENTITY_LINK_TYPES.TOKEN, + TRIGGER_ENTITY_LINK_TYPES.TRIGGER, + TRIGGER_ENTITY_LINK_TYPES.DRAWING, + TRIGGER_ENTITY_LINK_TYPES.DOOR, + ]); + const entityMatchRgx = `@(${entityLinks.join('|')})\\[([^\\]]+)\\](?:{([^}]+)})?`; + const rgx = new RegExp(entityMatchRgx, 'g'); + let trigger = null; + let options = []; + const effects = []; + for (let match of line.matchAll(rgx)) { + const [string, entity, id, label] = match; + if (entity === TRIGGER_ENTITY_LINK_TYPES.TRIGGER) { + options = id.split(' '); + continue; + } + if ( + !trigger && + ![ + TRIGGER_ENTITY_TYPES.ACTOR, + TRIGGER_ENTITY_TYPES.TOKEN, + TRIGGER_ENTITY_TYPES.SCENE, + TRIGGER_ENTITY_TYPES.DRAWING, + TRIGGER_ENTITY_TYPES.DOOR, + ].includes(entity) + ) + break; + let effect = null; + if (entity === TRIGGER_ENTITY_LINK_TYPES.CHAT_MESSAGE) { + effect = new ChatMessage({ content: id, speaker: { alias: label } }, {}); + } else if (entity === TRIGGER_ENTITY_LINK_TYPES.TOKEN) { + effect = new TokenDocument({ name: id }, {}); + } else if (!trigger && entity === TRIGGER_ENTITY_LINK_TYPES.DRAWING) { + effect = new DrawingDocument({ type: 'r', text: id }, {}); + } else if (!trigger && entity === TRIGGER_ENTITY_LINK_TYPES.DOOR) { + const coords = id.split(',').map((c) => Number(c)); + effect = new WallDocument({ door: 1, c: coords }, {}); + } else { + const config = CONFIG[entity]; + if (!config) continue; + effect = config.collection.instance.get(id); + if (!effect) effect = config.collection.instance.getName(id); + } + if (!trigger && !effect) break; + if (!trigger) { + trigger = effect; + continue; + } + if (!effect) continue; + effects.push(effect); + } + if (trigger) this.triggers.push({ trigger, effects, options }); + } + } + + async _executeTriggers(triggers) { + if (!triggers.length) return; + for (const trigger of triggers) { + for (let effect of trigger.effects) { + if (effect.documentName === 'Scene') { + if (trigger.options.includes(TRIGGERS.PRELOAD)) await game.scenes.preload(effect.id); + else { + const scene = game.scenes.get(effect.id); + await scene.view(); + } + } else if (effect instanceof Macro) { + await effect.execute(); + } else if (effect instanceof RollTable) { + await effect.draw(); + } else if (effect instanceof ChatMessage) { + const chatData = duplicate(effect.data); + if (trigger.options.includes(TRIGGERS.OOC)) { + chatData.type = CONST.CHAT_MESSAGE_TYPES.OOC; + } else if (trigger.options.includes(TRIGGERS.EMOTE)) { + chatData.type = CONST.CHAT_MESSAGE_TYPES.EMOTE; + } else if (trigger.options.includes(TRIGGERS.WHISPER)) { + chatData.type = CONST.CHAT_MESSAGE_TYPES.WHISPER; + chatData.whisper = ChatMessage.getWhisperRecipients('GM'); + } else if (trigger.options.includes(TRIGGERS.SELF_WHISPER)) { + chatData.type = CONST.CHAT_MESSAGE_TYPES.WHISPER; + chatData.whisper = [game.user.id]; + } + await ChatMessage.create(chatData); + } else if (effect instanceof TokenDocument) { + const token = canvas.tokens.placeables.find((t) => t.name === effect.name || t.id === effect.id); + if (token) await token.control(); + } else { + await effect.sheet.render(true); + } + } + } + } + /** + * Checks if a token is causing a trigger to be activated + * @param {Token} token The token to test + * @param {Object} trigger The trigger to test against + * @param {String} type Type of trigger, can be 'click' or 'move' + */ + _isTokenTrigger(token, trigger, type) { + const isTrigger = + (trigger.trigger instanceof Actor && trigger.trigger.id === token.data.actorId) || + (trigger.trigger instanceof TokenDocument && + (trigger.trigger.data.name === token.data.name || + trigger.trigger.data.id === token.id || + trigger.trigger.data.name === token.id)); + if (!isTrigger) return false; + if (type === TRIGGERS.CLICK) + return ( + trigger.options.includes(TRIGGERS.CLICK) || (!trigger.options.includes(TRIGGERS.MOVE) && !token.data.hidden) + ); + if (type === TRIGGERS.MOVE) + return ( + trigger.options.includes(TRIGGERS.MOVE) || (!trigger.options.includes(TRIGGERS.CLICK) && token.data.hidden) + ); + if (type === TRIGGERS.CAPTURE) return trigger.options.includes(TRIGGERS.CAPTURE); + return true; + } + _isDrawingTrigger(drawing, trigger, type) { + const isTrigger = trigger.trigger instanceof DrawingDocument && trigger.trigger.data.text === drawing.data.text; + if (!isTrigger) return false; + if (type === TRIGGERS.CLICK) + return ( + trigger.options.includes(TRIGGERS.CLICK) || (!trigger.options.includes(TRIGGERS.MOVE) && !drawing.data.hidden) + ); + if (type === TRIGGERS.MOVE) + return ( + trigger.options.includes(TRIGGERS.MOVE) || (!trigger.options.includes(TRIGGERS.CLICK) && drawing.data.hidden) + ); + if (type === TRIGGERS.CAPTURE) return trigger.options.includes(TRIGGERS.CAPTURE); + return true; + } + _isSceneTrigger(scene, trigger) { + return trigger.trigger instanceof Scene && trigger.trigger.id === scene.id; + } + + _placeableContains(placeable, position) { + // Tokens have getter (since width/height is in grid increments) but drawings use data.width/height directly + const w = placeable.w || placeable.data.width; + const h = placeable.h || placeable.data.height; + return ( + Number.between(position.x, placeable.data.x, placeable.data.x + w) && + Number.between(position.y, placeable.data.y, placeable.data.y + h) + ); + } + + _getPlaceablesAt(placeables, position) { + return placeables.filter((placeable) => this._placeableContains(placeable, position)); + } + + // return all tokens which have a token trigger + _getTokensFromTriggers(tokens, triggers, type) { + return tokens.filter((token) => triggers.some((trigger) => this._isTokenTrigger(token, trigger, type))); + } + _getDrawingsFromTriggers(drawings, triggers, type) { + return drawings.filter((drawing) => triggers.some((trigger) => this._isDrawingTrigger(drawing, trigger, type))); + } + + // return all triggers for the set of tokens + _getTriggersFromTokens(triggers, tokens, type) { + return triggers.filter((trigger) => tokens.some((token) => this._isTokenTrigger(token, trigger, type))); + } + + _getTriggersFromDrawings(triggers, drawings, type) { + // Don't trigger on drawings while on the drawing layer. + if (canvas.activeLayer === canvas.drawings) return []; + return triggers.filter((trigger) => drawings.some((drawing) => this._isDrawingTrigger(drawing, trigger, type))); + } + _onCanvasReady(canvas) { + const triggers = this.triggers.filter((trigger) => this._isSceneTrigger(canvas.scene, trigger)); + this._executeTriggers(triggers); + canvas.stage.on('mousedown', (ev) => this._onMouseDown(ev)); + } + + _getMousePosition(event) { + let transform = canvas.tokens.worldTransform; + return { + x: (event.data.global.x - transform.tx) / canvas.stage.scale.x, + y: (event.data.global.y - transform.ty) / canvas.stage.scale.y, + }; + } + _onMouseDown(event) { + const position = this._getMousePosition(event); + const clickTokens = this._getPlaceablesAt(canvas.tokens.placeables, position); + const clickDrawings = this._getPlaceablesAt(canvas.drawings.placeables, position); + if (clickTokens.length === 0 && clickDrawings.length == 0) return; + const downTriggers = this._getTriggersFromTokens(this.triggers, clickTokens, TRIGGERS.CLICK); + downTriggers.push(...this._getTriggersFromDrawings(this.triggers, clickDrawings, TRIGGERS.CLICK)); + if (downTriggers.length === 0) return; + canvas.stage.once('mouseup', (ev) => this._onMouseUp(ev, clickTokens, clickDrawings, downTriggers)); + } + + _onMouseUp(event, tokens, drawings, downTriggers) { + const position = this._getMousePosition(event); + const upTokens = this._getPlaceablesAt(tokens, position); + const upDrawings = this._getPlaceablesAt(drawings, position); + if (upTokens.length === 0 && upDrawings.length === 0) return; + const triggers = this._getTriggersFromTokens(this.triggers, upTokens, TRIGGERS.CLICK); + triggers.push(...this._getTriggersFromDrawings(this.triggers, upDrawings, TRIGGERS.CLICK)); + this._executeTriggers(triggers); + } + + _onControlToken(token, controlled) { + if (!controlled) return; + const tokens = [token]; + const triggers = this._getTriggersFromTokens(this.triggers, tokens, TRIGGERS.CLICK); + if (triggers.length === 0) return; + token.once('click', (ev) => this._onMouseUp(ev, tokens, [], triggers)); + } + + _doMoveTriggers(tokenDocument, scene, update) { + const token = tokenDocument.object; + const position = { + x: (update.x || token.x) + (token.data.width * scene.data.grid) / 2, + y: (update.y || token.y) + (token.data.height * scene.data.grid) / 2, + }; + const movementTokens = canvas.tokens.placeables.filter((tok) => tok.data._id !== token.id); + const tokens = this._getPlaceablesAt(movementTokens, position); + const drawings = this._getPlaceablesAt(canvas.drawings.placeables, position); + if (tokens.length === 0 && drawings.length === 0) return true; + const triggers = this._getTriggersFromTokens(this.triggers, tokens, TRIGGERS.MOVE); + triggers.push(...this._getTriggersFromDrawings(this.triggers, drawings, TRIGGERS.MOVE)); + + if (triggers.length === 0) return true; + if (triggers.some((trigger) => trigger.options.includes(TRIGGERS.STOP_MOVEMENT))) { + this._executeTriggers(triggers); + return false; + } + Hooks.once('updateToken', () => this._executeTriggers(triggers)); + return true; + } + + _doCaptureTriggers(tokenDocument, scene, update) { + // Get all trigger tokens in scene + const token = tokenDocument.object; + let targets = this._getTokensFromTriggers(canvas.tokens.placeables, this.triggers, TRIGGERS.CAPTURE); + targets.push(...this._getDrawingsFromTriggers(canvas.drawings.placeables, this.triggers, TRIGGERS.CAPTURE)); + if (targets.length === 0) return; + + const finalX = update.x || token.x; + const finalY = update.y || token.y; + // need to calculate this by hand since token is just token data + const tokenWidth = (token.data.width * canvas.scene.data.grid) / 2; + const tokenHeight = (token.data.height * canvas.scene.data.grid) / 2; + + const motion = new Ray( + { x: token.x + tokenWidth, y: token.y + tokenHeight }, + { x: finalX + tokenWidth, y: finalY + tokenHeight }, + ); + + // don't consider targets if the token's start position is inside the target + targets = targets.filter( + (target) => !this._placeableContains(target, { x: token.x + tokenWidth, y: token.y + tokenHeight }), + ); + + // sort targets by distance from the token's start position + targets.sort((a, b) => + targets.sort((a, b) => Math.hypot(token.x - a.x, token.y - a.y) - Math.hypot(token.x - b.x, token.y - b.y)), + ); + + for (let target of targets) { + const tx = target.data.x; + const ty = target.data.y; + const tw = target.w || target.data.width; + const th = target.h || target.data.height; + + let intersects; + // test motion vs token diagonals + if (tw > canvas.grid.w && th > canvas.grid.w && tw * th > 4 * canvas.grid.w * canvas.grid.w) { + // big token so do boundary lines + intersects = + motion.intersectSegment([tx, ty, tx + tw, ty]) || + motion.intersectSegment([tx + tw, ty, tx + tw, ty + th]) || + motion.intersectSegment([tx + tw, ty + th, tx, ty + th]) || + motion.intersectSegment([tx, ty + th, tx, ty]); + } else { + // just check the diagonals + intersects = + motion.intersectSegment([tx, ty, tx + tw, ty + th]) || motion.intersectSegment([tx, ty + th, tx + tw, ty]); + } + if (intersects) { + update.x = target.center.x - tokenWidth; + update.y = target.center.y - tokenHeight; + return true; + } + } + return true; + } + // Arguments match the new prototype of FVTT 0.8.x + _onPreUpdateToken(tokenDocument, update, options, userId) { + if (!tokenDocument.object?.scene?.isView) return true; + if (update.x === undefined && update.y === undefined) return true; + let stop; + if (game.settings.get(TRIGGER_HAPPY_MODULE_NAME, 'edgeCollision')) + stop = this._doCaptureTriggersEdge(tokenDocument, tokenDocument.object.scene, update); + else stop = this._doCaptureTriggers(tokenDocument, tokenDocument.object.scene, update); + if (stop === false) return false; + return this._doMoveTriggers(tokenDocument, tokenDocument.object.scene, update); + } + _onPreUpdateWall(wallDocument, update, options, userId) { + // Only trigger on door state changes + if (wallDocument.data.door === 0 || update.ds === undefined) return; + const triggers = this.triggers.filter((trigger) => { + if (!(trigger.trigger instanceof WallDocument)) return false; + if (wallDocument.data.c.toString() !== trigger.trigger.data.c.toString()) return false; + const onClose = trigger.options.includes(TRIGGERS.DOOR_CLOSE); + const onOpen = !trigger.options.includes(TRIGGERS.DOOR_CLOSE) || trigger.options.includes(TRIGGERS.DOOR_OPEN); + return (update.ds === 1 && onOpen) || (update.ds === 0 && onClose && wallDocument.data.ds === 1); + }); + this._executeTriggers(triggers); + } + + static getSceneControlButtons(buttons) { + let tokenButton = buttons.find((b) => b.name == 'token'); + + if (tokenButton && game.settings.get(TRIGGER_HAPPY_MODULE_NAME, 'enableTriggerButton')) { + tokenButton.tools.push({ + name: 'triggers', + title: i18n(`${TRIGGER_HAPPY_MODULE_NAME}.labels.button.layer.enableTriggerHappy`), + icon: 'fas fa-grin-squint-tears', + toggle: true, + active: game.settings.get(TRIGGER_HAPPY_MODULE_NAME, 'enableTriggers'), + visible: game.user.isGM, + onClick: (value) => game.settings.set(TRIGGER_HAPPY_MODULE_NAME, 'enableTriggers', value), + }); + } + } + _doCaptureTriggersEdge(tokenDocument, scene, update) { + const token = tokenDocument.object; + // Get all trigger tokens in scene + let targets = this._getTokensFromTriggers(canvas.tokens.placeables, this.triggers, TRIGGERS.CAPTURE); + targets.push(...this._getDrawingsFromTriggers(canvas.drawings.placeables, this.triggers, TRIGGERS.CAPTURE)); + + if (!targets) return; + + const finalX = update.x || token.x; + const finalY = update.y || token.y; + // need to calculate this by hand since token is just token data + const tokenWidth = (token.data.width * canvas.scene.data.grid) / 2; + const tokenHeight = (token.data.height * canvas.scene.data.grid) / 2; + + const motion = new Ray( + { x: token.x + tokenWidth, y: token.y + tokenHeight }, + { x: finalX + tokenWidth, y: finalY + tokenHeight }, + ); + + // don't trigger on tokens that are already captured + targets = targets.filter( + (target) => !this._placeableContains(target, { x: token.x + tokenWidth, y: token.y + tokenHeight }), + ); + + // sort list by distance from start token position + targets.sort((a, b) => + targets.sort((a, b) => Math.hypot(token.x - a.x, token.y - a.y) - Math.hypot(token.x - b.x, token.y - b.y)), + ); + const gridSize = canvas.grid.size; + + for (let target of targets) { + const tx = target.x; + const ty = target.y; + const tw = target.w || target.data.width; + const th = target.h || target.data.height; + const tgw = Math.ceil(target.data.width / gridSize); // target token width in grid units + const tgh = Math.ceil(target.data.height / gridSize); // target token height in grid units + + let intersects; + // test motion vs token diagonals + if (tgw > 1 && tgh > 1 && tgw * tgh > 4) { + // big token so do boundary lines + intersects = + motion.intersectSegment([tx, ty, tx + tw, ty]) || + motion.intersectSegment([tx + tw, ty, tx + tw, ty + th]) || + motion.intersectSegment([tx + tw, ty + th, tx, ty + th]) || + motion.intersectSegment([tx, ty + th, tx, ty]); + } else { + // just check the diagonals + intersects = + motion.intersectSegment([tx, ty, tx + tw, ty + th]) || motion.intersectSegment([tx, ty + th, tx + tw, ty]); + } + if (intersects) { + if (tgw === 1 && tgh === 1) { + // simple case size 1 target, return straight away. + update.x = target.center.x - tokenWidth; + update.y = target.center.y - tokenHeight; + return true; + } + // Create a grid of the squares covered by the target token + let corners = Array(tgw) + .fill(Array(tgh).fill(0)) + .map((v, i) => + v.map((_, j) => { + return { x: target.data.x + i * gridSize, y: target.data.y + j * gridSize }; + }), + ) + .flat(); + + // Find the closest square to the token start position that intersets the motion + const closest = corners.sort( + (a, b) => + Math.hypot(token.x + tokenWidth - (a.x + gridSize / 2), token.y + tokenHeight - (a.y + gridSize / 2)) - + Math.hypot(token.x + tokenWidth - (b.x + gridSize / 2), token.y + tokenHeight - (b.y + gridSize / 2)), + ); + for (let corner of closest) { + if ( + motion.intersectSegment([corner.x, corner.y, corner.x + gridSize, corner.y + gridSize]) || + motion.intersectSegment([corner.x, corner.y + gridSize, corner.x + gridSize, corner.y]) + ) { + update.x = corner.x; + update.y = corner.y; + return true; + } + } + warn('Help me the universe is non-euclidean'); + } + } + return true; + } +}