mirror of
https://github.com/tiennm99/WPlace-AutoBOT.git
synced 2026-10-15 00:17:01 +00:00
refactor: Modular Auto-image
This commit is contained in:
1 parent
c52fb50c3d
commit
a59aeb1351
12 files changed
+200852
-2180
No files matched your search
@@ -41,6 +41,130 @@ async function executeLocalScript(scriptName, tabId) {
|
||||
try {
|
||||
console.log(`Loading script: ${scriptName}`);
|
||||
|
||||
// Check if we need to inject dependencies first for Auto-Image.js
|
||||
if (scriptName === 'Auto-Image.js') {
|
||||
console.log('🔑 Auto-Image.js detected, ensuring dependencies are loaded first...');
|
||||
|
||||
try {
|
||||
// First inject token-manager.js
|
||||
const tokenManagerUrl = chrome.runtime.getURL('scripts/token-manager.js');
|
||||
const tokenManagerResponse = await fetch(tokenManagerUrl);
|
||||
|
||||
if (tokenManagerResponse.ok) {
|
||||
const tokenManagerCode = await tokenManagerResponse.text();
|
||||
console.log('🔑 Token manager loaded, injecting first...');
|
||||
|
||||
// Execute token manager first
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: tabId },
|
||||
world: "MAIN",
|
||||
func: (code) => {
|
||||
console.log('%c🔑 Loading WPlace Token Manager...', 'color: #4ade80; font-weight: bold;');
|
||||
const script = document.createElement('script');
|
||||
script.textContent = code;
|
||||
document.head.appendChild(script);
|
||||
script.remove();
|
||||
console.log('%c✅ Token Manager loaded successfully', 'color: #4ade80; font-weight: bold;');
|
||||
},
|
||||
args: [tokenManagerCode]
|
||||
});
|
||||
|
||||
console.log('✅ Token manager injected successfully');
|
||||
} else {
|
||||
console.warn('⚠️ Could not load token-manager.js, proceeding without it');
|
||||
}
|
||||
|
||||
// Then inject image-processor.js
|
||||
const imageProcessorUrl = chrome.runtime.getURL('scripts/image-processor.js');
|
||||
const imageProcessorResponse = await fetch(imageProcessorUrl);
|
||||
|
||||
if (imageProcessorResponse.ok) {
|
||||
const imageProcessorCode = await imageProcessorResponse.text();
|
||||
console.log('🖼️ Image processor loaded, injecting second...');
|
||||
|
||||
// Execute image processor second
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: tabId },
|
||||
world: "MAIN",
|
||||
func: (code) => {
|
||||
console.log('%c🖼️ Loading WPlace Image Processor...', 'color: #3b82f6; font-weight: bold;');
|
||||
const script = document.createElement('script');
|
||||
script.textContent = code;
|
||||
document.head.appendChild(script);
|
||||
script.remove();
|
||||
console.log('%c✅ Image Processor loaded successfully', 'color: #3b82f6; font-weight: bold;');
|
||||
},
|
||||
args: [imageProcessorCode]
|
||||
});
|
||||
|
||||
console.log('✅ Image processor injected successfully');
|
||||
} else {
|
||||
console.warn('⚠️ Could not load image-processor.js, proceeding without it');
|
||||
}
|
||||
|
||||
// Then inject overlay-manager.js
|
||||
const overlayManagerUrl = chrome.runtime.getURL('scripts/overlay-manager.js');
|
||||
const overlayManagerResponse = await fetch(overlayManagerUrl);
|
||||
|
||||
if (overlayManagerResponse.ok) {
|
||||
const overlayManagerCode = await overlayManagerResponse.text();
|
||||
console.log('🎨 Overlay manager loaded, injecting third...');
|
||||
|
||||
// Execute overlay manager third
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: tabId },
|
||||
world: "MAIN",
|
||||
func: (code) => {
|
||||
console.log('%c🎨 Loading WPlace Overlay Manager...', 'color: #8b5cf6; font-weight: bold;');
|
||||
const script = document.createElement('script');
|
||||
script.textContent = code;
|
||||
document.head.appendChild(script);
|
||||
script.remove();
|
||||
console.log('%c✅ Overlay Manager loaded successfully', 'color: #8b5cf6; font-weight: bold;');
|
||||
},
|
||||
args: [overlayManagerCode]
|
||||
});
|
||||
|
||||
console.log('✅ Overlay manager injected successfully');
|
||||
} else {
|
||||
console.warn('⚠️ Could not load overlay-manager.js, proceeding without it');
|
||||
}
|
||||
|
||||
// Finally inject utils-manager.js
|
||||
const utilsManagerUrl = chrome.runtime.getURL('scripts/utils-manager.js');
|
||||
const utilsManagerResponse = await fetch(utilsManagerUrl);
|
||||
|
||||
if (utilsManagerResponse.ok) {
|
||||
const utilsManagerCode = await utilsManagerResponse.text();
|
||||
console.log('🛠️ Utils manager loaded, injecting fourth...');
|
||||
|
||||
// Execute utils manager fourth
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: tabId },
|
||||
world: "MAIN",
|
||||
func: (code) => {
|
||||
console.log('%c🛠️ Loading WPlace Utils Manager...', 'color: #f59e0b; font-weight: bold;');
|
||||
const script = document.createElement('script');
|
||||
script.textContent = code;
|
||||
document.head.appendChild(script);
|
||||
script.remove();
|
||||
console.log('%c✅ Utils Manager loaded successfully', 'color: #f59e0b; font-weight: bold;');
|
||||
},
|
||||
args: [utilsManagerCode]
|
||||
});
|
||||
|
||||
console.log('✅ Utils manager injected successfully');
|
||||
} else {
|
||||
console.warn('⚠️ Could not load utils-manager.js, proceeding without it');
|
||||
}
|
||||
|
||||
// Small delay to ensure dependencies are fully initialized
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Error loading dependencies:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine script path - Script-manager files are in root, others in scripts/
|
||||
let scriptUrl;
|
||||
if (scriptName === 'Script-manager.js' || scriptName === 'Script-manager-fixed.js') {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import json
|
||||
import base64
|
||||
from PIL import Image
|
||||
|
||||
def extract_image_from_data(data, output_filename):
|
||||
"""Extracts the original image from the imageData."""
|
||||
image_data = data['imageData']
|
||||
width = image_data['width']
|
||||
height = image_data['height']
|
||||
pixels = image_data['pixels']
|
||||
|
||||
# Create a new image with RGBA mode
|
||||
img = Image.new('RGBA', (width, height))
|
||||
# The pixel data is a flat list of R, G, B, A values
|
||||
img.putdata([tuple(pixels[i:i+4]) for i in range(0, len(pixels), 4)])
|
||||
img.save(output_filename)
|
||||
print(f"Saved original image to {output_filename}")
|
||||
|
||||
def extract_painted_map(data, output_filename):
|
||||
"""Extracts the painted map from the paintedMapPacked."""
|
||||
painted_map = data['paintedMapPacked']
|
||||
width = painted_map['width']
|
||||
height = painted_map['height']
|
||||
encoded_data = painted_map['data']
|
||||
|
||||
# Decode the base64 data
|
||||
decoded_data = base64.b64decode(encoded_data)
|
||||
|
||||
# Create a color palette from the available colors
|
||||
# The 'id' from the color list is the index for the color
|
||||
color_palette = {color['id']: tuple(color['rgb']) for color in data['state']['availableColors']}
|
||||
|
||||
# Create a new image with RGB mode
|
||||
img = Image.new('RGB', (width, height))
|
||||
pixels = []
|
||||
for byte in decoded_data:
|
||||
# Get the color from the palette using the byte as the key
|
||||
# Default to black if the color is not found
|
||||
color = color_palette.get(byte, (0, 0, 0))
|
||||
pixels.append(color)
|
||||
|
||||
img.putdata(pixels)
|
||||
img.save(output_filename)
|
||||
print(f"Saved painted map to {output_filename}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Load the JSON file
|
||||
with open('wplace-bot-progress-2025-09-12T19-27-37.json', 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Extract and save the original image
|
||||
extract_image_from_data(data, 'original_image.png')
|
||||
|
||||
# Extract and save the painted map
|
||||
extract_painted_map(data, 'painted_map.png')
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 216 B |
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+507
-2161
File diff suppressed because it is too large.
Load diff
@@ -1637,27 +1637,34 @@
|
||||
const damagedPixels = [];
|
||||
const { width, height, pixels } = state.imageData;
|
||||
|
||||
const ready = await overlayManager.waitForTiles(
|
||||
state.region.x,
|
||||
state.region.y,
|
||||
width,
|
||||
height,
|
||||
state.startPosition.x,
|
||||
state.startPosition.y,
|
||||
state.autonomousMode ? 20000 : 15000
|
||||
);
|
||||
// Check if we're working with restored data - skip tile waiting for restored saves
|
||||
const isRestoredData = state.availableColors && state.availableColors.length > 0 && state.colorsChecked;
|
||||
|
||||
if (!isRestoredData) {
|
||||
const ready = await overlayManager.waitForTiles(
|
||||
state.region.x,
|
||||
state.region.y,
|
||||
width,
|
||||
height,
|
||||
state.startPosition.x,
|
||||
state.startPosition.y,
|
||||
state.autonomousMode ? 20000 : 15000
|
||||
);
|
||||
|
||||
if (!ready) {
|
||||
Utils.addDebugLog('Failed to load required tiles for scanning', 'error');
|
||||
if (state.autonomousMode) {
|
||||
Utils.addDebugLog('Autonomous mode: will retry scan in 30 seconds', 'warning');
|
||||
setTimeout(() => {
|
||||
if (state.autoRepairEnabled && !state.stopFlag) {
|
||||
scanForDamage();
|
||||
}
|
||||
}, 30000);
|
||||
if (!ready) {
|
||||
Utils.addDebugLog('Failed to load required tiles for scanning', 'error');
|
||||
if (state.autonomousMode) {
|
||||
Utils.addDebugLog('Autonomous mode: will retry scan in 30 seconds', 'warning');
|
||||
setTimeout(() => {
|
||||
if (state.autoRepairEnabled && !state.stopFlag) {
|
||||
scanForDamage();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
} else {
|
||||
Utils.addDebugLog('Using restored data - skipping tile wait for damage scan', 'info');
|
||||
}
|
||||
|
||||
Utils.addDebugLog(`Scanning ${width}x${height} image for damage (transparent detection: ${state.paintTransparentPixels})...`, 'info');
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
// ==UserScript==
|
||||
// @name WPlace Image Processor
|
||||
// @namespace http://tampermonkey.net/
|
||||
// @version 2025-09-16.1
|
||||
// @description Image processing and color management for WPlace AutoBot
|
||||
// @author Wbot
|
||||
// @match https://wplace.live/*
|
||||
// @grant none
|
||||
// ==/UserScript==
|
||||
|
||||
/**
|
||||
* ImageProcessor - Handles image loading, processing, color conversion, and dithering for WPlace AutoBot
|
||||
* Extracted from Auto-Image.js for better modularity and reusability
|
||||
*/
|
||||
class ImageProcessor {
|
||||
constructor(imageSrc = null) {
|
||||
this.imageSrc = imageSrc;
|
||||
this.img = null;
|
||||
this.canvas = null;
|
||||
this.ctx = null;
|
||||
|
||||
// Dithering buffers
|
||||
this._ditherWorkBuf = null;
|
||||
this._ditherEligibleBuf = null;
|
||||
|
||||
// Color cache for performance optimization
|
||||
this._colorCache = new Map();
|
||||
this._labCache = new Map();
|
||||
|
||||
// Configuration constants
|
||||
this.TRANSPARENCY_THRESHOLD = 128;
|
||||
this.WHITE_THRESHOLD = 230;
|
||||
this.COLOR_CACHE_LIMIT = 15000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load image from source
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async load() {
|
||||
if (!this.imageSrc) {
|
||||
throw new Error('No image source provided');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.img = new Image();
|
||||
this.img.crossOrigin = 'anonymous';
|
||||
this.img.onload = () => {
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.canvas.width = this.img.width;
|
||||
this.canvas.height = this.img.height;
|
||||
this.ctx.drawImage(this.img, 0, 0);
|
||||
resolve();
|
||||
};
|
||||
this.img.onerror = reject;
|
||||
this.img.src = this.imageSrc;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image dimensions
|
||||
* @returns {{width: number, height: number}}
|
||||
*/
|
||||
getDimensions() {
|
||||
if (!this.canvas) {
|
||||
throw new Error('Image not loaded. Call load() first.');
|
||||
}
|
||||
return {
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pixel data from the image
|
||||
* @returns {Uint8ClampedArray} RGBA pixel data
|
||||
*/
|
||||
getPixelData() {
|
||||
if (!this.ctx) {
|
||||
throw new Error('Image not loaded. Call load() first.');
|
||||
}
|
||||
return this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize image to new dimensions
|
||||
* @param {number} newWidth - Target width
|
||||
* @param {number} newHeight - Target height
|
||||
* @returns {Uint8ClampedArray} Resized image data
|
||||
*/
|
||||
resize(newWidth, newHeight) {
|
||||
if (!this.canvas || !this.ctx) {
|
||||
throw new Error('Image not loaded. Call load() first.');
|
||||
}
|
||||
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
|
||||
tempCanvas.width = newWidth;
|
||||
tempCanvas.height = newHeight;
|
||||
|
||||
tempCtx.imageSmoothingEnabled = false;
|
||||
tempCtx.drawImage(this.canvas, 0, 0, newWidth, newHeight);
|
||||
|
||||
this.canvas.width = newWidth;
|
||||
this.canvas.height = newHeight;
|
||||
this.ctx.imageSmoothingEnabled = false;
|
||||
this.ctx.drawImage(tempCanvas, 0, 0);
|
||||
|
||||
return this.ctx.getImageData(0, 0, newWidth, newHeight).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate preview with resizing
|
||||
* @param {number} width - Preview width
|
||||
* @param {number} height - Preview height
|
||||
* @returns {string} Base64 data URL of the preview
|
||||
*/
|
||||
generatePreview(width, height) {
|
||||
if (!this.canvas) {
|
||||
throw new Error('Image not loaded. Call load() first.');
|
||||
}
|
||||
|
||||
const previewCanvas = document.createElement('canvas');
|
||||
const previewCtx = previewCanvas.getContext('2d');
|
||||
previewCanvas.width = width;
|
||||
previewCanvas.height = height;
|
||||
|
||||
previewCtx.imageSmoothingEnabled = false;
|
||||
previewCtx.drawImage(this.canvas, 0, 0, width, height);
|
||||
|
||||
return previewCanvas.toDataURL();
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// COLOR PROCESSING METHODS
|
||||
// =============================================
|
||||
|
||||
/**
|
||||
* Convert RGB to LAB color space
|
||||
* @param {number} r - Red value (0-255)
|
||||
* @param {number} g - Green value (0-255)
|
||||
* @param {number} b - Blue value (0-255)
|
||||
* @returns {number[]} LAB values [L, a, b]
|
||||
* @private
|
||||
*/
|
||||
_rgbToLab(r, g, b) {
|
||||
// Normalize RGB values
|
||||
let x = r / 255.0;
|
||||
let y = g / 255.0;
|
||||
let z = b / 255.0;
|
||||
|
||||
// Apply gamma correction
|
||||
x = x > 0.04045 ? Math.pow((x + 0.055) / 1.055, 2.4) : x / 12.92;
|
||||
y = y > 0.04045 ? Math.pow((y + 0.055) / 1.055, 2.4) : y / 12.92;
|
||||
z = z > 0.04045 ? Math.pow((z + 0.055) / 1.055, 2.4) : z / 12.92;
|
||||
|
||||
// Convert to XYZ using sRGB matrix
|
||||
let X = x * 0.4124564 + y * 0.3575761 + z * 0.1804375;
|
||||
let Y = x * 0.2126729 + y * 0.7151522 + z * 0.0721750;
|
||||
let Z = x * 0.0193339 + y * 0.1191920 + z * 0.9503041;
|
||||
|
||||
// Normalize for D65 illuminant
|
||||
X /= 0.95047;
|
||||
Y /= 1.00000;
|
||||
Z /= 1.08883;
|
||||
|
||||
// Convert to LAB
|
||||
X = X > 0.008856 ? Math.pow(X, 1/3) : (7.787 * X + 16/116);
|
||||
Y = Y > 0.008856 ? Math.pow(Y, 1/3) : (7.787 * Y + 16/116);
|
||||
Z = Z > 0.008856 ? Math.pow(Z, 1/3) : (7.787 * Z + 16/116);
|
||||
|
||||
const L = 116 * Y - 16;
|
||||
const a = 500 * (X - Y);
|
||||
const b2 = 200 * (Y - Z);
|
||||
|
||||
return [L, a, b2];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LAB values with caching
|
||||
* @param {number} r - Red value
|
||||
* @param {number} g - Green value
|
||||
* @param {number} b - Blue value
|
||||
* @returns {number[]} Cached LAB values
|
||||
* @private
|
||||
*/
|
||||
_getLab(r, g, b) {
|
||||
const key = (r << 16) | (g << 8) | b;
|
||||
let v = this._labCache.get(key);
|
||||
if (!v) {
|
||||
v = this._rgbToLab(r, g, b);
|
||||
this._labCache.set(key, v);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find closest color in palette using specified algorithm
|
||||
* @param {number} r - Red value
|
||||
* @param {number} g - Green value
|
||||
* @param {number} b - Blue value
|
||||
* @param {Array} palette - Array of RGB arrays [[r,g,b], ...]
|
||||
* @param {string} algorithm - 'legacy' or 'lab'
|
||||
* @param {Object} options - Additional options for color matching
|
||||
* @returns {number[]} Closest RGB color [r, g, b]
|
||||
*/
|
||||
findClosestPaletteColor(r, g, b, palette, algorithm = 'lab', options = {}) {
|
||||
if (!palette || palette.length === 0) {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
const { enableChromaPenalty = false, chromaPenaltyWeight = 0.15 } = options;
|
||||
|
||||
if (algorithm === 'legacy') {
|
||||
let menorDist = Infinity;
|
||||
let cor = [0, 0, 0];
|
||||
for (let i = 0; i < palette.length; i++) {
|
||||
const [pr, pg, pb] = palette[i];
|
||||
const rmean = (pr + r) / 2;
|
||||
const rdiff = pr - r;
|
||||
const gdiff = pg - g;
|
||||
const bdiff = pb - b;
|
||||
const dist = Math.sqrt(
|
||||
(((512 + rmean) * rdiff * rdiff) >> 8) +
|
||||
4 * gdiff * gdiff +
|
||||
(((767 - rmean) * bdiff * bdiff) >> 8)
|
||||
);
|
||||
if (dist < menorDist) {
|
||||
menorDist = dist;
|
||||
cor = [pr, pg, pb];
|
||||
}
|
||||
}
|
||||
return cor;
|
||||
}
|
||||
|
||||
// LAB algorithm
|
||||
const [Lt, at, bt] = this._getLab(r, g, b);
|
||||
const targetChroma = Math.sqrt(at * at + bt * bt);
|
||||
let best = null;
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (let i = 0; i < palette.length; i++) {
|
||||
const [pr, pg, pb] = palette[i];
|
||||
const [Lp, ap, bp] = this._getLab(pr, pg, pb);
|
||||
const dL = Lt - Lp;
|
||||
const da = at - ap;
|
||||
const db = bt - bp;
|
||||
let dist = dL * dL + da * da + db * db;
|
||||
|
||||
if (enableChromaPenalty && targetChroma > 20) {
|
||||
const candChroma = Math.sqrt(ap * ap + bp * bp);
|
||||
if (candChroma < targetChroma) {
|
||||
const chromaDiff = targetChroma - candChroma;
|
||||
dist += chromaDiff * chromaDiff * chromaPenaltyWeight;
|
||||
}
|
||||
}
|
||||
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = palette[i];
|
||||
if (bestDist === 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
return best || [0, 0, 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a pixel is considered white based on threshold
|
||||
* @param {number} r - Red value
|
||||
* @param {number} g - Green value
|
||||
* @param {number} b - Blue value
|
||||
* @param {number} threshold - White threshold (default: 230)
|
||||
* @returns {boolean} True if pixel is white
|
||||
*/
|
||||
isWhitePixel(r, g, b, threshold = this.WHITE_THRESHOLD) {
|
||||
return r >= threshold && g >= threshold && b >= threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve target RGB to closest available color with caching
|
||||
* @param {number[]} targetRgb - Target RGB array [r, g, b]
|
||||
* @param {Array} availableColors - Available colors with id and rgb properties
|
||||
* @param {Object} options - Matching options
|
||||
* @returns {Object} {id: number|null, rgb: number[]}
|
||||
*/
|
||||
resolveColor(targetRgb, availableColors, options = {}) {
|
||||
const {
|
||||
exactMatch = false,
|
||||
algorithm = 'lab',
|
||||
enableChromaPenalty = false,
|
||||
chromaPenaltyWeight = 0.15,
|
||||
whiteThreshold = this.WHITE_THRESHOLD
|
||||
} = options;
|
||||
|
||||
if (!availableColors || availableColors.length === 0) {
|
||||
return { id: null, rgb: targetRgb };
|
||||
}
|
||||
|
||||
const cacheKey = `${targetRgb[0]},${targetRgb[1]},${targetRgb[2]}|${algorithm}|${enableChromaPenalty ? 'c' : 'nc'}|${chromaPenaltyWeight}|${exactMatch ? 'exact' : 'closest'}`;
|
||||
|
||||
if (this._colorCache.has(cacheKey)) {
|
||||
return this._colorCache.get(cacheKey);
|
||||
}
|
||||
|
||||
// Check for exact match
|
||||
if (exactMatch) {
|
||||
const match = availableColors.find(
|
||||
(c) => c.rgb[0] === targetRgb[0] && c.rgb[1] === targetRgb[1] && c.rgb[2] === targetRgb[2]
|
||||
);
|
||||
const result = match ? { id: match.id, rgb: [...match.rgb] } : { id: null, rgb: targetRgb };
|
||||
this._colorCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check for white pixel matching
|
||||
if (
|
||||
targetRgb[0] >= whiteThreshold &&
|
||||
targetRgb[1] >= whiteThreshold &&
|
||||
targetRgb[2] >= whiteThreshold
|
||||
) {
|
||||
const whiteEntry = availableColors.find(
|
||||
(c) => c.rgb[0] >= whiteThreshold && c.rgb[1] >= whiteThreshold && c.rgb[2] >= whiteThreshold
|
||||
);
|
||||
if (whiteEntry) {
|
||||
const result = { id: whiteEntry.id, rgb: [...whiteEntry.rgb] };
|
||||
this._colorCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Find nearest color
|
||||
let bestId = availableColors[0].id;
|
||||
let bestRgb = [...availableColors[0].rgb];
|
||||
let bestScore = Infinity;
|
||||
|
||||
if (algorithm === 'legacy') {
|
||||
for (let i = 0; i < availableColors.length; i++) {
|
||||
const c = availableColors[i];
|
||||
const [r, g, b] = c.rgb;
|
||||
const rmean = (r + targetRgb[0]) / 2;
|
||||
const rdiff = r - targetRgb[0];
|
||||
const gdiff = g - targetRgb[1];
|
||||
const bdiff = b - targetRgb[2];
|
||||
const dist = Math.sqrt(
|
||||
(((512 + rmean) * rdiff * rdiff) >> 8) +
|
||||
4 * gdiff * gdiff +
|
||||
(((767 - rmean) * bdiff * bdiff) >> 8)
|
||||
);
|
||||
if (dist < bestScore) {
|
||||
bestScore = dist;
|
||||
bestId = c.id;
|
||||
bestRgb = [...c.rgb];
|
||||
if (dist === 0) break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const [Lt, at, bt] = this._getLab(targetRgb[0], targetRgb[1], targetRgb[2]);
|
||||
const targetChroma = Math.sqrt(at * at + bt * bt);
|
||||
const penaltyWeight = enableChromaPenalty ? chromaPenaltyWeight : 0;
|
||||
|
||||
for (let i = 0; i < availableColors.length; i++) {
|
||||
const c = availableColors[i];
|
||||
const [r, g, b] = c.rgb;
|
||||
const [L2, a2, b2] = this._getLab(r, g, b);
|
||||
const dL = Lt - L2;
|
||||
const da = at - a2;
|
||||
const db = bt - b2;
|
||||
let dist = dL * dL + da * da + db * db;
|
||||
|
||||
if (penaltyWeight > 0 && targetChroma > 20) {
|
||||
const candChroma = Math.sqrt(a2 * a2 + b2 * b2);
|
||||
if (candChroma < targetChroma) {
|
||||
const cd = targetChroma - candChroma;
|
||||
dist += cd * cd * penaltyWeight;
|
||||
}
|
||||
}
|
||||
|
||||
if (dist < bestScore) {
|
||||
bestScore = dist;
|
||||
bestId = c.id;
|
||||
bestRgb = [...c.rgb];
|
||||
if (dist === 0) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = { id: bestId, rgb: bestRgb };
|
||||
this._colorCache.set(cacheKey, result);
|
||||
|
||||
// Limit cache size
|
||||
if (this._colorCache.size > this.COLOR_CACHE_LIMIT) {
|
||||
const firstKey = this._colorCache.keys().next().value;
|
||||
this._colorCache.delete(firstKey);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// DITHERING METHODS (Floyd-Steinberg)
|
||||
// =============================================
|
||||
|
||||
/**
|
||||
* Ensure dithering buffers are properly sized
|
||||
* @param {number} n - Number of pixels
|
||||
* @returns {Object} {work: Float32Array, eligible: Uint8Array}
|
||||
* @private
|
||||
*/
|
||||
_ensureDitherBuffers(n) {
|
||||
if (!this._ditherWorkBuf || this._ditherWorkBuf.length !== n * 3) {
|
||||
this._ditherWorkBuf = new Float32Array(n * 3);
|
||||
}
|
||||
if (!this._ditherEligibleBuf || this._ditherEligibleBuf.length !== n) {
|
||||
this._ditherEligibleBuf = new Uint8Array(n);
|
||||
}
|
||||
return { work: this._ditherWorkBuf, eligible: this._ditherEligibleBuf };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Floyd-Steinberg dithering to image data
|
||||
* @param {Uint8ClampedArray} imageData - RGBA image data
|
||||
* @param {number} width - Image width
|
||||
* @param {number} height - Image height
|
||||
* @param {Array} palette - Color palette for dithering
|
||||
* @param {Object} options - Dithering options
|
||||
* @returns {Object} {data: Uint8ClampedArray, totalValidPixels: number}
|
||||
*/
|
||||
applyFloydSteinbergDithering(imageData, width, height, palette, options = {}) {
|
||||
const {
|
||||
paintTransparentPixels = false,
|
||||
paintWhitePixels = true,
|
||||
transparencyThreshold = this.TRANSPARENCY_THRESHOLD,
|
||||
whiteThreshold = this.WHITE_THRESHOLD,
|
||||
algorithm = 'lab',
|
||||
enableChromaPenalty = false,
|
||||
chromaPenaltyWeight = 0.15,
|
||||
mask = null
|
||||
} = options;
|
||||
|
||||
const data = new Uint8ClampedArray(imageData);
|
||||
const n = width * height;
|
||||
const { work, eligible } = this._ensureDitherBuffers(n);
|
||||
let totalValidPixels = 0;
|
||||
|
||||
// Initialize working buffers
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = y * width + x;
|
||||
const i4 = idx * 4;
|
||||
const r = data[i4];
|
||||
const g = data[i4 + 1];
|
||||
const b = data[i4 + 2];
|
||||
const a = data[i4 + 3];
|
||||
const masked = mask && mask[idx];
|
||||
|
||||
const isEligible =
|
||||
!masked &&
|
||||
(paintTransparentPixels || a >= transparencyThreshold) &&
|
||||
(paintWhitePixels || !this.isWhitePixel(r, g, b, whiteThreshold));
|
||||
|
||||
eligible[idx] = isEligible ? 1 : 0;
|
||||
work[idx * 3] = r;
|
||||
work[idx * 3 + 1] = g;
|
||||
work[idx * 3 + 2] = b;
|
||||
|
||||
if (!isEligible) {
|
||||
data[i4 + 3] = 0; // Make ineligible pixels transparent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error diffusion function
|
||||
const diffuse = (nx, ny, er, eg, eb, factor) => {
|
||||
if (nx < 0 || nx >= width || ny < 0 || ny >= height) return;
|
||||
const nidx = ny * width + nx;
|
||||
if (!eligible[nidx]) return;
|
||||
const base = nidx * 3;
|
||||
work[base] = Math.min(255, Math.max(0, work[base] + er * factor));
|
||||
work[base + 1] = Math.min(255, Math.max(0, work[base + 1] + eg * factor));
|
||||
work[base + 2] = Math.min(255, Math.max(0, work[base + 2] + eb * factor));
|
||||
};
|
||||
|
||||
// Apply Floyd-Steinberg dithering
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = y * width + x;
|
||||
if (!eligible[idx]) continue;
|
||||
|
||||
const base = idx * 3;
|
||||
const r0 = work[base];
|
||||
const g0 = work[base + 1];
|
||||
const b0 = work[base + 2];
|
||||
|
||||
const [nr, ng, nb] = this.findClosestPaletteColor(
|
||||
r0, g0, b0, palette, algorithm, { enableChromaPenalty, chromaPenaltyWeight }
|
||||
);
|
||||
|
||||
const i4 = idx * 4;
|
||||
data[i4] = nr;
|
||||
data[i4 + 1] = ng;
|
||||
data[i4 + 2] = nb;
|
||||
data[i4 + 3] = 255;
|
||||
totalValidPixels++;
|
||||
|
||||
// Calculate and diffuse error
|
||||
const er = r0 - nr;
|
||||
const eg = g0 - ng;
|
||||
const eb = b0 - nb;
|
||||
|
||||
diffuse(x + 1, y, er, eg, eb, 7 / 16);
|
||||
diffuse(x - 1, y + 1, er, eg, eb, 3 / 16);
|
||||
diffuse(x, y + 1, er, eg, eb, 5 / 16);
|
||||
diffuse(x + 1, y + 1, er, eg, eb, 1 / 16);
|
||||
}
|
||||
}
|
||||
|
||||
return { data, totalValidPixels };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply simple color quantization without dithering
|
||||
* @param {Uint8ClampedArray} imageData - RGBA image data
|
||||
* @param {number} width - Image width
|
||||
* @param {number} height - Image height
|
||||
* @param {Array} palette - Color palette for quantization
|
||||
* @param {Object} options - Quantization options
|
||||
* @returns {Object} {data: Uint8ClampedArray, totalValidPixels: number}
|
||||
*/
|
||||
applySimpleQuantization(imageData, width, height, palette, options = {}) {
|
||||
const {
|
||||
paintTransparentPixels = false,
|
||||
paintWhitePixels = true,
|
||||
transparencyThreshold = this.TRANSPARENCY_THRESHOLD,
|
||||
whiteThreshold = this.WHITE_THRESHOLD,
|
||||
algorithm = 'lab',
|
||||
enableChromaPenalty = false,
|
||||
chromaPenaltyWeight = 0.15
|
||||
} = options;
|
||||
|
||||
const data = new Uint8ClampedArray(imageData);
|
||||
let totalValidPixels = 0;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
const isEligible =
|
||||
(paintTransparentPixels || a >= transparencyThreshold) &&
|
||||
(paintWhitePixels || !this.isWhitePixel(r, g, b, whiteThreshold));
|
||||
|
||||
if (!isEligible) {
|
||||
data[i + 3] = 0; // Make ineligible pixels transparent
|
||||
continue;
|
||||
}
|
||||
|
||||
const [nr, ng, nb] = this.findClosestPaletteColor(
|
||||
r, g, b, palette, algorithm, { enableChromaPenalty, chromaPenaltyWeight }
|
||||
);
|
||||
|
||||
data[i] = nr;
|
||||
data[i + 1] = ng;
|
||||
data[i + 2] = nb;
|
||||
data[i + 3] = 255;
|
||||
totalValidPixels++;
|
||||
}
|
||||
|
||||
return { data, totalValidPixels };
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// UTILITY METHODS
|
||||
// =============================================
|
||||
|
||||
/**
|
||||
* Extract available colors from DOM elements
|
||||
* @returns {Array|null} Array of color objects with id and rgb properties
|
||||
*/
|
||||
extractAvailableColors() {
|
||||
const colorElements = document.querySelectorAll('.color-option, [data-color-id]');
|
||||
if (colorElements.length === 0) return null;
|
||||
|
||||
const colors = [];
|
||||
colorElements.forEach((element) => {
|
||||
const colorId = element.dataset.colorId || element.getAttribute('data-color-id');
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
const backgroundColor = computedStyle.backgroundColor;
|
||||
|
||||
if (backgroundColor && colorId) {
|
||||
const rgbMatch = backgroundColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
if (rgbMatch) {
|
||||
colors.push({
|
||||
id: parseInt(colorId),
|
||||
rgb: [parseInt(rgbMatch[1]), parseInt(rgbMatch[2]), parseInt(rgbMatch[3])]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return colors.length > 0 ? colors : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create file uploader for images
|
||||
* @returns {Promise<string>} Promise resolving to image data URL
|
||||
*/
|
||||
createImageUploader() {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/png,image/jpeg';
|
||||
input.onchange = () => {
|
||||
if (input.files && input.files[0]) {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(fr.result);
|
||||
fr.readAsDataURL(input.files[0]);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract available colors from DOM palette
|
||||
* @param {Object} colorMap - The color map object (optional, will use window.CONFIG if not provided)
|
||||
* @returns {Array|null} Array of available colors or null if none found
|
||||
*/
|
||||
extractAvailableColors(colorMap = null) {
|
||||
const colorElements = document.querySelectorAll('.tooltip button[id^="color-"]');
|
||||
if (colorElements.length === 0) {
|
||||
console.log('❌ No color elements found on page');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use provided colorMap or fallback to window.CONFIG
|
||||
const effectiveColorMap = colorMap || (window.CONFIG?.COLOR_MAP);
|
||||
|
||||
// Separate available and unavailable colors
|
||||
const availableColors = [];
|
||||
const unavailableColors = [];
|
||||
|
||||
Array.from(colorElements).forEach((el) => {
|
||||
const id = Number.parseInt(el.id.replace('color-', ''));
|
||||
if (id === 0) return; // Skip transparent color
|
||||
|
||||
const rgbStr = el.style.backgroundColor.match(/\d+/g);
|
||||
if (!rgbStr || rgbStr.length < 3) {
|
||||
console.warn(`Skipping color element ${el.id} — cannot parse RGB`);
|
||||
return;
|
||||
}
|
||||
const rgb = rgbStr.map(Number);
|
||||
|
||||
// Find color name from COLOR_MAP
|
||||
let name = `Unknown Color ${id}`;
|
||||
if (effectiveColorMap) {
|
||||
const colorInfo = Object.values(effectiveColorMap).find((color) => color.id === id);
|
||||
name = colorInfo ? colorInfo.name : name;
|
||||
}
|
||||
|
||||
const colorData = { id, name, rgb };
|
||||
|
||||
// Check if color is available (no SVG overlay means available)
|
||||
if (!el.querySelector('svg')) {
|
||||
availableColors.push(colorData);
|
||||
} else {
|
||||
unavailableColors.push(colorData);
|
||||
}
|
||||
});
|
||||
|
||||
// Console log detailed color information
|
||||
console.log('=== CAPTURED COLORS STATUS ===');
|
||||
console.log(`Total available colors: ${availableColors.length}`);
|
||||
console.log(`Total unavailable colors: ${unavailableColors.length}`);
|
||||
console.log(`Total colors scanned: ${availableColors.length + unavailableColors.length}`);
|
||||
|
||||
if (availableColors.length > 0) {
|
||||
console.log('\n--- AVAILABLE COLORS ---');
|
||||
availableColors.forEach((color, index) => {
|
||||
console.log(
|
||||
`${index + 1
|
||||
}. ID: ${color.id}, Name: "${color.name}", RGB: (${color.rgb[0]}, ${color.rgb[1]}, ${color.rgb[2]})`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (unavailableColors.length > 0) {
|
||||
console.log('\n--- UNAVAILABLE COLORS ---');
|
||||
unavailableColors.forEach((color, index) => {
|
||||
console.log(
|
||||
`${index + 1
|
||||
}. ID: ${color.id}, Name: "${color.name}", RGB: (${color.rgb[0]}, ${color.rgb[1]}, ${color.rgb[2]}) [LOCKED]`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('=== END COLOR STATUS ===');
|
||||
|
||||
return availableColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches to free memory
|
||||
*/
|
||||
clearCaches() {
|
||||
this._colorCache.clear();
|
||||
this._labCache.clear();
|
||||
this._ditherWorkBuf = null;
|
||||
this._ditherEligibleBuf = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
cleanup() {
|
||||
this.clearCaches();
|
||||
if (this.canvas) {
|
||||
this.canvas.width = 0;
|
||||
this.canvas.height = 0;
|
||||
}
|
||||
this.img = null;
|
||||
this.canvas = null;
|
||||
this.ctx = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create global instance
|
||||
window.WPlaceImageProcessor = ImageProcessor;
|
||||
|
||||
// Create global instance for Auto-Image.js compatibility
|
||||
window.globalImageProcessor = new ImageProcessor();
|
||||
|
||||
// Legacy compatibility - expose key methods globally for backward compatibility
|
||||
window.ImageProcessor = ImageProcessor;
|
||||
|
||||
console.log('✅ WPlace Image Processor loaded and ready');
|
||||
@@ -0,0 +1,837 @@
|
||||
// ==UserScript==
|
||||
// @name WPlace Overlay Manager
|
||||
// @namespace http://tampermonkey.net/
|
||||
// @version 2025-09-16.1
|
||||
// @description Overlay management for WPlace AutoBot - handles tile processing, chunking, and rendering
|
||||
// @author Wbot
|
||||
// @match https://wplace.live/*
|
||||
// @grant none
|
||||
// ==/UserScript==
|
||||
|
||||
/**
|
||||
* OverlayManager - Handles overlay processing, tile chunking, and canvas operations for WPlace AutoBot
|
||||
* Extracted from Auto-Image.js for better modularity and reusability
|
||||
*/
|
||||
class OverlayManager {
|
||||
constructor() {
|
||||
this.isEnabled = false;
|
||||
this.startCoords = null; // { region: {x, y}, pixel: {x, y} }
|
||||
this.imageBitmap = null;
|
||||
this.chunkedTiles = new Map(); // Map<"tileX,tileY", ImageBitmap>
|
||||
this.originalTiles = new Map(); // Map<"tileX,ttileY", ImageBitmap> store latest original tile bitmaps
|
||||
this.originalTilesData = new Map(); // Map<"tileX,tileY", {w,h,data:Uint8ClampedArray}> cache full ImageData for fast pixel reads
|
||||
this.tileSize = 1000;
|
||||
this.processPromise = null; // Track ongoing processing
|
||||
this.lastProcessedHash = null; // Cache invalidation
|
||||
this.workerPool = null; // Web worker pool for heavy processing
|
||||
this.neededTilesForSave = null; // Set of tiles needed for save file restoration
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle overlay enabled state
|
||||
* @returns {boolean} New enabled state
|
||||
*/
|
||||
toggle() {
|
||||
this.isEnabled = !this.isEnabled;
|
||||
console.log(`Overlay ${this.isEnabled ? 'enabled' : 'disabled'}.`);
|
||||
return this.isEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable overlay
|
||||
*/
|
||||
enable() {
|
||||
this.isEnabled = true;
|
||||
|
||||
// Force canvas refresh if script was loaded manually (not on startup)
|
||||
// This ensures overlay works even when tiles are already cached
|
||||
this._triggerCanvasRefreshIfNeeded();
|
||||
|
||||
// Also try direct canvas overlay approach as backup
|
||||
this._attemptDirectCanvasOverlay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to directly overlay on the existing canvas
|
||||
* @private
|
||||
*/
|
||||
_attemptDirectCanvasOverlay() {
|
||||
// Only try this if we have overlay data and it's been more than 5 seconds since page load
|
||||
const timeSinceLoad = Date.now() - (window.performance?.timing?.navigationStart || 0);
|
||||
|
||||
if (timeSinceLoad > 5000 && this.chunkedTiles.size > 0) {
|
||||
setTimeout(() => {
|
||||
this._drawDirectOverlay();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw overlay directly on the main canvas
|
||||
* @private
|
||||
*/
|
||||
_drawDirectOverlay() {
|
||||
try {
|
||||
const canvas = document.querySelector('canvas');
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store original composite operation
|
||||
const originalComposite = ctx.globalCompositeOperation;
|
||||
const originalAlpha = ctx.globalAlpha;
|
||||
|
||||
// Set overlay blending
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = window.state?.overlayOpacity || 0.6;
|
||||
|
||||
// Draw overlay chunks directly on canvas
|
||||
this.chunkedTiles.forEach((bitmap, tileKey) => {
|
||||
const [tileX, tileY] = tileKey.split(',').map(Number);
|
||||
|
||||
// Convert tile coordinates to canvas coordinates
|
||||
// This assumes WPlace uses 1000px tiles
|
||||
const canvasX = tileX * 1000;
|
||||
const canvasY = tileY * 1000;
|
||||
|
||||
try {
|
||||
ctx.drawImage(bitmap, canvasX, canvasY);
|
||||
} catch (error) {
|
||||
// Ignore individual tile draw errors
|
||||
}
|
||||
});
|
||||
|
||||
// Restore original settings
|
||||
ctx.globalCompositeOperation = originalComposite;
|
||||
ctx.globalAlpha = originalAlpha;
|
||||
|
||||
} catch (error) {
|
||||
// Ignore direct overlay errors silently
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger canvas refresh for manual script execution
|
||||
* @private
|
||||
*/
|
||||
_triggerCanvasRefreshIfNeeded() {
|
||||
// Only trigger refresh if we have overlay data and it's been more than 5 seconds since page load
|
||||
// (indicates manual execution rather than startup execution)
|
||||
const timeSinceLoad = Date.now() - (window.performance?.timing?.navigationStart || 0);
|
||||
|
||||
if (timeSinceLoad > 5000 && this.chunkedTiles.size > 0) {
|
||||
// Try multiple methods to refresh the canvas tiles
|
||||
setTimeout(() => {
|
||||
this._attemptCanvasRefresh();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh canvas tiles using various methods
|
||||
* @private
|
||||
*/
|
||||
_attemptCanvasRefresh() {
|
||||
try {
|
||||
// Method 1: Clear browser cache for tile URLs
|
||||
if ('caches' in window) {
|
||||
caches.keys().then(cacheNames => {
|
||||
cacheNames.forEach(cacheName => {
|
||||
caches.delete(cacheName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Method 2: Force browser to bypass cache by adding cache-busting parameter
|
||||
// We need to intercept and modify tile requests to add cache-busting
|
||||
const originalFetch = window.fetch;
|
||||
let fetchOverrideActive = true;
|
||||
|
||||
window.fetch = async function(...args) {
|
||||
const url = args[0] instanceof Request ? args[0].url : args[0];
|
||||
|
||||
if (fetchOverrideActive && typeof url === 'string' && url.match(/\/\d+\/\d+\.png/)) {
|
||||
// Add cache-busting parameter
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
const cacheBustUrl = `${url}${separator}_cb=${Date.now()}`;
|
||||
|
||||
if (args[0] instanceof Request) {
|
||||
args[0] = new Request(cacheBustUrl, args[0]);
|
||||
} else {
|
||||
args[0] = cacheBustUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return originalFetch.apply(this, args);
|
||||
};
|
||||
|
||||
// Disable cache-busting after 10 seconds
|
||||
setTimeout(() => {
|
||||
fetchOverrideActive = false;
|
||||
}, 10000);
|
||||
|
||||
// Method 3: Try to trigger a slight zoom change
|
||||
const canvas = document.querySelector('canvas');
|
||||
if (canvas) {
|
||||
// Dispatch a wheel event to trigger tile refresh
|
||||
const wheelEvent = new WheelEvent('wheel', {
|
||||
deltaY: 1,
|
||||
deltaMode: 0,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window
|
||||
});
|
||||
|
||||
canvas.dispatchEvent(wheelEvent);
|
||||
|
||||
// Immediately counter it
|
||||
setTimeout(() => {
|
||||
const counterEvent = new WheelEvent('wheel', {
|
||||
deltaY: -1,
|
||||
deltaMode: 0,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window
|
||||
});
|
||||
canvas.dispatchEvent(counterEvent);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// Method 4: Try to trigger resize event
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Could not trigger canvas refresh:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable overlay
|
||||
*/
|
||||
disable() {
|
||||
this.isEnabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all overlay data and disable
|
||||
*/
|
||||
clear() {
|
||||
this.disable();
|
||||
this.imageBitmap = null;
|
||||
this.chunkedTiles.clear();
|
||||
this.originalTiles.clear();
|
||||
this.originalTilesData.clear();
|
||||
this.lastProcessedHash = null;
|
||||
if (this.processPromise) {
|
||||
this.processPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the image bitmap for overlay processing
|
||||
* @param {ImageBitmap} imageBitmap - The image to use for overlay
|
||||
*/
|
||||
async setImage(imageBitmap) {
|
||||
this.imageBitmap = imageBitmap;
|
||||
this.lastProcessedHash = null; // Invalidate cache
|
||||
if (this.imageBitmap && this.startCoords) {
|
||||
await this.processImageIntoChunks();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the position for overlay rendering
|
||||
* @param {Object} startPosition - Starting pixel position {x, y}
|
||||
* @param {Object} region - Region coordinates {x, y}
|
||||
*/
|
||||
async setPosition(startPosition, region) {
|
||||
if (!startPosition || !region) {
|
||||
this.startCoords = null;
|
||||
this.chunkedTiles.clear();
|
||||
this.lastProcessedHash = null;
|
||||
return;
|
||||
}
|
||||
this.startCoords = { region, pixel: startPosition };
|
||||
this.lastProcessedHash = null; // Invalidate cache
|
||||
if (this.imageBitmap) {
|
||||
await this.processImageIntoChunks();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hash for cache invalidation
|
||||
* @returns {string|null} Hash string or null if no data
|
||||
* @private
|
||||
*/
|
||||
_generateProcessHash() {
|
||||
if (!this.imageBitmap || !this.startCoords) return null;
|
||||
const { width, height } = this.imageBitmap;
|
||||
const { x: px, y: py } = this.startCoords.pixel;
|
||||
const { x: rx, y: ry } = this.startCoords.region;
|
||||
|
||||
// Access global state for overlay settings
|
||||
const blueMarbleEnabled = window.state?.blueMarbleEnabled || false;
|
||||
const overlayOpacity = window.state?.overlayOpacity || 0.6;
|
||||
|
||||
return `${width}x${height}_${px},${py}_${rx},${ry}_${blueMarbleEnabled}_${overlayOpacity}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process image into chunks with caching and batch processing
|
||||
*/
|
||||
async processImageIntoChunks() {
|
||||
if (!this.imageBitmap || !this.startCoords) {
|
||||
console.warn('🚫 Cannot process chunks - missing imageBitmap or startCoords');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔄 Processing image into chunks...', {
|
||||
imageSize: `${this.imageBitmap.width}x${this.imageBitmap.height}`,
|
||||
startCoords: this.startCoords
|
||||
});
|
||||
|
||||
// Check if we're already processing to avoid duplicate work
|
||||
if (this.processPromise) {
|
||||
console.log('⏳ Already processing chunks - waiting...');
|
||||
return this.processPromise;
|
||||
}
|
||||
|
||||
// Check cache validity
|
||||
const currentHash = this._generateProcessHash();
|
||||
if (this.lastProcessedHash === currentHash && this.chunkedTiles.size > 0) {
|
||||
// Using cached overlay chunks
|
||||
return;
|
||||
}
|
||||
|
||||
// Start processing
|
||||
console.log('🚀 Starting new chunk processing...');
|
||||
this.processPromise = this._doProcessImageIntoChunks();
|
||||
try {
|
||||
await this.processPromise;
|
||||
this.lastProcessedHash = currentHash;
|
||||
console.log(`✅ Chunk processing complete - ${this.chunkedTiles.size} tiles created`);
|
||||
} finally {
|
||||
this.processPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to process image into chunks
|
||||
* @private
|
||||
*/
|
||||
async _doProcessImageIntoChunks() {
|
||||
const startTime = performance.now();
|
||||
this.chunkedTiles.clear();
|
||||
|
||||
const { width: imageWidth, height: imageHeight } = this.imageBitmap;
|
||||
const { x: startPixelX, y: startPixelY } = this.startCoords.pixel;
|
||||
const { x: startRegionX, y: startRegionY } = this.startCoords.region;
|
||||
|
||||
const { startTileX, startTileY, endTileX, endTileY } = this.calculateTileRange(
|
||||
startRegionX,
|
||||
startRegionY,
|
||||
startPixelX,
|
||||
startPixelY,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
this.tileSize
|
||||
);
|
||||
|
||||
const totalTiles = (endTileX - startTileX + 1) * (endTileY - startTileY + 1);
|
||||
console.log(`🔄 Processing ${totalTiles} overlay tiles...`);
|
||||
|
||||
// Process tiles in batches to avoid blocking the main thread
|
||||
const batchSize = 4; // Process 4 tiles at a time
|
||||
const tilesToProcess = [];
|
||||
|
||||
for (let ty = startTileY; ty <= endTileY; ty++) {
|
||||
for (let tx = startTileX; tx <= endTileX; tx++) {
|
||||
tilesToProcess.push({ tx, ty });
|
||||
}
|
||||
}
|
||||
|
||||
// Process tiles in batches with yielding
|
||||
for (let i = 0; i < tilesToProcess.length; i += batchSize) {
|
||||
const batch = tilesToProcess.slice(i, i + batchSize);
|
||||
|
||||
await Promise.all(
|
||||
batch.map(async ({ tx, ty }) => {
|
||||
const tileKey = `${tx},${ty}`;
|
||||
const chunkBitmap = await this._processTile(
|
||||
tx,
|
||||
ty,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
startPixelX,
|
||||
startPixelY,
|
||||
startRegionX,
|
||||
startRegionY
|
||||
);
|
||||
if (chunkBitmap) {
|
||||
this.chunkedTiles.set(tileKey, chunkBitmap);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Yield control to prevent blocking
|
||||
if (i + batchSize < tilesToProcess.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
const processingTime = performance.now() - startTime;
|
||||
console.log(
|
||||
`✅ Overlay processed ${this.chunkedTiles.size} tiles in ${Math.round(processingTime)}ms`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single tile
|
||||
* @param {number} tx - Tile X coordinate
|
||||
* @param {number} ty - Tile Y coordinate
|
||||
* @param {number} imageWidth - Image width
|
||||
* @param {number} imageHeight - Image height
|
||||
* @param {number} startPixelX - Starting pixel X
|
||||
* @param {number} startPixelY - Starting pixel Y
|
||||
* @param {number} startRegionX - Starting region X
|
||||
* @param {number} startRegionY - Starting region Y
|
||||
* @returns {Promise<ImageBitmap|null>} Processed tile bitmap
|
||||
* @private
|
||||
*/
|
||||
async _processTile(
|
||||
tx,
|
||||
ty,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
startPixelX,
|
||||
startPixelY,
|
||||
startRegionX,
|
||||
startRegionY
|
||||
) {
|
||||
const tileKey = `${tx},${ty}`;
|
||||
|
||||
// Calculate the portion of the image that overlaps with this tile
|
||||
const imgStartX = (tx - startRegionX) * this.tileSize - startPixelX;
|
||||
const imgStartY = (ty - startRegionY) * this.tileSize - startPixelY;
|
||||
|
||||
// Crop coordinates within the source image
|
||||
const sX = Math.max(0, imgStartX);
|
||||
const sY = Math.max(0, imgStartY);
|
||||
const sW = Math.min(imageWidth - sX, this.tileSize - (sX - imgStartX));
|
||||
const sH = Math.min(imageHeight - sY, this.tileSize - (sY - imgStartY));
|
||||
|
||||
if (sW <= 0 || sH <= 0) return null;
|
||||
|
||||
// Destination coordinates on the new chunk canvas
|
||||
const dX = Math.max(0, -imgStartX);
|
||||
const dY = Math.max(0, -imgStartY);
|
||||
|
||||
const chunkCanvas = new OffscreenCanvas(this.tileSize, this.tileSize);
|
||||
const chunkCtx = chunkCanvas.getContext('2d');
|
||||
chunkCtx.imageSmoothingEnabled = false;
|
||||
|
||||
chunkCtx.drawImage(this.imageBitmap, sX, sY, sW, sH, dX, dY, sW, sH);
|
||||
|
||||
// Blue marble effect with faster pixel manipulation
|
||||
const blueMarbleEnabled = window.state?.blueMarbleEnabled || false;
|
||||
if (blueMarbleEnabled) {
|
||||
const imageData = chunkCtx.getImageData(dX, dY, sW, sH);
|
||||
const data = imageData.data;
|
||||
|
||||
// Faster pixel manipulation using typed arrays
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const pixelIndex = i / 4;
|
||||
const pixelY = Math.floor(pixelIndex / sW);
|
||||
const pixelX = pixelIndex % sW;
|
||||
|
||||
if ((pixelX + pixelY) % 2 === 0 && data[i + 3] > 0) {
|
||||
data[i + 3] = 0; // Set alpha to 0
|
||||
}
|
||||
}
|
||||
|
||||
chunkCtx.putImageData(imageData, dX, dY);
|
||||
}
|
||||
|
||||
return await chunkCanvas.transferToImageBitmap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and respond to tile requests with overlay compositing
|
||||
* @param {Object} eventData - Event data containing endpoint, blobID, and blobData
|
||||
*/
|
||||
async processAndRespondToTileRequest(eventData) {
|
||||
const { endpoint, blobID, blobData } = eventData;
|
||||
|
||||
let finalBlob = blobData;
|
||||
|
||||
if (this.isEnabled && this.chunkedTiles.size > 0) {
|
||||
const tileMatch = endpoint.match(/(\d+)\/(\d+)\.png/);
|
||||
if (tileMatch) {
|
||||
const tileX = parseInt(tileMatch[1], 10);
|
||||
const tileY = parseInt(tileMatch[2], 10);
|
||||
const tileKey = `${tileX},${tileY}`;
|
||||
|
||||
const chunkBitmap = this.chunkedTiles.get(tileKey);
|
||||
// Also store the original tile bitmap for later pixel color checks
|
||||
try {
|
||||
const originalBitmap = await createImageBitmap(blobData);
|
||||
this.originalTiles.set(tileKey, originalBitmap);
|
||||
// Cache full ImageData for fast pixel access (avoid repeated drawImage/getImageData)
|
||||
try {
|
||||
let canvas, ctx;
|
||||
if (typeof OffscreenCanvas !== 'undefined') {
|
||||
canvas = new OffscreenCanvas(originalBitmap.width, originalBitmap.height);
|
||||
ctx = canvas.getContext('2d');
|
||||
} else {
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.width = originalBitmap.width;
|
||||
canvas.height = originalBitmap.height;
|
||||
ctx = canvas.getContext('2d');
|
||||
}
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(originalBitmap, 0, 0);
|
||||
const imgData = ctx.getImageData(0, 0, originalBitmap.width, originalBitmap.height);
|
||||
// Store typed array copy to avoid retaining large canvas
|
||||
this.originalTilesData.set(tileKey, {
|
||||
w: originalBitmap.width,
|
||||
h: originalBitmap.height,
|
||||
data: new Uint8ClampedArray(imgData.data),
|
||||
});
|
||||
} catch (e) {
|
||||
// If ImageData extraction fails, still keep the bitmap as fallback
|
||||
console.warn('OverlayManager: could not cache ImageData for', tileKey, e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('OverlayManager: could not create original bitmap for', tileKey, e);
|
||||
}
|
||||
|
||||
if (chunkBitmap) {
|
||||
try {
|
||||
// Use faster compositing for better performance
|
||||
finalBlob = await this._compositeTileOptimized(blobData, chunkBitmap);
|
||||
} catch (e) {
|
||||
console.error('Error compositing overlay:', e);
|
||||
// Fallback to original tile on error
|
||||
finalBlob = blobData;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send the (possibly modified) blob back to the injected script
|
||||
window.postMessage(
|
||||
{
|
||||
source: 'auto-image-overlay',
|
||||
blobID: blobID,
|
||||
blobData: finalBlob,
|
||||
},
|
||||
'*'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pixel color from a tile
|
||||
* @param {number} tileX - Tile X coordinate
|
||||
* @param {number} tileY - Tile Y coordinate
|
||||
* @param {number} pixelX - Pixel X within tile
|
||||
* @param {number} pixelY - Pixel Y within tile
|
||||
* @returns {Promise<Array|null>} [r,g,b,a] or null
|
||||
*/
|
||||
async getTilePixelColor(tileX, tileY, pixelX, pixelY) {
|
||||
const tileKey = `${tileX},${tileY}`;
|
||||
|
||||
// Get transparency threshold from global state or config
|
||||
const alphaThresh = window.state?.customTransparencyThreshold ||
|
||||
window.CONFIG?.TRANSPARENCY_THRESHOLD || 10;
|
||||
|
||||
// 1. Prefer cached ImageData if available
|
||||
const cached = this.originalTilesData.get(tileKey);
|
||||
if (cached && cached.data && cached.w > 0 && cached.h > 0) {
|
||||
const x = Math.max(0, Math.min(cached.w - 1, pixelX));
|
||||
const y = Math.max(0, Math.min(cached.h - 1, pixelY));
|
||||
const idx = (y * cached.w + x) * 4;
|
||||
const d = cached.data;
|
||||
const a = d[idx + 3];
|
||||
|
||||
const paintTransparentPixels = window.state?.paintTransparentPixels || false;
|
||||
if (!paintTransparentPixels && a < alphaThresh) {
|
||||
// Treat as transparent / unavailable
|
||||
return null;
|
||||
}
|
||||
return [d[idx], d[idx + 1], d[idx + 2], a];
|
||||
}
|
||||
|
||||
// 2. Fallback: use bitmap, with retry
|
||||
const maxRetries = 3;
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
const bitmap = this.originalTiles.get(tileKey);
|
||||
if (!bitmap) {
|
||||
if (attempt === maxRetries) {
|
||||
console.warn('OverlayManager: no bitmap for', tileKey, 'after', maxRetries, 'attempts');
|
||||
} else {
|
||||
await this._sleep(50 * attempt); // exponential delay
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
let canvas, ctx;
|
||||
if (typeof OffscreenCanvas !== 'undefined') {
|
||||
canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
ctx = canvas.getContext('2d');
|
||||
} else {
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
ctx = canvas.getContext('2d');
|
||||
}
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
|
||||
const x = Math.max(0, Math.min(bitmap.width - 1, pixelX));
|
||||
const y = Math.max(0, Math.min(bitmap.height - 1, pixelY));
|
||||
const data = ctx.getImageData(x, y, 1, 1).data;
|
||||
const a = data[3];
|
||||
|
||||
const paintTransparentPixels = window.state?.paintTransparentPixels || false;
|
||||
if (!paintTransparentPixels && a < alphaThresh) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [data[0], data[1], data[2], a];
|
||||
} catch (e) {
|
||||
console.warn('OverlayManager: failed to read pixel (attempt', attempt, ')', tileKey, e);
|
||||
if (attempt < maxRetries) {
|
||||
await this._sleep(50 * attempt);
|
||||
} else {
|
||||
console.error(
|
||||
'OverlayManager: failed to read pixel after',
|
||||
maxRetries,
|
||||
'attempts',
|
||||
tileKey
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. If everything fails, return null
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite tile with overlay using optimized rendering
|
||||
* @param {Blob} originalBlob - Original tile blob
|
||||
* @param {ImageBitmap} overlayBitmap - Overlay bitmap
|
||||
* @returns {Promise<Blob>} Composited tile blob
|
||||
* @private
|
||||
*/
|
||||
async _compositeTileOptimized(originalBlob, overlayBitmap) {
|
||||
const originalBitmap = await createImageBitmap(originalBlob);
|
||||
const canvas = new OffscreenCanvas(originalBitmap.width, originalBitmap.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Disable antialiasing for pixel-perfect rendering
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Draw original tile first
|
||||
ctx.drawImage(originalBitmap, 0, 0);
|
||||
|
||||
// Set opacity and draw overlay with optimized blend mode
|
||||
const overlayOpacity = window.state?.overlayOpacity || 0.6;
|
||||
ctx.globalAlpha = overlayOpacity;
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.drawImage(overlayBitmap, 0, 0);
|
||||
|
||||
// Use faster blob conversion with compression settings
|
||||
return await canvas.convertToBlob({
|
||||
type: 'image/png',
|
||||
quality: 0.95, // Slight compression for faster processing
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until all required tiles are loaded and cached
|
||||
* @param {number} startRegionX - Starting region X
|
||||
* @param {number} startRegionY - Starting region Y
|
||||
* @param {number} pixelWidth - Image width in pixels
|
||||
* @param {number} pixelHeight - Image height in pixels
|
||||
* @param {number} startPixelX - Starting pixel X offset
|
||||
* @param {number} startPixelY - Starting pixel Y offset
|
||||
* @param {number} timeoutMs - Timeout in milliseconds
|
||||
* @returns {Promise<boolean>} True if tiles are ready
|
||||
*/
|
||||
async waitForTiles(
|
||||
startRegionX,
|
||||
startRegionY,
|
||||
pixelWidth,
|
||||
pixelHeight,
|
||||
startPixelX = 0,
|
||||
startPixelY = 0,
|
||||
timeoutMs = 10000
|
||||
) {
|
||||
const { startTileX, startTileY, endTileX, endTileY } = this.calculateTileRange(
|
||||
startRegionX,
|
||||
startRegionY,
|
||||
startPixelX,
|
||||
startPixelY,
|
||||
pixelWidth,
|
||||
pixelHeight,
|
||||
this.tileSize
|
||||
);
|
||||
|
||||
const requiredTiles = [];
|
||||
for (let ty = startTileY; ty <= endTileY; ty++) {
|
||||
for (let tx = startTileX; tx <= endTileX; tx++) {
|
||||
requiredTiles.push(`${tx},${ty}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (requiredTiles.length === 0) return true;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const stopFlag = window.state?.stopFlag || false;
|
||||
if (stopFlag) {
|
||||
console.log('waitForTiles: stopped by user');
|
||||
return false;
|
||||
}
|
||||
|
||||
const missing = requiredTiles.filter((key) => !this.originalTiles.has(key));
|
||||
if (missing.length === 0) {
|
||||
console.log(`✅ All ${requiredTiles.length} required tiles are loaded`);
|
||||
return true;
|
||||
}
|
||||
|
||||
await this._sleep(100);
|
||||
}
|
||||
|
||||
console.warn(`❌ Timeout waiting for tiles: ${requiredTiles.length} required,
|
||||
${requiredTiles.filter((k) => this.originalTiles.has(k)).length} loaded`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tile range for given parameters
|
||||
* @param {number} startRegionX - Starting region X
|
||||
* @param {number} startRegionY - Starting region Y
|
||||
* @param {number} startPixelX - Starting pixel X
|
||||
* @param {number} startPixelY - Starting pixel Y
|
||||
* @param {number} width - Width in pixels
|
||||
* @param {number} height - Height in pixels
|
||||
* @param {number} tileSize - Size of each tile (default 1000)
|
||||
* @returns {{startTileX: number, startTileY: number, endTileX: number, endTileY: number}}
|
||||
*/
|
||||
calculateTileRange(
|
||||
startRegionX,
|
||||
startRegionY,
|
||||
startPixelX,
|
||||
startPixelY,
|
||||
width,
|
||||
height,
|
||||
tileSize = 1000
|
||||
) {
|
||||
const endPixelX = startPixelX + width;
|
||||
const endPixelY = startPixelY + height;
|
||||
|
||||
return {
|
||||
startTileX: startRegionX + Math.floor(startPixelX / tileSize),
|
||||
startTileY: startRegionY + Math.floor(startPixelY / tileSize),
|
||||
endTileX: startRegionX + Math.floor((endPixelX - 1) / tileSize),
|
||||
endTileY: startRegionY + Math.floor((endPixelY - 1) / tileSize),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload tiles for save file coordinates to populate originalTiles cache
|
||||
* This fixes the "no bitmap" error when loading save files
|
||||
* @param {Object} startPosition - The start position from save file
|
||||
* @param {Object} region - The region from save file
|
||||
* @param {number} width - Image width
|
||||
* @param {number} height - Image height
|
||||
*/
|
||||
/**
|
||||
* Preload tiles for save file coordinates to populate originalTiles cache
|
||||
* This fixes the "no bitmap" error when loading save files
|
||||
* For WPlace, we can't manually fetch tiles like r/place - we need to trigger natural tile loading
|
||||
* @param {Object} startPosition - The start position from save file
|
||||
* @param {Object} region - The region from save file
|
||||
* @param {number} width - Image width
|
||||
* @param {number} height - Image height
|
||||
*/
|
||||
async preloadTilesForSaveFile(startPosition, region, width, height) {
|
||||
if (!startPosition || !region || !width || !height) {
|
||||
console.warn('OverlayManager: Missing parameters for tile preload');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Calculate tiles that need to be loaded based on image area
|
||||
const tileSize = 1000; // WPlace uses 1000px tiles, not 256px like r/place
|
||||
const tiles = this.calculateTileRange(
|
||||
region.x, region.y,
|
||||
startPosition.x, startPosition.y,
|
||||
width, height,
|
||||
tileSize
|
||||
);
|
||||
|
||||
console.log(`🔄 WPlace: Need to preload ${(tiles.endTileX - tiles.startTileX + 1) * (tiles.endTileY - tiles.startTileY + 1)} tiles for save file...`);
|
||||
|
||||
// For WPlace, instead of manually fetching tiles, we need to wait for natural tile loading
|
||||
// The browser will request tiles as the user navigates, and we intercept them
|
||||
// For now, just log the tiles we need and let the overlay work with what's available
|
||||
const neededTiles = [];
|
||||
for (let tileX = tiles.startTileX; tileX <= tiles.endTileX; tileX++) {
|
||||
for (let tileY = tiles.startTileY; tileY <= tiles.endTileY; tileY++) {
|
||||
neededTiles.push(`${tileX},${tileY}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📍 WPlace: Will use tiles when available: ${neededTiles.slice(0, 5).join(', ')}${neededTiles.length > 5 ? ` and ${neededTiles.length - 5} more...` : ''}`);
|
||||
|
||||
// Store the needed tiles list for reference
|
||||
this.neededTilesForSave = new Set(neededTiles);
|
||||
|
||||
console.log('✅ WPlace: Tile preload setup complete - tiles will populate as user navigates');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to setup tile preload for save file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep utility
|
||||
* @param {number} ms - Milliseconds to sleep
|
||||
* @returns {Promise<void>}
|
||||
* @private
|
||||
*/
|
||||
_sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
cleanup() {
|
||||
this.clear();
|
||||
if (this.workerPool) {
|
||||
// Clean up worker pool if implemented
|
||||
this.workerPool = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create global instance
|
||||
window.WPlaceOverlayManager = OverlayManager;
|
||||
|
||||
// Create global instance for Auto-Image.js compatibility
|
||||
window.globalOverlayManager = new OverlayManager();
|
||||
|
||||
// Legacy compatibility - expose key methods globally for backward compatibility
|
||||
window.OverlayManager = OverlayManager;
|
||||
|
||||
console.log('✅ WPlace Overlay Manager loaded and ready');
|
||||
@@ -0,0 +1,846 @@
|
||||
// ==UserScript==
|
||||
// @name WPlace Token Manager
|
||||
// @namespace http://tampermonkey.net/
|
||||
// @version 2025-09-16.1
|
||||
// @description Turnstile token management for WPlace AutoBot
|
||||
// @author Wbot
|
||||
// @match https://wplace.live/*
|
||||
// @grant none
|
||||
// ==/UserScript==
|
||||
|
||||
/**
|
||||
* TokenManager - Handles Turnstile token generation, caching, and validation for WPlace AutoBot
|
||||
* Extracted from Auto-Image.js for better modularity and reusability
|
||||
*/
|
||||
class TokenManager {
|
||||
constructor() {
|
||||
// Token state
|
||||
this.turnstileToken = null;
|
||||
this.tokenExpiryTime = 0;
|
||||
this.tokenGenerationInProgress = false;
|
||||
this._resolveToken = null;
|
||||
this.tokenPromise = new Promise((resolve) => {
|
||||
this._resolveToken = resolve;
|
||||
});
|
||||
|
||||
// Configuration constants
|
||||
this.TOKEN_LIFETIME = 240000; // 4 minutes (tokens typically last 5 min, use 4 for safety)
|
||||
this.MAX_RETRIES = 10;
|
||||
this.MAX_BATCH_RETRIES = 10; // Maximum attempts for batch sending
|
||||
|
||||
// Turnstile widget state
|
||||
this.turnstileLoaded = false;
|
||||
this._turnstileContainer = null;
|
||||
this._turnstileOverlay = null;
|
||||
this._turnstileWidgetId = null;
|
||||
this._lastSitekey = null;
|
||||
this._cachedSitekey = null;
|
||||
|
||||
// Retry counter
|
||||
this.retryCount = 0;
|
||||
|
||||
// Initialize message listener for token capture
|
||||
this._initializeTokenCapture();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize token capture from injected script
|
||||
*/
|
||||
_initializeTokenCapture() {
|
||||
window.addEventListener('message', (event) => {
|
||||
const { source, token } = event.data;
|
||||
if (source === 'turnstile-capture' && token) {
|
||||
this.setTurnstileToken(token);
|
||||
// Show success message if UI is expecting it
|
||||
if (document.querySelector('#statusText')?.textContent.includes('CAPTCHA')) {
|
||||
this._showAlert('Token captured successfully', 'success');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new Turnstile token with expiry
|
||||
* @param {string} token - The Turnstile token
|
||||
*/
|
||||
setTurnstileToken(token) {
|
||||
if (this._resolveToken) {
|
||||
this._resolveToken(token);
|
||||
this._resolveToken = null;
|
||||
}
|
||||
this.turnstileToken = token;
|
||||
this.tokenExpiryTime = Date.now() + this.TOKEN_LIFETIME;
|
||||
console.log('✅ Turnstile token set successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current token is valid
|
||||
* @returns {boolean} True if token exists and not expired
|
||||
*/
|
||||
isTokenValid() {
|
||||
return this.turnstileToken && Date.now() < this.tokenExpiryTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate current token
|
||||
*/
|
||||
invalidateToken() {
|
||||
this.turnstileToken = null;
|
||||
this.tokenExpiryTime = 0;
|
||||
console.log('🗑️ Token invalidated, will force fresh generation');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a valid token is available, generating if necessary
|
||||
* @param {boolean} forceRefresh - Force generation of new token
|
||||
* @returns {Promise<string|null>} The token or null if failed
|
||||
*/
|
||||
async ensureToken(forceRefresh = false) {
|
||||
// Return cached token if still valid and not forcing refresh
|
||||
if (this.isTokenValid() && !forceRefresh) {
|
||||
return this.turnstileToken;
|
||||
}
|
||||
|
||||
// Invalidate token if forcing refresh
|
||||
if (forceRefresh) this.invalidateToken();
|
||||
|
||||
// Avoid multiple simultaneous token generations
|
||||
if (this.tokenGenerationInProgress) {
|
||||
console.log('🔄 Token generation already in progress, waiting...');
|
||||
await this._sleep(2000);
|
||||
return this.isTokenValid() ? this.turnstileToken : null;
|
||||
}
|
||||
|
||||
this.tokenGenerationInProgress = true;
|
||||
|
||||
try {
|
||||
console.log('🔄 Token expired or missing, generating new one...');
|
||||
const token = await this.handleCaptchaWithRetry();
|
||||
if (token && token.length > 20) {
|
||||
this.setTurnstileToken(token);
|
||||
console.log('✅ Token captured and cached successfully');
|
||||
return token;
|
||||
}
|
||||
|
||||
console.log('⚠️ Invisible Turnstile failed, forcing browser automation...');
|
||||
const fallbackToken = await this.handleCaptchaFallback();
|
||||
if (fallbackToken && fallbackToken.length > 20) {
|
||||
this.setTurnstileToken(fallbackToken);
|
||||
console.log('✅ Fallback token captured successfully');
|
||||
return fallbackToken;
|
||||
}
|
||||
|
||||
console.log('❌ All token generation methods failed');
|
||||
return null;
|
||||
} finally {
|
||||
this.tokenGenerationInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle captcha generation with retry logic
|
||||
* @returns {Promise<string|null>} The token or null if failed
|
||||
*/
|
||||
async handleCaptchaWithRetry() {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const { sitekey, token: preGeneratedToken } = await this.obtainSitekeyAndToken();
|
||||
|
||||
if (!sitekey) {
|
||||
throw new Error('No valid sitekey found');
|
||||
}
|
||||
|
||||
console.log('🔑 Using sitekey:', sitekey);
|
||||
|
||||
if (typeof window !== 'undefined' && window.navigator) {
|
||||
console.log(
|
||||
'🧭 UA:',
|
||||
window.navigator.userAgent.substring(0, 50) + '...',
|
||||
'Platform:',
|
||||
window.navigator.platform
|
||||
);
|
||||
}
|
||||
|
||||
let token = null;
|
||||
|
||||
if (
|
||||
preGeneratedToken &&
|
||||
typeof preGeneratedToken === 'string' &&
|
||||
preGeneratedToken.length > 20
|
||||
) {
|
||||
console.log('♻️ Reusing pre-generated Turnstile token');
|
||||
token = preGeneratedToken;
|
||||
} else {
|
||||
if (this.isTokenValid()) {
|
||||
console.log('♻️ Using existing cached token (from previous session)');
|
||||
token = this.turnstileToken;
|
||||
} else {
|
||||
console.log('🔐 Generating new token with executeTurnstile...');
|
||||
token = await this.executeTurnstile(sitekey, 'paint');
|
||||
if (token) this.setTurnstileToken(token);
|
||||
}
|
||||
}
|
||||
|
||||
if (token && typeof token === 'string' && token.length > 20) {
|
||||
const elapsed = Math.round(performance.now() - startTime);
|
||||
console.log(`✅ Turnstile token generated successfully in ${elapsed}ms`);
|
||||
return token;
|
||||
} else {
|
||||
throw new Error(`Invalid or empty token received - Length: ${token?.length || 0}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const elapsed = Math.round(performance.now() - startTime);
|
||||
console.error(`❌ Turnstile token generation failed after ${elapsed}ms:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback token generation method using pixel placement automation
|
||||
* @returns {Promise<string|null>} The token or null if failed
|
||||
*/
|
||||
async handleCaptchaFallback() {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
// Ensure we have a fresh promise to await for a new token capture
|
||||
if (!this._resolveToken) {
|
||||
this.tokenPromise = new Promise((res) => {
|
||||
this._resolveToken = res;
|
||||
});
|
||||
}
|
||||
|
||||
const timeoutPromise = this._sleep(20000).then(() =>
|
||||
reject(new Error('Auto-CAPTCHA timed out.'))
|
||||
);
|
||||
|
||||
const solvePromise = (async () => {
|
||||
// Wait for main paint button
|
||||
const mainPaintBtn = await this._waitForSelector(
|
||||
'button.btn.btn-primary.btn-lg, button.btn.btn-primary.sm\\:btn-xl',
|
||||
200,
|
||||
10000
|
||||
);
|
||||
if (!mainPaintBtn) throw new Error('Could not find the main paint button.');
|
||||
mainPaintBtn.click();
|
||||
await this._sleep(500);
|
||||
|
||||
// Click transparent color button
|
||||
const transBtn = await this._waitForSelector('button#color-0', 200, 5000);
|
||||
if (!transBtn) throw new Error('Could not find the transparent color button.');
|
||||
transBtn.click();
|
||||
await this._sleep(500);
|
||||
|
||||
// Find canvas and simulate painting
|
||||
const canvas = await this._waitForSelector('canvas', 200, 5000);
|
||||
if (!canvas) throw new Error('Could not find the canvas element.');
|
||||
|
||||
canvas.setAttribute('tabindex', '0');
|
||||
canvas.focus();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const centerX = Math.round(rect.left + rect.width / 2);
|
||||
const centerY = Math.round(rect.top + rect.height / 2);
|
||||
|
||||
// Simulate mouse movement and spacebar press
|
||||
canvas.dispatchEvent(
|
||||
new MouseEvent('mousemove', {
|
||||
clientX: centerX,
|
||||
clientY: centerY,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
canvas.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: ' ',
|
||||
code: 'Space',
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
await this._sleep(50);
|
||||
canvas.dispatchEvent(
|
||||
new KeyboardEvent('keyup', {
|
||||
key: ' ',
|
||||
code: 'Space',
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
await this._sleep(500);
|
||||
|
||||
// Delay before sending confirmation
|
||||
await this._sleep(800);
|
||||
|
||||
// Keep confirming until token is captured
|
||||
const confirmLoop = async () => {
|
||||
while (!this.turnstileToken) {
|
||||
let confirmBtn = await this._waitForSelector(
|
||||
'button.btn.btn-primary.btn-lg, button.btn.btn-primary.sm\\:btn-xl'
|
||||
);
|
||||
if (!confirmBtn) {
|
||||
const allPrimary = Array.from(document.querySelectorAll('button.btn-primary'));
|
||||
confirmBtn = allPrimary.length ? allPrimary[allPrimary.length - 1] : null;
|
||||
}
|
||||
if (confirmBtn) {
|
||||
confirmBtn.click();
|
||||
}
|
||||
await this._sleep(500); // 500ms delay between confirmation attempts
|
||||
}
|
||||
};
|
||||
|
||||
// Start confirmation loop and wait for token
|
||||
confirmLoop();
|
||||
const token = await this.tokenPromise;
|
||||
await this._sleep(300); // small delay after token is captured
|
||||
resolve(token);
|
||||
})();
|
||||
|
||||
await Promise.race([solvePromise, timeoutPromise]);
|
||||
} catch (error) {
|
||||
console.error('Auto-CAPTCHA process failed:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Turnstile script if not already loaded
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async loadTurnstile() {
|
||||
// If Turnstile is already present, just resolve.
|
||||
if (window.turnstile) {
|
||||
this.turnstileLoaded = true;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Avoid adding the script twice
|
||||
if (
|
||||
document.querySelector(
|
||||
'script[src^="https://challenges.cloudflare.com/turnstile/v0/api.js"]'
|
||||
)
|
||||
) {
|
||||
const checkReady = () => {
|
||||
if (window.turnstile) {
|
||||
this.turnstileLoaded = true;
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkReady, 100);
|
||||
}
|
||||
};
|
||||
return checkReady();
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
this.turnstileLoaded = true;
|
||||
console.log('✅ Turnstile script loaded successfully');
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.error('❌ Failed to load Turnstile script');
|
||||
reject(new Error('Failed to load Turnstile'));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or reuse the turnstile container - completely hidden for token generation
|
||||
* @returns {HTMLElement} The container element
|
||||
*/
|
||||
ensureTurnstileContainer() {
|
||||
if (!this._turnstileContainer || !document.body.contains(this._turnstileContainer)) {
|
||||
// Clean up old container if it exists
|
||||
if (this._turnstileContainer) {
|
||||
this._turnstileContainer.remove();
|
||||
}
|
||||
|
||||
this._turnstileContainer = document.createElement('div');
|
||||
this._turnstileContainer.className = 'wplace-turnstile-hidden';
|
||||
this._turnstileContainer.setAttribute('aria-hidden', 'true');
|
||||
this._turnstileContainer.id = 'turnstile-widget-container';
|
||||
document.body.appendChild(this._turnstileContainer);
|
||||
}
|
||||
return this._turnstileContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive overlay container for visible widgets when needed
|
||||
* @returns {HTMLElement} The overlay element
|
||||
*/
|
||||
ensureTurnstileOverlayContainer() {
|
||||
if (this._turnstileOverlay && document.body.contains(this._turnstileOverlay)) {
|
||||
return this._turnstileOverlay;
|
||||
}
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'turnstile-overlay-container';
|
||||
overlay.className = 'wplace-turnstile-overlay wplace-overlay-hidden';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.textContent = this._getText('turnstileInstructions', 'Complete the verification');
|
||||
title.className = 'wplace-turnstile-title';
|
||||
|
||||
const host = document.createElement('div');
|
||||
host.id = 'turnstile-overlay-host';
|
||||
host.className = 'wplace-turnstile-host';
|
||||
|
||||
const hideBtn = document.createElement('button');
|
||||
hideBtn.textContent = this._getText('hideTurnstileBtn', 'Hide');
|
||||
hideBtn.className = 'wplace-turnstile-hide-btn';
|
||||
hideBtn.addEventListener('click', () => overlay.remove());
|
||||
|
||||
overlay.appendChild(title);
|
||||
overlay.appendChild(host);
|
||||
overlay.appendChild(hideBtn);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
this._turnstileOverlay = overlay;
|
||||
return overlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute Turnstile widget to generate token
|
||||
* @param {string} sitekey - The site key
|
||||
* @param {string} action - The action name (default: 'paint')
|
||||
* @returns {Promise<string|null>} The token or null if failed
|
||||
*/
|
||||
async executeTurnstile(sitekey, action = 'paint') {
|
||||
await this.loadTurnstile();
|
||||
|
||||
// Try reusing existing widget first if sitekey matches
|
||||
if (this._turnstileWidgetId && this._lastSitekey === sitekey && window.turnstile?.execute) {
|
||||
try {
|
||||
console.log('🔄 Reusing existing Turnstile widget...');
|
||||
const token = await Promise.race([
|
||||
window.turnstile.execute(this._turnstileWidgetId, { action }),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Execute timeout')), 15000)
|
||||
),
|
||||
]);
|
||||
if (token && token.length > 20) {
|
||||
console.log('✅ Token generated via widget reuse');
|
||||
return token;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('⚠️ Widget reuse failed, will create a fresh widget:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Try invisible widget first
|
||||
const invisibleToken = await this.createTurnstileWidget(sitekey, action);
|
||||
if (invisibleToken && invisibleToken.length > 20) {
|
||||
return invisibleToken;
|
||||
}
|
||||
|
||||
console.log('⚠️ Falling back to interactive Turnstile (visible).');
|
||||
return await this.createTurnstileWidgetInteractive(sitekey, action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create invisible Turnstile widget
|
||||
* @param {string} sitekey - The site key
|
||||
* @param {string} action - The action name
|
||||
* @returns {Promise<string|null>} The token or null if failed
|
||||
*/
|
||||
async createTurnstileWidget(sitekey, action) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
// Force cleanup of any existing widget
|
||||
if (this._turnstileWidgetId && window.turnstile?.remove) {
|
||||
try {
|
||||
window.turnstile.remove(this._turnstileWidgetId);
|
||||
console.log('🧹 Cleaned up existing Turnstile widget');
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Widget cleanup warning:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const container = this.ensureTurnstileContainer();
|
||||
container.innerHTML = '';
|
||||
|
||||
// Verify Turnstile is available
|
||||
if (!window.turnstile?.render) {
|
||||
console.error('❌ Turnstile not available for rendering');
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔧 Creating invisible Turnstile widget...');
|
||||
const widgetId = window.turnstile.render(container, {
|
||||
sitekey,
|
||||
action,
|
||||
size: 'invisible',
|
||||
retry: 'auto',
|
||||
'retry-interval': 8000,
|
||||
callback: (token) => {
|
||||
console.log('✅ Invisible Turnstile callback');
|
||||
resolve(token);
|
||||
},
|
||||
'error-callback': () => resolve(null),
|
||||
'timeout-callback': () => resolve(null),
|
||||
});
|
||||
|
||||
this._turnstileWidgetId = widgetId;
|
||||
this._lastSitekey = sitekey;
|
||||
|
||||
if (!widgetId) {
|
||||
return resolve(null);
|
||||
}
|
||||
|
||||
// Execute the widget and race with timeout
|
||||
Promise.race([
|
||||
window.turnstile.execute(widgetId, { action }),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Invisible execute timeout')), 12000)
|
||||
),
|
||||
])
|
||||
.then(resolve)
|
||||
.catch(() => resolve(null));
|
||||
} catch (e) {
|
||||
console.error('❌ Invisible Turnstile creation failed:', e);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create interactive Turnstile widget (visible)
|
||||
* @param {string} sitekey - The site key
|
||||
* @param {string} action - The action name
|
||||
* @returns {Promise<string|null>} The token or null if failed
|
||||
*/
|
||||
async createTurnstileWidgetInteractive(sitekey, action) {
|
||||
// Create a visible widget that users can interact with if needed
|
||||
console.log('🔄 Creating interactive Turnstile widget (visible)');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
// Force cleanup of any existing widget
|
||||
if (this._turnstileWidgetId && window.turnstile?.remove) {
|
||||
try {
|
||||
window.turnstile.remove(this._turnstileWidgetId);
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Widget cleanup warning:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const overlay = this.ensureTurnstileOverlayContainer();
|
||||
overlay.classList.remove('wplace-overlay-hidden');
|
||||
overlay.style.display = 'block';
|
||||
|
||||
const host = overlay.querySelector('#turnstile-overlay-host');
|
||||
host.innerHTML = '';
|
||||
|
||||
// Set a timeout for interactive mode
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn('⏰ Interactive Turnstile widget timeout');
|
||||
overlay.classList.add('wplace-overlay-hidden');
|
||||
overlay.style.display = 'none';
|
||||
resolve(null);
|
||||
}, 60000); // 60 seconds for user interaction
|
||||
|
||||
const widgetId = window.turnstile.render(host, {
|
||||
sitekey,
|
||||
action,
|
||||
size: 'normal',
|
||||
theme: 'light',
|
||||
callback: (token) => {
|
||||
clearTimeout(timeout);
|
||||
overlay.classList.add('wplace-overlay-hidden');
|
||||
overlay.style.display = 'none';
|
||||
console.log('✅ Interactive Turnstile completed successfully');
|
||||
|
||||
if (typeof token === 'string' && token.length > 20) {
|
||||
resolve(token);
|
||||
} else {
|
||||
console.warn('❌ Invalid token from interactive widget');
|
||||
resolve(null);
|
||||
}
|
||||
},
|
||||
'error-callback': (error) => {
|
||||
clearTimeout(timeout);
|
||||
overlay.classList.add('wplace-overlay-hidden');
|
||||
overlay.style.display = 'none';
|
||||
console.warn('❌ Interactive Turnstile error:', error);
|
||||
resolve(null);
|
||||
},
|
||||
});
|
||||
|
||||
this._turnstileWidgetId = widgetId;
|
||||
this._lastSitekey = sitekey;
|
||||
|
||||
if (!widgetId) {
|
||||
clearTimeout(timeout);
|
||||
overlay.classList.add('wplace-overlay-hidden');
|
||||
overlay.style.display = 'none';
|
||||
console.warn('❌ Failed to create interactive Turnstile widget');
|
||||
resolve(null);
|
||||
} else {
|
||||
console.log('✅ Interactive Turnstile widget created, waiting for user interaction...');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ Interactive Turnstile creation failed:', e);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup method for when the script is disabled/reloaded
|
||||
*/
|
||||
cleanupTurnstile() {
|
||||
if (this._turnstileWidgetId && window.turnstile?.remove) {
|
||||
try {
|
||||
window.turnstile.remove(this._turnstileWidgetId);
|
||||
} catch (e) {
|
||||
console.warn('Failed to cleanup Turnstile widget:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (this._turnstileContainer && document.body.contains(this._turnstileContainer)) {
|
||||
this._turnstileContainer.remove();
|
||||
}
|
||||
|
||||
if (this._turnstileOverlay && document.body.contains(this._turnstileOverlay)) {
|
||||
this._turnstileOverlay.remove();
|
||||
}
|
||||
|
||||
this._turnstileWidgetId = null;
|
||||
this._turnstileContainer = null;
|
||||
this._turnstileOverlay = null;
|
||||
this._lastSitekey = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain sitekey and token
|
||||
* @param {string} fallback - Fallback sitekey
|
||||
* @returns {Promise<{sitekey: string, token: string|null}>}
|
||||
*/
|
||||
async obtainSitekeyAndToken(fallback = '0x4AAAAAABpqJe8FO0N84q0F') {
|
||||
// Cache sitekey to avoid repeated DOM queries
|
||||
if (this._cachedSitekey) {
|
||||
console.log('🔍 Using cached sitekey:', this._cachedSitekey);
|
||||
|
||||
return this.isTokenValid()
|
||||
? {
|
||||
sitekey: this._cachedSitekey,
|
||||
token: this.turnstileToken,
|
||||
}
|
||||
: { sitekey: this._cachedSitekey, token: null };
|
||||
}
|
||||
|
||||
// List of potential sitekeys to try
|
||||
const potentialSitekeys = [
|
||||
'0x4AAAAAABpqJe8FO0N84q0F', // WPlace common sitekey
|
||||
'0x4AAAAAAAJ7xjKAp6Mt_7zw', // Alternative WPlace sitekey
|
||||
'0x4AAAAAADm5QWx6Ov2LNF2g', // Another common sitekey
|
||||
];
|
||||
|
||||
const trySitekey = async (sitekey, source) => {
|
||||
if (!sitekey || sitekey.length < 10) return null;
|
||||
|
||||
console.log(`🔍 Testing sitekey from ${source}:`, sitekey);
|
||||
const token = await this.executeTurnstile(sitekey);
|
||||
|
||||
if (token && token.length >= 20) {
|
||||
console.log(`✅ Valid token generated from ${source} sitekey`);
|
||||
this.setTurnstileToken(token);
|
||||
this._cachedSitekey = sitekey;
|
||||
return { sitekey, token };
|
||||
} else {
|
||||
console.log(`❌ Failed to get token from ${source} sitekey`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// 1️⃣ data-sitekey attribute
|
||||
const sitekeySel = document.querySelector('[data-sitekey]');
|
||||
if (sitekeySel) {
|
||||
const sitekey = sitekeySel.getAttribute('data-sitekey');
|
||||
const result = await trySitekey(sitekey, 'data attribute');
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 2️⃣ Turnstile element
|
||||
const turnstileEl = document.querySelector('.cf-turnstile');
|
||||
if (turnstileEl?.dataset?.sitekey) {
|
||||
const sitekey = turnstileEl.dataset.sitekey;
|
||||
const result = await trySitekey(sitekey, 'turnstile element');
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 3️⃣ Meta tags
|
||||
const metaTags = document.querySelectorAll(
|
||||
'meta[name*="turnstile"], meta[property*="turnstile"]'
|
||||
);
|
||||
for (const meta of metaTags) {
|
||||
const content = meta.getAttribute('content');
|
||||
const result = await trySitekey(content, 'meta tag');
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 4️⃣ Global variable
|
||||
if (window.__TURNSTILE_SITEKEY) {
|
||||
const result = await trySitekey(window.__TURNSTILE_SITEKEY, 'global variable');
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 5️⃣ Script tags
|
||||
const scripts = document.querySelectorAll('script');
|
||||
for (const script of scripts) {
|
||||
const content = script.textContent || script.innerHTML;
|
||||
const match = content.match(
|
||||
/(?:sitekey|data-sitekey)['"\s\[\]:\=\(]*['"]?([0-9a-zA-Z_-]{20,})['"]?/i
|
||||
);
|
||||
if (match && match[1]) {
|
||||
const extracted = match[1].replace(/['"]/g, '');
|
||||
const result = await trySitekey(extracted, 'script content');
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6️⃣ Known potential sitekeys
|
||||
console.log('🔍 Testing known potential sitekeys...');
|
||||
for (const testSitekey of potentialSitekeys) {
|
||||
const result = await trySitekey(testSitekey, 'known list');
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Error during sitekey detection:', error);
|
||||
}
|
||||
|
||||
// 7️⃣ Fallback
|
||||
console.log('🔧 Trying fallback sitekey:', fallback);
|
||||
const fallbackResult = await trySitekey(fallback, 'fallback');
|
||||
if (fallbackResult) {
|
||||
return fallbackResult;
|
||||
}
|
||||
|
||||
// 8️⃣ Ultimate fallback with no token
|
||||
console.warn('❌ All sitekey detection methods failed, using fallback without token validation');
|
||||
this._cachedSitekey = fallback;
|
||||
return { sitekey: fallback, token: null };
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// UTILITY METHODS
|
||||
// =============================================
|
||||
|
||||
/**
|
||||
* Sleep utility
|
||||
* @param {number} ms - Milliseconds to sleep
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
_sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for DOM element to appear
|
||||
* @param {string} selector - CSS selector
|
||||
* @param {number} pollInterval - Polling interval in ms
|
||||
* @param {number} timeout - Timeout in ms
|
||||
* @returns {Promise<Element|null>} The element or null if timeout
|
||||
* @private
|
||||
*/
|
||||
_waitForSelector(selector, pollInterval = 200, timeout = 10000) {
|
||||
return new Promise((resolve) => {
|
||||
const endTime = Date.now() + timeout;
|
||||
const poll = () => {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) {
|
||||
resolve(element);
|
||||
} else if (Date.now() < endTime) {
|
||||
setTimeout(poll, pollInterval);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple translation helper (fallback to English)
|
||||
* @param {string} key - Translation key
|
||||
* @param {string} fallback - Fallback text
|
||||
* @returns {string} Translated text or fallback
|
||||
*/
|
||||
_getText(key, fallback = '') {
|
||||
// Try to use external translation function if available
|
||||
if (typeof window.Utils?.t === 'function') {
|
||||
return window.Utils.t(key);
|
||||
}
|
||||
|
||||
// Fallback translations
|
||||
const translations = {
|
||||
turnstileInstructions: 'Complete the verification',
|
||||
hideTurnstileBtn: 'Hide',
|
||||
tokenCapturedSuccess: 'Token captured successfully'
|
||||
};
|
||||
|
||||
return translations[key] || fallback || key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show alert helper (uses external alert if available)
|
||||
* @param {string} message - Message to show
|
||||
* @param {string} type - Alert type
|
||||
*/
|
||||
_showAlert(message, type = 'info') {
|
||||
// Try to use external alert function if available
|
||||
if (typeof window.Utils?.showAlert === 'function') {
|
||||
window.Utils.showAlert(message, type);
|
||||
} else {
|
||||
console.log(`${type.toUpperCase()}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random string utility
|
||||
* @param {number} len - Length of string
|
||||
* @param {string} chars - Character set
|
||||
* @returns {string} Random string
|
||||
*/
|
||||
_randStr(len, chars = 'abcdefghijklmnopqrstuvwxyz0123456789') {
|
||||
return [...Array(len)]
|
||||
.map(() =>
|
||||
chars[
|
||||
(crypto?.getRandomValues?.(new Uint32Array(1))[0] % chars.length) ||
|
||||
Math.floor(Math.random() * chars.length)
|
||||
]
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
|
||||
// Create global instance
|
||||
window.WPlaceTokenManager = new TokenManager();
|
||||
|
||||
// Create global instance alias for Auto-Image.js compatibility
|
||||
window.globalTokenManager = window.WPlaceTokenManager;
|
||||
|
||||
// Legacy compatibility - expose key methods globally for backward compatibility
|
||||
window.setTurnstileToken = (token) => window.WPlaceTokenManager.setTurnstileToken(token);
|
||||
window.ensureToken = (forceRefresh) => window.WPlaceTokenManager.ensureToken(forceRefresh);
|
||||
window.isTokenValid = () => window.WPlaceTokenManager.isTokenValid();
|
||||
window.invalidateToken = () => window.WPlaceTokenManager.invalidateToken();
|
||||
|
||||
console.log('✅ WPlace Token Manager loaded and ready');
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user