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 @@ + +