} data
13 | */
14 | constructor(data) {
15 | this.data = data;
16 |
17 | const values = Object.values(this.data);
18 | this.revealed = values.filter((d) => d.reveal === true);
19 | this.partials = values.filter((d) => d.reveal === "partial");
20 | }
21 |
22 | /**
23 | * TODO: Consider returning default data instead of null
24 | * @param {CoordsOrOffset} coordsOrOffset
25 | * @returns {GridEntry | null}
26 | */
27 | get({ coords = null, offset = null }) {
28 | if (!coords && !offset) return null;
29 | offset ??= canvas.grid.getOffset(coords);
30 | const key = offsetToString(offset);
31 | return this.data[key] ?? null;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/link-foundry.mjs:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import path from "path";
3 | import process from "process";
4 | import prompts from "prompts";
5 |
6 | const MODULE_NAME = "world-explorer";
7 |
8 | const windowsInstructions = process.platform === "win32" ? ' Start with a drive letter ("C:\\").' : "";
9 | const dataPath = (
10 | await prompts({
11 | type: "text",
12 | name: "value",
13 | format: (v) => v.replace(/\W*$/, "").trim(),
14 | message: `Enter the full path to your Foundry data folder.${windowsInstructions}`,
15 | })
16 | ).value;
17 | if (!dataPath || !/\bData$/.test(dataPath)) {
18 | console.error(`"${dataPath}" does not look like a Foundry data folder.`);
19 | process.exit(1);
20 | }
21 | const dataPathStats = fs.lstatSync(dataPath, { throwIfNoEntry: false });
22 | if (!dataPathStats?.isDirectory()) {
23 | console.error(`No folder found at "${dataPath}"`);
24 | process.exit(1);
25 | }
26 |
27 | const symlinkPath = path.resolve(dataPath, "modules", MODULE_NAME);
28 | const symlinkStats = fs.lstatSync(symlinkPath, { throwIfNoEntry: false });
29 | if (symlinkStats) {
30 | const atPath = symlinkStats.isDirectory() ? "folder" : symlinkStats.isSymbolicLink() ? "symlink" : "file";
31 | const proceed = (
32 | await prompts({
33 | type: "confirm",
34 | name: "value",
35 | initial: false,
36 | message: `A "${MODULE_NAME}" ${atPath} already exists in the "systems" subfolder. Replace with new symlink?`,
37 | })
38 | ).value;
39 | if (!proceed) {
40 | console.log("Aborting.");
41 | process.exit();
42 | }
43 | }
44 |
45 | try {
46 | if (symlinkStats?.isDirectory()) {
47 | fs.rmSync(symlinkPath, { recursive: true, force: true });
48 | } else if (symlinkStats) {
49 | fs.unlinkSync(symlinkPath);
50 | }
51 | fs.symlinkSync(process.cwd(), symlinkPath);
52 | } catch (error) {
53 | if (error instanceof Error) {
54 | console.error(`An error was encountered trying to create a symlink: ${error.message}`);
55 | process.exit(1);
56 | }
57 | }
58 |
59 | console.log(`Symlink successfully created at "${symlinkPath}"!`);
60 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Module CI/CD
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v2
13 |
14 | - name: Use Node.js
15 | uses: actions/setup-node@v1
16 | with:
17 | node-version: '12.x'
18 |
19 | # create a zip file with all files required by the module to add to the release
20 | - name: Zip Files
21 | working-directory: ./
22 | run: zip -r ./module.zip ./module.json ./index.js ./templates/* ./lang/* ./module/* ./styles/* ./templates/*
23 |
24 | # Get the version from 'module.json'
25 | - name: Get Version
26 | shell: bash
27 | id: get-version
28 | run: echo "::set-output name=version::$(node ./.github/workflows/get-version.js)"
29 |
30 | # Generate changelog for release body
31 | - name: Changelog
32 | id: Changelog
33 | uses: scottbrenner/generate-changelog-action@master
34 | env:
35 | REPO: ${{ github.repository }}
36 |
37 | # Create a release for this specific version
38 | - name: Create Release
39 | id: create_version_release
40 | uses: ncipollo/release-action@v1
41 | with:
42 | allowUpdates: true # set this to false if you want to prevent updating existing releases
43 | name: ${{ steps.get-version.outputs.version }}
44 | body: |
45 | ${{ steps.Changelog.outputs.changelog }}
46 | draft: false
47 | prerelease: false
48 | token: ${{ secrets.GITHUB_TOKEN }}
49 | artifacts: './module.json,./module.zip'
50 | tag_name: ${{ steps.get-version.outputs.version }}
51 |
52 | # Update the 'latest' release
53 | - name: Create Release
54 | id: create_latest_release
55 | uses: ncipollo/release-action@v1
56 | if: endsWith(github.ref, 'master')
57 | with:
58 | allowUpdates: true
59 | name: Latest
60 | draft: false
61 | prerelease: false
62 | token: ${{ secrets.GITHUB_TOKEN }}
63 | artifacts: './module.json,./module.zip'
64 | tag: latest
65 |
--------------------------------------------------------------------------------
/module/opacity-slider.mjs:
--------------------------------------------------------------------------------
1 | import { DEFAULT_SETTINGS } from "./world-explorer-layer.mjs";
2 | const fapi = foundry.applications.api;
3 |
4 | export class OpacityGMAdjuster extends fapi.HandlebarsApplicationMixin(fapi.Application) {
5 | static #instance = null;
6 |
7 | static get instance() {
8 | return (this.#instance ??= new this());
9 | }
10 |
11 | static DEFAULT_OPTIONS = {
12 | id: "world-explorer-opacity-adjuster",
13 | classes: ["application"],
14 | window: {
15 | frame: false,
16 | positioned: false,
17 | },
18 | position: {
19 | width: 400,
20 | height: 38,
21 | }
22 | }
23 |
24 | static PARTS = {
25 | main: {
26 | template: "modules/world-explorer/templates/opacity-adjuster.hbs",
27 | root: true,
28 | }
29 | }
30 |
31 | scene = null;
32 |
33 | _prepareContext() {
34 | const flags = this.scene.flags["world-explorer"] ?? {};
35 | return {
36 | opacityGM: flags.opacityGM ?? DEFAULT_SETTINGS.opacityGM,
37 | };
38 | }
39 |
40 | /** Render and replace the referenced scene */
41 | async render(options = {}) {
42 | options.scene ??= canvas.scene;
43 | this.scene = options.scene;
44 | return super.render(options);
45 | }
46 |
47 | async _onRender(...args) {
48 | await super._onRender(...args);
49 | if (!this.scene) return;
50 | const element = this.element;
51 |
52 | // Adjust position of this application's window
53 | const bounds = ui.controls.element.querySelector('button[data-tool="opacity"]')?.getBoundingClientRect();
54 | if (bounds) {
55 | element.style.left = `${bounds.right + 6}px`;
56 | element.style.top = `${bounds.top}px`;
57 | }
58 |
59 | element.addEventListener("input", (event) => {
60 | const value = Number(event.target.value);
61 | const property = event.target.closest("[name]").name;
62 | this.scene.update({ [property]: value });
63 | });
64 | }
65 |
66 | toggleVisibility() {
67 | if (this.rendered) {
68 | this.close();
69 | } else {
70 | this.render({ force: true });
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/lang/cn.json:
--------------------------------------------------------------------------------
1 | {
2 | "WorldExplorer": {
3 | "Name": "世界探索器",
4 | "FIELDS": {
5 | "image": {
6 | "Name": "覆盖图片"
7 | },
8 | "color": {
9 | "Name": "覆盖颜色",
10 | "Hint": "若采用覆盖图片,则忽略。",
11 | "Placeholder": "默认(#000000)"
12 | },
13 | "opacityGM": {
14 | "Name": "GM 不透明"
15 | },
16 | "opacityPlayer": {
17 | "Name": "玩家不透明"
18 | },
19 | "partialColor": {
20 | "Name": "局部色彩",
21 | "Hint": "若为空,将采用覆盖颜色。若不为空,且采用了覆盖图片,则将为 GM 染色,但不为玩家染色。",
22 | "Placeholder": "默认(同上述叠加颜色)"
23 | },
24 | "partialOpacityPlayer": {
25 | "Name": "局部不透明",
26 | "Hint": "GM 会看到根据其他不透明度计算得出的局部揭示空间的加权不透明度。计算值显示在右侧。"
27 | },
28 | "persistExploredAreas": {
29 | "Name": "保留指示物已探索区域"
30 | },
31 | "position": {
32 | "Name": "覆盖位置",
33 | "Hint": "显示在贴图与纹理的下方还是上方。",
34 | "Choices": {
35 | "back": "只覆盖地图",
36 | "behindDrawings": "覆盖地图和非间接贴图",
37 | "behindTokens": "覆盖地图、非间接贴图、手绘",
38 | "front": "覆盖一切,除了网格"
39 | }
40 | },
41 | "revealRadius": {
42 | "Name": "指示物揭示距离",
43 | "Hint": "友好指示物暂时揭示的周遭距离(自中心)。"
44 | },
45 | "gridRevealRadius": {
46 | "Name": "延展揭示距离",
47 | "Hint": "若设置,已揭示的网格空间会向隐藏和局部揭示的空间延展一定的范围。不会延展部分局部揭示空间。"
48 | }
49 | },
50 | "SceneSettings": {
51 | "Enabled": "为此场景启用",
52 | "Overlay": {
53 | "Title": "覆盖设置"
54 | },
55 | "Partial": {
56 | "Title": "局部揭示空间设置",
57 | "DerivedName": "GM"
58 | },
59 | "TokenInteractions" : {
60 | "Title": "指示物交互"
61 | }
62 | },
63 | "Tools": {
64 | "Toggle": "切换空间",
65 | "Reveal": "揭示空间",
66 | "Partial": "局部揭示空间",
67 | "Hide": "隐藏空间",
68 | "Reset": "重置场景",
69 | "Opacity": "GM 覆盖不透明"
70 | },
71 | "ResetDialog": {
72 | "Title": "重置场景?",
73 | "Content": "确定要重置并更新整个场景吗?这无法撤回!",
74 | "Confirm": "在后文框中输入准确代码,以继续:{code}",
75 | "Choices": {
76 | "Explored": "显示全部",
77 | "Unexplored": "隐藏全部"
78 | }
79 | },
80 | "MigrateModuleSettings": {
81 | "Title": "迁移模组设置",
82 | "Content": "此场景的某些数据已迁移至模组设置中。您想用此场景以前的值覆盖模组设置吗?"
83 | },
84 | "Notifications": {
85 | "Migrated": "世界探索器 | 完成当前场景的迁移"
86 | }
87 | }
88 | }
--------------------------------------------------------------------------------
/module/util.mjs:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * @param {unknown} object
4 | * @param {{ before?: string, after?: string, key: string, value: unknown }} options
5 | * @returns
6 | */
7 | export function insertIntoObject(object, options) {
8 | const result = {};
9 | for (const [key, value] of Object.entries(object)) {
10 | if (key === options.before) {
11 | result[options.key] = options.value;
12 | }
13 | result[key] = value;
14 | if (key === options.after && !(options.key in result)) {
15 | result[options.key] = options.value;
16 | }
17 | }
18 |
19 | for (const key of Object.keys(object)) {
20 | delete object[key];
21 | }
22 | mergeObject(object, result);
23 |
24 | return result;
25 | }
26 |
27 | function relativeAdjust(value, reference) {
28 | if (value < reference) {
29 | return Math.floor(value);
30 | } else if (value > reference) {
31 | return Math.ceil(value);
32 | }
33 | return value;
34 | }
35 |
36 | /** Some polygons may have fractional parts, we round outwards so they tile nicely */
37 | export function expandPolygon(polygon, center) {
38 | for (const idx in polygon.points) {
39 | const value = polygon.points[idx];
40 | if (idx % 2 === 0) {
41 | polygon.points[idx] = relativeAdjust(value, center[0]);
42 | } else {
43 | polygon.points[idx] = relativeAdjust(value, center[1]);
44 | }
45 | }
46 |
47 | return polygon;
48 | }
49 |
50 | export function translatePolygon(polygon, translate) {
51 | for (const idx of polygon.points) {
52 | if (idx % 2 === 0) {
53 | polygon.points[idx] += translate[0];
54 | } else {
55 | polygon.points[idx] += translate[1];
56 | }
57 | }
58 |
59 | return polygon;
60 | }
61 |
62 | // Get a unique identifier string from the offset object
63 | export function offsetToString(entry) {
64 | const offset = entry.offset ?? entry;
65 | return `${offset.i}_${offset.j}`;
66 | }
67 |
68 | /**
69 | * Creates a simple PIXI texture sized to the canvas. The resolution scales based on size to handle large scenes.
70 | */
71 | export function createPlainTexture() {
72 | const { width, height } = canvas.dimensions.sceneRect;
73 | const area = width * height;
74 | const resolution = area > 16000 ** 2 ? 0.25 : area > 8000 ** 2 ? 0.5 : 1.0;
75 | return PIXI.RenderTexture.create({ width, height, resolution });
76 | }
77 |
78 | /**
79 | * Calculate the partial opacity for GMs based on the player, GM, and partial opacities.
80 | * Compute the percentage of partial vs. non-partial, and reapply to the GM selected value.
81 | * Afterwards, average it with the previous value, weighing closer to the previous the lower the alpha (so that we don't lose too much visibility).
82 | */
83 | export function calculateGmPartialOpacity({ opacityPlayer, opacityGM, opacityPartial }) {
84 | if (opacityPlayer === 0) return opacityPartial; // avoid divide by 0
85 | const partialRatio = opacityPartial / opacityPlayer;
86 | const newAlpha = partialRatio * opacityGM;
87 | return Math.min(opacityGM, opacityPartial * (1 - partialRatio) + newAlpha * partialRatio);
88 | }
89 |
--------------------------------------------------------------------------------
/Readme.md:
--------------------------------------------------------------------------------
1 | # World Explorer
2 |
3 | A Foundry VTT module for hexcrawl campaigns that gives you full control of what your party discovers on the map. This module creates a second manual fog of war—with color or image—which you can reveal one grid space at a time. Grid spaces can be revealed in full or in part, and while the GM can always do this manually, you can also auto-reveal areas explored by friendly tokens.
4 |
5 | This second fog of war displays over the background but below the tokens, showing the party while exploring uncharted territory. You can choose whether or not this layer covers tiles, or even tokens—for games that would prefer to treat it as a manual grid based fog of war.
6 |
7 | If you're feeling generous, you can send something through [Paypal](https://paypal.me/carlosfernandez1779?locale.x=en_US) if you want.
8 |
9 | # Features
10 |
11 | ## Scene Settings & Canvas Tools
12 |
13 |
14 |
15 |
16 |
Scene setting tab (left) and
Canvas control tools (right)
17 |
18 |
19 |
20 | Enable this module for a scene in the scene's configuration. Once enabled, a tool button will be available in the canvas controls to the left. With those, you can either edit the map in toggle mode (where you hide/reveal grid spaces one at time) or in reveal, partial, or hide modes. You can also quickly change the opacity for the GM and reset the entire map (hiding or revealing everything).
21 |
22 | ## Separate Gamemaster and Player Opacities
23 |
24 |
25 |
26 |
27 | Gamemaster view (left) and player view (right) of the scene with World Explorer enabled
28 |
29 | By default, the GM has 70% opacity and players have 100% opacity. While the GM can see what's underneath, the view is completely blocked for players unless you set it otherwise. The GM can quickly change their own opacity from the canvas controls whenever they want.
30 |
31 | ## Partially Revealed Spaces
32 |
33 | Optionally, you can mark spaces as being partially revealed using the World Explorer canvas tools to the left. These spaces have their own color and opacity that you can set (40% by default). You can use it, for example, to mark stuff on the map that players know about but haven't visited.
34 |
35 | ## Automatic Revealing
36 |
37 | While the module expects a manual approach, you can optionally set the module to automatically reveal spaces that players have ventured through. It only performs these updates for tokens that have a player owner. Tokens used to represent wandering encounters won't get revealed to players.
38 |
39 |
40 |
41 | # Credits
42 |
43 | * Thanks to [morepurplemorebetter](https://github.com/morepurplemorebetter/) for implementing partially revealed tiles as well as certain optimizations and fixes
44 |
--------------------------------------------------------------------------------
/module/scene-updater.mjs:
--------------------------------------------------------------------------------
1 | import { offsetToString } from "./util.mjs";
2 | import { MODULE } from "../index.js";
3 |
4 | /**
5 | * A wrapper around a scene used to handle persistence and sequencing
6 | * todo: move functionality to WorldExplorerGridData to handle optimistic updates better
7 | */
8 | export class SceneUpdater {
9 | constructor(scene) {
10 | this.scene = scene;
11 | this.hexUpdates = new Map();
12 | this.updating = false;
13 | this.paddedSceneRect = canvas.dimensions.sceneRect.clone().pad(canvas.grid.size);
14 | }
15 |
16 | /**
17 | * Updates a specific coordinate or offset with new data
18 | * @param {CoordsOrOffset} position
19 | * @param {{ reveal: boolean | "partial"}} param1
20 | */
21 | update({ coords = null, offset = null }, { reveal = false }) {
22 | if (!coords && !offset) return;
23 | if (typeof reveal !== "boolean" && reveal !== "partial") {
24 | throw new Error("Invalid type, reveal must be a boolean or the value partial");
25 | }
26 |
27 | // Ignore if this is outside the map's grid (sceneRect + padding of 1 grid size)
28 | if (coords && !this.paddedSceneRect.contains(coords.x, coords.y)) return;
29 |
30 | offset ??= canvas.grid.getOffset(coords);
31 | const key = offsetToString(offset);
32 | this.hexUpdates.set(key, { offset, reveal });
33 | this.#performUpdates();
34 | }
35 |
36 | clear(options) {
37 | this.hexUpdates.clear();
38 |
39 | const reveal = options?.reveal ?? false;
40 | if (reveal) {
41 | // Add a reveal for every grid position that is on the map (i.e. not in the padding)
42 | const { x, y, width, height } = canvas.dimensions.sceneRect;
43 | // First grid square/hex that is on the map (sceneRect)
44 | const startOffset = canvas.grid.getOffset({ x: x + 1, y: y + 1 });
45 | // Last grid square/hex that is on the map (sceneRect)
46 | const endOffset = canvas.grid.getOffset({ x: x + width - 1, y: y + height - 1 });
47 | // Compensate for hexes being weird
48 | // TODO: improve this by looking at the different hex grid types
49 | if (canvas.grid.isHexagonal) {
50 | startOffset.i -= 1;
51 | startOffset.j -= 1;
52 | endOffset.i += 1;
53 | endOffset.j += 1;
54 | }
55 | const newPositions = {};
56 | for (let i = startOffset.i; i <= endOffset.i; i++) {
57 | for (let j = startOffset.j; j <= endOffset.j; j++) {
58 | const offset = { i, j };
59 | const key = offsetToString(offset);
60 | newPositions[key] = { offset, reveal };
61 | }
62 | }
63 | this.scene.setFlag(MODULE, "gridData", newPositions);
64 | } else {
65 | this.scene.unsetFlag(MODULE, "gridData");
66 | }
67 | }
68 |
69 | #performUpdates = foundry.utils.throttle(async () => {
70 | if (this.updating) return;
71 |
72 | const updates = {};
73 | const flagBase = `flags.${MODULE}.gridData`;
74 | for (const [key, value] of this.hexUpdates.entries()) {
75 | if (value.reveal === false) {
76 | updates[`${flagBase}.-=${key}`] = null;
77 | } else {
78 | updates[`${flagBase}.${key}`] = value;
79 | }
80 | }
81 |
82 | this.hexUpdates.clear();
83 | this.updating = true;
84 | try {
85 | await this.scene.update(updates);
86 | } finally {
87 | this.updating = false;
88 | if (this.hexUpdates.size) {
89 | this.#performUpdates();
90 | }
91 | }
92 | }, 50);
93 | }
94 |
--------------------------------------------------------------------------------
/lang/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "WorldExplorer": {
3 | "Name": "World Explorer",
4 | "FIELDS": {
5 | "image": {
6 | "Name": "Overlay Image"
7 | },
8 | "color": {
9 | "Name": "Overlay Color",
10 | "Hint": "Ignored if using an overlay image.",
11 | "Placeholder": "Default (#000000)"
12 | },
13 | "opacityGM": {
14 | "Name": "Gamemaster Opacity"
15 | },
16 | "opacityPlayer": {
17 | "Name": "Player Opacity"
18 | },
19 | "partialColor": {
20 | "Name": "Partials Color",
21 | "Hint": "If empty, the overlay color will be used. If not empty and using an overlay image, the partials will be colored for the GM but not for the players.",
22 | "Placeholder": "Default (same as Overlay Color above)"
23 | },
24 | "partialOpacityPlayer": {
25 | "Name": "Partials Opacity",
26 | "Hint": "GMs will see a weighed opacity for the partially revealed spaces, calculated from the other opacities. This calculated value is shown on the right."
27 | },
28 | "persistExploredAreas": {
29 | "Name": "Keep areas explored by tokens"
30 | },
31 | "position": {
32 | "Name": "Overlay Position",
33 | "Hint": "Whether it should display below tiles and textures or above.",
34 | "Choices": {
35 | "back": "Cover the map only",
36 | "behindDrawings": "Cover the map and non-overhead tiles",
37 | "behindTokens": "Cover the map, non-overhead tiles, and drawings",
38 | "front": "Cover everything except grid"
39 | }
40 | },
41 | "revealRadius": {
42 | "Name": "Token Reveal Distance",
43 | "Hint": "Distance (from center) to temporarily reveal around friendly tokens."
44 | },
45 | "gridRevealRadius": {
46 | "Name": "Extend Revealed Distance",
47 | "Hint": "If set, revealed grid spaces extend a certain amount into hidden and partially revealed spaces. Does not extend the partially revealed spaces."
48 | }
49 | },
50 | "SceneSettings": {
51 | "Enabled": "Enable for this scene",
52 | "Overlay": {
53 | "Title": "Overlay Settings"
54 | },
55 | "Partial": {
56 | "Title": "Partially Revealed Spaces Settings",
57 | "DerivedName": "GM"
58 | },
59 | "TokenInteractions" : {
60 | "Title": "Token Interactions"
61 | }
62 | },
63 | "Tools": {
64 | "Toggle": "Toggle Spaces",
65 | "Reveal": "Reveal Spaces",
66 | "Partial": "Partially Reveal Spaces",
67 | "Hide": "Hide Spaces",
68 | "Reset": "Reset Scene",
69 | "Opacity": "GM Overlay Opacity"
70 | },
71 | "ResetDialog": {
72 | "Title": "Reset Scene?",
73 | "Content": "Are you sure you want to reset and update the entire scene?This can't be undone!",
74 | "Confirm": "Enter this exact code in the box below to proceed: {code}",
75 | "Choices": {
76 | "Explored": "Show All",
77 | "Unexplored": "Hide All"
78 | }
79 | },
80 | "MigrateModuleSettings": {
81 | "Title": "Migrate Module Settings",
82 | "Content": "This scene has certain data that has been migrated to module settings. Do you want to override the module settings with the values this scene used to have?"
83 | },
84 | "Notifications": {
85 | "Migrated": "World Explorer | Completed migration for the current scene"
86 | }
87 | }
88 | }
--------------------------------------------------------------------------------
/templates/scene-settings.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import { OpacityGMAdjuster } from "./module/opacity-slider.mjs";
2 | import { WorldExplorerLayer, DEFAULT_SETTINGS } from "./module/world-explorer-layer.mjs";
3 | import { calculateGmPartialOpacity } from "./module/util.mjs";
4 |
5 | const POSITION_OPTIONS = {
6 | back: "WorldExplorer.FIELDS.position.Choices.back",
7 | behindDrawings: "WorldExplorer.FIELDS.position.Choices.behindDrawings",
8 | behindTokens: "WorldExplorer.FIELDS.position.Choices.behindTokens",
9 | front: "WorldExplorer.FIELDS.position.Choices.front",
10 | };
11 |
12 | export const MODULE = "world-explorer";
13 |
14 | Hooks.on("init", () => {
15 | // Add world explorer layer
16 | CONFIG.Canvas.layers["worldExplorer"] = {
17 | layerClass: WorldExplorerLayer,
18 | group: "primary",
19 | };
20 | });
21 |
22 | Hooks.on("ready", () => {
23 | // Figure out the scene config we need to extend. Some systems may subclass this
24 | const FoundrySceneConfig = foundry.applications.sheets.SceneConfig;
25 | const DefaultSceneConfig = Object.values(CONFIG.Scene.sheetClasses.base).find((d) => d.default).cls;
26 | const SceneConfig = DefaultSceneConfig?.prototype instanceof FoundrySceneConfig ? DefaultSceneConfig : FoundrySceneConfig;
27 |
28 | // Add the world explorer tab and config to the scene config
29 | // We need to make sure the world explorer tab renders before the footer
30 | const label = game.i18n.localize("WorldExplorer.Name");
31 | SceneConfig.TABS.sheet.tabs.push({ id: "worldExplorer", label, icon: "fa-solid fa-map-location-dot" });
32 | const footerPart = SceneConfig.PARTS.footer;
33 | delete SceneConfig.PARTS.footer;
34 | SceneConfig.PARTS.worldExplorer = {
35 | template: "modules/world-explorer/templates/scene-settings.hbs"
36 | };
37 | SceneConfig.PARTS.footer = footerPart;
38 |
39 | // Override part context to include the world explorer config data
40 | const defaultRenderPartContext = SceneConfig.prototype._preparePartContext;
41 | SceneConfig.prototype._preparePartContext = async function(partId, context, options) {
42 | if (partId === "worldExplorer") {
43 | const flags = this.document.flags[MODULE] ?? null;
44 | const opacityPlayer = flags?.opacityPlayer ?? DEFAULT_SETTINGS.opacityPlayer;
45 | const opacityGM = flags?.opacityGM ?? DEFAULT_SETTINGS.opacityGM;
46 | const opacityPartial = flags?.partialOpacityPlayer ?? DEFAULT_SETTINGS.partialOpacityPlayer;
47 | const partialOpacityGM = calculateGmPartialOpacity({ opacityPlayer, opacityGM, opacityPartial }).toFixed(2);
48 | return {
49 | ...DEFAULT_SETTINGS,
50 | ...(flags ?? {}),
51 | POSITION_OPTIONS,
52 | units: this.document.grid.units,
53 | document: this.document,
54 | tab: context.tabs[partId],
55 | roles: {
56 | gm: game.i18n.localize("USER.RoleGamemaster"),
57 | player: game.i18n.localize("USER.RolePlayer"),
58 | },
59 | partialOpacityGM
60 | };
61 | }
62 |
63 | return defaultRenderPartContext.call(this, partId, context, options);
64 | }
65 |
66 | // Override onChangeForm to include world explorer
67 | const default_onChangeForm = SceneConfig.prototype._onChangeForm;
68 | SceneConfig.prototype._onChangeForm = function(formConfig, event) {
69 | const formElements = this.form.elements;
70 | const opacityPlayerElement = formElements['flags.world-explorer.opacityPlayer'];
71 | const opacityGmElement = formElements['flags.world-explorer.opacityGM'];
72 | const opacityPartialElement = formElements['flags.world-explorer.partialOpacityPlayer'];
73 | switch (event.target) {
74 | case opacityPlayerElement:
75 | case opacityGmElement:
76 | case opacityPartialElement:
77 | const opacityPlayer = opacityPlayerElement.value;
78 | const opacityGM = opacityGmElement.value;
79 | const opacityPartial = opacityPartialElement.value;
80 | formElements['WorldExplorerPartialOpacityGM'].value = calculateGmPartialOpacity({ opacityPlayer, opacityGM, opacityPartial }).toFixed(2);
81 | break;
82 | }
83 | return default_onChangeForm.call(this, formConfig, event);
84 | }
85 | });
86 |
87 | Hooks.on("canvasReady", (canvas) => {
88 | canvas.worldExplorer?.onCanvasReady();
89 | OpacityGMAdjuster.instance?.close();
90 | });
91 |
92 | Hooks.on("createToken", (token) => {
93 | updateForToken(token);
94 | if (canvas.worldExplorer?.settings.revealRadius) {
95 | canvas.worldExplorer.refreshMask();
96 | }
97 | });
98 |
99 | Hooks.on("updateToken", (token, data) => {
100 | if (data.x || data.y) {
101 | setTimeout(() => {
102 | updateForToken(token, data);
103 | }, 100);
104 | }
105 | });
106 |
107 | Hooks.on("refreshToken", (token, options) => {
108 | if (options.refreshPosition) {
109 | refreshThrottled();
110 | }
111 | });
112 |
113 | Hooks.on("deleteToken", () => {
114 | if (canvas.worldExplorer?.settings.revealRadius) {
115 | canvas.worldExplorer.refreshMask();
116 | }
117 | });
118 |
119 | Hooks.on("updateScene", (scene, data) => {
120 | // Skip if the updated scene isn't the current one
121 | if (scene.id !== canvas.scene.id) return;
122 |
123 | if (data.flags && MODULE in data.flags) {
124 | const worldExplorerFlags = data.flags[MODULE];
125 |
126 | // If the change only affects the mask, do the throttled refresh to not interfere with token moving
127 | const maskOnlyFlags = ["gridData", "opacityGM", "opacityPlayer", "partialOpacityPlayer"];
128 | const hasMaskOnlyFlag = maskOnlyFlags.find((flag) => { if (flag in worldExplorerFlags) return flag; });
129 | if (hasMaskOnlyFlag && Object.keys(worldExplorerFlags).length === 1) {
130 | // Force recreating the gridDataMap if that data changed but we are only refreshing the masks
131 | if (hasMaskOnlyFlag === "gridData") canvas.worldExplorer._gridDataMap = null;
132 | refreshThrottled(true);
133 | } else {
134 | canvas.worldExplorer?.update();
135 | }
136 |
137 | // If the Z-Index has changed, re-evaluate children
138 | if (worldExplorerFlags.position) {
139 | canvas.primary.sortChildren();
140 | }
141 |
142 | // Handle side-controls not re-rendering when the world explorer mode changes
143 | if ("enabled" in worldExplorerFlags) {
144 | ui.controls.render({ reset: true });
145 | }
146 | }
147 | });
148 |
149 | // Add Controls
150 | Hooks.on("getSceneControlButtons", (controls) => {
151 | if (!game.user.isGM) return;
152 | if (!canvas.worldExplorer?.enabled) {
153 | if (canvas.worldExplorer?.active) {
154 | // World Explorer tools active, but not enabled for this scene, thus
155 | // activate top (token) controls instead, so the scene doesn't fail to load
156 | canvas.tokens.activate();
157 | }
158 | return;
159 | }
160 |
161 | controls.worldExplorer = {
162 | name: "worldExplorer",
163 | title: game.i18n.localize("WorldExplorer.Name"),
164 | icon: "fa-solid fa-map-location-dot",
165 | layer: "worldExplorer",
166 | onChange: (_event, active) => {
167 | if (active) canvas.worldExplorer.activate();
168 | },
169 | tools: {
170 | toggle: {
171 | name: "toggle",
172 | title: "WorldExplorer.Tools.Toggle",
173 | icon: "fa-solid fa-shuffle",
174 | },
175 | reveal: {
176 | name: "reveal",
177 | title: "WorldExplorer.Tools.Reveal",
178 | icon: "fa-thin fa-grid-2-plus"
179 | },
180 | partial: {
181 | name: "partial",
182 | title: "WorldExplorer.Tools.Partial",
183 | icon: "fa-duotone fa-grid-2-plus"
184 | },
185 | hide: {
186 | name: "hide",
187 | title: "WorldExplorer.Tools.Hide",
188 | icon: "fa-solid fa-grid-2-plus"
189 | },
190 | opacity: {
191 | name: "opacity",
192 | title: "WorldExplorer.Tools.Opacity",
193 | icon: "fa-duotone fa-eye-low-vision",
194 | toggle: true,
195 | onChange: () => {
196 | const adjuster = OpacityGMAdjuster.instance;
197 | adjuster.toggleVisibility();
198 | },
199 | },
200 | reset: {
201 | name: "reset",
202 | title: game.i18n.localize("WorldExplorer.Tools.Reset"),
203 | icon: "fa-solid fa-trash",
204 | button: true,
205 | onChange: async () => {
206 | const code = foundry.utils.randomID(4).toLowerCase();
207 | const content = `
208 | ${game.i18n.localize("WorldExplorer.ResetDialog.Content")}
209 | ${game.i18n.format("WorldExplorer.ResetDialog.Confirm", { code })}
210 |
211 | `;
212 | const dialog = new foundry.applications.api.Dialog({
213 | window: {
214 | title: "WorldExplorer.ResetDialog.Title"
215 | },
216 | content,
217 | modal: true,
218 | buttons: [
219 | {
220 | action: "unexplored",
221 | icon: "fa-solid fa-user-secret",
222 | label: game.i18n.localize("WorldExplorer.ResetDialog.Choices.Unexplored"),
223 | callback: () => canvas.worldExplorer.clear(),
224 | },
225 | {
226 | action: "explored",
227 | icon: "fa-solid fa-eye",
228 | label: game.i18n.localize("WorldExplorer.ResetDialog.Choices.Explored"),
229 | callback: () => canvas.worldExplorer.clear({ reveal: true }),
230 | },
231 | {
232 | action: "cancel",
233 | icon: "fa-solid fa-xmark",
234 | label: game.i18n.localize("Cancel"),
235 | },
236 | ],
237 | });
238 |
239 | // Lock buttons until the code matches
240 | dialog.addEventListener("render", () => {
241 | const element = dialog.element;
242 | const codeInput = element.querySelector("input");
243 | const buttons = element.querySelectorAll("button:not([data-action=cancel],[data-action=close])");
244 | for (const button of buttons) {
245 | button.disabled = true;
246 | }
247 | codeInput.addEventListener("input", () => {
248 | const matches = codeInput.value.trim() === code;
249 | for (const button of buttons) {
250 | button.disabled = !matches;
251 | }
252 | });
253 | })
254 |
255 | dialog.render({ force: true });
256 | },
257 | }
258 | },
259 | activeTool: "toggle",
260 | };
261 | });
262 |
263 | // Handle Control Changes
264 | Hooks.on('activateSceneControls', (controls) => {
265 | if (!canvas.worldExplorer) return;
266 |
267 | canvas.worldExplorer?.onChangeTool(controls.tool.name);
268 | if (controls.control.name !== "worldExplorer") {
269 | OpacityGMAdjuster.instance?.close();
270 | }
271 | });
272 |
273 | /** Refreshes the scene on token move, revealing a location if necessary */
274 | function updateForToken(token, data={}) {
275 | if (!game.user.isGM || !canvas.worldExplorer?.enabled) return;
276 |
277 | // Only do token reveals for player owned or player friendly tokens
278 | if (token.disposition !== CONST.TOKEN_DISPOSITIONS.FRIENDLY && !token.hasPlayerOwner) {
279 | return;
280 | }
281 |
282 | const settings = canvas.worldExplorer.settings;
283 | if (settings.persistExploredAreas) {
284 | // Computing token's center is required to not reveal an area to the token's left upon token's creation.
285 | // This happened on "Hexagonal Rows - Odd" grid configuration during token creation. Using center works
286 | // on every grid configuration afaik.
287 | const center = {
288 | x: (data.x ?? token.x) + ((token.parent?.dimensions?.size / 2) ?? 0),
289 | y: (data.y ?? token.y) + ((token.parent?.dimensions?.size / 2) ?? 0),
290 | };
291 | canvas.worldExplorer.setRevealed({coords: center}, true);
292 | }
293 | }
294 |
295 | const refreshThrottled = foundry.utils.throttle((force) => {
296 | if (force || canvas.worldExplorer?.settings.revealRadius) {
297 | canvas.worldExplorer.refreshMask();
298 | }
299 | }, 30);
--------------------------------------------------------------------------------
/module/world-explorer-layer.mjs:
--------------------------------------------------------------------------------
1 | import { SceneUpdater } from "./scene-updater.mjs";
2 | import { createPlainTexture, offsetToString, calculateGmPartialOpacity } from "./util.mjs";
3 | import { WorldExplorerGridData } from "./world-explorer-grid-data.mjs";
4 | import { MODULE } from "../index.js";
5 |
6 | /**
7 | * A pair of row and column coordinates of a grid space.
8 | * @typedef {object} GridOffset
9 | * @property {number} i The row coordinate
10 | * @property {number} j The column coordinate
11 | */
12 |
13 | export const DEFAULT_SETTINGS = {
14 | color: "#000000",
15 | partialColor: "",
16 | revealRadius: 0,
17 | gridRevealRadius: 0,
18 | opacityGM: 0.7,
19 | opacityPlayer: 1,
20 | partialOpacityPlayer: 0.4,
21 | persistExploredAreas: false,
22 | position: "behindDrawings",
23 | };
24 |
25 | // DEV NOTE: On sorting layers
26 | // Elements within the primary canvas group are sorted via the following heuristics:
27 | // 1. The object's elevation property. Drawings use their Z-Index, Tiles have a fixed value if overhead
28 | // 2. The layer's static PRIMARY_SORT_ORDER.
29 | // 3. The object's sort property
30 |
31 | /**
32 | * The world explorer canvas layer, which is added to the primary canvas layer.
33 | * The primary canvas layer is host to the background, and the actual token/drawing/tile sprites.
34 | * The separate token/drawing/tiles layers in the interaction layer are specifically for drawing borders and rendering the hud.
35 | */
36 | export class WorldExplorerLayer extends foundry.canvas.layers.InteractionLayer {
37 | /**
38 | * Providing baseClass for proper 'name' support
39 | * @see InteractionLayer
40 | */
41 | static get layerOptions() {
42 | return {
43 | ...super.layerOptions,
44 | name: "worldExplorer",
45 | baseClass: WorldExplorerLayer,
46 | };
47 | }
48 |
49 | get sortLayer() {
50 | // Tokens are 700, Drawings are 600, Tiles are 500
51 | switch (this.settings.position) {
52 | case "front":
53 | return 1000;
54 | case "behindTokens":
55 | return 650;
56 | case "behindDrawings":
57 | return 550;
58 | default:
59 | return 0;
60 | }
61 | }
62 |
63 | /**
64 | * The currently set alpha value of the world explorer layer main mask
65 | * For players this is usually 1, but it may differ for GMs
66 | * @type {number};
67 | */
68 | overlayAlpha;
69 |
70 | /**
71 | * The main overlay for completely hidden tiles.
72 | * @type {PIXI.Sprite}
73 | */
74 | hiddenTiles;
75 |
76 | /**
77 | * The texture associated with the hiddenTiles mask
78 | * @type {PIXI.RenderTexture}
79 | */
80 | hiddenTilesMaskTexture;
81 |
82 | /**
83 | * The currently set alpha value of the world explorer layer partial mask
84 | * For players this is different than for GMs
85 | * @type {number};
86 | */
87 | partialAlpha;
88 |
89 | /**
90 | * The overlay for partly revealed tiles.
91 | * @type {PIXI.Sprite}
92 | */
93 | partialTiles;
94 |
95 | /**
96 | * The texture associated with the partialTiles mask
97 | * @type {PIXI.RenderTexture}
98 | */
99 | partialTilesMaskTexture;
100 |
101 | constructor() {
102 | super();
103 | this.color = DEFAULT_SETTINGS.color;
104 | this.partialColor = this.color;
105 |
106 | /** @type {Partial} */
107 | this.state = {};
108 | }
109 |
110 | /** Any settings we are currently previewing. Currently unused, will be used once we're more familiar with the scene config preview */
111 | previewSettings = {};
112 |
113 | /**
114 | * TODO: cache between updates
115 | * @returns {WorldExplorerFlags}
116 | */
117 | get settings() {
118 | const settings = this.scene.flags[MODULE] ?? {};
119 | return { ...DEFAULT_SETTINGS, ...settings, ...this.previewSettings };
120 | }
121 |
122 | get elevation() {
123 | return this.settings.position === "front" ? Infinity : 0;
124 | }
125 |
126 | /**
127 | * Get a GridHighlight layer for this Ruler
128 | * @type {GridHighlight}
129 | */
130 | get highlightLayer() {
131 | return canvas.interface.grid.highlightLayers[this.name] || canvas.interface.grid.addHighlightLayer(this.name);
132 | }
133 |
134 | /** @type {WorldExplorerGridData} */
135 | get gridDataMap() {
136 | this._gridDataMap ??= new WorldExplorerGridData(this.scene.getFlag(MODULE, "gridData") ?? {});
137 | return this._gridDataMap;
138 | }
139 |
140 | get enabled() {
141 | return !!this._enabled;
142 | }
143 |
144 | set enabled(value) {
145 | this._enabled = !!value;
146 | this.visible = !!value;
147 |
148 | if (value) {
149 | this.refreshOverlay();
150 | this.refreshMask();
151 | } else {
152 | this.removeChildren();
153 | }
154 | }
155 |
156 | /** Returns true if the user is currently editing, false otherwise. */
157 | get editing() {
158 | return this.enabled && this.state.clearing;
159 | }
160 |
161 | /**
162 | * Returns true if there is no image or the GM is viewing and partial color is set
163 | * @type {boolean}
164 | */
165 | get showPartialTiles() {
166 | return !this.image || !!(this.settings.partialColor && game.user.isGM);
167 | }
168 |
169 | initialize() {
170 | const { x, y, width, height } = canvas.dimensions.sceneRect;
171 |
172 | // Sprite to cover the hidden tiles. Fill with white texture, or image texture if one is set
173 | this.hiddenTiles = new PIXI.Sprite(PIXI.Texture.WHITE);
174 | this.hiddenTiles.position.set(x, y);
175 | this.hiddenTiles.width = width;
176 | this.hiddenTiles.height = height;
177 |
178 | // Create a mask for it, with a texture we can reference later to update the mask
179 | this.hiddenTilesMaskTexture = createPlainTexture();
180 | this.hiddenTiles.mask = new PIXI.Sprite(this.hiddenTilesMaskTexture);
181 | this.hiddenTiles.mask.position.set(x, y);
182 |
183 | // Add to the layer
184 | this.addChild(this.hiddenTiles);
185 | this.addChild(this.hiddenTiles.mask);
186 |
187 | // Graphic to cover the partially revealed tiles (doesn't need an image texture, so use Graphics)
188 | // Needs to be separate, for we want it to have a different color
189 | this.partialTiles = new PIXI.Graphics();
190 |
191 | // Create a separate mask for it, as it will also have separate transparency so it can overlay the image texture
192 | this.partialTilesMaskTexture = createPlainTexture();
193 | this.partialTiles.mask = new PIXI.Sprite(this.partialTilesMaskTexture);
194 | this.partialTiles.mask.position.set(x, y);
195 |
196 | // Add to the layer
197 | this.addChild(this.partialTiles);
198 | this.addChild(this.partialTiles.mask);
199 |
200 | this.#syncSettings();
201 | this.#migrateData();
202 | }
203 |
204 | async _draw() {
205 | const scene = canvas.scene;
206 | this.scene = scene;
207 | this.updater = new SceneUpdater(scene);
208 |
209 | this.state = {};
210 | this.initialize();
211 | this.refreshOverlay();
212 | this.refreshImage();
213 |
214 | return this;
215 | }
216 |
217 | /** Triggered when the current scene updates */
218 | update() {
219 | if (this.#migrateData()) {
220 | return;
221 | }
222 |
223 | const flags = this.settings;
224 | const imageChanged = this.image !== flags.image;
225 | const becameEnabled = !this.enabled && flags.enabled;
226 | const partialTilesChanged = this.partialTiles.visible !== this.showPartialTiles;
227 |
228 | // Hide the partial tiles if an image is present and this is not the GM
229 | this.partialTiles.visible = this.showPartialTiles;
230 |
231 | this.#syncSettings();
232 | this.refreshMask();
233 |
234 | if (becameEnabled || partialTilesChanged) {
235 | this.refreshOverlay();
236 | } else {
237 | this.refreshColors();
238 | }
239 | if (imageChanged || !flags.enabled || becameEnabled) {
240 | this.refreshImage();
241 | }
242 | }
243 |
244 | /** Reads flags and updates variables to match */
245 | #syncSettings() {
246 | const flags = this.settings;
247 | this._enabled = flags.enabled;
248 | this.visible = this._enabled;
249 | this.color = flags.color;
250 | this.partialColor = flags.partialColor || this.color;
251 | this.image = flags.image;
252 | this._gridDataMap = new WorldExplorerGridData(this.scene.getFlag(MODULE, "gridData") ?? {});
253 | this.#syncAlphas();
254 | }
255 |
256 | /**
257 | * Reads alpha flags and update variables to match.
258 | * Do this separately, so it can be invoked on a mask-only update
259 | * As only the mask uses the alpha
260 | */
261 | #syncAlphas() {
262 | const flags = this.settings;
263 | this.overlayAlpha = (game.user.isGM ? flags.opacityGM : flags.opacityPlayer) ?? DEFAULT_SETTINGS.opacityPlayer;
264 | this.partialAlpha = flags.partialOpacityPlayer ?? DEFAULT_SETTINGS.partialOpacityPlayer;
265 |
266 | // If the user is a GM, compute the partial opacity based on the other opacities
267 | if (game.user.isGM && flags.opacityPlayer) {
268 | const opacityPlayer = flags.opacityPlayer ?? DEFAULT_SETTINGS.opacityPlayer;
269 | const opacityGM = this.overlayAlpha;
270 | const opacityPartial = this.partialAlpha;
271 | this.partialAlpha = calculateGmPartialOpacity({ opacityPlayer, opacityGM, opacityPartial });
272 | }
273 | }
274 |
275 | onChangeTool(toolName) {
276 | const isEditTool = ["toggle", "reveal", "partial", "hide"].includes(toolName);
277 | if (this.active && isEditTool) {
278 | canvas.worldExplorer.startEditing(toolName);
279 | } else {
280 | canvas.worldExplorer.stopEditing();
281 | }
282 | }
283 |
284 | /** @param {EditingMode} mode */
285 | startEditing(mode) {
286 | this.state.clearing = true;
287 | this.state.tool = mode;
288 | if (this.enabled) {
289 | this.highlightLayer.clear();
290 | }
291 | }
292 |
293 | stopEditing() {
294 | this.state.clearing = false;
295 | if (this.enabled) {
296 | this.highlightLayer.clear();
297 | }
298 | }
299 |
300 | refreshImage(image = null) {
301 | this.image ??= image;
302 | if (this.enabled && this.image) {
303 | foundry.canvas.loadTexture(this.image).then((texture) => {
304 | this.hiddenTiles.texture = texture;
305 | });
306 | } else {
307 | this.hiddenTiles.texture = this.enabled ? PIXI.Texture.WHITE : null;
308 | }
309 | }
310 |
311 | refreshOverlay() {
312 | if (!this.enabled) return;
313 |
314 | // Fill the partialTiles, if visible, with something to mask
315 | if (this.partialTiles.visible) {
316 | const { x, y, width, height } = canvas.dimensions.sceneRect;
317 | this.partialTiles.beginFill(0xffffff);
318 | this.partialTiles.drawRect(x, y, width, height);
319 | this.partialTiles.endFill();
320 | }
321 |
322 | this.refreshColors();
323 | }
324 |
325 | refreshColors() {
326 | if (!this.enabled) return;
327 |
328 | // Set the color of the overlay, but only if no image is present
329 | this.hiddenTiles.tint = !this.image ? Color.from(this.color) : 0xffffff;
330 | // Set the color of the partial tiles
331 | this.partialTiles.tint = Color.from(this.partialColor);
332 | }
333 |
334 | /**
335 | * Create masks for the main (maskGraphic) and partial (partialMask) layers
336 | * The maskGraphic must be everything except the revealed and partial tiles
337 | * The partialMask must be only the partial tiles
338 | */
339 | refreshMask() {
340 | if (!this.enabled) return;
341 | this.#syncAlphas();
342 | const { x, y, width, height } = canvas.dimensions.sceneRect;
343 |
344 | // Create the mask graphics, although partialMask may be null if not enabled
345 | const maskGraphic = new PIXI.Graphics();
346 | maskGraphic.position.set(-x, -y);
347 | const partialMask = this.showPartialTiles ? new PIXI.Graphics() : null;
348 | partialMask?.position.set(-x, -y);
349 |
350 | // Cover everything with the main mask by painting it white
351 | maskGraphic.beginFill(0xffffff, this.overlayAlpha);
352 | maskGraphic.drawRect(x, y, width, height);
353 | maskGraphic.endFill();
354 |
355 | // Process the partial tiles. Uncover them in the main mask, but cover them in the partial mask
356 | //
357 | // Unless this is an image, then we need to:
358 | // - Cover the tile on the main mask again, but with partial alpha
359 | // - Use 0.5 alpha on the partial mask to slightly color the partial
360 | // - reveal parts of the image for the GM
361 | maskGraphic.beginFill(0x000000);
362 | partialMask?.beginFill(0xffffff, !this.image ? this.partialAlpha : 0.5);
363 | // We are not drawing gridRevealRadius for partials, as that will result in overlapping transparant circles, which looks terrible
364 | for (const entry of this.gridDataMap.partials) {
365 | const poly = this._getGridPolygon(entry.offset);
366 | maskGraphic.drawPolygon(poly);
367 | partialMask?.drawPolygon(poly);
368 | // If this is an image, we need to set the tile to the partial opacity, thus we have to draw a new white polygon where we just made a black one
369 | if (this.image) {
370 | maskGraphic.beginFill(0xffffff, this.partialAlpha);
371 | maskGraphic.drawPolygon(poly);
372 | // Back to a black fill for the next one
373 | maskGraphic.beginFill(0x000000);
374 | }
375 | }
376 |
377 | // Process the revealed tiles, uncover them in the main mask
378 | // Also uncover reveal radius, if enabled, in both.
379 | // This needs to happen after the partial tiles
380 | const gridRevealRadius = this.getGridRevealRadius();
381 | partialMask?.beginFill(0x000000);
382 | for (const entry of this.gridDataMap.revealed) {
383 | // Uncover circles if extend grid elements is set
384 | if (gridRevealRadius > 0) {
385 | const { x, y } = canvas.grid.getCenterPoint(entry.offset);
386 | maskGraphic.drawCircle(x, y, gridRevealRadius);
387 | partialMask?.drawCircle(x, y, gridRevealRadius);
388 | } else {
389 | // Otherwise just uncover the revealed grid
390 | const poly = this._getGridPolygon(entry.offset);
391 | maskGraphic.drawPolygon(poly);
392 | }
393 | }
394 |
395 | // Uncover observer tokens, if set
396 | const tokenRevealRadius = Math.max(Number(this.scene.getFlag(MODULE, "revealRadius")) || 0, 0);
397 | if (tokenRevealRadius > 0) {
398 | for (const token of canvas.tokens.placeables) {
399 | const document = token.document;
400 | if (document.disposition === CONST.TOKEN_DISPOSITIONS.FRIENDLY || document.hasPlayerOwner) {
401 | const { x, y } = token.center;
402 | maskGraphic.drawCircle(x, y, token.getLightRadius(tokenRevealRadius));
403 | partialMask?.drawCircle(x, y, token.getLightRadius(tokenRevealRadius));
404 | }
405 | }
406 | }
407 |
408 | maskGraphic.endFill();
409 | partialMask?.endFill();
410 |
411 | // Render the masks. Only render the partial mask if applicable
412 | canvas.app.renderer.render(maskGraphic, { renderTexture: this.hiddenTilesMaskTexture });
413 | maskGraphic.destroy();
414 | if (this.showPartialTiles && partialMask) {
415 | canvas.app.renderer.render(partialMask, { renderTexture: this.partialTilesMaskTexture });
416 | partialMask.destroy();
417 | }
418 | }
419 |
420 | /** Returns the grid reveal distance in canvas coordinates (if configured) */
421 | getGridRevealRadius() {
422 | const gridRadius = Math.max(
423 | Number(this.settings.gridRevealRadius) || 0,
424 | DEFAULT_SETTINGS.gridRevealRadius
425 | );
426 | if (!(gridRadius > 0)) return 0;
427 |
428 | // Convert from units to pixel radius, stolen from token.getLightRadius()
429 | const u = Math.abs(gridRadius);
430 | const hw = canvas.grid.sizeX / 2;
431 | return ((u / canvas.dimensions.distance) * canvas.dimensions.size + hw) * Math.sign(gridRadius);
432 | }
433 |
434 | /**
435 | * Returns true if a grid coordinate (x, y) or offset (i, j) is revealed.
436 | * @param {CoordsOrOffset} coordsOrOffset
437 | */
438 | isRevealed(coordsOrOffset) {
439 | if (!coordsOrOffset.coords && !coordsOrOffset.offset) return null;
440 | return this.gridDataMap.get(coordsOrOffset)?.reveal === true;
441 | }
442 |
443 | /**
444 | * Returns true if a grid coordinate (x, y) or offset (i, j) is partly revealed.
445 | * @param {CoordsOrOffset} coordsOrOffset
446 | */
447 | isPartial(coordsOrOffset) {
448 | if (!coordsOrOffset.coords && !coordsOrOffset.offset) return null;
449 | return this.gridDataMap.get(coordsOrOffset)?.reveal === "partial";
450 | }
451 |
452 | /**
453 | * Reveals a coordinate or offset and saves it to the scene
454 | * @param {CoordsOrOffset} coordsOrOffset
455 | * @param { boolean | "partial" } reveal
456 | */
457 | setRevealed(coordsOrOffset, reveal) {
458 | if (!this.enabled || (!coordsOrOffset.coords && !coordsOrOffset.offset)) return;
459 |
460 | // Check if this operation is valid. todo: move check to updater
461 | const current = this.gridDataMap.get(coordsOrOffset)?.reveal ?? false;
462 | if (current !== reveal) {
463 | this.updater.update(coordsOrOffset, { reveal });
464 | }
465 | }
466 |
467 | /** Clears the entire scene. If reveal: true is passed, reveals all positions instead */
468 | clear(options) {
469 | this.updater.clear(options);
470 | }
471 |
472 | onCanvasReady() {
473 | this.refreshMask();
474 | this.registerMouseListeners();
475 | // enable the currently select tool if its one of World Explorer's
476 | if (this.active) this.onChangeTool(game.activeTool);
477 | }
478 |
479 | registerMouseListeners() {
480 | // We need to make sure that pointer events are only valid if they started on the canvas
481 | // If null, dragging is not ongoing. If false, all events should be blocked. If true, we started dragging from the canvas
482 | let draggingOnCanvas = null;
483 |
484 | /** Returns true if the element is the board, aka the main canvas */
485 | const canEditLayer = (event) => {
486 | const element = event.srcElement;
487 | const isMainCanvas = element && element.tagName === "CANVAS" && element.id === "board";
488 | return draggingOnCanvas !== false && this.enabled && this.editing && (draggingOnCanvas || isMainCanvas);
489 | };
490 |
491 | /**
492 | * Given the state of a hex and a check of the tool, determines what the hex will become.
493 | * Returns null if there will be no change
494 | * @param {boolean | "partial"} currentReveal
495 | */
496 | const checkRevealChange = (currentReveal) => {
497 | const revealed = currentReveal === true;
498 | const partial = currentReveal === "partial";
499 | const canReveal = !revealed && ["toggle", "reveal"].includes(this.state.tool);
500 | const canHide =
501 | (revealed && ["toggle", "hide"].includes(this.state.tool)) || (partial && this.state.tool === "hide");
502 | const canPartial = !partial && this.state.tool === "partial";
503 | return canReveal ? true : canHide ? false : canPartial ? "partial" : null;
504 | };
505 |
506 | /**
507 | * Renders the highlight to use for the grid's future status. If null, doesn't render anything
508 | * @param {boolean | "partial" | null} newReveal
509 | */
510 | const renderHighlight = (position, newReveal) => {
511 | const { x, y } = canvas.grid.getTopLeftPoint(position);
512 | this.highlightLayer.clear();
513 |
514 | // In certain modes, we only go one way, check if the operation is valid
515 | if (newReveal !== null) {
516 | // blue color for revealing tiles
517 | let color = 0x0022ff;
518 | if (newReveal === "partial") {
519 | // default to purple for making tiles partly revealed if no partial
520 | // color is defined, otherwise it would look identical to the hide tool
521 | color = this.settings.partialColor ? Color.from(this.partialColor) : 0x7700ff;
522 | } else if (newReveal === false) {
523 | color = Color.from(this.color);
524 | }
525 | canvas.interface.grid.highlightPosition(this.highlightLayer.name, { x, y, color, border: color });
526 | }
527 | };
528 |
529 | canvas.stage.addListener("pointerup", () => {
530 | draggingOnCanvas = null; // clear dragging status when mouse is lifted
531 | });
532 |
533 | canvas.stage.addListener("pointerdown", (event) => {
534 | if (!canEditLayer(event)) {
535 | draggingOnCanvas = false;
536 | return;
537 | }
538 | draggingOnCanvas = true;
539 |
540 | if (event.data.button !== 0) return;
541 |
542 | const coords = event.data.getLocalPosition(canvas.app.stage);
543 | const offset = canvas.grid.getOffset(coords);
544 |
545 | // In certain modes, we only go one way, check if the operation is valid
546 | const currentStatus = this.gridDataMap.get({ coords, offset })?.reveal ?? false;
547 | const newReveal = checkRevealChange(currentStatus);
548 | if (newReveal !== null) {
549 | this.setRevealed({ coords, offset }, newReveal);
550 | renderHighlight(coords, newReveal);
551 | }
552 | });
553 |
554 | canvas.stage.addListener("pointermove", (event) => {
555 | // If no button is held down, clear the dragging status
556 | if (event.data.buttons !== 1) {
557 | draggingOnCanvas = null;
558 | }
559 |
560 | if (!canEditLayer(event)) {
561 | // If we can't edit the layer *and* a button is held down, flag as a non-canvas drag
562 | if (event.data.buttons === 1) {
563 | draggingOnCanvas = false;
564 | }
565 | this.highlightLayer.clear();
566 | return;
567 | }
568 |
569 | // Get mouse position translated to canvas coords
570 | const coords = event.data.getLocalPosition(canvas.app.stage);
571 | const offset = canvas.grid.getOffset(coords);
572 | const currentStatus = this.gridDataMap.get({ coords, offset })?.reveal ?? false;
573 | const newReveal = checkRevealChange(currentStatus);
574 | renderHighlight(coords, newReveal);
575 |
576 | // For brush or eraser modes, allow click drag drawing
577 | if (event.data.buttons === 1 && this.state.tool !== "toggle") {
578 | draggingOnCanvas = true;
579 | if (newReveal !== null) {
580 | this.setRevealed({ coords, offset }, newReveal);
581 | }
582 | }
583 | });
584 | }
585 |
586 | /**
587 | * Gets the grid polygon from a grid position (row and column).
588 | * @param {GridOffset} offset
589 | */
590 | _getGridPolygon(offset) {
591 | // todo: check if this has issues with gaps again. If so, bring back expandPolygon
592 | return new PIXI.Polygon(canvas.grid.getVertices(offset));
593 | }
594 |
595 | /**
596 | * Migrate from older flags to newer flag data
597 | * When there's a schema change, the schemaVersion will be changed to the module version
598 | * @returns {boolean} true if changes have been made
599 | */
600 | #migrateData() {
601 | const flags = this.scene.flags[MODULE] ?? {};
602 | // When there's a schema change, set the schemaVersion to the module version
603 | const schemaVersion = "2.1.0";
604 | const flagsVersion = flags.schemaVersion ?? 0;
605 |
606 | // Stop if there is no reason to migrate
607 | if (!foundry.utils.isNewerVersion(schemaVersion, flagsVersion)) return false;
608 |
609 | const updateFlags = {
610 | "flags.world-explorer.schemaVersion": schemaVersion,
611 | };
612 |
613 | // Check if migration is needed
614 | if (foundry.utils.isNewerVersion("2.1.0", flagsVersion)) {
615 | // v2.1.0
616 | // Introduction of schemaVersion in #migratePositions
617 | // Migrate to the gridData flag
618 | // This includes all previous migrations
619 | const oldFlags = ["revealed", "revealedPositions", "gridPositions"];
620 | const hasOldFlag = oldFlags.find((flag) => flag in flags);
621 | if (hasOldFlag) {
622 | // Get info about grid position that are in the padding, so they aren't migrated
623 | const { x, y, width, height } = canvas.dimensions.sceneRect;
624 | // First grid square/hex that is on the map (sceneRect)
625 | const startOffset = canvas.grid.getOffset({ x: x + 1, y: y + 1 });
626 | // Last grid square/hex that is on the map (sceneRect)
627 | const endOffset = canvas.grid.getOffset({ x: x + width - 1, y: y + height - 1 });
628 |
629 | const newFlagData = flags[hasOldFlag].reduce((newFlag, position) => {
630 | let i, j, reveal;
631 | switch (hasOldFlag) {
632 | case "revealed":
633 | [i, j] = canvas.grid.getGridPositionFromPixels(...position);
634 | reveal = true;
635 | break;
636 | case "revealedPositions":
637 | [i, j] = position;
638 | reveal = true;
639 | break;
640 | case "gridPositions":
641 | [i, j, reveal] = position;
642 | reveal = reveal === "reveal" ? true : "partial";
643 | break;
644 | }
645 | // Only add it if this offset is on the map and not in the padding
646 | if (i >= startOffset.i && j >= startOffset.j && i <= endOffset.i && j <= endOffset.j) {
647 | const offset = { i, j };
648 | const key = offsetToString(offset);
649 | newFlag[key] = { offset, reveal };
650 | }
651 | return newFlag;
652 | }, {});
653 |
654 | updateFlags["flags.world-explorer.gridData"] = newFlagData;
655 | for (const flag of oldFlags) {
656 | updateFlags[`flags.world-explorer.-=${flag}`] = null;
657 | }
658 | ui.notifications.info(game.i18n.localize("WorldExplorer.Notifications.Migrated"));
659 | }
660 | }
661 |
662 | // Set current version to the flags and process added migrations
663 | this.scene.update(updateFlags);
664 | return true;
665 | }
666 | }
667 |
--------------------------------------------------------------------------------