mirror of
https://github.com/ShinkoNet/Wplace-Overlay-Pro.git
synced 2026-01-11 22:40:37 +00:00
V3.1 symbol tiling
This commit is contained in:
parent
0e8f4f5c1e
commit
aec81b95c9
9 changed files with 446 additions and 103 deletions
143
import/blueprint.ts
Normal file
143
import/blueprint.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
//import * as PImage from 'pureimage';
|
||||
const PImage = require('pureimage');
|
||||
import fs from 'fs';
|
||||
import { workerData, parentPort } from 'worker_threads';
|
||||
import { toNearestColor } from '../util/canvasUtil';
|
||||
import { ISwatch } from '../models/swatch';
|
||||
|
||||
console.log('blueprint worker: starting');
|
||||
|
||||
const imageDecodeFunc = (workerData.stencilUrl as string).endsWith('.png') ? PImage.decodePNGFromStream : PImage.decodeJPEGFromStream;
|
||||
//Not needed as we always save in PNG (tho JPG extensions is left, TODO: fix in Stencil Functionality update)
|
||||
//const imageEncodeFunc = (workerData.stencilUrl as string).endsWith('.png') ? PImage.encodePNGToStream : PImage.encodeJPEGToStream;
|
||||
|
||||
// still not sure of the typing:
|
||||
Promise.all([
|
||||
imageDecodeFunc(fs.createReadStream('./store/stencils/' + workerData.stencilUrl)),
|
||||
workerData.fullBpMarksUrl && PImage.decodePNGFromStream(fs.createReadStream(workerData.fullBpMarksUrl))
|
||||
]).then(res => {
|
||||
const [img, symImg]: HTMLImageElement[] = res;
|
||||
|
||||
const SYM_SIZE_FALLBACK_NO_BORDER = 5;
|
||||
const SYM_SIZE_FALLBACK_WITH_BORDER = SYM_SIZE_FALLBACK_NO_BORDER + 2;
|
||||
|
||||
let SYM_SIZE = symImg?.height ?? SYM_SIZE_FALLBACK_WITH_BORDER;
|
||||
const [w, h] = [Math.floor(workerData.metadata.width), Math.floor(workerData.metadata.height || (workerData.metadata.width / img.width * img.height))]
|
||||
const canvas = PImage.make(w * SYM_SIZE, h * SYM_SIZE);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, w * SYM_SIZE, h * SYM_SIZE);
|
||||
|
||||
// "glow"
|
||||
/*
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.drawImage(img, 0, 0, w * SYM_SIZE, h * SYM_SIZE);
|
||||
*/
|
||||
|
||||
const palette = workerData.palette as ISwatch[];
|
||||
|
||||
const [symW, symH] = [symImg?.width ?? 256 * SYM_SIZE_FALLBACK_WITH_BORDER, symImg?.height ?? SYM_SIZE_FALLBACK_WITH_BORDER]
|
||||
const symCanvas = PImage.make(symW, symH);
|
||||
const symCtx = symCanvas.getContext('2d');
|
||||
symCtx.clearRect(0, 0, symW, symH);
|
||||
// generating symbols marks from swatch glyph data
|
||||
if (!symImg && workerData.bpType === 'symbols') {
|
||||
palette.forEach((s, index) => {
|
||||
// new ImageData not implemented in pureImage, getting from symCanvas
|
||||
const data = symCtx.getImageData(palette.length * SYM_SIZE_FALLBACK_WITH_BORDER + 1, 1, SYM_SIZE_FALLBACK_NO_BORDER, SYM_SIZE_FALLBACK_NO_BORDER)
|
||||
for (let i = 0; i < SYM_SIZE_FALLBACK_NO_BORDER * SYM_SIZE_FALLBACK_NO_BORDER; i++) {
|
||||
if ((s.glyph >> i) & 0x1) {
|
||||
for (let s = 0; s < 4; s++) {
|
||||
data.data[i * 4 + s] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
symCtx.putImageData(data, index * SYM_SIZE_FALLBACK_WITH_BORDER + 1, 1)
|
||||
});
|
||||
} else {
|
||||
symCtx.drawImage(symImg, -1, -1, symImg.width + 1, symImg.height + 1);
|
||||
}
|
||||
|
||||
const swatchCanvas = PImage.make(256 * SYM_SIZE, SYM_SIZE);
|
||||
const sctx = swatchCanvas.getContext('2d');
|
||||
sctx.clearRect(0, 0, 256 * SYM_SIZE, SYM_SIZE);
|
||||
palette.forEach((s, i) => {
|
||||
let symIndex: number = 0;
|
||||
switch (workerData.bpType) {
|
||||
case 'letters':
|
||||
symIndex = s.name.toUpperCase().charCodeAt(0) - 0x41;
|
||||
break;
|
||||
case 'symbols':
|
||||
symIndex = i;
|
||||
break;
|
||||
case 'numbers':
|
||||
symIndex = (i + 1) % 10;
|
||||
break;
|
||||
}
|
||||
sctx.drawImage(symCanvas,
|
||||
symIndex * SYM_SIZE,
|
||||
0,
|
||||
SYM_SIZE,
|
||||
SYM_SIZE,
|
||||
s.index * SYM_SIZE - ((workerData.bpType === 'numbers' && i < 9) ? 2 : 0), // centering numbers
|
||||
0,
|
||||
SYM_SIZE,
|
||||
SYM_SIZE);
|
||||
if (workerData.bpType === 'numbers' && i >= 9) {
|
||||
sctx.drawImage(symCanvas, Math.floor((i + 1) / 10) * SYM_SIZE + 4, 0, 4, SYM_SIZE, s.index * SYM_SIZE, 0, 4, SYM_SIZE);
|
||||
}
|
||||
});
|
||||
|
||||
// Colorize the blueprint marks:
|
||||
/*
|
||||
sctx.globalCompositeOperation = 'source-atop';
|
||||
(workerData.palette as ISwatch[]).forEach(s => {
|
||||
sctx.fillStyle = `rgba(${s.rgba[0]},${s.rgba[1]},${s.rgba[2]},${s.rgba[3] / 255})`;
|
||||
sctx.fillRect(s.index * SYM_SIZE, 0, SYM_SIZE, SYM_SIZE);
|
||||
});
|
||||
*/
|
||||
|
||||
const sData = sctx.getImageData(0, 0, 256 * SYM_SIZE, SYM_SIZE);
|
||||
for (let i = 0; i < sData.data.length; i += 4) {
|
||||
const px = (i / 4) % (256 * SYM_SIZE);
|
||||
const paletteIndex = Math.floor(px / SYM_SIZE);
|
||||
const swatch = (workerData.palette as ISwatch[]).find(sw => sw.index === paletteIndex);
|
||||
if (swatch) {
|
||||
sData.data[i + 0] = swatch.rgba[0];
|
||||
sData.data[i + 1] = swatch.rgba[1];
|
||||
sData.data[i + 2] = swatch.rgba[2];
|
||||
}
|
||||
}
|
||||
sctx.putImageData(sData, 0, 0, 256 * SYM_SIZE, SYM_SIZE);
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
const miniCanvas = PImage.make(w, h);
|
||||
const mctx = miniCanvas.getContext('2d');
|
||||
mctx.clearRect(0, 0, w, h);
|
||||
|
||||
// the PureImage lib uses nearest neighbor scaling for shrinking, but always uses left-top pixel from source rectangle
|
||||
mctx.drawImage(img, 0, 0, img.width, img.height, -1, -1, w + 1, h + 1);
|
||||
const { data } = mctx.getImageData(0, 0, w, h);
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const color = toNearestColor([data[i], data[i + 1], data[i + 2], data[i + 3]], workerData.palette);
|
||||
if (!color || data[i + 3] < 128) continue;
|
||||
const x = (i / 4) % w;
|
||||
const y = Math.floor(i / 4 / w);
|
||||
|
||||
ctx.drawImage(
|
||||
swatchCanvas,
|
||||
color.index * SYM_SIZE,
|
||||
0,
|
||||
SYM_SIZE,
|
||||
SYM_SIZE,
|
||||
x * SYM_SIZE,
|
||||
y * SYM_SIZE,
|
||||
SYM_SIZE,
|
||||
SYM_SIZE);
|
||||
}
|
||||
|
||||
PImage.encodePNGToStream(canvas, fs.createWriteStream(workerData.dirPath + '/' + workerData.stencilUrl)).then(() => {
|
||||
console.log('blueprint worker: finishing');
|
||||
parentPort?.postMessage(workerData.fileName);
|
||||
})
|
||||
})
|
||||
0
plan.md
Normal file
0
plan.md
Normal file
|
|
@ -1,3 +1,4 @@
|
|||
export const TILE_SIZE = 1000;
|
||||
export const MAX_OVERLAY_DIM = 1000;
|
||||
export const MINIFY_SCALE = 3;
|
||||
export const MINIFY_SCALE = 3;
|
||||
export const MINIFY_SCALE_SYMBOL = 7;
|
||||
|
|
@ -1,7 +1,31 @@
|
|||
import { createCanvas, createHTMLCanvas, canvasToBlob, blobToImage, loadImage } from './canvas';
|
||||
import { MINIFY_SCALE, TILE_SIZE, MAX_OVERLAY_DIM } from './constants';
|
||||
import { MINIFY_SCALE, MINIFY_SCALE_SYMBOL, TILE_SIZE, MAX_OVERLAY_DIM } from './constants';
|
||||
import { imageDecodeCache, overlayCache, tooLargeOverlays } from './cache';
|
||||
import { showToast } from './toast';
|
||||
import { config } from './store';
|
||||
import { WPLACE_FREE, WPLACE_PAID, SYMBOL_TILES, SYMBOL_W, SYMBOL_H } from './palette';
|
||||
|
||||
const ALL_COLORS = [...WPLACE_FREE, ...WPLACE_PAID];
|
||||
const colorIndexMap = new Map<string, number>();
|
||||
ALL_COLORS.forEach((c, i) => colorIndexMap.set(c.join(','), i));
|
||||
|
||||
function findClosestColorIndex(r: number, g: number, b: number) {
|
||||
let minDistance = Infinity;
|
||||
let index = 0;
|
||||
for (let i = 0; i < ALL_COLORS.length; i++) {
|
||||
const color = ALL_COLORS[i];
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(r - color[0], 2) +
|
||||
Math.pow(g - color[1], 2) +
|
||||
Math.pow(b - color[2], 2)
|
||||
);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
index = i;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function extractPixelCoords(pixelUrl: string) {
|
||||
try {
|
||||
|
|
@ -102,7 +126,7 @@ export async function buildOverlayDataForChunkUnified(
|
|||
const cached = overlayCache.get(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const colorStrength = ov.opacity;
|
||||
const colorStrength = (mode === 'minify') ? 1.0 : ov.opacity;
|
||||
const whiteStrength = 1 - colorStrength;
|
||||
|
||||
if (mode !== 'minify') {
|
||||
|
|
@ -117,10 +141,15 @@ export async function buildOverlayDataForChunkUnified(
|
|||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i + 3] > 0) {
|
||||
data[i] = Math.round(data[i] * colorStrength + 255 * whiteStrength);
|
||||
data[i + 1] = Math.round(data[i + 1] * colorStrength + 255 * whiteStrength);
|
||||
data[i + 2] = Math.round(data[i + 2] * colorStrength + 255 * whiteStrength);
|
||||
const r = data[i], g = data[i+1], b = data[i+2], a = data[i+3];
|
||||
// Special case for #deface color
|
||||
if (r === 0xde && g === 0xfa && b === 0xce) {
|
||||
continue;
|
||||
}
|
||||
if (a > 0) {
|
||||
data[i] = Math.round(r * colorStrength + 255 * whiteStrength);
|
||||
data[i + 1] = Math.round(g * colorStrength + 255 * whiteStrength);
|
||||
data[i + 2] = Math.round(b * colorStrength + 255 * whiteStrength);
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
|
@ -129,50 +158,125 @@ export async function buildOverlayDataForChunkUnified(
|
|||
overlayCache.set(cacheKey, result);
|
||||
return result;
|
||||
} else {
|
||||
const scale = MINIFY_SCALE;
|
||||
const tileW = TILE_SIZE * scale;
|
||||
const tileH = TILE_SIZE * scale;
|
||||
const drawXScaled = Math.round(drawX * scale);
|
||||
const drawYScaled = Math.round(drawY * scale);
|
||||
const wScaled = wImg * scale;
|
||||
const hScaled = hImg * scale;
|
||||
if (config.minifyStyle === 'symbols') {
|
||||
const scale = MINIFY_SCALE_SYMBOL;
|
||||
const tileW = TILE_SIZE * scale;
|
||||
const tileH = TILE_SIZE * scale;
|
||||
const drawXScaled = Math.round(drawX * scale);
|
||||
const drawYScaled = Math.round(drawY * scale);
|
||||
const wScaled = wImg * scale;
|
||||
const hScaled = hImg * scale;
|
||||
|
||||
const isect = rectIntersect(0, 0, tileW, tileH, drawXScaled, drawYScaled, wScaled, hScaled);
|
||||
if (isect.w === 0 || isect.h === 0) { overlayCache.set(cacheKey, null); return null; }
|
||||
const isect = rectIntersect(0, 0, tileW, tileH, drawXScaled, drawYScaled, wScaled, hScaled);
|
||||
if (isect.w === 0 || isect.h === 0) { overlayCache.set(cacheKey, null); return null; }
|
||||
|
||||
const canvas = createCanvas(tileW, tileH) as any;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.clearRect(0, 0, tileW, tileH);
|
||||
ctx.drawImage(img as any, 0, 0, wImg, hImg, drawXScaled, drawYScaled, wScaled, hScaled);
|
||||
const canvas = createCanvas(wImg, hImg) as any;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(img as any, 0, 0);
|
||||
const originalImageData = ctx.getImageData(0, 0, wImg, hImg);
|
||||
|
||||
const imageData = ctx.getImageData(isect.x, isect.y, isect.w, isect.h);
|
||||
const data = imageData.data;
|
||||
const center = Math.floor(scale / 2);
|
||||
const width = isect.w;
|
||||
const outCanvas = createCanvas(tileW, tileH) as any;
|
||||
const outCtx = outCanvas.getContext('2d', { willReadFrequently: true })!;
|
||||
const outputImageData = outCtx.createImageData(tileW, tileH);
|
||||
const outData = outputImageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const a = data[i + 3];
|
||||
if (a === 0) continue;
|
||||
for (let y = 0; y < TILE_SIZE; y++) {
|
||||
for (let x = 0; x < TILE_SIZE; x++) {
|
||||
const imgX = x - drawX;
|
||||
const imgY = y - drawY;
|
||||
if (imgX >= 0 && imgX < wImg && imgY >= 0 && imgY < hImg) {
|
||||
const idx = (imgY * wImg + imgX) * 4;
|
||||
const r = originalImageData.data[idx];
|
||||
const g = originalImageData.data[idx+1];
|
||||
const b = originalImageData.data[idx+2];
|
||||
const a = originalImageData.data[idx+3];
|
||||
|
||||
const px = (i / 4) % width;
|
||||
const py = Math.floor((i / 4) / width);
|
||||
const absX = isect.x + px;
|
||||
const absY = isect.y + py;
|
||||
if (a > 128 && !(r === 0xde && g === 0xfa && b === 0xce)) {
|
||||
const colorIndex = findClosestColorIndex(r, g, b);
|
||||
if (colorIndex < SYMBOL_TILES.length) {
|
||||
const symbol = SYMBOL_TILES[colorIndex];
|
||||
const tileX = Math.floor(x * scale);
|
||||
const tileY = Math.floor(y * scale);
|
||||
const a_r = ALL_COLORS[colorIndex][0];
|
||||
const a_g = ALL_COLORS[colorIndex][1];
|
||||
const a_b = ALL_COLORS[colorIndex][2];
|
||||
|
||||
if ((absX % scale) === center && (absY % scale) === center) {
|
||||
data[i] = Math.round(data[i] * colorStrength + 255 * whiteStrength);
|
||||
data[i + 1] = Math.round(data[i + 1] * colorStrength + 255 * whiteStrength);
|
||||
data[i + 2] = Math.round(data[i + 2] * colorStrength + 255 * whiteStrength);
|
||||
data[i + 3] = 255;
|
||||
} else {
|
||||
data[i] = 0; data[i + 1] = 0; data[i + 2] = 0; data[i + 3] = 0;
|
||||
for (let sy = 0; sy < SYMBOL_H; sy++) {
|
||||
for (let sx = 0; sx < SYMBOL_W; sx++) {
|
||||
const bit_idx = sy * SYMBOL_W + sx;
|
||||
const bit = (symbol >>> bit_idx) & 1;
|
||||
if (bit) {
|
||||
const outX = tileX + sx + Math.floor((scale - SYMBOL_W) / 2);
|
||||
const outY = tileY + sy + Math.floor((scale - SYMBOL_H) / 2);
|
||||
if (outX >= 0 && outX < tileW && outY >= 0 && outY < tileH) {
|
||||
const outIdx = (outY * tileW + outX) * 4;
|
||||
outData[outIdx] = a_r;
|
||||
outData[outIdx+1] = a_g;
|
||||
outData[outIdx+2] = a_b;
|
||||
outData[outIdx+3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
outCtx.putImageData(outputImageData, 0, 0);
|
||||
|
||||
const result = { imageData, dx: isect.x, dy: isect.y, scaled: true, scale };
|
||||
overlayCache.set(cacheKey, result);
|
||||
return result;
|
||||
const finalIsect = rectIntersect(0, 0, tileW, tileH, 0, 0, tileW, tileH);
|
||||
const finalImageData = outCtx.getImageData(finalIsect.x, finalIsect.y, finalIsect.w, finalIsect.h);
|
||||
|
||||
const result = { imageData: finalImageData, dx: finalIsect.x, dy: finalIsect.y, scaled: true, scale };
|
||||
overlayCache.set(cacheKey, result);
|
||||
return result;
|
||||
} else { // 'dots'
|
||||
const scale = MINIFY_SCALE;
|
||||
const tileW = TILE_SIZE * scale;
|
||||
const tileH = TILE_SIZE * scale;
|
||||
const drawXScaled = Math.round(drawX * scale);
|
||||
const drawYScaled = Math.round(drawY * scale);
|
||||
const wScaled = wImg * scale;
|
||||
const hScaled = hImg * scale;
|
||||
|
||||
const isect = rectIntersect(0, 0, tileW, tileH, drawXScaled, drawYScaled, wScaled, hScaled);
|
||||
if (isect.w === 0 || isect.h === 0) { overlayCache.set(cacheKey, null); return null; }
|
||||
|
||||
const canvas = createCanvas(tileW, tileH) as any;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.clearRect(0, 0, tileW, tileH);
|
||||
ctx.drawImage(img as any, 0, 0, wImg, hImg, drawXScaled, drawYScaled, wScaled, hScaled);
|
||||
|
||||
const imageData = ctx.getImageData(isect.x, isect.y, isect.w, isect.h);
|
||||
const data = imageData.data;
|
||||
const center = Math.floor(scale / 2);
|
||||
const width = isect.w;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const a = data[i + 3];
|
||||
if (a === 0) continue;
|
||||
|
||||
const px = (i / 4) % width;
|
||||
const py = Math.floor((i / 4) / width);
|
||||
const absX = isect.x + px;
|
||||
const absY = isect.y + py;
|
||||
|
||||
if ((absX % scale) === center && (absY % scale) === center) {
|
||||
data[i] = Math.round(data[i] * colorStrength + 255 * whiteStrength);
|
||||
data[i + 1] = Math.round(data[i + 1] * colorStrength + 255 * whiteStrength);
|
||||
data[i + 2] = Math.round(data[i + 2] * colorStrength + 255 * whiteStrength);
|
||||
data[i + 3] = 255;
|
||||
} else {
|
||||
data[i] = 0; data[i + 1] = 0; data[i + 2] = 0; data[i + 3] = 0;
|
||||
}
|
||||
}
|
||||
const result = { imageData, dx: isect.x, dy: isect.y, scaled: true, scale };
|
||||
overlayCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -185,7 +289,7 @@ export async function composeTileUnified(
|
|||
const originalImage = await blobToImage(originalBlob) as any;
|
||||
|
||||
if (mode === 'minify') {
|
||||
const scale = MINIFY_SCALE;
|
||||
const scale = config.minifyStyle === 'symbols' ? MINIFY_SCALE_SYMBOL : MINIFY_SCALE;
|
||||
const w = originalImage.width, h = originalImage.height;
|
||||
const canvas = createCanvas(w * scale, h * scale) as any;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
|
||||
|
|
|
|||
|
|
@ -44,4 +44,15 @@ export const WPLACE_NAMES: Record<string,string> = {
|
|||
"51,57,65":"Dark Slate","109,117,141":"Slate","179,185,209":"Light Slate"
|
||||
};
|
||||
export const DEFAULT_FREE_KEYS = WPLACE_FREE.map(([r,g,b]) => `${r},${g},${b}`);
|
||||
export const DEFAULT_PAID_KEYS: string[] = [];
|
||||
export const DEFAULT_PAID_KEYS: string[] = [];
|
||||
|
||||
// Auto-generated by png_to_bitmask_js.py
|
||||
// Each tile is a 25-bit mask (1 = black, 0 = transparent),
|
||||
// bits packed LSB-first in row-major order: bit = y*5 + x for (x,y) in [0..4].
|
||||
export const SYMBOL_W = 5;
|
||||
export const SYMBOL_H = 5;
|
||||
export const SYMBOL_TILES = new Uint32Array([0x04ABAA4, 0x0489224, 0x0E8922E, 0x0A8D6AA, 0x0E8FEAE, 0x1FABB75, 0x0EFFFEE, 0x0EEEEEE, 0x1FAFEBF, 0x1FD82BF, 0x1F212BF, 0x0EDDEEA, 0x0AFBBEA, 0x094EE52, 0x0EAFA88, 0x0477FEA, 0x0EDD76E, 0x1BDA95C, 0x0367CD8, 0x0E8D62E, 0x1BDEF7F, 0x146F46F, 0x1577DD5, 0x0E756B5, 0x04739C4, 0x0ADD5C4, 0x0AD936A, 0x067F308, 0x04FBBEE, 0x1BD837B, 0x11701D1, 0x1E601E6, 0x1260126, 0x0F7BF14, 0x1FEE34C, 0x15A82B5, 0x0DB01B6, 0x077BD9E, 0x074C65C, 0x15756AE, 0x1B23AB5, 0x08C1062, 0x1CF18E3, 0x0477DD5, 0x11729D1, 0x1999A79, 0x1759577, 0x04F837B, 0x0E247FF, 0x1123891, 0x1B8923B, 0x0476DC4, 0x1466CC5, 0x071D71C, 0x15F29F5, 0x1BAA94E, 0x11F9231, 0x14D0754, 0x1BAC6BB, 0x0427C84, 0x15FD48E, 0x19A28B3, 0x04FFDC4, 0x0E8814A]);
|
||||
|
||||
// Example: test a bit (x,y) in tile i
|
||||
// const idx = y * TILE_W + x;
|
||||
// const bit = (TILES[i] >>> idx) & 1;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export type Config = {
|
|||
overlays: OverlayItem[];
|
||||
activeOverlayId: string | null;
|
||||
overlayMode: 'behind' | 'above' | 'minify' | 'original';
|
||||
minifyStyle: 'dots' | 'symbols';
|
||||
isPanelCollapsed: boolean;
|
||||
autoCapturePixelUrl: boolean;
|
||||
panelX: number | null;
|
||||
|
|
@ -26,7 +27,7 @@ export type Config = {
|
|||
theme: 'light' | 'dark';
|
||||
collapseList: boolean;
|
||||
collapseEditor: boolean;
|
||||
collapseNudge: boolean;
|
||||
collapsePositioning: boolean;
|
||||
ccFreeKeys: string[];
|
||||
ccPaidKeys: string[];
|
||||
ccZoom: number;
|
||||
|
|
@ -37,6 +38,7 @@ export const config: Config = {
|
|||
overlays: [],
|
||||
activeOverlayId: null,
|
||||
overlayMode: 'behind',
|
||||
minifyStyle: 'dots',
|
||||
isPanelCollapsed: false,
|
||||
autoCapturePixelUrl: false,
|
||||
panelX: null,
|
||||
|
|
@ -44,7 +46,7 @@ export const config: Config = {
|
|||
theme: 'light',
|
||||
collapseList: false,
|
||||
collapseEditor: false,
|
||||
collapseNudge: false,
|
||||
collapsePositioning: false,
|
||||
ccFreeKeys: DEFAULT_FREE_KEYS.slice(),
|
||||
ccPaidKeys: DEFAULT_PAID_KEYS.slice(),
|
||||
ccZoom: 1.0,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// ==UserScript==
|
||||
// @name Wplace Overlay Pro
|
||||
// @namespace http://netcavy.net/
|
||||
// @namespace http://tampermonkey.net/
|
||||
// @version 3.0.0
|
||||
// @description Overlays tiles on wplace.live. Can also resize, and color-match your overlay to wplace's palette. Make sure to comply with the site's Terms of Service, and rules! This script is not affiliated with Wplace.live in any way, use at your own risk. This script is not affiliated with TamperMonkey. The author of this userscript is not responsible for any damages, issues, loss of data, or punishment that may occur as a result of using this script. This script is provided "as is" under GPLv3.
|
||||
// @author shinkonet
|
||||
|
|
|
|||
183
src/ui/panel.ts
183
src/ui/panel.ts
|
|
@ -37,13 +37,55 @@ export function createUI() {
|
|||
</div>
|
||||
<div class="op-content" id="op-content">
|
||||
<div class="op-section">
|
||||
<div class="op-row space">
|
||||
<button class="op-button" id="op-mode-toggle">Mode</button>
|
||||
<div class="op-row">
|
||||
<span class="op-muted" id="op-place-label">Place overlay:</span>
|
||||
<button class="op-button" id="op-autocap-toggle" title="Capture next clicked pixel as anchor">OFF</button>
|
||||
<div class="op-section-title">
|
||||
<div class="op-title-left">
|
||||
<span class="op-title-text">Mode</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="op-row op-tabs">
|
||||
<button class="op-tab-btn" data-mode="above">Full Overlay</button>
|
||||
<button class="op-tab-btn" data-mode="minify">Mini-pixel</button>
|
||||
<button class="op-tab-btn" data-mode="original">Disabled</button>
|
||||
</div>
|
||||
<div id="op-mode-settings">
|
||||
<div class="op-mode-setting" data-setting="above">
|
||||
<div class="op-row"><label>Layering</label><div id="op-layering-btns"></div></div>
|
||||
<div class="op-row"><label style="width: 60px;">Opacity</label><input type="range" min="0" max="1" step="0.05" class="op-slider op-grow" id="op-opacity-slider"><span id="op-opacity-value" style="width: 36px; text-align: right;">70%</span></div>
|
||||
</div>
|
||||
<div class="op-mode-setting" data-setting="minify">
|
||||
<div class="op-row"><label>Style</label>
|
||||
<div class="op-row"><input type="radio" name="minify-style" value="dots" id="op-style-dots"><label for="op-style-dots">Dots</label></div>
|
||||
<div class="op-row"><input type="radio" name="minify-style" value="symbols" id="op-style-symbols"><label for="op-style-symbols">Symbols</label></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="op-section" id="op-positioning-section">
|
||||
<div class="op-section-title">
|
||||
<div class="op-title-left">
|
||||
<span class="op-title-text">Positioning</span>
|
||||
</div>
|
||||
<div class="op-title-right">
|
||||
<span class="op-muted" id="op-offset-indicator">Offset X 0, Y 0</span>
|
||||
<button class="op-chevron" id="op-collapse-positioning" title="Collapse/Expand">▾</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="op-positioning-body">
|
||||
<div class="op-row space">
|
||||
<div>
|
||||
<span class="op-muted" id="op-place-label">Place overlay:</span>
|
||||
<div class="op-small-text">Click a pixel on the canvas to set the anchor.</div>
|
||||
</div>
|
||||
<button class="op-button" id="op-autocap-toggle" title="Capture next clicked pixel as anchor">Disabled</button>
|
||||
</div>
|
||||
<div class="op-nudge-row" style="text-align: right;">
|
||||
<button class="op-icon-btn" id="op-nudge-left" title="Left">←</button>
|
||||
<button class="op-icon-btn" id="op-nudge-down" title="Down">↓</button>
|
||||
<button class="op-icon-btn" id="op-nudge-up" title="Up">↑</button>
|
||||
<button class="op-icon-btn" id="op-nudge-right" title="Right">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="op-section">
|
||||
|
|
@ -104,32 +146,6 @@ export function createUI() {
|
|||
</div>
|
||||
|
||||
<div class="op-row"><span class="op-muted" id="op-coord-display"></span></div>
|
||||
|
||||
<div class="op-row" style="width: 100%; gap: 12px; padding: 6px 0;">
|
||||
<label style="width: 60px;">Opacity</label>
|
||||
<input type="range" min="0" max="1" step="0.05" class="op-slider op-grow" id="op-opacity-slider">
|
||||
<span id="op-opacity-value" style="width: 36px; text-align: right;">70%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="op-section" id="op-nudge-section">
|
||||
<div class="op-section-title">
|
||||
<div class="op-title-left">
|
||||
<span class="op-title-text">Nudge overlay</span>
|
||||
</div>
|
||||
<div class="op-title-right">
|
||||
<span class="op-muted" id="op-offset-indicator">Offset X 0, Y 0</span>
|
||||
<button class="op-chevron" id="op-collapse-nudge" title="Collapse/Expand">▾</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="op-nudge-body">
|
||||
<div class="op-nudge-row" style="text-align: right;">
|
||||
<button class="op-icon-btn" id="op-nudge-left" title="Left">←</button>
|
||||
<button class="op-icon-btn" id="op-nudge-down" title="Down">↓</button>
|
||||
<button class="op-icon-btn" id="op-nudge-up" title="Up">↑</button>
|
||||
<button class="op-icon-btn" id="op-nudge-right" title="Right">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -257,14 +273,24 @@ function addEventListeners(panel: HTMLDivElement) {
|
|||
$('op-refresh-btn').addEventListener('click', (e) => { e.stopPropagation(); location.reload(); });
|
||||
$('op-panel-toggle').addEventListener('click', (e) => { e.stopPropagation(); config.isPanelCollapsed = !config.isPanelCollapsed; saveConfig(['isPanelCollapsed']); updateUI(); });
|
||||
|
||||
$('op-mode-toggle').addEventListener('click', () => {
|
||||
const modes: any[] = ['behind', 'above', 'minify', 'original'];
|
||||
const current = modes.indexOf(config.overlayMode);
|
||||
config.overlayMode = modes[(current + 1) % modes.length] as any;
|
||||
saveConfig(['overlayMode']);
|
||||
ensureHook();
|
||||
updateUI();
|
||||
panel.querySelectorAll('.op-tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const mode = btn.getAttribute('data-mode') as 'above' | 'minify' | 'original';
|
||||
if (mode === 'above') {
|
||||
if (config.overlayMode !== 'behind') {
|
||||
config.overlayMode = 'above';
|
||||
}
|
||||
} else {
|
||||
config.overlayMode = mode;
|
||||
}
|
||||
saveConfig(['overlayMode']);
|
||||
ensureHook();
|
||||
updateUI();
|
||||
});
|
||||
});
|
||||
$('op-style-dots').addEventListener('change', () => { if (($('op-style-dots') as HTMLInputElement).checked) { config.minifyStyle = 'dots'; saveConfig(['minifyStyle']); clearOverlayCache(); ensureHook(); }});
|
||||
$('op-style-symbols').addEventListener('change', () => { if (($('op-style-symbols') as HTMLInputElement).checked) { config.minifyStyle = 'symbols'; saveConfig(['minifyStyle']); clearOverlayCache(); ensureHook(); }});
|
||||
|
||||
$('op-autocap-toggle').addEventListener('click', () => { config.autoCapturePixelUrl = !config.autoCapturePixelUrl; saveConfig(['autoCapturePixelUrl']); ensureHook(); updateUI(); });
|
||||
|
||||
$('op-add-overlay').addEventListener('click', async () => { try { await addBlankOverlay(); } catch (e) { console.error(e); } });
|
||||
|
|
@ -272,7 +298,7 @@ function addEventListeners(panel: HTMLDivElement) {
|
|||
$('op-export-overlay').addEventListener('click', () => exportActiveOverlayToClipboard());
|
||||
$('op-collapse-list').addEventListener('click', () => { config.collapseList = !config.collapseList; saveConfig(['collapseList']); updateUI(); });
|
||||
$('op-collapse-editor').addEventListener('click', () => { config.collapseEditor = !config.collapseEditor; saveConfig(['collapseEditor']); updateUI(); });
|
||||
$('op-collapse-nudge').addEventListener('click', () => { config.collapseNudge = !config.collapseNudge; saveConfig(['collapseNudge']); updateUI(); });
|
||||
$('op-collapse-positioning').addEventListener('click', () => { config.collapsePositioning = !config.collapsePositioning; saveConfig(['collapsePositioning']); updateUI(); });
|
||||
|
||||
$('op-name').addEventListener('change', async (e: any) => {
|
||||
const ov = getActiveOverlay(); if (!ov) return;
|
||||
|
|
@ -425,12 +451,13 @@ function updateEditorUI() {
|
|||
}
|
||||
|
||||
const coords = ov.pixelUrl ? extractPixelCoords(ov.pixelUrl) : { chunk1: '-', chunk2: '-', posX: '-', posY: '-' } as any;
|
||||
$('op-coord-display').textContent = ov.pixelUrl
|
||||
? `Ref: chunk ${coords.chunk1}/${coords.chunk2} at (${coords.posX}, ${coords.posY})`
|
||||
: `No pixel anchor set. Turn ON "Place overlay" and click a pixel once.`;
|
||||
|
||||
( $('op-opacity-slider') as HTMLInputElement ).value = String(ov.opacity);
|
||||
$('op-opacity-value').textContent = Math.round(ov.opacity * 100) + '%';
|
||||
const coordDisplay = $('op-coord-display');
|
||||
if(coordDisplay) {
|
||||
coordDisplay.textContent = ov.pixelUrl
|
||||
? `Ref: chunk ${coords.chunk1}/${coords.chunk2} at (${coords.posX}, ${coords.posY})`
|
||||
: `No pixel anchor set. Enable placement and click a pixel.`;
|
||||
}
|
||||
|
||||
|
||||
const indicator = $('op-offset-indicator');
|
||||
if (indicator) indicator.textContent = `Offset X ${ov.offsetX}, Y ${ov.offsetY}`;
|
||||
|
|
@ -452,26 +479,72 @@ export function updateUI() {
|
|||
toggle.textContent = collapsed ? '▸' : '▾';
|
||||
toggle.title = collapsed ? 'Expand' : 'Collapse';
|
||||
|
||||
const modeBtn = $('op-mode-toggle');
|
||||
const modeMap: any = { behind: 'Overlay Behind', above: 'Overlay Above', minify: `Minified`, original: 'Original' };
|
||||
modeBtn.textContent = `Mode: ${modeMap[config.overlayMode] || 'Original'}`;
|
||||
// --- Mode Tabs ---
|
||||
panelEl.querySelectorAll('.op-tab-btn').forEach(btn => {
|
||||
const mode = btn.getAttribute('data-mode');
|
||||
let isActive = false;
|
||||
if (mode === 'above' && (config.overlayMode === 'above' || config.overlayMode === 'behind')) {
|
||||
isActive = true;
|
||||
} else {
|
||||
isActive = mode === config.overlayMode;
|
||||
}
|
||||
btn.classList.toggle('active', isActive);
|
||||
});
|
||||
|
||||
// --- Mode Settings ---
|
||||
const fullOverlaySettings = $('op-mode-settings').querySelector('[data-setting="above"]') as HTMLDivElement;
|
||||
const minifySettings = $('op-mode-settings').querySelector('[data-setting="minify"]') as HTMLDivElement;
|
||||
|
||||
if (config.overlayMode === 'above' || config.overlayMode === 'behind') {
|
||||
fullOverlaySettings.classList.add('active');
|
||||
minifySettings.classList.remove('active');
|
||||
const ov = getActiveOverlay();
|
||||
if(ov) {
|
||||
( $('op-opacity-slider') as HTMLInputElement ).value = String(ov.opacity);
|
||||
$('op-opacity-value').textContent = Math.round(ov.opacity * 100) + '%';
|
||||
}
|
||||
} else if (config.overlayMode === 'minify') {
|
||||
fullOverlaySettings.classList.remove('active');
|
||||
minifySettings.classList.add('active');
|
||||
} else {
|
||||
fullOverlaySettings.classList.remove('active');
|
||||
minifySettings.classList.remove('active');
|
||||
}
|
||||
|
||||
($('op-style-dots') as HTMLInputElement).checked = config.minifyStyle === 'dots';
|
||||
($('op-style-symbols') as HTMLInputElement).checked = config.minifyStyle === 'symbols';
|
||||
|
||||
const layeringBtns = $('op-layering-btns');
|
||||
layeringBtns.innerHTML = '';
|
||||
const behindBtn = document.createElement('button');
|
||||
behindBtn.textContent = 'Behind';
|
||||
behindBtn.className = 'op-button' + (config.overlayMode === 'behind' ? ' active' : '');
|
||||
behindBtn.addEventListener('click', () => { config.overlayMode = 'behind'; saveConfig(['overlayMode']); ensureHook(); updateUI(); });
|
||||
const aboveBtn = document.createElement('button');
|
||||
aboveBtn.textContent = 'Above';
|
||||
aboveBtn.className = 'op-button' + (config.overlayMode === 'above' ? ' active' : '');
|
||||
aboveBtn.addEventListener('click', () => { config.overlayMode = 'above'; saveConfig(['overlayMode']); ensureHook(); updateUI(); });
|
||||
layeringBtns.appendChild(behindBtn);
|
||||
layeringBtns.appendChild(aboveBtn);
|
||||
|
||||
|
||||
// --- Positioning Section ---
|
||||
const autoBtn = $('op-autocap-toggle');
|
||||
const placeLabel = $('op-place-label');
|
||||
autoBtn.textContent = config.autoCapturePixelUrl ? 'ON' : 'OFF';
|
||||
autoBtn.textContent = config.autoCapturePixelUrl ? 'Enabled' : 'Disabled';
|
||||
autoBtn.classList.toggle('op-danger', !!config.autoCapturePixelUrl);
|
||||
placeLabel.classList.toggle('op-danger-text', !!config.autoCapturePixelUrl);
|
||||
if(placeLabel) placeLabel.classList.toggle('op-danger-text', !!config.autoCapturePixelUrl);
|
||||
|
||||
const positioningBody = $('op-positioning-body');
|
||||
const positioningCz = $('op-collapse-positioning');
|
||||
if(positioningBody) positioningBody.style.display = config.collapsePositioning ? 'none' : 'block';
|
||||
if (positioningCz) positioningCz.textContent = config.collapsePositioning ? '▸' : '▾';
|
||||
|
||||
const listWrap = $('op-list-wrap');
|
||||
const listCz = $('op-collapse-list');
|
||||
listWrap.style.display = config.collapseList ? 'none' : 'block';
|
||||
if (listCz) listCz.textContent = config.collapseList ? '▸' : '▾';
|
||||
|
||||
const nudgeBody = $('op-nudge-body');
|
||||
const nudgeCz = $('op-collapse-nudge');
|
||||
nudgeBody.style.display = config.collapseNudge ? 'none' : 'block';
|
||||
if (nudgeCz) nudgeCz.textContent = config.collapseNudge ? '▸' : '▾';
|
||||
|
||||
rebuildOverlayListUI();
|
||||
updateEditorUI();
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export function injectStyles() {
|
|||
|
||||
.op-row { display: flex; align-items: center; gap: 8px; }
|
||||
.op-row.space { justify-content: space-between; }
|
||||
.op-small-text { font-size: 11px; color: var(--op-muted); }
|
||||
|
||||
.op-button { background: var(--op-btn); color: var(--op-text); border: 1px solid var(--op-btn-border); border-radius: 10px; padding: 6px 10px; cursor: pointer; }
|
||||
.op-button:hover { background: var(--op-btn-hover); }
|
||||
|
|
@ -67,6 +68,14 @@ export function injectStyles() {
|
|||
|
||||
.op-muted { color: var(--op-muted); font-size: 12px; }
|
||||
|
||||
.op-tabs { padding: 4px; border-bottom: 1px solid var(--op-border); }
|
||||
.op-tab-btn { flex: 1; padding: 6px; border-radius: 8px; border: 1px solid transparent; background: transparent; color: var(--op-text); cursor: pointer; }
|
||||
.op-tab-btn:hover { background: var(--op-btn-hover); }
|
||||
.op-tab-btn.active { background: var(--op-btn); border-color: var(--op-btn-border); font-weight: 600; }
|
||||
|
||||
.op-mode-setting { display: none; padding: 6px; }
|
||||
.op-mode-setting.active { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.op-preview { width: 100%; height: 90px; background: var(--op-bg); display: flex; align-items: center; justify-content: center; border: 2px dashed color-mix(in oklab, var(--op-accent) 40%, var(--op-border)); border-radius: 10px; overflow: hidden; position: relative; cursor: pointer; }
|
||||
.op-preview img { max-width: 100%; max-height: 100%; display: block; pointer-events: none; }
|
||||
.op-preview.drop-highlight { background: color-mix(in oklab, var(--op-accent) 12%, transparent); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue