diff --git a/README.md b/README.md new file mode 100644 index 0000000..116f6e2 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Daggerheart Environment Overlay + +A FoundryVTT module for the **Daggerheart** system that allows GMs to link **Environment** type actors to specific scenes. When a linked scene is active, the environment actor's token is displayed as an interactive, persistent overlay on the canvas. + +## Features + +- **Scene Linking**: Drag and drop an Environment actor into the Scene Configuration to link it. +- **Interactive Overlay**: Click the overlay to instantly open the Environment actor's sheet. +- **Movable Interface**: Hold `Alt` + Left Click and Drag to reposition the overlay. The position is saved per-scene. +- **Customizable**: + - Toggle the display of the actor's name. + - Customize the border color of the overlay token. +- **Permission Support**: The overlay respects ownership permissions, ensuring only relevant users can see or interact with it. diff --git a/module.json b/module.json new file mode 100644 index 0000000..76b3af7 --- /dev/null +++ b/module.json @@ -0,0 +1,35 @@ +{ + "id": "dh-environment-overlay", + "title": "Daggerheart Environment Overlay", + "description": "Links Environment actors to scenes, displaying them as interactive, movable overlays on the canvas.", + "version": "1.0.0", + "compatibility": { + "minimum": "13", + "verified": "13" + }, + "authors": [ + { + "name": "Antigravity" + } + ], + "relationships": { + "systems": [ + { + "id": "daggerheart", + "type": "system", + "compatibility": { + "minimum": "1.0.0" + } + } + ] + }, + "esmodules": [ + "scripts/module.js" + ], + "styles": [ + "styles/module.css" + ], + "url": "", + "manifest": "", + "download": "" +} \ No newline at end of file diff --git a/scripts/module.js b/scripts/module.js new file mode 100644 index 0000000..ac7865d --- /dev/null +++ b/scripts/module.js @@ -0,0 +1,286 @@ +const MODULE_ID = "dh-environment-overlay"; +const FLAG_KEY = "environmentUuid"; + +Hooks.once("init", () => { + console.log(`${MODULE_ID} | Initializing Daggerheart Environment Overlay`); + + game.settings.register(MODULE_ID, "borderColor", { + name: "Overlay Border Color", + hint: "Color of the environment token border.", + scope: "world", + config: true, + type: String, + default: "#f3c267", + onChange: () => renderEnvironmentOverlay() + }); + + game.settings.register(MODULE_ID, "showName", { + name: "Show Actor Name", + hint: "Display the environment actor's name below the token.", + scope: "world", + config: true, + type: Boolean, + default: true, + onChange: () => renderEnvironmentOverlay() + }); +}); + +/** + * Handle Scene Configuration Render + */ +Hooks.on("renderSceneConfig", async (app, html, data) => { + // Determine if html is a jQuery object or HTMLElement + const $html = html instanceof HTMLElement ? $(html) : html; + + // Target the Daggerheart specific tab + const dhTab = $html.find('.tab[data-tab="dh"]'); + + const environmentUuid = app.document.getFlag(MODULE_ID, FLAG_KEY); + + let currentActor = null; + if (environmentUuid) { + currentActor = await fromUuid(environmentUuid); + } + + const dropZoneHtml = ` +
+ +
+
+ ${currentActor ? ` +
+ + ${currentActor.name} + +
+ ` : ` +
+ Drag & Drop Environment Actor Here +
+ `} +
+
+

Link a Daggerheart Environment Actor to this scene. Its token will appear as an overlay.

