Bonk.io Image → Map

Drop an image in the editor and generate a bonk.io map (platforms) from it. Requires kklee. Click the "image -> map" button to access this amazing program!

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Advertisement:

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

Advertisement:

// ==UserScript==
// @name         Bonk.io Image → Map
// @namespace    https://example.com/bonk-image-to-map
// @version      0.1.0
// @description  Drop an image in the editor and generate a bonk.io map (platforms) from it. Requires kklee. Click the "image -> map" button to access this amazing program!
// @author       Greninja9257
// @match        https://bonk.io/gameframe-release.html
// @match        https://*.bonk.io/gameframe-release.html
// @match        https://bonkisback.io/gameframe-release.html
// @match        https://*.bonkisback.io/gameframe-release.html
// @match        https://multiplayer.gg/physics/gameframe-release.html
// @match        https://bonk.io/*
// @match        https://*.bonk.io/*
// @match        https://bonkisback.io/*
// @match        https://*.bonkisback.io/*
// @run-at       document-start
// @grant        none
// ==/UserScript==

/*
  HOW THIS WORKS (read this before reporting a bug)
  ---------------------------------------------------
  If you have kklee installed (the most widely used bonk.io map editor mod -
  https://github.com/BonkModdingCommunity/kklee), this script rides on its
  already-exposed, already-working API: window.kklee.mapObject,
  window.kklee.setMapObject(), and window.kklee's update*() functions for
  refreshing the editor preview. That's deliberately preferred over doing our
  own thing, because kklee's hooks are actively maintained against bonk.io
  updates and are far more reliable than anything we could reverse-engineer
  ourselves. This is checked at the moment you click "Generate", not just
  once at page load, so install order doesn't matter.

  If kklee ISN'T installed, this script falls back to doing the same kind of
  hook itself: bonk.io's game code (alpha2s.js) is fetched and patched with a
  couple of small regexes before it runs, to get a reference to the *live*
  map object and the editor's refresh function. This is the same technique
  kklee itself uses, and cooperates with Excigma's "Code Injector - Bonk.io"
  or kklee if either is present (shared window.bonkCodeInjectors array).

  The fallback regexes are matched against bonk.io's current minified code
  and can break if bonk.io changes that code. If they do, this script fails
  loudly (console error + on-screen diagnostics) rather than silently doing
  the wrong thing, and the image -> map JSON generation (which doesn't touch
  bonk.io's internals at all) still works via "Download map JSON".
*/
(function () {
  "use strict";
  const NS = "imgToMap";
  window[NS] = window[NS] || {};
  const api = window[NS];
  api.log = (...a) => console.log("[Image→Map]", ...a);
  api.warn = (...a) => console.warn("[Image→Map]", ...a);

  // ---------------------------------------------------------------------
  // 1. Injector: find the live map object + (best-effort) editor refresh
  // ---------------------------------------------------------------------
  function imageToMapInjector(src) {
    let out = src;

    // Find the variable holding the current map object. The map metadata
    // always has an "rxid" key right next to the variable, e.g. "rxid:ab[12]"
    const refMatch = out.match(/rxid:([A-Za-z_$][\w$]{0,5}\[\d{1,4}\])/);
    if (!refMatch) {
      throw new Error(
        "[Image→Map] couldn't find the map object reference (rxid pattern). " +
          "bonk.io's code may have changed."
      );
    }
    const mapVar = refMatch[1];
    const mapVarEsc = mapVar.replace(/([.*+?^${}()|[\]\\])/g, "\\$1");

    let setterInjected = false;
    const assignRe = new RegExp(`(${mapVarEsc}=[^;]+;)`, "g");
    out = out.replace(assignRe, (whole) => {
      let extra = `window.${NS}._mirror(${mapVar});`;
      if (!setterInjected) {
        // IMPORTANT: mutate the live object's arrays/fields *in place* rather
        // than reassigning ${mapVar} to a brand-new object. bonk.io's other
        // internal code likely holds direct references to the existing
        // physics/spawns/etc arrays (not just to the top-level variable), so
        // replacing the whole object can silently leave those other parts of
        // the editor pointing at stale data. This mirrors how kklee's own
        // (verified working) code only ever does moph.bodies.push(...) /
        // .delete(...) on the existing arrays, never `mapObject = newThing`.
        extra += `window.${NS}.setMapObject=function(m){\
var L=${mapVar};\
var cp=function(a,b){a.length=0;for(var i=0;i<b.length;i++)a.push(b[i]);};\
L.v=m.v;L.s=m.s;L.m=m.m;\
cp(L.spawns,m.spawns);\
cp(L.capZones,m.capZones);\
cp(L.physics.shapes,m.physics.shapes);\
cp(L.physics.fixtures,m.physics.fixtures);\
cp(L.physics.bodies,m.physics.bodies);\
cp(L.physics.bro,m.physics.bro);\
cp(L.physics.joints,m.physics.joints);\
L.physics.ppm=m.physics.ppm;\
window.${NS}._mirror(L);\
};`;
        setterInjected = true;
      }
      return whole + extra;
    });
    if (!setterInjected) {
      throw new Error("[Image→Map] found the map variable but failed to patch it.");
    }

    // Best-effort: find the editor's "refresh everything" function so we can
    // force a redraw after loading new data. Pattern: a 0-arg function that
    // sets a couple of "selected id" vars to -1, calls something with (true),
    // then calls a few more 0-arg functions.
    try {
      const resetRe =
        /function ([A-Za-z_$][\w$]{0,5})\(\)\{(?:.{0,80}?\[\d{1,4}\]=-1;){2}.{0,120}?\(true\);(?:.{0,80}?\(\);){2}[^}]*\}/;
      const resetMatch = out.match(resetRe);
      if (resetMatch) {
        const fnName = resetMatch[1];
        out = out.replace(
          resetMatch[0],
          resetMatch[0] + `;window.${NS}.refreshEditor=${fnName};`
        );
        api.log("hooked editor refresh function:", fnName);
      } else {
        api.warn(
          "couldn't find the editor refresh function (non-fatal). " +
            "You may need to click a platform in the list to force a redraw " +
            "after generating a map."
        );
      }
    } catch (e) {
      api.warn("editor refresh hook failed (non-fatal):", e);
    }

    return out;
  }

  // ---------------------------------------------------------------------
  // 2. Bootstrap: register with (or become) the bonk.io code-injector chain
  // ---------------------------------------------------------------------

  if (!window.bonkCodeInjectors) window.bonkCodeInjectors = [];
  window.bonkCodeInjectors.push(imageToMapInjector);

  api.status = {
    sawAnyScript: false,
    sawGameScript: false,
    patchApplied: false,
    patchError: null,
    recentScriptSrcs: [],
  };

  // If bonk.io ever renames its game bundle away from "alpha2s.js", add the
  // new filename here.
  const GAME_SCRIPT_HINTS = ["alpha2s.js"];

  // Set up our own appendChild interception too, in case no other
  // "code injector" style userscript (Excigma's, kklee, etc.) is installed.
  // If one IS installed, whichever interceptor sees the real <script src=...>
  // tag first wins; both read from the same shared window.bonkCodeInjectors
  // array, so our injector runs either way.
  if (!window.__imgToMapBootstrapped) {
    window.__imgToMapBootstrapped = true;
    const originalAppendChild = document.head.appendChild.bind(document.head);
    let intercepted = false;

    document.head.appendChild = function (node) {
      if (node && node.tagName === "SCRIPT" && node.src) {
        api.status.sawAnyScript = true;
        api.status.recentScriptSrcs.push(node.src);
        if (api.status.recentScriptSrcs.length > 20) api.status.recentScriptSrcs.shift();
      }

      if (
        !intercepted &&
        node &&
        node.tagName === "SCRIPT" &&
        node.src &&
        GAME_SCRIPT_HINTS.some((hint) => node.src.includes(hint))
      ) {
        intercepted = true;
        api.status.sawGameScript = true;
        api.log("intercepted what looks like the game bundle:", node.src);
        const url = node.src;
        node.removeAttribute("src");

        fetch(url, { cache: "no-store" })
          .then((r) => r.text())
          .then((text) => {
            api.log(
              `fetched game code (${text.length} chars), running ${window.bonkCodeInjectors.length} injector(s)`
            );
            let patched = text;
            for (const inj of window.bonkCodeInjectors) {
              try {
                patched = inj(patched);
              } catch (e) {
                api.status.patchError = e.message;
                console.error("[Image→Map] a code injector failed:", e);
                alert(
                  "A bonk.io mod script failed to load (see console for details). " +
                    "The game itself should still work."
                );
              }
            }
            api.status.patchApplied = !api.status.patchError;
            api.log("patch applied:", api.status.patchApplied, "setMapObject ready:", !!api.setMapObject);
            const script = document.createElement("script");
            script.text = patched;
            originalAppendChild(script);
          })
          .catch((e) => {
            api.status.patchError = e.message;
            console.error("[Image→Map] failed to fetch bonk.io's game code:", e);
            // Fall back to loading the untouched original so the game still works
            const fallback = document.createElement("script");
            fallback.src = url;
            originalAppendChild(fallback);
          });

        return node;
      }
      return originalAppendChild(node);
    };

    // If nothing matching GAME_SCRIPT_HINTS ever loads, and our hook never
    // got wired up some other way either, say so loudly with what we DID see.
    setTimeout(() => {
      if (!api.status.sawGameScript && !api.setMapObject) {
        api.warn(
          `never saw a script load matching ${JSON.stringify(GAME_SCRIPT_HINTS)}, and no ` +
            "live map connection was established any other way either. bonk.io may have " +
            "renamed its game bundle or changed how it's loaded. Scripts seen instead:",
          api.status.recentScriptSrcs
        );
      }
    }, 8000);
  }

  // ---------------------------------------------------------------------
  // 3. Mirror of the live map object + a couple of small live helpers
  // ---------------------------------------------------------------------

  api.mapObject = null;
  api._mirror = (m) => {
    api.mapObject = m;
  };

  // ---- prefer kklee's own (proven, actively-maintained) hooks if present --
  //
  // kklee already solves exactly this problem: it finds the live map object,
  // exposes window.kklee.mapObject / window.kklee.setMapObject, and exposes
  // the editor's own update functions as window.kklee.update*(). If the user
  // has kklee installed, riding on its already-working hooks is far more
  // reliable than our own reverse-engineered regexes, which are a fallback
  // for when kklee isn't installed. Checked at call time (not just once at
  // startup) since load order between userscripts isn't guaranteed.

  function kkleeReady() {
    return !!(window.kklee && typeof window.kklee.setMapObject === "function");
  }

  function liveMapObject() {
    if (kkleeReady() && window.kklee.mapObject) return window.kklee.mapObject;
    return api.mapObject;
  }

  function liveIsConnected() {
    return kkleeReady() || !!api.setMapObject;
  }

  // Returns "kklee", "own", or null (nothing available)
  function liveSetMapObject(m) {
    if (kkleeReady()) {
      window.kklee.setMapObject(m);
      return "kklee";
    }
    if (api.setMapObject) {
      api.setMapObject(m);
      return "own";
    }
    return null;
  }

  // Mirrors kklee's own editor-reset function call sequence (see
  // BonkModdingCommunity/kklee src/injector.js's "reset function" hook):
  // updateLeftBox, updateRenderer(true), updateWarnings, updateUndoButtons,
  // updateModeDropdown, then saveToUndoHistory so Ctrl+Z behaves normally.
  function liveRefreshEditor() {
    if (kkleeReady()) {
      const k = window.kklee;
      let didAny = false;
      [
        ["updateLeftBox", []],
        ["updateRenderer", [true]],
        ["updateWarnings", []],
        ["updateUndoButtons", []],
        ["updateModeDropdown", []],
      ].forEach(([fn, args]) => {
        if (typeof k[fn] === "function") {
          try {
            k[fn](...args);
            didAny = true;
          } catch (e) {
            api.warn(`window.kklee.${fn}() failed:`, e);
          }
        }
      });
      if (typeof k.saveToUndoHistory === "function") {
        try {
          k.saveToUndoHistory();
        } catch (e) {
          /* non-fatal */
        }
      }
      if (didAny) return "kklee";
    }
    if (api.refreshEditor) {
      try {
        api.refreshEditor();
        return "own";
      } catch (e) {
        api.warn("own refreshEditor() failed:", e);
      }
    }
    return null;
  }

  // ---------------------------------------------------------------------
  // 4. Image → map conversion
  // ---------------------------------------------------------------------

  function quantize(v, step) {
    return Math.max(0, Math.min(255, Math.round(v / step) * step));
  }

  function colorDistance(r1, g1, b1, r2, g2, b2) {
    return Math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2);
  }

  // Greedy rectangle merge over a 2D grid of color keys (string) or null
  // (empty/background). Returns [{r,c,w,h,color}], each cell covered exactly
  // once. Verified separately against overlap/coverage bugs.
  function mergeGrid(grid) {
    const rows = grid.length;
    const cols = grid[0].length;
    const visited = Array.from({ length: rows }, () => new Array(cols).fill(false));
    const rects = [];

    for (let r = 0; r < rows; r++) {
      for (let c = 0; c < cols; c++) {
        if (visited[r][c] || grid[r][c] === null) continue;
        const color = grid[r][c];

        let w = 1;
        while (c + w < cols && !visited[r][c + w] && grid[r][c + w] === color) w++;

        let h = 1;
        outer: while (r + h < rows) {
          for (let cc = c; cc < c + w; cc++) {
            if (visited[r + h][cc] || grid[r + h][cc] !== color) break outer;
          }
          h++;
        }

        for (let rr = r; rr < r + h; rr++)
          for (let cc = c; cc < c + w; cc++) visited[rr][cc] = true;

        rects.push({ r, c, w, h, color });
      }
    }
    return rects;
  }
  // Downsamples `img` onto a cols x rows grid and returns
  // { grid, cols, rows, srcWidth, srcHeight }
  function imageToGrid(img, cols, opts) {
    const srcWidth = img.naturalWidth || img.width;
    const srcHeight = img.naturalHeight || img.height;
    const rows = Math.max(1, Math.round(cols * (srcHeight / srcWidth)));

    const c = document.createElement("canvas");
    c.width = cols;
    c.height = rows;
    const ctx = c.getContext("2d");
    ctx.imageSmoothingEnabled = true;
    ctx.drawImage(img, 0, 0, cols, rows);
    const { data } = ctx.getImageData(0, 0, cols, rows);

    const grid = Array.from({ length: rows }, () => new Array(cols).fill(null));
    for (let r = 0; r < rows; r++) {
      for (let cIdx = 0; cIdx < cols; cIdx++) {
        const i = (r * cols + cIdx) * 4;
        let red = data[i], green = data[i + 1], blue = data[i + 2], alpha = data[i + 3];

        if (alpha < opts.alphaThreshold) continue;

        if (
          opts.removeBackground &&
          colorDistance(red, green, blue, opts.bgR, opts.bgG, opts.bgB) <= opts.bgTolerance
        ) {
          continue;
        }

        red = quantize(red, opts.colorStep);
        green = quantize(green, opts.colorStep);
        blue = quantize(blue, opts.colorStep);

        const rowForKey = opts.flipVertical ? rows - 1 - r : r;
        grid[rowForKey][cIdx] = `${red},${green},${blue}`;
      }
    }
    return { grid, cols, rows, srcWidth, srcHeight };
  }

  function colorKeyToInt(key) {
    const [r, g, b] = key.split(",").map(Number);
    return (r << 16) | (g << 8) | b;
  }

  function getNewBoxShape() {
    // MapShape: type "bx" = box. c = centre position, w/h = dimensions.
    return { type: "bx", w: 10, h: 10, c: [0, 0], a: 0, sk: false };
  }

  function getNewFixture() {
    // fr/re/de = null → encoder writes MAX_VALUE sentinel (cheap, no float written).
    // fp = null → encoder writes 0 (3 values: 0=null, 1=false, 2=true).
    // Setting these to actual floats writes 8 extra bytes each per fixture.
    return {
      n: "img",
      d: false, ng: false, np: false, fp: null, ig: false,
      de: null, re: null, fr: null,
      f: 0xffffff, sh: -1,
    };
  }

  function getNewBody() {
    // Nested .s format — what both kklee's UI (updateLeftBox reads body.s.n,
    // updateRenderer reads body.s.type) and the current bonk.io encoder expect
    // after the Jul 2022 update. MAPFORMAT.md's flat-format decoder is outdated.
    // The previous DataView overflow was a SIZE issue (too many shapes), not a
    // format issue. Polygon mode keeps shape count low enough to avoid it.
    return {
      a: 0, av: 0,
      p: [0, 0], lv: [0, 0],
      fx: [],
      cf: { x: 0, y: 0, ct: 0, w: true },
      fz: { x: 0, y: 0, on: false, d: true, p: true, a: true },
      s: {
        n: "img",
        type: "s",
        ad: 0, de: 0.3, fric: 0.3, ld: 0, re: 0.3,
        f_1: true, f_2: true, f_3: true, f_4: true, f_p: true,
        f_c: 1,
        fr: false, fricp: false, bu: false,
      },
    };
  }

  // ---- Polygon outline tracing ----------------------------------------
  // Groups same-colored cells into connected components, then traces each
  // component's boundary as a polygon. One polygon per connected region of
  // a given color, instead of one box per merged rectangle. At high detail
  // levels this reduces tens of thousands of shapes to hundreds or fewer,
  // making the encoded map small enough to save.

  function findConnectedComponents(grid, rows, cols) {
    const visited = Array.from({ length: rows }, () => new Uint8Array(cols));
    const components = [];
    for (let r = 0; r < rows; r++) {
      for (let c = 0; c < cols; c++) {
        if (visited[r][c] || grid[r][c] === null) continue;
        const color = grid[r][c];
        const cells = [];
        const queue = [[r, c]];
        visited[r][c] = 1;
        let qi = 0;
        while (qi < queue.length) {
          const [cr, cc] = queue[qi++];
          cells.push([cr, cc]);
          for (const [dr, dc] of [[-1,0],[1,0],[0,-1],[0,1]]) {
            const nr = cr + dr, nc = cc + dc;
            if (nr >= 0 && nr < rows && nc >= 0 && nc < cols &&
                !visited[nr][nc] && grid[nr][nc] === color) {
              visited[nr][nc] = 1;
              queue.push([nr, nc]);
            }
          }
        }
        components.push({ color, cells });
      }
    }
    return components;
  }

  // Traces the outer boundary of a connected set of grid cells as a polygon.
  // Returns array of [x, y] corner points in grid-unit coordinates (collinear
  // points are removed so we get only actual corners), or null if degenerate.
  function traceOutline(cells, cols) {
    const W = cols + 2;
    const cellSet = new Set(cells.map(([r, c]) => r * W + c));
    const has = (r, c) => cellSet.has(r * W + c);

    // Build directed boundary edges. Grid points are intersections at
    // integer (col, row) coordinates. Edges go clockwise around the interior.
    //   top of cell (r,c)    → right if nothing above
    //   right of cell (r,c)  → down if nothing to right
    //   bottom of cell (r,c) → left if nothing below
    //   left of cell (r,c)   → up if nothing to left
    const edgeMap = new Map();
    for (const [r, c] of cells) {
      if (!has(r-1, c)) edgeMap.set(`${c},${r}`,     [c+1, r]);
      if (!has(r, c+1)) edgeMap.set(`${c+1},${r}`,   [c+1, r+1]);
      if (!has(r+1, c)) edgeMap.set(`${c+1},${r+1}`, [c, r+1]);
      if (!has(r, c-1)) edgeMap.set(`${c},${r+1}`,   [c, r]);
    }
    if (edgeMap.size === 0) return null;

    const startKey = edgeMap.keys().next().value;
    const [sx, sy] = startKey.split(',').map(Number);
    const vertices = [];
    let x = sx, y = sy, prevDx = null, prevDy = null;
    const maxSteps = edgeMap.size + 4;
    let steps = 0;

    do {
      const next = edgeMap.get(`${x},${y}`);
      if (!next) break;
      const [nx, ny] = next;
      const dx = nx - x, dy = ny - y;
      if (dx !== prevDx || dy !== prevDy) vertices.push([x, y]);
      prevDx = dx; prevDy = dy;
      x = nx; y = ny;
    } while ((x !== sx || y !== sy) && steps++ < maxSteps);

    return vertices.length >= 3 ? vertices : null;
  }

  // Converts world-coordinate vertex list into a bonk.io polygon shape object.
  // Match bonk.io/kklee editor-created polygons: c stays [0,0] and v stores
  // absolute world coordinates. Using centroid-relative vertices can render
  // as wrong/overlapping fills in the editor.
  function vertsToPolyShape(worldVerts) {
    return {
      type: "po",
      s: 1, a: 0,
      c: [0, 0],
      v: worldVerts,
    };
  }

  // Builds physics from connected-component polygon outlines (default) or
  // greedy-merged rectangles (fallback / box mode).
  function buildPhysics(grid, rows, cols, cellSize, originX, originY, opts) {
    const shapes = [], fixtures = [], bodies = [], bro = [];

    const addShape = (shapeObj, color, bodyRef) => {
      const shIdx = shapes.length;
      shapes.push(shapeObj);
      const fx = Object.assign(getNewFixture(), {
        f: colorKeyToInt(color),
        np: opts.decorative,
        sh: shIdx,
      });
      const fxIdx = fixtures.length;
      fixtures.push(fx);
      bodyRef.fx.push(fxIdx);
    };

    // bonk.io/kklee can break when one element/body has more than 100 shapes.
    // So "single element" here means "group into image chunks", not literally
    // unlimited shapes on one body: img_1, img_2, ... each <= 100 fixtures.
    const MAX_SHAPES_PER_BODY = 100;
    const makeImageBody = () => {
      const body = getNewBody();
      body.s.n = `img_${bodies.length + 1}`;
      body.s.f_p = !opts.decorative;
      bodies.push(body);
      bro.push(bodies.length - 1);
      return body;
    };
    const ensureBodyWithRoom = (currentBody) => {
      if (!currentBody || currentBody.fx.length >= MAX_SHAPES_PER_BODY) {
        return makeImageBody();
      }
      return currentBody;
    };

    /* Polygon outline mode disabled — box mode (below) is used unconditionally.
    if (opts.usePolygons) {
      const components = findConnectedComponents(grid, rows, cols);
      opts._shapeCount = components.length;

      if (opts.singleElement) {
        let body = null;
        for (const { color, cells } of components) {
          const gridVerts = traceOutline(cells, cols);
          if (!gridVerts) continue;
          const worldVerts = gridVerts.map(([gx, gy]) => [
            originX + gx * cellSize, originY + gy * cellSize
          ]);
          body = ensureBodyWithRoom(body);
          addShape(vertsToPolyShape(worldVerts), color, body);
        }
      } else {
        for (const { color, cells } of components) {
          const gridVerts = traceOutline(cells, cols);
          if (!gridVerts) continue;
          const worldVerts = gridVerts.map(([gx, gy]) => [
            originX + gx * cellSize, originY + gy * cellSize
          ]);
          const body = makeImageBody();
          addShape(vertsToPolyShape(worldVerts), color, body);
        }
      }
    } else {
    */
    {
      // Box mode: greedy rectangle merge (kept as fallback)
      const rects = mergeGrid(grid);
      opts._shapeCount = rects.length;

      if (opts.singleElement) {
        let body = null;
        for (const rect of rects) {
          body = ensureBodyWithRoom(body);
          addShape({
            type: "bx",
            w: rect.w * cellSize, h: rect.h * cellSize,
            c: [originX + (rect.c + rect.w / 2) * cellSize,
                originY + (rect.r + rect.h / 2) * cellSize],
            a: 0, sk: false,
          }, rect.color, body);
        }
      } else {
        for (const rect of rects) {
          const body = makeImageBody();
          addShape({
            type: "bx",
            w: rect.w * cellSize, h: rect.h * cellSize,
            c: [originX + (rect.c + rect.w / 2) * cellSize,
                originY + (rect.r + rect.h / 2) * cellSize],
            a: 0, sk: false,
          }, rect.color, body);
        }
      }
    }

    return { shapes, fixtures, bodies, bro, joints: [], ppm: opts.ppm };
  }




  // ---------------------------------------------------------------------
  // 5. UI — built to slot into bonk.io's *real* editor DOM/CSS instead of
  //    floating over the page. Verified against bonk.io modding community
  //    mod source (kklee) for the actual element IDs and class names:
  //      - #mapeditor_rightbox_mapparams  -> table other mods add settings rows to.
  //        Lives inside the editor's own DOM, so it (and our panel, appended
  //        as another row right under it) is hidden automatically whenever
  //        the editor itself is hidden. No absolute positioning anywhere, so
  //        there's nothing for our panel to end up clipped/hidden behind.
  //      - .brownButton/.brownButton_classic/.buttonShadow -> native button look
  //      - .mapeditor_field/.fieldShadow  -> native text/number input look
  //      - .compactSlider/.compactSlider_classic -> native range input look
  //    (the panel header is our own plain styling, not a reused bonk.io
  //    class — bonk.io's "window" title bar classes are meant for floating
  //    dialogs with their own positioning assumptions we don't want to
  //    inherit unverified)
  //    If bonk.io changes these, this section degrades to "nothing shows up"
  //    rather than a broken-looking floating panel — check the console.
  // ---------------------------------------------------------------------

  function injectStyles() {
    const css = `
      .itmCheckbox { width: 13px; height: 13px; display:inline-block; text-align:center;
        border: 2px solid #111; margin: auto 3px; color:#111; font-size: 11px;
        line-height: 11px; cursor:pointer; background:#586e77; vertical-align:middle; }
      .itmCheckbox:hover { filter: brightness(1.3); }
      .itmCheckbox.checked { background:#59b0d6; }
      .itmSwatch { width: 18px; height: 13px; display:inline-block; cursor:pointer;
        border: 1px solid #fff; outline: 2px solid #111; vertical-align:middle; }
      .itmRow { display:flex; flex-flow:row wrap; justify-content:space-between;
        align-items:center; margin: 5px 0; gap: 6px; }
      .itmRow > span:first-child { flex: 0 1 auto; min-width: 0; }
      .itmDrop { margin-top:6px; border: 2px dashed #8a8a8a; border-radius:4px;
        padding: 10px; text-align:center; cursor:pointer; font-size:11px; }
      .itmDrop.over { border-color: #2f6; }
      #itmPreview { width:100%; image-rendering: pixelated; margin-top:6px;
        border: 1px solid #888; background:#000; display:block; }
      .itmStatus { margin-top:8px; white-space:pre-wrap; font-size:11px;
        max-height: 70px; overflow-y:auto; }
      #itmPanelRow { display: none; }
      #itmPanelRow > td { padding: 0 !important; }
      .itmWrap { width: 100%; box-sizing: border-box; }
      .itmWrap * { box-sizing: border-box; }
      .itmBody { background:#cfd8dc; padding:6px 8px 10px; border-radius: 0 0 3px 3px;
        font-size: clamp(10px, 1.4vw, 12px); width: 100%; max-height: 60vh; overflow-y: auto; }
      #itmPanelClose { cursor:pointer; float:right; }
      .itmSlider { flex: 1 1 auto; min-width: 60px; }
      .itmDiag { font-size: 10px; background:#b7c2c6; border-radius:3px; padding:4px 6px;
        margin-bottom: 6px; line-height:1.5; }
      .itmDiag .ok { color: #0a6b0a; font-weight:bold; }
      .itmDiag .bad { color: #a30000; font-weight:bold; }
      .itmHeader { position: static; background:#37474f; color:#fff; font-weight:bold;
        padding: 4px 8px; border-radius: 3px 3px 0 0; }
    `;
    const style = document.createElement("style");
    style.textContent = css;
    document.head.appendChild(style);
  }

  // Mirrors kklee's own shapeTableCell() + createBonkButton() markup exactly,
  // since that's the real (proven-working) row/button structure bonk.io's
  // editor right-hand settings table expects.
  function makeSettingsRow(label, buttonText, onClick) {
    const tr = document.createElement("tr");

    const labelTd = document.createElement("td");
    labelTd.className = "mapeditor_rightbox_table_leftcell";
    labelTd.innerText = label;
    tr.appendChild(labelTd);

    const cellTd = document.createElement("td");
    cellTd.className = "mapeditor_rightbox_table_rightcell";
    const btn = document.createElement("div");
    btn.className = "brownButton brownButton_classic buttonShadow";
    btn.innerText = buttonText;
    btn.onclick = onClick;
    cellTd.appendChild(btn);
    tr.appendChild(cellTd);

    return tr;
  }

  function makeButton(text, onClick, extraClass) {
    const btn = document.createElement("div");
    btn.className = "brownButton brownButton_classic buttonShadow" + (extraClass ? " " + extraClass : "");
    btn.style.marginTop = "6px";
    btn.innerText = text;
    btn.onclick = onClick;
    return btn;
  }

  function makeCheckbox(initial, onChange) {
    const box = document.createElement("div");
    box.className = "itmCheckbox" + (initial ? " checked" : "");
    box.innerText = initial ? "✔" : "";
    let checked = initial;
    box.onclick = () => {
      checked = !checked;
      box.classList.toggle("checked", checked);
      box.innerText = checked ? "✔" : "";
      onChange(checked);
    };
    box.get = () => checked;
    return box;
  }

  function makeRow(labelText, fieldEl) {
    const row = document.createElement("div");
    row.className = "itmRow";
    const label = document.createElement("span");
    label.innerText = labelText;
    row.appendChild(label);
    row.appendChild(fieldEl);
    return row;
  }

  function makeNumberInput(value, width, onInput) {
    const input = document.createElement("input");
    input.type = "number";
    input.className = "mapeditor_field mapeditor_field_spacing_bodge fieldShadow";
    input.style.width = (width || 60) + "px";
    input.value = value;
    input.oninput = () => onInput(input.value);
    return input;
  }

  function makeTextInput(value, width, onInput) {
    const input = document.createElement("input");
    input.type = "text";
    input.className = "mapeditor_field mapeditor_field_spacing_bodge fieldShadow";
    input.style.width = (width || 70) + "px";
    input.value = value;
    input.oninput = () => onInput(input.value);
    return input;
  }

  function makeSlider(min, max, value, onInput) {
    const wrap = document.createElement("span");
    wrap.style.display = "flex";
    wrap.style.alignItems = "center";
    wrap.style.gap = "4px";
    wrap.style.flex = "1 1 auto";

    const input = document.createElement("input");
    input.type = "range";
    input.className = "compactSlider compactSlider_classic itmSlider";
    input.min = min;
    input.max = max;
    input.value = value;

    const valSpan = document.createElement("span");
    valSpan.textContent = value;
    valSpan.style.minWidth = "28px";
    valSpan.style.textAlign = "right";

    input.oninput = () => {
      valSpan.textContent = input.value;
      onInput(input.value);
    };

    wrap.appendChild(input);
    wrap.appendChild(valSpan);
    return wrap;
  }

  function makeSelect(options, value, onChange) {
    const select = document.createElement("select");
    select.className = "mapeditor_field fieldShadow";
    for (const [val, label] of options) {
      const opt = document.createElement("option");
      opt.value = val;
      opt.textContent = label;
      if (val === value) opt.selected = true;
      select.appendChild(opt);
    }
    select.onchange = () => onChange(select.value);
    return select;
  }

  function waitFor(getEl, timeoutMs, onFound) {
    const start = Date.now();
    (function tick() {
      const el = getEl();
      if (el) {
        onFound(el);
        return;
      }
      if (Date.now() - start > timeoutMs) {
        api.warn(
          "timed out waiting for an editor DOM element. bonk.io's editor markup " +
            "may have changed - this script needs updating."
        );
        return;
      }
      requestAnimationFrame(tick);
    })();
  }

  function buildAndMountUI(mapparams) {
    injectStyles();

    const state = { img: null, panelOpen: false };

    // Mounted as a normal <tr> directly under the entry button, in normal
    // document flow - not absolutely positioned over anything, so there's
    // nothing for it to be hidden behind, and it just inherits the real
    // width bonk.io already gives this settings table.
    const panelRow = document.createElement("tr");
    panelRow.id = "itmPanelRow";
    const panelCell = document.createElement("td");
    panelCell.colSpan = 2;
    panelRow.appendChild(panelCell);

    const wrap = document.createElement("div");
    wrap.className = "itmWrap";
    wrap.innerHTML = `<div class="itmHeader">Image → Map<span id="itmPanelClose">✕</span></div>`;
    const body = document.createElement("div");
    body.className = "itmBody";
    wrap.appendChild(body);
    panelCell.appendChild(wrap);

    wrap.querySelector("#itmPanelClose").onclick = () => {
      panelRow.style.display = "none";
      state.panelOpen = false;
    };

    mapparams.appendChild(
      makeSettingsRow("", "Image → Map", () => {
        state.panelOpen = !state.panelOpen;
        panelRow.style.display = state.panelOpen ? "table-row" : "none";
      })
    );
    mapparams.appendChild(panelRow);

    const status = document.createElement("div");
    status.className = "itmStatus";
    const setStatus = (t) => (status.textContent = t);

    // ---- live diagnostics (so "it doesn't load" has an actual answer) ----
    const diag = document.createElement("div");
    diag.className = "itmDiag";
    body.appendChild(diag);

    const tag = (ok) => (ok ? '<span class="ok">yes</span>' : '<span class="bad">no</span>');
    function refreshDiag() {
      const injectorCount = (window.bonkCodeInjectors || []).length;
      const viaKklee = kkleeReady();
      diag.innerHTML =
        `kklee detected: ${tag(!!window.kklee)}<br>` +
        `Live map connection: ${tag(liveIsConnected())}${viaKklee ? " (via kklee)" : api.setMapObject ? " (own hook)" : ""}<br>` +
        `Auto-refresh: ${tag(viaKklee || !!api.refreshEditor)}${viaKklee ? " (via kklee)" : ""}<br>` +
        `Code injectors registered: ${injectorCount} · Game script intercepted: ${tag(api.status && api.status.sawGameScript)}`;
    }
    refreshDiag();
    setInterval(refreshDiag, 1500);

    body.appendChild(
      makeButton(
        "Copy diagnostics",
        () => {
          const info = {
            url: location.href,
            kkleeDetected: !!window.kklee,
            kkleeReady: kkleeReady(),
            bonkCodeInjectorsCount: (window.bonkCodeInjectors || []).length,
            status: api.status,
            hasOwnSetMapObject: !!api.setMapObject,
            hasOwnRefreshEditor: !!api.refreshEditor,
            hasLiveMapObject: !!liveMapObject(),
          };
          console.log("[Image→Map] diagnostics:", info);
          const text = JSON.stringify(info, null, 2);
          if (navigator.clipboard && navigator.clipboard.writeText) {
            navigator.clipboard.writeText(text).then(
              () => setStatus("Diagnostics copied to clipboard - paste them wherever you're reporting this."),
              () => setStatus("Couldn't copy automatically — diagnostics were printed to the console (F12) instead.")
            );
          } else {
            setStatus("Diagnostics printed to the console (F12).");
          }
        },
        "secondary"
      )
    );

    // ---- drop zone + preview -------------------------------------------------
    const drop = document.createElement("div");
    drop.className = "itmDrop";
    drop.innerText = "Drop an image here, or click to choose one";
    const fileInput = document.createElement("input");
    fileInput.type = "file";
    fileInput.accept = "image/*";
    fileInput.style.display = "none";
    body.appendChild(drop);
    body.appendChild(fileInput);

    const preview = document.createElement("canvas");
    preview.id = "itmPreview";
    preview.width = 200;
    preview.height = 150;
    body.appendChild(preview);

    // ---- controls --------------------------------------------------------
    const opts = {
      cols: 100, colorStep: 16, alphaThreshold: 20,
      removeBackground: false, bgHex: "#ffffff", bgTolerance: 20,
      decorative: false, flipVertical: false, singleElement: true, usePolygons: false, // polygon mode disabled
      mapWidth: 800, offsetX: 0, offsetY: 0,
      mode: "add", cap: 8000,
    };

    let redrawTimer = null;
    const scheduleRedraw = () => {
      clearTimeout(redrawTimer);
      redrawTimer = setTimeout(drawPreview, 120);
    };

    body.appendChild(makeRow("Columns (detail)", makeSlider(8, 1000, opts.cols, (v) => { opts.cols = +v; scheduleRedraw(); })));
    body.appendChild(makeRow("Colour grouping", makeSlider(1, 96, opts.colorStep, (v) => { opts.colorStep = +v; scheduleRedraw(); })));
    body.appendChild(makeRow("Alpha cutoff", makeSlider(0, 255, opts.alphaThreshold, (v) => { opts.alphaThreshold = +v; scheduleRedraw(); })));

    const bgCheck = makeCheckbox(opts.removeBackground, (v) => { opts.removeBackground = v; scheduleRedraw(); });
    body.appendChild(makeRow("Remove background colour", bgCheck));

    const bgSwatch = document.createElement("div");
    bgSwatch.className = "itmSwatch";
    bgSwatch.style.background = opts.bgHex;
    bgSwatch.onclick = () => bgColorInput.click();
    const bgColorInput = document.createElement("input");
    bgColorInput.type = "color";
    bgColorInput.value = opts.bgHex;
    bgColorInput.style.display = "none";
    bgColorInput.oninput = () => { opts.bgHex = bgColorInput.value; bgSwatch.style.background = opts.bgHex; scheduleRedraw(); };
    body.appendChild(bgColorInput);
    const bgRow = document.createElement("div");
    bgRow.className = "itmRow";
    bgRow.appendChild(bgSwatch);
    bgRow.appendChild(makeNumberInput(opts.bgTolerance, 50, (v) => { opts.bgTolerance = +v; scheduleRedraw(); }));
    body.appendChild(makeRow("Background colour / tolerance", bgRow));

    body.appendChild(makeRow("Decorative only (no collision)", makeCheckbox(opts.decorative, (v) => (opts.decorative = v))));
    body.appendChild(makeRow("Group image bodies (max 100 shapes each)", makeCheckbox(opts.singleElement, (v) => (opts.singleElement = v))));
    // body.appendChild(makeRow("Polygon outlines (fewer shapes, saves larger maps)", makeCheckbox(opts.usePolygons, (v) => (opts.usePolygons = v)))); // polygon mode disabled
    body.appendChild(makeRow("Flip vertically", makeCheckbox(opts.flipVertical, (v) => { opts.flipVertical = v; scheduleRedraw(); })));
    body.appendChild(makeRow("Map width (units)", makeNumberInput(opts.mapWidth, 70, (v) => (opts.mapWidth = +v))));
    body.appendChild(makeRow("Offset X / Y", (() => {
      const wrap = document.createElement("span");
      wrap.appendChild(makeNumberInput(opts.offsetX, 55, (v) => (opts.offsetX = +v)));
      wrap.appendChild(makeNumberInput(opts.offsetY, 55, (v) => (opts.offsetY = +v)));
      return wrap;
    })()));
    body.appendChild(makeRow("Mode", makeSelect(
      [["add", "Add to current map (safe)"], ["replace", "Replace current map (destructive)"]],
      opts.mode, (v) => (opts.mode = v)
    )));
    body.appendChild(makeRow("Shape cap (warn above this)", makeNumberInput(opts.cap, 70, (v) => (opts.cap = +v))));

    body.appendChild(makeButton("Generate & load into editor", onGenerate));
    body.appendChild(makeButton("Download map JSON", onDownload, "secondary"));
    body.appendChild(status);

    // ---- pipeline (same logic as before, just reading `opts` directly) ----

    function colorObj() {
      const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(opts.bgHex);
      return m
        ? { bgR: parseInt(m[1], 16), bgG: parseInt(m[2], 16), bgB: parseInt(m[3], 16) }
        : { bgR: 255, bgG: 255, bgB: 255 };
    }

    function gridOpts() {
      return {
        alphaThreshold: opts.alphaThreshold,
        removeBackground: opts.removeBackground,
        bgTolerance: opts.bgTolerance,
        colorStep: opts.colorStep,
        flipVertical: opts.flipVertical,
        ...colorObj(),
      };
    }

    function drawPreview() {
      if (!state.img) return;
      try {
        const { grid, cols, rows } = imageToGrid(state.img, opts.cols, gridOpts());
        preview.width = cols;
        preview.height = rows;
        const ctx = preview.getContext("2d");
        ctx.clearRect(0, 0, cols, rows);
        for (let r = 0; r < rows; r++) {
          for (let c = 0; c < cols; c++) {
            const key = grid[r][c];
            if (key === null) continue;
            ctx.fillStyle = `rgb(${key})`;
            ctx.fillRect(c, r, 1, 1);
          }
        }
        // const count = opts.usePolygons
        //   ? findConnectedComponents(grid, rows, cols).length
        //   : mergeGrid(grid).length; // polygon mode disabled
        const count = mergeGrid(grid).length;
        const mode = "box";
        setStatus(`Preview: ${cols}×${rows} cells → ${count} ${mode} shapes`);
      } catch (e) {
        setStatus("Preview error: " + e.message);
      }
    }

    function safeNum(v, fallback) {
      const n = Number(v);
      return Number.isFinite(n) ? n : fallback;
    }

    function buildMapData() {
      const live = liveMapObject();
      const cols = Math.max(8, Math.round(safeNum(opts.cols, 100)));
      const mapWidth = Math.max(10, safeNum(opts.mapWidth, 800));
      const offsetX = safeNum(opts.offsetX, 0);
      const offsetY = safeNum(opts.offsetY, 0);
      const cap = Math.max(1, Math.round(safeNum(opts.cap, 8000)));

      const { grid, rows } = imageToGrid(state.img, cols, gridOpts());

      const cellSize = mapWidth / cols;
      const totalH = rows * cellSize;
      const originX = offsetX - mapWidth / 2;
      const originY = offsetY - totalH / 2;

      const ppm = (live && live.physics && Number.isFinite(live.physics.ppm) && live.physics.ppm) || 12;
      const physicsOpts = {
        decorative: opts.decorative,
        singleElement: opts.singleElement,
        usePolygons: opts.usePolygons,
        ppm,
      };
      const physics = buildPhysics(grid, rows, cols, cellSize, originX, originY, physicsOpts);
      const shapeCount = physicsOpts._shapeCount || physics.shapes.length;

      if (shapeCount === 0) {
        throw new Error(
          "Generated 0 shapes — the whole image was treated as background/transparent. " +
            "Lower the alpha cutoff or turn off background removal."
        );
      }
      if (shapeCount > cap) {
        const go = confirm(
          `Generated ${shapeCount} shapes (above your warning threshold of ${cap}). ` +
            `This may be slow to load or cause lag in-game. Continue anyway?`
        );
        if (!go) throw new Error("Cancelled — too many shapes.");
      }

      let mapData;
      if (live && opts.mode === "add") {
        mapData = JSON.parse(JSON.stringify(live));
        const shapeOffset = mapData.physics.shapes.length;
        const fixtureOffset = mapData.physics.fixtures.length;
        const bodyOffset = mapData.physics.bodies.length;

        physics.fixtures.forEach((f) => (f.sh += shapeOffset));
        physics.bodies.forEach((b) => (b.fx = b.fx.map((i) => i + fixtureOffset)));
        const newBro = physics.bro.map((i) => i + bodyOffset);

        mapData.physics.shapes = mapData.physics.shapes.concat(physics.shapes);
        mapData.physics.fixtures = mapData.physics.fixtures.concat(physics.fixtures);
        mapData.physics.bodies = mapData.physics.bodies.concat(physics.bodies);
        mapData.physics.bro = mapData.physics.bro.concat(newBro);
      } else {
        mapData = {
          v: (live && live.v) || 13,
          s: (live && live.s) || { re: false, nc: false, pq: 1, gd: 25, fl: false },
          m: (live && JSON.parse(JSON.stringify(live.m))) || {
            n: "Image Map", a: "you", dbv: 2, dbid: -1, authid: -1, date: "",
            rxid: 0, rxn: "", rxa: "", rxdb: 1, cr: [], pub: false, mo: "",
          },
          spawns: (live && live.spawns) ? JSON.parse(JSON.stringify(live.spawns)) : [],
          capZones: [],
          physics,
        };
      }
      return { mapData, rectCount: shapeCount, cols, rows };
    }

    function onGenerate() {
      if (!state.img) return setStatus("Load an image first.");
      if (opts.mode === "replace") {
        const ok = confirm(
          "This will REPLACE every spawn, capture zone, and platform in your " +
            "currently loaded map with the generated image (Ctrl+Z should still " +
            "undo it afterwards). Continue?"
        );
        if (!ok) {
          setStatus("Cancelled - nothing was changed.");
          return;
        }
      }
      try {
        const { mapData, rectCount } = buildMapData();
        const setVia = liveSetMapObject(mapData);
        if (!setVia) {
          setStatus(
            `Built a map with ${rectCount} shapes, but couldn't find a live editor ` +
              `connection (no kklee, and our own hook isn't connected either). ` +
              `Use "Download map JSON" instead, or check the diagnostics above.`
          );
          return;
        }
        const refreshVia = liveRefreshEditor();
        const note = refreshVia
          ? ` (via ${setVia === "kklee" ? "kklee" : "own hook"})`
          : " Click a platform in the elements list to force the preview to redraw.";
        setStatus(`Loaded ${rectCount} shapes into the editor.${note}`);
      } catch (e) {
        setStatus("Error: " + e.message);
        console.error(e);
      }
    }

    function onDownload() {
      if (!state.img) return setStatus("Load an image first.");
      try {
        const { mapData, rectCount } = buildMapData();
        const blob = new Blob([JSON.stringify(mapData, null, 2)], { type: "application/json" });
        const a = document.createElement("a");
        a.href = URL.createObjectURL(blob);
        a.download = "image-map.json";
        a.click();
        setStatus(`Downloaded JSON with ${rectCount} shapes.`);
      } catch (e) {
        setStatus("Error: " + e.message);
        console.error(e);
      }
    }

    function loadFile(file) {
      if (!file || !file.type.startsWith("image/")) return setStatus("That doesn't look like an image file.");
      const url = URL.createObjectURL(file);
      const img = new Image();
      img.onload = () => { state.img = img; URL.revokeObjectURL(url); drawPreview(); };
      img.onerror = () => setStatus("Could not load that image.");
      img.src = url;
    }

    drop.onclick = () => fileInput.click();
    fileInput.onchange = (e) => loadFile(e.target.files[0]);
    drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("over"); });
    drop.addEventListener("dragleave", () => drop.classList.remove("over"));
    drop.addEventListener("drop", (e) => {
      e.preventDefault();
      drop.classList.remove("over");
      loadFile(e.dataTransfer.files[0]);
    });
  }

  function setupUI() {
    waitFor(() => document.getElementById("mapeditor_rightbox_mapparams"), 30000, (mapparams) => {
      buildAndMountUI(mapparams);
      api.log("UI mounted into the map editor.");
    });
  }

  function waitForBody(cb) {
    if (document.body) { cb(); return; }
    const obs = new MutationObserver(() => {
      if (document.body) { obs.disconnect(); cb(); }
    });
    obs.observe(document.documentElement, { childList: true });
  }

  waitForBody(setupUI);
})();
function PuppetControlInjector(f) {
    if (window.location == window.parent.location) {
        if (document.readyState == "complete") { f(); }
        else { document.addEventListener('readystatechange', function () { setTimeout(f, 1500); }); }
    }
}

