import { injectConfig } from "./lib/injectConfig.js";
import { TileHandler } from "./handlers/tileHandler.js";
import { RefreshHandler } from "./handlers/refreshHandler.js";
import { DrawingHandler } from "./handlers/drawingHandler.js";
import { UIHandler } from "./handlers/uiHandler.js";
import { SightHandler } from "./handlers/sightHandler.js";
import { LightHandler } from "./handlers/lightHandler.js";
import { SoundHandler } from "./handlers/soundHandler.js";
import { NoteHandler } from "./handlers/noteHandler.js";
import { TokenHandler } from "./handlers/tokenHandler.js";
import { TemplateHandler } from "./handlers/templateHandler.js";
import { FoWHandler } from "./handlers/fowHandler.js";
import { BackgroundHandler } from "./handlers/backgroundHandler.js";
import { SettingsHandler } from "./handlers/settingsHandler.js";
import { LevelsAPI } from "./API.js";
import { registerWrappers } from "./wrappers.js";
import { inRange, getRangeForDocument, cloneTileMesh, inDistance } from "./helpers.js";
import { setupWarnings } from "./warnings.js";
//warnings
Hooks.on("ready", () => {
if (!game.user.isGM) return;
setupWarnings();
const recommendedVersion = "10.291";
if (isNewerVersion(recommendedVersion, game.version)) {
ui.notifications.error(`Levels recommends Foundry VTT version ${recommendedVersion} or newer. Levels might not work as expected in the currently installed version (${game.version}).`, { permanent: true });
return;
}
});
Object.defineProperty(globalThis, "_levels", {
get: () => {
console.warn("Levels: _levels is deprecated. Use CONFIG.Levels.API instead.");
return CONFIG.Levels.API;
},
});
Object.defineProperty(TileDocument.prototype, "elevation", {
get: function () {
if (CONFIG.Levels?.UI?.rangeEnabled && !this.id) {
return parseFloat(CONFIG.Levels.UI.range[0] || 0);
}
return this.overhead ? this.flags?.levels?.rangeBottom ?? canvas.scene.foregroundElevation : canvas.primary.background.elevation;
},
});
Tile.prototype.inTriggeringRange = function (token) {
const bottom = this.document.elevation;
let top = this.document.flags?.levels?.rangeTop ?? Infinity;
if (game.Levels3DPreview?._active) {
const depth = this.document.flags?.["levels-3d-preview"]?.depth;
if (depth) top = bottom + (depth / canvas.scene.dimensions.size) * canvas.scene.dimensions.distance;
}
if (token) {
return token.document.elevation >= bottom && token.document.elevation <= top;
} else {
return { bottom, top };
}
};
Object.defineProperty(DrawingDocument.prototype, "elevation", {
get: function () {
if (CONFIG.Levels?.UI?.rangeEnabled && !this.id) {
return parseFloat(CONFIG.Levels.UI.range[0] || 0);
}
return this.flags?.levels?.rangeBottom ?? canvas.primary.background.elevation;
},
});
Object.defineProperty(NoteDocument.prototype, "elevation", {
get: function () {
return this.flags?.levels?.rangeBottom ?? canvas.primary.background.elevation;
},
});
Object.defineProperty(AmbientLightDocument.prototype, "elevation", {
get: function () {
if (CONFIG.Levels.UI.rangeEnabled && !this.id) {
return parseFloat(CONFIG.Levels.UI.range[0] || 0);
}
return this.flags?.levels?.rangeBottom ?? canvas.primary.background.elevation;
},
});
Object.defineProperty(AmbientSoundDocument.prototype, "elevation", {
get: function () {
if (CONFIG.Levels.UI.rangeEnabled && !this.id) {
return parseFloat(CONFIG.Levels.UI.range[0] || 0);
}
if (isNaN(this.flags?.levels?.rangeBottom)) return canvas.primary.background.elevation;
return (this.flags?.levels?.rangeBottom + (this.flags?.levels?.rangeTop ?? this.flags?.levels?.rangeBottom)) / 2;
},
});
Object.defineProperty(MeasuredTemplateDocument.prototype, "elevation", {
get: function () {
return this.flags?.levels?.elevation ?? canvas.primary.background.elevation;
},
});
Object.defineProperty(WeatherEffects.prototype, "elevation", {
get: function () {
return canvas?.scene?.flags?.levels?.weatherElevation ?? Infinity;
},
set: function (value) {
console.error("Cannot set elevation on WeatherEffects. Levels overrides WeatherEffects.prototype.elevation core behaviour. If you wish to set the WeatherEffects elevation, use SceneDocument.flags.levels.weatherElevation");
},
});
Hooks.on("init", () => {
const canvas3d = game.modules.get("levels-3d-preview")?.active;
CONFIG.Levels = {
MODULE_ID: "levels",
};
Object.defineProperty(CONFIG.Levels, "useCollision3D", {
get: function () {
return canvas3d && canvas.scene.flags["levels-3d-preview"]?.object3dSight;
},
});
Object.defineProperty(CONFIG.Levels, "currentToken", {
get: function () {
return this._currentToken;
},
set: function (value) {
this._currentToken = value;
Hooks.callAll("levelsPerspectiveChanged", this._currentToken);
},
});
CONFIG.Levels.handlers = {
TileHandler,
RefreshHandler,
DrawingHandler,
UIHandler,
SightHandler,
LightHandler,
SoundHandler,
NoteHandler,
TokenHandler,
TemplateHandler,
FoWHandler,
BackgroundHandler,
SettingsHandler,
};
CONFIG.Levels.helpers = {
inRange,
getRangeForDocument,
cloneTileMesh,
inDistance,
};
CONFIG.Levels.API = LevelsAPI;
CONFIG.Levels.UI = new LevelsUI();
CONFIG.Levels.settings = new SettingsHandler();
Hooks.callAll("levelsInit", CONFIG.Levels);
registerWrappers();
CONFIG.Levels.FoWHandler = new FoWHandler();
CONFIG.Levels.handlers.BackgroundHandler.setupElevation();
Hooks.callAll("levelsReady", CONFIG.Levels);
});
Hooks.once("ready", () => {
if (game.modules.get("levels-3d-preview")?.active) return;
// Module title
const MODULE_ID = CONFIG.Levels.MODULE_ID;
const MODULE_TITLE = game.modules.get(MODULE_ID).title;
const FALLBACK_MESSAGE_TITLE = MODULE_TITLE;
const FALLBACK_MESSAGE = ` This module may be very complicated for a first timer, be sure to stop by my Discord for help and support from the wonderful community as well as many resources Thanks to all the patreons supporting the development of this module making continued updates possible! If you want to support the development of the module or get customized support in setting up your maps you can do so here : Patreon
Patreons get also access to 15+ premium modules
Is Levels not enough? Go Full 3D
Check 3D Canvas and all my other 15+ premium modules Here
Special thanks to Baileywiki for the support and feedback and Blair for the amazing UI elements
`; // Settings key used for the "Don't remind me again" setting const DONT_REMIND_AGAIN_KEY = "popup-dont-remind-again-2"; // Dialog code game.settings.register(MODULE_ID, DONT_REMIND_AGAIN_KEY, { name: "", default: false, type: Boolean, scope: "world", config: false, }); if (game.user.isGM && !game.settings.get(MODULE_ID, DONT_REMIND_AGAIN_KEY)) { new Dialog({ title: FALLBACK_MESSAGE_TITLE, content: FALLBACK_MESSAGE, buttons: { ok: { icon: '', label: "Understood" }, dont_remind: { icon: '', label: "Don't remind me again", callback: () => game.settings.set(MODULE_ID, DONT_REMIND_AGAIN_KEY, true), }, }, }).render(true); } }); Hooks.on("init", () => { game.settings.register(CONFIG.Levels.MODULE_ID, "tokenElevScale", { name: game.i18n.localize("levels.settings.tokenElevScale.name"), hint: game.i18n.localize("levels.settings.tokenElevScale.hint"), scope: "world", config: true, type: Boolean, default: false, onChange: () => { CONFIG.Levels.settings.cacheSettings(); }, }); game.settings.register(CONFIG.Levels.MODULE_ID, "tokenElevScaleMultiSett", { name: game.i18n.localize("levels.settings.tokenElevScaleMultiSett.name"), hint: game.i18n.localize("levels.settings.tokenElevScaleMultiSett.hint"), scope: "world", config: true, type: Number, range: { min: 0.1, max: 2, step: 0.1, }, default: 1, onChange: () => { CONFIG.Levels.settings.cacheSettings(); }, }); game.settings.register(CONFIG.Levels.MODULE_ID, "fogHiding", { name: game.i18n.localize("levels.settings.fogHiding.name"), hint: game.i18n.localize("levels.settings.fogHiding.hint"), scope: "world", config: true, type: Boolean, default: true, onChange: () => { CONFIG.Levels.settings.cacheSettings(); }, }); game.settings.register(CONFIG.Levels.MODULE_ID, "revealTokenInFog", { name: game.i18n.localize("levels.settings.revealTokenInFog.name"), hint: game.i18n.localize("levels.settings.revealTokenInFog.hint"), scope: "world", config: true, type: Boolean, default: false, onChange: () => { CONFIG.Levels.settings.cacheSettings(); }, }); game.settings.register(CONFIG.Levels.MODULE_ID, "lockElevation", { name: game.i18n.localize("levels.settings.lockElevation.name"), hint: game.i18n.localize("levels.settings.lockElevation.hint"), scope: "world", config: true, type: Boolean, default: false, onChange: () => { CONFIG.Levels.settings.cacheSettings(); }, }); game.settings.register(CONFIG.Levels.MODULE_ID, "hideElevation", { name: game.i18n.localize("levels.settings.hideElevation.name"), hint: game.i18n.localize("levels.settings.hideElevation.hint"), scope: "world", config: true, type: Number, choices: { 0: game.i18n.localize("levels.settings.hideElevation.opt0"), 1: game.i18n.localize("levels.settings.hideElevation.opt1"), 2: game.i18n.localize("levels.settings.hideElevation.opt2"), }, default: 0, onChange: () => { CONFIG.Levels.settings.cacheSettings(); }, }); game.settings.register(CONFIG.Levels.MODULE_ID, "enableTooltips", { name: game.i18n.localize("levels.settings.enableTooltips.name"), hint: game.i18n.localize("levels.settings.enableTooltips.hint"), scope: "world", config: true, type: Boolean, default: false, onChange: () => { CONFIG.Levels.settings.cacheSettings(); }, }); game.settings.register(CONFIG.Levels.MODULE_ID, "preciseTokenVisibility", { name: game.i18n.localize("levels.settings.preciseTokenVisibility.name"), hint: game.i18n.localize("levels.settings.preciseTokenVisibility.hint"), scope: "world", config: true, type: Boolean, default: true, onChange: () => { CONFIG.Levels.settings.cacheSettings(); }, }); game.settings.register(CONFIG.Levels.MODULE_ID, "exactTokenVisibility", { name: game.i18n.localize("levels.settings.exactTokenVisibility.name"), hint: game.i18n.localize("levels.settings.exactTokenVisibility.hint"), scope: "world", config: true, type: Boolean, default: false, onChange: () => { CONFIG.Levels.settings.cacheSettings(); }, }); }); Hooks.on("updateTile", (tile, updates) => { if (updates?.flags?.levels?.allWallBlockSight !== undefined) { canvas.walls.placeables.forEach((w) => w.identifyInteriorState()); WallHeight.schedulePerceptionUpdate(); } }); Hooks.on("renderTileConfig", (app, html, data) => { const isInjected = html.find(`input[name="flags.${CONFIG.Levels.MODULE_ID}.rangeTop"]`).length > 0; if (isInjected) return; const injHtml = injectConfig.inject(app, html, { moduleId: "levels", tab: { name: "levels", label: "Levels", icon: "fas fa-layer-group", }, rangeTop: { type: "number", label: game.i18n.localize("levels.tileconfig.rangeTop.name"), units: game.i18n.localize("levels.tileconfig.range.unit"), default: "", placeholder: "Infinity", step: "any", }, rangeBottom: { type: "number", label: game.i18n.localize("levels.tileconfig.rangeBottom.name"), units: game.i18n.localize("levels.tileconfig.range.unit"), default: "", placeholder: "-Infinity", step: "any", }, showIfAbove: { type: "checkbox", label: game.i18n.localize("levels.tileconfig.showIfAbove.name"), notes: game.i18n.localize("levels.tileconfig.showIfAbove.hint"), }, showAboveRange: { type: "number", label: game.i18n.localize("levels.tileconfig.showAboveRange.name"), notes: game.i18n.localize("levels.tileconfig.showAboveRange.hint"), units: game.i18n.localize("levels.tileconfig.range.unit"), default: "", placeholder: "Infinity", }, noCollision: { type: "checkbox", label: game.i18n.localize("levels.tileconfig.noCollision.name"), notes: game.i18n.localize("levels.tileconfig.noCollision.hint"), }, noFogHide: { type: "checkbox", label: game.i18n.localize("levels.tileconfig.noFogHide.name"), notes: game.i18n.localize("levels.tileconfig.noFogHide.hint"), }, isBasement: { type: "checkbox", label: game.i18n.localize("levels.tileconfig.isBasement.name"), notes: game.i18n.localize("levels.tileconfig.isBasement.hint"), }, allWallBlockSight: { type: "checkbox", label: game.i18n.localize("levels.tileconfig.allWallBlockSight.name"), notes: game.i18n.localize("levels.tileconfig.allWallBlockSight.hint"), default: true, }, }); injHtml.find(`input[name="flags.${CONFIG.Levels.MODULE_ID}.rangeTop"]`).closest(".form-group").before(`${game.i18n.localize("levels.tileconfig.noOverhead")}>
${game.i18n.localize("levels.tileconfig.occlusionNone")}> `); html.on("change", "input", (e) => { const isOverhead = html.find(`input[name="overhead"]`).is(":checked"); const occlusionMode = html.find(`select[name="occlusion.mode"]`).val(); const isShowIfAbove = injHtml.find(`input[name="flags.levels.showIfAbove"]`).is(":checked"); injHtml.find("input").prop("disabled", !isOverhead); injHtml.find("input[name='flags.levels.showAboveRange']").closest(".form-group").toggle(isShowIfAbove); html.find("#no-overhead-warning").toggle(!isOverhead); html.find("#occlusion-none-warning").toggle(occlusionMode == 0); app.setPosition({ height: "auto" }); }); html.find(`input[name="overhead"]`).trigger("change"); app.setPosition({ height: "auto" }); }); Hooks.on("renderAmbientLightConfig", (app, html, data) => { const injHtml = injectConfig.inject(app, html, { moduleId: "levels", inject: 'input[name="config.dim"]', rangeTop: { type: "number", label: game.i18n.localize("levels.tileconfig.rangeTop.name"), units: game.i18n.localize("levels.tileconfig.range.unit"), default: "", placeholder: "Infinity", step: "any", }, rangeBottom: { type: "number", label: game.i18n.localize("levels.tileconfig.rangeBottom.name"), units: game.i18n.localize("levels.tileconfig.range.unit"), default: "", placeholder: "-Infinity", step: "any", }, }); }); Hooks.on("renderNoteConfig", (app, html, data) => { const injHtml = injectConfig.inject(app, html, { moduleId: "levels", inject: 'select[name="textAnchor"]', rangeTop: { type: "number", label: game.i18n.localize("levels.tileconfig.rangeTop.name"), units: game.i18n.localize("levels.tileconfig.range.unit"), default: "", placeholder: "Infinity", step: "any", }, rangeBottom: { type: "number", label: game.i18n.localize("levels.tileconfig.rangeBottom.name"), units: game.i18n.localize("levels.tileconfig.range.unit"), default: "", placeholder: "-Infinity", step: "any", }, }); }); Hooks.on("renderAmbientSoundConfig", (app, html, data) => { const injHtml = injectConfig.inject(app, html, { moduleId: "levels", inject: 'input[name="radius"]', rangeTop: { type: "number", label: game.i18n.localize("levels.tileconfig.rangeTop.name"), units: game.i18n.localize("levels.tileconfig.range.unit"), default: "", placeholder: "Infinity", step: "any", }, rangeBottom: { type: "number", label: game.i18n.localize("levels.tileconfig.rangeBottom.name"), units: game.i18n.localize("levels.tileconfig.range.unit"), default: "", placeholder: "-Infinity", step: "any", }, }); }); Hooks.on("renderDrawingConfig", (app, html, data) => { const injHtml = injectConfig.inject(app, html, { moduleId: "levels", inject: 'input[name="z"]', drawingMode: { type: "select", label: game.i18n.localize("levels.drawingconfig.isHole.name"), default: 0, dType: "Number", options: { 0: game.i18n.localize("levels.drawingconfig.isHole.opt0"), 2: game.i18n.localize("levels.drawingconfig.isHole.opt2"), 21: game.i18n.localize("levels.drawingconfig.isHole.opt21"), 22: game.i18n.localize("levels.drawingconfig.isHole.opt22"), 3: game.i18n.localize("levels.drawingconfig.isHole.opt3"), }, }, elevatorFloors: { type: "text", label: game.i18n.localize("levels.drawingconfig.elevatorFloors.name"), notes: game.i18n.localize("levels.drawingconfig.elevatorFloors.hint"), }, rangeTop: { type: "number", label: game.i18n.localize("levels.tileconfig.rangeTop.name"), units: game.i18n.localize("levels.tileconfig.range.unit"), default: "", placeholder: "Infinity", step: "any", }, rangeBottom: { type: "number", label: game.i18n.localize("levels.tileconfig.rangeBottom.name"), units: game.i18n.localize("levels.tileconfig.range.unit"), default: "", placeholder: "-Infinity", step: "any", }, }); }); Hooks.on("renderMeasuredTemplateConfig", (app, html, data) => { const injHtml = injectConfig.inject(app, html, { moduleId: "levels", inject: 'input[name="width"]', elevation: { type: "text", dType: "Number", label: game.i18n.localize("levels.template.elevation.name"), units: game.i18n.localize("levels.tileconfig.range.unit"), default: Infinity, step: "any", }, special: { type: "number", label: game.i18n.localize("levels.template.depth.name"), default: 0, dType: "Number", }, }); }); Hooks.on("renderDrawingHUD", (data, hud, drawData) => { let drawing = data.object.document; if (drawing.getFlag(CONFIG.Levels.MODULE_ID, "drawingMode")) { let active = drawing.getFlag(CONFIG.Levels.MODULE_ID, "stairLocked") || false; let toggleStairbtn = `
`; const controlIcons = hud.find("div.control-icon"); controlIcons.last().after(toggleStairbtn); $(hud.find(`div[id="toggleStair"]`)).on("click", test); function test() { console.log("test"); active = !active; drawing.setFlag(CONFIG.Levels.MODULE_ID, "stairLocked", !(drawing.getFlag(CONFIG.Levels.MODULE_ID, "stairLocked") || false)); let hudbtn = hud.find(`div[id="toggleStair"]`); if (active) hudbtn.addClass("active"); else hudbtn.removeClass("active"); } } }); Hooks.on("renderTokenHUD", (data, hud, drawData) => { if (CONFIG.Levels.settings.get("lockElevation") && !game.user.isGM) { const controlIcons = hud.find(`div[class="attribute elevation"]`); $(controlIcons[0]).remove(); } }); Hooks.on("preCreateMeasuredTemplate", (template) => { const templateData = CONFIG.Levels.handlers.TemplateHandler.getTemplateData(); if (template.flags?.levels?.elevation) return; template.updateSource({ flags: { levels: { elevation: templateData.elevation, special: templateData.special } }, }); }); Hooks.on("renderSceneConfig", (app, html, data) => { injectConfig.inject(app, html, { moduleId: "levels", inject: 'input[name="foregroundElevation"]', backgroundElevation: { type: "number", label: game.i18n.localize("levels.sceneconfig.backgroundElevation.name"), notes: game.i18n.localize("levels.sceneconfig.backgroundElevation.notes"), default: 0, }, }); injectConfig.inject(app, html, { moduleId: "levels", inject: 'select[name="weather"]', weatherElevation: { type: "number", label: game.i18n.localize("levels.sceneconfig.weatherElevation.name"), notes: game.i18n.localize("levels.sceneconfig.weatherElevation.notes"), default: "", placeholder: "Infinity", }, }); injectConfig.inject(app, html, { moduleId: "levels", inject: 'input[name="fogExploration"]', lightMasking: { type: "checkbox", label: game.i18n.localize("levels.sceneconfig.lightMasking.name"), notes: game.i18n.localize("levels.sceneconfig.lightMasking.notes"), default: true, }, }); });