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 }); game.settings.register("dh-stream-overlay", "autoExpandChat", { name: "DH_STREAM_OVERLAY.Settings.AutoExpandChat", hint: "DH_STREAM_OVERLAY.Settings.AutoExpandChatHint", scope: "world", config: true, type: Boolean, default: true, onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } }); game.settings.register("dh-stream-overlay", "hideChatActions", { name: "DH_STREAM_OVERLAY.Settings.HideChatActions", hint: "DH_STREAM_OVERLAY.Settings.HideChatActionsHint", scope: "world", config: true, type: Boolean, default: true, onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } }); game.settings.register("dh-stream-overlay", "cardLayout", { name: "DH_STREAM_OVERLAY.Settings.CardLayout", hint: "DH_STREAM_OVERLAY.Settings.CardLayoutHint", scope: "world", config: true, type: String, choices: { "standard": "DH_STREAM_OVERLAY.Settings.Choices.Standard", "vertical": "DH_STREAM_OVERLAY.Settings.Choices.Vertical" }, default: "standard", onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } }); game.settings.register("dh-stream-overlay", "carouselEnabled", { name: "DH_STREAM_OVERLAY.Settings.CarouselEnabled", hint: "DH_STREAM_OVERLAY.Settings.CarouselEnabledHint", scope: "world", config: true, type: Boolean, default: false, onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } }); game.settings.register("dh-stream-overlay", "carouselSpeed", { name: "DH_STREAM_OVERLAY.Settings.CarouselSpeed", hint: "DH_STREAM_OVERLAY.Settings.CarouselSpeedHint", scope: "world", config: true, type: Number, default: 5, range: { min: 2, max: 60, step: 1 }, onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } }); game.settings.register("dh-stream-overlay", "carouselEffect", { name: "DH_STREAM_OVERLAY.Settings.CarouselEffect", hint: "DH_STREAM_OVERLAY.Settings.CarouselEffectHint", scope: "world", config: true, type: String, choices: { "fade": "DH_STREAM_OVERLAY.Settings.Choices.RefFade", "slide": "DH_STREAM_OVERLAY.Settings.Choices.RefSlide", "zoom": "DH_STREAM_OVERLAY.Settings.Choices.RefZoom" }, default: "fade", onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } }); game.settings.register("dh-stream-overlay", "carouselGMMode", { name: "DH_STREAM_OVERLAY.Settings.CarouselGMMode", hint: "DH_STREAM_OVERLAY.Settings.CarouselGMModeHint", scope: "world", config: true, type: String, choices: { "cycle": "DH_STREAM_OVERLAY.Settings.Choices.RefCycle", "static": "DH_STREAM_OVERLAY.Settings.Choices.RefStatic", "hidden": "DH_STREAM_OVERLAY.Settings.Choices.RefHidden" }, default: "cycle", onChange: () => { if (document.body.classList.contains("stream")) location.reload(); } }); }); // ============================================================================= // CAROUSEL LOGIC // ============================================================================= let carouselIndex = 0; let carouselTimer = null; function startCarousel() { if (carouselTimer) clearInterval(carouselTimer); const speed = game.settings.get("dh-stream-overlay", "carouselSpeed") || 5; carouselTimer = setInterval(() => { cycleCarousel(); }, speed * 1000); } function stopCarousel() { if (carouselTimer) { clearInterval(carouselTimer); carouselTimer = null; } } function cycleCarousel() { const container = document.querySelector(".dh-party-grid.carousel-mode"); if (!container) return; const cards = container.querySelectorAll(".dh-party-card"); if (cards.length === 0) return; // Increment carouselIndex++; if (carouselIndex >= cards.length) carouselIndex = 0; updateCarouselClasses(container); } function updateCarouselClasses(container) { if (!container) return; const cards = container.querySelectorAll(".dh-party-card"); // Safety: Ensure index is valid if (carouselIndex >= cards.length) carouselIndex = 0; cards.forEach((card, idx) => { // Reset classes card.classList.remove("active-slide", "exit-slide"); if (idx === carouselIndex) { card.classList.add("active-slide"); } else { // Optional: Mark previous slide for exit animation if needed // For simple fade, just lacking 'active-slide' hides it } }); } // ... // ... 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) { return; } let layoutMode = "standard"; try { layoutMode = game.settings.get("dh-stream-overlay", "cardLayout"); } catch (e) { } let carouselEnabled = false; let carouselEffect = "fade"; let carouselGMMode = "cycle"; try { carouselEnabled = game.settings.get("dh-stream-overlay", "carouselEnabled"); carouselEffect = game.settings.get("dh-stream-overlay", "carouselEffect"); carouselGMMode = game.settings.get("dh-stream-overlay", "carouselGMMode"); } catch (e) { } const renderPips = (val, max, type) => { let h = `
`; for (let i = 1; i <= max; i++) { h += `
`; } h += `
`; return h; }; // --- HTML GENERATORS --- // GM HTML Generator let gmHtml = ""; gms.forEach(gm => { const img = gm.avatar || "icons/svg/mystery-man.svg"; const name = gm.name; gmHtml += `
Game Master
${name}
`; }); // Player HTML Generator let playerHtml = ""; // 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"); } 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 ? `
` : ""; playerHtml += `
${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}
` : ''}
`; }); // --- ASSEMBLY LOGIC --- if (carouselEnabled) { let finalHtml = ""; let carouselClass = `carousel-mode effect-${carouselEffect}`; // GM Logic if (carouselGMMode === "static") { // Static grid for GM, Carousel for Players // Wrap in a flex container container.innerHTML = ` `; } else if (carouselGMMode === "hidden") { // Only Players in Carousel container.innerHTML = `
${playerHtml}
`; } else { // "cycle" or default - Mixed container.innerHTML = `
${gmHtml}${playerHtml}
`; } } else { // Standard Mode container.innerHTML = `
${gmHtml}${playerHtml}
`; } // Post-Render: Apply Carousel State immediately (if applicable) if (carouselEnabled) { // Find the carousel grid (could be the main one or secondary) const grid = container.querySelector(".dh-party-grid.carousel-mode"); if (grid) { updateCarouselClasses(grid); if (!carouselTimer) startCarousel(); } } else { stopCarousel(); } } // ============================================================================= // GLOBAL INIT & TRIGGERS // ============================================================================= let overlayInitialized = false; Hooks.once("ready", async () => { if (document.body.classList.contains("stream") && !overlayInitialized) { overlayInitialized = true; await initStreamOverlay(); } }); // Fallback Init Trigger // Fallback Init Trigger if (window.location.pathname.includes("/stream")) { const attemptInit = async (retries = 0) => { if (overlayInitialized) return; // If game is ready, or purely maximizing retries (10 seconds), force init if (game.ready || retries > 20) { if (!overlayInitialized) { console.warn(`DH Stream Overlay | Fallback Init Triggered (Ready: ${game.ready}, Retries: ${retries})`); overlayInitialized = true; await initStreamOverlay(); } } else { // Check again in 500ms setTimeout(() => attemptInit(retries + 1), 500); } }; // Start the fallback poll setTimeout(() => attemptInit(), 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 = () => { const path = window.location.pathname.replace(/\/game\/?$/, "/stream"); window.open(path, "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..."); // 0a. WAIT FOR SETTINGS // Ensure "init" hook has run so settings are registered. // If not, we wait a moment. if (!game.settings.settings.has("dh-stream-overlay.autoExpandChat")) { console.warn("DH Stream Overlay | Settings not yet registered, waiting for init..."); await new Promise(r => setTimeout(r, 500)); } // 0. WAIT FOR GAME DATA // The stream view might load faster than Foundry data. let dataRetries = 0; while ((!game.users || !game.users.size) && dataRetries < 10) { console.log(`DH Stream Overlay | Waiting for game.users... (${dataRetries}/10)`); await new Promise(r => setTimeout(r, 200)); 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 // 3. Move Chat Log const retryChat = async () => { let attempts = 0; while (attempts < 20) { // Reduced retries to avoid long load times // 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"); // --- FIX: EXISTING MESSAGES --- // Manually strip inline styles from existing messages if auto-expand is OFF const autoExpand = game.settings.get("dh-stream-overlay", "autoExpandChat"); if (!autoExpand) { const $log = $(chatLog); // Remove .expanded class first $log.find(".dice-roll.expanded, .damage-section.expanded, .target-section.expanded").removeClass("expanded"); // Strip inline styles $log.find(".dice-tooltip, .dice-formula, .daggerheart-target-list, .evaluation-selection").css("display", ""); $log.find(".dice-roll .dice-tooltip").css("display", ""); // Specificity check } return; } await new Promise(r => setTimeout(r, 250)); 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 // Scroll chat to bottom on new message & Handle Auto-Expand Logic Hooks.on("renderChatMessage", (message, html) => { // Scroll const log = document.getElementById("chat-log"); if (log) log.scrollTop = log.scrollHeight; // Auto-Expand Logic (Override System Defaults if necessary) // If Auto-Expand is DISABLED, we want to ensure tooltips don't have inline 'display: block' // which the system might be adding by default. try { const autoExpand = game.settings.get("dh-stream-overlay", "autoExpandChat"); if (!autoExpand) { // WRAPPED IN TIMEOUT to beat system race conditions setTimeout(() => { // 1. Remove .expanded class to force collapse state, allowing manual toggle primarily html.find(".dice-roll.expanded, .damage-section.expanded, .target-section.expanded").removeClass("expanded"); // 2. Remove inline display styles that force expansion (failsafe) html.find(".dice-tooltip, .dice-formula, .daggerheart-target-list, .evaluation-selection").css("display", ""); // Also legacy support for non-jquery if appropriate (but standard hook passes jQuery) if (html instanceof HTMLElement) { html.querySelectorAll(".expanded").forEach(el => el.classList.remove("expanded")); const nodes = html.querySelectorAll(".dice-tooltip, .dice-formula, .daggerheart-target-list, .evaluation-selection"); nodes.forEach(n => n.style.display = ""); } }, 10); } } catch (e) { console.warn("DH Stream Overlay | Error in renderChatMessage hook", e); } }); // 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 autoExpand = game.settings.get("dh-stream-overlay", "autoExpandChat"); const hideActions = game.settings.get("dh-stream-overlay", "hideChatActions"); 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 !important; z-index: 10000 !important; /* Force on top of everything */ position: relative; display: flex; flex-direction: column; overflow: hidden; } /* GLOBAL INTERACTIVITY FORCE - ensuring chat is always clickable */ #stream-chat-container #chat-log, #stream-chat-container .chat-message, #stream-chat-container .message-content, #stream-chat-container .dice-roll, #stream-chat-container button { pointer-events: auto !important; } #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; } /* Auto-Expand Chat Logic */ ${autoExpand ? ` .chat-message .message-content, .chat-message .dice-roll, .chat-message .dice-result, .chat-message .dice-tooltip, .chat-message .dice-formula { display: block !important; visibility: visible !important; height: auto !important; opacity: 1 !important; } .dice-tooltip { display: block !important; } ` : ` /* Force collapse default state (allow JS toggling) via HIGH SPECIFICITY */ #stream-chat-container .chat-message .dice-tooltip, #stream-chat-container .chat-message .dice-formula, #stream-chat-container .chat-message .daggerheart-target-list, #stream-chat-container .chat-message .evaluation-selection, #stream-chat-container .chat-message .dice-roll .dice-tooltip, #stream-chat-container .chat-message .dice-result .dice-tooltip { display: none !important; } /* Allow manual interaction if the .expanded class is present */ #stream-chat-container .chat-message .dice-roll.expanded .dice-tooltip, #stream-chat-container .chat-message .dice-roll.expanded .dice-formula, #stream-chat-container .chat-message .target-section.expanded .daggerheart-target-list, #stream-chat-container .chat-message .damage-section.expanded .dice-formula, #stream-chat-container .chat-message .dice-roll.expanded .dice-result .dice-tooltip { display: block !important; } /* FORCE INTERACTIVITY removed from here to be global */ `} ${hideActions ? ` .chat-message button, .chat-message .chat-card-buttons, .chat-message .card-buttons { display: none !important; } ` : ''} #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: 40px; width: 100%; padding: 20px; padding-right: 30px; } .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; } `; style.innerHTML += ` /* Vertical Layout */ .dh-party-grid.layout-vertical { grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); align-items: start; } .dh-party-grid.layout-vertical .dh-party-card { flex-direction: column; min-height: 400px; } .dh-party-grid.layout-vertical .dh-card-img-wrapper { width: 100%; height: 250px; border-right: none; border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .dh-party-grid.layout-vertical .dh-card-img { object-position: top center; } .dh-party-grid.layout-vertical .dh-card-content { padding: 15px; gap: 10px; } `; /* Carousel Mode */ style.innerHTML += ` /* Carousel Mode */ .dh-party-grid.carousel-mode { display: grid !important; grid-template-columns: 1fr !important; grid-template-rows: 1fr; justify-items: center; align-items: center; } .dh-party-grid.carousel-mode .dh-party-card { grid-area: 1 / 1 / -1 / -1; /* Stack on top of each other */ opacity: 0; visibility: hidden; /* TRANSITION FIX: Delay visibility hide so it fades out first */ transition: opacity 0.8s ease-in-out, transform 0.8s ease-in-out, visibility 0s linear 0.8s; pointer-events: none; width: 100%; max-width: 600px; /* Constrain width in carousel */ min-height: 120px; } /* Active Slide */ .dh-party-grid.carousel-mode .dh-party-card.active-slide { opacity: 1; visibility: visible; /* Show immediately */ transition: opacity 0.8s ease-in-out, transform 0.8s ease-in-out, visibility 0s linear; pointer-events: auto; z-index: 10; transform: none; } /* Effects */ /* Fade (Default behavior above) */ /* Slide */ .dh-party-grid.carousel-mode.effect-slide .dh-party-card { transform: translateX(50px); } .dh-party-grid.carousel-mode.effect-slide .dh-party-card.active-slide { transform: translateX(0); } /* Zoom */ .dh-party-grid.carousel-mode.effect-zoom .dh-party-card { transform: scale(0.9); } .dh-party-grid.carousel-mode.effect-zoom .dh-party-card.active-slide { transform: scale(1); } `; style.innerHTML += ` /* Width Constraint for Vertical Carousel */ .dh-party-grid.carousel-mode.layout-vertical .dh-party-card { max-width: 350px !important; } /* Static GM Wrapper */ .dh-carousel-wrapper { display: flex; align-items: center; width: 100%; gap: 20px; justify-content: center; } .dh-static-gm { flex: 0 0 auto; /* Matches vertical card layout styling */ width: 350px; } .dh-static-gm .dh-party-card { height: 100%; max-width: 350px; } `; document.head.appendChild(style); }