├── .gitignore ├── .gitattributes ├── .prettierrc.json ├── .github └── workflows │ ├── get-version.js │ └── main.yml ├── images ├── screenshot-world-explorer-tools.webp ├── screenshot-world-explorer-gm-view.webp ├── screenshot-world-explorer-player-view.webp └── screenshot-world-explorer-scene-settings.webp ├── templates ├── opacity-adjuster.hbs └── scene-settings.hbs ├── .editorconfig ├── styles └── world-explorer.css ├── lang ├── ja.json ├── cn.json └── en.json ├── package.json ├── types.d.ts ├── module.json ├── module ├── world-explorer-grid-data.mjs ├── opacity-slider.mjs ├── util.mjs ├── scene-updater.mjs └── world-explorer-layer.mjs ├── link-foundry.mjs ├── Readme.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.db text 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 4 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/get-version.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | console.log(JSON.parse(fs.readFileSync('./module.json', 'utf8')).version); -------------------------------------------------------------------------------- /images/screenshot-world-explorer-tools.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarlosFdez/world-explorer/HEAD/images/screenshot-world-explorer-tools.webp -------------------------------------------------------------------------------- /images/screenshot-world-explorer-gm-view.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarlosFdez/world-explorer/HEAD/images/screenshot-world-explorer-gm-view.webp -------------------------------------------------------------------------------- /images/screenshot-world-explorer-player-view.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarlosFdez/world-explorer/HEAD/images/screenshot-world-explorer-player-view.webp -------------------------------------------------------------------------------- /images/screenshot-world-explorer-scene-settings.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarlosFdez/world-explorer/HEAD/images/screenshot-world-explorer-scene-settings.webp -------------------------------------------------------------------------------- /templates/opacity-adjuster.hbs: -------------------------------------------------------------------------------- 1 |
2 | 3 |
-------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.mjs,*.ts] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | max_line_length = 100 11 | -------------------------------------------------------------------------------- /styles/world-explorer.css: -------------------------------------------------------------------------------- 1 | #world-explorer-opacity-adjuster { 2 | justify-content: center; 3 | padding: 6px 12px; 4 | min-height: 32px; 5 | } 6 | .form-fields.world-explorer-derived { 7 | flex: 0.6; 8 | } 9 | /* Make identical to a slider number input */ 10 | .form-fields.world-explorer-derived input[type=number] { 11 | flex: 0 0 40px; 12 | text-align: center; 13 | padding: 0; 14 | font-size: 0.8em; 15 | } -------------------------------------------------------------------------------- /lang/ja.json: -------------------------------------------------------------------------------- 1 | { 2 | "WorldExplorer": { 3 | "Name": "ワールドマスク", 4 | "FIELDS": { 5 | "image": { 6 | "Name": "マスク画像" 7 | }, 8 | "color": { 9 | "Name": "マスク色" 10 | }, 11 | "persistExploredAreas": { 12 | "Name": "コマが見たマスクを記憶する" 13 | }, 14 | "revealRadius": { 15 | "Name": "コマ視認距離" 16 | } 17 | }, 18 | "SceneSettings": { 19 | "Enabled": "このシーンでModを使用する" 20 | }, 21 | "Tools": { 22 | "Toggle": "タイル切替", 23 | "Reset": "マスク・リセット" 24 | }, 25 | "ResetDialog": { 26 | "Title": "シーンのマスクをリセットしますか?", 27 | "Content": "シーンのマスク除去状況がリセットされます" 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "world-explorer", 3 | "version": "2.0.2", 4 | "description": "GM tool for hexcrawl campaigns that allows displaying a color layer or image over the background layer, while keeping the grid and tile elements visible. Tiles can be removed by the GM to reveal the underlying map on successing scouting or mapping checks. Enable in scene configuration.", 5 | "main": "index.js", 6 | "scripts": { 7 | "link": "node link-foundry.mjs" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/CarlosFdez/world-explorer.git" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "bugs": { 16 | "url": "https://github.com/CarlosFdez/world-explorer/issues" 17 | }, 18 | "homepage": "https://github.com/CarlosFdez/world-explorer#readme", 19 | "devDependencies": { 20 | "pixi.js": "^7.2.4", 21 | "prompts": "^2.4.2", 22 | "typescript": "^4.7.4" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | import PixiJS from "pixi.js"; 2 | 3 | declare global { 4 | type EditingMode = "toggle" | "reveal" | "hide"; 5 | 6 | type CoordsOrOffset = { offset?: unknown; coords?: unknown }; 7 | type Position = "back" | "behindDrawings" | "behindTokens" | "front"; 8 | 9 | interface GridEntry { 10 | offset: { i: number; j: number; }; 11 | reveal: boolean | "partial"; 12 | } 13 | 14 | interface WorldExplorerFlags { 15 | color: string; 16 | revealRadius: number; 17 | gridRevealRadius: number; 18 | opacityGM: number; 19 | opacityPlayer: number; 20 | persistExploredAreas: boolean; 21 | image?: string; 22 | enabled?: boolean; 23 | zIndex: number; 24 | gridData?: Record; 25 | position: Position; 26 | } 27 | 28 | interface WorldExplorerState { 29 | clearing: boolean; 30 | tool: EditingMode; 31 | } 32 | 33 | namespace globalThis { 34 | export import PIXI = PixiJS; 35 | } 36 | } -------------------------------------------------------------------------------- /module.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "world-explorer", 3 | "title": "World Explorer", 4 | "description": "Manual reveal of background image for map traversal tracking", 5 | "version": "2.1.1", 6 | "authors": [ 7 | { 8 | "name": "Supe", 9 | "flags": {} 10 | } 11 | ], 12 | "compatibility": { 13 | "minimum": "13", 14 | "verified": "13.347", 15 | "maximum": "13" 16 | }, 17 | "esmodules": [ 18 | "index.js" 19 | ], 20 | "languages": [ 21 | { 22 | "lang": "en", 23 | "name": "English", 24 | "path": "lang/en.json", 25 | "flags": {} 26 | }, 27 | { 28 | "lang": "ja", 29 | "name": "日本語", 30 | "path": "lang/ja.json", 31 | "flags": {} 32 | } 33 | ], 34 | "styles": [ 35 | "styles/world-explorer.css" 36 | ], 37 | "url": "https://github.com/CarlosFdez/world-explorer", 38 | "manifest": "https://github.com/CarlosFdez/world-explorer/releases/latest/download/module.json", 39 | "download": "https://github.com/CarlosFdez/world-explorer/releases/download/v2.1.1/module.zip" 40 | } -------------------------------------------------------------------------------- /module/world-explorer-grid-data.mjs: -------------------------------------------------------------------------------- 1 | import { offsetToString } from "./util.mjs"; 2 | 3 | /** Contains the grid data for the world */ 4 | export class WorldExplorerGridData { 5 | /** @type {GridEntry[]} */ 6 | revealed; 7 | 8 | /** @type {GridEntry[]} */ 9 | partials; 10 | 11 | /** 12 | * @param {Record} 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 |
2 | 3 | {{!-- Enable --}} 4 |
5 | 6 | 7 |
8 | 9 | {{!-- Overlay Settings --}} 10 |
11 | {{localize "WorldExplorer.SceneSettings.Overlay.Title"}} 12 | {{!-- Image --}} 13 |
14 | 15 |
16 | 17 |
18 |
19 | {{!-- Color --}} 20 |
21 | 22 |
23 | 24 |
25 |

