From 4b3e13a78378e35e40906e4b539f6b9c229c8163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20V=C3=B6gele?= Date: Fri, 28 Oct 2022 10:44:52 +0200 Subject: [PATCH] Re-design the API to allow it to be used without libwrapper --- README.md | 17 +++++- classes/ruleprovider.js | 41 +++++++++++++ classes/terrainlayer.js | 27 +-------- js/api.js | 128 ++++++++++++++++++++++++++++++++++++++++ js/settings.js | 13 ++++ lang/en.json | 5 ++ terrain-main.js | 8 +++ 7 files changed, 213 insertions(+), 26 deletions(-) create mode 100644 classes/ruleprovider.js create mode 100644 js/api.js diff --git a/README.md b/README.md index 3af521a..2b411ef 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,22 @@ A list of Terrain Environments can be found by calling `canvas.terrain.getEnviro if you need to find the terrain at a certain grid co-ordinate you can call `canvas.terrain.terrainFromGrid(x, y);` or `canvas.terrain.terrainFromPixels(x, y);`. This is useful if you want to determine if the terrain in question is water, and use the swim speed instead of walking speed to calculate speed. ### Integrating game system rules -Other modules or game systems systems can indicate to Enhanced Terrain Layer how a given token should interact with the terrain present in a scene and how to handle stacked terrain. That way it's possible to integrate the rules of a given game system into Enhanced Terrain Layer. To do this, the function `canvas.terrain.__proto__.calculateCombinedCost` should be overridden using libwrapper. The function receives two parameters: The first parameter is a list of `TerrainInfo` objects (more on those in the next paragraph) for which the function should calculate the cost. The second parameter is an `options` object that contains all the options that were specified by the caller of `canvas.terrain.cost`. The function shall return a number that indicates a multiplier indicating how much more expensive it is to move through a square of indicated terrain than moving through a square that has no terrain at all. For example if moving thorugh a given terrain should be twice as expensive as moving through no terrain, the function should return 2. If moving through the given terrain should be equally expensive as moving through no terrain, the function should return 1. +Other modules or game systems systems can indicate to Enhanced Terrain Layer how a given token should interact with the terrain present in a scene and how to handle stacked terrain. That way it's possible to integrate the rules of a given game system into Enhanced Terrain Layer. Enhanced Terrain Layer offers an API to which modules and game systems can register to provide the implementation of the respective rules to Enhanced Terrain Layer. Registering with the API works as follows: + +```javascript +Hooks.once("enhancedTerrainLayer.ready", (RuleProvider) => { + class ExampleGameSystemRuleProvider extends RuleProvider { + calculateCombinedCost(terrain, options) { + let cost; + // Calculate the cost for this terrain + return cost; + } + } + enhancedTerrainLayer.registerModule("my-module-id", ExampleGameSystemRuleProvider); +}); +``` + +If you're accessing the Enahanced Terrain Layer API from a game system, use `registerSystem` instead of `registerModule`. The `calculateCombinedCost` needs to implemented in a way that reflects the rules of your system. The function receives two parameters: The first parameter is a list of `TerrainInfo` objects (more on those in the next paragraph) for which the function should calculate the cost. The second parameter is an `options` object that contains all the options that were specified by the caller of `canvas.terrain.cost`. The function shall return a number that indicates a multiplier indicating how much more expensive it is to move through a square of indicated terrain than moving through a square that has no terrain at all. For example if moving thorugh a given terrain should be twice as expensive as moving through no terrain, the function should return 2. If moving through the given terrain should be equally expensive as moving through no terrain, the function should return 1. The `TerrainInfo` objects received by this function are wrappers around objects that create terrain and allow unified access to the terrain specific properties. The following properties are offered by `TerrainInfo` objects: - `cost`: The cost multiplicator that has been specified for this type of terrain diff --git a/classes/ruleprovider.js b/classes/ruleprovider.js new file mode 100644 index 0000000..f9c6e93 --- /dev/null +++ b/classes/ruleprovider.js @@ -0,0 +1,41 @@ +export class RuleProvider { + calculateCombinedCost(terrain, options) { + let calculate = options.calculate || "maximum"; + let calculateFn; + if (typeof calculate == "function") { + calculateFn = calculate; + } else { + switch (calculate) { + case "maximum": + calculateFn = function (cost, total) { + return Math.max(cost, total); + }; + break; + case "additive": + calculateFn = function (cost, total) { + return cost + total; + }; + break; + default: + throw new Error(i18n("EnhancedTerrainLayer.ErrorCalculate")); + } + } + + let total = null; + for (const terrainInfo of terrain) { + if (typeof calculateFn == "function") { + total = calculateFn(terrainInfo.cost, total, terrainInfo.object); + } + } + return total ?? 1; + } + + /** + * Constructs a new instance of the speed provider + * + * This function should neither be called or overridden by rule provider implementations + */ + constructor(id) { + this.id = id; + } +} diff --git a/classes/terrainlayer.js b/classes/terrainlayer.js index 9e62d28..9967f91 100644 --- a/classes/terrainlayer.js +++ b/classes/terrainlayer.js @@ -5,6 +5,7 @@ import { TerrainDocument } from './terraindocument.js'; import { PolygonTerrainInfo, TemplateTerrainInfo, TokenTerrainInfo } from './terraininfo.js'; import { makeid, log, debug, warn, error, i18n, setting, getflag } from '../terrain-main.js'; import EmbeddedCollection from "../../../common/abstract/embedded-collection.mjs"; +import { calculateCombinedCost } from '../js/api.js'; export let environments = (key) => { return canvas.terrain.getEnvironments(); @@ -262,30 +263,6 @@ export class TerrainLayer extends PlaceablesLayer { return this.listTokenTerrain({ list: this.listMeasuredTerrain({ list: this.listTerrain(options), ...options }), ...options }) } - calculateCombinedCost(terrain, options = {}) { - let calculate = options.calculate || 'maximum'; - let calculateFn; - if (typeof calculate == 'function') - calculateFn = calculate; - else { - switch (calculate) { - case 'maximum': - calculateFn = function (cost, total) { return Math.max(cost, total); }; break; - case 'additive': - calculateFn = function (cost, total) { return cost + total; }; break; - default: - throw new Error(i18n("EnhancedTerrainLayer.ErrorCalculate")); - } - } - - let total = null; - for (const terrainInfo of terrain) { - if (typeof calculateFn == 'function') - total = calculateFn(terrainInfo.cost, total, terrainInfo.object); - } - return total ?? 1; - } - costWithTerrain(pts, terrain, options = {}) { const multipleResults = pts instanceof Array; pts = multipleResults ? pts : [pts]; @@ -303,7 +280,7 @@ export class TerrainLayer extends PlaceablesLayer { terrain = terrain.filter((t) => t.shape.contains(tx - t.object.x, ty - t.object.y) ); - const cost = this.calculateCombinedCost(terrain, options); + const cost = calculateCombinedCost(terrain, options); costs.push(cost); } diff --git a/js/api.js b/js/api.js new file mode 100644 index 0000000..74c9d6b --- /dev/null +++ b/js/api.js @@ -0,0 +1,128 @@ +import {RuleProvider} from "../classes/ruleprovider.js"; +import {i18n} from "../terrain-main.js"; + +const availableRuleProviders = {}; +let currentRuleProvider = undefined; + +function register(module, type, ruleProvider) { + const id = `${type}.${module.id}`; + const ruleProviderInstance = new ruleProvider(id); + setupProvider(ruleProviderInstance); + game.settings.settings.get("enhanced-terrain-layer.rule-provider").config = true; +} + +function setupProvider(ruleProvider) { + availableRuleProviders[ruleProvider.id] = ruleProvider; + refreshProviderSetting(); + updateRuleProviderVariable(); +} + +function refreshProviderSetting() { + const choices = {}; + for (const provider of Object.values(availableRuleProviders)) { + let dotPosition = provider.id.indexOf("."); + if (dotPosition === -1) { + dotPosition = provider.id.length; + } + const type = provider.id.substring(0, dotPosition); + const id = provider.id.substring(dotPosition + 1); + let text; + if (type === "bultin") { + text = i18n("EnhancedTerrainLayer.rule-provider.choices.builtin"); + } else { + let name; + if (type === "module") { + name = game.modules.get(id).title; + } else { + name = game.system.title; + } + text = game.i18n.format(`EnhancedTerrainLayer.rule-provider.choices.${type}`, {name}); + } + choices[provider.id] = text; + } + game.settings.settings.get("enhanced-terrain-layer.rule-provider").choices = choices; + game.settings.settings.get("enhanced-terrain-layer.rule-provider").default = + getDefaultRuleProvider(); +} + +function getDefaultRuleProvider() { + const providerIds = Object.keys(availableRuleProviders); + + // Game systems take the highest precedence for the being the default + const gameSystem = providerIds.find(key => key.startsWith("system.")); + if (gameSystem) return gameSystem; + + // If no game system is registered modules are next up. + // For lack of a method to select the best module we're just falling back to taking the next best module + // Object keys should always be sorted the same way so this should achive a stable default + const module = providerIds.find(key => key.startsWith("module.")); + if (module) return module; + + // If neither a game system or a module is found fall back to the native implementation + return providerIds[0]; +} + +export function updateRuleProviderVariable() { + // If the configured provider is registered use that one. If not use the default provider + const configuredProvider = game.settings.get("enhanced-terrain-layer", "rule-provider"); + currentRuleProvider = + availableRuleProviders[configuredProvider] ?? + availableRuleProviders[game.settings.settings.get("enhanced-terrain-layer.rule-provider")]; +} + +export function initApi() { + const builtinRuleProviderInstance = new RuleProvider("builtin"); + setupProvider(builtinRuleProviderInstance); +} + +export function registerModule(moduleId, ruleProvider) { + // Check if a module with the given id exists and is currently enabled + const module = game.modules.get(moduleId); + // If it doesn't the calling module did something wrong. Log a warning and ignore this module + if (!module) { + console.warn( + `Enhanced Terrain Layer | A module tried to register with the id "${moduleId}". However no active module with this id was found.` + + "This api registration call was ignored. " + + "If you are the author of that module please check that the id passed to `registerModule` matches the id in your manifest exactly." + + "If this call was made form a game system instead of a module please use `registerSystem` instead.", + ); + return; + } + // Using Enhanced Terrain Layer's id is not allowed + if (moduleId === "enhanced-terrain-layer") { + console.warn( + `Enhanced Terrain Layer | A module tried to register with the id "${moduleId}", which is not allowed. This api registration call was ignored. ` + + "If you're the author of the module please use the id of your own module as it's specified in your manifest to register to this api. " + + "If this call was made form a game system instead of a module please use `registerSystem` instead.", + ); + return; + } + + register(module, "module", ruleProvider); +} + +export function registerSystem(systemId, speedProvider) { + const system = game.system; + // If the current system id doesn't match the provided id something went wrong. Log a warning and ignore this module + if (system.id != systemId) { + console.warn( + `Drag Ruler | A system tried to register with the id "${systemId}". However the active system has a different id.` + + "This api registration call was ignored. " + + "If you are the author of that system please check that the id passed to `registerSystem` matches the id in your manifest exactly." + + "If this call was made form a module instead of a game system please use `registerModule` instead.", + ); + return; + } + + register(system, "system", speedProvider); +} + +export function calculateCombinedCost(terrain, options = {}) { + const cost = currentRuleProvider.calculateCombinedCost(terrain, options); + // Check if the provider returned a number. If not, log an error and fall back to returning 1 + if (isNaN(cost)) { + console.error(`The active rule provider returned an invalid cost value: ${cost}`); + return 1; + } + return cost; +} diff --git a/js/settings.js b/js/settings.js index 2ebf271..1fdaad1 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1,4 +1,5 @@ import { TerrainColor } from "../classes/terraincolor.js"; +import { updateRuleProviderVariable } from "./api.js"; export const registerSettings = function () { let modulename = "enhanced-terrain-layer"; @@ -144,6 +145,18 @@ export const registerSettings = function () { default: 4, type: Number }); + + game.settings.register(modulename, "rule-provider", { + name: "EnhancedTerrainLayer.rule-provider.name", + hint: "EnhancedTerrainLayer.rule-provider.hint", + scope: "world", + config: false, + default: "bulitin", + type: String, + choices: {}, + onChange: updateRuleProviderVariable, + }); + game.settings.register(modulename, 'showterrain', { scope: "world", config: false, diff --git a/lang/en.json b/lang/en.json index cdc6043..63f64d2 100644 --- a/lang/en.json +++ b/lang/en.json @@ -55,6 +55,11 @@ "EnhancedTerrainLayer.draw-border.hint": "Set if the border is drawn or not", "EnhancedTerrainLayer.terrain-image.name": "Terrain Image", "EnhancedTerrainLayer.terrain-image.hint": "Change the background texture for terrain", + "EnhancedTerrainLayer.rule-provider.name": "Rule provider", + "EnhancedTerrainLayer.rule-provider.hint": "Which rule provider should Enhanced Terrain Layer pull it's terrain rules from", + "EnhancedTerrainLayer.rule-provider.choices.builtin": "Built-in", + "EnhancedTerrainLayer.rule-provider.choices.module": "Module {name}", + "EnhancedTerrainLayer.rule-provider.choices.system": "System {name}", "EnhancedTerrainLayer.environment.arctic": "Arctic", "EnhancedTerrainLayer.environment.coast": "Coast", diff --git a/terrain-main.js b/terrain-main.js index 289e522..3f5a5eb 100644 --- a/terrain-main.js +++ b/terrain-main.js @@ -5,6 +5,8 @@ import { Terrain } from './classes/terrain.js'; import { TerrainDocument } from './classes/terraindocument.js'; import { TerrainShape } from './classes/terrainshape.js'; import { registerSettings } from "./js/settings.js"; +import { initApi, registerModule, registerSystem } from './js/api.js'; +import { RuleProvider } from './classes/ruleprovider.js'; let debugEnabled = 2; export let debug = (...args) => { @@ -478,6 +480,10 @@ Hooks.on('init', async () => { } } } + + initApi(); + + window.enhancedTerrainLayer = {registerModule, registerSystem}; }); Hooks.on("ready", () => { @@ -495,6 +501,8 @@ Hooks.on("ready", () => { canvas.terrain._setting["minimum-cost"] = setting("minimum-cost"); canvas.terrain._setting["maximum-cost"] = setting("maximum-cost"); } + + Hooks.callAll("enhancedTerrainLayer.ready", RuleProvider); }); Hooks.on('renderMeasuredTemplateConfig', (app, html, data) => {