Initial commit: OP Extension Chrome Extension

This commit is contained in:
2026-01-27 10:50:29 +01:00
commit aa67a890e7
9 changed files with 938 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
*.log

478
content.js Normal file
View File

@@ -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 = `
<div class="tracker-header" id="tracker-header">
<span class="tracker-title">spawn tracker</span>
<button id="tracker-toggle" class="tracker-btn">hide</button>
</div>
<div id="tracker-content">
<div class="tracker-status">disconnected</div>
<div class="tracker-controls">
<label class="tracker-checkbox">
<input type="checkbox" id="auto-mark-toggle">
<span>auto-mark</span>
</label>
</div>
<div id="tracker-players"></div>
<button id="tracker-clear" class="tracker-btn tracker-btn-secondary">clear my spawn</button>
</div>
`;
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 = `
<span class="player-name">${player.name}</span>
<span class="player-location">${locationText}</span>
`;
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();
}
});
}
});

BIN
icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 B

BIN
icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

BIN
icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

39
manifest.json Normal file
View File

@@ -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"
}
}

145
overlay.css Normal file
View File

@@ -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;
}

178
popup.html Normal file
View File

@@ -0,0 +1,178 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
width: 280px;
padding: 12px;
font-family: monospace;
background: #1a1a1a;
color: #ddd;
margin: 0;
}
h2 {
margin: 0 0 12px 0;
color: #888;
font-size: 14px;
font-weight: normal;
}
.form-group {
margin-bottom: 10px;
}
label {
display: block;
margin-bottom: 3px;
font-size: 11px;
color: #666;
}
input {
width: 100%;
padding: 6px;
border: 1px solid #333;
border-radius: 2px;
background: #111;
color: #ddd;
font-size: 11px;
font-family: monospace;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #444;
}
.btn-group {
display: flex;
gap: 6px;
margin-top: 12px;
}
button {
flex: 1;
padding: 8px;
border: 1px solid #333;
border-radius: 2px;
cursor: pointer;
font-size: 11px;
font-family: monospace;
transition: all 0.2s;
}
.btn-save {
background: #2a2a2a;
color: #aaa;
}
.btn-save:hover {
background: #333;
}
.btn-disable {
background: #1a1a1a;
color: #666;
}
.btn-disable:hover {
background: #222;
}
.status {
margin-top: 10px;
padding: 6px;
border-radius: 2px;
text-align: center;
font-size: 10px;
display: none;
}
.status.success {
background: #1a2a1a;
color: #0f0;
border: 1px solid #0f0;
}
.status.error {
background: #2a1a1a;
color: #f00;
border: 1px solid #f00;
}
.info {
margin-top: 12px;
padding: 8px;
background: #111;
border: 1px solid #222;
border-radius: 2px;
font-size: 10px;
color: #666;
line-height: 1.4;
}
.debug {
margin-top: 12px;
padding: 8px;
background: #111;
border: 1px solid #222;
border-radius: 2px;
font-size: 10px;
line-height: 1.4;
}
.debug.checking {
color: #888;
}
.debug.ok {
color: #0f0;
border-color: #0f0;
}
.debug.error {
color: #f00;
border-color: #f00;
}
.auto-detected {
color: #0f0;
font-size: 9px;
margin-top: 2px;
}
</style>
</head>
<body>
<h2>spawn tracker</h2>
<div class="form-group">
<label for="serverUrl">server</label>
<input type="text" id="serverUrl" placeholder="wss://op.lopensed.dev">
</div>
<div class="form-group">
<label for="roomId">room (share with friends)</label>
<input type="text" id="roomId" placeholder="default">
</div>
<div class="form-group">
<label for="playerName">name</label>
<input type="text" id="playerName" placeholder="auto-detected">
<div class="auto-detected" id="autoDetected"></div>
</div>
<div class="btn-group">
<button class="btn-save" id="saveBtn">save</button>
<button class="btn-disable" id="disableBtn">disable</button>
</div>
<div class="status" id="status"></div>
<div class="debug" id="debug">checking server...</div>
<script src="popup.js"></script>
</body>
</html>

96
popup.js Normal file
View File

@@ -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);
}