// ==UserScript==
// @name Wplace chunk downloader
// @namespace http://tampermonkey.net/
// @version 1.0.2
// @description Easily download chunk images from wplace.live using multi-point selection and highlighting
// @author NotNotWaldo
// @match https://wplace.live/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=wplace.live
// @license MIT
// @run-at document-end
// @grant none
// ==/UserScript==
// Code below, feel free to read in horror
// Global vals because I'm way too lazy
// These variables are for handling the highlighting feature
let isHightlightOn = false;
let downloadingState = false;
// the downloading state ensures that the highlight wont be also printed when downloading the images of chunk
// just a template for chunk img
const chunkTemplateUrl = `https://backend.wplace.live/files/s0/tiles/`;
(() => {
// array for highlighting chunks
let highlightedChunksLinksArr = [];
// the coords of chunks that are selected by the points you've set
let mlChunkCoords = {
firstChunk: { x: null, y: null },
secondChunk: { x: null, y: null },
};
const originalFetch = window.fetch;
const mlCoordsOrganizer = async (mlCoords) => {
let tempMlChunkCoords = structuredClone(mlChunkCoords);
// checks if the second point is empty. If yes, it copies the val of first point onto the second
if (mlCoords.secondChunk.x == null) {
tempMlChunkCoords.secondChunk.x = mlChunkCoords.firstChunk.x;
tempMlChunkCoords.secondChunk.y = mlChunkCoords.firstChunk.y;
return tempMlChunkCoords;
}
// making sure that the coords that will be sent would be appropriate
// turns the first point to be the topleft corner and the second the bottom right
tempMlChunkCoords.firstChunk.x = Math.min(
mlCoords.firstChunk.x,
mlCoords.secondChunk.x
);
tempMlChunkCoords.secondChunk.x = Math.max(
mlCoords.firstChunk.x,
mlCoords.secondChunk.x
);
tempMlChunkCoords.firstChunk.y = Math.min(
mlCoords.firstChunk.y,
mlCoords.secondChunk.y
);
tempMlChunkCoords.secondChunk.y = Math.max(
mlCoords.firstChunk.y,
mlCoords.secondChunk.y
);
return tempMlChunkCoords;
};
window.fetch = async (resource, init) => {
const url = new URL(
typeof resource === "string" ? resource : resource.url || ""
);
const res = await originalFetch(resource, init);
const x = url.searchParams.get("x");
const y = url.searchParams.get("y");
// This part is for the wonky feature that highlights a chunk, the reason it is wonky is becuase
// it has to wait for the app to reload/refetch the img so we can apply effects
// Why wait? because the images are flattened in the <canvas> object, and I have no access
// (or I dont know how to get access) to the functions that sets the <canvas> obj
// leaving me unable to modify or even force to reload/fetch the image
// Detect tile fetches
if (url.pathname.endsWith(".png")) {
const pathParts = url.pathname.split("/");
const chunkX = pathParts[pathParts.length - 2];
const chunkY = pathParts[pathParts.length - 1].replace(".png", "");
// If this chunk matches our selected one
if (
isHightlightOn &&
highlightedChunksLinksArr.includes(
chunkTemplateUrl + chunkX + "/" + chunkY + ".png"
) &&
!downloadingState
) {
// Clone so we don't consume the original stream
const cloned = res.clone();
const blob = await cloned.blob();
const imgBitmap = await createImageBitmap(blob);
// Draw onto an offscreen canvas
const canvas = document.createElement("canvas");
canvas.width = imgBitmap.width;
canvas.height = imgBitmap.height;
const ctx = canvas.getContext("2d");
// Draw original
ctx.drawImage(imgBitmap, 0, 0);
// Add blue overlay
ctx.fillStyle = "rgba(0, 0, 255, 0.2)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
const modifiedBlob = await new Promise((r) =>
canvas.toBlob(r, "image/png")
);
return new Response(modifiedBlob, {
status: res.status,
statusText: res.statusText,
headers: res.headers,
});
}
}
if (x && y) {
// Setting important infos
const pathnames = url.pathname.split("/");
const chunkX = pathnames[pathnames.length - 2];
const chunkY = pathnames[pathnames.length - 1];
const chunkUrl = `https://backend.wplace.live/files/s0/tiles/${chunkX}/${chunkY}.png`;
const parent = document
.querySelector(".rounded-t-box")
?.querySelector("div");
if (parent) {
const coordsParent = parent.querySelector("h2.mt-0\\.5");
const insertAfterThisObj = coordsParent.querySelector(
"span.whitespace-nowrap:nth-child(1)"
);
const pixelBtns = parent.querySelector(".hide-scrollbar");
let chunkCoords = parent.querySelector(".chunkCoords");
if (!chunkCoords) {
chunkCoords = document.createElement("span");
chunkCoords.className = "chunkCoords whitespace-nowrap";
insertAfterThisObj.after(chunkCoords);
}
chunkCoords.textContent = ` | Chunk: ${chunkX}, ${chunkY}`;
// checks if the buttons are already there
if (pixelBtns.children.length > 3) {
// remove those buttons <<< WILL BREAK IF THE DEVS ADD NEW BUTTONS TO IT LOL, it has, multiple times, because of me lmao.
for (let i = 0; i < 4 && pixelBtns.lastElementChild; i++) {
pixelBtns.removeChild(pixelBtns.lastElementChild);
}
}
if (pixelBtns) {
pixelBtns.style.flexWrap = "wrap";
pixelBtns.style.overflowX = "visible";
// adds "View chunk img" button
const viewImgBtn = document.createElement("button");
viewImgBtn.className = "btn btn-soft";
viewImgBtn.textContent = "View chunk img";
viewImgBtn.addEventListener("click", () => {
window.open(chunkUrl, "_blank");
});
// adds "Download chunk png" button
const dlBtn = document.createElement("button");
dlBtn.className = "btn btn-soft";
dlBtn.textContent = "Download chunk png";
dlBtn.addEventListener("click", async () => {
multipleChunksDlUrl(chunkX, chunkY, chunkX, chunkY);
});
const downloadChunksText = document.createElement("span");
downloadChunksText.className = "w-full pt-2";
downloadChunksText.textContent = "Multiple chunks downloader:";
const refreshSetPointsInfo = () => {
let infoChildren = mlChunkDlCon.querySelectorAll("div");
infoChildren[0].textContent = `1st X: ${mlChunkCoords.firstChunk.x}, Y: ${mlChunkCoords.firstChunk.y}`;
infoChildren[1].textContent = `2nd X: ${mlChunkCoords.secondChunk.x}, Y: ${mlChunkCoords.secondChunk.y}`;
};
// This are for the multiple chunks downloader
let mlChunkDlCon = document.createElement("div");
mlChunkDlCon.className = "flex sm:rounded-b-box w-full pt- sm:mb-2";
mlChunkDlCon.style.gap = "5px";
mlChunkDlCon.style.flexWrap = "wrap";
mlChunkDlCon.style.overflowX = "visible";
// Yes, these "divs" has their classes set to "btn"... a class for <button> objs... I just dont wanna do css
let topLeftCoords = document.createElement("div");
topLeftCoords.className = "btn btn-soft";
topLeftCoords.textContent = `1st X: ${mlChunkCoords.firstChunk.x}, Y: ${mlChunkCoords.firstChunk.y}`;
let botRightCoords = document.createElement("div");
botRightCoords.className = "btn btn-soft";
botRightCoords.textContent = `2nd X: ${mlChunkCoords.secondChunk.x}, Y: ${mlChunkCoords.secondChunk.y}`;
let setPointBtn = document.createElement("button");
setPointBtn.textContent = "Set point";
setPointBtn.className = "btn btn-soft btn-primary";
mlChunkDlCon.appendChild(setPointBtn);
mlChunkDlCon.appendChild(topLeftCoords);
mlChunkDlCon.appendChild(botRightCoords);
setPointBtn.addEventListener("click", async () => {
if (mlChunkCoords.firstChunk.x == null) {
// yes I'm lazy
mlChunkCoords.firstChunk = { x: chunkX, y: chunkY };
} else if (mlChunkCoords.secondChunk.x == null) {
mlChunkCoords.secondChunk = { x: chunkX, y: chunkY };
} else {
mlChunkCoords.firstChunk = mlChunkCoords.secondChunk;
mlChunkCoords.secondChunk = { x: chunkX, y: chunkY };
}
if (isHightlightOn) {
highlightedChunksLinksArr.length = 0;
let organizedCoords = await mlCoordsOrganizer(mlChunkCoords);
highlightedChunksLinksArr.push(
...getLinksFromChunkCoords(organizedCoords)
);
}
refreshSetPointsInfo();
console.log(
`current mlcoords-> firstChunk: {X: ${mlChunkCoords.firstChunk.x}, Y: ${mlChunkCoords.firstChunk.y}}, secondChunk: {X: ${mlChunkCoords.secondChunk.x}, Y: ${mlChunkCoords.secondChunk.y}}`
);
});
// creates the "download chunks" button
let downloadChunksBtn = document.createElement("button");
downloadChunksBtn.className = "btn btn-soft btn-primary";
downloadChunksBtn.textContent = "Download chunks";
downloadChunksBtn.addEventListener("click", async () => {
let tempCoords = structuredClone(mlChunkCoords);
if (
mlChunkCoords.firstChunk.x == null &&
mlChunkCoords.secondChunk.x == null
)
{return;}
if (mlChunkCoords.secondChunk.x == null) {
tempCoords.secondChunk.x = mlChunkCoords.firstChunk.x;
tempCoords.secondChunk.y = mlChunkCoords.firstChunk.y;
}
console.log(
`downloading chunks: ${tempCoords.firstChunk.x}, ${tempCoords.firstChunk.y} | ${tempCoords.secondChunk.x}, ${tempCoords.secondChunk.y}`
);
// making sure the coords are appropriate before passing it
let organizedCoords = await mlCoordsOrganizer(mlChunkCoords);
multipleChunksDlUrl(
organizedCoords.firstChunk.x,
organizedCoords.firstChunk.y,
organizedCoords.secondChunk.x,
organizedCoords.secondChunk.y
);
});
let removePointsBtn = document.createElement("button");
removePointsBtn.className = "btn btn-soft";
removePointsBtn.textContent = "Remove points";
removePointsBtn.addEventListener("click", async () => {
mlChunkCoords = {
firstChunk: { x: null, y: null },
secondChunk: { x: null, y: null },
};
highlightedChunksLinksArr.length = 0;
isHightlightOn = false;
refreshSetPointsInfo();
let highlightBtn = mlChunkDlCon.querySelector(".highlight-btn"); // I pray that this does not break :)
highlightBtn.textContent = "Highlight chunks";
});
let highlightChunksBtn = document.createElement("button");
highlightChunksBtn.className = "highlight-btn btn btn-soft";
if (!isHightlightOn) {
highlightChunksBtn.textContent = "Highlight chunks";
} else {
highlightChunksBtn.textContent = "Unhighlight chunks";
}
highlightChunksBtn.addEventListener("click", async () => {
console.log("Trying to hightlight chunks");
if (mlChunkCoords.firstChunk.x == null) return;
if (!isHightlightOn) {
let organizedCoords = await mlCoordsOrganizer(mlChunkCoords);
console.log(Object.keys(organizedCoords));
highlightedChunksLinksArr.push(
...getLinksFromChunkCoords(organizedCoords)
);
console.log(`Turned on hightlight`);
isHightlightOn = !isHightlightOn;
highlightChunksBtn.textContent = "Unhighlight chunks";
} else {
highlightedChunksLinksArr.length = 0;
console.log(`Turned off highlight`);
isHightlightOn = !isHightlightOn;
highlightChunksBtn.textContent = "Highlight chunks";
}
});
mlChunkDlCon.appendChild(setPointBtn);
mlChunkDlCon.appendChild(topLeftCoords);
mlChunkDlCon.appendChild(botRightCoords);
mlChunkDlCon.appendChild(downloadChunksBtn);
mlChunkDlCon.appendChild(removePointsBtn);
mlChunkDlCon.appendChild(highlightChunksBtn);
// phew, that was a lot...
pixelBtns.appendChild(viewImgBtn);
pixelBtns.appendChild(dlBtn);
pixelBtns.appendChild(downloadChunksText);
pixelBtns.appendChild(mlChunkDlCon);
} else {
console.error("Btn obj not found");
}
} else {
console.error("Parent element not found");
}
}
return res;
};
})();
const multipleChunksDlUrl = async (
topleftX,
topleftY,
botRightX,
botRightY,
safety = true
) => {
let linksResultArr = getLinksFromChunkCoords({
firstChunk: { x: topleftX, y: topleftY },
secondChunk: { x: botRightX, y: botRightY },
});
downloadingState = true;
let safetyThreshold = 70;
let chunkWidth = 1 + Number(botRightX - topleftX);
if (linksResultArr.length > safetyThreshold) {
if (safety) {
console.warn(
`You were about to download ${linksResultArr.length} images but was prevented by this precaution. If you intentionally wanted to download that much, you can edit the script and change the "safetyThreshold" variable's value. Good luck.`
);
return;
} else {
console.log("Better pray to God...");
}
}
stitchAndDownload(
linksResultArr,
chunkWidth,
`ch(${topleftX}, ${topleftY}, ${botRightX}, ${botRightY})` // the name of the stitched file png
);
};
let getLinksFromChunkCoords = (chunkCoords) => {
console.log("getting the links from chunk coords.");
console.log(
"tempChunkCoords: " +
`First chunk {x: ${chunkCoords.firstChunk.x}, y: ${chunkCoords.firstChunk.y}}, Second chunk {x: ${chunkCoords.secondChunk.x}, y: ${chunkCoords.secondChunk.y}}`
);
let topleftX = chunkCoords.firstChunk.x,
topleftY = chunkCoords.firstChunk.y,
botRightX = chunkCoords.secondChunk.x,
botRightY = chunkCoords.secondChunk.y;
if (botRightX == null) {
botRightX = topleftX;
botRightY = topleftY;
}
let chunkWidth = 1 + Number(botRightX - topleftX);
let chunkHeight = 1 + Number(botRightY - topleftY);
console.log("chunkWidth: " + chunkWidth);
console.log("chunkHeight: " + chunkHeight);
let linksArr = [];
for (let j = 0; j < chunkHeight; j++) {
for (let i = 0; i < chunkWidth; i++) {
// I F*CKING HATE JAVASCRIPT. TF YOU MEAN THAT YOU THINK A F*CKING NUMBER IS A STRING!
linksArr.push(
chunkTemplateUrl +
(Number(i) + Number(topleftX)) +
"/" +
(Number(j) + Number(topleftY)) +
".png"
);
}
}
return linksArr;
};
async function stitchAndDownload(images, width, name) {
// Creates a temp 1000x1000 image
const createBlank = () => {
const c = document.createElement("canvas");
c.width = 1000;
c.height = 1000;
const ctx = c.getContext("2d");
ctx.fillStyle = "rgba(0,0,0,0)"; // or transparent
ctx.fillRect(0, 0, c.width, c.height);
return new Promise((resolve) => {
c.toBlob((blob) => {
const img = new Image();
img.src = URL.createObjectURL(blob);
img.onload = () => resolve(img);
});
});
};
// loads all images with delay
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function loadImagesWithDelay(images, delay = 200) {
const loadedImages = [];
for (const src of images) {
const img = await new Promise((resolve) => {
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => resolve(image);
image.onerror = async () => {
console.warn("Failed to load, using blank:", src);
const blank = await createBlank();
resolve(blank);
};
image.src = src;
});
loadedImages.push(img);
// cooldown for lesser chance of being rate-limited by the website
// wait before starting the next one
await sleep(delay);
}
return loadedImages;
}
// usage
const loadedImages = await loadImagesWithDelay(images, 200);
// # of columns/rows
const columns = width;
const rows = Math.ceil(loadedImages.length / columns);
const imgWidth = loadedImages[0].width;
const imgHeight = loadedImages[0].height;
const canvas = document.createElement("canvas");
canvas.width = imgWidth * columns;
canvas.height = imgHeight * rows;
const ctx = canvas.getContext("2d");
// this draw images in order: left to right, top to bottom
loadedImages.forEach((img, index) => {
const x = (index % columns) * imgWidth;
const y = Math.floor(index / columns) * imgHeight;
ctx.drawImage(img, x, y);
});
// Download stitched image
canvas.toBlob((blob) => {
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `${name}_${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
// to enable the highlight to stay after downloading
downloadingState = false;
}, "image/png");
}
// Sry for horrible coding lmao
// No one just can win against Javascript that easily...