diff --git a/_datafiles/html/public/static/js/fx.js b/_datafiles/html/public/static/js/fx.js index b66e7b056..3cbd1854d 100644 --- a/_datafiles/html/public/static/js/fx.js +++ b/_datafiles/html/public/static/js/fx.js @@ -60,7 +60,7 @@ const FX = { // level-up. // duration: fade-out time in seconds. // ----------------------------------------------------------------------- - Flash(color = 'rgba(255,0,0,0.45)', duration = 0.5) { + Flash(color = '#ff0000', duration = 0.5) { const el = document.createElement('div'); el.style.cssText = [ 'position:fixed', 'inset:0', 'pointer-events:none', 'z-index:99999', @@ -267,4 +267,301 @@ const FX = { })(performance.now()); }, + // ----------------------------------------------------------------------- + // Snow — white flakes drift down with gentle sideways sway. + // count: number of flakes. + // duration: seconds the effect runs. + // ----------------------------------------------------------------------- + Snow(count = 150, duration = 4.0) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + document.body.appendChild(canvas); + canvas.style.cssText = 'position:fixed;top:0;left:0;pointer-events:none;z-index:99999;'; + + function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; } + resize(); + window.addEventListener('resize', resize); + + const flakes = Array.from({ length: count }, () => ({ + x: Math.random() * canvas.width, + y: Math.random() * -canvas.height, + size: Math.random() * 3 + 1, + speed: Math.random() * 1.5 + 0.5, + sway: Math.random() * Math.PI * 2, + swaySpeed: Math.random() * 0.02 + 0.005, + alpha: Math.random() * 0.5 + 0.5, + })); + + const start = performance.now(); + const durationMs = duration * 1000; + + (function animate(now) { + const elapsed = now - start; + ctx.clearRect(0, 0, canvas.width, canvas.height); + flakes.forEach(f => { + f.sway += f.swaySpeed; + f.x += Math.sin(f.sway) * 0.8; + f.y += f.speed; + if (f.y > canvas.height) { f.y = -f.size; f.x = Math.random() * canvas.width; } + ctx.globalAlpha = f.alpha; + ctx.beginPath(); + ctx.arc(f.x, f.y, f.size, 0, Math.PI * 2); + ctx.fillStyle = '#ffffff'; + ctx.fill(); + }); + ctx.globalAlpha = 1; + if (elapsed < durationMs) { + requestAnimationFrame(animate); + } else { + window.removeEventListener('resize', resize); + document.body.removeChild(canvas); + } + })(performance.now()); + }, + + // ----------------------------------------------------------------------- + // Embers — slow-rising glowing particles, good for fire rooms or forges. + // count: number of ember particles. + // duration: seconds the effect runs. + // ----------------------------------------------------------------------- + Embers(count = 80, duration = 3.0) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + document.body.appendChild(canvas); + canvas.style.cssText = 'position:fixed;top:0;left:0;pointer-events:none;z-index:99999;'; + + function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; } + resize(); + window.addEventListener('resize', resize); + + const colors = ['#ff6600', '#ff4400', '#ff9900', '#ffcc00', '#ff2200']; + const embers = Array.from({ length: count }, () => ({ + x: Math.random() * canvas.width, + y: canvas.height + Math.random() * 40, + size: Math.random() * 2.5 + 0.5, + speed: Math.random() * 1.2 + 0.4, + sway: Math.random() * Math.PI * 2, + swaySpeed: Math.random() * 0.03 + 0.01, + color: colors[Math.floor(Math.random() * colors.length)], + life: Math.random() * 0.6 + 0.4, + })); + + const start = performance.now(); + const durationMs = duration * 1000; + + (function animate(now) { + const elapsed = now - start; + const t = elapsed / durationMs; + ctx.clearRect(0, 0, canvas.width, canvas.height); + embers.forEach(e => { + e.sway += e.swaySpeed; + e.x += Math.sin(e.sway) * 1.2; + e.y -= e.speed; + if (e.y < -10) { e.y = canvas.height + 10; e.x = Math.random() * canvas.width; } + const alpha = Math.max(0, e.life - t) / e.life; + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.arc(e.x, e.y, e.size, 0, Math.PI * 2); + ctx.fillStyle = e.color; + ctx.fill(); + }); + ctx.globalAlpha = 1; + if (elapsed < durationMs) { + requestAnimationFrame(animate); + } else { + window.removeEventListener('resize', resize); + document.body.removeChild(canvas); + } + })(performance.now()); + }, + + // ----------------------------------------------------------------------- + // Fireflies — soft glowing dots that drift and pulse, good for forests. + // count: number of fireflies. + // duration: seconds the effect runs. + // ----------------------------------------------------------------------- + Fireflies(count = 40, duration = 4.0) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + document.body.appendChild(canvas); + canvas.style.cssText = 'position:fixed;top:0;left:0;pointer-events:none;z-index:99999;'; + + function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; } + resize(); + window.addEventListener('resize', resize); + + const flies = Array.from({ length: count }, () => ({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height, + vx: (Math.random() - 0.5) * 0.8, + vy: (Math.random() - 0.5) * 0.5, + phase: Math.random() * Math.PI * 2, + phaseSpeed: Math.random() * 0.04 + 0.02, + size: Math.random() * 3 + 2, + })); + + const start = performance.now(); + const durationMs = duration * 1000; + + (function animate(now) { + const elapsed = now - start; + const t = elapsed / durationMs; + const fade = t < 0.15 ? t / 0.15 : t > 0.85 ? (1 - t) / 0.15 : 1; + ctx.clearRect(0, 0, canvas.width, canvas.height); + flies.forEach(f => { + f.phase += f.phaseSpeed; + f.x += f.vx + Math.sin(f.phase * 0.7) * 0.4; + f.y += f.vy + Math.cos(f.phase * 0.5) * 0.3; + if (f.x < 0) { f.x = canvas.width; } + if (f.x > canvas.width) { f.x = 0; } + if (f.y < 0) { f.y = canvas.height; } + if (f.y > canvas.height) { f.y = 0; } + const pulse = (Math.sin(f.phase) + 1) / 2; + const alpha = pulse * 0.8 * fade; + const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.size * 3); + grad.addColorStop(0, 'rgba(180,255,120,' + alpha + ')'); + grad.addColorStop(1, 'rgba(180,255,120,0)'); + ctx.beginPath(); + ctx.arc(f.x, f.y, f.size * 3, 0, Math.PI * 2); + ctx.fillStyle = grad; + ctx.fill(); + }); + if (elapsed < durationMs) { + requestAnimationFrame(animate); + } else { + window.removeEventListener('resize', resize); + document.body.removeChild(canvas); + } + })(performance.now()); + }, + + // ----------------------------------------------------------------------- + // Bubbles — slow-rising translucent circles, good for underwater rooms. + // count: number of bubbles. + // duration: seconds the effect runs. + // ----------------------------------------------------------------------- + Bubbles(count = 60, duration = 3.5) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + document.body.appendChild(canvas); + canvas.style.cssText = 'position:fixed;top:0;left:0;pointer-events:none;z-index:99999;'; + + function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; } + resize(); + window.addEventListener('resize', resize); + + const bubbles = Array.from({ length: count }, () => ({ + x: Math.random() * canvas.width, + y: canvas.height + Math.random() * canvas.height, + size: Math.random() * 10 + 3, + speed: Math.random() * 1.5 + 0.5, + sway: Math.random() * Math.PI * 2, + swaySpeed: Math.random() * 0.02 + 0.005, + alpha: Math.random() * 0.3 + 0.1, + })); + + const start = performance.now(); + const durationMs = duration * 1000; + + (function animate(now) { + const elapsed = now - start; + ctx.clearRect(0, 0, canvas.width, canvas.height); + bubbles.forEach(b => { + b.sway += b.swaySpeed; + b.x += Math.sin(b.sway) * 0.6; + b.y -= b.speed; + if (b.y < -b.size) { b.y = canvas.height + b.size; b.x = Math.random() * canvas.width; } + ctx.globalAlpha = b.alpha; + ctx.beginPath(); + ctx.arc(b.x, b.y, b.size, 0, Math.PI * 2); + ctx.strokeStyle = '#88ccff'; + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.fillStyle = 'rgba(136,204,255,0.06)'; + ctx.fill(); + }); + ctx.globalAlpha = 1; + if (elapsed < durationMs) { + requestAnimationFrame(animate); + } else { + window.removeEventListener('resize', resize); + document.body.removeChild(canvas); + } + })(performance.now()); + }, + + // ----------------------------------------------------------------------- + // Shockwave — a single fast-expanding ring bursts from the screen center. + // color: ring colour. + // duration: seconds for the ring to expand and fade. + // ----------------------------------------------------------------------- + Shockwave(color = '#ffffff', duration = 0.5) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + document.body.appendChild(canvas); + canvas.style.cssText = 'position:fixed;top:0;left:0;pointer-events:none;z-index:99999;'; + + function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; } + resize(); + window.addEventListener('resize', resize); + + const cx = canvas.width / 2; + const cy = canvas.height / 2; + const maxRadius = Math.sqrt(cx * cx + cy * cy); + const start = performance.now(); + const durationMs = duration * 1000; + + (function animate(now) { + const elapsed = now - start; + const t = Math.min(elapsed / durationMs, 1); + // Ease out: fast start, slow end + const ease = 1 - Math.pow(1 - t, 3); + const radius = ease * maxRadius; + const alpha = 1 - t; + const width = (1 - t) * 18 + 2; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.strokeStyle = color; + ctx.globalAlpha = alpha; + ctx.lineWidth = width; + ctx.stroke(); + ctx.globalAlpha = 1; + if (elapsed < durationMs) { + requestAnimationFrame(animate); + } else { + window.removeEventListener('resize', resize); + document.body.removeChild(canvas); + } + })(performance.now()); + }, + + // ----------------------------------------------------------------------- + // Pulse — #main-container breathes out and back once, like a heartbeat. + // scale: peak scale factor. + // duration: total animation time in seconds. + // ----------------------------------------------------------------------- + Pulse(scale = 1.02, duration = 0.5) { + const target = document.getElementById('main-container') || document.body; + const original = target.style.transform; + const start = performance.now(); + const durationMs = duration * 1000; + + (function animate(now) { + const elapsed = now - start; + const t = Math.min(elapsed / durationMs, 1); + // Smooth sine pulse + const ease = Math.sin(t * Math.PI); + const s = 1 + (scale - 1) * ease; + target.style.transform = 'scale(' + s + ')'; + if (t < 1) { + requestAnimationFrame(animate); + } else { + target.style.transform = original; + } + })(performance.now()); + }, + }; + +window.FX = FX; diff --git a/_datafiles/html/public/static/js/triggers.js b/_datafiles/html/public/static/js/triggers.js index 78875f0f1..d6e70b523 100644 --- a/_datafiles/html/public/static/js/triggers.js +++ b/_datafiles/html/public/static/js/triggers.js @@ -3,10 +3,104 @@ const Triggers = (() => { const STORAGE_KEY = 'triggers'; + // ----------------------------------------------------------------------- + // FX catalogue — drives both execution and the editor UI. + // params: array of { key, label, type, default } + // type: 'number' | 'color' + // ----------------------------------------------------------------------- + const FX_DEFS = [ + // Particles + { + name: 'Confetti', group: 'Particles', + params: [ + { key: 'duration', label: 'Duration (s)', type: 'number', default: 1.5 }, + ], + }, + { + name: 'Sparks', group: 'Particles', + params: [ + { key: 'count', label: 'Count', type: 'number', default: 120 }, + { key: 'duration', label: 'Duration (s)', type: 'number', default: 1.2 }, + ], + }, + { + name: 'Snow', group: 'Particles', + params: [ + { key: 'count', label: 'Count', type: 'number', default: 150 }, + { key: 'duration', label: 'Duration (s)', type: 'number', default: 4.0 }, + ], + }, + { + name: 'Embers', group: 'Particles', + params: [ + { key: 'count', label: 'Count', type: 'number', default: 80 }, + { key: 'duration', label: 'Duration (s)', type: 'number', default: 3.0 }, + ], + }, + { + name: 'Fireflies', group: 'Particles', + params: [ + { key: 'count', label: 'Count', type: 'number', default: 40 }, + { key: 'duration', label: 'Duration (s)', type: 'number', default: 4.0 }, + ], + }, + { + name: 'Bubbles', group: 'Particles', + params: [ + { key: 'count', label: 'Count', type: 'number', default: 60 }, + { key: 'duration', label: 'Duration (s)', type: 'number', default: 3.5 }, + ], + }, + // Overlays + { + name: 'Flash', group: 'Overlays', + params: [ + { key: 'color', label: 'Color', type: 'color', default: '#ff0000' }, + { key: 'duration', label: 'Duration (s)', type: 'number', default: 0.5 }, + ], + }, + { + name: 'Rain', group: 'Overlays', + params: [ + { key: 'color', label: 'Color', type: 'color', default: '#66aaff' }, + { key: 'duration', label: 'Duration (s)', type: 'number', default: 2.0 }, + ], + }, + // Motion + { + name: 'Shake', group: 'Motion', + params: [ + { key: 'intensity', label: 'Intensity', type: 'number', default: 8 }, + { key: 'duration', label: 'Duration (s)', type: 'number', default: 0.4 }, + ], + }, + { + name: 'Ripple', group: 'Motion', + params: [ + { key: 'color', label: 'Color', type: 'color', default: '#3ad4b8' }, + { key: 'rings', label: 'Rings', type: 'number', default: 4 }, + { key: 'duration', label: 'Duration (s)', type: 'number', default: 1.0 }, + ], + }, + { + name: 'Shockwave', group: 'Motion', + params: [ + { key: 'color', label: 'Color', type: 'color', default: '#ffffff' }, + { key: 'duration', label: 'Duration (s)', type: 'number', default: 0.5 }, + ], + }, + { + name: 'Pulse', group: 'Motion', + params: [ + { key: 'scale', label: 'Scale', type: 'number', default: 1.02 }, + { key: 'duration', label: 'Duration (s)', type: 'number', default: 0.5 }, + ], + }, + ]; + // ----------------------------------------------------------------------- // Built-in (default) triggers. // These are merged into storage on first load, starting disabled. - // Edit the body string to change what the trigger does. // ----------------------------------------------------------------------- const _defaults = [ { @@ -14,12 +108,14 @@ const Triggers = (() => { body: [ 'Client.SendInput("get gold");', ].join('\n'), + fx: null, }, ]; // ----------------------------------------------------------------------- // Storage - // Each record: { pattern, body, enabled, isDefault } + // Each record: { pattern, body, enabled, isDefault, fx } + // fx: null | { name: string, params: { [key]: value } } // ----------------------------------------------------------------------- function _load() { try { @@ -40,19 +136,16 @@ const Triggers = (() => { } } - // Merge defaults into a stored list: add any default whose pattern is not - // already present. Existing entries (including user edits) are untouched. function _mergeDefaults(list) { _defaults.forEach(def => { const exists = list.some(t => t.pattern === def.pattern && t.isDefault); if (!exists) { - list.push({ pattern: def.pattern, body: def.body, enabled: false, isDefault: true }); + list.push({ pattern: def.pattern, body: def.body, enabled: false, isDefault: true, fx: def.fx || null }); } }); return list; } - // Initialise: load from storage, merge defaults, persist. let _triggers = _mergeDefaults(_load() || []); _save(_triggers); @@ -94,7 +187,6 @@ const Triggers = (() => { return str.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); } - // Validate a function body string. Returns null on success, error message on failure. function validateBody(body) { try { // eslint-disable-next-line no-new-func @@ -105,68 +197,135 @@ const Triggers = (() => { } } + // Fire all enabled FX for a trigger. + // fx: { [name]: { [key]: value } } — only keys present in the object are fired. + function _fireFX(fx) { + if (!fx || typeof window.FX !== 'object') { return; } + FX_DEFS.forEach(def => { + if (!fx[def.name]) { return; } + if (typeof window.FX[def.name] !== 'function') { return; } + const params = fx[def.name]; + const args = def.params.map(p => { + const v = params[p.key] !== undefined ? params[p.key] : p.default; + return p.type === 'number' ? Number(v) : v; + }); + window.FX[def.name](...args); + }); + } + // ----------------------------------------------------------------------- // Public API // ----------------------------------------------------------------------- - // Run all enabled triggers against a line of output. function Try(str) { str = stripAnsi(str); _triggers.forEach(trigger => { if (!trigger.enabled) { return; } const matches = matchPattern(trigger.pattern, str); if (!matches) { return; } - try { - // eslint-disable-next-line no-new-func - const fn = new Function('matches', trigger.body); - fn(matches); - } catch (e) { - console.warn('Trigger error [' + trigger.pattern + ']:', e); + if (trigger.body && trigger.body.trim()) { + try { + // eslint-disable-next-line no-new-func + const fn = new Function('matches', trigger.body); + fn(matches); + } catch (e) { + console.warn('Trigger error [' + trigger.pattern + ']:', e); + } + } + if (trigger.fx) { + _fireFX(trigger.fx); } }); } - // Return a shallow copy of the trigger list. function getTriggers() { return _triggers.map(t => Object.assign({}, t)); } - // Update the pattern and body of an existing trigger by index. - // Returns null on success, error string on validation failure. - function saveTrigger(idx, pattern, body) { - const err = validateBody(body); - if (err) { return err; } + // Returns null on success, error string on failure. + // fx: null | { [fxName]: { [paramKey]: value } } + function saveTrigger(idx, pattern, body, fx) { + if (body && body.trim()) { + const err = validateBody(body); + if (err) { return err; } + } if (idx < 0 || idx >= _triggers.length) { return 'Invalid trigger index.'; } _triggers[idx].pattern = pattern; _triggers[idx].body = body; + _triggers[idx].fx = fx || null; _save(_triggers); return null; } - // Add a new user trigger. Returns null on success, error string on failure. - function addTrigger(pattern, body) { - const err = validateBody(body); - if (err) { return err; } + function addTrigger(pattern, body, fx) { + if (body && body.trim()) { + const err = validateBody(body); + if (err) { return err; } + } if (!pattern.trim()) { return 'Pattern cannot be empty.'; } - _triggers.push({ pattern, body, enabled: true, isDefault: false }); + _triggers.push({ pattern, body, enabled: true, isDefault: false, fx: fx || null }); _save(_triggers); return null; } - // Remove a trigger by index. function removeTrigger(idx) { if (idx < 0 || idx >= _triggers.length) { return; } _triggers.splice(idx, 1); _save(_triggers); } - // Enable or disable a trigger by index. function setEnabled(idx, enabled) { if (idx < 0 || idx >= _triggers.length) { return; } _triggers[idx].enabled = !!enabled; _save(_triggers); } + function setAllEnabled(enabled) { + _triggers.forEach(t => { t.enabled = !!enabled; }); + _save(_triggers); + } + + // Returns a JSON string of all non-default triggers suitable for sharing. + function exportTriggers() { + const exportable = _triggers + .filter(t => !t.isDefault) + .map(t => ({ pattern: t.pattern, body: t.body, enabled: t.enabled, fx: t.fx || null })); + return JSON.stringify(exportable, null, 2); + } + + // Imports triggers from a JSON string. + // Returns null on success, an error string on failure. + // Duplicate patterns (matching an existing non-default trigger) are skipped. + function importTriggers(jsonStr) { + let parsed; + try { + parsed = JSON.parse(jsonStr); + } catch (e) { + return 'Invalid JSON: ' + e.message; + } + if (!Array.isArray(parsed)) { return 'Expected a JSON array of triggers.'; } + let added = 0; + for (const item of parsed) { + if (typeof item.pattern !== 'string' || !item.pattern.trim()) { continue; } + if (item.body && item.body.trim()) { + const err = validateBody(item.body); + if (err) { return 'Trigger "' + item.pattern + '": ' + err; } + } + const duplicate = _triggers.some(t => t.pattern === item.pattern && !t.isDefault); + if (duplicate) { continue; } + _triggers.push({ + pattern: item.pattern, + body: item.body || '', + enabled: item.enabled !== false, + isDefault: false, + fx: item.fx || null, + }); + added++; + } + _save(_triggers); + return null; + } + return { Try, getTriggers, @@ -174,10 +333,14 @@ const Triggers = (() => { addTrigger, removeTrigger, setEnabled, + setAllEnabled, + exportTriggers, + importTriggers, validateBody, matchPattern, stripAnsi, ParseNumber, + FX_DEFS, }; })(); diff --git a/_datafiles/html/public/static/js/webclient-core.js b/_datafiles/html/public/static/js/webclient-core.js index 71cf68a51..cf7ed84b1 100644 --- a/_datafiles/html/public/static/js/webclient-core.js +++ b/_datafiles/html/public/static/js/webclient-core.js @@ -1168,10 +1168,10 @@ const Client = (() => { // Terminal // ----------------------------------------------------------------------- const term = new window.Terminal({ - cols: 80, - rows: 60, + cols: 80, + rows: 60, cursorBlink: true, - fontSize: 20, + fontSize: 20, }); const fitAddon = new window.FitAddon.FitAddon(); term.loadAddon(fitAddon); @@ -1192,6 +1192,7 @@ const Client = (() => { let totalBytesReceived = 0; let totalBytesSent = 0; const gmcpInBytes = {}; // namespace -> bytes received + const gmcpInCount = {}; // namespace -> number of payloads received let connectTime = null; // Date of last successful connection // ----------------------------------------------------------------------- @@ -1356,6 +1357,7 @@ const Client = (() => { const gmcpNamespace = gmcpPayload.slice(0, jsonIndex).trim(); const gmcpBody = JSON.parse(gmcpPayload.slice(jsonIndex).trim()); gmcpInBytes[gmcpNamespace] = (gmcpInBytes[gmcpNamespace] || 0) + event.data.length; + gmcpInCount[gmcpNamespace] = (gmcpInCount[gmcpNamespace] || 0) + 1; _applyGMCPPayload(gmcpNamespace, gmcpBody); VirtualWindows.handleGMCP(gmcpNamespace, gmcpBody); return; @@ -1387,6 +1389,7 @@ const Client = (() => { totalBytesReceived = 0; totalBytesSent = 0; Object.keys(gmcpInBytes).forEach(k => delete gmcpInBytes[k]); + Object.keys(gmcpInCount).forEach(k => delete gmcpInCount[k]); connectTime = Date.now(); VirtualWindows.setConnected(true); }; @@ -1644,6 +1647,7 @@ const Client = (() => { totalBytesSent, totalBytesReceived, gmcpInBytes: Object.assign({}, gmcpInBytes), + gmcpInCount: Object.assign({}, gmcpInCount), connectTime, }; } diff --git a/_datafiles/html/public/static/js/windows/window-map.js b/_datafiles/html/public/static/js/windows/window-map.js index 0d0ef244c..965afbcbb 100644 --- a/_datafiles/html/public/static/js/windows/window-map.js +++ b/_datafiles/html/public/static/js/windows/window-map.js @@ -402,7 +402,7 @@ var sz = Math.max(7, Math.round(CONNECTION_WIDTH * zoomScale * 2.5)); var half = sz / 2; - console.log('[map] drawing ' + type + ' badge at (' + Math.round(mx) + ', ' + Math.round(my) + ') size=' + sz); + ctx.save(); diff --git a/_datafiles/html/public/static/js/windows/window-online.js b/_datafiles/html/public/static/js/windows/window-online.js index ab5d5e7b6..ddcaf1c60 100644 --- a/_datafiles/html/public/static/js/windows/window-online.js +++ b/_datafiles/html/public/static/js/windows/window-online.js @@ -216,6 +216,11 @@ offOnLoad: true, factory() { const el = createDOM(); + // Request a fresh payload from the server. The response will + // arrive as a normal GMCP message and flow through update(). + Client.GMCPRequest('Game'); + // Populate from already-stored data after the dock has settled. + requestAnimationFrame(function() { update(); }); return { title: 'Online', mount: el, @@ -279,10 +284,7 @@ function update() { const game = Client.GMCPStructs.Game; if (!game || !game.Who || !Array.isArray(game.Who.Players)) { return; } - - win.open(); if (!win.isOpen()) { return; } - render(game.Who.Players); } @@ -295,4 +297,12 @@ onGMCP() { update(); }, }); + // Second registration with no window so the handler always fires, + // keeping the DOM current even while the window is hidden. + VirtualWindows.register({ + window: null, + gmcpHandlers: ['Game'], + onGMCP() { update(); }, + }); + })(); diff --git a/_datafiles/html/public/webclient-pure.html b/_datafiles/html/public/webclient-pure.html index e2ecf14ca..4a1001408 100644 --- a/_datafiles/html/public/webclient-pure.html +++ b/_datafiles/html/public/webclient-pure.html @@ -567,6 +567,213 @@ margin-top: 2px; } + /* FX picker */ + .trigger-fx-list { + display: flex; + flex-direction: column; + gap: 2px; + } + + .trigger-fx-group-header { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + cursor: pointer; + user-select: none; + border-radius: 3px; + } + + .trigger-fx-group-header:hover { + background: #0d2e28; + } + + .trigger-fx-group-arrow { + font-size: 8px; + color: #3ad4b8; + width: 10px; + text-align: center; + flex-shrink: 0; + } + + .trigger-fx-group-name { + font-size: 10px; + font-family: Arial, sans-serif; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #7ab8a0; + flex: 1; + } + + .trigger-fx-group-count { + font-size: 10px; + font-family: Arial, sans-serif; + color: #3ad4b8; + } + + .trigger-fx-group-items { + display: flex; + flex-direction: column; + gap: 3px; + padding-left: 10px; + margin-bottom: 4px; + } + + .trigger-fx-group-items.collapsed { + display: none; + } + + .trigger-fx-item { + background: #0a1e1a; + border: 1px solid #0f3333; + border-radius: 4px; + overflow: hidden; + transition: border-color 0.15s; + } + + .trigger-fx-item.active { + border-color: #1c6b60; + } + + .trigger-fx-header { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 8px; + cursor: default; + } + + .trigger-fx-preview-btn { + background: none; + border: 1px solid #1c6b60; + border-radius: 3px; + color: #7ab8a0; + font-size: 10px; + font-family: Arial, sans-serif; + padding: 1px 7px; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.04em; + flex-shrink: 0; + transition: background 0.15s, color 0.15s; + } + + .trigger-fx-preview-btn:hover { + background: #1c6b60; + color: #dffbd1; + } + + .trigger-fx-name { + flex: 1; + font-family: monospace; + font-size: 12px; + color: #7ab8a0; + transition: color 0.15s; + } + + .trigger-fx-item.active .trigger-fx-name { + color: #dffbd1; + } + + .trigger-fx-params { + display: none; + flex-wrap: wrap; + gap: 8px; + padding: 6px 8px 8px 40px; + border-top: 1px solid #0f3333; + } + + .trigger-fx-item.active .trigger-fx-params { + display: flex; + } + + .fx-param-group { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 90px; + } + + .fx-param-group label { + color: #7ab8a0; + font-size: 10px; + font-family: Arial, sans-serif; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .fx-param-input { + padding: 4px 6px; + background: #111; + border: 1px solid #1c6b60; + border-radius: 3px; + color: #dffbd1; + font-family: monospace; + font-size: 12px; + outline: none; + width: 100%; + } + + .fx-param-input[type="color"] { + padding: 2px 4px; + height: 28px; + cursor: pointer; + } + + .fx-param-input:focus { + border-color: #3ad4b8; + } + + /* Trigger IO section */ + .trigger-io-row { + display: flex; + gap: 8px; + align-items: center; + margin-top: 2px; + } + + .trigger-io-actions { + display: flex; + gap: 8px; + margin-bottom: 8px; + } + + #trigger-io-area { + width: 100%; + min-height: 80px; + padding: 6px 7px; + background: #111; + border: 1px solid #1c6b60; + border-radius: 3px; + color: #dffbd1; + font-family: monospace; + font-size: 11px; + resize: vertical; + outline: none; + line-height: 1.4; + display: none; + } + + #trigger-io-area.visible { + display: block; + } + + #trigger-io-area:focus { + border-color: #3ad4b8; + } + + #trigger-io-error { + color: #e06060; + font-size: 11px; + font-family: monospace; + margin-top: 4px; + display: none; + } + + #trigger-io-error.visible { + display: block; + } + /* Stats tab */ .stats-uptime { font-size: 11px; @@ -578,24 +785,46 @@ } .stats-row { - display: grid; - grid-template-columns: 1fr 7em 7em; + display: flex; align-items: baseline; + justify-content: space-between; padding: 3px 0; font-size: 11px; - font-family: monospace; border-bottom: 1px solid #0a1a16; } .stats-row:last-child { border-bottom: none; } - .stats-row-label { color: #7ab8a0; font-family: Arial, sans-serif; } - .stats-row-values { display: contents; } - .stats-row-value { color: #dffbd1; font-variant-numeric: tabular-nums; text-align: right; padding-right: 8px; } - .stats-row-rate { color: #7ab8a0; font-variant-numeric: tabular-nums; text-align: right; } + + .stats-row-label { + color: #7ab8a0; + font-family: Arial, sans-serif; + flex: 1; + } + + .stats-row-right { + display: flex; + gap: 16px; + } + + .stats-row-value { + color: #dffbd1; + font-family: monospace; + font-variant-numeric: tabular-nums; + text-align: right; + min-width: 7em; + } + + .stats-row-rate { + color: #7ab8a0; + font-family: monospace; + font-variant-numeric: tabular-nums; + text-align: right; + min-width: 7em; + } .stats-gmcp-header { - display: grid; - grid-template-columns: 1fr 7em 7em; + display: flex; align-items: baseline; + justify-content: space-between; padding: 4px 0 3px; font-size: 11px; font-family: Arial, sans-serif; @@ -605,8 +834,20 @@ border-bottom: 1px solid #0f3333; } .stats-gmcp-header:hover { color: #dffbd1; } - .stats-gmcp-toggle { font-size: 9px; color: #3ad4b8; margin-right: 4px; } - .stats-gmcp-total { color: #dffbd1; font-variant-numeric: tabular-nums; font-family: monospace; text-align: right; padding-right: 8px; } + + .stats-gmcp-toggle { + font-size: 9px; + color: #3ad4b8; + margin-right: 4px; + } + + .stats-gmcp-total { + color: #dffbd1; + font-family: monospace; + font-variant-numeric: tabular-nums; + text-align: right; + min-width: 7em; + } .stats-gmcp-rows { display: none; @@ -614,6 +855,41 @@ padding-left: 10px; } .stats-gmcp-rows.open { display: flex; } + + .stats-ns-row { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 3px 0; + font-size: 11px; + border-bottom: 1px solid #0a1a16; + cursor: pointer; + user-select: none; + } + .stats-ns-row:last-of-type { border-bottom: none; } + .stats-ns-row:hover .stats-row-label { color: #dffbd1; } + + .stats-ns-count { + color: #7ab8a0; + font-family: Arial, sans-serif; + font-size: 10px; + margin-left: 3px; + } + + .stats-ns-payload { + display: none; + margin: 2px 0 4px 14px; + padding: 6px 8px; + background: #0a1a16; + border-left: 2px solid #1c6b60; + font-family: monospace; + font-size: 11px; + color: #dffbd1; + white-space: pre-wrap; + word-break: break-all; + line-height: 1.5; + } + .stats-ns-payload.open { display: block; } @@ -622,7 +898,7 @@ - + @@ -695,13 +971,28 @@ + +
- +
+ + + +
+ +
+
Import / Export
+
+ + +
+ +
@@ -793,6 +1084,27 @@ row.appendChild(badge); } + if (t.fx) { + var fxNames = Object.keys(t.fx); + var show = fxNames.slice(0, 2); + show.forEach(function(fxName) { + var fxBadge = document.createElement('span'); + fxBadge.className = 'trigger-row-badge'; + fxBadge.style.color = '#3ad4b8'; + fxBadge.style.borderColor = '#1c6b60'; + fxBadge.textContent = fxName; + row.appendChild(fxBadge); + }); + if (fxNames.length > 2) { + var moreBadge = document.createElement('span'); + moreBadge.className = 'trigger-row-badge'; + moreBadge.style.color = '#3ad4b8'; + moreBadge.style.borderColor = '#1c6b60'; + moreBadge.textContent = '+' + (fxNames.length - 2) + ' more'; + row.appendChild(moreBadge); + } + } + if (!t.isDefault) { var del = document.createElement('button'); del.className = 'trigger-delete-btn'; @@ -819,12 +1131,169 @@ }); } + // Build the grouped, collapsible FX editor. + // savedFX: { [fxName]: { [paramKey]: value } } or null + // _fxGroupOpen tracks which groups are expanded; persists across open/close. + var _fxGroupOpen = {}; + + function renderFXEditor(savedFX) { + var container = document.getElementById('trigger-fx-list'); + if (!container) { return; } + container.innerHTML = ''; + + // Collect groups in order + var groups = []; + var groupMap = {}; + Triggers.FX_DEFS.forEach(function(def) { + if (!groupMap[def.group]) { + groupMap[def.group] = []; + groups.push(def.group); + } + groupMap[def.group].push(def); + }); + + groups.forEach(function(groupName) { + var defs = groupMap[groupName]; + var activeCount = defs.filter(function(d) { return savedFX && savedFX[d.name]; }).length; + + // Default: open groups that have active items only + if (_fxGroupOpen[groupName] === undefined) { + _fxGroupOpen[groupName] = activeCount > 0; + } + var open = _fxGroupOpen[groupName]; + + // Group header + var header = document.createElement('div'); + header.className = 'trigger-fx-group-header'; + + var arrow = document.createElement('span'); + arrow.className = 'trigger-fx-group-arrow'; + arrow.textContent = open ? '\u25bc' : '\u25b6'; + + var nameSpan = document.createElement('span'); + nameSpan.className = 'trigger-fx-group-name'; + nameSpan.textContent = groupName; + + var countSpan = document.createElement('span'); + countSpan.className = 'trigger-fx-group-count'; + if (activeCount > 0) { countSpan.textContent = activeCount + ' on'; } + + header.appendChild(arrow); + header.appendChild(nameSpan); + header.appendChild(countSpan); + container.appendChild(header); + + // Group items + var itemsDiv = document.createElement('div'); + itemsDiv.className = 'trigger-fx-group-items' + (open ? '' : ' collapsed'); + + defs.forEach(function(def) { + var saved = savedFX && savedFX[def.name]; + var enabled = !!saved; + + var item = document.createElement('div'); + item.className = 'trigger-fx-item' + (enabled ? ' active' : ''); + item.dataset.fx = def.name; + + var itemHeader = document.createElement('div'); + itemHeader.className = 'trigger-fx-header'; + + var toggleLabel = document.createElement('label'); + toggleLabel.className = 'toggle-switch'; + var chk = document.createElement('input'); + chk.type = 'checkbox'; + chk.checked = enabled; + var track = document.createElement('span'); + track.className = 'toggle-track'; + toggleLabel.appendChild(chk); + toggleLabel.appendChild(track); + + var fxNameSpan = document.createElement('span'); + fxNameSpan.className = 'trigger-fx-name'; + fxNameSpan.textContent = def.name; + + var paramsDiv = document.createElement('div'); + paramsDiv.className = 'trigger-fx-params'; + def.params.forEach(function(p) { + var group = document.createElement('div'); + group.className = 'fx-param-group'; + var lbl = document.createElement('label'); + lbl.textContent = p.label; + var inp = document.createElement('input'); + inp.className = 'fx-param-input'; + inp.type = p.type === 'color' ? 'color' : 'number'; + inp.dataset.key = p.key; + inp.dataset.paramType = p.type; + if (p.type === 'number') { inp.step = 'any'; inp.min = '0'; } + inp.value = (saved && saved[p.key] !== undefined) ? saved[p.key] : p.default; + group.appendChild(lbl); + group.appendChild(inp); + paramsDiv.appendChild(group); + }); + + var previewBtn = document.createElement('button'); + previewBtn.className = 'trigger-fx-preview-btn'; + previewBtn.textContent = 'Preview'; + previewBtn.addEventListener('click', function() { + if (typeof window.FX !== 'object' || typeof window.FX[def.name] !== 'function') { return; } + var args = def.params.map(function(p) { + var inp = paramsDiv.querySelector('[data-key="' + p.key + '"]'); + var v = inp ? inp.value : p.default; + return p.type === 'number' ? Number(v) : v; + }); + window.FX[def.name].apply(null, args); + }); + + itemHeader.appendChild(toggleLabel); + itemHeader.appendChild(fxNameSpan); + itemHeader.appendChild(previewBtn); + item.appendChild(itemHeader); + item.appendChild(paramsDiv); + + chk.addEventListener('change', function() { + item.classList.toggle('active', chk.checked); + // Update the group active count badge + var on = itemsDiv.querySelectorAll('.trigger-fx-item.active').length; + countSpan.textContent = on > 0 ? on + ' on' : ''; + }); + + itemsDiv.appendChild(item); + }); + + header.addEventListener('click', function() { + var isCollapsed = itemsDiv.classList.toggle('collapsed'); + _fxGroupOpen[groupName] = !isCollapsed; + arrow.textContent = isCollapsed ? '\u25b6' : '\u25bc'; + }); + + container.appendChild(itemsDiv); + }); + } + + // Collect FX map from editor. Returns null if nothing enabled. + function collectFX() { + var result = null; + document.querySelectorAll('#trigger-fx-list .trigger-fx-item').forEach(function(item) { + var chk = item.querySelector('input[type="checkbox"]'); + if (!chk || !chk.checked) { return; } + var fxName = item.dataset.fx; + var params = {}; + item.querySelectorAll('.fx-param-input').forEach(function(inp) { + params[inp.dataset.key] = inp.dataset.paramType === 'number' ? Number(inp.value) : inp.value; + }); + if (!result) { result = {}; } + result[fxName] = params; + }); + return result; + } + function openEditor(idx) { var triggers = Triggers.getTriggers(); _editingIdx = idx; var t = triggers[idx]; document.getElementById('trigger-pattern-input').value = t.pattern; document.getElementById('trigger-body-input').value = t.body; + renderFXEditor(t.fx || null); document.getElementById('trigger-error').textContent = ''; document.getElementById('trigger-error').classList.remove('visible'); document.getElementById('trigger-editor').classList.add('visible'); @@ -835,6 +1304,7 @@ _editingIdx = -1; document.getElementById('trigger-pattern-input').value = ''; document.getElementById('trigger-body-input').value = ''; + renderFXEditor(null); document.getElementById('trigger-error').textContent = ''; document.getElementById('trigger-error').classList.remove('visible'); document.getElementById('trigger-editor').classList.add('visible'); @@ -854,13 +1324,14 @@ document.getElementById('trigger-save-btn').addEventListener('click', function() { var pattern = document.getElementById('trigger-pattern-input').value.trim(); var body = document.getElementById('trigger-body-input').value; + var fx = collectFX(); var errEl = document.getElementById('trigger-error'); var err; if (_editingIdx === -1) { - err = Triggers.addTrigger(pattern, body); + err = Triggers.addTrigger(pattern, body, fx); } else { - err = Triggers.saveTrigger(_editingIdx, pattern, body); + err = Triggers.saveTrigger(_editingIdx, pattern, body, fx); } if (err) { @@ -881,6 +1352,67 @@ openNewEditor(); }); + // Enable All / Disable All + document.getElementById('trigger-enable-all-btn').addEventListener('click', function() { + Triggers.setAllEnabled(true); + renderTriggerList(); + }); + + document.getElementById('trigger-disable-all-btn').addEventListener('click', function() { + Triggers.setAllEnabled(false); + renderTriggerList(); + }); + + // Export + document.getElementById('trigger-export-btn').addEventListener('click', function() { + var area = document.getElementById('trigger-io-area'); + var errEl = document.getElementById('trigger-io-error'); + area.value = Triggers.exportTriggers(); + area.classList.add('visible'); + errEl.classList.remove('visible'); + area.select(); + }); + + // Import + document.getElementById('trigger-import-btn').addEventListener('click', function() { + var area = document.getElementById('trigger-io-area'); + var errEl = document.getElementById('trigger-io-error'); + var isVisible = area.classList.contains('visible'); + + if (!isVisible) { + area.value = ''; + area.classList.add('visible'); + errEl.classList.remove('visible'); + area.focus(); + document.getElementById('trigger-import-btn').textContent = 'Apply Import'; + return; + } + + var json = area.value.trim(); + if (!json) { + area.classList.remove('visible'); + errEl.classList.remove('visible'); + document.getElementById('trigger-import-btn').textContent = 'Import JSON'; + return; + } + + var err = Triggers.importTriggers(json); + if (err) { + errEl.textContent = err; + errEl.classList.add('visible'); + return; + } + + area.value = ''; + area.classList.remove('visible'); + errEl.classList.remove('visible'); + document.getElementById('trigger-import-btn').textContent = 'Import JSON'; + renderTriggerList(); + }); + + // Hide IO area when switching away from Triggers tab + // (handled in the tab-click listener below) + // Restart the stats interval if the modal is reopened with Stats already active. var observer = new MutationObserver(function() { var backdrop = document.getElementById('settings-backdrop'); @@ -905,6 +1437,17 @@ if (btn.dataset.tab === 'tab-triggers') { closeEditor(); renderTriggerList(); + // Reset import button label when leaving + } else { + // Leaving triggers: reset import UI + var area = document.getElementById('trigger-io-area'); + if (area) { + area.classList.remove('visible'); + var importBtn = document.getElementById('trigger-import-btn'); + if (importBtn) { importBtn.textContent = 'Import JSON'; } + } + var ioErr = document.getElementById('trigger-io-error'); + if (ioErr) { ioErr.classList.remove('visible'); } } if (btn.dataset.tab === 'tab-stats') { renderStats(); @@ -931,84 +1474,121 @@ }); // ----------------------------------------------------------------------- - // Stats tab renderer + // Stats tab // ----------------------------------------------------------------------- - var _gmcpRowsOpen = false; var _statsInterval = null; + var _gmcpOpen = false; // is the GMCP In section expanded? + var _nsOpen = {}; // which namespace payloads are expanded? + + function _fmtBytes(b) { + if (b < 1024 * 1024) { return (b / 1024).toFixed(2) + ' KB'; } + return (b / (1024 * 1024)).toFixed(2) + ' MB'; + } - function fmtBytes(b) { - if (b < 1024) { return b.toFixed(2) + ' B'; } - if (b < 1024*1024) { return (b / 1024).toFixed(2) + ' KB'; } - return (b / (1024*1024)).toFixed(2) + ' MB'; + function _fmtRate(b, sec) { + return sec > 0 ? _fmtBytes(b / sec) + '/s' : '—'; } - function fmtUptime(ms) { - var s = Math.floor(ms / 1000); - var h = Math.floor(s / 3600); - var m = Math.floor((s % 3600) / 60); + function _fmtUptime(ms) { + var s = Math.floor(ms / 1000); + var h = Math.floor(s / 3600); + var m = Math.floor((s % 3600) / 60); var sec = s % 60; if (h > 0) { return h + 'h ' + m + 'm ' + sec + 's'; } if (m > 0) { return m + 'm ' + sec + 's'; } return sec + 's'; } - function statsRow(label, bytes, elapsedSec) { - var rate = elapsedSec > 0 ? fmtBytes(bytes / elapsedSec) + '/s' : '—'; - return '
' + - '' + label + '' + - '' + - '' + fmtBytes(bytes) + '' + - '' + rate + '' + - '' + - '
'; + function _gmcpValue(ns) { + if (typeof Client === 'undefined' || !Client.GMCPStructs) { return undefined; } + var cur = Client.GMCPStructs; + var parts = ns.split('.'); + for (var i = 0; i < parts.length; i++) { + if (cur == null || typeof cur !== 'object') { return undefined; } + cur = cur[parts[i]]; + } + return cur; } function renderStats() { if (typeof Client === 'undefined' || !Client.getNetStats) { return; } - var s = Client.getNetStats(); - var now = Date.now(); - var elapsedMs = (s.connectTime && s.connectTime > 0) ? (now - s.connectTime) : 0; - var elapsedSec = elapsedMs / 1000; - - // Uptime - var overallEl = document.getElementById('stats-net-overall'); - if (overallEl) { - var uptimeHtml = '
Connected for ' + - (elapsedMs > 0 ? fmtUptime(elapsedMs) : 'not connected') + - '
'; - overallEl.innerHTML = uptimeHtml + - statsRow('Sent', s.totalBytesSent, elapsedSec) + - statsRow('Received', s.totalBytesReceived, elapsedSec); + var st = Client.getNetStats(); + var now = Date.now(); + var ms = (st.connectTime && st.connectTime > 0) ? (now - st.connectTime) : 0; + var sec = ms / 1000; + + // --- Overall section --- + var overall = document.getElementById('stats-net-overall'); + if (overall) { + overall.innerHTML = + '
Connected for ' + (ms > 0 ? _fmtUptime(ms) : 'not connected') + '
' + + '
Sent
' + _fmtBytes(st.totalBytesSent) + '' + _fmtRate(st.totalBytesSent, sec) + '
' + + '
Received
' + _fmtBytes(st.totalBytesReceived) + '' + _fmtRate(st.totalBytesReceived, sec) + '
'; } - // GMCP In collapsible + // --- GMCP In section --- var gmcpEl = document.getElementById('stats-net-gmcp'); if (!gmcpEl) { return; } - var namespaces = Object.keys(s.gmcpInBytes).sort(); - var gmcpTotal = namespaces.reduce(function(sum, k) { return sum + s.gmcpInBytes[k]; }, 0); - var gmcpRate = elapsedSec > 0 ? fmtBytes(gmcpTotal / elapsedSec) + '/s' : '—'; - - var rowsHtml = namespaces.map(function(ns) { - return statsRow(ns, s.gmcpInBytes[ns], elapsedSec); - }).join(''); - - gmcpEl.innerHTML = - '
' + - '' + (_gmcpRowsOpen ? '▼' : '▶') + 'GMCP In' + - '' + - '' + fmtBytes(gmcpTotal) + '' + - '' + gmcpRate + '' + - '' + - '
' + - '
' + - rowsHtml + - '
'; - - document.getElementById('stats-gmcp-header').addEventListener('click', function() { - _gmcpRowsOpen = !_gmcpRowsOpen; - renderStats(); - }); + var nsList = Object.keys(st.gmcpInBytes).sort(); + var total = nsList.reduce(function(s, k) { return s + st.gmcpInBytes[k]; }, 0); + + // Build the whole section as a plain string — no grid, no display:contents. + // Each row is a simple flex div. Namespace rows get onclick attributes so + // there is zero ambiguity about what receives the click. + var h = ''; + + h += '
'; + h += '' + (_gmcpOpen ? '▼' : '▶') + 'GMCP In'; + h += '
'; + h += '' + _fmtBytes(total) + ''; + h += '' + _fmtRate(total, sec) + ''; + h += '
'; + h += '
'; + + if (_gmcpOpen) { + h += '
'; + nsList.forEach(function(ns, i) { + var bytes = st.gmcpInBytes[ns]; + var count = st.gmcpInCount[ns] || 0; + var isOpen = !!_nsOpen[ns]; + var safeNs = ns.replace(/"/g, '"'); + + h += '
'; + h += '
'; + h += ''; + h += '' + (isOpen ? '▼' : '▶') + ''; + h += ns + '(' + count + ')'; + h += ''; + h += '
'; + h += '' + _fmtBytes(bytes) + ''; + h += '' + _fmtRate(bytes, sec) + ''; + h += '
'; + h += '
'; + + if (isOpen) { + var val = _gmcpValue(ns); + var text = val !== undefined ? JSON.stringify(val, null, 2) : '(no data)'; + text = text.replace(/&/g, '&').replace(//g, '>'); + h += '
' + text + '
'; + } + + h += '
'; + }); + h += '
'; + } + + gmcpEl.innerHTML = h; + } + + function _statsToggleGmcp() { + _gmcpOpen = !_gmcpOpen; + renderStats(); + } + + function _statsToggleNs(ns) { + _nsOpen[ns] = !_nsOpen[ns]; + renderStats(); } diff --git a/internal/web/template_func.go b/internal/web/template_func.go index e0aa9291a..4d5a7c647 100644 --- a/internal/web/template_func.go +++ b/internal/web/template_func.go @@ -5,6 +5,7 @@ import ( "html" "strings" "text/template" + "time" "github.com/GoMudEngine/GoMud/internal/configs" ) @@ -99,6 +100,7 @@ var ( "lowercase": func(str string) string { return strings.ToLower(str) }, + "now": func() int64 { return time.Now().UnixMilli() }, "getconfig": func() configs.Config { return configs.GetConfig() },