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;
+ }
+}