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;
|
||||
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",
|
||||
"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"
|
||||
|
||||
+175
-12
@@ -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 `<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 ──────────────────────────────────────────────────
|
||||
Hooks.on('renderActorDirectory', (app, html) => {
|
||||
const root = html instanceof jQuery ? html[0] : html;
|
||||
|
||||
Reference in New Issue
Block a user