TileMan.io Client

Minimap History & Pins · Live Stats · Global Chat · Key Overlay

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Advertisement:

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

Advertisement:

// ==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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
        }

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