feat: SLAGMStatusWindow — draggable PC status board with live refresh (v1.6.5)
This commit is contained in:
@@ -4615,3 +4615,59 @@ body > .ze-pause-root .ze-sat-inner {
|
|||||||
color: #eee; border-radius: 3px; padding: 3px 4px; font-size: 13px;
|
color: #eee; border-radius: 3px; padding: 3px 4px; font-size: 13px;
|
||||||
font-weight: bold;
|
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; }
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"id": "zero-engine",
|
"id": "zero-engine",
|
||||||
"title": "Zero Engine (SLA Industries)",
|
"title": "Zero Engine (SLA Industries)",
|
||||||
"description": "A basic game system for SLA Industries using the Zero Engine rules.",
|
"description": "A basic game system for SLA Industries using the Zero Engine rules.",
|
||||||
"version": "1.6.4",
|
"version": "1.6.5",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"minimum": 13,
|
"minimum": 13,
|
||||||
"verified": "14.359"
|
"verified": "14.359"
|
||||||
|
|||||||
+175
-12
@@ -3742,31 +3742,32 @@ Hooks.once("setup", () => {
|
|||||||
* Ready hook
|
* Ready hook
|
||||||
*/
|
*/
|
||||||
// ── GM SCENE CONTROLS: PC STATUS BUTTON ──────────────────────────────────────
|
// ── GM SCENE CONTROLS: PC STATUS BUTTON ──────────────────────────────────────
|
||||||
// Adds a standalone clipboard icon to the scene controls toolbar (GM only).
|
// Opens the draggable SLAGMStatusWindow (GM only).
|
||||||
// Foundry v14 uses an object-keyed API; v13 uses an array — handle both.
|
// Foundry v14: controls is an object; v13: controls is an array.
|
||||||
Hooks.on("getSceneControlButtons", (controls) => {
|
Hooks.on("getSceneControlButtons", (controls) => {
|
||||||
if (!game.user?.isGM) return;
|
if (!game.user?.isGM) return;
|
||||||
|
|
||||||
|
const openStatus = () => SLAGMStatusWindow.open();
|
||||||
|
|
||||||
const tool = {
|
const tool = {
|
||||||
name: "sla-pc-status",
|
name: "sla-pc-status",
|
||||||
title: "PC Status Report",
|
title: "PC Status Board",
|
||||||
icon: "fas fa-clipboard-list",
|
icon: "fas fa-clipboard-list",
|
||||||
visible: true,
|
visible: true,
|
||||||
button: true,
|
button: true,
|
||||||
onChange: () => _broadcastPCConditions(), // v14
|
onChange: openStatus, // v14
|
||||||
onClick: () => _broadcastPCConditions() // v13 compat
|
onClick: openStatus // v13 compat
|
||||||
};
|
};
|
||||||
|
|
||||||
// Foundry v14: controls is a plain object keyed by group name
|
// Foundry v14: controls is a plain object keyed by group name
|
||||||
if (controls && !Array.isArray(controls)) {
|
if (controls && !Array.isArray(controls)) {
|
||||||
// Add as a standalone top-level button (no sub-tools; fires on click)
|
|
||||||
controls["sla-gm-tools"] = {
|
controls["sla-gm-tools"] = {
|
||||||
name: "sla-gm-tools",
|
name: "sla-gm-tools",
|
||||||
title: "SLA GM Tools",
|
title: "SLA GM Tools",
|
||||||
icon: "fas fa-clipboard-list",
|
icon: "fas fa-clipboard-list",
|
||||||
visible: true,
|
visible: true,
|
||||||
button: true,
|
button: true,
|
||||||
onChange: () => _broadcastPCConditions()
|
onChange: openStatus
|
||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3775,11 +3776,8 @@ Hooks.on("getSceneControlButtons", (controls) => {
|
|||||||
const tokenGroup = controls.find(c => c.name === "token" || c.name === "tokens");
|
const tokenGroup = controls.find(c => c.name === "token" || c.name === "tokens");
|
||||||
if (tokenGroup) {
|
if (tokenGroup) {
|
||||||
tokenGroup.tools ??= [];
|
tokenGroup.tools ??= [];
|
||||||
if (Array.isArray(tokenGroup.tools)) {
|
if (Array.isArray(tokenGroup.tools)) tokenGroup.tools.push(tool);
|
||||||
tokenGroup.tools.push(tool);
|
else tokenGroup.tools["sla-pc-status"] = 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 `<span class="gsw-cond-tag" title="${c.description}">
|
||||||
|
<i class="${c.icon ?? 'fas fa-exclamation-triangle'}"></i> ${c.label}${penalty}
|
||||||
|
<button class="gsw-remove-cond" data-actor-id="${actor.id}" data-cond-id="${c.id}" title="Remove condition">✕</button>
|
||||||
|
</span>`;
|
||||||
|
}).join('') : '<span class="gsw-no-cond">— clean —</span>';
|
||||||
|
|
||||||
|
const stressColor = stress === 0 ? '#44cc66' : stress <= 2 ? '#ffaa00' : '#cc2222';
|
||||||
|
const soColor = showOff >= 20 ? '#ff4444' : showOff >= 10 ? '#ff9900' : showOff > 0 ? '#ffcc00' : '#888';
|
||||||
|
|
||||||
|
return `<div class="gsw-actor-row">
|
||||||
|
<img class="gsw-portrait" src="${img}" title="${actor.name}"
|
||||||
|
data-actor-id="${actor.id}" />
|
||||||
|
<div class="gsw-actor-body">
|
||||||
|
<div class="gsw-actor-name" data-actor-id="${actor.id}">${actor.name}</div>
|
||||||
|
<div class="gsw-stats-row">
|
||||||
|
<span class="gsw-stat" title="Health">
|
||||||
|
❤ <span style="color:${this._hpColor(hp.value, hp.max)}">${hp.value}</span>/<span style="opacity:.6">${hp.max}</span>
|
||||||
|
</span>
|
||||||
|
<span class="gsw-stat" title="Resolve">
|
||||||
|
🔵 <span style="color:${this._hpColor(res.value, res.max)}">${res.value}</span>/<span style="opacity:.6">${res.max}</span>
|
||||||
|
</span>
|
||||||
|
<span class="gsw-stat" title="Stress" style="color:${stressColor}">
|
||||||
|
⚡ ${stress}
|
||||||
|
</span>
|
||||||
|
${showOff > 0 ? `<span class="gsw-stat" title="Showing Off tally" style="color:${soColor}">✨ ${showOff}pts</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="gsw-conds-row">${condTags}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const noPC = pcs.length === 0
|
||||||
|
? '<div style="text-align:center;color:#888;padding:20px;">No player characters found.</div>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const html = `<div class="gsw-wrap">
|
||||||
|
<div class="gsw-header-bar">
|
||||||
|
<span style="opacity:.6;font-size:11px;">Auto-refreshes on actor changes</span>
|
||||||
|
<button class="gsw-post-chat" title="Whisper full status report to yourself">
|
||||||
|
<i class="fas fa-comment-dots"></i> Post to Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="gsw-actor-list">${noPC}${rows}</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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 ──────────────────────────────────────────────────
|
// ── ACTOR DIRECTORY BUTTONS ──────────────────────────────────────────────────
|
||||||
Hooks.on('renderActorDirectory', (app, html) => {
|
Hooks.on('renderActorDirectory', (app, html) => {
|
||||||
const root = html instanceof jQuery ? html[0] : html;
|
const root = html instanceof jQuery ? html[0] : html;
|
||||||
|
|||||||
Reference in New Issue
Block a user