dh-stream-overlay/scripts/module.js

1266 lines
46 KiB
JavaScript

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 `<img src="${settings.customSvgPath}" class="fear-icon-img" />`;
}
if (iconClass.includes('.svg')) {
return `<img src="${iconClass}" class="fear-icon-img" />`;
}
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 `<i class="${iconClass}" style="${style}"></i>`;
};
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 ? '' : '<div class="fear-label">Fear</div>'}
<div class="fear-tokens">
`;
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 += `<div class="${classes}" style="${style}">${icon}</div>`;
}
html += `</div>`;
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 = `<div style="color:white; opacity:0.6; padding:10px;">No Active Players or GM Found</div>`;
return;
}
let html = `<div class="dh-party-grid">`;
// 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 += `
<div class="dh-party-card" style="min-height: 100px;">
<div class="dh-card-img-wrapper" style="width: 100px;">
<img class="dh-card-img" src="${img}" />
</div>
<div class="dh-card-content">
<div class="dh-card-header" style="border-bottom:none; padding-bottom:0;">
<div class="dh-card-name" style="color: #fca5a5;">Game Master</div>
<div class="dh-card-meta" style="font-size:1.1em; color:white; margin-top:5px;">${name}</div>
</div>
</div>
</div>
`;
});
// 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 = `<div class="dh-pips-container ${type}">`;
for (let i = 1; i <= max; i++) {
h += `<div class="dh-pip ${i <= val ? 'marked' : 'open'}"></div>`;
}
h += `</div>`;
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 = `<div class="dh-card-name">${name}</div>`;
const playerNameStr = `<div class="dh-card-player-name" style="font-size:0.9em; color:#aaa; margin-bottom:2px;">${user.name}</div>`;
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 ? `<i class="fas fa-heart"></i>` : "";
const iconStress = showIcons ? `<i class="fas fa-bolt"></i>` : "";
const iconHope = showIcons ? `<i class="fas fa-star"></i>` : "";
// Resource Values (Numeric vs Pips)
const renderRes = (val, max, type) => {
if (resMode === "pips") return renderPips(val, max, type);
return `<div><span class="stat-val">${val}</span> <span class="stat-max">/ ${max}</span></div>`;
};
// 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 ?
`<div class="dh-spotlight-indicator"><i class="fas fa-hand-sparkles"></i></div>` : "";
html += `
<div class="dh-party-card ${turnClass}">
${spotlightHtml}
<div class="dh-card-img-wrapper">
<img class="dh-card-img" src="${img}" />
</div>
<div class="dh-card-content">
<div class="${headerClass}">
${nameHtml}
<div class="dh-card-meta">
${metaAncestry ? `<span class="dh-meta-ancestry">${metaAncestry}</span>` : ""}
</div>
<div class="dh-card-meta">
${metaClass ? `<span class="dh-meta-class">${metaClass}</span>` : ""}
</div>
</div>
${showRes ? `
<div class="dh-stat-row ${showResLabels ? 'with-labels' : ''}">
<div class="dh-stat stat-hp" title="HP">
${(showIcons || showResLabels) ? `<div class="dh-stat-icon">${iconHp} ${showResLabels ? '<span class="stat-label">HP</span>' : ''}</div>` : ''}
${renderRes(hp.value, hp.max, "hp")}
</div>
<div class="dh-stat stat-stress" title="Stress">
${(showIcons || showResLabels) ? `<div class="dh-stat-icon">${iconStress} ${showResLabels ? '<span class="stat-label">Stress</span>' : ''}</div>` : ''}
${renderRes(stress.value, stress.max, "stress")}
</div>
<div class="dh-stat stat-hope" title="Hope">
${(showIcons || showResLabels) ? `<div class="dh-stat-icon">${iconHope} ${showResLabels ? '<span class="stat-label">Hope</span>' : ''}</div>` : ''}
${renderRes(hope.value, hope.max, "hope")}
</div>
</div>` : ''}
${showAttr ? `
<div class="${attrClass}">
<div class="dh-attr"><span class="dh-attr-label">${lbl.agi}</span> <span class="dh-attr-val">${agi}</span></div>
<div class="dh-attr"><span class="dh-attr-label">${lbl.str}</span> <span class="dh-attr-val">${str}</span></div>
<div class="dh-attr"><span class="dh-attr-label">${lbl.fin}</span> <span class="dh-attr-val">${fin}</span></div>
<div class="dh-attr"><span class="dh-attr-label">${lbl.ins}</span> <span class="dh-attr-val">${inst}</span></div>
<div class="dh-attr"><span class="dh-attr-label">${lbl.pre}</span> <span class="dh-attr-val">${pre}</span></div>
<div class="dh-attr"><span class="dh-attr-label">${lbl.kno}</span> <span class="dh-attr-val">${kno}</span></div>
</div>` : ''}
</div>
</div>
`;
});
html += `</div>`;
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 = `<legend>${game.i18n.localize("DH_STREAM_OVERLAY.Settings.MenuLabel")}</legend>`;
section.classList.add("dh-stream-overlay-menu-section");
const button = document.createElement("button");
button.innerHTML = `<i class="fas fa-video"></i> ${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 <ol>
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);
}