Minimap History & Pins · Live Stats · Global Chat · Key Overlay
// ==UserScript==
// @name TileMan.io Client
// @namespace https://tileman.io/
// @version 4.3.1
// @description Minimap History & Pins · Live Stats · Global Chat · Key Overlay
// @author Ech0
// @copyright 2026, Ech0
// @license MIT
// @match *://tileman.io/*
// @match *://*.tileman.io/*
// @match *://*.unblocked.tileman.io/*
// @match *://*.unb.tileman.io/*
// @run-at document-start
// @grant unsafeWindow
// ==/UserScript==
(function injectTilemanCombined() {
"use strict";
const myWindow = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
pageMain(myWindow);
function pageMain(globalContext) {
"use strict";
// ═══════════════════════════════════════════════════════════════════════
// STORAGE KEYS
// ═══════════════════════════════════════════════════════════════════════
const SK = {
OPACITY: "TM_HISTORY_OPACITY_V1",
SECONDS: "TM_HISTORY_SECONDS_V1",
THEME: "TM_THEME_V1",
PINS: "TM_PINS_V1",
CHAT_NAME: "TM_CHAT_NAME_V1",
KEYBINDS: "TM_KEYBINDS_V1",
CHAT_VISIBLE: "TM_CHAT_VISIBLE_V1",
STATS_VISIBLE: "TM_STATS_VISIBLE_V1",
CHAT_GEO: "TM_CHAT_GEO_V1",
SETTINGS_GEO: "TM_SETTINGS_GEO_V1",
KEYS_VISIBLE: "TM_KEYS_VISIBLE_V1",
};
// ═══════════════════════════════════════════════════════════════════════
// KEYBIND SYSTEM
// ═══════════════════════════════════════════════════════════════════════
const DEFAULT_KB = {
toggleStats: "KeyT",
cycleRate: "KeyR",
toggleChat: "KeyC",
toggleKeys: "KeyK",
};
let KB = { ...DEFAULT_KB };
try {
const saved = JSON.parse(localStorage.getItem(SK.KEYBINDS) || "{}");
KB = Object.assign({}, DEFAULT_KB, saved);
} catch(_) {}
function saveKB() { try { localStorage.setItem(SK.KEYBINDS, JSON.stringify(KB)); } catch(_) {} }
function keyLabel(code) {
if (!code) return "?";
if (code.startsWith("Key")) return code.slice(3);
if (code.startsWith("Digit")) return code.slice(5);
const MAP = { Space:"SPC", Backquote:"`", Minus:"-", Equal:"=", BracketLeft:"[", BracketRight:"]", Semicolon:";", Quote:"'", Comma:",", Period:".", Slash:"/", Backslash:"\\" };
return MAP[code] || code;
}
// ═══════════════════════════════════════════════════════════════════════
// KEY DISPLAY SYSTEM STATE & LOGIC
// ═══════════════════════════════════════════════════════════════════════
const nativeAddEventListener = EventTarget.prototype.addEventListener;
const activeInputs = new Set();
const targetIdMap = {
'keyw': 'tileman-visual-key-up', 'arrowup': 'tileman-visual-key-up',
'keys': 'tileman-visual-key-down', 'arrowdown': 'tileman-visual-key-down',
'keya': 'tileman-visual-key-left', 'arrowleft': 'tileman-visual-key-left',
'keyd': 'tileman-visual-key-right', 'arrowright': 'tileman-visual-key-right',
'keye': 'tileman-visual-key-e', 'keyp': 'tileman-visual-key-p',
'keyx': 'tileman-visual-key-x', 'keyz': 'tileman-visual-key-z',
'space': 'tileman-visual-key-space',
'w': 'tileman-visual-key-up', 's': 'tileman-visual-key-down',
'a': 'tileman-visual-key-left', 'd': 'tileman-visual-key-right',
'e': 'tileman-visual-key-e', 'p': 'tileman-visual-key-p',
'x': 'tileman-visual-key-x', 'z': 'tileman-visual-key-z',
' ': 'tileman-visual-key-space'
};
function setElementActive(element, active) {
if (active) {
element.style.setProperty('background-color', 'rgba(240, 240, 240, 0.95)', 'important');
element.style.setProperty('color', '#111', 'important');
element.style.setProperty('border-color', '#fff', 'important');
element.style.setProperty('transform', 'scale(0.92)', 'important');
element.style.setProperty('box-shadow', '0 0 8px rgba(255, 255, 255, 0.4)', 'important');
} else {
element.style.removeProperty('background-color');
element.style.removeProperty('color');
element.style.removeProperty('border-color');
element.style.removeProperty('transform');
element.style.removeProperty('box-shadow');
}
}
function handleKeyEvent(e, isDown) {
const activeEl = document.activeElement;
if (activeEl && (activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA")) return;
const codeVal = e.code ? e.code.toLowerCase() : '';
const keyVal = e.key ? e.key.toLowerCase() : '';
const inputId = codeVal || keyVal;
if (!inputId) return;
if (isDown) {
if (codeVal) activeInputs.add(codeVal);
else if (keyVal) activeInputs.add(keyVal);
} else {
if (codeVal) activeInputs.delete(codeVal);
if (keyVal) activeInputs.delete(keyVal);
}
if (!S.keysVisible) return;
document.querySelectorAll('.key-cap').forEach(el => setElementActive(el, false));
activeInputs.forEach(activeId => {
const targetId = targetIdMap[activeId];
if (targetId) {
const element = document.getElementById(targetId);
if (element) setElementActive(element, true);
}
});
}
try {
nativeAddEventListener.call(window, 'keydown', (e) => handleKeyEvent(e, true), true);
nativeAddEventListener.call(window, 'keyup', (e) => handleKeyEvent(e, false), true);
nativeAddEventListener.call(document, 'keydown', (e) => handleKeyEvent(e, true), true);
nativeAddEventListener.call(document, 'keyup', (e) => handleKeyEvent(e, false), true);
} catch (err) {
window.addEventListener('keydown', (e) => handleKeyEvent(e, true), true);
window.addEventListener('keyup', (e) => handleKeyEvent(e, false), true);
}
window.addEventListener('blur', () => {
activeInputs.clear();
document.querySelectorAll('.key-cap').forEach(el => setElementActive(el, false));
});
// ═══════════════════════════════════════════════════════════════════════
// MINIMAP CONSTANTS & STATE
// ═══════════════════════════════════════════════════════════════════════
const COPY_EVERY_MS = 80;
const MAX_HISTORY_SECONDS = 3600;
const FILLED_THRESHOLD = 128;
const BURST_FRACTION = 0.01;
const STALE_TIMEOUT_MS = 5000;
const MAX_ELAPSED_MS = 1000;
const ARROW_SIZE = 56;
const ARROW_ORBIT_FRAC = 0.36;
const PIN_CLOSE_THRESH = 0.04;
const S = {
panel: null, stage: null, map: null, mapCtx: null,
history: null, histCtx: null, marker: null,
gearBtn: null, settingsWin: null, settingsOpen: false,
rawW: 1, rawH: 1, lastSource: null, lastCopyAt: 0, lastHistAt: 0,
histRemaining: null, histData: null,
minimapCanvas: null, minimapGeo: null,
drawHooked: false, shapeHooked: false, wsHooked: false,
arcs: new WeakMap(),
opacity: readNumber(SK.OPACITY, 0.95, 0, 1),
seconds: readNumber(SK.SECONDS, 1800, 0, MAX_HISTORY_SECONDS),
theme: localStorage.getItem(SK.THEME) || "Rainbow",
markerAt: 0, hideTimer: null, staleTimer: null,
wsConnected: false, wsAwaitingBurst: true, prevFilled: null,
playerX: null, playerY: null,
pinEls: {}, arrowEls: {}, activePinPopup: null,
keysVisible: localStorage.getItem(SK.KEYS_VISIBLE) !== "false",
};
let pins = [];
let pinIdCounter = 0;
// ═══════════════════════════════════════════════════════════════════════
// STATS CONSTANTS & STATE
// ═══════════════════════════════════════════════════════════════════════
globalContext.connection = {
socketIo: null, ws: null, lastDeath: 0, lastConnected: 0, playing: false,
};
globalContext.fix = function(input, radix, length) {
let result = input.toString(radix);
while (result.length < length) result = "0" + result;
return result;
};
globalContext.num2time = function(t) {
t = t / 1000;
return [t / 3600, (t / 60) % 60, t % 60].map(x => globalContext.fix(Math.floor(x), 10, 2)).join(":");
};
function getKills() {
const el = document.getElementById("kills"); return el ? Number(el.innerText) || 0 : 0;
}
globalContext.stats = {
get kpm() { return (60 * getKills() / (this.timeAlive / 1000)) || 0; },
get kph() { return (3600 * getKills() / (this.timeAlive / 1000)) || 0; },
get spk() { const k = getKills(); return k ? (this.timeAlive / 1000) / k : 0; },
get timeAlive(){ return ((globalContext.connection.playing ? Date.now() : globalContext.connection.lastDeath) - globalContext.connection.lastConnected) || 0; },
};
let speedIndex = 0, statsBarEl = null, deathStatsEl = null;
const speedProps = ["kpm", "kph", "spk"];
const speedLabels = { kpm: "KPM", kph: "KPH", spk: "S/KILL" };
let statsBarVisible = localStorage.getItem(SK.STATS_VISIBLE) !== "false";
// ═══════════════════════════════════════════════════════════════════════
// CHAT CONSTANTS & STATE
// ═══════════════════════════════════════════════════════════════════════
const GLOBAL_TOPIC = "tileman_chat_global_v2";
const HEARTBEAT_INTERVAL = 25000; // Say "I'm online" every 25s
const ONLINE_TIMEOUT_MS = 45000; // If no heartbeat for 45s, they left
const PRUNE_INTERVAL = 5000; // Sweep for timeouts every 5s
let eventSource = null;
let chatVisible = localStorage.getItem(SK.CHAT_VISIBLE) !== "false";
let heartbeatTimer = null;
let timeoutSweepTimer = null;
const onlinePlayers = new Map(); // username → lastSeen (epoch ms)
function getChatName() {
const override = localStorage.getItem(SK.CHAT_NAME);
if (override && override.trim()) return override.trim();
return localStorage.getItem("n") || "Anonymous";
}
function escHtml(s) {
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
}
function formatTime(ms) {
return new Date(ms).toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
// ═══════════════════════════════════════════════════════════════════════
// NETWORK HOOKS
// ═══════════════════════════════════════════════════════════════════════
function hookWebSocket() {
if (S.wsHooked || typeof globalContext.WebSocket === "undefined") return;
const NativeWS = globalContext.WebSocket;
const WSProxy = new Proxy(NativeWS, {
construct(target, args, newTarget) {
const ws = Reflect.construct(target, args, newTarget);
globalContext.connection.ws = ws;
ws.addEventListener("open", () => { S.wsConnected = true; S.wsAwaitingBurst = true; clearHistory(); hideAllArrows(); });
ws.addEventListener("close", () => { S.wsConnected = false; S.wsAwaitingBurst = true; clearTimeout(S.staleTimer); clearHistory(); setPanelVisible(false); });
ws.addEventListener("error", () => { S.wsConnected = false; S.wsAwaitingBurst = true; clearTimeout(S.staleTimer); setPanelVisible(false); });
return ws;
}
});
globalContext.WebSocket = WSProxy; S.wsHooked = true;
}
function createIoProxy(originalVal) {
function hookSocket(socketIo) {
globalContext.connection.socketIo = socketIo;
socketIo.on("rp", () => { globalContext.connection.lastDeath = Date.now(); globalContext.connection.playing = false; updateDeathStats(); });
socketIo.on("in", () => { globalContext.connection.lastDeath = Date.now(); globalContext.connection.playing = false; });
socketIo.once("ti", () => { globalContext.connection.lastConnected = Date.now(); globalContext.connection.playing = true; });
}
return new Proxy(originalVal, {
apply(target, thisArg, args) { const s = Reflect.apply(target, thisArg, args); try { hookSocket(s); } catch(e) {} return s; },
construct(target, args, newTarget) { const s = Reflect.construct(target, args, newTarget); try { hookSocket(s); } catch(e) {} return s; }
});
}
function hookSocketIO() {
const tryHook = () => {
if (globalContext.io && !globalContext.io.__tmHooked) { globalContext.io = createIoProxy(globalContext.io); globalContext.io.__tmHooked = true; return true; }
return false;
};
if (!tryHook()) {
const iv = setInterval(() => { if (tryHook()) clearInterval(iv); }, 5);
setTimeout(() => clearInterval(iv), 12000);
}
}
// ═══════════════════════════════════════════════════════════════════════
// CANVAS HOOKS
// ═══════════════════════════════════════════════════════════════════════
function hookDrawImage() {
if (S.drawHooked || typeof CanvasRenderingContext2D === "undefined") return;
const proto = CanvasRenderingContext2D.prototype;
const orig = proto.drawImage;
if (!orig || orig.__tm4) return;
proto.drawImage = function(src) {
const r = orig.apply(this, arguments);
try {
const g = txGeo(this, getGeo(arguments, src));
if (isMinimapCandidate(this.canvas, src, g)) copyMinimap(src, this.canvas, g);
} catch(_) {}
return r;
};
proto.drawImage.__tm4 = true;
S.drawHooked = true;
}
function hookShapes() {
if (S.shapeHooked || typeof CanvasRenderingContext2D === "undefined") return;
const proto = CanvasRenderingContext2D.prototype;
const oArc = proto.arc, oFill = proto.fill, oStroke = proto.stroke;
if (!oArc || oArc.__tm4) return;
proto.arc = function(x, y, radius) {
const r = oArc.apply(this, arguments);
try {
const pt = txPt(this, +x, +y);
if (insideMinimap(this.canvas, pt.x, pt.y) && +radius <= 18)
S.arcs.set(this, { x: pt.x, y: pt.y, t: performance.now() });
} catch(_) {}
return r;
};
proto.arc.__tm4 = true;
proto.fill = function() { const r = oFill.apply(this, arguments); tryMarker(this, this.fillStyle); return r; };
proto.stroke = function() { const r = oStroke.apply(this, arguments); tryMarker(this, this.strokeStyle); return r; };
S.shapeHooked = true;
}
function readNumber(key, fallback, min, max) { const raw = localStorage.getItem(key); if (raw === null) return fallback; const v = Number(raw); return Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : fallback; }
function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
function hslStr(hue) { return `hsl(${hue},100%,54%)`; }
function hueToRgb(h) {
const s=1, l=0.54, c=(1-Math.abs(2*l-1))*s;
const x=c*(1-Math.abs((h/60)%2-1)), m=l-c/2; let r=0,g=0,b=0;
if (h<60){r=c;g=x;} else if(h<120){r=x;g=c;} else if (h<180){g=c;b=x;} else if(h<240){g=x;b=c;} else if (h<300){r=x;b=c;} else{r=c;b=x;}
return [Math.round((r+m)*255),Math.round((g+m)*255),Math.round((b+m)*255)];
}
function parseOpacity(str) { let v = parseFloat(str.replace(/[^0-9.]/g,"")); if (isNaN(v)) return null; if (str.includes("%") || v > 1) v = v / 100; return clamp(v, 0, 1); }
function parseSeconds(str) {
str = str.toLowerCase().trim(); const mm = str.match(/([\d.]+)\s*m/), ss = str.match(/([\d.]+)\s*s/); let total = 0, found = false;
if (mm) { total += parseFloat(mm[1]) * 60; found = true; } if (ss) { total += parseFloat(ss[1]); found = true; }
if (!found) { const p = parseFloat(str.replace(/[^0-9.]/g,"")); if (!isNaN(p)) total = p; else return null; }
return clamp(Math.round(total), 0, MAX_HISTORY_SECONDS);
}
function lifetimeMs() { return S.seconds * 1000; }
function pct(v) { return Math.round(v * 100) + "%"; }
function secs(v) { const val = Math.round(v); if (val < 60) return val + "s"; return (val % 60 > 0) ? `${Math.floor(val/60)}m ${val%60}s` : `${Math.floor(val/60)}m`; }
// ═══════════════════════════════════════════════════════════════════════
// MINIMAP ENGINE
// ═══════════════════════════════════════════════════════════════════════
function copyMinimap(src, dest, geo) {
try {
const now = performance.now(); positionOverlay(dest, geo);
if (now - S.lastCopyAt < COPY_EVERY_MS && src === S.lastSource) return;
S.lastCopyAt = now; S.lastSource = src; installUi();
if (!S.mapCtx || !S.histCtx) return;
const w = Math.max(1, Math.round(src.width)), h = Math.max(1, Math.round(src.height));
resizeCanvases(w, h);
S.mapCtx.fillStyle = "#000"; S.mapCtx.fillRect(0,0,w,h); S.mapCtx.drawImage(src,0,0);
kickStaleTimer(); updateHistory(w, h, now);
} catch(e) {}
}
function updateHistory(w, h, now) {
const pixels = S.mapCtx.getImageData(0,0,w,h).data, n = w*h;
if (S.wsAwaitingBurst) {
let nf = 0; const prev = S.prevFilled && S.prevFilled.length === n ? S.prevFilled : null;
for (let i=0,o=0; i<n; i++,o+=4) { const f = ((pixels[o]+pixels[o+1]+pixels[o+2])/3) < FILLED_THRESHOLD ? 1 : 0; if (f && (!prev || !prev[i])) nf++; }
if (!prev) S.prevFilled = new Uint8Array(n);
for (let i=0,o=0; i<n; i++,o+=4) S.prevFilled[i] = ((pixels[o]+pixels[o+1]+pixels[o+2])/3) < FILLED_THRESHOLD ? 1 : 0;
if (nf/n >= BURST_FRACTION) { S.wsAwaitingBurst = false; S.lastHistAt = now; positionOverlay(S.minimapCanvas, S.minimapGeo); } return;
}
if (document.hidden) { S.lastHistAt = now; return; }
const elapsed = Math.min(MAX_ELAPSED_MS, Math.max(1, now - (S.lastHistAt || now))), lifetime = lifetimeMs(); S.lastHistAt = now;
if (!S.prevFilled || S.prevFilled.length !== n) {
S.prevFilled = new Uint8Array(n); for (let i=0,o=0; i<n; i++,o+=4) S.prevFilled[i] = ((pixels[o]+pixels[o+1]+pixels[o+2])/3) < FILLED_THRESHOLD ? 1 : 0;
S.lastHistAt = now; return;
}
const rem = S.histRemaining;
for (let i=0,o=0; i<n; i++,o+=4) {
rem[i] = Math.max(0, rem[i] - elapsed);
const f = ((pixels[o]+pixels[o+1]+pixels[o+2])/3) < FILLED_THRESHOLD ? 1 : 0;
if (f && !S.prevFilled[i] && lifetime > 0) rem[i] = lifetime; else if (!f && S.prevFilled[i]) rem[i] = 0;
S.prevFilled[i] = f;
}
renderHistory();
}
function renderHistory() {
const lifetime = lifetimeMs(); if (lifetime <= 0) { S.histCtx.clearRect(0,0,S.rawW,S.rawH); return; }
const img = S.histData, out = img.data, rem = S.histRemaining;
for (let i=0,o=0; i<rem.length; i++,o+=4) {
const left = rem[i]; if (left <= 0) { out[o]=out[o+1]=out[o+2]=out[o+3]=0; continue; }
const c = histColor(1 - left/lifetime); out[o]=c[0]; out[o+1]=c[1]; out[o+2]=c[2]; out[o+3]=c[3];
}
S.histCtx.putImageData(img, 0, 0);
}
function histColor(age) {
if (S.theme === "Rainbow") {
const stops = [[0,[255,0,0,235]],[0.2,[255,126,0,225]],[0.4,[255,232,0,210]],[0.6,[0,214,68,185]],[0.8,[42,125,255,145]],[1,[0,0,0,0]]];
for (let i=1; i<stops.length; i++) {
if (age <= stops[i][0]) {
const a=stops[i-1], b=stops[i], t=clamp((age-a[0])/(b[0]-a[0]),0,1);
return [Math.round(a[1][0]+(b[1][0]-a[1][0])*t),Math.round(a[1][1]+(b[1][1]-a[1][1])*t),Math.round(a[1][2]+(b[1][2]-a[1][2])*t),Math.round(a[1][3]+(b[1][3]-a[1][3])*t)];
}
}
return stops[stops.length-1][1];
}
let rgb;
switch(S.theme) {
case "Grayscale": rgb=[255,255,255]; break; case "Red": rgb=[255,0,0]; break;
case "Orange": rgb=[255,127,0]; break; case "Yellow": rgb=[255,230,0]; break;
case "Green": rgb=[0,214,68]; break; case "Blue": rgb=[42,125,255]; break;
case "Purple": rgb=[170,0,255]; break; default: rgb=[255,255,255]; break;
}
return [rgb[0], rgb[1], rgb[2], Math.round(235*(1-age))];
}
// ─── Minimap Sub-Helpers
function isCanvasLike(src) { return src && typeof src.width==="number" && (src instanceof HTMLCanvasElement || (typeof OffscreenCanvas!=="undefined" && src instanceof OffscreenCanvas) || (typeof ImageBitmap!=="undefined" && src instanceof ImageBitmap)); }
function getGeo(args,src) { if (args.length>=9) return {sx:+args[1],sy:+args[2],sw:+args[3],sh:+args[4],dx:+args[5],dy:+args[6],dw:+args[7],dh:+args[8]}; if (args.length>=5) return {sx:0,sy:0,sw:src.width,sh:src.height,dx:+args[1],dy:+args[2],dw:+args[3],dh:+args[4]}; return {sx:0,sy:0,sw:src.width,sh:src.height,dx:+args[1],dy:+args[2],dw:src.width,dh:src.height}; }
function txGeo(ctx,g) { const pts=[txPt(ctx,g.dx,g.dy),txPt(ctx,g.dx+g.dw,g.dy),txPt(ctx,g.dx,g.dy+g.dh),txPt(ctx,g.dx+g.dw,g.dy+g.dh)]; const xs=pts.map(p=>p.x),ys=pts.map(p=>p.y); return {sx:g.sx,sy:g.sy,sw:g.sw,sh:g.sh,dx:Math.min(...xs),dy:Math.min(...ys),dw:Math.max(...xs)-Math.min(...xs),dh:Math.max(...ys)-Math.min(...ys)}; }
function isMinimapCandidate(dest,src,g) {
if (!dest||dest.id!=="canvas"||!isCanvasLike(src)||!Number.isFinite(g.dw)||!Number.isFinite(g.dh)) return false;
if (src.width<16||src.height<16||src.width>2400||src.height>2400) return false;
if (Math.abs(src.width-src.height)>Math.max(2,src.width*0.03)) return false;
if (Math.abs(g.dw-g.dh)>Math.max(2,Math.abs(g.dw)*0.04)) return false;
const maxDraw=Math.min(dest.width||0,dest.height||0)*0.65; if (maxDraw>0&&Math.max(Math.abs(g.dw),Math.abs(g.dh))>maxDraw) return false;
return g.dx<80||g.dy<80||g.dx+g.dw>(dest.width||0)-80||g.dy+g.dh>(dest.height||0)-80;
}
function txPt(ctx,x,y) { if (typeof ctx.getTransform!=="function") return {x,y}; const m=ctx.getTransform(); return {x:m.a*x+m.c*y+m.e, y:m.b*x+m.d*y+m.f}; }
function insideMinimap(canvas,x,y) { const g=S.minimapGeo; return canvas===S.minimapCanvas&&g&&x>=g.dx&&y>=g.dy&&x<=g.dx+g.dw&&y<=g.dy+g.dh; }
function isMarkerStyle(s) { s=String(s).toLowerCase().replace(/\s+/g,""); return s==="#fff"||s==="#ffffff"||s==="white"||s==="#000"||s==="#000000"||s==="black"||s.startsWith("rgb(255,255,255")||s.startsWith("rgba(255,255,255")||s.startsWith("rgb(0,0,0")||s.startsWith("rgba(0,0,0"); }
function tryMarker(ctx,style) {
const arc=S.arcs.get(ctx); if (!arc||performance.now()-arc.t>120||!isMarkerStyle(style)) return;
const g=S.minimapGeo; if (!g) return; const x=(arc.x-g.dx)/g.dw, y=(arc.y-g.dy)/g.dh;
if (x<-0.02||y<-0.02||x>1.02||y>1.02) return; const cx=clamp(x,0,1), cy=clamp(y,0,1);
showMarker(cx,cy); if (S.playerX!==cx||S.playerY!==cy) { S.playerX=cx; S.playerY=cy; updateAllArrows(); }
}
function showMarker(x,y) { installUi(); if (!S.marker) return; const ir=2.5, ox=(x-0.5)*2*ir, oy=(y-0.5)*2*ir; S.marker.style.display="block"; S.marker.style.left=(x*100)+"%"; S.marker.style.top=(y*100)+"%"; S.marker.style.transform=`translate(calc(-50% + ${ox}px),calc(-50% + ${oy}px))`; S.markerAt=performance.now(); }
function resizeCanvases(w,h) { if (S.rawW===w&&S.rawH===h) return; S.rawW=w; S.rawH=h; S.map.width=w; S.map.height=h; S.history.width=w; S.history.height=h; S.mapCtx.imageSmoothingEnabled=false; S.histCtx.imageSmoothingEnabled=false; S.histRemaining=new Float32Array(w*h); S.histData=S.histCtx.createImageData(w,h); }
function kickStaleTimer() { clearTimeout(S.staleTimer); S.staleTimer=setTimeout(()=>{ setPanelVisible(false); clearHistory(); S.wsAwaitingBurst=true; }, STALE_TIMEOUT_MS); }
function clearHistory() { S.prevFilled=null; if (S.histRemaining) S.histRemaining.fill(0); if (S.histCtx) S.histCtx.clearRect(0,0,S.rawW,S.rawH); S.lastHistAt=0; }
function positionOverlay(dest,geo) {
installUi(); if (!S.panel||!dest||S.wsAwaitingBurst) return;
const r=dest.getBoundingClientRect(); const sx=r.width/Math.max(1,dest.width), sy=r.height/Math.max(1,dest.height);
const rL=r.left+geo.dx*sx, rT=r.top+geo.dy*sy; const rW=Math.abs(geo.dw*sx), rH=Math.abs(geo.dh*sy);
const W=clamp(rW,1,window.innerWidth), H=clamp(rH,1,window.innerHeight); const L=clamp(rL,0,Math.max(0,window.innerWidth-W)), T=clamp(rT,0,Math.max(0,window.innerHeight-H));
S.panel.style.display="block"; S.panel.style.left=L+"px"; S.panel.style.top=T+"px"; S.panel.style.width=W+"px"; S.panel.style.height=H+"px";
S.minimapCanvas=dest; S.minimapGeo={dx:geo.dx,dy:geo.dy,dw:geo.dw,dh:geo.dh}; if (performance.now()-S.markerAt>2000&&S.marker) S.marker.style.display="none";
}
function applyOpacity() { if (S.panel) S.panel.style.opacity=String(S.opacity); }
function setPanelVisible(on) { if (S.panel&&!on) S.panel.style.display="none"; if (!on) hideAllArrows(); }
function hideAllArrows() { Object.values(S.arrowEls).forEach(e=>{ if(e) e.wrapper.style.display="none"; }); }
function rescaleHistory(oldLT,newLT) { if (!S.histRemaining) return; if (newLT<=0) { S.histCtx.clearRect(0,0,S.rawW,S.rawH); S.histRemaining.fill(0); return; } if (oldLT>0) { const scale=newLT/oldLT; for(let i=0;i<S.histRemaining.length;i++) S.histRemaining[i]=clamp(S.histRemaining[i]*scale,0,newLT); } renderHistory(); }
// ═══════════════════════════════════════════════════════════════════════
// PIN & ARROW LOGIC
// ═══════════════════════════════════════════════════════════════════════
function loadPins() { try { const raw=localStorage.getItem(SK.PINS); if(!raw) return; const arr=JSON.parse(raw); if(!Array.isArray(arr)) return; pins=arr.map(p=>({id:++pinIdCounter,x:+p.x,y:+p.y,hue:typeof p.hue==="number"?p.hue:0,visible:p.visible !== false})); } catch(_) {} }
function savePins() { try { localStorage.setItem(SK.PINS,JSON.stringify(pins.map(p=>({x:p.x,y:p.y,hue:p.hue,visible:p.visible})))); } catch(_) {} }
loadPins();
function createPin(nx,ny) { const pin={id:++pinIdCounter,x:nx,y:ny,hue:0,visible:true}; pins.push(pin); savePins(); buildPinEl(pin); buildArrowEl(pin); updateAllArrows(); openPinPopup(pin); }
function buildPinEl(pin) {
if (!S.panel) return; const el=document.createElement("div"); el.className="tm-pin"+(pin.visible?"":" tm-pin-hidden");
el.style.left=(pin.x*100)+"%"; el.style.top=(pin.y*100)+"%"; el.style.background=hslStr(pin.hue);
el.addEventListener("click",e=>{e.stopPropagation();pin.visible=!pin.visible;el.classList.toggle("tm-pin-hidden",!pin.visible);savePins();updateArrowVisibility(pin);});
el.addEventListener("contextmenu",e=>{e.preventDefault();e.stopPropagation();openPinPopup(pin);});
S.panel.appendChild(el); S.pinEls[pin.id]=el;
}
function openPinPopup(pin) {
closePinPopup(); const popup=document.createElement("div"); popup.className="tm-popup";
const title=document.createElement("div"); title.className="tm-popup-title"; title.textContent="Pin color";
const swatch=document.createElement("div"); swatch.className="tm-popup-swatch"; swatch.style.background=hslStr(pin.hue);
const slider=document.createElement("input"); slider.type="range"; slider.min="0"; slider.max="359"; slider.step="1"; slider.value=String(pin.hue); slider.className="tm-hue-slider"; slider.style.color=hslStr(pin.hue);
slider.addEventListener("input",()=>{pin.hue=Number(slider.value);const c=hslStr(pin.hue);swatch.style.background=c;slider.style.color=c;const pe=S.pinEls[pin.id];if(pe)pe.style.background=c;redrawArrow(pin);savePins();});
const btnRow=document.createElement("div"); btnRow.className="tm-popup-btn-row";
const delBtn=document.createElement("button"); delBtn.className="tm-popup-del"; delBtn.textContent="Remove pin"; delBtn.addEventListener("click",e=>{e.stopPropagation();deletePin(pin.id);});
const delAll=document.createElement("button"); delAll.className="tm-popup-del"; delAll.textContent="Remove all"; delAll.addEventListener("click",e=>{e.stopPropagation();deleteAllPins();});
btnRow.append(delBtn,delAll); popup.append(title,swatch,slider,btnRow);
document.body.appendChild(popup); positionPopup(popup,pin); S.activePinPopup={pinId:pin.id,el:popup};
}
function positionPopup(popupEl,pin) {
if (!S.panel) return; const pr=S.panel.getBoundingClientRect(), px=pr.left+pin.x*pr.width, py=pr.top+pin.y*pr.height, ew=180, eh=130;
const left=clamp(px-ew/2,6,window.innerWidth-ew-6), top=clamp(py-eh-20,6,window.innerHeight-eh-6);
popupEl.style.left=left+"px"; popupEl.style.top=top+"px";
}
function closePinPopup() { if (!S.activePinPopup) return; S.activePinPopup.el.remove(); S.activePinPopup=null; }
function deletePin(id) { pins=pins.filter(p=>p.id!==id); savePins(); const el=S.pinEls[id]; if(el){el.remove();delete S.pinEls[id];} const aw=S.arrowEls[id]; if(aw){aw.wrapper.remove();delete S.arrowEls[id];} if (S.activePinPopup&&S.activePinPopup.pinId===id) closePinPopup(); }
function deleteAllPins() { pins=[]; savePins(); Object.values(S.pinEls).forEach(el=>{if(el)el.remove();}); Object.values(S.arrowEls).forEach(ae=>{if(ae&&ae.wrapper)ae.wrapper.remove();}); S.pinEls={}; S.arrowEls={}; closePinPopup(); }
function buildArrowEl(pin) { const wrapper=document.createElement("div"); wrapper.className="tm-arrow"; wrapper.style.width=ARROW_SIZE+"px"; wrapper.style.height=ARROW_SIZE+"px"; const canvas=document.createElement("canvas"); canvas.width=canvas.height=ARROW_SIZE; wrapper.appendChild(canvas); document.body.appendChild(wrapper); S.arrowEls[pin.id]={wrapper,canvas}; }
function updateArrowVisibility(pin) { const e=S.arrowEls[pin.id]; if(!e) return; if(!pin.visible){e.wrapper.style.display="none";return;} updateArrow(pin); }
function redrawArrow(pin) { const e=S.arrowEls[pin.id]; if(!e||!pin.visible) return; updateArrow(pin); }
function updateAllArrows() { pins.forEach(p=>updateArrow(p)); }
function updateArrow(pin) {
const e=S.arrowEls[pin.id]; if(!e) return;
if(!pin.visible||S.playerX===null||S.playerY===null){e.wrapper.style.display="none";return;}
const dx=pin.x-S.playerX, dy=pin.y-S.playerY, dist=Math.sqrt(dx*dx+dy*dy);
if(dist<PIN_CLOSE_THRESH){e.wrapper.style.display="none";return;}
const angle=Math.atan2(dy,dx), orbitR=Math.min(window.innerWidth,window.innerHeight)*ARROW_ORBIT_FRAC;
const ax=window.innerWidth/2+Math.cos(angle)*orbitR-ARROW_SIZE/2, ay=window.innerHeight/2+Math.sin(angle)*orbitR-ARROW_SIZE/2;
e.wrapper.style.display="block"; e.wrapper.style.left=ax+"px"; e.wrapper.style.top=ay+"px"; paintArrow(e.canvas,angle,pin.hue);
}
function paintArrow(canvas,angle,hue) {
const ctx=canvas.getContext("2d"), sz=ARROW_SIZE, cx=sz/2, cy=sz/2; ctx.clearRect(0,0,sz,sz);
const [r,g,b]=hueToRgb(hue), bright=`rgb(${r},${g},${b})`, dim=`rgba(${r},${g},${b},0.28)`, glow=`rgba(${r},${g},${b},0.55)`;
ctx.save(); ctx.translate(cx,cy); ctx.rotate(angle+Math.PI/2); const tip=-sz*0.33, bk=sz*0.27, hw=sz*0.19, notch=sz*0.08;
ctx.shadowColor=glow; ctx.shadowBlur=10; ctx.beginPath();
ctx.moveTo(0,tip); ctx.lineTo(hw,bk); ctx.lineTo(notch,bk-notch*1.5); ctx.lineTo(-notch,bk-notch*1.5); ctx.lineTo(-hw,bk); ctx.closePath();
const grad=ctx.createLinearGradient(0,tip,0,bk); grad.addColorStop(0,bright); grad.addColorStop(1,dim); ctx.fillStyle=grad; ctx.fill();
ctx.shadowBlur=0; ctx.strokeStyle="rgba(0,0,0,0.42)"; ctx.lineWidth=1.4; ctx.stroke();
ctx.beginPath(); ctx.moveTo(-hw*0.08,tip+sz*0.07); ctx.lineTo(-hw*0.48,bk*0.35); ctx.lineTo(hw*0.08,bk*0.1); ctx.closePath(); ctx.fillStyle="rgba(255,255,255,0.22)"; ctx.fill();
ctx.restore();
}
// ═══════════════════════════════════════════════════════════════════════
// STATS LOGIC
// ═══════════════════════════════════════════════════════════════════════
function updateDeathStats() {
if (!deathStatsEl) return;
const kills = getKills();
const timeMs = globalContext.stats.timeAlive;
const kpm = timeMs > 0 ? (60 * kills / (timeMs / 1000)).toFixed(2) : "0.00";
const endTime = globalContext.connection.lastDeath > 0
? new Date(globalContext.connection.lastDeath).toLocaleTimeString([], {hour:"2-digit",minute:"2-digit",second:"2-digit"})
: "—";
deathStatsEl.innerHTML = `
<p class="tm-ds-title">Session stats</p>
<div class="tm-ds-row"><span>Time alive</span><span>${globalContext.num2time(timeMs)}</span></div>
<div class="tm-ds-row"><span>Kills</span><span>${kills}</span></div>
<div class="tm-ds-row"><span>Kills / min</span><span>${kpm}</span></div>
<div class="tm-ds-row"><span>Ended at</span><span>${endTime}</span></div>
`;
}
function statsLoop() {
if (statsBarEl && statsBarVisible) {
const timeEl = statsBarEl.querySelector(".tm-stat-time");
const rateEl = statsBarEl.querySelector(".tm-stat-rate");
if (timeEl) timeEl.textContent = globalContext.num2time(globalContext.stats.timeAlive);
if (rateEl) {
const prop = speedProps[speedIndex], label = speedLabels[prop], val = globalContext.stats[prop];
rateEl.textContent = `${label}: ${typeof val === "number" ? val.toFixed(2) : "0.00"}`;
}
}
requestAnimationFrame(statsLoop);
}
// ═══════════════════════════════════════════════════════════════════════
// CHAT LOGIC (NEW: Heartbeats, Joins/Leaves, Timestamps)
// ═══════════════════════════════════════════════════════════════════════
function broadcastNetwork(type, text = "") {
const username = getChatName();
const payload = { username, type, text };
fetch(`https://ntfy.sh/${GLOBAL_TOPIC}`, {
method: "POST", body: JSON.stringify(payload)
}).catch(() => {});
}
function handleUserSeen(username, isHistorical = false) {
if (!username) return;
const now = Date.now();
const myName = getChatName();
// If it's live, not us, and we haven't seen them recently -> announce Join
if (!isHistorical && !onlinePlayers.has(username) && username !== myName) {
addSystemMessage(username, "has joined.");
// If a new person arrived, politely whisper back our heartbeat shortly after
// so they know we are here (staggered so all 10 clients don't flood instantly).
setTimeout(() => broadcastNetwork("heartbeat"), Math.random() * 2500);
}
onlinePlayers.set(username, now);
refreshPlayersTabIfVisible();
}
function handleUserLeft(username) {
if (!username || !onlinePlayers.has(username)) return;
onlinePlayers.delete(username);
if (username !== getChatName()) {
addSystemMessage(username, "has left.");
}
refreshPlayersTabIfVisible();
}
function loadHistory() {
fetch(`https://ntfy.sh/${GLOBAL_TOPIC}/json?poll=1`)
.then(r => r.text())
.then(text => {
text.trim().split("\n").forEach(line => {
if (!line) return;
try {
const parsed = JSON.parse(line);
const payload = JSON.parse(parsed.message);
if (!payload.username) return;
const seenAt = parsed.time ? parsed.time * 1000 : Date.now();
// We don't want to parse history presence/joins/leaves to avoid a spam wall
if ((payload.type === "chat" || !payload.type) && payload.text) {
addMessageToChat(payload.username, payload.text, true, seenAt);
}
} catch(_) {}
});
}).catch(e => console.error("[TM Chat] History:", e));
}
function connectLiveStream() {
if (eventSource) eventSource.close();
try {
eventSource = new EventSource(`https://ntfy.sh/${GLOBAL_TOPIC}/sse`);
eventSource.onmessage = function(event) {
try {
const raw = JSON.parse(event.data);
const payload = JSON.parse(raw.message);
if (!payload.username) return;
if (payload.type === "join") {
handleUserSeen(payload.username, false);
} else if (payload.type === "leave") {
handleUserLeft(payload.username);
} else if (payload.type === "heartbeat" || payload.type === "presence") {
handleUserSeen(payload.username, false);
} else if (payload.type === "chat" || (!payload.type && payload.text)) {
handleUserSeen(payload.username, false);
addMessageToChat(payload.username, payload.text, false, Date.now());
}
} catch(_) {}
};
eventSource.onerror = () => setTimeout(connectLiveStream, 5000);
} catch(e) { console.error("[TM Chat] SSE:", e); }
}
function sendMessage(text) {
broadcastNetwork("chat", text);
}
function startPresence() {
// Guarantee we show up locally instantly
onlinePlayers.set(getChatName(), Date.now());
// Introduce ourselves to the lobby
broadcastNetwork("join");
// Setup continuous ping
if (heartbeatTimer) clearInterval(heartbeatTimer);
heartbeatTimer = setInterval(() => broadcastNetwork("heartbeat"), HEARTBEAT_INTERVAL);
// Setup local timeout sweeper (who left?)
if (timeoutSweepTimer) clearInterval(timeoutSweepTimer);
timeoutSweepTimer = setInterval(() => {
const now = Date.now();
for (const [user, lastSeen] of onlinePlayers.entries()) {
if (now - lastSeen > ONLINE_TIMEOUT_MS) {
handleUserLeft(user);
}
}
}, PRUNE_INTERVAL);
// Attempt instantaneous disconnect notify when closing tab
window.addEventListener("beforeunload", () => {
const p = { username: getChatName(), type: "leave" };
navigator.sendBeacon(`https://ntfy.sh/${GLOBAL_TOPIC}`, JSON.stringify(p));
});
}
function addMessageToChat(senderName, text, isHistorical, timestampMs = Date.now()) {
const log = document.getElementById("tm-chat-log");
if (!log) return;
const msgEl = document.createElement("div");
msgEl.className = "tm-msg" + (isHistorical ? " tm-msg-hist" : "");
const timeSpan = document.createElement("span");
timeSpan.className = "tm-msg-time";
timeSpan.textContent = `[${formatTime(timestampMs)}]`;
const nameSpan = document.createElement("span");
nameSpan.className = "tm-msg-name";
nameSpan.style.color = senderName === getChatName() ? "#4fc3f7" : "#81c784";
nameSpan.textContent = senderName;
const sep = document.createElement("span");
sep.className = "tm-msg-sep";
sep.textContent = ": ";
const textSpan = document.createElement("span");
textSpan.className = "tm-msg-text";
textSpan.textContent = text;
msgEl.append(timeSpan, nameSpan, sep, textSpan);
log.appendChild(msgEl);
log.scrollTop = log.scrollHeight;
}
function addSystemMessage(username, actionText) {
const log = document.getElementById("tm-chat-log");
if (!log) return;
const msgEl = document.createElement("div");
msgEl.className = "tm-msg tm-msg-sys";
const timeSpan = document.createElement("span");
timeSpan.className = "tm-msg-time";
timeSpan.textContent = `[${formatTime(Date.now())}]`;
const textSpan = document.createElement("span");
textSpan.textContent = `${username} ${actionText}`;
msgEl.append(timeSpan, textSpan);
log.appendChild(msgEl);
log.scrollTop = log.scrollHeight;
}
function renderPlayersTab() {
const list = document.getElementById("tm-players-list");
if (!list) return;
const myName = getChatName();
// Self-correction for map
onlinePlayers.set(myName, Date.now());
const players = [...onlinePlayers.keys()].sort((a, b) => {
if (a === myName) return -1;
if (b === myName) return 1;
return a.localeCompare(b);
});
list.innerHTML = "";
const header = document.createElement("div");
header.className = "tm-players-header";
header.textContent = players.length === 1 ? "1 player online" : `${players.length} players online`;
list.appendChild(header);
players.forEach(username => {
const isMe = username === myName;
const row = document.createElement("div");
row.className = "tm-player-row";
const dot = document.createElement("span");
dot.className = "tm-player-dot";
const name = document.createElement("span");
name.className = "tm-player-name" + (isMe ? " tm-player-name-self" : "");
name.textContent = escHtml(username) + (isMe ? " (you)" : "");
row.append(dot, name);
list.appendChild(row);
});
}
function refreshPlayersTabIfVisible() {
const list = document.getElementById("tm-players-list");
// Important fix: check for block instead of !== none
if (list && list.style.display === "block") renderPlayersTab();
}
function toggleChat() {
chatVisible = !chatVisible;
localStorage.setItem(SK.CHAT_VISIBLE, String(chatVisible));
const c = document.getElementById("tm-chat-container");
if (c) c.style.display = chatVisible ? "flex" : "none";
}
function toggleKeys(forceState) {
if (typeof forceState === "boolean") S.keysVisible = forceState; else S.keysVisible = !S.keysVisible;
localStorage.setItem(SK.KEYS_VISIBLE, String(S.keysVisible));
const container = document.getElementById("tileman-key-display-container");
if (container) container.style.setProperty("display", S.keysVisible ? "grid" : "none", "important");
const chk = document.getElementById("tm-s-showkeys");
if (chk) chk.checked = S.keysVisible;
}
// ═══════════════════════════════════════════════════════════════════════
// DRAG & RESIZE ENGINE
// ═══════════════════════════════════════════════════════════════════════
function saveGeo(el, key) {
if (!key) return;
const geo = { left: el.offsetLeft, top: el.offsetTop, width: el.offsetWidth, height: el.offsetHeight };
try { localStorage.setItem(key, JSON.stringify(geo)); } catch(_) {}
}
function makeDraggable(el, handle, storageKey) {
let x1=0, y1=0, x2=0, y2=0;
handle.onmousedown = function(e) {
if (e.target.tagName === "BUTTON" || e.target.tagName === "INPUT" || e.target.tagName === "SELECT") return;
e.preventDefault(); x2 = e.clientX; y2 = e.clientY;
document.onmouseup = () => { document.onmouseup = null; document.onmousemove = null; saveGeo(el, storageKey); };
document.onmousemove = function(e) {
e.preventDefault(); x1 = x2 - e.clientX; y1 = y2 - e.clientY; x2 = e.clientX; y2 = e.clientY;
el.style.top = (el.offsetTop - y1) + "px"; el.style.left = (el.offsetLeft - x1) + "px"; el.style.bottom = "auto"; el.style.right = "auto";
};
};
}
function makeResizable(el, handle, storageKey, minW, minH) {
let xStart=0, yStart=0, wStart=0, hStart=0;
handle.onmousedown = function(e) {
e.preventDefault(); e.stopPropagation(); xStart = e.clientX; yStart = e.clientY; wStart = el.offsetWidth; hStart = el.offsetHeight;
document.onmouseup = () => { document.onmouseup = null; document.onmousemove = null; saveGeo(el, storageKey); };
document.onmousemove = function(e) {
e.preventDefault();
el.style.width = Math.max(minW, wStart + (e.clientX - xStart)) + "px";
el.style.height = Math.max(minH, hStart + (e.clientY - yStart)) + "px";
};
};
}
// ═══════════════════════════════════════════════════════════════════════
// SETTINGS WINDOW
// ═══════════════════════════════════════════════════════════════════════
function openSettings() {
S.settingsOpen = true; const w = document.getElementById("tm-settings-win"); if (!w) return;
w.style.display = "flex";
const savedGeo = JSON.parse(localStorage.getItem(SK.SETTINGS_GEO) || "null");
if (savedGeo) {
w.style.left = savedGeo.left + "px"; w.style.top = savedGeo.top + "px"; w.style.width = savedGeo.width + "px"; w.style.height = savedGeo.height + "px"; w.style.bottom = "auto"; w.style.right = "auto";
} else {
const sw = 320, sh = Math.min(440, window.innerHeight - 16); const chat = document.getElementById("tm-chat-container"); let left, top;
if (chat) {
const r = chat.getBoundingClientRect(); left = r.right + 10; top = r.bottom - sh;
if (left + sw > window.innerWidth) left = r.left - sw - 10;
if (left < 4) left = clamp(r.left, 4, Math.max(4, window.innerWidth - sw - 4));
top = clamp(top, 4, Math.max(4, window.innerHeight - sh - 4));
} else { left = window.innerWidth - sw - 24; top = 24; }
w.style.left = left + "px"; w.style.top = top + "px"; w.style.width = sw + "px"; w.style.height = sh + "px";
}
syncSettingsForm();
}
function closeSettings() { S.settingsOpen = false; const w = document.getElementById("tm-settings-win"); if (w) w.style.display = "none"; if (S.gearBtn) S.gearBtn.classList.remove("tm-gear-active"); }
function toggleSettings() { S.settingsOpen ? closeSettings() : openSettings(); if (S.gearBtn) S.gearBtn.classList.toggle("tm-gear-active", S.settingsOpen); }
function syncSettingsForm() {
const opIn = document.getElementById("tm-s-opacity"), opOut = document.getElementById("tm-s-opacity-out"), secIn = document.getElementById("tm-s-seconds"), secOut = document.getElementById("tm-s-seconds-out"), themeEl = document.getElementById("tm-s-theme"), nameEl = document.getElementById("tm-s-chatname"), chkKeys = document.getElementById("tm-s-showkeys");
if (opIn && opOut) { opIn.value = String(S.opacity); opOut.value = pct(S.opacity); }
if (secIn && secOut) { secIn.value = String(S.seconds); secOut.value = secs(S.seconds); }
if (themeEl) themeEl.value = S.theme; if (nameEl) nameEl.value = localStorage.getItem(SK.CHAT_NAME) || ""; if (chkKeys) chkKeys.checked = S.keysVisible;
Object.keys(KB).forEach(action => { const el = document.getElementById("tm-kb-" + action); if (el) el.textContent = keyLabel(KB[action]); });
}
function buildSettingsWindow() {
const win = document.createElement("div"); win.id = "tm-settings-win";
win.innerHTML = `
<div id="tm-sw-drag"><span id="tm-sw-title">Settings</span><button id="tm-sw-close" title="Close">×</button></div>
<div id="tm-sw-tabs"><button class="tm-sw-tab active" data-tab="minimap">Minimap</button><button class="tm-sw-tab" data-tab="chat">Chat</button><button class="tm-sw-tab" data-tab="stats">Stats</button><button class="tm-sw-tab" data-tab="keys">Keys</button></div>
<div id="tm-sw-body">
<div class="tm-sw-pane active" id="tm-swp-minimap">
<div class="tm-sw-field-row"><label>Opacity</label><input type="range" id="tm-s-opacity" min="0" max="1" step="0.01"><input type="text" id="tm-s-opacity-out" class="tm-sw-small-in"></div>
<div class="tm-sw-field-row"><label>Trail time</label><input type="range" id="tm-s-seconds" min="0" max="3600" step="1"><input type="text" id="tm-s-seconds-out" class="tm-sw-small-in"></div>
<div class="tm-sw-field-row tm-sw-field-row--select"><label>Theme</label><select id="tm-s-theme">${["Rainbow","Grayscale","Red","Orange","Yellow","Green","Blue","Purple"].map(t=>`<option value="${t}">${t}</option>`).join("")}</select></div>
</div>
<div class="tm-sw-pane" id="tm-swp-chat">
<div class="tm-sw-field-row tm-sw-field-row--full"><label>Chat name</label><input type="text" id="tm-s-chatname" placeholder="Uses in-game name if blank" maxlength="24"></div>
<p class="tm-sw-hint">Leave blank to use your current in-game nickname.</p>
</div>
<div class="tm-sw-pane" id="tm-swp-stats">
<div class="tm-sw-field-row tm-sw-field-row--full" style="grid-template-columns: 120px 1fr; margin-bottom: 12px;"><label>Show key display</label><input type="checkbox" id="tm-s-showkeys" style="width: auto; justify-self: start; cursor: pointer; accent-color: #2a7dff;"></div>
<p class="tm-sw-hint">The stats bar is injected into the game's HUD.<br>Use cycle key to switch metrics.<br>Use toggle key to show or hide it.<br>Check box above for key overlay.</p>
</div>
<div class="tm-sw-pane" id="tm-swp-keys">
<div class="tm-kb-grid"><span class="tm-kb-label">Toggle stats</span><button class="tm-kb-btn" id="tm-kb-toggleStats" data-action="toggleStats"></button><span class="tm-kb-label">Cycle stat</span><button class="tm-kb-btn" id="tm-kb-cycleRate" data-action="cycleRate"></button><span class="tm-kb-label">Toggle chat</span><button class="tm-kb-btn" id="tm-kb-toggleChat" data-action="toggleChat"></button><span class="tm-kb-label">Toggle keys</span><button class="tm-kb-btn" id="tm-kb-toggleKeys" data-action="toggleKeys"></button></div>
<button id="tm-kb-reset">Reset to defaults</button>
</div>
</div><div class="tm-resize-handle"></div>`;
document.body.appendChild(win);
const resizeHandle = win.querySelector(".tm-resize-handle"); makeDraggable(win, win.querySelector("#tm-sw-drag"), SK.SETTINGS_GEO); makeResizable(win, resizeHandle, SK.SETTINGS_GEO, 260, 200);
win.querySelectorAll(".tm-sw-tab").forEach(tab => { tab.addEventListener("click", () => { win.querySelectorAll(".tm-sw-tab").forEach(t => t.classList.remove("active")); win.querySelectorAll(".tm-sw-pane").forEach(p => p.classList.remove("active")); tab.classList.add("active"); const pane = win.querySelector(`#tm-swp-${tab.dataset.tab}`); if (pane) pane.classList.add("active"); }); });
win.querySelector("#tm-sw-close").addEventListener("click", () => closeSettings());
const opIn = win.querySelector("#tm-s-opacity"), opOut = win.querySelector("#tm-s-opacity-out"); opIn.addEventListener("input", () => { S.opacity = Number(opIn.value); applyOpacity(); opOut.value = pct(S.opacity); localStorage.setItem(SK.OPACITY, String(S.opacity)); }); opOut.addEventListener("change", () => { const p = parseOpacity(opOut.value); if (p !== null) { S.opacity = p; opIn.value = String(p); applyOpacity(); localStorage.setItem(SK.OPACITY, String(S.opacity)); } opOut.value = pct(S.opacity); }); opOut.addEventListener("keydown", e => { if(e.key==="Enter") opOut.blur(); });
const secIn = win.querySelector("#tm-s-seconds"), secOut = win.querySelector("#tm-s-seconds-out"); secIn.addEventListener("input", () => { const oldLT = lifetimeMs(); S.seconds = Number(secIn.value); secOut.value = secs(S.seconds); localStorage.setItem(SK.SECONDS, String(S.seconds)); rescaleHistory(oldLT, lifetimeMs()); }); secOut.addEventListener("change", () => { const p = parseSeconds(secOut.value); if (p !== null) { const oldLT = lifetimeMs(); S.seconds = p; secIn.value = String(p); localStorage.setItem(SK.SECONDS, String(S.seconds)); rescaleHistory(oldLT, lifetimeMs()); } secOut.value = secs(S.seconds); }); secOut.addEventListener("keydown", e => { if(e.key==="Enter") secOut.blur(); });
const themeEl = win.querySelector("#tm-s-theme"); themeEl.addEventListener("change", () => { S.theme = themeEl.value; localStorage.setItem(SK.THEME, S.theme); renderHistory(); });
const nameEl = win.querySelector("#tm-s-chatname"); nameEl.addEventListener("input", () => { localStorage.setItem(SK.CHAT_NAME, nameEl.value.trim()); });
const chkKeys = win.querySelector("#tm-s-showkeys"); if (chkKeys) chkKeys.addEventListener("change", () => toggleKeys(chkKeys.checked));
let listeningBtn = null; win.querySelectorAll(".tm-kb-btn").forEach(btn => { btn.addEventListener("click", () => { if (listeningBtn) listeningBtn.classList.remove("tm-kb-listening"); if (listeningBtn === btn) { listeningBtn = null; syncSettingsForm(); return; } listeningBtn = btn; btn.classList.add("tm-kb-listening"); btn.textContent = "…"; }); });
document.addEventListener("keydown", e => { if (!listeningBtn) return; e.preventDefault(); e.stopPropagation(); const action = listeningBtn.dataset.action; KB[action] = e.code; saveKB(); listeningBtn.textContent = keyLabel(e.code); listeningBtn.classList.remove("tm-kb-listening"); listeningBtn = null; }, true);
win.querySelector("#tm-kb-reset").addEventListener("click", () => { KB = { ...DEFAULT_KB }; saveKB(); if (listeningBtn) { listeningBtn.classList.remove("tm-kb-listening"); listeningBtn = null; } syncSettingsForm(); });
S.settingsWin = win; syncSettingsForm();
}
// ═══════════════════════════════════════════════════════════════════════
// UI BUILDERS
// ═══════════════════════════════════════════════════════════════════════
function installUi() {
if (S.panel || !document.body) return;
const style = document.createElement("style");
style.textContent = `
/* ─── Minimap Panel ─────────────────────────────────────────────────── */
#tm-panel { position:fixed; z-index:2147483647; display:none; box-sizing:border-box; background:transparent; overflow:visible; user-select:none; pointer-events:auto; }
#tm-stage { position:absolute; inset:0; overflow:hidden; background:#000; pointer-events:none; }
#tm-map, #tm-history { position:absolute; inset:0; width:100%; height:100%; display:block; image-rendering:pixelated; image-rendering:crisp-edges; pointer-events:none; }
#tm-marker { position:absolute; width:9px; height:9px; border:2px solid #050505; border-radius:50%; background:rgba(255,255,255,.45); box-sizing:border-box; box-shadow:0 0 0 1px rgba(255,255,255,.82),0 0 7px rgba(255,255,255,.56); transform:translate(-50%,-50%); pointer-events:none; display:none; z-index:4; }
#tm-marker::after { content:""; position:absolute; left:50%; top:50%; width:2px; height:2px; border-radius:50%; background:#111; transform:translate(-50%,-50%); }
#tm-gear-btn { flex-shrink: 0; margin-left: auto; align-self: center; width: 21px; height: 21px; border-radius: 50%; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.14); color: rgba(255,255,255,.55); font-size: 12px; line-height: 19px; text-align: center; cursor: pointer; user-select: none; padding: 0; margin-top: 3px; margin-bottom: 3px; margin-right: 2px; transition: background .14s ease, border-color .14s ease, color .14s ease, transform .18s ease; }
#tm-gear-btn:hover { background: rgba(255,255,255,.13); color: #fff; transform: rotate(28deg); }
#tm-gear-btn.tm-gear-active { background: rgba(42,125,255,.28); border-color: rgba(42,125,255,.7); color: #fff; }
/* ─── Settings Window ────────────────────────────────────────────────── */
#tm-settings-win { position: fixed; z-index: 2147483646; display: none; flex-direction: column; width: 300px; background: #0b0c10; border: 1px solid rgba(255,255,255,.11); border-radius: 10px; box-shadow: 0 24px 60px rgba(0,0,0,.75); font: 12px/1.4 "Segoe UI", system-ui, Arial, sans-serif; color: #e4e6f0; user-select: none; overflow: hidden; box-sizing: border-box; }
#tm-sw-drag { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px 0; cursor: move; }
#tm-sw-title { font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: rgba(255,255,255,.35); }
#tm-sw-close { background: none; border: none; color: rgba(255,255,255,.35); font-size: 18px; line-height: 1; cursor: pointer; padding: 0 0 1px 6px; transition: color .12s; }
#tm-sw-close:hover { color: rgba(255,255,255,.8); }
#tm-sw-tabs { display: flex; padding: 8px 8px 0; gap: 2px; }
.tm-sw-tab { flex: 1; background: none; border: none; border-bottom: 2px solid transparent; color: rgba(255,255,255,.36); font: 10.5px/1 "Segoe UI", Arial, sans-serif; padding: 6px 4px 5px; cursor: pointer; letter-spacing: .04em; text-transform: uppercase; transition: color .12s, border-color .12s; }
.tm-sw-tab:hover { color: rgba(255,255,255,.65); }
.tm-sw-tab.active { color: #e4e6f0; border-bottom-color: #2a7dff; }
#tm-sw-body { padding: 12px 14px 16px; overflow-y: auto; max-height: 380px; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.1) transparent; }
.tm-sw-pane { display: none; } .tm-sw-pane.active { display: block; }
.tm-sw-hint { font-size: 10.5px; color: rgba(255,255,255,.4); line-height: 1.55; margin: 0; }
.tm-sw-field-row { display: grid; grid-template-columns: 80px 1fr 52px; align-items: center; gap: 8px; margin-bottom: 10px; }
.tm-sw-field-row--select { grid-template-columns: 80px 1fr; }
.tm-sw-field-row--full { grid-template-columns: 120px 1fr; }
.tm-sw-field-row label { font-size: 11px; color: rgba(255,255,255,.6); }
.tm-sw-field-row input[type=range] { width: 100%; accent-color: #2a7dff; cursor: pointer; }
.tm-sw-small-in { width: 100%; background: #161820; color: #eee; border: 1px solid rgba(255,255,255,.13); border-radius: 4px; padding: 3px 5px; font: 10px "Courier New", monospace; box-sizing: border-box; outline: none; text-align: right; }
.tm-sw-small-in:focus { border-color: rgba(42,125,255,.55); }
.tm-sw-field-row select { background: #161820; color: #eee; border: 1px solid rgba(255,255,255,.13); border-radius: 4px; padding: 4px 6px; font: 11px Arial; outline: none; cursor: pointer; width: 100%; }
.tm-kb-grid { display: grid; grid-template-columns: 1fr auto; gap: 7px 10px; align-items: center; margin-bottom: 12px; }
.tm-kb-label { font-size: 11px; color: rgba(255,255,255,.6); }
.tm-kb-btn { background: #161820; border: 1px solid rgba(255,255,255,.16); border-radius: 5px; color: #dde; font: 11px/1 "Courier New", monospace; padding: 5px 10px; min-width: 44px; text-align: center; cursor: pointer; transition: border-color .12s, background .12s; }
.tm-kb-btn:hover { border-color: rgba(42,125,255,.5); background: #1c1f2a; }
.tm-kb-listening { border-color: #2a7dff !important; background: rgba(42,125,255,.15) !important; color: #7fb3ff !important; animation: tmPulse .7s ease-in-out infinite; }
@keyframes tmPulse { 0%,100% { opacity:1; } 50% { opacity:.5; } }
#tm-kb-reset { width: 100%; background: rgba(255,55,55,.1); border: 1px solid rgba(255,80,80,.22); color: rgba(255,120,120,.85); border-radius: 5px; padding: 5px; cursor: pointer; font: 11px Arial; transition: background .12s; }
#tm-kb-reset:hover { background: rgba(255,55,55,.22); }
.tm-resize-handle { position: absolute; right: 0; bottom: 0; width: 15px; height: 14px; cursor: se-resize; z-index: 1000000; box-sizing: border-box; background: linear-gradient(135deg, transparent 65%, rgba(255,255,255,0.18) 65%, rgba(255,255,255,0.18) 75%, transparent 75%, transparent 80%, rgba(255,255,255,0.18) 80%); }
/* ─── Pins & Popups ────────────────────────────────────────────────────── */
.tm-pin { position:absolute; z-index:5; width:13px; height:13px; border-radius:50% 50% 50% 0; border:2px solid rgba(0,0,0,.5); box-shadow:0 2px 6px rgba(0,0,0,.6); transform:translate(-50%,-100%) rotate(-45deg); cursor:pointer; pointer-events:auto; transition:opacity .18s,box-shadow .18s; }
.tm-pin:hover { box-shadow:0 0 0 3px rgba(255,255,255,.2),0 2px 10px rgba(0,0,0,.7); }
.tm-pin.tm-pin-hidden { opacity:.38; }
.tm-popup { position:fixed; z-index:2147483648; background:rgba(8,9,12,.93); color:rgba(255,255,255,.78); border:1px solid rgba(255,255,255,.16); border-radius:9px; padding:10px 11px 11px; min-width:180px; box-shadow:0 12px 32px rgba(0,0,0,.55); backdrop-filter:blur(6px); font:11px/1.35 Arial,sans-serif; display:flex; flex-direction:column; gap:8px; pointer-events:auto; }
.tm-popup-title { font-size:10px; letter-spacing:.06em; text-transform:uppercase; color:rgba(255,255,255,.38); }
.tm-popup-swatch { width:100%; height:16px; border-radius:5px; border:1px solid rgba(255,255,255,.14); }
.tm-hue-slider { -webkit-appearance:none; appearance:none; width:100%; height:11px; border-radius:6px; border:none; outline:none; cursor:pointer; background:linear-gradient(to right,hsl(0,100%,54%),hsl(30,100%,54%),hsl(60,100%,54%),hsl(120,100%,54%),hsl(180,100%,54%),hsl(240,100%,54%),hsl(300,100%,54%),hsl(360,100%,54%)); }
.tm-hue-slider::-webkit-slider-thumb { -webkit-appearance:none; width:17px; height:17px; border-radius:50%; border:2.5px solid #fff; background:currentColor; box-shadow:0 1px 4px rgba(0,0,0,.5); }
.tm-popup-btn-row { display:flex; gap:6px; }
.tm-popup-btn-row button { flex:1; background:rgba(255,55,55,.15); border:1px solid rgba(255,85,85,.28); color:rgba(255,115,115,.9); border-radius:5px; padding:4px 0; cursor:pointer; font:11px Arial; transition:background .12s; }
.tm-popup-btn-row button:hover { background:rgba(255,55,55,.28); }
.tm-arrow { position:fixed; pointer-events:none; z-index:2147483646; }
/* ─── In-game Stats ──────────────────────────────────────────────────── */
#tm-stats-bar { display: flex; flex-direction: column; gap: 2px; padding: 5px 10px; margin: 3px 0 2px; background: rgba(0,0,0,.46); border-left: 3px solid rgba(42,125,255,.65); border-radius: 0 4px 4px 0; font-family: "Segoe UI", Arial, sans-serif; }
.tm-stat-time { color: #ffffff; font-size: 15px; font-weight: 700; letter-spacing: .03em; text-shadow: 0 1px 2px rgba(0,0,0,.95), 0 0 6px rgba(0,0,0,.65), 0 0 1px rgba(0,0,0,1); }
.tm-stat-rate { color: #bfe0ff; font-size: 12px; font-weight: 600; letter-spacing: .02em; cursor: default; text-shadow: 0 1px 2px rgba(0,0,0,.95), 0 0 5px rgba(0,0,0,.6); }
#after2 { overflow: visible !important; height: auto !important; max-height: none !important; scrollbar-width: none !important; }
#after2::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; }
#tm-death-stats { margin: 8px 0 0; padding: 9px 12px; background: rgba(18,20,28,.55); border: 1px solid rgba(255,255,255,.08); border-radius: 7px; font-family: "Segoe UI", Arial, sans-serif; backdrop-filter: blur(2px); }
.tm-ds-title { margin: 0 0 6px; font-size: 9.5px; letter-spacing: .12em; text-transform: uppercase; color: rgba(255,255,255,.32); text-align: left; }
.tm-ds-row { display: flex; justify-content: space-between; align-items: baseline; padding: 3px 0; border-bottom: 1px solid rgba(255,255,255,.05); font: 12px "Courier New", monospace; }
.tm-ds-row:last-child { border-bottom: none; }
.tm-ds-row span:first-child { font-size: 11px; color: rgba(255,255,255,.45); }
.tm-ds-row span:last-child { font-size: 12.5px; font-weight: 700; color: #ffffff; }
/* ─── Chat Window ─────────────────────────────────────────────────────── */
#tm-chat-container { position: fixed; bottom: 28px; left: 18px; width: 310px; height: 240px; display: flex; flex-direction: column; background: rgba(10,11,16,.92); border: 1px solid rgba(255,255,255,.09); border-radius: 9px; box-shadow: 0 8px 36px rgba(0,0,0,.7); font: 11px/1.4 "Segoe UI", Arial, sans-serif; color: #dde; z-index: 999990; overflow: hidden; backdrop-filter: blur(5px); user-select: none; box-sizing: border-box; }
#tm-chat-tab-bar { display: flex; align-items: stretch; flex-shrink: 0; background: rgba(255,255,255,.03); border-bottom: 1px solid rgba(255,255,255,.07); cursor: move; padding: 0 6px; }
.tm-chat-tab { background: none; border: none; border-bottom: 2px solid transparent; color: rgba(255,255,255,.35); font: 10px "Segoe UI", Arial, sans-serif; padding: 7px 9px 5px; cursor: pointer; letter-spacing: .05em; text-transform: uppercase; transition: color .13s, border-color .13s; flex-shrink: 0; }
.tm-chat-tab:hover { color: rgba(255,255,255,.62); }
.tm-chat-tab.active { color: rgba(255,255,255,.88); border-bottom-color: #2a7dff; }
#tm-chat-log, #tm-players-list { flex: 1; overflow-y: auto; padding: 8px 9px; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.08) transparent; }
#tm-chat-log { font: 11px/1.5 monospace; }
/* Note: Display none removed from tm-players-list here so JS toggles work flawlessly */
#tm-chat-log::-webkit-scrollbar, #tm-players-list::-webkit-scrollbar { width: 3px; }
#tm-chat-log::-webkit-scrollbar-thumb, #tm-players-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 2px; }
#tm-chat-footer { display: flex; gap: 5px; padding: 5px 7px; flex-shrink: 0; background: rgba(0,0,0,.18); border-top: 1px solid rgba(255,255,255,.06); }
#tm-chat-input { flex: 1; background: rgba(255,255,255,.07); color: #fff; border: 1px solid rgba(255,255,255,.09); border-radius: 5px; padding: 5px 8px; font: 11px Arial; outline: none; }
#tm-chat-input:focus { border-color: rgba(42,125,255,.5); background: rgba(255,255,255,.1); }
#tm-chat-send { background: #1a5cce; color: #fff; border: none; border-radius: 5px; padding: 5px 11px; cursor: pointer; font: bold 10px Arial; transition: background .13s; letter-spacing: .04em; }
#tm-chat-send:hover { background: #2a7dff; }
.tm-msg { margin-bottom: 2px; word-break: break-word; }
.tm-msg-hist { opacity: .55; }
.tm-msg-time { color: rgba(255,255,255,0.4); margin-right: 5px; font-size: 10px; }
.tm-msg-sys { color: rgba(255,255,255,0.6); font-style: italic; }
.tm-msg-name { font-weight: 700; }
.tm-msg-sep { color: rgba(255,255,255,.3); }
.tm-msg-text { color: rgba(255,255,255,.8); }
.tm-player-row { display:flex; align-items:center; gap:9px; padding:5px 5px; border-radius:4px; transition:background .1s; }
.tm-player-row:hover { background:rgba(255,255,255,.04); }
.tm-player-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; background:#4caf50; box-shadow:0 0 5px rgba(76,175,80,.6); }
.tm-player-name { font-size:12px; color:rgba(255,255,255,.85); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.tm-player-name-self { color:#4fc3f7; font-weight:600; }
.tm-players-header { font-size:9.5px; letter-spacing:.1em; text-transform:uppercase; color:rgba(255,255,255,.3); padding:2px 5px 9px; }
/* ─── Key Display ─────────────────────────────────────────────────────── */
#tileman-key-display-container { position: fixed; bottom: 8px; left: 50%; transform: translateX(-50%); z-index: 999999; display: grid !important; grid-template-columns: repeat(6, 40px) !important; grid-template-rows: 40px 40px !important; gap: 6px !important; font-family: system-ui, -apple-system, sans-serif !important; user-select: none !important; pointer-events: none !important; opacity: 0.8; }
.key-cap { width: 38px !important; height: 38px !important; background-color: rgba(20, 20, 20, 0.8) !important; border: 1px solid rgba(255, 255, 255, 0.15) !important; border-radius: 6px !important; color: rgba(255, 255, 255, 0.7) !important; display: flex !important; justify-content: center !important; align-items: center !important; font-size: 14px !important; font-weight: bold !important; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3) !important; transition: background-color 0.05s ease, transform 0.05s ease, color 0.05s ease !important; }
.tileman-grid-e { grid-column: 4 !important; grid-row: 1 !important; } .tileman-grid-up { grid-column: 5 !important; grid-row: 1 !important; } .tileman-grid-p { grid-column: 6 !important; grid-row: 1 !important; }
.tileman-grid-z { grid-column: 1 !important; grid-row: 2 !important; } .tileman-grid-space { grid-column: 2 !important; grid-row: 2 !important; font-size: 11px !important; } .tileman-grid-x { grid-column: 3 !important; grid-row: 2 !important; }
.tileman-grid-left { grid-column: 4 !important; grid-row: 2 !important; } .tileman-grid-down { grid-column: 5 !important; grid-row: 2 !important; } .tileman-grid-right { grid-column: 6 !important; grid-row: 2 !important; }
`;
document.head.appendChild(style);
const panel = document.createElement("div"); panel.id = "tm-panel";
const stage = document.createElement("div"); stage.id = "tm-stage";
const map = document.createElement("canvas"); map.id = "tm-map"; map.width = map.height = 1;
const htmlHist = document.createElement("canvas"); htmlHist.id= "tm-history"; htmlHist.width= htmlHist.height= 1;
const marker = document.createElement("div"); marker.id = "tm-marker";
stage.append(map, htmlHist, marker); panel.appendChild(stage);
document.body.append(panel);
S.panel = panel; S.stage = stage; S.map = map; S.mapCtx = map.getContext("2d", { alpha:true });
S.history = htmlHist; S.histCtx = htmlHist.getContext("2d", { alpha:true }); S.marker = marker;
S.mapCtx.imageSmoothingEnabled = false; S.histCtx.imageSmoothingEnabled = false; applyOpacity();
panel.addEventListener("contextmenu", e => { e.preventDefault(); const r = panel.getBoundingClientRect(); createPin(clamp((e.clientX-r.left)/r.width,0,1), clamp((e.clientY-r.top)/r.height,0,1)); });
document.addEventListener("mousedown", e => { if (S.activePinPopup && !S.activePinPopup.el.contains(e.target)) closePinPopup(); if (S.settingsOpen && S.settingsWin && !S.settingsWin.contains(e.target) && e.target !== S.gearBtn) closeSettings(); }, true);
buildSettingsWindow();
pins.forEach(p => { buildPinEl(p); buildArrowEl(p); });
}
function initChatUI() {
if (document.getElementById("tm-chat-container")) return;
const container = document.createElement("div"); container.id = "tm-chat-container";
container.innerHTML = `
<div id="tm-chat-tab-bar">
<button class="tm-chat-tab active" data-pane="log">Chat</button>
<button class="tm-chat-tab" data-pane="players">Players</button>
<button id="tm-gear-btn" title="Settings">⚙</button>
</div>
<div id="tm-chat-log"></div>
<div id="tm-players-list" style="display: none;"></div>
<div id="tm-chat-footer"><input id="tm-chat-input" type="text" placeholder="Message…" maxlength="200" autocomplete="off"><button id="tm-chat-send">SEND</button></div>
<div class="tm-resize-handle"></div>`;
container.style.display = chatVisible ? "flex" : "none";
const chatGeo = JSON.parse(localStorage.getItem(SK.CHAT_GEO) || "null");
if (chatGeo) { container.style.left = chatGeo.left + "px"; container.style.top = chatGeo.top + "px"; container.style.width = chatGeo.width + "px"; container.style.height = chatGeo.height + "px"; container.style.bottom = "auto"; }
document.body.appendChild(container);
const resizeHandle = container.querySelector(".tm-resize-handle"); makeDraggable(container, document.getElementById("tm-chat-tab-bar"), SK.CHAT_GEO); makeResizable(container, resizeHandle, SK.CHAT_GEO, 200, 150);
const footer = document.getElementById("tm-chat-footer");
container.querySelectorAll(".tm-chat-tab").forEach(tab => {
tab.addEventListener("click", () => {
container.querySelectorAll(".tm-chat-tab").forEach(t => t.classList.remove("active"));
tab.classList.add("active");
const log = document.getElementById("tm-chat-log"), pl = document.getElementById("tm-players-list");
if (tab.dataset.pane === "log") {
log.style.display = "block";
pl.style.display = "none";
footer.style.display = "flex";
} else {
log.style.display = "none";
pl.style.display = "block";
footer.style.display = "none";
renderPlayersTab();
}
});
});
const input = document.getElementById("tm-chat-input"), sendBtn = document.getElementById("tm-chat-send");
const doSend = () => { const t = input.value.trim(); if (t) { sendMessage(t); input.value = ""; } };
sendBtn.addEventListener("click", doSend);
input.addEventListener("keydown", e => { if (e.key === "Enter") { doSend(); e.stopPropagation(); } });
S.gearBtn = document.getElementById("tm-gear-btn"); S.gearBtn.addEventListener("click", e => { e.stopPropagation(); toggleSettings(); });
loadHistory();
connectLiveStream();
startPresence();
}
function initStatsUI() {
const lb = document.querySelector("#leftbottom"); if (!lb || statsBarEl) return;
statsBarEl = document.createElement("div"); statsBarEl.id = "tm-stats-bar";
statsBarEl.innerHTML = `<span class="tm-stat-time">00:00:00</span><span class="tm-stat-rate" title="Press R to cycle metric">KPM: 0.00</span>`;
statsBarEl.style.display = statsBarVisible ? "flex" : "none";
const blink = lb.querySelector("#blink_buttons"); if (blink) lb.insertBefore(statsBarEl, blink); else lb.prepend(statsBarEl);
}
function initDeathStatsUI() {
const after2 = document.querySelector("#after2"); if (!after2 || deathStatsEl) return;
deathStatsEl = document.createElement("div"); deathStatsEl.id = "tm-death-stats";
deathStatsEl.innerHTML = `<div class="tm-ds-row"><span>Time alive</span><span>—</span></div><div class="tm-ds-row"><span>Kills</span><span>—</span></div><div class="tm-ds-row"><span>Kills/min</span><span>—</span></div><div class="tm-ds-row"><span>Session ended</span><span>—</span></div>`;
const info2 = after2.querySelector("#info2"); if (info2) after2.insertBefore(deathStatsEl, info2); else after2.appendChild(deathStatsEl);
}
function initKeyDisplayUI() {
if (document.getElementById('tileman-key-display-container')) return;
const container = document.createElement('div'); container.id = 'tileman-key-display-container';
container.innerHTML = `
<div id="tileman-visual-key-e" class="key-cap tileman-grid-e">E</div><div id="tileman-visual-key-up" class="key-cap tileman-grid-up">▲</div><div id="tileman-visual-key-p" class="key-cap tileman-grid-p">P</div>
<div id="tileman-visual-key-z" class="key-cap tileman-grid-z">Z</div><div id="tileman-visual-key-space" class="key-cap tileman-grid-space">SPC</div><div id="tileman-visual-key-x" class="key-cap tileman-grid-x">X</div>
<div id="tileman-visual-key-left" class="key-cap tileman-grid-left">◀</div><div id="tileman-visual-key-down" class="key-cap tileman-grid-down">▼</div><div id="tileman-visual-key-right" class="key-cap tileman-grid-right">▶</div>`;
container.style.setProperty("display", S.keysVisible ? "grid" : "none", "important"); document.body.appendChild(container);
}
function initUnifiedUI() { installUi(); initChatUI(); initStatsUI(); initDeathStatsUI(); initKeyDisplayUI(); }
window.addEventListener("resize", () => {
if (S.minimapCanvas && S.minimapGeo) positionOverlay(S.minimapCanvas, S.minimapGeo);
if (S.activePinPopup) { const p = pins.find(p => p.id === S.activePinPopup.pinId); if (p) positionPopup(S.activePinPopup.el, p); }
updateAllArrows();
});
document.addEventListener("visibilitychange", () => { if (!document.hidden) S.lastHistAt = performance.now(); });
window.addEventListener("keydown", evt => {
const activeEl = document.activeElement; if (activeEl && (activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA")) return;
if (evt.code === KB.toggleChat) { toggleChat(); return; }
if (evt.code === KB.toggleKeys) { toggleKeys(); return; }
if (!globalContext.connection.playing) return;
if (evt.code === KB.toggleStats) { statsBarVisible = !statsBarVisible; localStorage.setItem(SK.STATS_VISIBLE, String(statsBarVisible)); if (statsBarEl) statsBarEl.style.display = statsBarVisible ? "flex" : "none"; }
if (evt.code === KB.cycleRate) { speedIndex = (speedIndex + 1) % speedProps.length; }
});
const uiRetryObserver = new MutationObserver(() => {
if (document.getElementById("leftbottom") && !statsBarEl) initStatsUI();
if (document.getElementById("after2") && !deathStatsEl) initDeathStatsUI();
});
if (document.body) { uiRetryObserver.observe(document.body, { childList: true, subtree: false }); }
hookWebSocket(); hookSocketIO(); hookDrawImage(); hookShapes();
const boot = () => { initUnifiedUI(); requestAnimationFrame(statsLoop); };
if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", boot, { once: true }); } else { boot(); }
}
})();