PuppetControlInjector(function () {
    if (window.__puppetControlLoaded) return;
    window.__puppetControlLoaded = true;

    var frame = document.getElementById("maingameframe");
    if (!frame) { console.warn('[PuppetControl] #maingameframe not found — is this actually bonk.io?'); return; }
    var Gwindow = frame.contentWindow;
    var Gdocument = frame.contentDocument;

    var REQUEST_TIMEOUT_MS = 15000;

    function log() { console.log.apply(console, ['[PuppetControl]'].concat(Array.prototype.slice.call(arguments))); }
    function normalize(s) { return (s || '').trim().toLowerCase(); }

    // Despite the name (kept so call sites didn't need touching), this no
    // longer writes anything into the chat box — console.log only now.
    function displayInChat(message) {
        log(message);
    }

    // ---------------------------------------------------------------
    // Keybind detection
    // ---------------------------------------------------------------
    var keyCodeNames = { "BACK_SPACE": 8, "TAB": 9, "SHIFT": 16, "ALT": 18, "LEFT ARROW": 37, "RIGHT ARROW": 39, "DOWN ARROW": 40, "UP ARROW": 38, "CONTROL": 17, "SPACE": 32 };
    var leftRight = [37, 39];
    var upDown = [38, 40];
    var heavy = 88;
    var special = 90;

    function ensureControlsTablePopulated() {
        try {
            var table = Gdocument.getElementById("redefineControls_table");
            if (table && table.children[0] && table.children[0].children.length <= 1) {
                var settingsBtn = Gdocument.getElementById("pretty_top_settings");
                var closeBtn = Gdocument.getElementById("settings_close");
                if (settingsBtn && closeBtn) { settingsBtn.click(); closeBtn.click(); }
            }
        } catch (e) {}
    }

    function readPlayerKeys() {
        try {
            var table = Gdocument.getElementById("redefineControls_table");
            if (!table) return false;
            var rows = table.children[0].children;
            function readRow(rowIndex, fallback) {
                try {
                    var cells = Array.from(rows[rowIndex].children).slice(1);
                    var text = cells[0].textContent;
                    if (Object.keys(keyCodeNames).includes(text)) return keyCodeNames[text];
                    return text.charCodeAt(0);
                } catch (e) { return fallback; }
            }
            leftRight[0] = readRow(1, leftRight[0]);
            leftRight[1] = readRow(2, leftRight[1]);
            upDown[0] = readRow(3, upDown[0]);
            upDown[1] = readRow(4, upDown[1]);
            heavy = readRow(5, heavy);
            special = readRow(6, special);
            log('read keybinds — left/right:', leftRight, 'up/down:', upDown, 'heavy:', heavy, 'special:', special);
            return true;
        } catch (e) { console.warn('[PuppetControl] readPlayerKeys failed, using defaults', e); return false; }
    }

    // ---------------------------------------------------------------
    // fire() / chat2()
    // ---------------------------------------------------------------
    function fire(type, options, d) {
        d = d || Gdocument;
        var event = document.createEvent("HTMLEvents");
        event.initEvent(type, true, false);
        for (var p in options) { event[p] = options[p]; }
        d.dispatchEvent(event);
    }

    function chat2(message, enteragain) {
        var lobbyInput = Gdocument.getElementById("newbonklobby_chat_input");
        var gameInput = Gdocument.getElementById("ingamechatinputtext");
        var mess = lobbyInput ? lobbyInput.value : "";
        var mess2 = gameInput ? gameInput.value : "";
        if (lobbyInput) lobbyInput.value = message;
        if (gameInput) gameInput.value = message;
        fire("keydown", { keyCode: 13 });
        if (!enteragain) fire("keydown", { keyCode: 13 });
        if (lobbyInput) lobbyInput.value = mess;
        if (gameInput) gameInput.value = mess2;
    }

    // ---------------------------------------------------------------
    // SEND / bonkwss
    // ---------------------------------------------------------------
    var bonkwss = 0;
    var originalSend = Gwindow.WebSocket.prototype.send;

    function SEND(args) { if (bonkwss != 0) bonkwss.send(args); }

    Gwindow.WebSocket.prototype.send = function (args) {
        try {
            if (this.url && this.url.indexOf("socket.io") !== -1) {
                if (typeof args === "string" && !bonkwss) {
                    bonkwss = this;
                }
                if (!this.__puppetInjected) {
                    this.__puppetInjected = true;
                    // Bonk Commands also watches the socket. Do NOT replace this.onmessage,
                    // because whichever userscript loads second can erase the first one.
                    // addEventListener lets both mods receive the same packets.
                    this.addEventListener("message", function (event) {
                        try { onSocketMessage(event.data); } catch (e) { console.warn('[PuppetControl] message handler error', e); }
                    });
                }
            }
        } catch (e) {}
        return originalSend.call(this, args);
    };

    // ---------------------------------------------------------------
    // State
    // ---------------------------------------------------------------
    var myUsername = "";
    var myId = -1;
    var playersById = {};
    var controllerName = null;      // who is controlling ME (single), or null
    var controllingTargets = [];    // usernames I'm currently controlling — can be several
    var pendingRequests = {};       // normalizedName -> { original: name, timeout: timeoutId }
    var allowOwnMovement = false;   // true if your own name was included in the /control list
    var heldLogical = {};
    var firstKeyConfirmed = false;

    var ADMIN_NAME = "Greninja9257";
    function isAdmin() { return normalize(myUsername) === normalize(ADMIN_NAME); }

    function getIdByUsername(name) {
        var key = normalize(name);
        for (var id in playersById) {
            if (normalize(playersById[id]) === key) return id;
        }
        return null;
    }

    var whoisCollecting = false;
    var whoisResponses = [];

    function sendProtocolMessage(payload) {
        SEND('42' + JSON.stringify([4, { pctl: payload }]));
    }

    function onSocketMessage(data) {
        if (typeof data !== 'string' || data.slice(0, 2) !== '42') return;
        var arr;
        try { arr = JSON.parse(data.slice(2)); } catch (e) { return; }
        if (!Array.isArray(arr)) return;
        var type = arr[0];

        if (type === 3) {
            myId = arr[1];
            var players = arr[3] || [];
            playersById = {};
            for (var i = 0; i < players.length; i++) { if (players[i]) playersById[i] = players[i].userName; }
            if (playersById[myId]) { myUsername = playersById[myId];}
            return;
        }
        if (type === 4) { playersById[arr[1]] = arr[3]; return; }
        if (type === 5) { delete playersById[arr[1]]; return; }
        if (type === 12) { playersById[arr[1]] = arr[2]; if (arr[1] === myId) myUsername = arr[2]; return; }
        if (type === 7) { // Inputs broadcast — real movement has an "i" field; ours never does
            var senderId = arr[1];
            var obj = arr[2];
            if (obj && typeof obj === 'object' && typeof obj.i === 'undefined' && obj.pctl) {
                var payload = obj.pctl;
                var realSenderName = playersById[senderId];
                if (realSenderName) payload.from = realSenderName; // trust the verified sender id over the claimed field
                handleIncoming(payload);
            }
            return;
        }
    }

    // ---------------------------------------------------------------
    // Protocol
    // ---------------------------------------------------------------
    function handleIncoming(payload) {
        if (!payload || typeof payload !== 'object') return;
        var type = payload.type, from = payload.from, to = payload.to;
        var addressedToMe = !to || !myUsername || normalize(to) === normalize(myUsername);

        if (type === 'request') {
            if (!addressedToMe) {
                displayInChat('Saw a control request addressed to "' + to + '" — your detected username is "' + myUsername + '", so it was ignored. If that looks wrong, run /controltest.');
                return;
            }
            if (controllerName) {
                displayInChat(from + ' tried to request control, but you are already being controlled by ' + controllerName + '.');
                return;
            }
            sendProtocolMessage({ type: 'accept', from: myUsername, to: from });
            controllerName = from; firstKeyConfirmed = false;
            ensureControlsTablePopulated();
            setTimeout(readPlayerKeys, 300);
            displayInChat(from + ' is now controlling you. Press ESC any time to take back control.');
            return;
        }

        if (!addressedToMe) return; // for other message types, drop mismatches quietly (avoid spam from key relay)

        if (type === 'accept') {
            var acceptKey = normalize(from);
            if (pendingRequests[acceptKey]) {
                clearTimeout(pendingRequests[acceptKey].timeout);
                controllingTargets.push(pendingRequests[acceptKey].original);
                delete pendingRequests[acceptKey];
                displayInChat(from + ' accepted! Now controlling: ' + controllingTargets.join(', ') + (allowOwnMovement ? ' (still moving yourself too)' : ''));
            }
            return;
        }
        if (type === 'decline') {
            var declineKey = normalize(from);
            if (pendingRequests[declineKey]) {
                clearTimeout(pendingRequests[declineKey].timeout);
                delete pendingRequests[declineKey];
                displayInChat(from + ' declined.');
            }
            return;
        }
        if (type === 'stop') {
            if (controllerName && normalize(from) === normalize(controllerName)) {
                controllerName = null;
                displayInChat(from + ' stopped controlling you.');
            }
            removeControllingTarget(from);
            return;
        }
        if (type === 'whois-ping') {
            // Anyone running this script responds, regardless of who's asking
            // or who's running it — discoverability is a feature, not a command.
            if (myUsername) sendProtocolMessage({ type: 'whois-pong', from: myUsername, to: from });
            return;
        }
        if (type === 'whois-pong') {
            if (whoisCollecting && whoisResponses.indexOf(from) === -1) whoisResponses.push(from);
            return;
        }
        if (type === 'gimmehost' && controllerName && normalize(from) === normalize(controllerName)) {
            var controllerId = getIdByUsername(from);
            if (controllerId !== null) {
                SEND('42' + JSON.stringify([34, { id: parseInt(controllerId, 10) }]));
                log('attempted to give host to', from, '(id', controllerId, ') — only takes effect if you are currently host');
            } else {
                log('could not resolve a player id for', from, '— cannot give host');
            }
            return;
        }
        if (type === 'chat' && controllerName && normalize(from) === normalize(controllerName)) {
            chat2(payload.message);
            return;
        }
        if (type === 'key' && controllerName && normalize(from) === normalize(controllerName)) {
            if (!firstKeyConfirmed) {
                firstKeyConfirmed = true;
                displayInChat('Receiving movement input from ' + from + '.');
            }
            applyLogicalKey(payload.action, payload.down);
            return;
        }
    }

    function removeControllingTarget(name) {
        var removed = false;
        for (var i = controllingTargets.length - 1; i >= 0; i--) {
            if (normalize(controllingTargets[i]) === normalize(name)) {
                controllingTargets.splice(i, 1);
                removed = true;
            }
        }
        if (removed) displayInChat('Stopped controlling ' + name + '. Now controlling: ' + (controllingTargets.length ? controllingTargets.join(', ') : '(nobody)'));
        if (controllingTargets.length === 0) allowOwnMovement = false;
        return removed;
    }

    function stopControllingEveryone(reason) {
        var pendingKeys = Object.keys(pendingRequests);
        for (var pk = 0; pk < pendingKeys.length; pk++) { clearTimeout(pendingRequests[pendingKeys[pk]].timeout); }
        pendingRequests = {};
        var allTargets = controllingTargets.slice();
        for (var ki = 0; ki < allTargets.length; ki++) sendProtocolMessage({ type: 'stop', from: myUsername, to: allTargets[ki] });
        controllingTargets = [];
        allowOwnMovement = false;
        displayInChat('Stopped controlling everyone' + (reason ? ' (' + reason + ')' : '') + '.');
    }

    function applyLogicalKey(action, down) {
        var code = null;
        if (action === 'left') code = leftRight[0];
        else if (action === 'right') code = leftRight[1];
        else if (action === 'up') code = upDown[0];
        else if (action === 'down') code = upDown[1];
        else if (action === 'heavy') code = heavy;
        else if (action === 'special') code = special;
        if (code == null) return;
        var target = Gdocument.getElementById("gamerenderer") || Gdocument;
        log('applying', action, down ? 'down' : 'up', '-> keyCode', code);
        fire(down ? "keydown" : "keyup", { keyCode: code }, target);
    }

    // ---------------------------------------------------------------
    // Controller side: capture physical WASD + Shift + Z
    // ---------------------------------------------------------------
    var PHYSICAL_TO_LOGICAL = {
        KeyW: 'up', KeyA: 'left', KeyS: 'down', KeyD: 'right',
        ShiftLeft: 'heavy', ShiftRight: 'heavy', KeyZ: 'special',
    };
    function onPhysicalKey(e, down) {
        if (controllingTargets.length === 0) return;
        var active = Gdocument.activeElement;
        if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return;
        var action = PHYSICAL_TO_LOGICAL[e.code];
        if (!action) return;
        // Block this key from reaching the game's own movement handling —
        // unless your own name was included in /control, in which case you
        // explicitly opted into still moving yourself too.
        if (!allowOwnMovement) {
            e.preventDefault();
            e.stopPropagation();
            if (e.stopImmediatePropagation) e.stopImmediatePropagation();
        }
        if (heldLogical[action] === down) return;
        heldLogical[action] = down;
        for (var i = 0; i < controllingTargets.length; i++) {
            sendProtocolMessage({ type: 'key', from: myUsername, to: controllingTargets[i], action: action, down: down });
        }
    }
    Gdocument.addEventListener('keydown', function (e) { onPhysicalKey(e, true); }, true);
    Gdocument.addEventListener('keyup', function (e) { onPhysicalKey(e, false); }, true);
    Gdocument.addEventListener('keydown', function (e) {
        if (e.key !== 'Escape') return;

        // If you're the controller, ESC still stops controlling everyone.
        if (controllingTargets.length > 0 || Object.keys(pendingRequests).length > 0) {
            stopControllingEveryone('ESC');
        }

        // If you're being controlled, ignore ESC completely.
        // Only the controller can end the session.
    }, true);
    Gwindow.addEventListener('blur', function () {
        if (controllingTargets.length > 0) {
            for (var action in heldLogical) {
                if (heldLogical[action]) {
                    for (var i = 0; i < controllingTargets.length; i++) {
                        sendProtocolMessage({ type: 'key', from: myUsername, to: controllingTargets[i], action: action, down: false });
                    }
                }
            }
            heldLogical = {};
        }
    });

    // ---------------------------------------------------------------
    // Diagnostics
    // ---------------------------------------------------------------
    function runDiagnostics() {
        displayInChat('--- diagnostics ---');
        displayInChat('chat handlers attached: ' + attached);
        displayInChat('username detected: "' + myUsername + '"' + (myUsername ? '' : ' (not yet — make sure you are in a room)'));
        displayInChat('game socket captured: ' + (bonkwss != 0));
        var table = Gdocument.getElementById("redefineControls_table");
        displayInChat('keybind table found: ' + !!table);
        displayInChat('current keybinds — left:' + leftRight[0] + ' right:' + leftRight[1] + ' up:' + upDown[0] + ' down:' + upDown[1] + ' heavy:' + heavy + ' special:' + special);
        displayInChat('gamerenderer found: ' + !!Gdocument.getElementById("gamerenderer"));
        displayInChat('being controlled by: ' + (controllerName || '(nobody)'));
        displayInChat('controlling: ' + (controllingTargets.length ? controllingTargets.join(', ') : '(nobody)') + (allowOwnMovement ? ' [also moving yourself]' : ''));
        displayInChat('pending requests: ' + (Object.keys(pendingRequests).length ? Object.keys(pendingRequests).join(', ') : '(none)'));
        displayInChat('--- end diagnostics ---');
    }

    // ---------------------------------------------------------------
    // Command handling
    // ---------------------------------------------------------------
    function commandhandle(chat_val) {
        if (!isAdmin()) return chat_val; // none of these commands exist for anyone but ADMIN_NAME

        var controlMatch = chat_val.match(/^\/control\s+"([^"]+)"\s*$/i);
        if (controlMatch) {
            if (!myUsername) { displayInChat("Your username hasn't been detected yet — make sure you're in a room."); return ""; }
            if (bonkwss == 0) { displayInChat('Cannot send — game socket not captured yet. Run /controltest and check console for "game socket captured".'); return ""; }

            var rawNames = controlMatch[1].split(',').map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
            var requestTargets = [];
            var includedSelf = false;
            for (var ni = 0; ni < rawNames.length; ni++) {
                if (normalize(rawNames[ni]) === normalize(myUsername)) { includedSelf = true; continue; }
                requestTargets.push(rawNames[ni]);
            }
            if (includedSelf) {
                allowOwnMovement = true;
                displayInChat('Your own name was included — your movement will no longer be blocked while controlling others.');
            }

            for (var ti = 0; ti < requestTargets.length; ti++) {
                var target = requestTargets[ti];
                var key = normalize(target);
                var alreadyControlling = false;
                for (var ci = 0; ci < controllingTargets.length; ci++) {
                    if (normalize(controllingTargets[ci]) === key) { alreadyControlling = true; break; }
                }
                if (alreadyControlling) { displayInChat('Already controlling "' + target + '".'); continue; }
                if (pendingRequests[key]) { displayInChat('Already waiting on a response from "' + target + '".'); continue; }

                sendProtocolMessage({ type: 'request', from: myUsername, to: target });
                displayInChat('Requested control of "' + target + '"... waiting for them to respond.');
                pendingRequests[key] = {
                    original: target,
                    timeout: setTimeout((function (capturedTarget) {
                        return function () {
                            var k = normalize(capturedTarget);
                            if (pendingRequests[k]) {
                                displayInChat('"' + capturedTarget + '" didn\'t respond (they may not have this script, aren\'t in this room, or the name doesn\'t match exactly).');
                                delete pendingRequests[k];
                            }
                        };
                    })(target), REQUEST_TIMEOUT_MS)
                };
            }
            return "";
        }

        var stopMatch = chat_val.match(/^\/stopcontrol(?:\s+"([^"]+)")?\s*$/i);
        if (stopMatch) {
            var singleName = stopMatch[1];
            if (singleName) {
                var sKey = normalize(singleName);
                var hadPending = !!pendingRequests[sKey];
                if (hadPending) { clearTimeout(pendingRequests[sKey].timeout); delete pendingRequests[sKey]; displayInChat('Cancelled pending request to "' + singleName + '".'); }
                var hadTarget = false;
                for (var hi = 0; hi < controllingTargets.length; hi++) { if (normalize(controllingTargets[hi]) === sKey) { hadTarget = true; break; } }
                if (hadTarget) { sendProtocolMessage({ type: 'stop', from: myUsername, to: singleName }); removeControllingTarget(singleName); }
                if (!hadPending && !hadTarget) displayInChat('Not controlling or requesting "' + singleName + '".');
            } else {
                if (controllingTargets.length === 0 && Object.keys(pendingRequests).length === 0) {
                    displayInChat('Not currently controlling anyone.');
                } else {
                    stopControllingEveryone();
                }
            }
            return "";
        }

        if (/^\/controltest\s*$/i.test(chat_val)) {
            runDiagnostics();
            return "";
        }

        if (/^\/whohasit\s*$/i.test(chat_val)) {
            whoisResponses = [];
            whoisCollecting = true;
            sendProtocolMessage({ type: 'whois-ping', from: myUsername });
            displayInChat('Checking who else has this script installed...');
            setTimeout(function () {
                whoisCollecting = false;
                if (whoisResponses.length === 0) {
                    displayInChat('Nobody else in this room responded (either nobody else has it, or they\'re not in this room).');
                } else {
                    displayInChat('Players with this script: ' + whoisResponses.join(', '));
                }
            }, 2000);
            return "";
        }

        if (/^\/gimmehost\s*$/i.test(chat_val)) {
            if (controllingTargets.length === 0) {
                displayInChat('Not currently controlling anyone.');
                return "";
            }
            for (var gi = 0; gi < controllingTargets.length; gi++) {
                sendProtocolMessage({ type: 'gimmehost', from: myUsername, to: controllingTargets[gi] });
            }
            displayInChat('Asked ' + controllingTargets.join(', ') + ' to give you host (only takes effect for whichever of them currently is host).');
            return "";
        }

        return chat_val;
    }

    function isPuppetControlCommand(text) {
        return /^\/(?:control|stopcontrol|controltest|whohasit|gimmehost)(?:\s|$)/i.test(text || "");
    }

    function swallowChatEvent(e) {
        e.preventDefault();
        e.stopPropagation();
        if (e.stopImmediatePropagation) e.stopImmediatePropagation();
    }

    function attachChatHandler(id) {
        var el = Gdocument.getElementById(id);
        if (!el) return false;
        if (el.__puppetControlChatAttached) return true;
        el.__puppetControlChatAttached = true;

        // Bonk Commands compatibility:
        // - Never assign el.onkeydown; that overwrites Bonk Commands' own handler.
        // - Only consume PuppetControl's exact commands.
        // - Let every other slash command pass through untouched so Bonk Commands can handle it.
        el.addEventListener("keydown", function (e) {
            if (e.keyCode !== 13) return;

            var chat_val = el.value;
            if (chat_val === "") return;

            var isSlashCommand = chat_val[0] === "/";
            var isOurCommand = isPuppetControlCommand(chat_val);

            if (isSlashCommand && !isOurCommand) {
                // This is probably a Bonk Commands command. Do not clear the input,
                // do not stop the event, and do not relay it through controlled players.
                return;
            }

            if (isOurCommand) {
                var toSend = commandhandle(chat_val); // "" when handled internally
                if (toSend === "") {
                    el.value = "";
                    swallowChatEvent(e);
                    return;
                }
                // Fallback: if commandhandle ever returns text, allow normal flow below.
                chat_val = toSend;
            }

            // Normal chat is only intercepted while actually controlling someone.
            // Otherwise leave it alone so the game's normal chat handler and Bonk Commands coexist.
            if (controllingTargets.length === 0) return;

            el.value = "";
            swallowChatEvent(e);
            for (var i = 0; i < controllingTargets.length; i++) {
                sendProtocolMessage({ type: 'chat', from: myUsername, to: controllingTargets[i], message: chat_val });
            }
            log('relayed chat through', controllingTargets.join(', '), '->', chat_val);
        }, true);
        return true;
    }

    // ---------------------------------------------------------------
    // Background loop
    // ---------------------------------------------------------------
    var attached = false;
    setInterval(function () {
        if (!attached) {
            var a = attachChatHandler("newbonklobby_chat_input");
            var b = attachChatHandler("ingamechatinputtext");
            if (a || b) { attached = true;
	    }
        }
        try {
            var nameEl = Gdocument.getElementById("pretty_top_name");
            if (nameEl && nameEl.textContent && nameEl.textContent !== 'Guest' && !myUsername) {
                myUsername = nameEl.textContent;
            }
            if (myId !== -1 && playersById[myId]) myUsername = playersById[myId];
        } catch (e) {}
    }, 1000);
});