commit aa67a890e7e2a99d641a0f1214f09cd3d7033966 Author: lopensed Date: Tue Jan 27 10:50:29 2026 +0100 Initial commit: OP Extension Chrome Extension diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2334d82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +*.log diff --git a/content.js b/content.js new file mode 100644 index 0000000..79638ab --- /dev/null +++ b/content.js @@ -0,0 +1,478 @@ +let ws = null; +let roomId = null; +let playerName = null; +let isEnabled = false; +let autoMark = false; +let isDragging = false; +let dragOffset = { x: 0, y: 0 }; + +// camera state tracking +let cameraState = { + scale: 1.8, + offsetX: -350, + offsetY: -200, + gameWidth: 0, + gameHeight: 0, + canvasRect: null +}; + +// spawn positions (tile coordinates) +let spawnPositions = new Map(); // playerName -> {x, y} + +// game state +let gameStarted = false; +let spawnPhaseCheckInterval = null; + +// overlay canvas for highlights +let overlayCanvas = null; +let overlayCtx = null; + +function createOverlay() { + const overlay = document.createElement('div'); + overlay.id = 'spawn-tracker-overlay'; + overlay.innerHTML = ` +
+ spawn tracker + +
+
+
disconnected
+
+ +
+
+ +
+ `; + document.body.appendChild(overlay); + + chrome.storage.local.get(['overlayPosition'], (data) => { + if (data.overlayPosition) { + overlay.style.top = data.overlayPosition.top; + overlay.style.left = data.overlayPosition.left; + overlay.style.right = 'auto'; + } + }); + + const header = document.getElementById('tracker-header'); + header.addEventListener('mousedown', startDrag); + document.addEventListener('mousemove', drag); + document.addEventListener('mouseup', stopDrag); + + document.getElementById('tracker-toggle').addEventListener('click', toggleOverlay); + document.getElementById('auto-mark-toggle').addEventListener('change', toggleAutoMark); + document.getElementById('tracker-clear').addEventListener('click', clearSpawn); + + chrome.storage.local.get(['autoMark'], (data) => { + autoMark = data.autoMark || false; + document.getElementById('auto-mark-toggle').checked = autoMark; + if (autoMark) { + setupAutoMark(); + } + }); + + initializeHighlighting(); + startSpawnPhaseCheck(); +} + +function initializeHighlighting() { + const gameCanvas = document.querySelector('canvas'); + if (!gameCanvas) { + setTimeout(initializeHighlighting, 500); + return; + } + + overlayCanvas = document.createElement('canvas'); + overlayCanvas.id = 'spawn-highlight-overlay'; + overlayCanvas.style.position = 'absolute'; + overlayCanvas.style.top = '0'; + overlayCanvas.style.left = '0'; + overlayCanvas.style.pointerEvents = 'none'; + overlayCanvas.style.zIndex = '999998'; + overlayCanvas.width = window.innerWidth; + overlayCanvas.height = window.innerHeight; + document.body.appendChild(overlayCanvas); + + overlayCtx = overlayCanvas.getContext('2d'); + + cameraState.gameWidth = gameCanvas.width; + cameraState.gameHeight = gameCanvas.height; + cameraState.canvasRect = gameCanvas.getBoundingClientRect(); + + setupCameraTracking(gameCanvas); + startHighlightLoop(); + + window.addEventListener('resize', () => { + overlayCanvas.width = window.innerWidth; + overlayCanvas.height = window.innerHeight; + cameraState.canvasRect = gameCanvas.getBoundingClientRect(); + }); +} + +function setupCameraTracking(canvas) { + let isPanning = false; + let lastMouseX = 0; + let lastMouseY = 0; + let hasMoved = false; + + canvas.addEventListener('wheel', (e) => { + const oldScale = cameraState.scale; + const zoomFactor = 1 + e.deltaY / 600; + cameraState.scale /= zoomFactor; + cameraState.scale = Math.max(0.2, Math.min(20, cameraState.scale)); + + const rect = canvas.getBoundingClientRect(); + const canvasX = e.clientX - rect.left; + const canvasY = e.clientY - rect.top; + + const zoomPointX = (canvasX - cameraState.gameWidth / 2) / oldScale + cameraState.offsetX; + const zoomPointY = (canvasY - cameraState.gameHeight / 2) / oldScale + cameraState.offsetY; + + cameraState.offsetX = zoomPointX - (canvasX - cameraState.gameWidth / 2) / cameraState.scale; + cameraState.offsetY = zoomPointY - (canvasY - cameraState.gameHeight / 2) / cameraState.scale; + }, { passive: true }); + + canvas.addEventListener('mousedown', (e) => { + if (e.button === 2 || e.button === 1) { + isPanning = true; + hasMoved = false; + lastMouseX = e.clientX; + lastMouseY = e.clientY; + } + }); + + canvas.addEventListener('mousemove', (e) => { + if (isPanning) { + const deltaX = e.clientX - lastMouseX; + const deltaY = e.clientY - lastMouseY; + + if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) { + hasMoved = true; + cameraState.offsetX -= deltaX / cameraState.scale; + cameraState.offsetY -= deltaY / cameraState.scale; + } + + lastMouseX = e.clientX; + lastMouseY = e.clientY; + } + }); + + canvas.addEventListener('mouseup', () => { + isPanning = false; + }); + + document.addEventListener('mouseup', () => { + isPanning = false; + }); +} + +function worldToScreenCoordinates(cellX, cellY) { + const gameX = cellX; + const gameY = cellY; + + const centerX = gameX - cameraState.gameWidth / 2; + const centerY = gameY - cameraState.gameHeight / 2; + + const canvasX = (centerX - cameraState.offsetX) * cameraState.scale + cameraState.gameWidth / 2; + const canvasY = (centerY - cameraState.offsetY) * cameraState.scale + cameraState.gameHeight / 2; + + const canvasRect = cameraState.canvasRect; + const screenX = canvasX + canvasRect.left; + const screenY = canvasY + canvasRect.top; + + return { x: screenX, y: screenY }; +} + +function startHighlightLoop() { + function render() { + if (!overlayCtx) return; + + overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); + + spawnPositions.forEach((pos, name) => { + const screen = worldToScreenCoordinates(pos.x, pos.y); + + const isMySpawn = name === playerName; + const radius = isMySpawn ? 20 : 15; + const color = isMySpawn ? '#0f0' : '#ff0'; + const pulseOffset = isMySpawn ? Math.sin(Date.now() / 200) * 3 : 0; + + overlayCtx.beginPath(); + overlayCtx.arc(screen.x, screen.y, radius + pulseOffset, 0, Math.PI * 2); + overlayCtx.strokeStyle = color; + overlayCtx.lineWidth = 3; + overlayCtx.stroke(); + + overlayCtx.fillStyle = color + '33'; + overlayCtx.fill(); + + overlayCtx.fillStyle = '#fff'; + overlayCtx.font = '10px monospace'; + overlayCtx.textAlign = 'center'; + overlayCtx.fillText(name, screen.x, screen.y - radius - 5); + }); + + requestAnimationFrame(render); + } + render(); +} + +function startDrag(e) { + isDragging = true; + const overlay = document.getElementById('spawn-tracker-overlay'); + const rect = overlay.getBoundingClientRect(); + dragOffset.x = e.clientX - rect.left; + dragOffset.y = e.clientY - rect.top; + overlay.style.cursor = 'grabbing'; +} + +function drag(e) { + if (!isDragging) return; + const overlay = document.getElementById('spawn-tracker-overlay'); + const x = e.clientX - dragOffset.x; + const y = e.clientY - dragOffset.y; + overlay.style.left = x + 'px'; + overlay.style.top = y + 'px'; + overlay.style.right = 'auto'; +} + +function stopDrag() { + if (!isDragging) return; + isDragging = false; + const overlay = document.getElementById('spawn-tracker-overlay'); + overlay.style.cursor = 'grab'; + + chrome.storage.local.set({ + overlayPosition: { + top: overlay.style.top, + left: overlay.style.left + } + }); +} + +function toggleOverlay() { + const content = document.getElementById('tracker-content'); + const btn = document.getElementById('tracker-toggle'); + + if (content.style.display === 'none') { + content.style.display = 'block'; + btn.textContent = 'hide'; + } else { + content.style.display = 'none'; + btn.textContent = 'show'; + } +} + +function toggleAutoMark(e) { + autoMark = e.target.checked; + chrome.storage.local.set({ autoMark: autoMark }); + + if (autoMark) { + updateStatus('auto-mark enabled', '#0f0'); + setupAutoMark(); + } else { + updateStatus('auto-mark disabled', '#666'); + removeAutoMark(); + } +} + +function setupAutoMark() { + const canvas = document.querySelector('canvas'); + if (!canvas) { + setTimeout(setupAutoMark, 500); + return; + } + + canvas.addEventListener('click', handleAutoMark); +} + +function removeAutoMark() { + const canvas = document.querySelector('canvas'); + if (canvas) { + canvas.removeEventListener('click', handleAutoMark); + } +} + +function handleAutoMark(e) { + if (!autoMark || !ws || ws.readyState !== WebSocket.OPEN || gameStarted) return; + + const canvas = e.target; + const rect = canvas.getBoundingClientRect(); + const canvasX = e.clientX - rect.left; + const canvasY = e.clientY - rect.top; + + const centerX = (canvasX - cameraState.gameWidth / 2) / cameraState.scale + cameraState.offsetX; + const centerY = (canvasY - cameraState.gameHeight / 2) / cameraState.scale + cameraState.offsetY; + + const gameX = centerX + cameraState.gameWidth / 2; + const gameY = centerY + cameraState.gameHeight / 2; + + const cellX = Math.floor(gameX); + const cellY = Math.floor(gameY); + + ws.send(JSON.stringify({ + type: 'location', + x: cellX, + y: cellY + })); +} + +function updateStatus(status, color = '#666') { + const statusEl = document.querySelector('.tracker-status'); + if (statusEl) { + statusEl.textContent = status; + statusEl.style.color = color; + } +} + +function updatePlayers(players) { + const playersEl = document.getElementById('tracker-players'); + if (!playersEl) return; + + playersEl.innerHTML = ''; + + spawnPositions.clear(); + + players.forEach(player => { + const playerDiv = document.createElement('div'); + playerDiv.className = 'tracker-player'; + + let locationText = 'waiting'; + if (player.location) { + spawnPositions.set(player.name, { x: player.location.x, y: player.location.y }); + locationText = `tile (${Math.round(player.location.x)}, ${Math.round(player.location.y)})`; + } + + playerDiv.innerHTML = ` + ${player.name} + ${locationText} + `; + + playersEl.appendChild(playerDiv); + }); +} + +function connectWebSocket(serverUrl, room, name) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(); + } + + roomId = room; + playerName = name; + + updateStatus('connecting', '#888'); + + ws = new WebSocket(serverUrl); + + ws.onopen = () => { + updateStatus('connected', '#0f0'); + ws.send(JSON.stringify({ + type: 'join', + roomId: roomId, + playerName: playerName + })); + + chrome.storage.local.get(['autoMark'], (data) => { + if (data.autoMark) { + autoMark = true; + setupAutoMark(); + } + }); + }; + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.type === 'update') { + updatePlayers(data.players); + } + }; + + ws.onclose = () => { + updateStatus('disconnected', '#f00'); + removeAutoMark(); + setTimeout(() => { + if (isEnabled) { + connectWebSocket(serverUrl, roomId, playerName); + } + }, 3000); + }; + + ws.onerror = (error) => { + console.error('ws error:', error); + updateStatus('error', '#f00'); + }; +} + +function clearSpawn() { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'clear' + })); + updateStatus('cleared', '#666'); + } +} + +function startSpawnPhaseCheck() { + spawnPhaseCheckInterval = setInterval(() => { + const spawnTimer = document.querySelector('spawn-timer'); + if (spawnTimer && spawnTimer.shadowRoot) { + const timerText = spawnTimer.shadowRoot.textContent; + if (!timerText || timerText.includes('Game') || timerText.includes('Tick')) { + if (!gameStarted) { + gameStarted = true; + updateStatus('game started', '#888'); + } + } + } + }, 1000); +} + +function getUsername() { + return localStorage.getItem('username') || ''; +} + +chrome.storage.sync.get(['serverUrl', 'roomId', 'playerName', 'enabled', 'autoMark'], (data) => { + const serverUrl = data.serverUrl || 'wss://op.lopensed.dev'; + const roomId = data.roomId || 'default'; + const name = data.playerName || getUsername(); + const enabled = data.enabled !== false; + + chrome.storage.sync.set({ + serverUrl: serverUrl, + roomId: roomId, + enabled: enabled + }); + + if (enabled && serverUrl && roomId && name) { + isEnabled = true; + createOverlay(); + connectWebSocket(serverUrl, roomId, name); + } +}); + +chrome.storage.onChanged.addListener((changes, namespace) => { + if (namespace === 'sync') { + chrome.storage.sync.get(['serverUrl', 'roomId', 'playerName', 'enabled'], (data) => { + const name = data.playerName || getUsername(); + + if (data.enabled && data.serverUrl && data.roomId && name) { + isEnabled = true; + if (!document.getElementById('spawn-tracker-overlay')) { + createOverlay(); + } + connectWebSocket(data.serverUrl, data.roomId, name); + } else { + isEnabled = false; + if (ws) ws.close(); + removeAutoMark(); + if (spawnPhaseCheckInterval) clearInterval(spawnPhaseCheckInterval); + const overlay = document.getElementById('spawn-tracker-overlay'); + if (overlay) overlay.remove(); + if (overlayCanvas) overlayCanvas.remove(); + } + }); + } +}); \ No newline at end of file diff --git a/icon128.png b/icon128.png new file mode 100644 index 0000000..ce85692 Binary files /dev/null and b/icon128.png differ diff --git a/icon16.png b/icon16.png new file mode 100644 index 0000000..3da8b87 Binary files /dev/null and b/icon16.png differ diff --git a/icon48.png b/icon48.png new file mode 100644 index 0000000..66355e2 Binary files /dev/null and b/icon48.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..c0b8d5a --- /dev/null +++ b/manifest.json @@ -0,0 +1,39 @@ +{ + "manifest_version": 3, + "name": "openfront spawn tracker", + "version": "1.0.0", + "description": "track spawn locations", + "permissions": [ + "storage", + "activeTab", + "scripting" + ], + "host_permissions": [ + "https://openfront.io/*", + "https://www.openfront.io/*" + ], + "content_scripts": [ + { + "matches": [ + "https://openfront.io/*", + "https://www.openfront.io/*" + ], + "js": ["content.js"], + "css": ["overlay.css"], + "run_at": "document_end" + } + ], + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icon16.png", + "48": "icon48.png", + "128": "icon128.png" + } + }, + "icons": { + "16": "icon16.png", + "48": "icon48.png", + "128": "icon128.png" + } +} \ No newline at end of file diff --git a/overlay.css b/overlay.css new file mode 100644 index 0000000..ce1a23e --- /dev/null +++ b/overlay.css @@ -0,0 +1,145 @@ +#spawn-tracker-overlay { + position: fixed; + top: 20px; + right: 20px; + background: #1a1a1a; + border: 1px solid #333; + border-radius: 4px; + padding: 12px; + min-width: 240px; + max-width: 300px; + z-index: 999999; + font-family: monospace; + color: #ddd; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.8); +} + +.tracker-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid #333; + cursor: grab; + user-select: none; +} + +.tracker-title { + font-size: 13px; + color: #888; +} + +.tracker-status { + text-align: center; + padding: 4px; + margin-bottom: 8px; + background: #111; + border-radius: 2px; + font-size: 11px; +} + +#tracker-players { + max-height: 200px; + overflow-y: auto; + margin-bottom: 8px; +} + +.tracker-player { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px; + margin-bottom: 4px; + background: #111; + border-radius: 2px; + border-left: 2px solid #444; +} + +.player-name { + color: #ddd; + font-size: 12px; +} + +.player-location { + font-size: 10px; + color: #666; +} + +.tracker-btn { + background: #222; + color: #888; + border: 1px solid #333; + border-radius: 2px; + padding: 4px 8px; + cursor: pointer; + font-size: 11px; + font-family: monospace; + transition: all 0.2s; +} + +.tracker-btn:hover { + background: #2a2a2a; + border-color: #444; + color: #aaa; +} + +.tracker-btn-primary { + width: 100%; + margin-bottom: 4px; + background: #2a2a2a; + color: #aaa; +} + +.tracker-btn-primary:hover { + background: #333; +} + +.tracker-btn-secondary { + width: 100%; + background: #1a1a1a; +} + +.tracker-btn-secondary:hover { + background: #222; +} + +#tracker-players::-webkit-scrollbar { + width: 4px; +} + +#tracker-players::-webkit-scrollbar-track { + background: #111; +} + +#tracker-players::-webkit-scrollbar-thumb { + background: #333; +} + +#tracker-players::-webkit-scrollbar-thumb:hover { + background: #444; +} + +.tracker-controls { + margin-bottom: 8px; + padding: 6px; + background: #111; + border-radius: 2px; +} + +.tracker-checkbox { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 11px; + color: #888; +} + +.tracker-checkbox input[type="checkbox"] { + cursor: pointer; +} + +.tracker-checkbox:hover { + color: #aaa; +} \ No newline at end of file diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..f5fa577 --- /dev/null +++ b/popup.html @@ -0,0 +1,178 @@ + + + + + + + +

spawn tracker

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ +
checking server...
+ + + + \ No newline at end of file diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..e69d8c8 --- /dev/null +++ b/popup.js @@ -0,0 +1,96 @@ +// check for auto-detected username +chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { + if (tabs[0] && tabs[0].url && tabs[0].url.includes('openfront.io')) { + chrome.scripting.executeScript({ + target: {tabId: tabs[0].id}, + func: () => localStorage.getItem('username') + }, (results) => { + if (results && results[0] && results[0].result) { + document.getElementById('autoDetected').textContent = `detected: ${results[0].result}`; + } + }); + } +}); + +chrome.storage.sync.get(['serverUrl', 'roomId', 'playerName', 'enabled'], (data) => { + // set defaults + const serverUrl = data.serverUrl || 'wss://op.lopensed.dev'; + const roomId = data.roomId || 'default'; + + document.getElementById('serverUrl').value = serverUrl; + document.getElementById('roomId').value = roomId; + if (data.playerName) document.getElementById('playerName').value = data.playerName; + + // auto-save defaults and enable if not already done + if (!data.enabled || !data.serverUrl || !data.roomId) { + chrome.storage.sync.set({ + serverUrl: serverUrl, + roomId: roomId, + enabled: true + }); + } + + // ping server for debug + pingServer(serverUrl); +}); + +function pingServer(url) { + const debug = document.getElementById('debug'); + debug.textContent = 'checking server...'; + debug.className = 'debug checking'; + + const ws = new WebSocket(url); + + const timeout = setTimeout(() => { + ws.close(); + debug.textContent = 'server: timeout'; + debug.className = 'debug error'; + }, 5000); + + ws.onopen = () => { + clearTimeout(timeout); + debug.textContent = 'server: ok'; + debug.className = 'debug ok'; + setTimeout(() => ws.close(), 100); + }; + + ws.onerror = () => { + clearTimeout(timeout); + debug.textContent = 'server: unreachable'; + debug.className = 'debug error'; + }; +} + +document.getElementById('saveBtn').addEventListener('click', () => { + const serverUrl = document.getElementById('serverUrl').value.trim() || 'wss://op.lopensed.dev'; + const roomId = document.getElementById('roomId').value.trim() || 'default'; + const playerName = document.getElementById('playerName').value.trim(); + + chrome.storage.sync.set({ + serverUrl: serverUrl, + roomId: roomId, + playerName: playerName, + enabled: true + }, () => { + showStatus('saved - refresh page', 'success'); + }); +}); + +document.getElementById('disableBtn').addEventListener('click', () => { + chrome.storage.sync.set({ + enabled: false + }, () => { + showStatus('disabled - refresh page', 'success'); + }); +}); + +function showStatus(message, type) { + const status = document.getElementById('status'); + status.textContent = message; + status.className = `status ${type}`; + status.style.display = 'block'; + + setTimeout(() => { + status.style.display = 'none'; + }, 3000); +} \ No newline at end of file