diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c1aec9c..e4faba0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,45 +1,145 @@ -name: Release Creation +# GitHub Actions workflow for creating a new FoundryVTT module release. +# +# Useful References: +# - https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions +# - https://docs.github.com/en/actions/learn-github-actions/contexts +# - https://docs.github.com/en/actions/learn-github-actions/environment-variables +# +# Troubleshooting Checklist: +# - Is the module's manifest file valid JSON? +# You can test your manifest file using https://jsonlint.com/. +# +# - Does the module's manifest have all the required keys? +# See https://foundryvtt.com/article/module-development/#manifest for more +# information. +# +# - Are all the proper files and directories being included in the release's +# module archive ("module.zip")? +# Check that the correct files are being passed to the `zip` command run +# in the "Create Module Archive" step below. +# +# - Is the release tag the proper format? +# See the comments for the "Extract Version From Tag" step below. +# +# - Is a GitHub release being published? +# This workflow will only run when a release is published, not when a +# release is updated. Furthermore, note that while a GitHub release will +# (by default) create a repository tag, a repository tag will not create +# or publish a GitHub release. +# +# - Has the module's entry on FoundryVTT's module administration site +# (https://foundryvtt.com/admin) been updated? +# +name: Create Module Files For GitHub Release -on: + +env: + # The URL used for the module's "Project URL" link on FoundryVTT's website. + project_url: "https://github.com/${{github.repository}}" + + # A URL that will always point to the latest manifest. + # FoundryVTT uses this URL to check whether the current module version that + # is installed is the latest version. This URL should NOT change, + # otherwise FoundryVTT won't be able to perform this check. + latest_manifest_url: "https://github.com/${{github.repository}}/releases/latest/download/module.json" + + # The URL to the module archive associated with the module release being + # processed by this workflow. + release_module_url: "https://github.com/${{github.repository}}/releases/download/${{github.event.release.tag_name}}/module.zip" + + +on: + # Only run this workflow when a release is published. + # To modify this workflow when other events occur, see: + # - https://docs.github.com/en/actions/using-workflows/triggering-a-workflow + # - https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows + # - https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on + # + # Note that some steps may depend on context variables that are only + # available for release events, so if you add other events, you may need to + # alter other parts of this workflow. release: types: [published] + jobs: build: runs-on: ubuntu-latest + permissions: + contents: write + steps: - - uses: actions/checkout@v3 - with: - submodules: 'true' - - # get part of the tag after the `v` - - id: get_version - run: | - echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV - echo "FOUNDRY_MANIFEST=https://github.com/${{github.repository}}/releases/latest/download/module.json" >> $GITHUB_ENV - echo "FOUNDRY_DOWNLOAD=https://github.com/${{github.repository}}/releases/download/${{github.event.release.tag_name}}/module.zip" >> $GITHUB_ENV - - - # Substitute the Manifest and Download URLs in the module.json - - name: Substitute Manifest and Download Links For Versioned Ones - id: sub_manifest_link_version - uses: restackio/update-json-file-action@v2.0 - with: - file: 'module.json' - fields: "{\"version\": \"${{env.VERSION}}\", \"manifest\": \"${{env.FOUNDRY_MANIFEST}}\", \"download\": \"${{env.FOUNDRY_DOWNLOAD}}\"}" - - # Create a zip file with all files required by the module to add to the release - - run: zip -r ./module.zip module.json LICENSE styles/ scripts/ templates/ languages/ packs/ - - # Create a release for this specific version - - name: Update Release with Files - id: create_version_release - uses: ncipollo/release-action@v1 - with: - allowUpdates: true # Set this to false if you want to prevent updating existing releases - name: ${{ github.event.release.name }} - draft: false - token: ${{ secrets.GITHUB_TOKEN }} - artifacts: './module.json, ./module.zip' - tag: ${{ github.event.release.tag_name }} - body: ${{ github.event.release.body }} + - name: Checkout Repository + uses: actions/checkout@v3 + with: + submodules: 'true' + + # Extract version embedded in the tag. + # This step expects the tag to be one of the following formats: + # - "v.." (e.g., "v1.2.3") + # - ".." (e.g., "1.2.3") + # + # The version will be used by later steps to fill in the value for the + # "version" key required for a valid module manifest. + - name: Extract Version From Tag + id: get_version + uses: battila7/get-version-action@v2 + + + # Modify "module.json" with values specific to the release. + # Since the values for the "version" and "url" keys aren't known ahead of + # time, the manifest file in the repository is updated with these values. + # + # While this does modify the manifest file in-place, the changes are not + # commited to the repository, and only exist in the action's filesystem. + - name: Modify Module Manifest With Release-Specific Values + id: sub_manifest_link_version + uses: cschleiden/replace-tokens@v1 + with: + files: 'module.json' + env: + VERSION: ${{steps.get_version.outputs.version-without-v}} + URL: ${{ env.project_url }} + MANIFEST: ${{ env.latest_manifest_url }} + DOWNLOAD: ${{ env.release_module_url }} + + + # Create a "module.zip" archive containing all the module's required files. + # If you have other directories or files that will need to be added to + # your packaged module, add them here. + - name: Create Module Archive + run: | + # Note that `zip` will only emit warnings when a file or directory + # doesn't exist, it will not fail. + zip \ + `# Options` \ + --recurse-paths \ + `# The name of the output file` \ + ./module.zip \ + `# The files that will be included.` \ + module.json \ + README.md \ + LICENSE \ + templates/ \ + scripts/ \ + styles/ \ + packs/ \ + languages/ \ + assets/ + # Don't forget to add a backslash at the end of the line for any + # additional files or directories! + + + # Update the GitHub release with the manifest and module archive files. + - name: Update Release With Files + id: create_version_release + uses: ncipollo/release-action@v1 + with: + allowUpdates: true + name: ${{ github.event.release.name }} + draft: ${{ github.event.release.unpublished }} + prerelease: ${{ github.event.release.prerelease }} + token: ${{ secrets.GITHUB_TOKEN }} + artifacts: './module.json, ./module.zip' + tag: ${{ github.event.release.tag_name }} + body: ${{ github.event.release.body }} \ No newline at end of file diff --git a/Changelog.md b/Changelog.md index ee523fa..3bc4aaf 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,17 @@ +## 0.1.0 + +### New Features +Users can add a terrain to a tile. Optionally set a transparency threshold for the tile, to have transparent pixels be considered to not have the terrain. +Users can add a terrain to a measured template. +Users can set an elevation to the tile or template, which can affect when the terrain is active. + +### Bug fixes +Popout the terrain effect configuration when a new terrain is created. See issue #15. +Update terrain color when the configuration setting is updated. Closes issue #10. +Fix hexagon fill. Closes issue #14. Note that the hexagon fills are slightly blocky, reflecting the lower resolution of the terrain fill (for speed). +Refactor settings and patching classes. +Update lib geometry to 0.2.12. + ## 0.0.6 Move the terrain import/replace/export buttons to the Terrain List. Add add/remove terrain buttons to the Terrain List. diff --git a/languages/en.json b/languages/en.json index 68fa882..6c666d8 100644 --- a/languages/en.json +++ b/languages/en.json @@ -39,6 +39,9 @@ "terrainmapper.list-config.replace": "Replace all terrains", "terrainmapper.list-config.export": "Export all terrains", + "terrainmapper.tile-config.transparency-threshold.name": "Inner Transparency Threshold", + "terrainmapper.tile-config.transparency-threshold.hint": "Inner tile pixels with transparency less than this percent will be considered 'holes' when testing terrain.", + "terrainmapper.settings.terrain.anchorOptions.absolute": "Absolute", "terrainmapper.settings.terrain.anchorOptions.relativeToTerrain": "Relative to Ground Elevation", "terrainmapper.settings.terrain.anchorOptions.relativeToLayer": "Relative to Layer Elevation", diff --git a/module.json b/module.json index bd66fb4..4a9d2b8 100644 --- a/module.json +++ b/module.json @@ -7,8 +7,8 @@ "socket": "true", "manifestPlusVersion": "1.2.0", "compatibility": { - "minimum": 11, - "verified": 11.311 + "minimum": "11", + "verified": "11.315" }, "authors": [ { @@ -52,7 +52,7 @@ "styles/terrainlayer.css" ], - "url": "https://github.com/caewok/fvtt-terrain-mapper", + "url": "#{URL}#", "manifest": "#{MANIFEST}#", "download": "#{DOWNLOAD}#", "license": "LICENSE", diff --git a/scripts/ActiveEffect.js b/scripts/ActiveEffect.js new file mode 100644 index 0000000..649b4a2 --- /dev/null +++ b/scripts/ActiveEffect.js @@ -0,0 +1,32 @@ +/* globals +canvas, +Terrain +*/ +"use strict"; +/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ + +// Methods related to ActiveEffect + +import { MODULE_ID } from "./const.js"; +import { Terrain } from "./Terrain.js"; + +export const PATCHES = {}; +PATCHES.BASIC = {}; + +/** + * Hook active effect creation. If the terrain color is updated, update the TerrainLayerShader. + * + * @event updateDocument + * @category Document + * @param {Document} document The existing Document which was updated + * @param {object} change Differential data that was used to update the document + * @param {DocumentModificationContext} options Additional options which modified the update request + * @param {string} userId The ID of the User who triggered the update workflow + */ +function updateActiveEffect(ae, changed, _options, _userId) { + if ( !changed.flags?.[MODULE_ID]?.color ) return; + const terrain = new Terrain(ae); + canvas.terrain._terrainColorsMesh.shader.updateTerrainColor(terrain); +} + +PATCHES.BASIC.HOOKS = { updateActiveEffect }; diff --git a/scripts/MeasuredTemplate.js b/scripts/MeasuredTemplate.js new file mode 100644 index 0000000..628c3a3 --- /dev/null +++ b/scripts/MeasuredTemplate.js @@ -0,0 +1,93 @@ +/* globals +*/ +/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ +"use strict"; + +import { MODULE_ID, FLAGS } from "./const.js"; +import { Terrain } from "./Terrain.js"; +import { TerrainMeasuredTemplate } from "./TerrainLevel.js"; + +export const PATCHES = {}; +PATCHES.BASIC = {}; + + +// Attach a terrain to a tile and interface with it. +// For now, only a single terrain can be attached to a tile. + +// ----- NOTE: Hooks ----- // + +/** + * Hook tile update and erase the terrain if the attachedTerrain flag was updated. + * A hook event that fires for every Document type after conclusion of an update workflow. + * Substitute the Document name in the hook event to target a specific Document type, for example "updateActor". + * This hook fires for all connected clients after the update has been processed. + * + * @event updateDocument + * @category Document + * @param {Document} document The existing Document which was updated + * @param {object} change Differential data that was used to update the document + * @param {DocumentModificationContext} options Additional options which modified the update request + * @param {string} userId The ID of the User who triggered the update workflow + */ +function updateMeasuredTemplate(templateD, changed, _options, _userId) { + const modFlag = changed.flags?.[MODULE_ID]; + if ( !modFlag || !Object.hasOwn(modFlag, [FLAGS.ATTACHED_TERRAIN]) ) return; + templateD.object._terrain = undefined; +} + +PATCHES.BASIC.HOOKS = { updateMeasuredTemplate }; + +// ----- NOTE: Methods ----- // + +/** + * Attach a terrain to this tile. + * At the moment, only one terrain can be associated with a tile at a time. Existing terrain + * will be removed. + * @param {Terrain} terrain + */ +async function attachTerrain(terrain) { + this._terrain = undefined; + await this.document.setFlag(MODULE_ID, FLAGS.ATTACHED_TERRAIN, terrain.id); +} + +/** + * Remove a terrain from this tile. + * At the moment, only one terrain can be associated with a tile at a time. + */ +async function removeTerrain() { + this._terrain = undefined; + await this.document.setFlag(MODULE_ID, FLAGS.ATTACHED_TERRAIN, ""); +} + +/** + * Determine if a terrain is active at a given point and elevation for this tile. + * @param {number} elevation + * @param {x, y} location + * @returns {boolean} If no terrain attached, returns false. + * Ignores the outer transparent edges of the tile. + * If option is set, ignores inner transparent portions. + */ +function terrainActiveAt(elevation, location) { + const terrain = this.attachedTerrain; + return !terrain || terrain.activeAt(elevation, location); +} + +PATCHES.BASIC.METHODS = { attachTerrain, removeTerrain, terrainActiveAt }; + +// ----- NOTE: Getters ----- // +function attachedTerrain() { + if ( !this._terrain ) { + const effectId = this.document.getFlag(MODULE_ID, FLAGS.ATTACHED_TERRAIN); + if ( !effectId ) return undefined; + const terrain = Terrain.fromEffectId(effectId); + this._terrain = new TerrainMeasuredTemplate(terrain, this); + } + return this._terrain; +} + +function hasAttachedTerrain() { + return Boolean(this.document.getFlag(MODULE_ID, FLAGS.ATTACHED_TERRAIN)); +} + + +PATCHES.BASIC.GETTERS = { attachedTerrain, hasAttachedTerrain }; diff --git a/scripts/MeasuredTemplateConfig.js b/scripts/MeasuredTemplateConfig.js new file mode 100644 index 0000000..b229c34 --- /dev/null +++ b/scripts/MeasuredTemplateConfig.js @@ -0,0 +1,56 @@ +/* globals +canvas, +renderTemplate +*/ +/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ +"use strict"; + +import { MODULE_ID, TEMPLATES, FLAGS } from "./const.js"; +import { Terrain } from "./Terrain.js"; +import { injectConfiguration } from "./util.js"; + +export const PATCHES = {}; +PATCHES.BASIC = {}; + +// Add dropdown to select a terrain to attach to this tile. + + +// ----- NOTE: Hooks ----- // + +/** + * Inject html to add controls to the tile configuration to allow user to set elevation. + */ +async function renderMeasuredTemplateConfig(app, html, data) { + const nullTerrain = canvas.terrain.sceneMap.get(0); + const terrains = { [nullTerrain.id]: nullTerrain.name }; + Terrain.getAll().forEach(t => terrains[t.id] = t.name); + const selected = app.object.getFlag(MODULE_ID, FLAGS.ATTACHED_TERRAIN) || ""; + data[MODULE_ID] = { terrains, selected }; + + const findString = "button[type='submit']"; + await injectConfiguration(app, html, data, TEMPLATES.MEASURED_TEMPLATE, findString, "before"); + +// const myHTML = await renderTemplate(TEMPLATES.MEASURED_TEMPLATE, data); +// html.find(".form-group").last().after(myHTML); +// app.setPosition(app.position); +} + +PATCHES.BASIC.HOOKS = { renderMeasuredTemplateConfig }; + +// ----- Note: Wraps ----- // + +/** + * Wrapper for MeasuredTemplateConfig.defaultOptions + * Make the template config window resize height automatically, to accommodate + * different parameters. + * @param {Function} wrapper + * @return {Object} See MeasuredTemplateConfig.defaultOptions. + */ +function defaultOptions(wrapper) { + const options = wrapper(); + return foundry.utils.mergeObject(options, { + height: "auto" + }); +} + +PATCHES.BASIC.STATIC_WRAPS = { defaultOptions }; diff --git a/scripts/ModuleSettingsAbstract.js b/scripts/ModuleSettingsAbstract.js new file mode 100644 index 0000000..0862df7 --- /dev/null +++ b/scripts/ModuleSettingsAbstract.js @@ -0,0 +1,101 @@ +/* globals +game, +Settings +*/ +/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ +"use strict"; + +import { MODULE_ID } from "./const.js"; + +// Patches for the Setting class +export const PATCHES = {}; +PATCHES.BASIC = {}; + +// ----- NOTE: Hooks ----- // + +/** + * Wipe the settings cache on update + */ +function updateSetting(document, change, options, userId) { // eslint-disable-line no-unused-vars + const [module, ...arr] = document.key.split("."); + const key = arr.join("."); // If the key has periods, multiple will be returned by split. + if ( module === MODULE_ID && Settings.cache.has(key) ) Settings.cache.delete(key); +} + +PATCHES.BASIC.HOOKS = { updateSetting }; + +export class ModuleSettingsAbstract { + /** @type {Map} */ + static cache = new Map(); + + /** @type {object} */ + static KEYS = {}; + + // ---- NOTE: Settings static methods ---- // + + /** + * Retrive a specific setting. + * Cache the setting. For caching to work, need to clean the cache whenever a setting below changes. + * @param {string} key + * @returns {*} + */ + static get(key) { + // TODO: Bring back a working cache. + + const cached = this.cache.get(key); + if ( typeof cached !== "undefined" ) { + const origValue = game.settings.get(MODULE_ID, key); + if ( origValue !== cached ) { + console.debug(`Settings cache fail: ${origValue} !== ${cached} for key ${key}`); + return origValue; + } + + return cached; + + } + const value = game.settings.get(MODULE_ID, key); + this.cache.set(key, value); + return value; + } + + /** + * Set a specific setting. + * @param {string} key + * @param {*} value + * @returns {Promise} + */ + static async set(key, value) { + this.cache.delete(key); + return game.settings.set(MODULE_ID, key, value); + } + + static async toggle(key) { + const curr = this.get(key); + return this.set(key, !curr); + } + + /** + * Register a specific setting. + * @param {string} key Passed to registerMenu + * @param {object} options Passed to registerMenu + */ + static register(key, options) { game.settings.register(MODULE_ID, key, options); } + + /** + * Register a submenu. + * @param {string} key Passed to registerMenu + * @param {object} options Passed to registerMenu + */ + static registerMenu(key, options) { game.settings.registerMenu(MODULE_ID, key, options); } + + /** + * Localize a setting key. + * @param {string} key + */ + static localize(key) { return game.i18n.localize(`${MODULE_ID}.settings.${key}`); } + + /** + * Register all settings + */ + static registerAll() {} +} diff --git a/scripts/Patcher.js b/scripts/Patcher.js index 7e043a5..d59868b 100644 --- a/scripts/Patcher.js +++ b/scripts/Patcher.js @@ -8,189 +8,152 @@ libWrapper import { MODULE_ID } from "./const.js"; -// Class to control patching: libWrapper, hooks, added methods. +/** + * Class to control patching: libWrapper, hooks, added methods. + * Patcher is primarily used to register arbitrary groups of patches. + * Patcher can also register/deregister specific patches. + */ export class Patcher { - /** - * @typedef {object} RegTracker - * @property {Map arguments to libWrapper - * @property {Map arguments to add the method - * @property {Map arguments to the hook - * @property {function} regHook decorated function to register hooks for the group - * @property {function} regMethod decorated function to register methods for the group - * @property {function} regWrap decorated function to register wraps for the group - * @property {function} regOverride decorated function to register overrides for the group - */ - /** @type {RegTracker} */ - regTracker = {}; + /** @type {Set} */ + registeredGroups = new Set(); - /** @type {Set} */ - groupings = new Set(); + /** @type {WeakSet} */ + registeredPatches = new WeakSet(); - /** @type {object} */ - patches; + /** @type {Map} */ + groupings = new Map(); - constructor(patches) { - this.patches = patches; - this.#initializeRegistrationTracker(); - } + /** @type {Set} */ + patches = new Set(); - groupIsRegistered(groupName) { - const regObj = this.regTracker[groupName]; - return regObj.PATCHES.size || regObj.METHODS.size || regObj.HOOKS.size; - } + groupIsRegistered(groupName) { return this.registeredGroups.has(groupName); } - /** - * Run through the patches and construct mappings for each group in the RegTracker. - */ - #initializeRegistrationTracker() { - // Decorate each group type and create one per option. - this.groupings.clear(); - Object.values(this.patches).forEach(obj => Object.keys(obj).forEach(k => this.initializeGroup(k))); + /** @type {Set} */ + groupPatches(groupName) { + if ( !this.groupings.has(groupName) ) this.groupings.set(groupName, new Set()); + return this.groupings.get(groupName); } /** - * Register a specific group in the tracker. - * @param {string} groupName + * Add new patch to track. + * @param {PatchAbstract} patch Patch to add to patch groups tracked by Patcher + * @param {boolean} [register=true] Whether to register the patch if group is registered */ - initializeGroup(groupName) { - if ( this.groupings.has(groupName) ) return; - this.groupings.add(groupName); - const regObj = this.regTracker[groupName] = {}; - regObj.PATCHES = new Map(); - regObj.METHODS = new Map(); - regObj.HOOKS = new Map(); - regObj.regLibWrapper = regDec(addLibWrapperPatch, regObj.PATCHES); - regObj.regMethod = regDec(this.constructor.addClassMethod, regObj.METHODS); - regObj.regHook = regDec(addHook, regObj.HOOKS); + addPatch(patch, register = true) { + this.patches.add(patch); + this.groupPatches(patch.group).add(patch); + if ( register && this.groupIsRegistered(patch.group) ) this.registerPatch(patch); } /** - * Register all of a given group of patches. + * Remove a patch from the tracker. + * If the patch is registered, deregister. + * @param {PatchAbstract} patch Patch to remove from patch groups tracked by Patcher + * @param {boolean} [deregister=true] Whether to deregister the patch when removing it */ - registerGroup(groupName) { - for ( const className of Object.keys(this.patches) ) this._registerGroupForClass(className, groupName); - } + removePatch(patch, deregister = true) { + if ( !this.patches.has(patch) ) return; + if ( deregister && this.registeredPatches.has(patch) ) this.deregisterPatch(patch); + this.patches.delete(patch); + const patchGroup = this.groupPatches(patch.group); + patchGroup.delete(patch); - /** - * For a given group of patches, register all of them. - */ - _registerGroupForClass(className, groupName) { - const grp = this.patches[className][groupName]; - if ( !grp ) return; - for ( const [key, obj] of Object.entries(grp) ) { - const prototype = !key.includes("STATIC"); - const libWrapperType = key.includes("OVERRIDES") - ? libWrapper.OVERRIDE : key.includes("MIXES") ? libWrapper.MIXED : libWrapper.WRAPPER; - let getter = false; - switch ( key ) { - case "HOOKS": - this._registerHooks(obj, groupName); - break; - case "STATIC_OVERRIDES": // eslint-disable-line no-fallthrough - case "OVERRIDES": - case "STATIC_MIXES": - case "MIXES": - case "STATIC_WRAPS": - case "WRAPS": - this._registerWraps(obj, groupName, className, { libWrapperType, prototype }); - break; - case "STATIC_GETTERS": // eslint-disable-line no-fallthrough - case "GETTERS": - getter = true; - default: // eslint-disable-line no-fallthrough - this._registerMethods(obj, groupName, className, { prototype, getter }); - } - } + // If last patch in a group is removed, mark the group as unregistered. + if ( !patchGroup.size ) this.registeredGroups.delete(patch.group); } /** - * Register a group of methods in libWrapper. - * @param {object|Map} wraps The functions to register - * @param {string} groupName Group to use for the tracker - * @param {string} className The class name to use; will be checked against CONFIG - * @param {object} [opt] Options passed to libWrapper - * @param {boolean} [opt.prototype] Whether to use class.prototype or just class - * @param {boolean} [opt.override] If true, use override in libWrapper - * @param {libWrapper.PERF_FAST|PERF_AUTO|PERF_NORMAL} + * Register this patch. + * If the patch is not in Patcher, add it. + * This does not affect group registration. I.e., if the patch group is not registered, + * this patch (but not its group) will be registered. + * @param {PatchAbstract} patch Patch to register */ - _registerWraps(wraps, groupName, className, { prototype, libWrapperType, perf_mode } = {}) { - prototype ??= true; - libWrapperType ??= libWrapper.WRAPPER; - perf_mode ??= libWrapper.PERF_FAST; - - className = this.constructor.lookupByClassName(className, { returnPathString: true }); - if ( prototype ) className = `${className}.prototype`; - for ( const [name, fn] of Object.entries(wraps) ) { - const methodName = `${className}.${name}`; - this.regTracker[groupName].regLibWrapper(methodName, fn, libWrapperType, { perf_mode }); - } + registerPatch(patch) { + if ( !this.patches.has(patch) ) this.addPatch(patch); + if ( this.registeredPatches.has(patch) ) return; + patch.register(); + this.registeredPatches.add(patch); } /** - * Register a group of new methods. - * @param {object|Map} methods The functions to register - * @param {string} groupName Group to use for the tracker - * @param {string} className The class name to use; will be checked against CONFIG - * @param {object} [opt] Options passed to teh registration - * @param {boolean} [opt.prototype] Whether to use class.prototype or just class - * @param {boolean} [opt.getter] If true, register as a getter + * Deregister this patch. + * @param {PatchAbstract} patch Patch to deregister */ - _registerMethods(methods, groupName, className, { prototype = true, getter = false } = {}) { - let cl = this.constructor.lookupByClassName(className); - if ( prototype ) cl = cl.prototype; - for ( const [name, fn] of Object.entries(methods) ) { - this.regTracker[groupName].regMethod(cl, name, fn, { getter }); - } + deregisterPatch(patch) { + if ( !this.registeredPatches.has(patch) ) return; + patch.deregister(); + this.registeredPatches.delete(patch); } /** - * Register a group of hooks. - * @param {object|Map} methods The hooks to register - * @param {string} groupName Group to use for the tracker + * Register a grouping of patches. + * @param {string} groupName Name of group to register */ - _registerHooks(hooks, groupName) { - for ( const [name, fn] of Object.entries(hooks) ) { - this.regTracker[groupName].regHook(name, fn); - } + registerGroup(groupName) { + if ( this.groupIsRegistered(groupName) || !this.groupings.has(groupName) ) return; + this.groupings.get(groupName).forEach(patch => this.registerPatch(patch)); + this.registeredGroups.add(groupName); } /** - * Deregister an entire group of patches. - * @param {string} groupName Name of the group to deregister. + * Deregister a grouping of patches. + * @param {string} groupName Name of group to deregister */ deregisterGroup(groupName) { - const regObj = this.regTracker[groupName]; - this.#deregisterPatches(regObj.PATCHES); - this.#deregisterMethods(regObj.METHODS); - this.#deregisterHooks(regObj.HOOKS); - } - - /** - * Deregister all libWrapper patches in this map. - */ - #deregisterPatches(map) { - map.forEach((_args, id) => libWrapper.unregister(MODULE_ID, id, false)); - map.clear(); + if ( !this.groupIsRegistered(groupName) ) return; + this.groupings.get(groupName).forEach(patch => this.deregisterPatch(patch)); + this.registeredGroups.delete(groupName); } /** - * Deregister all hooks in this map. + * Primarily for backward compatibility. + * Given an object of class names, register patches for each. + * - className0 + * - groupNameA + * - WRAPS, METHODS, etc. + * - method/hook + * - function + * @param {registrationObject} regObj */ - #deregisterHooks(map) { - map.forEach((hookName, id) => Hooks.off(hookName, id)); - map.clear(); - } - - /** - * Deregister all methods in this map. - */ - #deregisterMethods(map) { - map.forEach((args, _id) => { - const { cl, name } = args; - delete cl[name]; - }); - map.clear(); + addPatchesFromRegistrationObject(regObj) { + // Cannot use mergeObject because it breaks for names like "PIXI.Circle". + for ( const [clName, patchClass] of Object.entries(regObj) ) { + for ( const [groupName, patchGroup] of Object.entries(patchClass) ) { + for ( const [typeName, patchType] of Object.entries(patchGroup) ) { + for ( const [patchName, patch] of Object.entries(patchType) ) { + let patchCl; + let cfg = { + group: groupName, + perf_mode: libWrapper.PERF_FAST, + className: clName, + isStatic: typeName.includes("STATIC") }; + switch ( typeName ) { + case "HOOKS": patchCl = HookPatch; break; + case "STATIC_OVERRIDES": // eslint-disable-line no-fallthrough + case "OVERRIDES": + case "STATIC_MIXES": + case "MIXES": + case "STATIC_WRAPS": + case "WRAPS": + patchCl = LibWrapperPatch; + cfg.libWrapperType = typeName.includes("OVERRIDES") + ? libWrapper.OVERRIDE : typeName.includes("MIXES") + ? libWrapper.MIXED : libWrapper.WRAPPER; + break; + case "STATIC_GETTERS": // eslint-disable-line no-fallthrough + case "GETTERS": + cfg.isGetter = true; + default: // eslint-disable-line no-fallthrough + patchCl = MethodPatch; + } + const thePatch = patchCl.create(patchName, patch, cfg); + this.addPatch(thePatch); + } + } + } + } } /** @@ -227,9 +190,12 @@ export class Patcher { * @returns {class} */ static lookupByClassName(className, { returnPathString = false } = {}) { + if ( className === "Ruler" ) return returnPathString ? "CONFIG.Canvas.rulerClass" : CONFIG.Canvas.rulerClass; let isDoc = className.endsWith("Document"); let isConfig = className.endsWith("Config"); - let baseClass = isDoc ? className.replace("Document", "") : isConfig ? className.replace("Config", "") : className; + let baseClass = isDoc ? className.replace("Document", "") + : isConfig ? className.replace("Config", "") + : className; const configObj = CONFIG[baseClass]; if ( !configObj || isConfig ) return returnPathString ? className : eval?.(`"use strict";(${className})`); @@ -250,44 +216,241 @@ export class Patcher { return returnPathString ? className : eval?.(`"use strict";(${className})`); } + /** + * Split out the class name from the method name and determine if there is a prototype. + * Assumes "." demarcate parts of the name. + * @param {string} str String from which to extract. + * @returns {object} + * - {string} className Class such as Token or PIXI.Rectangle + * - {boolean} isStatic True if no "prototype" found in the string + * - {string} methodName Everything after "prototype" or the last piece of the string. + */ + static splitClassMethodString(str) { + str = str.split("."); + const methodName = str.pop(); + const notStatic = str.at(-1) === "prototype"; + if ( notStatic ) str.pop(); + const className = str.join("."); + return { className, isStatic: !notStatic, methodName }; + } } -// ----- NOTE: Helper functions ----- // +// ----- NOTE: Patch classes ----- // -/** - * Helper to wrap/mix/override methods. - * @param {string} method Method to wrap - * @param {function} fn Function to use for the wrap - * @param {libWrapper.TYPES} libWrapper.WRAPPED, MIXED, OVERRIDE - * @param {object} [options] Options passed to libWrapper.register. E.g., { perf_mode: libWrapper.PERF_FAST} - * @returns {object canvas.terrain.sceneMap.hasTerrainId(t.id)); - } - /** * Test if a token has this terrain already. * @param {Token} token @@ -419,6 +394,7 @@ export class Terrain { * @param {PIXI.Point} origin * @param {PIXI.Point} destination * @param {string} [speedAttribute] + * @returns {number} Percent of the distance between origin and destination */ static percentMovementForTokenAlongPath(token, origin, destination, speedAttribute) { speedAttribute ??= getDefaultSpeedAttribute(); diff --git a/scripts/TerrainEffectsController.js b/scripts/TerrainEffectsController.js index b031b98..e7fd2c9 100644 --- a/scripts/TerrainEffectsController.js +++ b/scripts/TerrainEffectsController.js @@ -28,9 +28,10 @@ export class TerrainEffectsController { constructor(viewMvc) { this._viewMvc = viewMvc; -// this._customEffectsHandler = new CustomEffectsHandler(); -// this._dynamicEffectsAdder = new DynamicEffectsAdder(); -// this._foundryHelpers = new FoundryHelpers(); + // Unused: + // this._customEffectsHandler = new CustomEffectsHandler(); + // this._dynamicEffectsAdder = new DynamicEffectsAdder(); + // this._foundryHelpers = new FoundryHelpers(); } /** @@ -92,7 +93,7 @@ export class TerrainEffectsController { _fetchFavorites(terrains) { // Debug: console.debug("TerrainEffectsController|_fetchFavorites"); - const favorites = new Set(Settings.getByName("FAVORITES")); + const favorites = new Set(Settings.get(Settings.KEYS.FAVORITES)); return terrains.filter(t => favorites.has(t.id)); } @@ -152,6 +153,7 @@ export class TerrainEffectsController { const terrain = new Terrain(); await terrain.initialize(); this._viewMvc.render(); + terrain.activeEffect.sheet.render(true); } /** @@ -185,13 +187,14 @@ export class TerrainEffectsController { } }); -// const effectName = effectItem.data().effectName; -// const customEffect = this._customEffectsHandler -// .getCustomEffects() -// .find((effect) => effect.name == effectName); -// -// await this._customEffectsHandler.deleteCustomEffect(customEffect); -// this._viewMvc.render(); + // Unused: + // const effectName = effectItem.data().effectName; + // const customEffect = this._customEffectsHandler + // .getCustomEffects() + // .find((effect) => effect.name == effectName); + // + // await this._customEffectsHandler.deleteCustomEffect(customEffect); + // this._viewMvc.render(); } /** @@ -287,8 +290,9 @@ export class TerrainEffectsController { const effectId = effectItem.data().effectId; return Settings.isFavorite(effectId); -// const effectName = effectItem.data().effectName; -// return this._settings.isFavoritedEffect(effectName); + // Unused: + // const effectName = effectItem.data().effectName; + // return this._settings.isFavoritedEffect(effectName); } /** @@ -330,19 +334,19 @@ export class TerrainEffectsController { * @param {jQuery} effectItem - jQuery element representing the effect list item */ async onToggleStatusEffect(_effectItem) { - // Debug: console.debug("TerrainEffectsController|onToggleStatusEffect"); + // Debug: console.debug("TerrainEffectsController|onToggleStatusEffect"); // const effectId = effectItem.data().effectId; -// const effectName = effectItem.data().effectName; -// -// if (this._settings.isStatusEffect(effectName)) { -// await this._settings.removeStatusEffect(effectName); -// } else { -// await this._settings.addStatusEffect(effectName); -// } -// -// this._viewMvc.showReloadRequired(); -// this._viewMvc.render(); + // const effectName = effectItem.data().effectName; + // + // if (this._settings.isStatusEffect(effectName)) { + // await this._settings.removeStatusEffect(effectName); + // } else { + // await this._settings.addStatusEffect(effectName); + // } + // + // this._viewMvc.showReloadRequired(); + // this._viewMvc.render(); } /** diff --git a/scripts/TerrainGridHexagon.js b/scripts/TerrainGridHexagon.js index 703b311..067ad3c 100644 --- a/scripts/TerrainGridHexagon.js +++ b/scripts/TerrainGridHexagon.js @@ -60,7 +60,9 @@ export class TerrainGridHexagon extends Hexagon { static _fromTopLeft(tlx, tly) { const sz = canvas.dimensions.size; const sz1_2 = sz * 0.5; - return new this({ x: tlx + sz1_2, y: tly + sz1_2 }, sz, { rotation: 45, width: sz }); + const width = canvas.grid.grid.w; + const height = canvas.grid.grid.h; + return new this({ x: tlx + (width * 0.5), y: tly + (height * 0.5) }, undefined, { width, height }); } /** diff --git a/scripts/TerrainLayer.js b/scripts/TerrainLayer.js index 5301deb..3e8161e 100644 --- a/scripts/TerrainLayer.js +++ b/scripts/TerrainLayer.js @@ -11,7 +11,8 @@ PreciseText, readTextFromFile, renderTemplate, saveDataToFile, -ui +ui, +Wall */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ "use strict"; @@ -169,7 +170,7 @@ export class TerrainLayer extends InteractionLayer { * adjustments by the GM to the scene elevation. * @type {PIXI.Sprite} */ -// _backgroundTerrain = PIXI.Sprite.from(PIXI.Texture.EMPTY); + // _backgroundTerrain = PIXI.Sprite.from(PIXI.Texture.EMPTY); // ----- NOTE: PIXI arrays/maps ----- // @@ -266,51 +267,86 @@ export class TerrainLayer extends InteractionLayer { // ----- NOTE: Access terrain data ----- // + /* + Definitions + - Terrain: Terrain from the Terrain book. Not anchored to a specific elevation. + - TerrainLevel: Class that associates a Terrain with an elevation level. + Linked to a canvas layer, tile, or measured template. + - Active: If the terrain affects the 3d position in space. + + Primary Methods + - terrainLevelsAt: Set of TerrainLevel|TerrainTile|TerrainMeasuredTemplate at a 2d position. + - activeTerrainsAt: `terrainLevelsAt` filtered by active. Requires elevation to determine. + + Submethods + - _canvasTerrainLevelsAt + - _tileTerrainLevelsAt + - _templateTerrainLevelsAt + */ + /** - * Unique terrain(s) at a given position. - * @param {Point} {x, y} - * @returns {Set} + * Canvas TerrainLevels at a given position. + * @param {Point} {x, y} location + * @returns {TerrainLevel[]} */ - terrainsAt(pt) { - if ( !this.#initialized ) return []; + _canvasTerrainLevelsAt(location) { + if ( !this.#initialized ) return new Set(); // Return only terrains that are non-zero. - return new Set(this.terrainLevelsAt(pt).map(t => t.terrain)); + const terrainLayers = this._terrainLayersAt(location); + return this._layersToTerrainLevels(terrainLayers); } /** - * Active unique terrain(s) at a given position and elevation. - * @param {Point|Point3d} {x, y, z} 2d or 3d point - * @param {number} [elevation] Optional elevation (if not pt.z or 0) - * @returns {Set} + * TerrainTiles at a given position. + * @param {Point} {x, y} location Position to test + * @param {PIXI.Rectangle} [bounds] Boundary rectangle around the pixel to use to search quadtree. + * @returns {Set} */ - activeTerrainsAt(pt, elevation) { - return new Set(this.activeTerrainLevelsAt(pt, elevation).map(t => t.terrain)); + _tileTerrainLevelsAt(location, bounds) { + bounds ??= new PIXI.Rectangle(location.x - 1, location.y -1, 3, 3); + const collisionTest = (o, rect) => o.t.hasAttachedTerrain && rect.contains(location.x, location.y); + const tiles = canvas.tiles.quadtree.getObjects(bounds, { collisionTest }); + return tiles.map(tile => tile.attachedTerrain); } /** - * Terrain levels at a given position. - * @param {Point} {x, y} - * @returns {TerrainLevel[]} + * TerrainMeasuredTemplates at a given position. + * @param {Point} {x, y} location Position to test + * @param {PIXI.Rectangle} [bounds] Boundary rectangle around the pixel to use to search quadtree. + * @returns {Set} */ - terrainLevelsAt(pt) { - if ( !this.#initialized ) return []; + _templateTerrainLevelsAt(location, bounds) { + bounds ??= new PIXI.Rectangle(location.x - 1, location.y -1, 3, 3); + const collisionTest = (o, rect) => o.t.hasAttachedTerrain && rect.contains(location.x, location.y); + const templates = canvas.templates.quadtree.getObjects(bounds, { collisionTest }); + return templates.map(template => template.attachedTerrain); + } - // Return only terrains that are non-zero. - const terrainLayers = this._terrainLayersAt(pt); - return this._layersToTerrainLevels(terrainLayers); + /** + * Terrain levels found at the provided 2d position. + * @param {Point} {x, y} location + * @returns {Set} + */ + terrainLevelsAt(location) { + const bounds = new PIXI.Rectangle(location.x - 1, location.y -1, 3, 3); + const canvasTerrains = this._canvasTerrainLevelsAt(location); + const tileTerrains = this._tileTerrainLevelsAt(location, bounds); + const templateTerrains = this._templateTerrainLevelsAt(location, bounds); + return canvasTerrains.union(tileTerrains).union(templateTerrains); } /** - * Active terrain levels at a given position and elevation. + * Unique active terrain(s) at a given position. * @param {Point|Point3d} {x, y, z} 2d or 3d point * @param {number} [elevation] Optional elevation (if not pt.z or 0) - * @returns {TerrainLevel[]} + * @returns {Set} */ - activeTerrainLevelsAt(pt, elevation) { - elevation ??= CONFIG.GeometryLib.utils.pixelsToGridUnits(pt.z) || 0; - const terrainLevels = this.terrainLevelsAt(pt); - return terrainLevels.filter(t => t.activeAt(elevation, pt)); + activeTerrainsAt(location, elevation) { + elevation ??= CONFIG.GeometryLib.utils.pixelsToGridUnits(location.z) || 0; + return this.terrainLevelsAt(location) + .filter(t => t.activeAt(elevation, location)) + .map(t => t.terrain); } /** @@ -330,10 +366,10 @@ export class TerrainLayer extends InteractionLayer { */ #terrainAt(pt) { const layers = this._terrainLayersAt(pt); + if ( !layers ) return undefined; const currLayer = this.toolbar.currentLayer; const pixelValue = layers[currLayer]; if ( !pixelValue ) return undefined; // Don't return the null terrain. - const terrain = this.terrainForPixel(pixelValue); return new TerrainLevel(terrain, currLayer); } @@ -365,18 +401,18 @@ export class TerrainLayer extends InteractionLayer { /** * Terrains for a given array of layers * @param {Uint8Array[MAX_LAYERS]} terrainLayers - * @returns {TerrainLevels[]} + * @returns {Set} */ _layersToTerrainLevels(terrainLayers) { - const terrainArr = []; + const terrains = new Set(); const nLayers = terrainLayers.length; for ( let i = 0; i < nLayers; i += 1 ) { const px = terrainLayers[i]; if ( !px ) continue; const terrain = this.terrainForPixel(px); - terrainArr.push(new TerrainLevel(terrain, i)); + terrains.add(new TerrainLevel(terrain, i)) } - return terrainArr; + return terrains; } // ----- NOTE: Initialize, activate, deactivate, destroy ----- // @@ -393,7 +429,7 @@ export class TerrainLayer extends InteractionLayer { // Required on every initialization (loading each scene) if ( this.#initialized ) return; - // Debug: console.debug(`${MODULE_ID}|Initializing Terrain Mapper`); + // Debug: console.debug(`${MODULE_ID}|Initializing Terrain Mapper`); // Set up the shared graphics object used to color grid spaces. this.#initializeGridShape(); @@ -403,7 +439,7 @@ export class TerrainLayer extends InteractionLayer { // Construct the render textures that are used for the layers. 1 per 3 layers. // The render texture changes (and is destroyed) each scene. - const nTextures = this.constructor.NUM_TEXTURES + const nTextures = this.constructor.NUM_TEXTURES; for ( let i = 0; i < nTextures; i += 1 ) { const tex = this._terrainTextures[i] = PIXI.RenderTexture.create(this._fileManager.textureConfiguration); tex.baseTexture.clearColor = [0, 0, 0, 0]; @@ -421,7 +457,7 @@ export class TerrainLayer extends InteractionLayer { this.#initializePixelCache(); this._clearPixelCacheArray(); - const currId = Settings.getByName("CURRENT_TERRAIN"); + const currId = Settings.get(Settings.KEYS.CURRENT_TERRAIN); if ( currId ) this.currentTerrain = this.sceneMap.terrainIds.get(currId); if ( !this.currentTerrain ) this.currentTerrain = this.sceneMap.values().next().value; @@ -432,7 +468,7 @@ export class TerrainLayer extends InteractionLayer { // Should start at the upper left scene corner // Holds the default background elevation settings // const { sceneX, sceneY } = canvas.dimensions; -// this._backgroundTerrain.position = { x: sceneX, y: sceneY }; + // this._backgroundTerrain.position = { x: sceneX, y: sceneY }; // TODO: Use a background terrain by combining the background with the foreground using an overlay // for the foreground. @@ -472,14 +508,12 @@ export class TerrainLayer extends InteractionLayer { this._activateHoverListener(); this.#firstInitialization = true; - // Debug: console.debug(`${MODULE_ID}|Finished first initialization Terrain Mapper`); + // Debug: console.debug(`${MODULE_ID}|Finished first initialization Terrain Mapper`); } - - /** @override */ _activate() { - // Debug: console.debug(`${MODULE_ID}|Activating Terrain Layer.`); + // Debug: console.debug(`${MODULE_ID}|Activating Terrain Layer.`); // Draw walls if ( game.user.isGM ) canvas.stage.addChild(this._wallDataContainer); @@ -529,11 +563,11 @@ export class TerrainLayer extends InteractionLayer { } /** @inheritdoc */ - async _tearDown(options) { - // Debug: console.debug(`${MODULE_ID}|_tearDown Terrain Layer`); + async _tearDown(_options) { + // Debug: console.debug(`${MODULE_ID}|_tearDown Terrain Layer`); if ( !this.#initialized ) return; // Don't call super._tearDown, which would destroy children. - // Debug: console.debug(`${MODULE_ID}|Tearing down Terrain Mapper`); + // Debug: console.debug(`${MODULE_ID}|Tearing down Terrain Mapper`); if ( this._requiresSave ) await this.save(); @@ -550,7 +584,7 @@ export class TerrainLayer extends InteractionLayer { */ #destroy() { // Debug: console.debug(`${MODULE_ID}|Destroying Terrain Mapper`); - this._terrainColorsMesh.destroy({children: true}) + this._terrainColorsMesh.destroy({children: true}); this._clearWallTracker(); this._terrainTextures.forEach(tex => tex.destroy()); } @@ -658,7 +692,7 @@ export class TerrainLayer extends InteractionLayer { this._requiresSave = true; // Refresh the UI related to the terrain. - this._terrainColorsMesh.shader.updateTerrainColors(); + this._terrainColorsMesh.shader.updateAllTerrainColors(); if ( ui.controls.activeControl === "terrain" ) ui.controls.render(); TerrainEffectsApp.rerender(); @@ -683,7 +717,7 @@ export class TerrainLayer extends InteractionLayer { this._requiresSave = true; // Refresh the UI for the terrain. - this._terrainColorsMesh.shader.updateTerrainColors(); + this._terrainColorsMesh.shader.updateAllTerrainColors(); if ( this.toolbar.currentTerrain === this ) this.toolbar._currentTerrain = undefined; if ( ui.controls.activeControl === "terrain" ) ui.controls.render(); TerrainEffectsApp.rerender(); @@ -704,7 +738,7 @@ export class TerrainLayer extends InteractionLayer { terrain._unassignPixel(); // Refresh the UI for the terrain. - this._terrainColorsMesh.shader.updateTerrainColors(); + this._terrainColorsMesh.shader.updateAllTerrainColors(); if ( this.toolbar.currentTerrain === this ) this.toolbar._currentTerrain = undefined; if ( ui.controls.activeControl === "terrain" ) ui.controls.render(); TerrainEffectsApp.rerender(); @@ -1328,7 +1362,8 @@ export class TerrainLayer extends InteractionLayer { */ - // Debug: console.debug(`${MODULE_ID}|Attempting fill at { x: ${origin.x}, y: ${origin.y} } with terrain ${terrain.name}`); + // Debug: + // console.debug(`${MODULE_ID}|Attempting fill at { x: ${origin.x}, y: ${origin.y} } with terrain ${terrain.name}`); const polys = SCENE_GRAPH.encompassingPolygonWithHoles(origin); if ( !polys.length ) { // Shouldn't happen, but... @@ -1376,8 +1411,9 @@ export class TerrainLayer extends InteractionLayer { this._shapeQueueArray.forEach(shapeQueue => shapeQueue.clear()); this._clearPixelCacheArray(); -// this._backgroundTerrain.destroy(); -// this._backgroundTerrain = PIXI.Sprite.from(PIXI.Texture.EMPTY); + // Unused: + // this._backgroundTerrain.destroy(); + // this._backgroundTerrain = PIXI.Sprite.from(PIXI.Texture.EMPTY); this._graphicsLayers.forEach(g => { const children = g.removeChildren(); @@ -1417,11 +1453,14 @@ export class TerrainLayer extends InteractionLayer { } } - #debugClickEvent(event, fnName) { - const activeTool = game.activeTool; - const o = event.interactionData.origin; - const currT = this.toolbar.currentTerrain; - // Debug: console.debug(`${MODULE_ID}|${fnName} at ${o.x}, ${o.y} with tool ${activeTool} and terrain ${currT?.name}`, event); + #debugClickEvent(_event, _fnName) { + // Unused + // const activeTool = game.activeTool; + // const o = event.interactionData.origin; + // const currT = this.toolbar.currentTerrain; + // Debug: + // console.debug(`${MODULE_ID}|${fnName} at ${o.x}, ${o.y} \ + // with tool ${activeTool} and terrain ${currT?.name}`, event); } @@ -1563,23 +1602,27 @@ export class TerrainLayer extends InteractionLayer { /** * User scrolls the mouse wheel. Currently does nothing in response. */ - _onMouseWheel(event) { - const o = event.interactionData.origin; - const activeTool = game.activeTool; - const currT = this.toolbar.currentTerrain; - // Debug: console.debug(`${MODULE_ID}|mouseWheel at ${o.x}, ${o.y} with tool ${activeTool} and terrain ${currT?.name}`, event); + _onMouseWheel(_event) { + // Unused: + // const o = event.interactionData.origin; + // const activeTool = game.activeTool; + // const currT = this.toolbar.currentTerrain; + // Debug: + // console.debug(`${MODULE_ID}|mouseWheel at ${o.x}, ${o.y} \ + // with tool ${activeTool} and terrain ${currT?.name}`, event); // Cycle to the next scene terrain - } /** * User hits delete key. Currently not triggered (at least on this M1 Mac). */ - async _onDeleteKey(event) { - const o = event.interactionData.origin; - const activeTool = game.activeTool; - const currT = this.toolbar.currentTerrain; - // Debug: console.debug(`${MODULE_ID}|deleteKey at ${o.x}, ${o.y} with tool ${activeTool} and terrain ${currT?.name}`, event); + async _onDeleteKey(_event) { + // Unused: + // const o = event.interactionData.origin; + // const activeTool = game.activeTool; + // const currT = this.toolbar.currentTerrain; + // Debug: console.debug(`${MODULE_ID}|deleteKey at ${o.x}, ${o.y} \ + // with tool ${activeTool} and terrain ${currT?.name}`, event); } } diff --git a/scripts/TerrainLayerToolBar.js b/scripts/TerrainLayerToolBar.js index dfdf08c..84c4bbd 100644 --- a/scripts/TerrainLayerToolBar.js +++ b/scripts/TerrainLayerToolBar.js @@ -29,7 +29,7 @@ export class TerrainLayerToolBar extends Application { return; } this.#currentTerrain = terrain; - Settings.setByName("CURRENT_TERRAIN", terrain.id); // Async + Settings.set(Settings.KEYS.CURRENT_TERRAIN, terrain.id); // Async } /** @type {number} */ @@ -39,7 +39,7 @@ export class TerrainLayerToolBar extends Application { set currentLayer(value) { this.#currentLayer = Math.clamped(Math.round(value), 0, canvas.terrain.constructor.MAX_LAYERS); - Settings.setByName("CURRENT_LAYER", this.#currentLayer); // Async + Settings.set(Settings.KEYS.CURRENT_LAYER, this.#currentLayer); // Async // Update the layer variable in the shader that displays terrain. canvas.terrain._terrainColorsMesh.shader.updateTerrainLayer(); @@ -51,7 +51,7 @@ export class TerrainLayerToolBar extends Application { * @returns {number} */ _loadStoredLayer() { - const storedId = Settings.getByName("CURRENT_LAYER"); + const storedId = Settings.get(Settings.KEYS.CURRENT_LAYER); return storedId ?? 0; } @@ -61,7 +61,7 @@ export class TerrainLayerToolBar extends Application { * @returns {Terrain|undefined} */ _loadStoredTerrain() { - const storedId = Settings.getByName("CURRENT_TERRAIN"); + const storedId = Settings.get(Settings.KEYS.CURRENT_TERRAIN); const sceneMap = canvas.terrain.sceneMap; if ( sceneMap.hasTerrainId(storedId) ) return sceneMap.terrainIds.get(storedId); diff --git a/scripts/TerrainLevel.js b/scripts/TerrainLevel.js index 40c4939..f59b4e4 100644 --- a/scripts/TerrainLevel.js +++ b/scripts/TerrainLevel.js @@ -1,5 +1,7 @@ /* globals -canvas +canvas, +CONFIG, +Tile */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ "use strict"; @@ -7,19 +9,27 @@ canvas import { MODULE_ID, FLAGS } from "./const.js"; import { TerrainKey } from "./TerrainPixelCache.js"; + /** * Represent the terrain at a specific level. - * Meant to be duplicated so that the underlying Terrain is not copied. * Stores the level information for this terrain. + * Singleton instance per id. */ export class TerrainLevel { + static _instances = new Map(); + /** @type {TerrainKey} */ key = new TerrainKey(0); constructor(terrain, level) { this.terrain = terrain ?? canvas.terrain.controls.currentTerrain; this.level = level ?? canvas.terrain.controls.currentLevel; + + const instances = this.constructor._instances; + if (instances.has(this.id) ) return instances.get(this.id); + instances.set(this.id, this); + this.scene = canvas.scene; this.key = TerrainKey.fromTerrainValue(this.terrain.pixelValue, this.level); } @@ -38,9 +48,15 @@ export class TerrainLevel { /** @type {boolean} */ get userVisible() { return this.terrain.userVisible; } + /** + * Unique id for this type of level and terrain. Used to distinguish between copies. + * @type {string} + */ + get id() { return `${this.terrain.id}_canvasLevel_${this.level}`; } + /** * Retrieve the anchor elevation of this level in this scene. - * @returns {number} + * @returns {number} The elevation, in grid units. */ _layerElevation() { const layerElevations = canvas.scene.getFlag(MODULE_ID, FLAGS.LAYER_ELEVATIONS) ?? (new Array(8)).fill(0); @@ -70,13 +86,27 @@ export class TerrainLevel { /** * Elevation range for this terrain at a given canvas location. * @param {Point} [location] Location on the map. Required if the anchor is RELATIVE_TO_TERRAIN and EV is present. - * @returns {min: {number}, max: {number}} + * @returns {min: {number}, max: {number}} In grid units */ elevationRange(location) { const anchorE = this.getAnchorElevation(location); return this.terrain._elevationMinMaxForAnchorElevation(anchorE); } + /** + * Convenience method to get the pixel units for the elevation range. Used by TravelTerrainRay. + * @param {Point} [location] Location on the map. Required if the anchor is RELATIVE_TO_TERRAIN and EV is present. + * @returns {min: {number}, max: {number}} In pixel units + */ + elevationRangeZ(location) { + const minMax = this.elevationRange(location); + minMax.min = CONFIG.GeometryLib.utils.gridUnitsToPixels(minMax.min); + minMax.max = CONFIG.GeometryLib.utils.gridUnitsToPixels(minMax.max); + return minMax; + } + + + /** * Determine if the terrain is active at the provided elevation. * @param {number} elevation Elevation to test @@ -88,3 +118,97 @@ export class TerrainLevel { return elevation.between(minMaxE.min, minMaxE.max); } } + +/** + * Represent a terrain linked to a tile. + */ +export class TerrainTile extends TerrainLevel { + /** @type {Tile} */ + tile; + + constructor(terrain, tile) { + if ( tile && !(tile instanceof Tile) ) console.error("TerrainTile requires a Tile object.", tile); + super(terrain, tile); + this.tile = tile; + } + + /** + * Unique id for this type of level and terrain. Used to distinguish between copies. + * @type {string} + */ + get id() { return `${this.terrain.id}_tile_${this.level.id}`; } // Level equals tile here. + + /** + * Returns the tile elevation. + * @returns {number} Elevation, in grid units. + */ + _layerElevation() { return this.tile.elevationE || 0; } + + /** + * Determine if the terrain is active at the provided elevation. + * @param {number} elevation Elevation to test + * @param {Point} location Location on the map. Required. + * @returns {boolean} + */ + activeAt(elevation, location) { + if ( !super.activeAt(elevation, location) ) return false; + + const tile = this.tile; + const pixelCache = tile.evPixelCache; + + // First, check if the point is within the non-transparent boundary of the tile. + const thresholdBounds = pixelCache.getThresholdCanvasBoundingBox(CONFIG[MODULE_ID].alphaThreshold); + if ( !thresholdBounds.contains(location.x, location.y) ) return false; + + // Second, check if the point is not transparent (based on inner transparency threshold). + const alphaThreshold = tile.document.getFlag(MODULE_ID, FLAGS.ALPHA_THRESHOLD); + if ( !alphaThreshold ) return true; + if ( alphaThreshold === 1 ) return false; + return this.tile.mesh.getPixelAlpha(location.x, location.y) < alphaThreshold; + } +} + +/** + * Represent a measured template linked to a tile. + */ +export class TerrainMeasuredTemplate extends TerrainLevel { + /** @type {MeasuredTemplate} */ + template; + + constructor(terrain, template) { + if ( template && !(template instanceof MeasuredTemplate) ) console.error("TerrainMeasuredTemplate requires a MeasuredTemplate object.", tile); + super(terrain, template); + this.template = template; + } + + /** + * Unique id for this type of level and terrain. Used to distinguish between copies. + * @type {string} + */ + get id() { return `${this.terrain.id}_template_${this.level.id}`; } // Level equals template here. + + /** + * Returns the tile elevation. + * @returns {number} Elevation, in grid units. + */ + _layerElevation() { return this.template.elevationE || 0; } + + /** + * Determine if the terrain is active at the provided elevation. + * @param {number} elevation Elevation to test + * @param {Point} location Location on the map. Required. + * @returns {boolean} + */ + activeAt(elevation, location) { + if ( !super.activeAt(elevation, location) ) return false; + + // First, check if within the bounds of the template. + const template = this.template; + if ( !template.bounds.contains(location.x, location.y) ) return false; + + // Second, check if contained within the template shape. + // Shape centered at origin 0, 0. + const shape = template.shape.translate(template.x, template.y) + return shape.contains(location.x, location.y); + } +} diff --git a/scripts/Tile.js b/scripts/Tile.js new file mode 100644 index 0000000..de78715 --- /dev/null +++ b/scripts/Tile.js @@ -0,0 +1,121 @@ +/* globals +*/ +/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ +"use strict"; + +import { MODULE_ID, FLAGS } from "./const.js"; +import { Terrain } from "./Terrain.js"; +import { TerrainTile } from "./TerrainLevel.js"; +import { TilePixelCache } from "./PixelCache.js"; + +export const PATCHES = {}; +PATCHES.BASIC = {}; + + +// Attach a terrain to a tile and interface with it. +// For now, only a single terrain can be attached to a tile. + +// ----- NOTE: Hooks ----- // + +/** + * Hook tile update and erase the terrain if the attachedTerrain flag was updated. + * A hook event that fires for every Document type after conclusion of an update workflow. + * Substitute the Document name in the hook event to target a specific Document type, for example "updateActor". + * This hook fires for all connected clients after the update has been processed. + * + * @event updateDocument + * @category Document + * @param {Document} document The existing Document which was updated + * @param {object} change Differential data that was used to update the document + * @param {DocumentModificationContext} options Additional options which modified the update request + * @param {string} userId The ID of the User who triggered the update workflow + */ +function updateTile(tileD, changed, _options, _userId) { + // Should not be needed: if ( changed.overhead ) document.object._evPixelCache = undefined; + const cache = document.object?._evPixelCache; + if ( cache ) { + if ( Object.hasOwn(changed, "x") + || Object.hasOwn(changed, "y") + || Object.hasOwn(changed, "width") + || Object.hasOwn(changed, "height") ) { + cache._resize(); + } + + if ( Object.hasOwn(changed, "rotation") + || Object.hasOwn(changed, "texture") + || (changed.texture + && (Object.hasOwn(changed.texture, "scaleX") + || Object.hasOwn(changed.texture, "scaleY"))) ) { + + cache.clearTransforms(); + } + } + + const modFlag = changed.flags?.[MODULE_ID]; + if ( !modFlag || !Object.hasOwn(modFlag, [FLAGS.ATTACHED_TERRAIN]) ) return; + tileD.object._terrain = undefined; +} + +PATCHES.BASIC.HOOKS = { updateTile }; + +// ----- NOTE: Methods ----- // + +/** + * Attach a terrain to this tile. + * At the moment, only one terrain can be associated with a tile at a time. Existing terrain + * will be removed. + * @param {Terrain} terrain + */ +async function attachTerrain(terrain) { + this._terrain = undefined; + await this.document.setFlag(MODULE_ID, FLAGS.ATTACHED_TERRAIN, terrain.id); +} + +/** + * Remove a terrain from this tile. + * At the moment, only one terrain can be associated with a tile at a time. + */ +async function removeTerrain() { + this._terrain = undefined; + await this.document.setFlag(MODULE_ID, FLAGS.ATTACHED_TERRAIN, ""); +} + +/** + * Determine if a terrain is active at a given point and elevation for this tile. + * @param {number} elevation + * @param {x, y} location + * @returns {boolean} If no terrain attached, returns false. + * Ignores the outer transparent edges of the tile. + * If option is set, ignores inner transparent portions. + */ +function terrainActiveAt(elevation, location) { + const terrain = this.attachedTerrain; + return !terrain || terrain.activeAt(elevation, location); +} + +PATCHES.BASIC.METHODS = { attachTerrain, removeTerrain, terrainActiveAt }; + +// ----- NOTE: Getters ----- // +function attachedTerrain() { + if ( !this._terrain ) { + const effectId = this.document.getFlag(MODULE_ID, FLAGS.ATTACHED_TERRAIN); + if ( !effectId ) return undefined; + const terrain = Terrain.fromEffectId(effectId); + this._terrain = new TerrainTile(terrain, this); + } + return this._terrain; +} + +function hasAttachedTerrain() { + return Boolean(this.document.getFlag(MODULE_ID, FLAGS.ATTACHED_TERRAIN)); +} + +/** + * Getter for Tile.mesh._evPixelCache + */ +function evPixelCache() { + return this._evPixelCache || (this._evPixelCache = TilePixelCache.fromOverheadTileAlpha(this)); +} + + +PATCHES.BASIC.GETTERS = { attachedTerrain, hasAttachedTerrain, evPixelCache }; diff --git a/scripts/TileConfig.js b/scripts/TileConfig.js new file mode 100644 index 0000000..8907507 --- /dev/null +++ b/scripts/TileConfig.js @@ -0,0 +1,32 @@ +/* globals +canvas +*/ +/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ +"use strict"; + +import { MODULE_ID, TEMPLATES, FLAGS } from "./const.js"; +import { Terrain } from "./Terrain.js"; +import { injectConfiguration } from "./util.js"; + +export const PATCHES = {}; +PATCHES.BASIC = {}; + +// Add dropdown to select a terrain to attach to this tile. + + +// ----- NOTE: Hooks ----- // + +/** + * Inject html to add controls to the tile configuration to allow user to set elevation. + */ +async function renderTileConfig(app, html, data) { + const findString = "div[data-tab='basic']:last"; + const nullTerrain = canvas.terrain.sceneMap.get(0); + const terrains = { [nullTerrain.id]: nullTerrain.name }; + Terrain.getAll().forEach(t => terrains[t.id] = t.name); + const selected = app.object.getFlag(MODULE_ID, FLAGS.ATTACHED_TERRAIN) || ""; + data[MODULE_ID] = { terrains, selected }; + await injectConfiguration(app, html, data, TEMPLATES.TILE, findString, "append"); +} + +PATCHES.BASIC.HOOKS = { renderTileConfig }; diff --git a/scripts/Token.js b/scripts/Token.js index 1cd4060..e3d038b 100644 --- a/scripts/Token.js +++ b/scripts/Token.js @@ -74,12 +74,12 @@ function refreshTokenHook(token, flags) { const center = token.getCenter(token.position.x, token.position.y); const pathTerrains = ttr.activeTerrainsAtClosestPoint(center); if ( !pathTerrains.size ) { - Terrain.removeAllSceneTerrainsFromToken(token); // Async + Terrain.removeAllFromToken(token); // Async return; } // Determine if terrains must be added or removed from the token at this point. - const tokenTerrains = new Set(Terrain.allSceneTerrainsOnToken(token)); + const tokenTerrains = new Set(Terrain.allOnToken(token)); const terrainsToRemove = tokenTerrains.difference(pathTerrains); const terrainsToAdd = pathTerrains.difference(tokenTerrains); diff --git a/scripts/TravelTerrainRay.js b/scripts/TravelTerrainRay.js index ec87cd2..2b4d3a1 100644 --- a/scripts/TravelTerrainRay.js +++ b/scripts/TravelTerrainRay.js @@ -1,12 +1,15 @@ /* globals canvas, +CONFIG, foundry, game, +mergeObject, PIXI */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ "use strict"; +import { MODULE_ID, FLAGS, MODULES_ACTIVE } from "./const.js"; import { Point3d } from "./geometry/3d/Point3d.js"; import { TerrainKey } from "./TerrainPixelCache.js"; @@ -46,14 +49,8 @@ export class TravelTerrainRay { /** @type {Token} */ #token; - /** @type {PIXI.Point} */ - #destination = new Point3d(); - - /** @type {PIXI.Point} */ - #origin = new Point3d(); - /** @type {object[]} */ - #path = []; + #activePath = []; /** @type {function} */ #markTerrainFn = (curr, prev) => curr !== prev; @@ -77,14 +74,9 @@ export class TravelTerrainRay { if ( destination ) this.destination.copyFrom(destination); } - /** - * Retrieve all terrain levels for a given pixel key. - * @param {TerrainKey} key - * @returns {TerrainLevels[]} - */ - static terrainLevelsForKey(key) { return canvas.terrain._layersToTerrainLevels(key.toTerrainLayers()); } + /** @type {PIXI.Point} */ + #origin = new Point3d(); - /** @type {Point3d} */ get origin() { return this.#origin; } set origin(value) { @@ -92,7 +84,9 @@ export class TravelTerrainRay { this.#path.length = 0; } - /** @type {Point3d} */ + /** @type {PIXI.Point} */ + #destination = new Point3d(); + get destination() { return this.#destination; } set destination(value) { @@ -100,39 +94,49 @@ export class TravelTerrainRay { this.#path.length = 0; } + /** @type {object[]} */ + #path = []; + get path() { if ( !this.#path.length ) this._walkPath(); return this.#path; } + get activePath() { + if ( !this.#activePath.length ) this._constructActivePath(); + return this.#activePath; + } + /** - * @param {number} t Percent distance along origin --> destination ray in 2d. - * @returns {PIXI.Point} + * Clear path and active path. */ - pointAtT(t) { return this.origin.to2d().projectToward(this.destination.to2d(), t); } + clearPath() { + this.#path.length = 0; + this.#activePath.length = 0; + } /** - * All unique terrains encountered at this point along the path. - * @param {number} t Percent distance along the ray - * @returns {Set} Terrains at that location. + * Clear the active path only. */ - terrainsAtT(t) { - const mark = this._pathMarkerAtT(t); - if ( !mark ) return []; - - const terrainLevels = this.constructor.terrainLevelsForKey(mark.terrainKey); - return new Set(terrainLevels.map(t => t.terrain)); + clearActivePath() { + this.#activePath.length = 0; } + /** + * @param {number} t Percent distance along origin --> destination ray in 2d. + * @returns {PIXI.Point} + */ + pointAtT(t) { return this.origin.to2d().projectToward(this.destination.to2d(), t); } + /** * List all terrain levels encountered at this point along the path. * @param {number} t Percent distance along the ray - * @returns {TerrainLevel[]} Terrains at that location. + * @returns {Set} Terrains at that location. */ terrainLevelsAtT(t) { - const mark = this._pathMarkerAtT(t); - if ( !mark ) return []; - return this.constructor.terrainLevelsForKey(mark.terrainKey); + const mark = this.constructor.markerAtT(t, this.path); + if ( !mark ) return new Set(); + return mark.terrains; } /** @@ -141,26 +145,9 @@ export class TravelTerrainRay { * @returns {Set} Active terrains at this location, given the path elevation. */ activeTerrainsAtT(t) { - const terrainLevels = this.activeTerrainLevelsAtT(t); - return new Set(terrainLevels.map(t => t.terrain)); - } - - /** - * List terrain levels that are enabled given the elevation of the path at this point. - * Depending on the terrain setting, it may be enabled for a specific fixed elevation range, - * or be enabled based on a range relative to the ground terrain or the layer elevation. - * @param {number} t Percent distance along the ray - * @returns {TerrainLevel[]} Terrains enabled at that location. - */ - activeTerrainLevelsAtT(t) { - const mark = this._pathMarkerAtT(t); - if ( !mark ) return []; - - // Filter the active terrains based on elevation and position at this mark. - const location = this.pointAtT(t); - const elevation = mark.elevation; - const terrains = this.constructor.terrainLevelsForKey(mark.terrainKey); - return terrains.filter(t => t.activeAt(elevation, location)); + const mark = this.constructor.markerAtT(t, this.activePath); + if ( !mark ) return new Set(); + return mark.terrains; } /** @@ -168,20 +155,12 @@ export class TravelTerrainRay { * @param {number} t Percent distance along the ray * @returns {object|undefined} The maker */ - _pathMarkerAtT(t) { - const path = this.path; + static markerAtT(t, path) { if ( t >= 1 ) return path.at(-1); if ( t <= 0 ) return path.at(0); return path.findLast(mark => mark.t <= t); } - /** - * Unique terrains on the ray nearest to a point on the canvas. - * @param {Point} pt Point to check - * @returns {TerrainLevel[]} Terrains nearest to that location on the ray. - */ - terrainsAtClosestPoint(pt) { return this.terrainsAtT(this.tForPoint(pt)); } - /** * Unique terrains that are enabled given the elevation of the path at the point * on the ray nearest to this canvas point. @@ -198,15 +177,7 @@ export class TravelTerrainRay { terrainLevelsAtClosestPoint(pt) { return this.terrainLevelsAtT(this.tForPoint(pt)); } /** - * List terrain levels that are enabled given the elevation of the path at the point - * on the ray nearest to this canvas point. - * @param {Point} pt Point to check - * @returns {TerrainLevel[]} Terrains enabled nearest to that location on the ray. - */ - activeTerrainLevelsAtClosestPoint(pt) { return this.activeTerrainLevelsAtT(this.tForPoint(pt)); } - - /** - * Closest point on the ray and return the t value for that location. + * Find closest point on the ray and return the t value for that location. * @param {Point} pt Point to use to determine the closest point to the ray * @returns {number} The t value, where origin = 0, destination = 1 */ @@ -225,92 +196,426 @@ export class TravelTerrainRay { return Math.sqrt(dist2 / delta.magnitudeSquared()); } + /** + * Tiles with terrains that overlap this travel ray. + * @returns {Set} + */ + terrainTilesInPath() { + const { origin, destination } = this; + const xMinMax = Math.minMax(origin.x, destination.x); + const yMinMax = Math.minMax(origin.y, destination.y); + const bounds = new PIXI.Rectangle(xMinMax.min, yMinMax.min, xMinMax.max - xMinMax.min, yMinMax.max - yMinMax.min); + const collisionTest = (o, _rect) => o.t.hasAttachedTerrain + && o.t.bounds.lineSegmentIntersects(origin, destination, { inside: true }); + return canvas.tiles.quadtree.getObjects(bounds, { collisionTest }); + } + + /** + * @typedef {object} Marker + * + * Object that represents a point along the path where the terrain set changes. + * Only in the flat 2d dimensions. Elevation handled elsewhere (active terrains). + * From that t position onward towards t = 1, terrains are as provided in the set. + * @property {number} t Percentage along the travel ray + * @property {Set} terrains What terrains are found at that t location + * @property {number} elevation Elevation at this t + * @property {string} type Originating type for this marker. elevation|tile|canvas|template + */ + /** * Get each point at which there is a terrain change. */ _walkPath() { + this.clearPath(); const path = this.#path; - path.length = 0; - let evMarkers = []; - const { pixelCache } = canvas.terrain; - - // Account for any elevation changes due to EV. - if ( game.modules.get("elevatedvision")?.active ) { - const ter = new canvas.elevation.TravelElevationRay(this.#token, - { origin: this.origin, destination: this.destination }); - evMarkers = ter._walkPath(); + + // Retrieve points of change along the ray: + // Combine and sort from t = 0 to t = 1. + const combinedMarkers = [ + ...this._elevationMarkers(), + ...this._canvasTerrainMarkers(), + ...this._tilesTerrainMarkers(), + ...this._templatesTerrainMarkers() + ].sort((a, b) => a.t - b.t); + if ( !combinedMarkers.length ) return []; + + // Walk along the markers, indicating at each step: + // - What terrains are present from this point forward. + // - What the current elevation step is from this point forward. + // Must track each terrain marker set to know when terrains have been removed. + const currTerrains = { + canvas: new Set(), + tile: new Set(), + template: new Set() + }; + + // Initialize. + let prevMarker = { t: 0, terrains: new Set() }; + const finalMarkers = [prevMarker]; + + // Combine markers with the same t (2d location). + // Track terrain levels that are present at each location. + for ( const marker of combinedMarkers ) { + const sameT = marker.t.almostEqual(prevMarker.t); + const currMarker = sameT ? prevMarker : mergeObject(prevMarker, { t: marker.t }, { inplace: false }); + if ( !sameT ) finalMarkers.push(currMarker); + if ( marker.type === "elevation" ) { + currMarker.elevation = marker.elevation; + continue; + } + currTerrains[marker.type] = marker.terrains; + currMarker.terrains = currTerrains.canvas.union(currTerrains.tile).union(currTerrains.template); // Copy + } + return this.#trimPath(finalMarkers, path); + } + + /** + * Trim path markers where terrains and elevation have not changed from the previous marker. + * @param {Marker[]} oldPath Array of markers to trim + * @param {Marker[]} [trimmedPath=[]] Optional (usually empty) array to place the trimmed markers + * @returns {Marker[]} The trimmedPath array, for convenience. + */ + #trimPath(oldPath, trimmedPath = [], skipElevation = false) { + let prevMarker = oldPath[0]; + trimmedPath.push(prevMarker); + const numMarkers = oldPath.length; + for ( let i = 1; i < numMarkers; i += 1 ) { + const currMarker = oldPath[i]; + if ( (skipElevation || prevMarker.elevation === currMarker.elevation) + && prevMarker.terrains.equals(currMarker.terrains) ) { + console.debug("TravelTerrainRay skipping duplicate marker."); + continue; + } + trimmedPath.push(currMarker); + prevMarker = currMarker; } + return trimmedPath; + } - // Find all the points of terrain change. - // TODO: Handle layers. + /** + * Retrieve elevation change markers along the path. + * @returns {Marker[]} + */ + _elevationMarkers() { + if ( !game.modules.get("elevatedvision")?.active ) return []; + const ter = new canvas.elevation.TravelElevationRay(this.#token, + { origin: this.origin, destination: this.destination }); + const evMarkers = ter._walkPath(); + return evMarkers.map(obj => { + return { + t: obj.t, + elevation: obj, + type: "elevation" + }; + }); + } + + /** + * Retrieve canvas terrain change markers along the path. + * @returns {Marker[]} + */ + _canvasTerrainMarkers() { + const pixelCache = canvas.terrain.pixelCache; const terrainMarkers = pixelCache._extractAllMarkedPixelValuesAlongCanvasRay( this.origin, this.destination, this.#markTerrainFn); - terrainMarkers.forEach(obj => { - obj.t = this.tForPoint(obj); + return terrainMarkers.map(obj => { + const key = new TerrainKey(obj.currPixel); + return { + t: this.tForPoint(obj), + terrains: new Set(canvas.terrain._layersToTerrainLevels(key.toTerrainLayers())), + type: "canvas" + }; }); + } - // Add each terrain and elevation to a map and then combine into a single entry for each t. - const markerMap = this._markerMap; - markerMap.clear(); - evMarkers.forEach(m => markerMap.set(m.t, { elevation: m })); - terrainMarkers.forEach(m => { - if ( markerMap.has(m.t) ) { - const marker = markerMap.get(m.t); - marker.terrains = m; - } else markerMap.set(m.t, { terrains: m }); + /** + * Retrieve tile terrain change markers along the path + * @returns {Marker[]} + */ + _tilesTerrainMarkers() { + const { origin, destination } = this; + const collisionTest = (o, _rect) => { + const tile = o.t; + if ( !tile.hasAttachedTerrain ) return false; + const pixelCache = tile.evPixelCache; + const thresholdBounds = pixelCache.getThresholdCanvasBoundingBox(CONFIG[MODULE_ID].alphaThreshold); + return thresholdBounds.lineSegmentIntersects(origin, destination, { inside: true }); + }; + + return this.#placeablesTerrainMarkers( + canvas.tiles.quadtree, + collisionTest, + this._tileTerrainMarkers.bind(this)); + } + + /** + * Retrieve tile terrain change markers along the path for a single tile. + * @param {Tile} tile + * @returns {Marker[]]} + */ + _tileTerrainMarkers(tile) { + if ( !tile.hasAttachedTerrain ) return []; + + // If tile alpha is set to 1, tile is treated as fully transparent. + const tileAlpha = tile.document.getFlag(MODULE_ID, FLAGS.ALPHA_THRESHOLD); + if ( tileAlpha === 1 ) return []; + + // If the tile should be treated as opaque, just identify the entry and exit points along the ray. + // May have only one if start or end point is within the bounds. + if ( !tileAlpha ) { + const thresholdBounds = pixelCache.getThresholdCanvasBoundingBox(CONFIG[MODULE_ID].alphaThreshold); + return this.#placeableTerrainMarkers(tile, "tile", thresholdBounds); + } + + // If the tile should be treated as opaque, just identify the entry and exit points along the ray. + // May have only one if start or end point is within the bounds. + const terrains = new Set([tile.attachedTerrain]); + const nullSet = new Set(); + const pixelCache = tile.evPixelCache; + const { origin, destination } = this; + + // Track the ray across the tile, locating points where transparency starts or stops. + const pixelAlpha = tileAlpha * 255; // Convert alpha percentage to pixel values. + const markTileFn = (curr, prev) => (prev < pixelAlpha) ^ (curr < pixelAlpha); // Find change above/below threshold. + const tileMarkers = pixelCache._extractAllMarkedPixelValuesAlongCanvasRay( + origin, destination, markTileFn, { alphaThreshold: CONFIG[MODULE_ID].alphaThreshold }); + return tileMarkers.map(obj => { + const [addTerrains, removeTerrains] = obj.currPixel < pixelAlpha ? [nullSet, terrains] : [terrains, nullSet]; + return { + t: this.tForPoint(obj), + addTerrains, + removeTerrains, + type: "tile" + }; }); + } - const originLayers = canvas.terrain._terrainLayersAt(this.origin); - let currTerrainKey = TerrainKey.fromTerrainLayers(originLayers); - let currE = this.origin.z; - const tValues = [...markerMap.keys()].sort((a, b) => a - b); - for ( const t of tValues ) { - const markerObj = markerMap.get(t); - const pathObj = {}; - const eObj = markerObj.elevation; - const tObj = markerObj.terrains; - - if ( eObj ) { - eObj.currElevationPixel = eObj.currPixel; - eObj.prevElevationPixel = eObj.prevPixel; - foundry.utils.mergeObject(pathObj, eObj); - pathObj.terrainKey = currTerrainKey; - currE = pathObj.elevation; - } + /** + * Retrieve tile terrain change markers along the path + * @returns {Marker[]} + */ + _templatesTerrainMarkers() { + const { origin, destination } = this; + const collisionTest = (o, _rect) => { + const template = o.t; + if ( !template.hasAttachedTerrain ) return false; + const shape = template.shape.translate(template.x, template.y); + return shape.lineSegmentIntersects(origin, destination, { inside: true }); + }; + + return this.#placeablesTerrainMarkers( + canvas.templates.quadtree, + collisionTest, + this._templateTerrainMarkers.bind(this)); + } + + /** + * Helper method to retrieve placeable object change markers along the path. + */ + #placeablesTerrainMarkers(quadtree, collisionTest, markerFn) { + const { origin, destination } = this; + const xMinMax = Math.minMax(origin.x, destination.x); + const yMinMax = Math.minMax(origin.y, destination.y); + const bounds = new PIXI.Rectangle(xMinMax.min, yMinMax, xMinMax.max - xMinMax.min, yMinMax.max - yMinMax.min); + const placeables = quadtree.getObjects(bounds, { collisionTest }); + const markers = []; + placeables.forEach(placeable => markers.push(...markerFn(placeable))); + markers.sort((a, b) => a.t - b.t); + + // Combine the different tiles. + const currTerrains = new Set(); + for ( const marker of markers ) { + marker.addTerrains.forEach(t => currTerrains.add(t)); + marker.removeTerrains.forEach(t => currTerrains.delete(t)); + marker.terrains = new Set(currTerrains); + } + return markers; + } - if ( tObj ) { - tObj.currTerrainPixel = tObj.currPixel; - tObj.prevTerrainPixel = tObj.prevPixel; - foundry.utils.mergeObject(pathObj, tObj); - pathObj.elevation ??= currE; - currTerrainKey = pathObj.terrainKey = new TerrainKey(tObj.currPixel); + /** + * Retrieve template terrain change markers along the path for a single template. + * @param {MeasuredTemplate} template + * @returns {Marker[]]} + */ + _templateTerrainMarkers(template) { + const shape = template.shape.translate(template.x, template.y); + return this.#placeableTerrainMarkers(template, "template", shape); + } + + /** + * Helper method to return a set of markers for a single placeable encountered along the path. + */ + #placeableTerrainMarkers(placeable, type, bounds) { + if ( !placeable.hasAttachedTerrain ) return []; + + // If the tile should be treated as opaque, just identify the entry and exit points along the ray. + // May have only one if start or end point is within the bounds. + const placeables = new Set([placeable.attachedTerrain]); + const nullSet = new Set(); + const { origin, destination } = this; + const ixs = bounds.segmentIntersections(origin, destination); + const markers = []; + + // We can reasonably assume in/out pattern. So if we start inside the template, the first + // intersection is outside, and vice-versa. + let inside = bounds.contains(origin.x, origin.y); + if ( inside ) markers.push({ + t: 0, + addTerrains: placeables, + removeTerrains: nullSet, + type + }); + for ( const ix of ixs ) { + inside ^= true; // Alternate true/false. + const [addTerrains, removeTerrains] = inside ? [placeables, nullSet] : [nullSet, placeables]; + markers.push({ + t: this.tForPoint(ix), + addTerrains, + removeTerrains, + type + }); + } + return markers; + } + + + /** + * Determine the active terrains along the 3d path. + * If elevation is set for the path markers, elevation moves in steps. + * Otherwise, elevation is pro-rated between origin and destination elevations. + * For each terrain encountered, find the actual active start point along the 3d ray. + * Add that active marker and remove the level marker. + * Keep the active marker only if its terrain is active when we get to that t point. + * Terrains cannot duplicate, so if the same terrain is active from, say, tile and canvas, + * only one applies. + */ + _constructActivePath() { + this.clearActivePath(); + const path = this.path; + if ( !path.length ) return []; + const activePath = this.#activePath; + + // For each terrain, determine its starting and ending t value based on elevation and position. + // Insert into array the start and end. + // Then walk the array, tracking what is active. + const tValues = []; + const elevationChange = !this.origin.z.almostEqual(this.destination.z); + const steppedElevation = MODULES_ACTIVE.ELEVATED_VISION || !elevationChange; + let currLevels = new Set(); + + for ( const marker of path ) { + const t = marker.t; + const removedLevels = currLevels.difference(marker.terrains); + const location = this.point3dAtT(t); + const elevation = marker.elevation ?? location.z; + currLevels = marker.terrains; + removedLevels.forEach(level => tValues.push({ t, removeTerrain: level.terrain, elevation })); + + for ( const terrainLevel of marker.terrains ) { + const terrain = terrainLevel.terrain; + const { min: minZ, max: maxZ} = terrainLevel.elevationRangeZ(location); // TODO: Need pixel units, not grid units + const currentlyActive = elevation.between(minZ, maxZ); + if ( currentlyActive ) tValues.push({ t, addTerrain: terrain, elevation }); + + // If elevation is stepped using EV, then every elevation change along the path is marked. + // We can assume fixed elevation until the next elevation change. + // So if the terrain is active, we can simply add it. + if ( steppedElevation ) continue; + + // Determine the start and end points. + const minT = this.tForElevation(minZ); + const maxT = this.tForElevation(maxZ); + const [startT, endT] = minT > maxT ? [maxT, minT] : [minT, maxT]; + if ( startT > t && startT <= 1) tValues.push({ + t: startT, + addTerrain: terrain, + elevation: this.point3dAtT(startT).z }); + if ( endT > t && endT <= 1 ) tValues.push({ + t: endT, + removeTerrain: terrain, + elevation: this.point3dAtT(endT).z }); } + } - // Remove unneeded/confusing properties. - delete pathObj.currPixel; - delete pathObj.prevPixel; - path.push(pathObj); + // Sort lower t values first. + tValues.sort((a, b) => a.t - b.t); + + // Now consolidate the t value array, tracking active terrains as we go. + // Similar to walkPath. + // Initialize. + let prevMarker = { t: 0, elevation: this.origin.z, terrains: new Set() }; + const finalMarkers = [prevMarker]; + for ( const marker of tValues ) { + const sameT = marker.t.almostEqual(prevMarker.t); + const currMarker = sameT ? prevMarker : { + t: marker.t, + elevation: marker.elevation, + terrains: new Set(prevMarker.terrains) }; + if ( !sameT ) finalMarkers.push(currMarker); + if ( marker.addTerrain ) currMarker.terrains.add(marker.addTerrain); + if ( marker.removeTerrain ) currMarker.terrains.delete(marker.removeTerrain); + prevMarker = currMarker; } - return this.#path; + // Trim where terrains and elevation have not changed from the previous step. + // If EV is not active, we can trim the duplicate terrains regardless of elevation, + // because elevation is calculated. + const skipElevation = !MODULES_ACTIVE.ELEVATED_VISION; + return this.#trimPath(finalMarkers, activePath, skipElevation); } -} + /** + * @param {number} t Percent distance along origin --> destination ray + * @returns {Point3d} + */ + point3dAtT(t) { return this.origin.projectToward(this.destination, t); } -/** - * Convert a terrain key to individual keys, one per terrain/level combination. - * @param {TerrainKey} key - * @returns {TerrainKey[]} - */ -function splitTerrainKey(key) { - const keys = []; - const layers = key.toTerrainLayers(); - const ln = layers.length; - for (let i = 0; i < ln; i += 1 ) { - const terrainValue = layers[i]; - if ( !terrainValue ) continue; - keys.push(TerrainKey.fromTerrainValue(terrainValue, i)); + /** + * For given elevation, find where on the ray that elevation occurs. + * @param {number} z Elevation to find, in pixel coordinates. + * @returns {number|undefined} The t value, where origin = 1, destination = 1. + * Undefined if elevation does not change. + */ + tForElevation(z) { + const { origin, destination } = this; + if ( origin.z.almostEqual(destination.z) ) return undefined; + const dz = destination.z - origin.z; + return (z - origin.z) / dz; } - return keys; + +} + +/* Testing +Point3d = CONFIG.GeometryLib.threeD.Point3d +let [target] = game.user.targets +token = canvas.tokens.controlled[0] +destination = Point3d.fromTokenCenter(target); +origin = Point3d.fromTokenCenter(token) +ttr = new canvas.terrain.TravelTerrainRay(_token, { origin, destination }) + +ttr._canvasTerrainMarkers() +ttr._tilesTerrainMarkers() +ttr._walkPath() +ttr._constructActivePath() + + +// Spit out t and terrain names in the set +function pathSummary(path) { + pathObj = path.map(obj => { + return { t: obj.t, elevation: obj.elevation, terrains: [...obj.terrains].map(t => t.name).join(", ") } + }); + console.table(pathObj); + return pathObj; } +path = ttr._walkPath(); +activePath = ttr._constructActivePath(); +pathSummary(path) +pathSummary(activePath) + +pathObj = path.map(obj => { + return { t: obj.t, terrains: [...obj.terrains].map(t => t.id).join(", ") } +}) +console.table(pathObj) + +*/ diff --git a/scripts/changelog.js b/scripts/changelog.js index 910f6ff..2ca2cd7 100644 --- a/scripts/changelog.js +++ b/scripts/changelog.js @@ -42,6 +42,20 @@ Hooks.once("ready", () => { Feel free to also suggest improvements or new features by filing a new issue there.` }) + .addEntry({ + version: "0.1.0", + title: "Tiles and Templates", + body: `\ + You can now add terrains to tiles or templates. The elevation of the tile or template is + taken into account if the terrain effect area is set to be relative to the level (as opposed to absolute). + + In all cases, the outer transparent border of the tile will be ignored. Note that in the tile configuration, + you can choose whether to ignore inner transparent portions of the tile. For example, you might have a tile of a + rectangular balcony that is open (transparent) in the center, and only have the terrain apply to the non-transparent + balcony portion.` + }) + + .build() ?.render(true); }); diff --git a/scripts/const.js b/scripts/const.js index 1d965c9..c984f1b 100644 --- a/scripts/const.js +++ b/scripts/const.js @@ -6,6 +6,11 @@ export const MODULE_ID = "terrainmapper"; export const SOCKETS = { socket: null }; +export const TEMPLATES = { + TILE: `modules/${MODULE_ID}/templates/tile-config.html`, + MEASURED_TEMPLATE: `modules/${MODULE_ID}/templates/template-config.html`, +} + export const LABELS = { ANCHOR_OPTIONS: { absolute: "terrainmapper.settings.terrain.anchorOptions.absolute", @@ -20,6 +25,15 @@ export const LABELS = { } }; +export const MODULES_ACTIVE = { + ELEVATED_VISION: false, +}; + +// Hook init b/c game.modules is not initialized at start. +Hooks.once("init", function() { + MODULES_ACTIVE.ELEVATED_VISION = game.modules.get("elevatedvision")?.active; +}); + export const FLAGS = { ANCHOR: { VALUE: "anchor", @@ -37,6 +51,9 @@ export const FLAGS = { COLOR: "color", LAYER_ELEVATIONS: "layerElevations", // Stored per scene. + + ATTACHED_TERRAIN: "attachedTerrain", // Stored in Tile and MeasuredTemplate documents + ALPHA_THRESHOLD: "alphaThreshold" // Stored in Tile documents }; diff --git a/scripts/glsl/TerrainLayerShader.js b/scripts/glsl/TerrainLayerShader.js index 6b335d9..7c9c1a3 100644 --- a/scripts/glsl/TerrainLayerShader.js +++ b/scripts/glsl/TerrainLayerShader.js @@ -7,7 +7,6 @@ PIXI import { defineFunction } from "./GLSLFunctions.js"; import { AbstractTerrainShader } from "./AbstractTerrainShader.js"; -import { Terrain } from "../Terrain.js"; import { Settings } from "../settings.js"; const MAX_TERRAINS = 16; // Including 0 as no terrain. @@ -54,8 +53,6 @@ for ( const e of canvas.terrain._shapeQueue.elements ) { canvas.stage.removeChild(e.graphics) } - - draw = new Draw(); for ( const e of canvas.terrain._shapeQueue.elements ) { const t = canvas.terrain.sceneMap.get(e.shape.pixelValue); @@ -69,8 +66,6 @@ canvas.stage.addChild(s) s.anchor.set(0.5) s.scale.set(.1, .1) - - */ @@ -166,7 +161,7 @@ void main() { static defaultUniforms = { uTerrainSampler0: 0, uTerrainSampler1: 0, - // uTerrainColors: new Uint8Array(MAX_TERRAINS * 4).fill(0) + // Unused: uTerrainColors: new Uint8Array(MAX_TERRAINS * 4).fill(0) uTerrainColors: new Array(MAX_TERRAINS * 4).fill(0), uTerrainIcon: 0, uTerrainLayer: 0 @@ -177,7 +172,7 @@ void main() { defaultUniforms.uTerrainSampler0 = tm._terrainTextures[0]; defaultUniforms.uTerrainSampler1 = tm._terrainTextures[1]; const shader = super.create(defaultUniforms); - shader.updateTerrainColors(); + shader.updateAllTerrainColors(); shader.updateTerrainIcons(); shader.updateTerrainLayer(); return shader; @@ -198,35 +193,29 @@ void main() { /** * Update the terrain colors represented in the scene. */ - updateTerrainColors() { + updateAllTerrainColors() { const colors = this.uniforms.uTerrainColors; colors.fill(0); - canvas.terrain.sceneMap.forEach(t => { - const i = t.pixelValue; - const idx = i * 4; - // const rgba = this.constructor.getColorArray(t.color).map(x => x * 255); - // colors.set(rgba, idx); - - const rgba = this.constructor.getColorArray(t.color); - colors.splice(idx, 4, ...rgba); - }); + canvas.terrain.sceneMap.forEach(t => this.updateTerrainColor(t)); } /** - * Update the terrain layer currently represented in the scene. + * Update a single terrain's color. + * @param {Terrain} t */ - updateTerrainLayer() { - this.uniforms.uTerrainLayer = canvas.terrain?.toolbar?.currentLayer ?? Settings.getByName("CURRENT_LAYER") ?? 0; + updateTerrainColor(t) { + const colors = this.uniforms.uTerrainColors; + const i = t.pixelValue; + const idx = i * 4; + const rgba = [...t.color.rgb, 1] + colors.splice(idx, 4, ...rgba); } /** - * Return the color array for a given hex. - * @param {number} hex Hex value for color with alpha - * @returns {number[4]} + * Update the terrain layer currently represented in the scene. */ - static getColorArray(hex) { - const c = new Color(hex); - const alpha = 1; - return [...c.rgb, alpha]; + updateTerrainLayer() { + this.uniforms.uTerrainLayer = canvas.terrain?.toolbar?.currentLayer + ?? Settings.get(Settings.KEYS.CURRENT_LAYER) ?? 0; } } diff --git a/scripts/module.js b/scripts/module.js index 5c9bed2..b22023e 100644 --- a/scripts/module.js +++ b/scripts/module.js @@ -14,6 +14,7 @@ import { TerrainMap } from "./TerrainMap.js"; import { EffectHelper } from "./EffectHelper.js"; import { PATCHER, initializePatching } from "./patching.js"; import { registerGeometry } from "./geometry/registration.js"; +import { registerElevationConfig } from "./geometry/elevation_configs.js"; import { terrainEncounteredDialog, updateTokenDocument } from "./Token.js"; import { TerrainLayerShader } from "./glsl/TerrainLayerShader.js"; @@ -34,6 +35,7 @@ import "./changelog.js"; */ Hooks.once("init", function() { initializePatching(); + initializeConfig(); initializeAPI(); registerGeometry(); TerrainLayer.register(); @@ -46,6 +48,8 @@ Hooks.once("init", function() { */ Hooks.once("setup", function() { Settings.registerAll(); + registerElevationConfig("TileConfig", "Terrain Mapper"); + registerElevationConfig("MeasuredTemplateConfig", "Terrain Mapper"); }); /** @@ -108,6 +112,17 @@ function initializeAPI() { }; } +function initializeConfig() { + CONFIG[MODULE_ID] = { + /** + * Alpha threshold below which a tile is considered transparent for purposes of terrain. + * @type {number} Between 0 and 1 + */ + alphaThreshold: 0.75 + }; + +} + /* Data Storage 1. Layer elevations: Scene Flag diff --git a/scripts/patching.js b/scripts/patching.js index ac89d20..ecdc48f 100644 --- a/scripts/patching.js +++ b/scripts/patching.js @@ -7,23 +7,34 @@ game import { Patcher } from "./Patcher.js"; import { PATCHES_SidebarTab, PATCHES_ItemDirectory } from "./settings.js"; +import { PATCHES as PATCHES_ActiveEffect } from "./ActiveEffect.js"; import { PATCHES as PATCHES_ActiveEffectConfig } from "./ActiveEffectConfig.js"; import { PATCHES as PATCHES_Canvas } from "./Canvas.js"; import { PATCHES as PATCHES_PIXI_Graphics } from "./PIXI_Graphics.js"; +import { PATCHES as PATCHES_MeasuredTemplate } from "./MeasuredTemplate.js"; +import { PATCHES as PATCHES_MeasuredTemplateConfig } from "./MeasuredTemplateConfig.js"; +import { PATCHES as PATCHES_Tile } from "./Tile.js"; +import { PATCHES as PATCHES_TileConfig } from "./TileConfig.js"; import { PATCHES as PATCHES_Token } from "./Token.js"; import { PATCHES as PATCHES_Wall } from "./Wall.js"; export const PATCHES = { + ActiveEffect: PATCHES_ActiveEffect, ActiveEffectConfig: PATCHES_ActiveEffectConfig, Canvas: PATCHES_Canvas, ItemDirectory: PATCHES_ItemDirectory, "PIXI.Graphics": PATCHES_PIXI_Graphics, + MeasuredTemplate: PATCHES_MeasuredTemplate, + MeasuredTemplateConfig: PATCHES_MeasuredTemplateConfig, SidebarTab: PATCHES_SidebarTab, + Tile: PATCHES_Tile, + TileConfig: PATCHES_TileConfig, Token: PATCHES_Token, Wall: PATCHES_Wall }; -export const PATCHER = new Patcher(PATCHES); +export const PATCHER = new Patcher(); +PATCHER.addPatchesFromRegistrationObject(PATCHES); export function initializePatching() { PATCHER.registerGroup("BASIC"); diff --git a/scripts/settings.js b/scripts/settings.js index c0262c4..0813b44 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -1,13 +1,13 @@ /* globals CONFIG, game, -getProperty, ItemDirectory */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ "use strict"; import { MODULE_ID } from "./const.js"; +import { ModuleSettingsAbstract } from "./ModuleSettingsAbstract.js"; export const PATCHES_SidebarTab = {}; export const PATCHES_ItemDirectory = {}; @@ -21,7 +21,7 @@ PATCHES_ItemDirectory.BASIC = {}; */ function removeTerrainsItemFromSidebar(dir) { if ( !(dir instanceof ItemDirectory) ) return; - const id = Settings.getByName("TERRAINS_ITEM"); + const id = Settings.get(Settings.KEYS.TERRAINS_ITEM); if ( !id ) return; const li = dir.element.find(`li[data-document-id="${id}"]`); li.remove(); @@ -38,7 +38,7 @@ PATCHES_SidebarTab.BASIC.HOOKS = { changeSidebarTab: removeTerrainItemHook }; PATCHES_ItemDirectory.BASIC.HOOKS = { renderItemDirectory: removeTerrainItemHook }; -export class Settings { +export class Settings extends ModuleSettingsAbstract { /** * Keys for all the settings used in this module. @@ -70,58 +70,6 @@ export class Settings { CHANGELOG: "changelog" }; - /** - * Retrive a specific setting. - * @param {string} key - * @returns {*} - */ - static get(key) { return game.settings.get(MODULE_ID, key); } - - /** - * Retrieve a specific setting by using the key name. - * @param {string} key - * @returns {*} - */ - static getByName(keyName) { - const key = getProperty(this.KEYS, keyName); - if ( !key ) console.warn(`Key ${keyName} does not exist.`); - return this.get(key); - } - - /** - * Set a specific setting. - * @param {string} key - * @param {*} value - * @returns {Promise} - */ - static async set(key, value) { return game.settings.set(MODULE_ID, key, value); } - - /** - * Set a specific setting by using the key name. - * @param {string} key - * @param {*} value - * @returns {Promise} - */ - static async setByName(keyName, value) { - const key = getProperty(this.KEYS, keyName); - if ( !key ) console.warn(`Key ${keyName} does not exist.`); - return this.set(key, value); - } - - /** - * Register a specific setting. - * @param {string} key Passed to registerMenu - * @param {object} options Passed to registerMenu - */ - static register(key, options) { game.settings.register(MODULE_ID, key, options); } - - /** - * Register a submenu. - * @param {string} key Passed to registerMenu - * @param {object} options Passed to registerMenu - */ - static registerMenu(key, options) { game.settings.registerMenu(MODULE_ID, key, options); } - /** * Register all settings */ @@ -204,15 +152,15 @@ export class Settings { img: "icons/svg/mountain.svg", type: "base" }); - await this.setByName("TERRAINS_ITEM", item.id); + await this.set(this.KEYS.TERRAINS_ITEM, item.id); } static get terrainEffectsItem() { - return game.items.get(this.getByName("TERRAINS_ITEM")); + return game.items.get(this.get(this.KEYS.TERRAINS_ITEM)); } /** @type {string[]} */ - static get expandedFolders() { return this.getByName("CONTROL_APP.EXPANDED_FOLDERS"); } + static get expandedFolders() { return this.get(this.KEYS.CONTROL_APP.EXPANDED_FOLDERS); } /** * Add a given folder id to the saved expanded folders. @@ -223,7 +171,7 @@ export class Settings { let folderArr = this.expandedFolders; folderArr.push(folderId); folderArr = [...new Set(folderArr)]; // Remove duplicates. - this.setByName("CONTROL_APP.EXPANDED_FOLDERS", folderArr); + this.set(this.KEYS.CONTROL_APP.EXPANDED_FOLDERS, folderArr); } /** @@ -233,14 +181,14 @@ export class Settings { */ static async removeExpandedFolder(id) { const expandedFolderArray = this.expandedFolders.filter(expandedFolder => expandedFolder !== id); - return this.setByName("CONTROL_APP.EXPANDED_FOLDERS", expandedFolderArray); + return this.set(this.KEYS.CONTROL_APP.EXPANDED_FOLDERS, expandedFolderArray); } /** * Remove all saved expanded folders. * @returns {Promise} Promise that resolves when the settings update complete. */ - static async clearExpandedFolders() { this.setByName("CONTROL_APP.EXPANDED_FOLDERS", []); } + static async clearExpandedFolders() { this.set(this.KEYS.CONTROL_APP.EXPANDED_FOLDERS, []); } /** * Check if given folder nae is expanded. @@ -255,7 +203,7 @@ export class Settings { * @returns {boolean} */ static isFavorite(id) { - const favorites = new Set(this.getByName("FAVORITES")); + const favorites = new Set(this.get(this.KEYS.FAVORITES)); return favorites.has(id); } @@ -264,9 +212,10 @@ export class Settings { * @param {string} id Active effect id */ static async addToFavorites(id) { - const favorites = new Set(this.getByName("FAVORITES")); + const key = this.KEYS.FAVORITES; + const favorites = new Set(this.get(key)); favorites.add(id); // Avoids duplicates. - return this.setByName("FAVORITES", [...favorites]); + return this.set(key, [...favorites]); } /** @@ -274,9 +223,10 @@ export class Settings { * @param {string} id */ static async removeFromFavorites(id) { - const favorites = new Set(this.getByName("FAVORITES")); + const key = this.KEYS.FAVORITES; + const favorites = new Set(this.get(key)); favorites.delete(id); // Avoids duplicates. - return this.setByName("FAVORITES", [...favorites]); + return this.set(key, [...favorites]); } } diff --git a/scripts/util.js b/scripts/util.js index a4188b8..8555b7b 100644 --- a/scripts/util.js +++ b/scripts/util.js @@ -4,6 +4,16 @@ PIXI /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ "use strict"; +/** + * Helper to inject configuration html into the application config. + */ +export async function injectConfiguration(app, html, data, template, findString, attachMethod = "append") { + const myHTML = await renderTemplate(template, data); + const form = html.find(findString); + form[attachMethod](myHTML); + app.setPosition(app.position); +} + /** * Capitalize the first letter of a string. * @param {string} str diff --git a/templates/active-effect-config.html b/templates/active-effect-config.html index 0c53f5e..32f8d1c 100644 --- a/templates/active-effect-config.html +++ b/templates/active-effect-config.html @@ -10,14 +10,14 @@
- {{colorPicker name=flags.terrainmapper.color value=data.flags.terrainmapper.color}} + {{colorPicker name="flags.terrainmapper.color" value=data.flags.terrainmapper.color}}
- {{ selectOptions terrainmapper.anchorOptions selected=data.flags.terrainmapper.anchor localize=true }}
diff --git a/templates/template-config.html b/templates/template-config.html new file mode 100644 index 0000000..78d7e80 --- /dev/null +++ b/templates/template-config.html @@ -0,0 +1,12 @@ +
+ {{ localize "terrainmapper.name" }} +
+ +
+ +
+
+ +
diff --git a/templates/tile-config.html b/templates/tile-config.html new file mode 100644 index 0000000..8dcc96e --- /dev/null +++ b/templates/tile-config.html @@ -0,0 +1,23 @@ +
+ {{ localize "terrainmapper.name" }} + +
+ +
+ +
+
+ +
+ +
+ {{rangePicker name="flags.terrainmapper.alphaThreshold" value=(ifThen data.flags.terrainmapper.alphaThreshold data.flags.terrainmapper.alphaThreshold 0) step=0.01 min=0 max=1}} +
+

{{ localize "terrainmapper.tile-config.transparency-threshold.hint" }}

+
+ + + +
\ No newline at end of file