Skip to content

Commit

Permalink
Merge pull request #117 from manuelVo/game-system-integration-no-libw…
Browse files Browse the repository at this point in the history
…rapper

Re-design the API to allow it to be used without libwrapper
  • Loading branch information
ironmonk88 authored Nov 11, 2022
2 parents 2313376 + 4b3e13a commit c62b443
Show file tree
Hide file tree
Showing 7 changed files with 213 additions and 26 deletions.
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions classes/ruleprovider.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
27 changes: 2 additions & 25 deletions classes/terrainlayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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];
Expand All @@ -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);
}

Expand Down
128 changes: 128 additions & 0 deletions js/api.js
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions js/settings.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { TerrainColor } from "../classes/terraincolor.js";
import { updateRuleProviderVariable } from "./api.js";

export const registerSettings = function () {
let modulename = "enhanced-terrain-layer";
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions terrain-main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -478,6 +480,10 @@ Hooks.on('init', async () => {
}
}
}

initApi();

window.enhancedTerrainLayer = {registerModule, registerSystem};
});

Hooks.on("ready", () => {
Expand All @@ -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) => {
Expand Down

0 comments on commit c62b443

Please sign in to comment.