import { CloseCode } from "@colyseus/shared-types"; import { disconnectIcon, envelopeDown, envelopeUp, infoIcon, jitterIcon, logoIcon, messageIcon, resizeIcon, sizedIcon, treeViewIcon } from "./icons.ts"; import { applyPanelPosition, formatBytes, getBorderColor, getDebugRoot, getPanelStack, isPanelsHidden, preferences, PREDICT_CONTAINER_ID, repositionDebugPanels, roomDebugInfo, setCompactRevealed } from "./core.ts"; import { isCoarsePointer, isCompact, onReflow } from "./layout.ts"; import { createMenu } from "./menu.ts"; import { openSendMessagesModal } from "./send-message.ts"; import { openStateInspectorModal } from "./state-inspector.ts"; export function initialize() { if (isPanelsHidden()) return; var container = document.createElement('div'); container.id = 'debug-logo-container'; container.className = 'cds-surface'; container.style.position = 'fixed'; container.style.zIndex = '1000'; container.style.width = '21px'; container.style.height = '21px'; container.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'; container.style.border = '3px solid ' + getBorderColor(preferences.latencySimulation.delay, 0.7); container.style.borderRadius = '50%'; container.style.padding = '10px'; container.style.boxSizing = 'content-box'; container.style.display = 'flex'; container.style.justifyContent = 'center'; container.style.alignItems = 'center'; container.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.3)'; container.style.transition = 'border-color 0.3s ease, background-color 0.3s ease, transform 0.12s ease'; container.style.cursor = 'pointer'; // Apply initial position applyPanelPosition(); // container on hover effect container.addEventListener('mouseenter', function() { container.style.backgroundColor = 'rgba(0, 0, 0, 0.9)'; container.style.borderColor = getBorderColor(preferences.latencySimulation.delay, 0.9); }); container.addEventListener('mouseleave', function() { container.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'; container.style.borderColor = getBorderColor(preferences.latencySimulation.delay, 0.7); }); var icon = document.createElement('div'); icon.style.width = '100%'; icon.style.height = '100%'; icon.style.display = 'flex'; icon.style.justifyContent = 'center'; icon.style.alignItems = 'center'; // Use insertAdjacentHTML for better Safari compatibility with SVG icon.insertAdjacentHTML('beforeend', logoIcon); container.appendChild(icon); getDebugRoot().appendChild(container); // Create menu first createMenu(container); // Apply initial position after menu is created applyPanelPosition(); watchPanelShape(); bindTooltipDismiss(); } // Helper function to create debug panel for a room export function createDebugPanel(uniquePanelId, debugInfo) { // Check if panel already exists var existingPanel = getDebugRoot().getElementById('debug-panel-' + uniquePanelId); if (existingPanel) { return existingPanel; } var panel = document.createElement('div'); panel.id = 'debug-panel-' + uniquePanelId; panel.className = 'cds-surface'; // A flex child of the panel stack — the stack owns position and z-index. panel.style.backgroundColor = 'rgba(0, 0, 0, 0.85)'; panel.style.color = '#fff'; panel.style.padding = '8px'; panel.style.borderRadius = '6px'; panel.style.fontFamily = 'monospace'; panel.style.fontSize = '11px'; panel.style.minWidth = '180px'; panel.style.pointerEvents = 'auto'; // the desktop stack is pointer-events:none panel.style.flexShrink = '0'; // never squash inside the scrollable compact drawer panel.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.5)'; panel.style.display = isPanelsHidden() ? 'none' : 'block'; var title = document.createElement('div'); title.id = 'debug-title-' + uniquePanelId; title.className = 'debug-title'; // `[id^="debug-title-"]` would also catch debug-title-text-* title.style.fontWeight = 'bold'; title.style.borderBottom = '1px solid rgba(255, 255, 255, 0.15)'; title.style.paddingBottom = '4px'; title.style.display = 'flex'; title.style.alignItems = 'center'; title.style.justifyContent = 'space-between'; title.style.gap = '8px'; title.style.position = 'relative'; title.innerHTML = '' + resizeIcon(infoIcon, 10) + '--'; // Create tooltip for info button (will be shown on hover) var tooltip = document.createElement('div'); tooltip.id = 'debug-tooltip-' + uniquePanelId; tooltip.style.position = 'absolute'; tooltip.style.top = '100%'; tooltip.style.left = '0'; tooltip.style.marginTop = '4px'; tooltip.style.padding = '6px 8px'; tooltip.style.backgroundColor = 'rgba(0, 0, 0, 0.95)'; tooltip.style.color = '#fff'; tooltip.style.borderRadius = '4px'; tooltip.style.fontSize = '10px'; tooltip.style.fontFamily = 'monospace'; tooltip.style.zIndex = '1000'; tooltip.style.display = 'none'; tooltip.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.5)'; tooltip.style.lineHeight = '1.4'; tooltip.innerHTML = '
Room ID: ' + debugInfo.roomId + '
Session ID: N/A
'; var content = document.createElement('div'); content.id = 'debug-content-' + uniquePanelId; // Create action buttons container at the bottom var actionsContainer = document.createElement('div'); actionsContainer.id = 'debug-actions-' + uniquePanelId; actionsContainer.style.display = 'flex'; actionsContainer.style.gap = '4px'; actionsContainer.style.marginTop = '8px'; actionsContainer.style.paddingTop = '6px'; actionsContainer.style.borderTop = '1px solid rgba(255, 255, 255, 0.15)'; actionsContainer.style.position = 'relative'; // Helper function to create action button function createActionButton(id, icon, label, onClick) { var btn = document.createElement('button'); btn.id = id; btn.className = 'cds-hit'; btn.style.display = 'flex'; btn.style.alignItems = 'center'; btn.style.justifyContent = 'center'; btn.style.gap = '4px'; btn.style.padding = '4px 8px'; btn.style.border = '1px solid rgba(255, 255, 255, 0.2)'; btn.style.borderRadius = '4px'; btn.style.background = 'rgba(255, 255, 255, 0.05)'; btn.style.color = '#fff'; btn.style.fontSize = '9px'; btn.style.cursor = 'pointer'; btn.style.transition = 'background 0.2s, border-color 0.2s'; btn.innerHTML = sizedIcon(icon, 12) + '' + label + ''; btn.addEventListener('mouseenter', function() { btn.style.background = 'rgba(255, 255, 255, 0.15)'; btn.style.borderColor = 'rgba(255, 255, 255, 0.3)'; }); btn.addEventListener('mouseleave', function() { btn.style.background = 'rgba(255, 255, 255, 0.05)'; btn.style.borderColor = 'rgba(255, 255, 255, 0.2)'; }); btn.addEventListener('click', function(e) { e.stopPropagation(); onClick(); }); return btn; } // Create action buttons var stateBtn = createActionButton( 'debug-state-btn-' + uniquePanelId, treeViewIcon, 'State', function() { openStateInspectorModal(uniquePanelId); } ); var messageBtn = createActionButton( 'debug-message-btn-' + uniquePanelId, messageIcon, 'Send', function() { openSendMessagesModal(uniquePanelId); } ); messageBtn.style.display = 'none'; // Hidden by default, shown when message types available // Create disconnect button (red, simulates abnormal websocket close) var disconnectBtn = createActionButton( 'debug-disconnect-btn-' + uniquePanelId, disconnectIcon, 'Drop', function() { var info = roomDebugInfo.get(uniquePanelId); if (info && info.room && info.room.connection) { // Simulate connection closure info.room.connection.close(CloseCode.MAY_TRY_RECONNECT); } } ); // Track button state for hover effects var isReconnecting = false; // Helper to apply normal (red) button style function applyNormalStyle() { disconnectBtn.style.background = 'rgba(239, 68, 68, 0.2)'; disconnectBtn.style.borderColor = 'rgba(239, 68, 68, 0.5)'; disconnectBtn.style.color = '#ef4444'; disconnectBtn.style.animation = ''; disconnectBtn.style.pointerEvents = 'auto'; disconnectBtn.style.opacity = '1'; var labelSpan = disconnectBtn.querySelector('span:last-child') as HTMLElement; if (labelSpan) labelSpan.textContent = 'Drop'; } // Helper to apply reconnecting (orange/pulsing) button style function applyReconnectingStyle() { disconnectBtn.style.background = 'rgba(251, 146, 60, 0.3)'; disconnectBtn.style.borderColor = 'rgba(251, 146, 60, 0.6)'; disconnectBtn.style.color = '#fb923c'; disconnectBtn.style.animation = 'debug-pulse 1.5s ease-in-out infinite'; disconnectBtn.style.pointerEvents = 'none'; disconnectBtn.style.opacity = '0.8'; var labelSpan = disconnectBtn.querySelector('span:last-child') as HTMLElement; if (labelSpan) labelSpan.textContent = 'Reconnecting...'; } // Apply initial style (the debug-pulse keyframes live in the base sheet) applyNormalStyle(); // Register onDrop callback to show reconnecting state if (debugInfo.room) { debugInfo.room.onDrop(function() { isReconnecting = true; applyReconnectingStyle(); }); // Register onReconnect callback to restore normal state debugInfo.room.onReconnect(function() { isReconnecting = false; applyNormalStyle(); }); } // Hover effects (only when not reconnecting) disconnectBtn.addEventListener('mouseenter', function() { if (!isReconnecting) { disconnectBtn.style.background = 'rgba(239, 68, 68, 0.35)'; disconnectBtn.style.borderColor = 'rgba(239, 68, 68, 0.7)'; } }); disconnectBtn.addEventListener('mouseleave', function() { if (!isReconnecting) { disconnectBtn.style.background = 'rgba(239, 68, 68, 0.2)'; disconnectBtn.style.borderColor = 'rgba(239, 68, 68, 0.5)'; } }); title.appendChild(tooltip); var infoIconEl = title.querySelector('.debug-info-icon') as HTMLElement; var tooltipTimeout: any = null; var showTooltip = function() { if (tooltipTimeout) { clearTimeout(tooltipTimeout); tooltipTimeout = null; } tooltip.style.display = 'block'; }; var hideTooltip = function() { tooltipTimeout = setTimeout(function() { tooltip.style.display = 'none'; }, 100); }; tooltip.style.pointerEvents = 'auto'; if (infoIconEl) { // On touch, `mouseenter` fires on tap and no `mouseleave` ever follows, so a // hover tooltip sticks open forever. Room/session ids are worth surfacing on // a phone, so tap toggles it rather than suppressing it. The click stops here // — that is what keeps the outside-tap dismissal from immediately undoing it. infoIconEl.addEventListener('click', function(e) { e.stopPropagation(); if (!isCoarsePointer()) { return; } tooltip.style.display = (tooltip.style.display === 'block') ? 'none' : 'block'; }); infoIconEl.addEventListener('mouseenter', function() { if (!isCoarsePointer()) { showTooltip(); } }); infoIconEl.addEventListener('mouseleave', function() { if (!isCoarsePointer()) { hideTooltip(); } }); tooltip.addEventListener('mouseenter', showTooltip); tooltip.addEventListener('mouseleave', function() { if (!isCoarsePointer()) { hideTooltip(); } }); } actionsContainer.appendChild(stateBtn); actionsContainer.appendChild(messageBtn); actionsContainer.appendChild(disconnectBtn); // Collapsible body: on a phone each room reduces to its title row (a chip) and // opens on tap. `content` stays the same element, so updateDebugPanel()'s // per-second innerHTML rewrite is unaffected. var body = document.createElement('div'); body.id = 'debug-body-' + uniquePanelId; body.style.marginTop = '6px'; body.appendChild(content); body.appendChild(actionsContainer); panel.appendChild(title); panel.appendChild(body); // Deliberately does not stop propagation: the click still needs to reach the // document listeners that close the menu and dismiss open tooltips. title.addEventListener('click', function() { if (!isCompact()) { return; } // desktop: always expanded, header inert setPanelExpanded(panel, panel.dataset.expanded !== '1'); }); setPanelExpanded(panel, true); // Creation order — oldest nearest the logo. In compact the Predict card is the // drawer's last child, so rooms have to go in ahead of it. var stack = getPanelStack(); stack.insertBefore(panel, stack.querySelector('#' + PREDICT_CONTAINER_ID)); return panel; } // Chip ⇄ full panel. The chevron only makes sense where the header is tappable. function setPanelExpanded(panel: HTMLElement, expanded: boolean) { var body = panel.querySelector('[id^="debug-body-"]') as HTMLElement; var title = panel.querySelector('.debug-title') as HTMLElement; var chevron = panel.querySelector('.debug-chevron') as HTMLElement; var compact = isCompact(); panel.dataset.expanded = expanded ? '1' : '0'; if (body) { body.style.display = expanded ? 'block' : 'none'; } if (title) { title.style.cursor = compact ? 'pointer' : 'default'; title.style.borderBottomColor = expanded ? 'rgba(255, 255, 255, 0.15)' : 'transparent'; title.style.paddingBottom = expanded ? '4px' : '0'; } if (chevron) { chevron.style.display = compact ? 'inline' : 'none'; chevron.textContent = expanded ? '▾' : '▸'; } } // Any tap outside an open info tooltip closes it. The icon that opens one stops the // click there, so this never fires for the very tap that opened the tooltip. function bindTooltipDismiss() { document.addEventListener('click', function() { var tooltips = getDebugRoot().querySelectorAll('[id^="debug-tooltip-"]') as NodeListOf; tooltips.forEach(function(tooltip) { tooltip.style.display = 'none'; }); }); } // Reshape the panels only when the breakpoint actually flips. reflow() also fires on // every plain resize — including the mobile URL bar sliding away mid-scroll — and // re-running this each time would reopen a panel the user just collapsed, or re-hide // the drawer they just revealed. function watchPanelShape() { var wasCompact = isCompact(); onReflow(function(state) { if (state.compact === wasCompact) { return; } wasCompact = state.compact; // Entering compact hands the screen back to the game until the logo is tapped. if (state.compact) { setCompactRevealed(false); } // Reopen everything: the chevron only exists in compact, so a panel collapsed // there would have no way back once the header goes inert on desktop. var panels = getDebugRoot().querySelectorAll('[id^="debug-panel-"]') as NodeListOf; panels.forEach(function(panel) { setPanelExpanded(panel, true); }); }); } // Update debug panel content export function updateDebugPanel(uniquePanelId, debugInfo) { var root = getDebugRoot(); var contentId = 'debug-content-' + uniquePanelId; var panelId = 'debug-panel-' + uniquePanelId; var titleId = 'debug-title-' + uniquePanelId; var content = root.getElementById(contentId); var panel = root.getElementById(panelId); var title = root.getElementById(titleId); if (!content || !panel) { // Only create if panel doesn't exist if (!panel) { createDebugPanel(uniquePanelId, debugInfo); content = root.getElementById(contentId); title = root.getElementById(titleId); repositionDebugPanels(); } else { content = root.getElementById(contentId); title = root.getElementById(titleId); } } // Update title with room name only (roomId and sessionId are in tooltip) var titleTextEl = root.getElementById('debug-title-text-' + uniquePanelId); var roomNameEl = titleTextEl?.querySelector('.debug-room-name'); if (roomNameEl) roomNameEl.textContent = debugInfo.roomName; root.getElementById('debug-tooltip-' + uniquePanelId).innerHTML = '
Room ID: ' + debugInfo.roomId + '
Session ID: ' + debugInfo.sessionId + '
'; // Update ping in header var pingDisplay = debugInfo.pingMs !== null ? debugInfo.pingMs + 'ms' : '--'; var pingColor = debugInfo.pingMs !== null ? (debugInfo.pingMs < 100 ? '#22c55e' : debugInfo.pingMs < 200 ? '#eab308' : '#ef4444') : '#888'; var pingElement = root.getElementById('debug-ping-' + uniquePanelId); if (pingElement) { pingElement.textContent = pingDisplay; pingElement.style.color = pingColor; } // Connection jitter (RoomClock.jitter) — only TIMED rooms advertise a patch // cadence, so non-input/NULL_CLOCK rooms (patchInterval 0) hide the row. Rendered // as its own stat row below — not in the header, which stays latency-only. var clock = debugInfo.room && debugInfo.room.clock; var hasJitter = !!(clock && typeof clock.jitter === 'function' && typeof clock.patchInterval === 'function' && clock.patchInterval() > 0); var jitterMs = hasJitter ? clock.jitter() : 0; var jitterText = (jitterMs < 10 ? jitterMs.toFixed(1) : jitterMs.toFixed(0)) + 'ms'; var jitterAccent = '#a78bfa'; // violet — signature color for the jitter stat (icon + graph) var html = '
'; html += '
'; html += '
'; html += '
' + envelopeUp + '' + formatBytes(debugInfo.bytesSentPerSec) + '/s
' + debugInfo.messagesSentPerSec.toFixed(0) + ' messages
'; html += '
' + envelopeDown + '' + formatBytes(debugInfo.bytesReceivedPerSec) + '/s
' + debugInfo.messagesReceivedPerSec.toFixed(0) + ' messages
'; // Jitter: third stat row, same shape as sent/received (icon + label, value below). if (hasJitter) { html += '
' + jitterIcon + 'jitter
±' + jitterText + '
'; } html += '
'; html += '
'; html += ''; html += ''; if (hasJitter) { html += ''; } html += '
'; html += '
'; html += ''; content.innerHTML = html; // Draw graphs after a short delay to ensure canvas elements are rendered setTimeout(function() { drawGraph('graph-sent-' + uniquePanelId, debugInfo.bytesSentHistory, '#FF9800'); drawGraph('graph-received-' + uniquePanelId, debugInfo.bytesReceivedHistory, '#2196F3'); if (hasJitter) drawGraph('graph-jitter-' + uniquePanelId, debugInfo.jitterHistory, jitterAccent); }, 10); } // Draw graph on canvas export function drawGraph(canvasId, data, color) { var canvas = getDebugRoot().getElementById(canvasId) as HTMLCanvasElement; if (!canvas) return; var ctx = canvas.getContext('2d') as CanvasRenderingContext2D; var width = canvas.width; var height = canvas.height; // Clear canvas ctx.clearRect(0, 0, width, height); if (!data || data.length === 0) return; // Find min and max values var maxValue = Math.max.apply(Math, data); var minValue = Math.min.apply(Math, data); var range = maxValue - minValue || 1; // Avoid division by zero // Padding var padding = 2; var graphWidth = width - padding * 2; var graphHeight = height - padding * 2; // Draw grid lines ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; ctx.lineWidth = 0.5; for (var i = 0; i <= 4; i++) { var y = padding + (graphHeight / 4) * i; ctx.beginPath(); ctx.moveTo(padding, y); ctx.lineTo(width - padding, y); ctx.stroke(); } // Draw the line ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.beginPath(); for (var i = 0; i < data.length; i++) { var x = padding + (graphWidth / (data.length - 1 || 1)) * i; var normalizedValue = (data[i] - minValue) / range; var y = padding + graphHeight - (normalizedValue * graphHeight); if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } } ctx.stroke(); // Fill area under the line if (data.length > 0) { ctx.lineTo(width - padding, height - padding); ctx.lineTo(padding, height - padding); ctx.closePath(); ctx.fillStyle = color; ctx.globalAlpha = 0.2; ctx.fill(); ctx.globalAlpha = 1.0; } } // Calculate per-second rates export function calculateRates(debugInfo) { var now = Date.now(); var elapsed = (now - debugInfo.lastUpdate) / 1000; // seconds if (elapsed > 0) { debugInfo.bytesSentPerSec = debugInfo.bytesSentDelta / elapsed; debugInfo.bytesReceivedPerSec = debugInfo.bytesReceivedDelta / elapsed; debugInfo.messagesSentPerSec = debugInfo.messagesSentDelta / elapsed; debugInfo.messagesReceivedPerSec = debugInfo.messagesReceivedDelta / elapsed; // Add to history debugInfo.bytesSentHistory.push(debugInfo.bytesSentPerSec); debugInfo.bytesReceivedHistory.push(debugInfo.bytesReceivedPerSec); // debugInfo.historyTimestamps.push(now); // Snapshot the clock's jitter EMA into the history ring (TIMED rooms only). var jitterClock = debugInfo.room && debugInfo.room.clock; if (jitterClock && typeof jitterClock.jitter === 'function' && typeof jitterClock.patchInterval === 'function' && jitterClock.patchInterval() > 0) { debugInfo.jitterHistory.push(jitterClock.jitter()); } // Limit history length var maxLen = debugInfo.maxHistoryLength || 60; if (debugInfo.bytesSentHistory.length > maxLen) { debugInfo.bytesSentHistory.shift(); debugInfo.bytesReceivedHistory.shift(); // debugInfo.historyTimestamps.shift(); } if (debugInfo.jitterHistory.length > maxLen) { debugInfo.jitterHistory.shift(); } // Reset deltas debugInfo.bytesSentDelta = 0; debugInfo.bytesReceivedDelta = 0; debugInfo.messagesSentDelta = 0; debugInfo.messagesReceivedDelta = 0; debugInfo.lastUpdate = now; } // Update panel updateDebugPanel(debugInfo.uniquePanelId, debugInfo); }