From cd38fd5d9b34ec3dd2d657ed6c10708192427723 Mon Sep 17 00:00:00 2001 From: Tim Evans Date: Wed, 10 Jun 2026 18:11:57 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20SLAGMStatusWindow=20=E2=80=94=20draggab?= =?UTF-8?q?le=20PC=20status=20board=20with=20live=20refresh=20(v1.6.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- styles/zero-engine.css | 56 ++++++++++++ system.json | 2 +- zero-engine.mjs | 187 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 232 insertions(+), 13 deletions(-) diff --git a/styles/zero-engine.css b/styles/zero-engine.css index 23bfca5..7b44d3d 100644 --- a/styles/zero-engine.css +++ b/styles/zero-engine.css @@ -4615,3 +4615,59 @@ body > .ze-pause-root .ze-sat-inner { color: #eee; border-radius: 3px; padding: 3px 4px; font-size: 13px; font-weight: bold; } + +/* ── GM Status Window ────────────────────────────────────────────────────── */ +.gm-status-window .gsw-wrap { + display: flex; flex-direction: column; gap: 0; + background: #111; color: #ddd; font-family: var(--font-primary, sans-serif); + min-width: 300px; +} +.gm-status-window .gsw-header-bar { + display: flex; align-items: center; justify-content: space-between; + padding: 6px 10px; background: rgba(255,255,255,0.05); + border-bottom: 1px solid #333; +} +.gm-status-window .gsw-post-chat { + font-size: 11px; padding: 3px 10px; + background: rgba(0,150,255,0.2); border: 1px solid #0099ff; + color: #66bbff; border-radius: 3px; cursor: pointer; +} +.gm-status-window .gsw-post-chat:hover { background: rgba(0,150,255,0.4); } +.gm-status-window .gsw-actor-list { display: flex; flex-direction: column; } +.gm-status-window .gsw-actor-row { + display: flex; gap: 10px; align-items: flex-start; + padding: 8px 10px; border-bottom: 1px solid #222; +} +.gm-status-window .gsw-actor-row:last-child { border-bottom: none; } +.gm-status-window .gsw-portrait { + width: 42px; height: 42px; border-radius: 50%; + object-fit: cover; border: 2px solid #444; flex-shrink: 0; + cursor: pointer; transition: border-color 0.15s; +} +.gm-status-window .gsw-portrait:hover { border-color: #ff6600; } +.gm-status-window .gsw-actor-body { flex: 1; min-width: 0; } +.gm-status-window .gsw-actor-name { + font-size: 13px; font-weight: bold; color: #ff6600; + cursor: pointer; margin-bottom: 3px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.gm-status-window .gsw-actor-name:hover { text-decoration: underline; } +.gm-status-window .gsw-stats-row { + display: flex; gap: 12px; flex-wrap: wrap; + font-size: 12px; margin-bottom: 5px; +} +.gm-status-window .gsw-stat { font-variant-numeric: tabular-nums; } +.gm-status-window .gsw-conds-row { + display: flex; flex-wrap: wrap; gap: 4px; font-size: 11px; +} +.gm-status-window .gsw-cond-tag { + display: inline-flex; align-items: center; gap: 4px; + background: rgba(200,50,50,0.25); border: 1px solid #aa3333; + border-radius: 3px; padding: 2px 6px; color: #ff9999; +} +.gm-status-window .gsw-no-cond { color: #444; font-style: italic; font-size: 11px; } +.gm-status-window .gsw-remove-cond { + background: none; border: none; color: #ff6666; cursor: pointer; + font-size: 10px; padding: 0 1px; line-height: 1; +} +.gm-status-window .gsw-remove-cond:hover { color: #ff0000; } diff --git a/system.json b/system.json index 9b7f9cb..edca695 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "id": "zero-engine", "title": "Zero Engine (SLA Industries)", "description": "A basic game system for SLA Industries using the Zero Engine rules.", - "version": "1.6.4", + "version": "1.6.5", "compatibility": { "minimum": 13, "verified": "14.359" diff --git a/zero-engine.mjs b/zero-engine.mjs index 8b88354..6d7ec16 100644 --- a/zero-engine.mjs +++ b/zero-engine.mjs @@ -3742,31 +3742,32 @@ Hooks.once("setup", () => { * Ready hook */ // ── GM SCENE CONTROLS: PC STATUS BUTTON ────────────────────────────────────── -// Adds a standalone clipboard icon to the scene controls toolbar (GM only). -// Foundry v14 uses an object-keyed API; v13 uses an array — handle both. +// Opens the draggable SLAGMStatusWindow (GM only). +// Foundry v14: controls is an object; v13: controls is an array. Hooks.on("getSceneControlButtons", (controls) => { if (!game.user?.isGM) return; + const openStatus = () => SLAGMStatusWindow.open(); + const tool = { name: "sla-pc-status", - title: "PC Status Report", + title: "PC Status Board", icon: "fas fa-clipboard-list", visible: true, button: true, - onChange: () => _broadcastPCConditions(), // v14 - onClick: () => _broadcastPCConditions() // v13 compat + onChange: openStatus, // v14 + onClick: openStatus // v13 compat }; // Foundry v14: controls is a plain object keyed by group name if (controls && !Array.isArray(controls)) { - // Add as a standalone top-level button (no sub-tools; fires on click) controls["sla-gm-tools"] = { name: "sla-gm-tools", title: "SLA GM Tools", icon: "fas fa-clipboard-list", visible: true, button: true, - onChange: () => _broadcastPCConditions() + onChange: openStatus }; return; } @@ -3775,11 +3776,8 @@ Hooks.on("getSceneControlButtons", (controls) => { const tokenGroup = controls.find(c => c.name === "token" || c.name === "tokens"); if (tokenGroup) { tokenGroup.tools ??= []; - if (Array.isArray(tokenGroup.tools)) { - tokenGroup.tools.push(tool); - } else { - tokenGroup.tools["sla-pc-status"] = tool; - } + if (Array.isArray(tokenGroup.tools)) tokenGroup.tools.push(tool); + else tokenGroup.tools["sla-pc-status"] = tool; } }); @@ -6919,6 +6917,171 @@ class SLAGroupNPCTool extends Application { } } +// ── GM PC STATUS WINDOW ─────────────────────────────────────────────────────── +/** + * SLAGMStatusWindow — a draggable, auto-refreshing window showing all PC + * conditions at a glance. Open via the scene-controls clipboard button. + * The GM can also click "Post to Chat" to whisper the same report. + */ +class SLAGMStatusWindow extends Application { + constructor(...args) { + super(...args); + this._hooks = []; + } + + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: 'sla-gm-status-window', + title: '⚡ PC Status Board', + width: 540, + height: 'auto', + resizable: true, + classes: ['zero-engine', 'gm-status-window'] + }); + } + + /** Singleton open / focus */ + static open() { + if (!SLAGMStatusWindow._inst) SLAGMStatusWindow._inst = new SLAGMStatusWindow(); + SLAGMStatusWindow._inst.render(true); + return SLAGMStatusWindow._inst; + } + + _pcActors() { + return (game.actors ?? []).filter(a => + a.type === 'character' && a.system?.details?.isPlayerCharacter + ); + } + + _activeConditions(actor) { + const statuses = actor.statuses ?? new Set(); + return SLA_CONDITIONS.filter(c => statuses.has(c.id)); + } + + _hpColor(cur, max) { + const pct = max > 0 ? cur / max : 1; + if (pct > 0.5) return '#44cc66'; + if (pct > 0.25) return '#ffaa00'; + return '#cc2222'; + } + + async _renderInner() { + const pcs = this._pcActors(); + const rows = pcs.map(actor => { + const sys = actor.system; + const hp = sys.derivedStats?.health ?? { value: 0, max: 0 }; + const res = sys.derivedStats?.resolve ?? { value: 0, max: 0 }; + const stress = sys.derivedStats?.stress ?? 0; + const showOff = sys.details?.showingOff ?? 0; + const conds = this._activeConditions(actor); + const img = actor.img ?? 'icons/svg/mystery-man.svg'; + + const condTags = conds.length ? conds.map(c => { + const allMod = c.diceModifiers?.all ?? 0; + const penalty = allMod ? ` (${allMod > 0 ? '+' : ''}${allMod} all)` : ''; + return ` + ${c.label}${penalty} + + `; + }).join('') : '— clean —'; + + const stressColor = stress === 0 ? '#44cc66' : stress <= 2 ? '#ffaa00' : '#cc2222'; + const soColor = showOff >= 20 ? '#ff4444' : showOff >= 10 ? '#ff9900' : showOff > 0 ? '#ffcc00' : '#888'; + + return `
+ +
+
${actor.name}
+
+ + ❤ ${hp.value}/${hp.max} + + + 🔵 ${res.value}/${res.max} + + + ⚡ ${stress} + + ${showOff > 0 ? `✨ ${showOff}pts` : ''} +
+
${condTags}
+
+
`; + }).join(''); + + const noPC = pcs.length === 0 + ? '
No player characters found.
' + : ''; + + const html = `
+
+ Auto-refreshes on actor changes + +
+
${noPC}${rows}
+
`; + + const el = document.createElement('div'); + el.innerHTML = html; + return $(el); + } + + async _render(force, options) { + await super._render(force, options); + // Register auto-refresh hooks once + if (!this._hooks.length) { + const refresh = () => { if (this.rendered) this.render(false); }; + this._hooks.push( + Hooks.on('updateActor', refresh), + Hooks.on('createActiveEffect', refresh), + Hooks.on('deleteActiveEffect', refresh), + Hooks.on('updateActiveEffect', refresh) + ); + } + } + + async close(options) { + this._hooks.forEach((id, i) => { + const evts = ['updateActor','createActiveEffect','deleteActiveEffect','updateActiveEffect']; + Hooks.off(evts[i], id); + }); + this._hooks = []; + SLAGMStatusWindow._inst = null; + return super.close(options); + } + + activateListeners(html) { + super.activateListeners(html); + // Open sheet on portrait or name click + html[0].querySelectorAll('.gsw-portrait, .gsw-actor-name').forEach(el => { + el.addEventListener('click', () => { + game.actors.get(el.dataset.actorId)?.sheet?.render(true); + }); + }); + // Remove condition + html[0].querySelectorAll('.gsw-remove-cond').forEach(btn => { + btn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + const actor = game.actors.get(btn.dataset.actorId); + if (!actor) return; + const condId = btn.dataset.condId; + // Remove matching active effects + const toDelete = actor.effects.filter(e => + e.statuses?.has(condId) || e.flags?.core?.statusId === condId + ).map(e => e.id); + if (toDelete.length) await actor.deleteEmbeddedDocuments('ActiveEffect', toDelete); + else await actor.toggleStatusEffect(condId, { active: false }); + }); + }); + // Post to chat + html[0].querySelector('.gsw-post-chat')?.addEventListener('click', () => _broadcastPCConditions()); + } +} +SLAGMStatusWindow._inst = null; + // ── ACTOR DIRECTORY BUTTONS ────────────────────────────────────────────────── Hooks.on('renderActorDirectory', (app, html) => { const root = html instanceof jQuery ? html[0] : html;