{{localize "WorldExplorer.FIELDS.color.Hint"}}

26 |
27 | {{!-- Position --}} 28 |
29 | 30 |
31 | 34 |
35 |

{{localize "WorldExplorer.FIELDS.position.Hint"}}

36 |
37 | {{!-- GM Opacity --}} 38 |
39 | 40 |
41 | 42 |
43 |
44 | {{!-- Player Opacity --}} 45 |
46 | 47 |
48 | 49 |
50 |
51 |
52 | 53 | {{!-- Partial Settings --}} 54 |
55 | {{localize "WorldExplorer.SceneSettings.Partial.Title"}} 56 | {{!-- Partial Color --}} 57 |
58 | 59 |
60 | 61 |
62 |

{{{localize "WorldExplorer.FIELDS.partialColor.Hint"}}}

63 |
64 | {{!-- Partial Opacity --}} 65 |
66 | 67 |
68 | 69 |
70 | {{!-- Display calculated GM Partial Opacity --}} 71 |
72 | 73 | 74 |
75 |

{{localize "WorldExplorer.FIELDS.partialOpacityPlayer.Hint"}}

76 |
77 |
78 | 79 | {{!-- Token Interactions --}} 80 |
81 | {{localize "WorldExplorer.SceneSettings.TokenInteractions.Title"}} 82 |
83 | 84 | 85 |
86 |
87 | 88 |
89 | 90 |
91 |

{{localize "WorldExplorer.FIELDS.revealRadius.Hint"}}

92 |
93 |
94 | 95 |
96 | 97 |
98 |

{{localize "WorldExplorer.FIELDS.gridRevealRadius.Hint"}}

99 |
100 |
101 |
-------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------