Wplace Overlay Pro

Upload and overlay images onto tiles on wplace.live.

当前为 2025-08-08 提交的版本,查看 最新版本

// ==UserScript==
// @name         Wplace Overlay Pro
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  Upload and overlay images onto tiles on wplace.live.
// @author       shinkonet
// @match        https://wplace.live/*
// @license      MIT
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        unsafeWindow
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  // Default template: 12x12 white square with an X
  const DEFAULT_TEMPLATE_BASE64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAAXNSR0IArs4c6QAAAFFJREFUGJWNkEEOACEMAinZ/3+5Hki6ihzk0minFQQeVCrdndtVAChCh5vQMGeTcUP8kHE7AeDbp4czi8SDDkiv3P5oRMzB6NRzzFV0E/8vawFhxDAS2NwXhAAAAABJRU5ErkJggg==";

  // Persisted config
  const config = {
    overlayImageBase64: DEFAULT_TEMPLATE_BASE64,
    pixelUrl: "https://backend.wplace.live/s0/pixel/1851/1258?x=22&y=217",
    offsetX: 0,
    offsetY: 0,
    opacity: 0.7,
    overlayMode: "overlay",
    isPanelCollapsed: false,
    autoCapturePixelUrl: true
  };

  // Page realm
  const page = unsafeWindow;

  // Utils
  async function loadConfig() {
    try {
      config.overlayImageBase64  = await GM_getValue("overlayImageBase64",  config.overlayImageBase64);
      config.pixelUrl            = await GM_getValue("pixelUrl",            config.pixelUrl);
      config.offsetX             = await GM_getValue("offsetX",             config.offsetX);
      config.offsetY             = await GM_getValue("offsetY",             config.offsetY);
      config.opacity             = await GM_getValue("opacity",             config.opacity);
      config.overlayMode         = await GM_getValue("overlayMode",         config.overlayMode);
      config.isPanelCollapsed    = await GM_getValue("isPanelCollapsed",    config.isPanelCollapsed);
      config.autoCapturePixelUrl = await GM_getValue("autoCapturePixelUrl", config.autoCapturePixelUrl);
    } catch (e) {
      console.error("Overlay Pro: Failed to load config", e);
    }
  }
  async function saveConfig() {
    try {
      await GM_setValue("overlayImageBase64",  config.overlayImageBase64);
      await GM_setValue("pixelUrl",            config.pixelUrl);
      await GM_setValue("offsetX",             config.offsetX);
      await GM_setValue("offsetY",             config.offsetY);
      await GM_setValue("opacity",             config.opacity);
      await GM_setValue("overlayMode",         config.overlayMode);
      await GM_setValue("isPanelCollapsed",    config.isPanelCollapsed);
      await GM_setValue("autoCapturePixelUrl", config.autoCapturePixelUrl);
    } catch (e) {
      console.error("Overlay Pro: Failed to save config", e);
    }
  }

  function createCanvas(w, h) {
    // OffscreenCanvas is slightly more performant and avoids polluting the DOM.
    if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
    const c = document.createElement('canvas'); c.width = w; c.height = h; return c;
  }

  function canvasToBlob(canvas) {
    if (canvas.convertToBlob) return canvas.convertToBlob(); // For OffscreenCanvas
    return new Promise((resolve, reject) => canvas.toBlob(b => b ? resolve(b) : reject(new Error("toBlob failed")), "image/png"));
  }

  function blobToImage(blob) {
    return createImageBitmap(blob);
  }

  function loadImage(src) {
    return new Promise((resolve, reject) => {
      const img = new Image();
      img.crossOrigin = "anonymous";
      img.onload = () => resolve(img);
      img.onerror = reject;
      img.src = src;
    });
  }

  function extractPixelCoords(pixelUrl) {
    try {
      const u = new URL(pixelUrl);
      const parts = u.pathname.split('/');
      const sp = new URLSearchParams(u.search);
      return {
        chunk1: parseInt(parts[3], 10),
        chunk2: parseInt(parts[4], 10),
        posX: parseInt(sp.get('x') || '0', 10),
        posY: parseInt(sp.get('y') || '0', 10),
      };
    } catch {
      return { chunk1: 0, chunk2: 0, posX: 0, posY: 0 };
    }
  }

  function matchTileUrl(urlStr) {
    try {
      const u = new URL(urlStr, location.href);
      if (u.hostname !== 'backend.wplace.live' || !u.pathname.startsWith('/files/')) return null;
      const m = u.pathname.match(/\/(\d+)\/(\d+)\.png$/i);
      if (!m) return null;
      return { chunk1: parseInt(m[1], 10), chunk2: parseInt(m[2], 10) };
    } catch { return null; }
  }

  function matchPixelUrl(urlStr) {
    try {
      const u = new URL(urlStr, location.href);
      if (u.hostname !== 'backend.wplace.live') return null;
      const m = u.pathname.match(/\/s0\/pixel\/(\d+)\/(\d+)$/);
      if (!m) return null;
      const sp = u.searchParams;
      return {
        normalized: `https://backend.wplace.live/s0/pixel/${m[1]}/${m[2]}?x=${sp.get('x')||0}&y=${sp.get('y')||0}`
      };
    } catch { return null; }
  }

  // Build overlay data for a specific chunk
  async function buildOverlayDataForChunk(targetChunk1, targetChunk2) {
    if (!config.overlayImageBase64) return null;
    const img = await loadImage(config.overlayImageBase64);
    if (!img) return null;

    const base = extractPixelCoords(config.pixelUrl);
    if (!Number.isFinite(base.chunk1) || !Number.isFinite(base.chunk2)) return null;

    const TILE_SIZE = 1000;
    const drawX = (base.chunk1 * TILE_SIZE + base.posX + config.offsetX) - (targetChunk1 * TILE_SIZE);
    const drawY = (base.chunk2 * TILE_SIZE + base.posY + config.offsetY) - (targetChunk2 * TILE_SIZE);

    const overlayCanvas = createCanvas(TILE_SIZE, TILE_SIZE);
    const ctx = overlayCanvas.getContext('2d');
    ctx.drawImage(img, drawX, drawY);

    // Apply the opacity blending. This algorithm blends the template color with white.
    const imageData = ctx.getImageData(0, 0, TILE_SIZE, TILE_SIZE);
    const data = imageData.data;
    const colorStrength = config.opacity;
    const whiteStrength = 1 - colorStrength;

    for (let i = 0; i < data.length; i += 4) {
      if (data[i + 3] > 0) { // Only modify non-transparent pixels
        data[i]     = Math.round(data[i]     * colorStrength + 255 * whiteStrength);
        data[i + 1] = Math.round(data[i + 1] * colorStrength + 255 * whiteStrength);
        data[i + 2] = Math.round(data[i + 2] * colorStrength + 255 * whiteStrength);
        data[i + 3] = 255; // Make it fully opaque to sit behind the original
      }
    }
    return imageData;
  }

  // Merge overlay behind original pixels (returns a new Blob)
  async function mergeChunk(originalBlob, overlayData) {
    if (!overlayData) return originalBlob;

    const originalImage = await blobToImage(originalBlob);
    const w = originalImage.width;
    const h = originalImage.height;

    const canvas = createCanvas(w, h);
    const ctx = canvas.getContext('2d');

    // Draw the generated overlay first
    ctx.putImageData(overlayData, 0, 0);

    // Draw the original tile image on top of the overlay
    ctx.drawImage(originalImage, 0, 0);

    return await canvasToBlob(canvas);
  }

  // Network hook to intercept tile requests
  function hookFetch() {
    const originalFetch = page.fetch;
    if (originalFetch.__overlayHooked) return;

    const hookedFetch = async (input, init) => {
      const urlStr = typeof input === 'string' ? input : (input && input.url) || '';

      // Auto-capture pixel placement URLs
      if (config.autoCapturePixelUrl) {
        const pixelMatch = matchPixelUrl(urlStr);
        if (pixelMatch && pixelMatch.normalized !== config.pixelUrl) {
          config.pixelUrl = pixelMatch.normalized;
          saveConfig();
          updateUI();
        }
      }

      const tileMatch = matchTileUrl(urlStr);
      // If it's not a tile URL, or the overlay is off, proceed with the original request
      if (!tileMatch || config.overlayMode !== 'overlay' || !config.overlayImageBase64) {
        return originalFetch(input, init);
      }

      try {
        const response = await originalFetch(input, init);
        if (!response.ok) return response;

        const originalBlob = await response.blob();
        const overlayData = await buildOverlayDataForChunk(tileMatch.chunk1, tileMatch.chunk2);
        const mergedBlob = await mergeChunk(originalBlob, overlayData);

        // Return a new response with the merged image
        return new Response(mergedBlob, {
          status: response.status,
          statusText: response.statusText,
          headers: { 'Content-Type': 'image/png' }
        });
      } catch (e) {
        console.error("Overlay Pro: Error processing tile", e);
        // Fallback to original fetch on error
        return originalFetch(input, init);
      }
    };

    hookedFetch.__overlayHooked = true;
    page.fetch = hookedFetch;
    // Also hook window.fetch for broader compatibility
    window.fetch = hookedFetch;
    console.log('Overlay Pro (Slim): Fetch hook installed.');
  }

  // UI Injection and Management
  function injectStyles() {
    const style = document.createElement('style');
    style.textContent = `
      #overlay-pro-panel {
        position: fixed;
        top: 230px;
        right: 15px;
        z-index: 10001;
        background: rgba(20,20,20,0.9);
        backdrop-filter: blur(8px);
        border: 1px solid #444;
        border-radius: 8px;
        color: white;
        font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
        font-size: 14px;
        transition: transform 0.3s ease-in-out;
      }
      .op-header { padding: 8px 12px; background: #333; cursor: pointer; display: flex; justify-content: space-between; align-items: center; border-top-left-radius: 8px; border-top-right-radius: 8px; }
      .op-header h3 { margin: 0; font-size: 16px; user-select: none; }
      .op-toggle-btn { font-size: 20px; background: none; border: none; color: white; cursor: pointer; padding: 0 5px; }
      .op-content { padding: 12px; display: flex; flex-direction: column; gap: 12px; width: 280px; }
      .op-control-group { display: flex; flex-direction: column; gap: 6px; }
      .op-control-row { display: flex; align-items: center; gap: 8px; }
      .op-button { background: #555; color: white; border: 1px solid #777; border-radius: 4px; padding: 6px 10px; cursor: pointer; flex-grow: 1; }
      .op-button:hover { background: #666; }
      .op-button.active { background: #007bff; border-color: #0056b3; font-weight: bold; }
      .op-input { background: #222; border: 1px solid #555; color: white; border-radius: 4px; padding: 5px; flex-grow: 1; }
      .op-input[type="number"] { text-align: center; min-width: 60px; }
      .op-slider { width: 100%; }
      #op-coord-display { font-size: 12px; color: #ccc; word-break: break-all; }
      #op-dropzone { border: 2px dashed #555; border-radius: 4px; padding: 10px; text-align: center; background-size: contain; background-position: center; background-repeat: no-repeat; min-height: 56px; display: flex; align-items: center; justify-content: center; transition: border-color 0.2s; }
      #op-dropzone.dragover { border-color: #007bff; }
      .op-full-width-btn { width: 100%; }
    `;
    document.head.appendChild(style);
  }

  function createUI() {
    if (document.getElementById('overlay-pro-panel')) return;
    const panel = document.createElement('div');
    panel.id = 'overlay-pro-panel';
    panel.innerHTML = `
      <div class="op-header" id="op-header">
        <h3>Overlay Pro</h3>
        <button class="op-toggle-btn" id="op-panel-toggle">▶</button>
      </div>
      <div class="op-content" id="op-content">
        <div class="op-control-group">
          <label>Template Image</label>
          <div id="op-dropzone">Drop image here or click</div>
          <input type="file" id="op-file-input" accept="image/*" style="display: none;" />
        </div>
        <div class="op-control-group">
          <label>Mode</label>
          <button class="op-button op-full-width-btn" id="op-mode-toggle"></button>
        </div>
        <div class="op-control-group">
          <label>Auto-capture Pixel URL</label>
          <button class="op-button op-full-width-btn" id="op-autocap-toggle"></button>
          <div id="op-coord-display"></div>
        </div>
        <div class="op-control-group">
          <label>Offset</label>
          <div class="op-control-row">
            <span>X:</span>
            <input type="number" class="op-input" id="op-offset-x" />
            <button class="op-button" data-offset="x" data-amount="-1">-</button>
            <button class="op-button" data-offset="x" data-amount="1">+</button>
          </div>
          <div class="op-control-row">
            <span>Y:</span>
            <input type="number" class="op-input" id="op-offset-y" />
            <button class="op-button" data-offset="y" data-amount="-1">-</button>
            <button class="op-button" data-offset="y" data-amount="1">+</button>
          </div>
        </div>
        <div class="op-control-group">
          <label>Opacity: <span id="op-opacity-value">70%</span></label>
          <input type="range" min="0" max="1" step="0.05" class="op-slider" id="op-opacity-slider" />
        </div>
        <div class="op-control-group">
            <button class="op-button op-full-width-btn" id="op-reload-btn">Reload Tiles (Refresh Page)</button>
        </div>
      </div>
    `;
    document.body.appendChild(panel);
    addEventListeners();
    updateUI();
  }

  function handleImageFile(file) {
      if (file && file.type.startsWith('image/')) {
        const reader = new FileReader();
        reader.onload = async (event) => {
          config.overlayImageBase64 = event.target.result;
          await saveConfig();
          updateUI();
          alert("Template updated. You may need to refresh the page to see changes on already loaded tiles.");
        };
        reader.readAsDataURL(file);
      }
  }

  function addEventListeners() {
    document.getElementById('op-header').addEventListener('click', () => {
        config.isPanelCollapsed = !config.isPanelCollapsed;
        saveConfig();
        updateUI();
    });

    document.getElementById('op-mode-toggle').addEventListener('click', () => {
      config.overlayMode = config.overlayMode === 'overlay' ? 'original' : 'overlay';
      saveConfig();
      updateUI();
    });

    document.getElementById('op-autocap-toggle').addEventListener('click', () => {
      config.autoCapturePixelUrl = !config.autoCapturePixelUrl;
      saveConfig();
      updateUI();
    });

    document.querySelectorAll('[data-offset]').forEach(btn => btn.addEventListener('click', () => {
      const offset = btn.dataset.offset;
      const amount = parseInt(btn.dataset.amount, 10);
      if (offset === 'x') config.offsetX += amount;
      if (offset === 'y') config.offsetY += amount;
      saveConfig();
      updateUI();
    }));

    document.getElementById('op-offset-x').addEventListener('change', e => {
      config.offsetX = parseInt(e.target.value, 10) || 0;
      saveConfig();
    });

    document.getElementById('op-offset-y').addEventListener('change', e => {
      config.offsetY = parseInt(e.target.value, 10) || 0;
      saveConfig();
    });

    document.getElementById('op-opacity-slider').addEventListener('input', e => {
      config.opacity = parseFloat(e.target.value);
      document.getElementById('op-opacity-value').textContent = Math.round(config.opacity * 100) + '%';
    });
    document.getElementById('op-opacity-slider').addEventListener('change', () => {
      saveConfig();
    });

    const dropzone = document.getElementById('op-dropzone');
    const fileInput = document.getElementById('op-file-input');
    dropzone.addEventListener('click', () => fileInput.click());
    fileInput.addEventListener('change', (e) => handleImageFile(e.target.files[0]));
    dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('dragover'); });
    dropzone.addEventListener('dragleave', e => { e.preventDefault(); dropzone.classList.remove('dragover'); });
    dropzone.addEventListener('drop', e => {
      e.preventDefault();
      dropzone.classList.remove('dragover');
      handleImageFile(e.dataTransfer.files[0]);
    });

    document.getElementById('op-reload-btn').addEventListener('click', () => {
        location.reload();
    });
  }

  function updateUI() {
    const panel = document.getElementById('overlay-pro-panel');
    if (!panel) return;

    panel.classList.toggle('collapsed', config.isPanelCollapsed);
    document.getElementById('op-content').style.display = config.isPanelCollapsed ? 'none' : 'flex';
    document.getElementById('op-panel-toggle').textContent = config.isPanelCollapsed ? '◀' : '▶';

    const dropzone = document.getElementById('op-dropzone');
    if (config.overlayImageBase64 && config.overlayImageBase64 !== DEFAULT_TEMPLATE_BASE64) {
      dropzone.textContent = '';
      dropzone.style.backgroundImage = `url(${config.overlayImageBase64})`;
    } else {
      dropzone.textContent = 'Drop image or click';
      dropzone.style.backgroundImage = `url(${DEFAULT_TEMPLATE_BASE64})`;
    }

    const modeBtn = document.getElementById('op-mode-toggle');
    modeBtn.textContent = `Mode: ${config.overlayMode.charAt(0).toUpperCase() + config.overlayMode.slice(1)}`;
    modeBtn.classList.toggle('active', config.overlayMode === 'overlay');

    const autoBtn = document.getElementById('op-autocap-toggle');
    autoBtn.textContent = `Auto-capture: ${config.autoCapturePixelUrl ? 'ON' : 'OFF'}`;
    autoBtn.classList.toggle('active', !!config.autoCapturePixelUrl);

    const coords = extractPixelCoords(config.pixelUrl);
    document.getElementById('op-coord-display').textContent =
      `Ref: chunk ${coords.chunk1}/${coords.chunk2} at (${coords.posX}, ${coords.posY})`;

    document.getElementById('op-offset-x').value = config.offsetX;
    document.getElementById('op-offset-y').value = config.offsetY;
    document.getElementById('op-opacity-slider').value = String(config.opacity);
    document.getElementById('op-opacity-value').textContent = Math.round(config.opacity * 100) + '%';
  }

  // Main execution
  async function main() {
    await loadConfig();
    injectStyles();

    // Create the UI once the DOM is ready
    if (document.readyState === 'loading') {
      window.addEventListener('DOMContentLoaded', createUI);
    } else {
      createUI();
    }

    // Install the core network hook
    hookFetch();

    console.log("Overlay Pro (Slim) v3.0.0: Initialized successfully.");
  }

  main();
})();