+
+ `; + + // Inject into the DH tab if it exists + if (dhTab.length > 0) { + dhTab.append(dropZoneHtml); + } else { + // Fallback: Try to put it in the first tab or top of form + const form = $html.find("form"); + const nameGroup = $html.find("input[name='name']").closest(".form-group"); + if (nameGroup.length > 0) { + nameGroup.after(dropZoneHtml); + } else { + form.prepend(dropZoneHtml); + } + } + + const dropZone = $html.find(".dh-environment-wrapper"); + + // Add drag/drop listeners + if (dropZone.length === 0) return; + + dropZone[0].addEventListener("dragover", (event) => { + event.preventDefault(); + dropZone.find(".dh-environment-drop-zone").addClass("drag-hover"); + }); + + dropZone[0].addEventListener("dragleave", (event) => { + event.preventDefault(); + dropZone.find(".dh-environment-drop-zone").removeClass("drag-hover"); + }); + + dropZone[0].addEventListener("drop", async (event) => { + event.preventDefault(); + dropZone.find(".dh-environment-drop-zone").removeClass("drag-hover"); + + try { + const data = TextEditor.getDragEventData(event); + if (data.type !== "Actor") return; + + const actor = await fromUuid(data.uuid); + if (!actor || actor.type !== "environment") { + ui.notifications.warn("DH Environment Overlay | Please drop a valid 'Environment' type Actor."); + return; + } + + updateDropZoneDisplay(dropZone, actor); + + } catch (err) { + console.error(err); + } + }); + + // Handle Remove + dropZone.on("click", ".dh-environment-remove", () => { + updateDropZoneDisplay(dropZone, null); + }); + + // Helper to update display and hidden input + function updateDropZoneDisplay(wrapper, actor) { + // Find or create hidden input + let input = wrapper.find(`input[name="flags.${MODULE_ID}.${FLAG_KEY}"]`); + if (input.length === 0) { + input = $(``); + wrapper.append(input); + } + + input.val(actor ? actor.uuid : ""); + + const content = actor ? ` +
+ + ${actor.name} + +
+ ` : ` +
+ Drag & Drop Environment Actor Here +
+ `; + + wrapper.children().not("input").remove(); + wrapper.append(content); + } + + // Ensure hidden input exists for initial validation if null + let existingInput = $html.find(`input[name="flags.${MODULE_ID}.${FLAG_KEY}"]`); + if (existingInput.length === 0) { + dropZone.append(``); + } + + // Force resize because we added content + app.setPosition({ height: "auto" }); + +}); + +/** + * Handle Overlay Rendering + */ +async function renderEnvironmentOverlay() { + // Remove existing + const existing = $("#dh-environment-overlay"); + if (existing.length) existing.remove(); + + if (!canvas.scene) return; + + const environmentUuid = canvas.scene.getFlag(MODULE_ID, FLAG_KEY); + if (!environmentUuid) return; + + try { + const actor = await fromUuid(environmentUuid); + if (!actor) return; // Maybe deleted? + + // PERMISSION CHECK + // testUserPermission second arg "OBSERVER" means user needs at least OBSERVER level. + // If users need only LIMITED, use "LIMITED". + // For opening the sheet (which we do on click), usually OBSERVER/OWNER is needed to see much, + // but even LIMITED users can open sheet (partial view). + // Let's assume OBSERVER is good to see the overlay (since they can see the token). + // Actually, if it's an "Environment" actor, players might need at least Limited to interactions. + // Let's stick to OBSERVER as safe default, or LIMITED if desired. + // User said "players who have permissions to access the actor". + // This implies at least LIMITED. + if (!actor.testUserPermission(game.user, "LIMITED")) { + return; + } + + const position = canvas.scene.getFlag(MODULE_ID, "overlayPosition") || { top: 100, right: 320 }; + const borderColor = game.settings.get(MODULE_ID, "borderColor"); + const showName = game.settings.get(MODULE_ID, "showName"); + + let styleStr = ""; + if (position.left !== undefined) styleStr += `left: ${position.left}px;`; + else if (position.right !== undefined) styleStr += `right: ${position.right}px;`; + + if (position.top !== undefined) styleStr += `top: ${position.top}px;`; + else if (position.bottom !== undefined) styleStr += `bottom: ${position.bottom}px;`; + + const overlay = $(` +
+ + ${showName ? `
${actor.name}
` : ""} +
+ `); + + $("body").append(overlay); + + overlay.on("click", (event) => { + // Prevent opening sheet if we just dragged + if (overlay.data("isDragging")) return; + actor.sheet.render(true); + }); + + // Drag Logic + overlay.on("mousedown", (event) => { + if (!event.altKey) return; + + event.preventDefault(); + event.stopPropagation(); + + overlay.data("isDragging", true); + + // Get initial cursor offset relative to element + const rect = overlay[0].getBoundingClientRect(); + const offsetX = event.clientX - rect.left; + const offsetY = event.clientY - rect.top; + + const moveHandler = (moveEvent) => { + const x = moveEvent.clientX - offsetX; + const y = moveEvent.clientY - offsetY; + + // Update specific styles to override CSS class + overlay.css({ + left: `${x}px`, + top: `${y}px`, + right: 'auto', + bottom: 'auto' + }); + }; + + const upHandler = async (upEvent) => { + $(document).off("mousemove", moveHandler); + $(document).off("mouseup", upHandler); + + // Small delay to prevent click trigger + setTimeout(() => overlay.data("isDragging", false), 50); + + const rect = overlay[0].getBoundingClientRect(); + const newPos = { + left: rect.left, + top: rect.top + }; + + await canvas.scene.setFlag(MODULE_ID, "overlayPosition", newPos); + }; + + $(document).on("mousemove", moveHandler); + $(document).on("mouseup", upHandler); + }); + + } catch (err) { + console.warn(`${MODULE_ID} | Failed to load environment actor:`, err); + } +} + +Hooks.on("canvasReady", renderEnvironmentOverlay); +Hooks.on("updateScene", (document, change, options, userId) => { + if (!document.isView) return; + + if (canvas.scene && document.id === canvas.scene.id) { + if (foundry.utils.hasProperty(change, `flags.${MODULE_ID}`)) { + const flags = change.flags[MODULE_ID]; + if (flags) { + if (flags[FLAG_KEY] !== undefined) { + renderEnvironmentOverlay(); + } + else if (flags.overlayPosition && userId !== game.user.id) { + renderEnvironmentOverlay(); + } + } + } + } +}); diff --git a/styles/module.css b/styles/module.css new file mode 100644 index 0000000..ae58ec1 --- /dev/null +++ b/styles/module.css @@ -0,0 +1,104 @@ +/* Scene Config Styles */ +.dh-environment-wrapper { + margin-top: 10px; + padding: 5px; + border: 1px solid var(--color-border-light-2); + border-radius: 4px; + background: rgba(0, 0, 0, 0.05); +} + +.dh-environment-drop-zone { + height: 50px; + border: 2px dashed var(--color-border-light-2); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-light-2); + font-size: 14px; + transition: background-color 0.2s, border-color 0.2s; +} + +.dh-environment-drop-zone.drag-hover { + background-color: rgba(0, 255, 0, 0.1); + border-color: var(--color-text-green); + color: var(--color-text-green); +} + +.dh-environment-info { + display: flex; + align-items: center; + gap: 10px; + padding: 5px; +} + +.dh-environment-img { + width: 40px; + height: 40px; + object-fit: cover; + border: 1px solid var(--color-border-dark); + border-radius: 4px; +} + +.dh-environment-name { + flex: 1; + font-weight: bold; +} + +.dh-environment-remove { + cursor: pointer; + color: var(--color-text-dark-inactive); +} + +.dh-environment-remove:hover { + color: var(--color-text-error); +} + +/* Overlay Styles */ +#dh-environment-overlay { + position: fixed; + /* Default fallback if flags missing */ + top: 100px; + right: 320px; + z-index: 50; + cursor: pointer; + transition: transform 0.2s ease; + pointer-events: all; + display: flex; + flex-direction: column; + align-items: center; + width: 80px; + /* Constrain width for centering */ +} + +#dh-environment-overlay img { + width: 80px; + height: 80px; + border-radius: 50%; + /* Border color handled via inline style now */ + border: 3px solid #ffcc00; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + background-color: #000; + transition: transform 0.2s, box-shadow 0.2s; +} + +#dh-environment-overlay:hover img { + transform: scale(1.05); + box-shadow: 0 0 15px rgba(255, 204, 0, 0.6); +} + +.dh-environment-overlay-name { + margin-top: 5px; + background: rgba(0, 0, 0, 0.8); + color: #fff; + padding: 2px 6px; + border-radius: 4px; + font-size: 12px; + text-align: center; + white-space: nowrap; + text-shadow: 1px 1px 2px #000; + pointer-events: none; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; +} \ No newline at end of file