From d516de52c0ab16d88600a01de8ed8e40fae9c9f9 Mon Sep 17 00:00:00 2001 From: CPTN Cosmo Date: Wed, 31 Dec 2025 11:18:38 +0100 Subject: [PATCH] feat: Create initial Daggerheart stream overlay module with manifest, scripts, styles, templates, and localization files. --- README.md | 23 + languages/de.json | 35 + languages/en.json | 41 ++ module.json | 50 ++ scripts/module.js | 1266 ++++++++++++++++++++++++++++++++++++ scripts/settings-app.js | 100 +++ styles/stream-overlay.css | 144 ++++ templates/settings-app.hbs | 34 + 8 files changed, 1693 insertions(+) create mode 100644 README.md create mode 100644 languages/de.json create mode 100644 languages/en.json create mode 100644 module.json create mode 100644 scripts/module.js create mode 100644 scripts/settings-app.js create mode 100644 styles/stream-overlay.css create mode 100644 templates/settings-app.hbs diff --git a/README.md b/README.md new file mode 100644 index 0000000..70ddfd0 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Daggerheart Stream Overlay + +A FoundryVTT module designed for the Daggerheart system. This module creates a broadcast-ready `/stream` view that displays player cards, chat, fear tracker, and combat details. + +## Features + +### Stream Overlay +- **Broadcast Ready**: Accessible via `/stream`. +- **Auto-Layout**: Intelligently arranges content with a transparent chat on the left and player/game info on the right. + +### Party & Player Cards +- **Dynamic Player Cards**: Automatically displays cards for all active players and the GM. +- **Live Updates**: Updates in real-time as players change HP, Stress, Hope, or their attributes. +- **Customizable Display**: + - Toggle visibility of Class, Subclass, Ancestry, Community, Resources, and Attributes. + - Choose between **Numeric** (3/6) or **Pips** (boxes) for resource display. + - Full or abbreviated attribute names. + - Customizable player name placement. +- **Spotlight Indicator**: Highlights the active player's card with a gold glow during combat. + +### Fear Tracker +- **Integrated Tracker**: Displays the current Fear tokens at the bottom of the screen. +- **Theme Support**: Fully compatible with `dh-feartrackerplus` themes and settings (icons, colors, gradients). Falls back to a default Blood Moon theme if the module is missing. \ No newline at end of file diff --git a/languages/de.json b/languages/de.json new file mode 100644 index 0000000..08391f4 --- /dev/null +++ b/languages/de.json @@ -0,0 +1,35 @@ +{ + "DH_STREAM_OVERLAY": { + "Settings": { + "ResourceDisplayMode": "Anzeigemodus für Ressourcen", + "ResourceDisplayModeHint": "Wähle zwischen numerischen Werten (3/6) oder visuellen Pips.", + "ShowResourceIcons": "Ressourcen-Icons anzeigen", + "ShowResourceIconsHint": "Sichtbarkeit der Ressourcen-Icons (Herz, Blitz, Stern) umschalten.", + "ShowResources": "Ressourcen anzeigen", + "ShowResourcesHint": "Sichtbarkeit der gesamten Ressourcen-Zeile (HP, Stress, Hoffnung) umschalten.", + "ShowAttributes": "Attribute anzeigen", + "ShowAttributesHint": "Sichtbarkeit des Attributs-Rasters umschalten.", + "ShowClass": "Klasse anzeigen", + "ShowClassHint": "Sichtbarkeit der Charakterklasse.", + "ShowSubclass": "Subklasse anzeigen", + "ShowSubclassHint": "Sichtbarkeit der Charaktersubklasse.", + "ShowAncestry": "Abstammung anzeigen", + "ShowAncestryHint": "Sichtbarkeit der Charakterabstammung.", + "ShowCommunity": "Gemeinschaft anzeigen", + "ShowCommunityHint": "Sichtbarkeit der Charaktergemeinschaft.", + "Choices": { + "CharTop": "Charakter-Name Oben (Spieler Unten)", + "MenuLabel": "Stream-Overlay", + "PlayerNameDisplay": "Spielernamen-Anzeige" + }, + "PlayerNameDisplayHint": "Wo der Spielername relativ zum Charakternamen angezeigt werden soll.", + "HideFearLabel": "Fear-Label verbergen", + "HideFearLabelHint": "Verbirgt die Textbeschriftung 'FEAR' neben den Fear-Tokens.", + "ShowResourceLabels": "Ressourcen-Bezeichnungen anzeigen", + "ShowResourceLabelsHint": "Text-Bezeichnungen (HP, Stress, Hoffnung) neben Icons anzeigen." + }, + "OpenStream": "Stream-Ansicht öffnen", + "ModeNumeric": "Numerisch (3/6)", + "ModePips": "Pips (Boxen)" + } +} \ No newline at end of file diff --git a/languages/en.json b/languages/en.json new file mode 100644 index 0000000..cb63cc1 --- /dev/null +++ b/languages/en.json @@ -0,0 +1,41 @@ +{ + "DH_STREAM_OVERLAY": { + "Settings": { + "ShowFullAttrNames": "Show Full Attribute Names", + "ShowFullAttrNamesHint": "Show full attribute names (Agility, Strength...) instead of abbreviations.", + "ResourceDisplayMode": "Resource Display Mode", + "ResourceDisplayModeHint": "Choose between numeric values (3/6) or visual pips.", + "ShowResourceIcons": "Show Resource Icons", + "ShowResourceIconsHint": "Toggle visibility of the resource icons (Heart, Bolt, Star).", + "ShowResources": "Show Resources", + "ShowResourcesHint": "Toggle visibility of the entire resources row (HP, Stress, Hope).", + "ShowAttributes": "Show Attributes", + "ShowAttributesHint": "Toggle visibility of the attributes grid.", + "ShowClass": "Show Class", + "ShowClassHint": "Toggle visibility of the character class.", + "ShowSubclass": "Show Subclass", + "ShowSubclassHint": "Toggle visibility of the character subclass.", + "ShowAncestry": "Show Ancestry", + "ShowAncestryHint": "Toggle visibility of the character ancestry.", + "ShowCommunity": "Show Community", + "ShowCommunityHint": "Toggle visibility of the character community.", + "UseGreenScreen": "Use Green Screen Background", + "UseGreenScreenHint": "If enabled, sets the background to bright green (#00ff00) for chroma keying. Otherwise, the background is transparent.", + "Choices": { + "CharTop": "Character Name Top (Player Bottom)", + "PlayerTop": "Player Name Top (Character Bottom)", + "NoPlayer": "Hide Player Name" + }, + "MenuLabel": "Stream Overlay", + "PlayerNameDisplay": "Player Name Display", + "PlayerNameDisplayHint": "Where to show the player's name relative to the character name.", + "HideFearLabel": "Hide Fear Label", + "HideFearLabelHint": "Hide the text label 'FEAR' next to the fear tokens.", + "ShowResourceLabels": "Show Resource Labels", + "ShowResourceLabelsHint": "Show text labels (HP, Stress, Hope) next to icons." + }, + "OpenStream": "Open Stream View", + "ModeNumeric": "Numeric (3/6)", + "ModePips": "Pips (Boxes)" + } +} \ No newline at end of file diff --git a/module.json b/module.json new file mode 100644 index 0000000..dc4241a --- /dev/null +++ b/module.json @@ -0,0 +1,50 @@ +{ + "id": "dh-stream-overlay", + "title": "Daggerheart Stream Overlay", + "version": "1.0.0", + "compatibility": { + "minimum": "13", + "verified": "13" + }, + "authors": [ + { + "name": "CPTN Cosmo", + "email": "cptncosmo@gmail.com", + "url": "https://github.com/cptn-cosmo", + "discord": "cptn_cosmo" + } + ], + "relationships": { + "systems": [ + { + "id": "daggerheart", + "type": "system", + "compatibility": {} + } + ], + "requires": [] + }, + "esmodules": [ + "scripts/module.js" + ], + "languages": [ + { + "lang": "en", + "name": "English", + "path": "languages/en.json" + }, + { + "lang": "de", + "name": "Deutsch", + "path": "languages/de.json" + } + ], + "styles": [ + "styles/stream-overlay.css" + ], + "packFolders": [], + "url": "https://github.com/cptn-cosmo/dh-stream-overlay", + "manifest": "https://github.com/cptn-cosmo/dh-stream-overlay/releases/latest/download/module.json", + "download": "https://github.com/cptn-cosmo/dh-stream-overlay/releases/download/1.0.0/dh-stream-overlay.zip", + "description": "A stream overlay module for Daggerheart that displays chat and a linked party actor sheet." +} \ No newline at end of file diff --git a/scripts/module.js b/scripts/module.js new file mode 100644 index 0000000..07db141 --- /dev/null +++ b/scripts/module.js @@ -0,0 +1,1266 @@ + + +console.log("DH Stream Overlay | Module Loaded (Rewrite v1.0)"); + +// ============================================================================= +// CRASH PROTECTION & STUBS +// ============================================================================= + +// The Daggerheart system crashes on the /stream view because it expects +// standard UI elements (ui.combat, canvas) to verify existence. +if (window.location.pathname.includes("/stream")) { + console.log("DH Stream Overlay | Stream View Detected - Applying Stubs"); + + if (typeof window.ui === "undefined") window.ui = {}; + + // Stub ui.combat + if (!window.ui.combat) { + window.ui.combat = { render: () => { }, apps: [], viewed: null, active: false }; + } + + // Stub ui.notifications + if (!window.ui.notifications) { + window.ui.notifications = { + active: [], + warn: (m) => console.warn(m), + error: (m) => console.error(m), + info: (m) => console.log(m) + }; + } +} + +// Fallback Canvas Stub +if (typeof canvas === "undefined" || !canvas) { + window.canvas = { + tokens: { placeables: [], get: () => null }, + grid: { size: 100 }, + ready: true, // DSN requires ready=true + dimensions: { width: 1920, height: 1080 } + }; +} + +// ============================================================================= +// INITIALIZATION +// ============================================================================= + +Hooks.once("init", () => { + // Register Settings + game.settings.register("dh-stream-overlay", "playerNameDisplay", { + name: "DH_STREAM_OVERLAY.Settings.PlayerNameDisplay", + hint: "DH_STREAM_OVERLAY.Settings.PlayerNameDisplayHint", + scope: "world", + config: true, + type: String, + choices: { + "char-top": "DH_STREAM_OVERLAY.Settings.Choices.CharTop", + "player-top": "DH_STREAM_OVERLAY.Settings.Choices.PlayerTop", + "no-player": "DH_STREAM_OVERLAY.Settings.Choices.NoPlayer" + }, + default: "char-top", + onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } + }); + + game.settings.register("dh-stream-overlay", "showResourceLabels", { + name: "DH_STREAM_OVERLAY.Settings.ShowResourceLabels", + hint: "DH_STREAM_OVERLAY.Settings.ShowResourceLabelsHint", + scope: "world", + config: true, + type: Boolean, + default: false, + onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } + }); + + game.settings.register("dh-stream-overlay", "showFullAttrNames", { + name: "DH_STREAM_OVERLAY.Settings.ShowFullAttrNames", + hint: "DH_STREAM_OVERLAY.Settings.ShowFullAttrNamesHint", + scope: "world", + config: true, + type: Boolean, + default: false, + onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } + }); + + game.settings.register("dh-stream-overlay", "resourceDisplayMode", { + name: "DH_STREAM_OVERLAY.Settings.ResourceDisplayMode", + hint: "DH_STREAM_OVERLAY.Settings.ResourceDisplayModeHint", + scope: "world", + config: true, + type: String, + choices: { + "numeric": "DH_STREAM_OVERLAY.ModeNumeric", + "pips": "DH_STREAM_OVERLAY.ModePips" + }, + default: "numeric", + onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } + }); + + game.settings.register("dh-stream-overlay", "showResourceIcons", { + name: "DH_STREAM_OVERLAY.Settings.ShowResourceIcons", + hint: "DH_STREAM_OVERLAY.Settings.ShowResourceIconsHint", + scope: "world", + config: true, + type: Boolean, + default: true, + onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } + }); + + game.settings.register("dh-stream-overlay", "showResources", { + name: "DH_STREAM_OVERLAY.Settings.ShowResources", + hint: "DH_STREAM_OVERLAY.Settings.ShowResourcesHint", + scope: "world", + config: true, + type: Boolean, + default: true, + onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } + }); + + game.settings.register("dh-stream-overlay", "showAttributes", { + name: "DH_STREAM_OVERLAY.Settings.ShowAttributes", + hint: "DH_STREAM_OVERLAY.Settings.ShowAttributesHint", + scope: "world", + config: true, + type: Boolean, + default: true, + onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } + }); + + game.settings.register("dh-stream-overlay", "showClass", { + name: "DH_STREAM_OVERLAY.Settings.ShowClass", + hint: "DH_STREAM_OVERLAY.Settings.ShowClassHint", + scope: "world", + config: true, + type: Boolean, + default: true, + onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } + }); + + game.settings.register("dh-stream-overlay", "showSubclass", { + name: "DH_STREAM_OVERLAY.Settings.ShowSubclass", + hint: "DH_STREAM_OVERLAY.Settings.ShowSubclassHint", + scope: "world", + config: true, + type: Boolean, + default: true, + onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } + }); + + game.settings.register("dh-stream-overlay", "useGreenScreen", { + name: "DH_STREAM_OVERLAY.Settings.UseGreenScreen", + hint: "DH_STREAM_OVERLAY.Settings.UseGreenScreenHint", + scope: "world", + config: true, + type: Boolean, + default: false, + onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } + }); + + game.settings.register("dh-stream-overlay", "hideFearLabel", { + name: "DH_STREAM_OVERLAY.Settings.HideFearLabel", + hint: "DH_STREAM_OVERLAY.Settings.HideFearLabelHint", + scope: "world", + config: true, + type: Boolean, + default: false, + onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } + }); + + game.settings.register("dh-stream-overlay", "showAncestry", { + name: "DH_STREAM_OVERLAY.Settings.ShowAncestry", + hint: "DH_STREAM_OVERLAY.Settings.ShowAncestryHint", + scope: "world", + config: true, + type: Boolean, + default: true, + onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } + }); + + game.settings.register("dh-stream-overlay", "showCommunity", { + name: "DH_STREAM_OVERLAY.Settings.ShowCommunity", + hint: "DH_STREAM_OVERLAY.Settings.ShowCommunityHint", + scope: "world", + config: true, + type: Boolean, + default: true, + onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } + }); + + game.settings.register("dh-stream-overlay", "partyActorUuid", { + scope: "world", config: false, type: String, default: "" + }); + game.settings.register("dh-stream-overlay", "enableDSN", { + scope: "world", config: false, type: Boolean, default: true + }); + +}); +// ... + +// ... inside renderPartyOverlay function ... + +// ============================================================================= +// RENDERER FUNCTION +// ============================================================================= + +// ============================================================================= +// RENDERER FUNCTION +// ============================================================================= + +// ============================================================================= +// RENDERER FUNCTION +// ============================================================================= + +function renderFearTracker(container) { + // If container is not provided, look for it. If not found, do nothing (should be created in init) + if (!container) container = document.getElementById("stream-fear-container"); + if (!container) return; + + // --------------------------------------------------------- + // 1. GATHER DATA + // --------------------------------------------------------- + let currentFear = 0; + let maxFear = 6; + try { + currentFear = game.settings.get("daggerheart", "ResourcesFear") || 0; + const homebrew = game.settings.get("daggerheart", "Homebrew"); + if (homebrew?.maxFear) maxFear = homebrew.maxFear; + } catch (e) { } + + // --------------------------------------------------------- + // 2. GATHER SETTINGS & THEMES + // --------------------------------------------------------- + const settings = { + iconShape: "circle", + iconType: "preset", + presetIcon: "fas fa-skull", + customIcon: "fas fa-skull", + customSvgPath: "icons/svg/mystery-man.svg", + iconColor: "#ffffff", + colorTheme: "foundryborne", // Updated Default + fullColor: "#cf0000" + }; + + // Attempt to read from module if active + if (game.modules.get("dh-feartrackerplus")?.active) { + try { + settings.iconShape = game.settings.get("dh-feartrackerplus", "iconShape"); + settings.iconType = game.settings.get("dh-feartrackerplus", "iconType"); + settings.presetIcon = game.settings.get("dh-feartrackerplus", "presetIcon"); + settings.customIcon = game.settings.get("dh-feartrackerplus", "customIcon"); + settings.customSvgPath = game.settings.get("dh-feartrackerplus", "customSvgPath"); + settings.iconColor = game.settings.get("dh-feartrackerplus", "iconColor"); + settings.colorTheme = game.settings.get("dh-feartrackerplus", "colorTheme"); + settings.fullColor = game.settings.get("dh-feartrackerplus", "fullColor"); + } catch (e) { console.warn("Stream Overlay | Error reading FearTrackerPlus settings", e); } + } + + + // --------------------------------------------------------- + // 3. HELPER FUNCTIONS (adapted from dh-feartrackerplus) + // --------------------------------------------------------- + + const getIconHtml = (index) => { + let iconClass = 'fas fa-skull'; + if (settings.iconType === 'preset') iconClass = settings.presetIcon; + else if (settings.iconType === 'custom') iconClass = settings.customIcon; + else if (settings.iconType === 'custom-svg') { + return ``; + } + + if (iconClass.includes('.svg')) { + return ``; + } + + let style = ""; + // Default to white if fallback + let iColor = settings.iconColor || '#ffffff'; + if (iColor !== '#ffffff') { + if (iColor.includes('gradient')) { + style = `background: ${iColor}; -webkit-background-clip: text; background-clip: text; color: transparent;`; + } else { + style = `color: ${iColor};`; + } + } + return ``; + }; + + const getTokenStyle = (index, total, isActive) => { + if (!isActive) return `background: #252529; border-color: rgba(255,255,255,0.05); box-shadow: inset 0 0 5px rgba(0,0,0,0.8);`; + + let background = '#cf0000'; // Fallback + + if (settings.colorTheme === 'custom') { + if (settings.fullColor.includes('gradient')) { + const posPercent = total > 1 ? (index / (total - 1)) * 100 : 0; + background = `${settings.fullColor} no-repeat; background-size: ${total * 100}% 100%; background-position: ${posPercent}% 0%; background-attachment: scroll; background-origin: border-box;`; + } else { + background = settings.fullColor; + } + } else { + const themes = { + 'hope-fear': { start: '#FFC107', end: '#512DA8' }, + 'blood-moon': { start: '#5c0000', end: '#ff0000' }, + 'ethereal': { start: '#00FFFF', end: '#0000FF' }, + 'toxic': { start: '#00FF00', end: '#FFFF00' }, + 'foundryborne': { start: '#0a388c', end: '#791d7d' } + }; + const t = themes[settings.colorTheme] || themes['blood-moon']; + + // Gradient Logic + const gradient = `linear-gradient(90deg, ${t.start}, ${t.end})`; + const posPercent = total > 1 ? (index / (total - 1)) * 100 : 0; + // Note: We use fixed attachment or careful sizing to simulate the continuous gradient across tokens + // Simplified approach: separate gradient for each or huge gradient + // The method from the module uses manual calculation + background = `${gradient} no-repeat; background-size: ${total * 100}% 100%; background-position: ${posPercent}% 0%;`; + } + + return `background: ${background}; border-color: rgba(255,255,255,0.4); box-shadow: 0 0 10px rgba(0,0,0,0.5);`; + }; + + + // --------------------------------------------------------- + // 4. GENERATE HTML + // --------------------------------------------------------- + + // Check if label should be hidden + const hideLabel = game.settings.get("dh-stream-overlay", "hideFearLabel"); + + let html = ` + ${hideLabel ? '' : '
Fear
'} +
+ `; + + for (let i = 0; i < maxFear; i++) { + const isActive = i < currentFear; + const style = getTokenStyle(i, maxFear, isActive); + const icon = getIconHtml(i); + + // Classes + let classes = `fear-token ${settings.iconShape}`; + if (isActive) classes += " active"; + + html += `
${icon}
`; + } + + html += `
`; + container.innerHTML = html; +} + +function renderPartyOverlay(container) { + if (!container) return; + + // Find Players & GM + const players = game.users.filter(u => u.hasPlayerOwner && u.character); + const gms = game.users.filter(u => u.isGM); + + if (players.length === 0 && gms.length === 0) { + container.innerHTML = `
No Active Players or GM Found
`; + return; + } + + let html = `
`; + + // 1. Render GM(s) + gms.forEach(gm => { + const img = gm.avatar || "icons/svg/mystery-man.svg"; + const name = gm.name; // GM just uses their user name usually + + html += ` +
+
+ +
+
+
+
Game Master
+
${name}
+
+
+
+ `; + }); + + // 2. Render Players + // Check setting availability + let nameMode = "char-top"; + let showResLabels = false; + let showFullAttr = false; + let resMode = "numeric"; + let showIcons = true; + let showRes = true; + let showAttr = true; + let showClass = true; + let showSubclass = true; + let showAncestry = true; + let showCommunity = true; + + try { + nameMode = game.settings.get("dh-stream-overlay", "playerNameDisplay"); + showResLabels = game.settings.get("dh-stream-overlay", "showResourceLabels"); + showFullAttr = game.settings.get("dh-stream-overlay", "showFullAttrNames"); + resMode = game.settings.get("dh-stream-overlay", "resourceDisplayMode"); + showIcons = game.settings.get("dh-stream-overlay", "showResourceIcons"); + showRes = game.settings.get("dh-stream-overlay", "showResources"); + showAttr = game.settings.get("dh-stream-overlay", "showAttributes"); + showClass = game.settings.get("dh-stream-overlay", "showClass"); + showSubclass = game.settings.get("dh-stream-overlay", "showSubclass"); + showAncestry = game.settings.get("dh-stream-overlay", "showAncestry"); + showCommunity = game.settings.get("dh-stream-overlay", "showCommunity"); + } catch (e) { console.warn("DH OVERLAY: Settings not ready yet, using default"); } + + const renderPips = (val, max, type) => { + let h = `
`; + for (let i = 1; i <= max; i++) { + h += `
`; + } + h += `
`; + return h; + }; + + players.forEach(user => { + const actor = user.character; + if (!actor) return; + const system = actor.system || {}; + const items = actor.items; + + // --- DATA MAPPING --- + const name = actor.name; + const img = actor.img || "icons/svg/mystery-man.svg"; + + // Find Items + const findItem = (type) => items.find(i => i.type.toLowerCase() === type.toLowerCase()); + const classItem = findItem("class"); + const subclassItem = findItem("subclass"); + const ancestryItem = findItem("ancestry"); + const communityItem = findItem("community"); + + const className = classItem ? classItem.name : "No Class"; + const subclassName = subclassItem ? subclassItem.name : ""; + const ancestryName = ancestryItem ? ancestryItem.name : "No Ancestry"; + const communityName = communityItem ? communityItem.name : ""; + + // Construct Meta Strings + // Ancestry Line: "Community Ancestry" + let metaAncestry = ""; + if (showCommunity && communityName) metaAncestry += communityName; + if (showCommunity && communityName && showAncestry && ancestryName) metaAncestry += " "; + if (showAncestry && ancestryName) metaAncestry += ancestryName; + + // Class Line: "Subclass Class" + let metaClass = ""; + if (showSubclass && subclassName) metaClass += subclassName; + if (showSubclass && subclassName && showClass && className) metaClass += " "; + if (showClass && className) metaClass += className; + + // Name Display Logic + let nameHtml = ""; + const charNameStr = `
${name}
`; + const playerNameStr = `
${user.name}
`; + + if (nameMode === "player-top") nameHtml = playerNameStr + charNameStr; + else if (nameMode === "no-player") nameHtml = charNameStr; + else nameHtml = charNameStr + playerNameStr; + + // Header Styling Logic + const headerClass = (!showRes && !showAttr) ? "dh-card-header no-border" : "dh-card-header"; + + // Resources + const res = system.resources || {}; + const hp = res.hitPoints || system.health || { value: "?", max: "?" }; + const stress = res.stress || system.stress || { value: "?", max: "?" }; + const hope = res.hope || system.hope || { value: "?", max: "?" }; + + // Attributes (Traits) + const traits = system.traits || {}; + const getTrait = (key) => traits[key]?.value ?? 0; + + const agi = getTrait("agility"); + const str = getTrait("strength"); + const fin = getTrait("finesse"); + const inst = getTrait("instinct"); + const pre = getTrait("presence"); + const kno = getTrait("knowledge"); + + // Attribute Labels + const lbl = showFullAttr ? + { agi: "Agility", str: "Strength", fin: "Finesse", ins: "Instinct", pre: "Presence", kno: "Knowledge" } : + { agi: "AGI", str: "STR", fin: "FIN", ins: "INS", pre: "PRE", kno: "KNO" }; + + const attrClass = showFullAttr ? "dh-attr-grid full-names" : "dh-attr-grid"; + + // Resource Icons + const iconHp = showIcons ? `` : ""; + const iconStress = showIcons ? `` : ""; + const iconHope = showIcons ? `` : ""; + + // Resource Values (Numeric vs Pips) + const renderRes = (val, max, type) => { + if (resMode === "pips") return renderPips(val, max, type); + return `
${val} / ${max}
`; + }; + + // Combat Context + let isSpotlightRequesting = false; + let turnClass = ""; + + // Determine Combat State explicitly + const combat = game.combat || game.combats.active || game.combats.contents.find(c => c.active); + + if (combat) { + // Log once per actor loop to diagnose + // console.log(`DH Overlay | Checking Actor: ${actor.name} (${actor.id}) in Combat: ${combat.id}`); + + let combatant = combat.combatants.find(c => c.actorId === actor.id); + if (combatant) { + // Check Active Turn via multiple heuristics + let isActiveTurn = false; + + // 1. Direct Actor ID match on combatant + if (combat.combatant?.actorId === actor.id) { + isActiveTurn = true; + } + // 2. Turn Index match + else if (combat.turns && Number.isFinite(combat.turn)) { + const turnCombatant = combat.turns[combat.turn]; + if (turnCombatant && turnCombatant.actorId === actor.id) { + isActiveTurn = true; + } + } + + // Final Check Logic + if (isActiveTurn) { + turnClass = "active-turn"; + } + + // Check Spotlight Request + if (combatant.system?.spotlight?.requesting) { + isSpotlightRequesting = true; + } + } else { + // console.log(`DH Overlay | Actor ${actor.name} is NOT in the active combat.`); + } + } else { + // console.log("DH Overlay | No Active Combat Found."); + } + + // Spotlight Indicator HTML + const spotlightHtml = isSpotlightRequesting ? + `
` : ""; + + html += ` +
+ ${spotlightHtml} +
+ +
+
+
+ ${nameHtml} +
+ ${metaAncestry ? `${metaAncestry}` : ""} +
+
+ ${metaClass ? `${metaClass}` : ""} +
+
+ + ${showRes ? ` +
+
+ ${(showIcons || showResLabels) ? `
${iconHp} ${showResLabels ? 'HP' : ''}
` : ''} + ${renderRes(hp.value, hp.max, "hp")} +
+
+ ${(showIcons || showResLabels) ? `
${iconStress} ${showResLabels ? 'Stress' : ''}
` : ''} + ${renderRes(stress.value, stress.max, "stress")} +
+
+ ${(showIcons || showResLabels) ? `
${iconHope} ${showResLabels ? 'Hope' : ''}
` : ''} + ${renderRes(hope.value, hope.max, "hope")} +
+
` : ''} + + ${showAttr ? ` +
+
${lbl.agi} ${agi}
+
${lbl.str} ${str}
+
${lbl.fin} ${fin}
+
${lbl.ins} ${inst}
+
${lbl.pre} ${pre}
+
${lbl.kno} ${kno}
+
` : ''} +
+
+ `; + + }); + + html += `
`; + container.innerHTML = html; +} + +// ============================================================================= +// GLOBAL INIT & TRIGGERS +// ============================================================================= + +let overlayInitialized = false; + +Hooks.once("ready", async () => { + if (document.body.classList.contains("stream") && !overlayInitialized) { + overlayInitialized = true; + await initStreamOverlay(); + } +}); + +// Fallback Init Trigger +document.addEventListener("DOMContentLoaded", () => { + if (window.location.pathname.includes("/stream")) { + setTimeout(async () => { + if (!overlayInitialized && document.body.classList.contains("stream")) { + console.warn("DH Stream Overlay | Fallback Init Triggered"); + overlayInitialized = true; + await initStreamOverlay(); + } + }, 1000); + } +}); + +// Menu Button Hook +Hooks.on("renderDaggerheartMenu", (_app, html) => { + const container = (html instanceof HTMLElement ? html : html[0]).querySelector("div") || html[0]; + const section = document.createElement("fieldset"); + section.innerHTML = `${game.i18n.localize("DH_STREAM_OVERLAY.Settings.MenuLabel")}`; + section.classList.add("dh-stream-overlay-menu-section"); + const button = document.createElement("button"); + button.innerHTML = ` ${game.i18n.localize("DH_STREAM_OVERLAY.OpenStream")}`; + button.style.width = "100%"; + button.onclick = () => window.open("/stream", "streamOverlay", "width=1280,height=720,menubar=no,toolbar=no,status=no"); + section.appendChild(button); + container.appendChild(section); +}); + +// ============================================================================= +// CORE SETUP FUNCTION +// ============================================================================= + +async function initStreamOverlay() { + console.log("DH Stream Overlay | Initializing Layout..."); + + // 0. WAIT FOR GAME DATA + // The stream view might load faster than Foundry data. + let dataRetries = 0; + while ((!game.users || !game.users.size) && dataRetries < 20) { + console.log(`DH Stream Overlay | Waiting for game.users... (${dataRetries}/20)`); + await new Promise(r => setTimeout(r, 500)); + dataRetries++; + } + + if (!game.users) { + console.error("DH Stream Overlay | CRITICAL: game.users never initialized. Aborting."); + return; + } + + // 1. Inject CSS + injectStyles(); + + // 2. Build Layout Structure + const body = document.body; + const wrapper = document.createElement("div"); + wrapper.id = "stream-overlay-wrapper"; + + // Chat Container + const chatContainer = document.createElement("div"); + chatContainer.id = "stream-chat-container"; + wrapper.appendChild(chatContainer); + + // Right Column Wrapper (Flex Column for Fear + Party) + const rightCol = document.createElement("div"); + rightCol.id = "stream-right-col"; + wrapper.appendChild(rightCol); + + // Actor/Party Container (First, so it takes top space) + const actorContainer = document.createElement("div"); + actorContainer.id = "stream-actor-container"; + rightCol.appendChild(actorContainer); + + // Fear Tracker Container (Second, so it sits at bottom) + const fearContainer = document.createElement("div"); + fearContainer.id = "stream-fear-container"; + rightCol.appendChild(fearContainer); + + // Combat Container (Hidden by default) + const combatContainer = document.createElement("div"); + combatContainer.id = "stream-combat-container"; + wrapper.appendChild(combatContainer); + + // Prepend to body so it sits behind/integrated (though fixed positioning handles it) + // We insert before scripts to be safe, or just append. + // Appending is safer for overlay z-index usually. + body.appendChild(wrapper); + + // 3. Move Chat Log + const retryChat = async () => { + let attempts = 0; + while (attempts < 50) { // Try for longer (approx 10s) + // Try Standard DOM + let chatLog = document.getElementById("chat-log"); + + // Try UI Object + if (!chatLog && ui.chat && ui.chat.element) { + chatLog = ui.chat.element[0]; + } + + if (chatLog) { + // Determine if we need to extract the list or the whole app + // Usually #chat-log is the
    + if (chatLog.tagName === "OL" || chatLog.id === "chat-log") { + chatContainer.appendChild(chatLog); + } else { + // If we got the app window, find the list + const list = chatLog.querySelector("#chat-log"); + if (list) chatContainer.appendChild(list); + } + + // Force scroll to bottom + chatLog.scrollTop = chatLog.scrollHeight; + console.log("DH Stream Overlay | Chat Log Moved Successfully"); + return; + } + await new Promise(r => setTimeout(r, 200)); + attempts++; + } + console.warn("DH Stream Overlay | Chat Log NOT FOUND after retries"); + }; + retryChat(); + + // 4. Initial Render of Party Overlay + renderPartyOverlay(actorContainer); + + // 5. Reactivity Hooks + Hooks.on("updateActor", (actor) => { + // If the actor is a player character, re-render + if (actor.hasPlayerOwner) { + renderPartyOverlay(actorContainer); + } + }); + + Hooks.on("userUpdated", () => { + // If assignments change + renderPartyOverlay(actorContainer); + }); + + // Scroll chat to bottom on new message + Hooks.on("renderChatMessageHTML", () => { + const log = document.getElementById("chat-log"); + if (log) log.scrollTop = log.scrollHeight; + }); + + // 6. Combat Tracker Handling + const updateCombat = async () => { + const combat = game.combat; + if (combat?.started) { + combatContainer.style.display = "flex"; + + // Try explicit render for sidebar app compatibility + let tracker = ui.combat; + if (!tracker) tracker = new CombatTracker({ popOut: false }); + + // Force it to render if not already in our container + const el = document.getElementById("combat") || (tracker.element ? tracker.element[0] : null); + + if (!el || !combatContainer.contains(el)) { + // Clone or Move? Move is safer for events. + if (tracker.render) await tracker.render(true); + const newEl = document.getElementById("combat"); + if (newEl) combatContainer.appendChild(newEl); + } + } else { + combatContainer.style.display = "none"; + } + // Force refresh of party overlay to update turn indicator + renderPartyOverlay(actorContainer); + }; + Hooks.on("renderCombatTracker", (app, html) => { + if (combatContainer && html) { + combatContainer.appendChild(html[0]); + combatContainer.style.display = "flex"; + } + }); + Hooks.on("updateCombat", updateCombat); + Hooks.on("deleteCombat", updateCombat); + Hooks.on("createCombat", updateCombat); + updateCombat(); // Initial check + + if (game.modules.get("dice-so-nice")?.active && game.settings.get("dh-stream-overlay", "enableDSN")) { + if (!document.getElementById("board")) { + const board = document.createElement("div"); + board.id = "board"; + board.style.position = "fixed"; board.style.top = 0; board.style.left = 0; + board.style.width = "100%"; board.style.height = "100%"; + board.style.pointerEvents = "none"; board.style.zIndex = 500; + document.body.appendChild(board); + } + if (game.dice3d) game.dice3d.resize(window.innerWidth, window.innerHeight); + } + + // 8. Fear Tracker Init + renderFearTracker(fearContainer); + Hooks.on("updateSetting", (setting) => { + if (setting.key === "daggerheart.ResourcesFear" || setting.key === "daggerheart.Homebrew") { + renderFearTracker(fearContainer); + } + }); +} + +// ============================================================================= +// CSS INJECTION +// ============================================================================= + +function injectStyles() { + const useGreen = game.settings.get("dh-stream-overlay", "useGreenScreen"); + const bgColor = useGreen ? "#00ff00" : "rgba(0, 0, 0, 0)"; + + const style = document.createElement("style"); + style.innerHTML = ` + html, body { + background-color: ${bgColor} !important; + margin: 0px auto; + overflow: hidden !important; + padding: 0; width: 100vw; height: 100vh; + border: none !important; + } + + /* Force removed borders on everything high-level */ + #stream-overlay-wrapper, #stream-chat-container, #stream-actor-container { + border: none !important; + outline: none !important; + box-shadow: none !important; + background: transparent !important; /* Context containers transparent */ + } + + #stream-overlay-wrapper { + display: grid !important; + grid-template-columns: 320px 1fr; + width: 100vw; height: 100vh; + position: fixed; top: 0; left: 0; + z-index: 1000; + z-index: 1000; + pointer-events: none; + } + + /* Fear Tracker */ + #stream-fear-container { + position: fixed; + top: 20px; + left: 360px; /* Offset from chat */ + z-index: 1100; + display: flex; + align-items: center; + gap: 15px; + background: rgba(0, 0, 0, 0.6); + padding: 10px 20px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(5px); + } + + .fear-label { + font-size: 1.2em; + font-weight: 800; + color: rgba(255, 255, 255, 0.8); + letter-spacing: 2px; + text-transform: uppercase; + text-shadow: 0 2px 4px rgba(0,0,0,0.5); + } + + .fear-tokens { + display: flex; + gap: 8px; + } + + .fear-token { + width: 32px; height: 32px; + display: flex; align-items: center; justify-content: center; + background: #333; + border: 2px solid rgba(255,255,255,0.1); + border-radius: 50%; + box-shadow: inset 0 0 10px rgba(0,0,0,0.5); + color: rgba(255,255,255,0.3); + transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + } + + .fear-token.highlighted { + background: linear-gradient(135deg, #a00, #500); + border-color: rgba(255, 100, 100, 0.6); + box-shadow: 0 0 10px rgba(255, 0, 0, 0.4); + color: #fff; + transform: scale(1.1); + } + + .fear-token.highlighted i { + text-shadow: 0 0 5px rgba(255, 255, 255, 0.8); + } + + /* Shape variants */ + .fear-token.square { border-radius: 4px; } + .fear-token.rounded { border-radius: 8px; } + .fear-token.circle { border-radius: 50%; } + + /* Spotlight / Turn Glow */ + .dh-active-glow { + border-color: #ffd700 !important; + box-shadow: 0 0 20px rgba(255, 215, 0, 0.5), 0 4px 15px rgba(0, 0, 0, 0.6) !important; + position: relative; + } + + .dh-spotlight-indicator { + position: absolute; + top: -10px; + right: -10px; + background: #ffd700; + color: #000; + width: 30px; height: 30px; + border-radius: 50%; + display: flex; align-items: center; justify-content: center; + z-index: 10; + box-shadow: 0 0 10px rgba(255, 215, 0, 0.8); + animation: spotlightPulse 1.5s infinite alternate; + font-size: 16px; + } + + @keyframes spotlightPulse { + from { transform: scale(1); box-shadow: 0 0 5px rgba(255, 215, 0, 0.5); } + to { transform: scale(1.15); box-shadow: 0 0 15px rgba(255, 215, 0, 0.9); } + } + + + #stream-chat-container { + grid-column: 1; + height: 100vh; + background: transparent !important; /* Fully transparent for Chroma Key */ + /* border-right: 2px solid rgba(255, 255, 255, 0.1) !important; Optional separator */ + pointer-events: auto; + position: relative; + display: flex; flex-direction: column; + overflow: hidden; + } + + #stream-right-col { + grid-column: 2; + height: 100vh; + overflow: hidden; /* Parent doesn't scroll */ + display: flex; + flex-direction: column; + padding: 20px; + gap: 0; /* Gaps handled by children margins/padding */ + } + + #stream-actor-container { + width: 100%; + flex: 1; /* Takes all available space */ + overflow-y: auto; /* Scrolls internally */ + display: flex; + flex-direction: column; + gap: 15px; + padding-bottom: 20px; /* Spacing before footer */ + } + + /* Fear Tracker */ + #stream-fear-container { + /* Footer Layout - Force Reset */ + position: relative !important; + top: auto !important; + bottom: auto !important; + left: auto !important; + right: auto !important; + + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 15px; + background: rgba(20, 20, 25, 0.9); + padding: 10px 20px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(5px); + box-shadow: 0 -4px 15px rgba(0,0,0,0.5); + margin-top: 10px; + align-self: flex-end; + width: fit-content; + margin-left: auto; + } + + .fear-label { + font-size: 1.2em; + font-weight: 800; + color: rgba(255, 255, 255, 0.8); + letter-spacing: 2px; + text-transform: uppercase; + text-shadow: 0 2px 4px rgba(0,0,0,0.5); + } + + .fear-tokens { + display: flex; + gap: 8px; + } + + .fear-token { + width: 32px; height: 32px; + display: flex; align-items: center; justify-content: center; + background: #333; + border: 2px solid rgba(255,255,255,0.1); + border-radius: 50%; + box-shadow: inset 0 0 10px rgba(0,0,0,0.5); + color: rgba(255,255,255,0.3); + transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + } + + .fear-token img.fear-icon-img { + width: 70%; + height: 70%; + object-fit: contain; + filter: brightness(0) invert(1) drop-shadow(0 0 2px rgba(0, 0, 0, 0.8)); + } + + .fear-token.active { + /* Highlights handled by inline styles now mostly, but keep some defaults */ + color: #fff; + transform: scale(1.1); + } + + .fear-token.active i { + text-shadow: 0 0 5px rgba(255, 255, 255, 0.8); + } + + /* Force Chat Log Visibility */ + #chat-log { + display: block !important; + height: 100% !important; + width: 100% !important; + overflow-y: auto !important; + background: transparent !important; + padding: 10px !important; + margin: 0 !important; + box-sizing: border-box !important; + border: none !important; + list-style: none !important; /* Remove bullets */ + font-family: var(--font-primary) !important; + } + + .chat-message { + background-color: #18181b !important; + background-image: none !important; + border: 1px solid rgba(255, 255, 255, 0.4) !important; + color: #f4f4f5 !important; + margin-bottom: 6px !important; + padding: 8px !important; + border-radius: 4px !important; + font-size: 0.9em !important; + text-shadow: none !important; + box-shadow: 0 2px 5px rgba(0,0,0,0.9) !important; + display: block !important; + opacity: 1 !important; + transform: translateZ(0); /* Force composition layer */ + } + + .chat-message .message-header { + color: #ccc !important; + border-bottom: 1px solid rgba(255,255,255,0.1); + margin-bottom: 4px; + padding-bottom: 2px; + font-weight: bold; + } + + .chat-message .message-content { + color: #fff !important; + } + + /* Hide standard timestamps to save space if needed, or style them */ + .message-timestamp { color: #888; font-size: 0.8em; } + + #stream-combat-container { + position: absolute; + top: 20px; right: 20px; + width: 250px; + background: rgba(0, 0, 0, 0.9); + border: none !important; + display: none; /* Hidden by default */ + flex-direction: column; + pointer-events: auto; + border-radius: 8px; + overflow: hidden; + padding: 5px; + } + + /* Party Overlay Cards - GRID RESTORED */ + .dh-party-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(520px, 1fr)); + align-items: start; /* Prevents cards from stretching vertically to match tallest */ + gap: 15px; + width: 100%; + padding-right: 10px; + } + + .dh-party-card { + background: rgba(20, 20, 25, 0.9); + border: 1px solid rgba(255, 255, 255, 0.1) !important; + border-radius: 8px; + color: white; + display: flex; + flex-direction: row; + overflow: hidden; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.6); + min-height: 90px; + transition: box-shadow 0.3s ease, border-color 0.3s ease; + } + + .dh-party-card.active-turn { + border-color: #ffd700 !important; + box-shadow: 0 0 20px rgba(255, 215, 0, 0.5), 0 4px 15px rgba(0, 0, 0, 0.6) !important; + position: relative; /* Context for spotlight indicator */ + } + + .dh-card-img-wrapper { + width: 140px; + position: relative; + border-right: 1px solid rgba(255,255,255,0.1); + background: #111; + flex-shrink: 0; + } + + .dh-card-img { + width: 100%; + height: 100%; + position: absolute; + top: 0; left: 0; + object-fit: cover; + object-position: top center; + } + + .dh-card-content { + flex: 1; + display: flex; + flex-direction: column; + padding: 12px; + gap: 8px; + justify-content: center; + } + + .dh-card-header { + display: flex; flex-direction: column; gap: 2px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + padding-bottom: 8px; + margin-bottom: auto; + } + + .dh-card-header.no-border { + border-bottom: none !important; + padding-bottom: 0 !important; + margin-bottom: 0 !important; + } + + .dh-card-name { + font-size: 1.5em; + font-weight: 700; + color: #fff; + text-transform: uppercase; + line-height: 1.1; + letter-spacing: 0.5px; + } + + .dh-card-meta { + font-size: 0.85em; + color: #bbb; + display: flex; flex-wrap: wrap; gap: 6px; + } + + .dh-stat-row { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; + background: rgba(0, 0, 0, 0.4); + padding: 8px; + border-radius: 6px; + margin-top: 5px; + } + + .dh-stat { + display: flex; flex-direction: column; align-items: center; justify-content: center; + } + .dh-stat i { font-size: 0.9em; margin-bottom: 3px; opacity: 0.7; } + .stat-val { font-size: 1.3em; font-weight: 700; line-height: 1; } + .stat-max { font-size: 0.85em; opacity: 0.5; } + + .stat-hp { color: #ff8787; } + .stat-stress { color: #ffd43b; } + .stat-hope { color: #74c0fc; } + + .dh-attr-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 2px; + margin-top: 5px; + background: rgba(0,0,0,0.3); + padding: 4px; + border-radius: 4px; + } + + .dh-attr-grid.full-names { + grid-template-columns: repeat(3, 1fr); + gap: 4px; + } + + .dh-attr { + display: flex; flex-direction: column; align-items: center; + } + .dh-attr-label { font-size: 0.7em; color: #777; font-weight: 600; text-transform: uppercase; } + .dh-attr-val { font-size: 1.1em; font-weight: bold; color: #ddd; } + + /* Stat Labels */ + .dh-stat-icon { display: flex; align-items: center; justify-content: center; margin-bottom: 2px; } + .stat-label { font-size: 0.75em; text-transform: uppercase; font-weight: 600; opacity: 0.8; margin-left: 5px; color: rgba(255,255,255,0.7); } + + /* Pips */ + .dh-pips-container { + display: flex; gap: 3px; flex-wrap: wrap; justify-content: center; + max-width: 100%; + margin-top: 5px; + } + .dh-pip { + width: 12px; height: 12px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.4); + border-radius: 2px; + position: relative; + } + + /* Colored Background for ALL pips per type */ + .dh-pips-container.hp .dh-pip { background: rgba(255, 107, 107, 0.2); border-color: #ff6b6b; } + .dh-pips-container.stress .dh-pip { background: rgba(254, 202, 87, 0.2); border-color: #feca57; } + .dh-pips-container.hope .dh-pip { background: rgba(72, 219, 251, 0.2); border-color: #48dbfb; } + + /* Marked (X'd out) Style */ + .dh-pip.marked::after { + content: "✕"; + position: absolute; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + font-size: 10px; + font-weight: bold; + color: white; + line-height: 1; + } + .dh-pips-container.hp .dh-pip.marked { background: #ff6b6b; color: white; } + .dh-pips-container.stress .dh-pip.marked { background: #feca57; color: black; } + .dh-pips-container.hope .dh-pip.marked { background: #48dbfb; color: black; } + + /* Ensure X contrasts */ + .dh-pips-container.stress .dh-pip.marked::after, + .dh-pips-container.hope .dh-pip.marked::after { + color: #333; + } + `; + document.head.appendChild(style); +} + + diff --git a/scripts/settings-app.js b/scripts/settings-app.js new file mode 100644 index 0000000..0d2bce8 --- /dev/null +++ b/scripts/settings-app.js @@ -0,0 +1,100 @@ +const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; + +export class StreamOverlaySettings extends HandlebarsApplicationMixin(ApplicationV2) { + static DEFAULT_OPTIONS = { + id: "stream-overlay-settings", + tag: "form", + window: { + title: "DH_STREAM_OVERLAY.Settings.Title", + icon: "fas fa-video", + resizable: false, + contentClasses: ["standard-form"] + }, + position: { + width: 400, + height: "auto" + }, + form: { + handler: StreamOverlaySettings.#onSubmit, + closeOnSubmit: true + } + }; + + static PARTS = { + form: { + template: "modules/dh-stream-overlay/templates/settings-app.hbs" + } + }; + + async _prepareContext(_options) { + const actorUuid = game.settings.get("dh-stream-overlay", "partyActorUuid"); + let actor = null; + if (actorUuid) { + actor = await fromUuid(actorUuid); + } + + const dsnInstalled = game.modules.get("dice-so-nice")?.active; + const dsnEnabled = game.settings.get("dh-stream-overlay", "enableDSN"); + + return { + actorUuid, + actor, + dsnInstalled, + dsnEnabled + }; + } + + _onRender(context, options) { + super._onRender(context, options); + const html = this.element; + const dropZone = html.querySelector(".drop-zone"); + + if (dropZone) { + dropZone.addEventListener("dragover", this.#onDragOver.bind(this)); + dropZone.addEventListener("dragleave", this.#onDragLeave.bind(this)); + dropZone.addEventListener("drop", this.#onDrop.bind(this)); + } + + const clearBtn = html.querySelector(".clear-actor"); + if (clearBtn) { + clearBtn.addEventListener("click", this.#onClear.bind(this)); + } + } + + #onDragOver(event) { + event.preventDefault(); + event.currentTarget.classList.add("drag-over"); + } + + #onDragLeave(event) { + event.preventDefault(); + event.currentTarget.classList.remove("drag-over"); + } + + async #onDrop(event) { + event.preventDefault(); + event.currentTarget.classList.remove("drag-over"); + const data = foundry.applications.ux.TextEditor.getDragEventData(event); + + if (data.type !== "Actor") return; + + const actor = await fromUuid(data.uuid); + if (actor) { + await game.settings.set("dh-stream-overlay", "partyActorUuid", data.uuid); + this.render(); + } + } + + async #onClear(event) { + event.preventDefault(); + await game.settings.set("dh-stream-overlay", "partyActorUuid", ""); + this.render(); + } + + static async #onSubmit(event, form, formData) { + // Handle DSN setting + if (formData.object.enableDSN !== undefined) { + await game.settings.set("dh-stream-overlay", "enableDSN", formData.object.enableDSN); + } + } +} diff --git a/styles/stream-overlay.css b/styles/stream-overlay.css new file mode 100644 index 0000000..f5931ea --- /dev/null +++ b/styles/stream-overlay.css @@ -0,0 +1,144 @@ +/* Existing settings styles */ +.stream-overlay-settings .drop-zone { + border: 2px dashed var(--color-border-light-2); + border-radius: 5px; + padding: 10px; + text-align: center; + min-height: 60px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-bg-light-1); + transition: background 0.2s, border-color 0.2s; +} + +.stream-overlay-settings .drop-zone.drag-over { + background: var(--color-bg-light-2); + border-color: var(--color-border-highlight); +} + +.stream-overlay-settings .actor-data { + display: flex; + align-items: center; + gap: 10px; + width: 100%; +} + +.stream-overlay-settings .actor-name { + flex: 1; + text-align: left; + font-weight: bold; +} + +.stream-overlay-settings .placeholder { + color: var(--color-text-light-2); + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; +} + +/* Stream View Layout */ +body.dh-stream-overlay-active { + margin: 0; + padding: 0; + overflow: hidden; + background: transparent !important; + /* Allow OBS to key out background if needed */ +} + +#stream-overlay-wrapper { + display: flex; + flex-direction: row; + width: 100vw; + height: 100vh; + box-sizing: border-box; + padding: 10px; + gap: 10px; +} + +/* Panels */ +.stream-panel { + background: rgba(0, 0, 0, 0.5); + border-radius: 10px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +#stream-chat-container { + flex: 0 0 320px; + min-width: 300px; +} + +#stream-combat-container { + flex: 0 0 300px; + min-width: 250px; + /* Hidden by default in JS if not active, or we can transition it */ + transition: flex-basis 0.3s ease, padding 0.3s ease; +} + +#stream-actor-container { + flex: 1; + min-width: 500px; + background: rgba(255, 0, 0, 0.2); + /* DEBUG RED TINT */ + border: 2px solid red; + /* DEBUG BORDER */ + position: relative; + /* Sheet usually has its own background */ +} + +/* Inner adjustments */ +#chat-log, +.chat-log { + flex: 1; + overflow-y: scroll; + padding: 5px; + padding-right: 20px; + /* Space for scrollbar */ + background: transparent !important; + margin: 0; + border: none; + list-style: none; + /* remove bullets */ +} + +/* Ensure messages wrap correctly */ +.chat-log .message { + margin: 5px 0; + padding: 8px; + background: rgba(0, 0, 0, 0.5); + /* semi-transparent background for readability */ + border-radius: 5px; + color: white; + word-wrap: break-word; + word-break: break-word; + /* Ensure long words don't overflow */ + max-width: 100%; +} + +/* Override window-app styles for embedded content */ +#stream-overlay-wrapper .window-app { + box-shadow: none !important; + background: rgba(30, 30, 30, 0.9) !important; + /* Unified dark theme backing */ + margin: 0 !important; +} + +#stream-actor-container .window-content { + background: rgba(255, 255, 255, 0.95); + /* Ensure legibility */ + border-radius: 8px; + height: 100%; +} + +/* Combat tracker specific */ +#combat-tracker { + height: 100%; + margin: 0; +} + +#combat-tracker .combatant { + line-height: 2em; +} \ No newline at end of file diff --git a/templates/settings-app.hbs b/templates/settings-app.hbs new file mode 100644 index 0000000..c271ff7 --- /dev/null +++ b/templates/settings-app.hbs @@ -0,0 +1,34 @@ +
    +

    {{localize "DH_STREAM_OVERLAY.Settings.Instructions"}}

    + +
    + +
    + {{#if actor}} +
    + + {{actor.name}} + +
    + {{else}} +
    + + {{localize "DH_STREAM_OVERLAY.Settings.DropActorHere"}} +
    + {{/if}} + +
    +
    + + {{#if dsnInstalled}} +
    + +

    If enabled, 3D dice rolls will appear on the stream overlay.

    +
    + {{/if}} +
    \ No newline at end of file