This commit is contained in:
SwingTheVine 2026-03-08 03:35:07 +00:00 committed by GitHub
commit 76ee827e9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2459 additions and 1109 deletions

View file

@ -1,131 +1,3 @@
/* src/WindowFilter.css */
#bm-window-filter p svg {
display: inline;
height: 1em;
fill: white;
}
#bm-filter-flex {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 1em 3ch;
}
.bm-filter-color {
width: fit-content;
max-width: 35ch;
background-color: rgba(21, 48, 99, 0.9);
border-radius: 1em;
padding: 0.5em;
gap: 1ch;
transition: background-color 0.3s ease;
}
.bm-filter-color:hover,
.bm-filter-color:focus-within {
background-color: rgba(17, 40, 85, 0.9);
}
.bm-filter-container-rgb {
display: block;
border: thick double darkslategray;
width: fit-content;
height: fit-content;
padding: 1ch;
}
.bm-filter-color[data-id="-2"] .bm-filter-container-rgb {
background:
conic-gradient(
#aa0000 0%,
#aaaa00 16.6%,
#00aa00 33.3%,
#00aaaa 50%,
#0000aa 66.6%,
#aa00aa 83.3%,
#aa0000 100%);
}
.bm-filter-color[data-id="-1"] .bm-filter-container-rgb {
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 8 8" width="1em" height="1em"><path d="M0,0V8H16V16H8V0" fill="rgba(0,0,0,0.5)"/></svg>') repeat;
background-color: transparent !important;
}
.bm-filter-color[data-id="-1"] .bm-filter-container-rgb svg {
fill: white !important;
}
.bm-filter-color[data-id="0"] .bm-filter-container-rgb {
background-color: transparent !important;
}
#bm-window-filter .bm-filter-container-rgb button {
padding: 0.75em 0.5ch;
}
.bm-filter-container-rgb svg {
width: 4ch;
}
.bm-filter-color > .bm-flex-between {
flex-direction: column;
align-items: flex-start;
gap: 0;
}
.bm-filter-color small {
font-size: 0.75em;
}
#bm-window-filter .bm-filter-color.bm-color-hide {
display: none;
}
.bm-windowed #bm-filter-flex {
flex-direction: column;
gap: 0.25em;
}
.bm-windowed .bm-filter-color {
width: auto;
margin: 0;
padding: 0;
}
.bm-windowed .bm-filter-container-rgb {
display: flex;
width: 100%;
gap: 0.5ch;
align-items: center;
padding: 0.1em 0.5ch;
border: none;
border-radius: 1em;
}
#bm-window-filter.bm-windowed .bm-filter-container-rgb button {
padding: 0.5em 0.25ch;
}
.bm-windowed .bm-filter-container-rgb svg {
width: 3ch;
}
.bm-windowed .bm-filter-color h2 {
font-size: 0.75em;
}
/* src/WindowWizard.css */
#bm-wizard-tlist {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
#bm-wizard-tlist > .bm-container {
width: 100%;
justify-content: flex-start;
background-color: rgba(21, 48, 99, 0.9);
border-radius: 1em;
padding: 0.5em;
transition: background-color 0.3s ease;
}
#bm-wizard-tlist > .bm-container:hover,
#bm-wizard-tlist > .bm-container:focus-within {
background-color: rgba(17, 40, 85, 0.9);
}
#bm-wizard-tlist .bm-wizard-template-container-image {
height: 100%;
font-size: xxx-large;
}
#bm-wizard-tlist .bm-wizard-template-container-flavor {
flex-direction: column;
align-items: flex-start;
gap: 0;
}
/* src/confettiManager.css */
div:has(> confetti-piece) {
position: absolute;
@ -222,7 +94,8 @@ confetti-piece {
font-weight: bold;
vertical-align: middle;
}
.bm-dragbar h1 {
.bm-dragbar h1,
.bm-dragbar-text {
font-size: 1.2em;
user-select: none;
overflow: hidden;
@ -406,11 +279,201 @@ input[type=file] {
font-size: 1.6em;
font-family: monospace;
}
.bm-container:not(#bm-window-main .bm-container) {
margin: 0.25em 0;
.bm-windowed .bm-container:not(#bm-window-main .bm-container) {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
.bm-windowed h1:not(#bm-window-main h1) {
font-size: 1em;
}
/* src/WindowFilter.css */
#bm-window-filter p svg {
display: inline;
height: 1em;
fill: white;
}
#bm-filter-flex {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 1em 3ch;
}
#bm-window-filter .bm-filter-color {
width: fit-content;
max-width: 35ch;
background-color: rgba(21, 48, 99, 0.9);
border-radius: 1em;
padding: 0.5em;
gap: 1ch;
transition: background-color 0.3s ease;
}
#bm-window-filter .bm-filter-color:hover,
#bm-window-filter.bm-filter-color:focus-within {
background-color: rgba(17, 40, 85, 0.9);
}
#bm-window-filter .bm-filter-container-rgb {
display: block;
border: thick double darkslategray;
width: fit-content;
height: fit-content;
padding: 1ch;
}
#bm-window-filter .bm-filter-color[data-id="-2"] .bm-filter-container-rgb {
background:
conic-gradient(
#aa0000 0%,
#aaaa00 16.6%,
#00aa00 33.3%,
#00aaaa 50%,
#0000aa 66.6%,
#aa00aa 83.3%,
#aa0000 100%);
}
#bm-window-filter .bm-filter-color[data-id="-1"] .bm-filter-container-rgb {
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 8 8" width="1em" height="1em"><path d="M0,0V8H16V16H8V0" fill="rgba(0,0,0,0.5)"/></svg>') repeat;
background-color: transparent !important;
}
#bm-window-filter .bm-filter-color[data-id="-1"] .bm-filter-container-rgb svg {
fill: white !important;
}
#bm-window-filter .bm-filter-color[data-id="0"] .bm-filter-container-rgb {
background-color: transparent !important;
}
#bm-window-filter .bm-filter-container-rgb button {
padding: 0.75em 0.5ch;
}
#bm-window-filter .bm-filter-container-rgb svg {
width: 4ch;
}
#bm-window-filter .bm-filter-color > .bm-flex-between {
flex-direction: column;
align-items: flex-start;
gap: 0;
}
#bm-window-filter .bm-filter-color small {
font-size: 0.75em;
}
#bm-window-filter .bm-filter-color.bm-color-hide {
display: none;
}
#bm-window-filter.bm-windowed #bm-filter-flex {
flex-direction: column;
gap: 0.25em;
}
#bm-window-filter.bm-windowed .bm-filter-color {
width: auto;
margin: 0;
padding: 0;
}
#bm-window-filter.bm-windowed .bm-filter-container-rgb {
display: flex;
width: 100%;
gap: 0.5ch;
align-items: center;
padding: 0.1em 0.5ch;
border: none;
border-radius: 1em;
}
#bm-window-filter.bm-windowed .bm-filter-container-rgb button {
padding: 0.5em 0.25ch;
}
#bm-window-filter.bm-windowed .bm-filter-container-rgb svg {
width: 3ch;
}
#bm-window-filter.bm-windowed .bm-filter-color h2 {
font-size: 0.75em;
}
#bm-window-filter #bm-filter-windowed-color-totals {
font-size: 1em;
}
/* src/WindowSettings.css */
#bm-window-settings div:has(> .bm-highlight-preset-container) {
width: fit-content;
justify-content: flex-start;
}
#bm-window-settings .bm-highlight-preset-container {
display: flex;
flex-direction: column;
width: 13%;
}
#bm-window-settings .bm-highlight-preset-container span {
width: fit-content;
margin: auto;
font-size: 0.7em;
}
#bm-window-settings .bm-highlight-preset-container button {
width: fit-content;
padding: 0;
border-radius: 0;
}
#bm-window-settings .bm-highlight-preset-container svg {
stroke: #333;
stroke-width: 0.02px;
width: 100%;
min-width: 1.5ch;
max-width: 14.5ch;
}
#bm-window-settings .bm-highlight-preset-container button:hover svg,
#bm-window-settings .bm-highlight-preset-container button:focus svg {
opacity: 0.9;
}
#bm-window-settings .bm-highlight-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
width: 25%;
min-width: 3ch;
max-width: 15ch;
}
#bm-window-settings .bm-highlight-grid > button {
width: 100%;
padding: 0;
aspect-ratio: 1 / 1;
background-color: white;
border: #333 1px solid;
border-radius: 0;
box-sizing: border-box;
}
#bm-window-settings .bm-highlight-grid > button[data-status=Incorrect] {
background-color: brown;
}
#bm-window-settings .bm-highlight-grid > button[data-status=Template] {
background-color: darkslategray;
}
#bm-window-settings .bm-highlight-grid > button:hover,
#bm-window-settings .bm-highlight-grid > button:focus {
opacity: 0.8;
}
/* src/WindowWizard.css */
#bm-wizard-tlist {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
#bm-wizard-tlist > .bm-container {
width: 100%;
justify-content: flex-start;
background-color: rgba(21, 48, 99, 0.9);
border-radius: 1em;
padding: 0.5em;
transition: background-color 0.3s ease;
}
#bm-wizard-tlist > .bm-container:hover,
#bm-wizard-tlist > .bm-container:focus-within {
background-color: rgba(17, 40, 85, 0.9);
}
#bm-wizard-tlist .bm-wizard-template-container-image {
height: 100%;
font-size: xxx-large;
}
#bm-wizard-tlist .bm-wizard-template-container-flavor {
flex-direction: column;
align-items: flex-start;
gap: 0;
}
/* src/main.css */

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -39,6 +39,7 @@ Special Thanks:
[Donators](https://ko-fi.com/swingthevine):
* Espresso
* BEST FAN
* FuchsDresden
* Jack
* raiken_au
* Jacob

View file

@ -51,7 +51,7 @@
<a href="https://discord.gg/tpeBPy46hf" target="_blank" rel="noopener noreferrer"><img alt="Contact Me" src="https://img.shields.io/badge/Contact_Me-gray?style=flat&logo=Discord&logoColor=white&logoSize=auto&labelColor=cornflowerblue"></a>
<a href="https://bluemarble.lol/" target="_blank" rel="noopener noreferrer"><img alt="Blue Marble Website" src="https://img.shields.io/badge/Blue_Marble_Website-crqch-blue?style=flat&logo=globe&logoColor=white"></a>
<a href="" target="_blank" rel="noopener noreferrer"><img alt="WakaTime" src="https://img.shields.io/badge/Coding_Time-212hrs_17mins-blue?style=flat&logo=wakatime&logoColor=black&logoSize=auto&labelColor=white"></a>
<a href="" target="_blank" rel="noopener noreferrer"><img alt="Total Patches" src="https://img.shields.io/badge/Total_Patches-1115-black?style=flat"></a>
<a href="" target="_blank" rel="noopener noreferrer"><img alt="Total Patches" src="https://img.shields.io/badge/Total_Patches-1217-black?style=flat"></a>
<a href="" target="_blank" rel="noopener noreferrer"><img alt="Total Lines of Code" src="https://img.shields.io/badge/Lines_Of_Code-6620-blue?style=flat"></a>
<a href="" target="_blank" rel="noopener noreferrer"><img alt="Total Comments" src="https://img.shields.io/badge/Lines_Of_Comments-5414-blue?style=flat"></a>
<a href="" target="_blank" rel="noopener noreferrer"><img alt="Compression" src="https://img.shields.io/badge/Compression-71.85%25-blue"></a>

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "wplace-bluemarble",
"version": "0.90.76",
"version": "0.91.102",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wplace-bluemarble",
"version": "0.90.76",
"version": "0.91.102",
"devDependencies": {
"esbuild": "^0.25.0",
"jsdoc": "^4.0.5",

View file

@ -1,6 +1,6 @@
{
"name": "wplace-bluemarble",
"version": "0.91.0",
"version": "0.91.102",
"type": "module",
"homepage": "https://bluemarble.lol/",
"repository": {

View file

@ -2,7 +2,7 @@
// @name Blue Marble
// @name:en Blue Marble
// @namespace https://github.com/SwingTheVine/
// @version 0.91.0
// @version 0.91.102
// @description A userscript to enhance the user experience on Wplace.live. This includes, but is not limited to: uploading images to display locally on a canvas, adding a button to move the Wplace color palette menu, and other QoL features.
// @description:en A userscript to enhance the user experience on Wplace.live. This includes, but is not limited to: uploading images to display locally on a canvas, adding a button to move the Wplace color palette menu, and other QoL features.
// @author SwingTheVine

View file

@ -37,6 +37,9 @@ export default class Overlay {
/** The API manager instance. Later populated when setApiManager is called @type {ApiManager} */
this.apiManager = null;
/** The Settings Manager instance. Later populated when setSettingsManager is called @type {SettingsManager} */
this.settingsManager = null;
this.outputStatusId = 'bm-output-status'; // ID for status element
@ -51,6 +54,12 @@ export default class Overlay {
*/
setApiManager(apiManager) {this.apiManager = apiManager;}
/** Populates the settingsManager variable with the settingsManager class.
* @param {SettingsManager} settingsManager - The settingsManager class instance
* @since 0.91.11
*/
setSettingsManager(settingsManager) {this.settingsManager = settingsManager;}
/** Creates an element.
* For **internal use** of the {@link Overlay} class.
* @param {string} tag - The tag name as a string.
@ -109,10 +118,7 @@ export default class Overlay {
).join('')
] = value;
} else if (property.startsWith('aria')) {
const camelCase = property.slice(5).split('-').map(
(part, i) => (i == 0) ? part : part[0].toUpperCase() + part.slice(1)
).join('');
element['aria' + camelCase[0].toUpperCase() + camelCase.slice(1)] = value;
element.setAttribute(property, value); // We can't do the solution for 'data', as 'aria-labelledby' would fail to apply
} else {
element[property] = value;
}
@ -511,8 +517,24 @@ export default class Overlay {
const properties = {'type': 'checkbox'}; // Shared checkbox DOM properties
const label = this.#createElement('label', {'textContent': additionalProperties['textContent'] ?? ''}); // Creates the label element
delete additionalProperties['textContent']; // Deletes 'textContent' DOM property before adding the properties to the checkbox
// Stores the label content from the additional property
const labelContent = {};
// If the label content was passed in as 'textContent'...
if (!!additionalProperties['textContent']) {
// Store the information, then delete it from additionalProperties
labelContent['textContent'] = additionalProperties['textContent'];
delete additionalProperties['textContent']; // Deletes 'textContent' DOM property before adding the properties to the checkbox
} else if (!!additionalProperties['innerHTML']) {
// Else if the label content was passed in as 'innerHTML'...
// Store the information, then delete it from additionalProperties
labelContent['innerHTML'] = additionalProperties['innerHTML'];
delete additionalProperties['textContent'];
}
const label = this.#createElement('label', labelContent); // Creates the label element
const checkbox = this.#createElement('input', properties, additionalProperties); // Creates the checkbox element
label.insertBefore(checkbox, label.firstChild); // Makes the checkbox the first child of the label (before the text content)
this.buildElement(); // Signifies that we are done adding children to the checkbox

View file

@ -1,4 +1,4 @@
import { uint8ToBase64 } from "./utils";
import { sleep, uint8ToBase64, viewCanvasInNewTab } from "./utils";
/** An instance of a template.
* Handles all mathematics, manipulation, and analysis regarding a single template.
@ -43,17 +43,26 @@ export default class Template {
this.tileSize = tileSize;
/** Total pixel count in template @type {{total: number, colors: Map<number, number>, correct?: { [key: string]: Map<number, number> }}} */
this.pixelCount = { total: 0, colors: new Map() };
this.shouldSkipTransTiles = true; // Should transparent template tiles be skipped during template creation?
this.shouldAggSkipTransTiles = false; // Should transparent template tiles be aggressively skipped during tempalte creation?
}
/** Creates chunks of the template for each tile.
* @param {Number} tileSize - Size of the tile as determined by templateManager
* @param {Object} paletteBM - An collection of Uint32Arrays containing the palette BM uses
* @param {boolean} shouldSkipTransTiles - Should transparent tiles be skipped over when creating the template?
* @param {boolean} shouldAggSkipTransTiles - Should transparent tiles be aggressively skipped over when creating the template?
* @returns {Object} Collection of template bitmaps & buffers organized by tile coordinates
* @since 0.65.4
*/
async createTemplateTiles(tileSize, paletteBM) {
async createTemplateTiles(tileSize, paletteBM, shouldSkipTransTiles, shouldAggSkipTransTiles) {
console.log('Template coordinates:', this.coords);
// Updates the class instance variable with the new information
this.shouldSkipTransTiles = shouldSkipTransTiles;
this.shouldAggSkipTransTiles = shouldAggSkipTransTiles;
const shreadSize = 3; // Scale image factor for pixel art enhancement (must be odd)
const bitmap = await createImageBitmap(this.file); // Create efficient bitmap from uploaded file
const imageWidth = bitmap.width;
@ -64,8 +73,16 @@ export default class Template {
const templateTiles = {}; // Holds the template tiles
const templateTilesBuffers = {}; // Holds the buffers of the template tiles
// The main canvas used during template creation
const canvas = new OffscreenCanvas(this.tileSize, this.tileSize);
const context = canvas.getContext('2d', { willReadFrequently: true });
// The canvas used to check if a specific template tile is transparent or not
const transCanvas = new OffscreenCanvas(this.tileSize, this.tileSize);
const transContext = transCanvas.getContext('2d', { willReadFrequently: true });
// Makes it so that `.drawImage()` calls on the canvas used to calculate transparency always draw below what is already on the canvas
transContext.globalCompositeOperation = "destination-over";
// Prep the canvas for drawing the entire template (so we can find total pixels)
canvas.width = imageWidth;
@ -101,7 +118,7 @@ export default class Template {
contextMask.fillRect(1, 1, 1, 1);
// For every tile...
for (let pixelY = this.coords[3]; pixelY < imageHeight + this.coords[3]; ) {
for (let pixelY = this.coords[3]; pixelY < imageHeight + this.coords[3];) {
// Draws the partial tile first, if any
// This calculates the size based on which is smaller:
@ -121,6 +138,26 @@ export default class Template {
// B. The top left corner of the current tile to the bottom right corner of the image
const drawSizeX = Math.min(this.tileSize - (pixelX % this.tileSize), imageWidth - (pixelX - this.coords[2]));
// If the user wants to skip any tiles where the template is transparent...
if (shouldSkipTransTiles) {
// Detects if the canvas is fully transparent
const isTemplateTileTransparent = !this.calculateCanvasTransparency({
bitmap: bitmap,
bitmapParams: [pixelX - this.coords[2], pixelY - this.coords[3], drawSizeX, drawSizeY], // Top left X, Top left Y, Width, Height
transCanvas: transCanvas,
transContext: transContext
});
console.log(`Tile contains template: ${!isTemplateTileTransparent}`);
// If the template in this tile is transparent...
if (isTemplateTileTransparent) {
pixelX += drawSizeX; // If you remove this, it will get stuck forever processing the template
continue; // ...the user does not want to save this tile, so we skip to the next tile
}
}
console.log(`Math.min(${this.tileSize} - (${pixelX} % ${this.tileSize}), ${imageWidth} - (${pixelX - this.coords[2]}))`);
console.log(`Draw Size X: ${drawSizeX}\nDraw Size Y: ${drawSizeY}`);
@ -155,6 +192,8 @@ export default class Template {
context.globalCompositeOperation = "destination-in"; // The existing canvas content is kept where both the new shape and existing canvas content overlap. Everything else is made transparent.
// For our purposes, this means any non-transparent pixels on the mask will be kept
console.log(`Should Skip: ${shouldSkipTransTiles}; Should Agg Skip: ${shouldAggSkipTransTiles}`);
// Fills the canvas with the mask
context.fillStyle = context.createPattern(canvasMask, "repeat");
context.fillRect(0, 0, canvasWidth, canvasHeight);
@ -197,6 +236,110 @@ export default class Template {
return { templateTiles, templateTilesBuffers };
}
/** Detects if the canvas is transparent.
* @param {Object} param - Object that contains the parameters for the function
* @param {ImageBitmap} param.bitmap - The bitmap template image
* @param {Array<number, number, number, number>} param.bitmapParams - The parameters to obtain the template tile image from the bitmap
* @param {OffscreenCanvas | HTMLCanvasElement} param.transCanvas - The canvas to draw to in order to calculate this
* @param {OffscreenCanvasRenderingContext2D} param.transContext - The context for the transparent canvas to draw to
* @return {boolean} Is the canvas transparent? If transparent, then `true` is returned. Otherwise, `false`.
* @since 0.91.75
*/
calculateCanvasTransparency({
bitmap: bitmap,
bitmapParams: bitmapParams,
transCanvas: transCanvas,
transContext: transContext
}) {
console.log(`Calculating template tile transparency...`);
console.log(`Should Skip: ${this.shouldSkipTransTiles}; Should Agg: ${this.shouldAggSkipTransTiles}`);
const timer = Date.now(); // Starts the timer
// Contains the directions to move the canvas when duplicating, in the unit of pixels
const duplicationCoordinateArray = [
[ 0, 1], // E.g. move 0 on the x axis, and 1 down on the y axis
[ 1, 0],
[ 0, -2], // E.g. move 0 on the x axis, and 2 up on the y axis
[ -2, 0],
[ 0, 4],
[ 4, 0],
[ 0, -8],
[ -8, 0],
[ 0, 16],
[ 16, 0],
[ 0, -32],
[-32, 0]
];
// Changes the size of the canvas so that it equals the template tile
const transCanvasWidth = bitmapParams[2];
const transCanvasHeight = bitmapParams[3];
transCanvas.width = transCanvasWidth;
transCanvas.height = transCanvasHeight;
transContext.clearRect(0, 0, transCanvasWidth, transCanvasHeight); // Clear any previous drawing (only runs when canvas size does not change)
// If the user does want to aggressively skip transparent template tiles...
if (this.shouldAggSkipTransTiles) {
// (This code will only run if `this.shouldSkipTransTiles` is `true`)
// Draw the template tile onto the canvas scaled down to 10x10
transContext.drawImage(
bitmap, // The bitmap image
...bitmapParams, // Bitmap image parameters (x, y, width, height)
0, 0, // The coordinate draw the output *at*
10, 10 // The width and height of the output
);
} else {
// Else, the user wants to skip transparent template tiles normally...
// Draw the template tile onto the canvas
transContext.drawImage(
bitmap, // The bitmap image
...bitmapParams, // Bitmap image parameters (x, y, width, height)
0, 0, // The coordinate draw the output *at*
transCanvasWidth, transCanvasHeight // Stretch to canvas (the canvas should already be the same size as the template image)
)
// For each canvas duplication...
for (const [relativeX, relativeY] of duplicationCoordinateArray) {
// Duplicate the canvas onto itself, but shifted slightly
transContext.drawImage(
transCanvas, // The canvas we are drawing to *is* the source image
0, 0, transCanvasWidth, transCanvasHeight, // The entire canvas (as a source image)
relativeX, relativeY, transCanvasWidth, transCanvasHeight // The output coordinates and size on the same canvas
)
}
// Scale down the image to 10x10, and store it between (0, 0) and (9, 9) on the canvas
transContext.drawImage(
transCanvas, // The canvas we are drawing to *is* the source image
0, 0, transCanvasWidth, transCanvasHeight, // The entire canvas (as a source image)
0, 0, 10, 10 // The output coordinates and size on the same canvas
);
}
const shunkCanvas = transContext.getImageData(0, 0, 10, 10);
const shunkCanvas32 = new Uint32Array(shunkCanvas.data.buffer);
console.log(`Calculated canvas transparency in ${(Date.now() - timer) / 1000} seconds.`);
// For every pixel in the `shrunkCanvas32` array...
for (const pixel of shunkCanvas32) {
// If the pixel is NOT 100% transparent
if (!!pixel) {
return true; // Return `true` early since we confirmed a template exists in the tile
}
}
return false; // Since we could not confirm any template exists, we assume no template eixsts in this tile
}
/** Calculates top left coordinate of template.
* It uses `Template.chunked` to update `Template.coords`
* @since 0.88.504

View file

@ -101,11 +101,12 @@ export default class WindowCredts extends Overlay {
.addUl()
.addLi({'textContent': 'Espresso'}).buildElement()
.addLi({'textContent': 'BEST FAN'}).buildElement()
.addLi({'textContent': 'FuchsDresden'}).buildElement()
.addLi({'textContent': 'Jack'}).buildElement()
.addLi({'textContent': 'raiken_au'}).buildElement()
.addLi({'textContent': 'Jacob'}).buildElement()
.addLi({'textContent': 'StupidOne'}).buildElement()
.addLi({'textContent': '1 Anonymous Supporter'}).buildElement()
.addLi({'textContent': '2 Anonymous Supporters'}).buildElement()
.buildElement()
.buildElement()
.buildElement()

View file

@ -17,7 +17,7 @@
}
/* Filter color */
.bm-filter-color {
#bm-window-filter .bm-filter-color {
width: fit-content;
max-width: 35ch;
background-color: rgba(21, 48, 99, 0.9);
@ -28,13 +28,13 @@
}
/* Filter color on hover */
.bm-filter-color:hover,
.bm-filter-color:focus-within {
#bm-window-filter .bm-filter-color:hover,
#bm-window-filter.bm-filter-color:focus-within {
background-color: rgba(17, 40, 85, 0.9);
}
/* Filter window container for RGB color display */
.bm-filter-container-rgb {
#bm-window-filter .bm-filter-container-rgb {
display: block;
border: thick double darkslategray;
width: fit-content;
@ -43,7 +43,7 @@
}
/* Filter window container for RGB color display for Other color */
.bm-filter-color[data-id="-2"] .bm-filter-container-rgb {
#bm-window-filter .bm-filter-color[data-id="-2"] .bm-filter-container-rgb {
background: conic-gradient(
#aa0000 0%,
#aaaa00 16.6%,
@ -56,16 +56,16 @@
}
/* Filter window container for RGB color display for Erased color */
.bm-filter-color[data-id="-1"] .bm-filter-container-rgb {
#bm-window-filter .bm-filter-color[data-id="-1"] .bm-filter-container-rgb {
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 8 8" width="1em" height="1em"><path d="M0,0V8H16V16H8V0" fill="rgba(0,0,0,0.5)"/></svg>') repeat;
background-color: transparent !important;
}
.bm-filter-color[data-id="-1"] .bm-filter-container-rgb svg {
#bm-window-filter .bm-filter-color[data-id="-1"] .bm-filter-container-rgb svg {
fill: white !important;
}
/* Filter window container for RGB color display for Transparent color */
.bm-filter-color[data-id="0"] .bm-filter-container-rgb {
#bm-window-filter .bm-filter-color[data-id="0"] .bm-filter-container-rgb {
background-color: transparent !important;
}
@ -75,19 +75,19 @@
}
/* Filter window hide color button SVG */
.bm-filter-container-rgb svg {
#bm-window-filter .bm-filter-container-rgb svg {
width: 4ch;
}
/* Filter window container for color information */
.bm-filter-color > .bm-flex-between {
#bm-window-filter .bm-filter-color > .bm-flex-between {
flex-direction: column;
align-items: flex-start;
gap: 0;
}
/* Filter window color flavor text */
.bm-filter-color small {
#bm-window-filter .bm-filter-color small {
font-size: 0.75em;
}
@ -99,20 +99,20 @@
/* WINDOWED MODE */
/* Filter flex in windowed mode */
.bm-windowed #bm-filter-flex {
#bm-window-filter.bm-windowed #bm-filter-flex {
flex-direction: column;
gap: 0.25em;
}
/* Filter color in windowed mode */
.bm-windowed .bm-filter-color {
#bm-window-filter.bm-windowed .bm-filter-color {
width: auto;
margin: 0;
padding: 0;
}
/* Filter window container for RGB color display in windowed mode */
.bm-windowed .bm-filter-container-rgb {
#bm-window-filter.bm-windowed .bm-filter-container-rgb {
display: flex;
width: 100%;
gap: 0.5ch;
@ -128,11 +128,16 @@
}
/* Filter window hide color button SVG in windowed mode */
.bm-windowed .bm-filter-container-rgb svg {
#bm-window-filter.bm-windowed .bm-filter-container-rgb svg {
width: 3ch;
}
/* Filter window header 2 in windowed mode */
.bm-windowed .bm-filter-color h2 {
#bm-window-filter.bm-windowed .bm-filter-color h2 {
font-size: 0.75em;
}
/* Filter window dragbar text area in windowed mode */
#bm-window-filter #bm-filter-windowed-color-totals {
font-size: 1em;
}

View file

@ -124,7 +124,7 @@ export default class WindowFilter extends Overlay {
.addSpan({'id': 'bm-filter-tot-completed', 'innerHTML': '??? ???'}).buildElement()
.buildElement()
.addDiv({'class': 'bm-container'})
.addP({'innerHTML': `Colors with the icon ${this.eyeOpen.replace('<svg', '<svg aria-label="Eye Open"')} will be shown on the canvas. Colors with the icon ${this.eyeClosed.replace('<svg', '<svg aria-label="Eye Closed"')} will not be shown on the canvas. The "Hide All Colors" and "Show All Colors" buttons only apply to colors that display in the list below. The amount of correct pixels is dependent on how many tiles of the template you have loaded since you last opened Wplace.live. If all tiles have been loaded, then the "correct pixel" count is accurate.`}).buildElement()
.addP({'innerHTML': `Press the 🗗 button to make this window smaller. Colors with the icon ${this.eyeOpen.replace('<svg', '<svg aria-label="Eye Open"')} will be shown on the canvas. Colors with the icon ${this.eyeClosed.replace('<svg', '<svg aria-label="Eye Closed"')} will not be shown on the canvas. The "Hide All Colors" and "Show All Colors" buttons only apply to colors that display in the list below. The amount of correct pixels is dependent on how many tiles of the template you have loaded since you last opened Wplace.live. If all tiles have been loaded, then the "correct pixel" count is accurate.`}).buildElement()
.buildElement()
.addHr().buildElement()
.addForm({'class': 'bm-container'})
@ -210,10 +210,19 @@ export default class WindowFilter extends Overlay {
this.window = this.addDiv({'id': this.windowID, 'class': 'bm-window bm-windowed'})
.addDragbar()
.addButton({'class': 'bm-button-circle', 'textContent': '▼', 'aria-label': 'Minimize window "Color Filter"', 'data-button-status': 'expanded'}, (instance, button) => {
button.onclick = () => instance.handleMinimization(button);
button.onclick = () => {
const windowedColorTotals = document.querySelector('#bm-filter-windowed-color-totals');
if (windowedColorTotals) {
windowedColorTotals.style.display = (button.dataset['buttonStatus'] == 'expanded') ? 'none' : '';
}
instance.handleMinimization(button);
};
button.ontouchend = () => {button.click()}; // Needed only to negate weird interaction with dragbar
}).buildElement()
.addDiv().buildElement() // Contains the minimized h1 element
.addDiv()
.addSpan({'id': 'bm-filter-windowed-color-totals', 'class': 'bm-dragbar-text', 'style': 'font-weight: 700;'}).buildElement() // Contains correct / total pixel values
// Minimized h1 element will appear here
.buildElement()
.addDiv({'class': 'bm-flex-center'})
.addButton({'class': 'bm-button-circle', 'textContent': '🗖', 'aria-label': 'Switch to fullscreen mode for "Color Filter"'}, (instance, button) => {
button.onclick = () => {
@ -423,7 +432,7 @@ export default class WindowFilter extends Overlay {
.addSmall({'textContent': `#${color.id.toString().padStart(2, 0)}`}).buildElement()
.addSmall({'class': 'bm-filter-color-pxl-cnt', 'textContent': `${colorCorrectLocalized} / ${colorTotalLocalized}`}).buildElement()
.buildElement()
.addP({'class': 'bm-filter-color-pxl-desc', 'textContent': `${((typeof colorIncorrect == 'number') && !isNaN(colorIncorrect)) ? colorIncorrect : '???'} incorrect pixels. Completed: ${colorPercent}`}).buildElement()
.addP({'class': 'bm-filter-color-pxl-desc', 'textContent': `${((typeof colorIncorrect == 'number') && !isNaN(colorIncorrect)) ? colorIncorrect : '???'} incorrect pixel${(colorIncorrect == 1) ? '' : 's'}. Completed: ${colorPercent}`}).buildElement()
.buildElement()
.buildElement();
}
@ -513,18 +522,21 @@ export default class WindowFilter extends Overlay {
}
}
/** The information about a specific color on the palette.
* @typedef {Object} ColorData
* @property {number | string} colorTotal
* @property {string} colorTotalLocalized
* @property {number | string} colorCorrect
* @property {string} colorCorrectLocalized
* @property {string} colorPercent
* @property {number} colorIncorrect
*/
/** Updates the information inside the colors in the color list.
* If the color list does not exist yet, it returns the color information instead.
* This assumes the information inside each element is the same between fullscreen and windowed mode.
* @since 0.90.60
* @returns {Object<number, {
* colorTotal: number | string,
* colorTotalLocalized: string,
* colorCorrect: number | string,
* colorCorrectLocalized: string,
* colorPercent: string,
* colorIncorrect: number
* }}
* @returns {Object.<number, ColorData>}
*/
updateColorList() {
@ -577,6 +589,22 @@ export default class WindowFilter extends Overlay {
}
}
// Obtains the correct / total pixels display element, or `undefined` if in fullscreen mode
const windowedColorTotals = document.querySelector('#bm-filter-windowed-color-totals');
// If the element exists...
if (windowedColorTotals) {
// Returns the number, unlocalized (no space to localize)
// OR returns the three characters on either end of the string, with the middle replaced with an ellipse.
// E.g. '1234567' or '123…678'
const allCorrect = (this.allPixelsCorrectTotal.toString().length > 7) ? this.allPixelsCorrectTotal.toString().slice(0, 2) + '…' + this.allPixelsCorrectTotal.toString().slice(-3) : this.allPixelsCorrectTotal.toString();
const allTotal = (this.allPixelsTotal.toString().length > 7) ? this.allPixelsTotal.toString().slice(0, 2) + '…' + this.allPixelsTotal.toString().slice(-3) : this.allPixelsTotal.toString();
// Updates the display with XSS protection enabled (because why not)
this.updateInnerHTML('#bm-filter-windowed-color-totals', `${allCorrect}/${allTotal}`, true);
}
// Return early if the color list does not exist.
// We can't update DOM elements that don't exist, so we exit now.
if (!colorList) {return colorStatistics;}
@ -610,7 +638,7 @@ export default class WindowFilter extends Overlay {
// Updates the pixel description if it exists
const pixelDesc = document.querySelector(`#${this.windowID} .bm-filter-color[data-id="${colorID}"] .bm-filter-color-pxl-desc`);
if (pixelDesc) {pixelDesc.textContent = `${((typeof colorIncorrect == 'number') && !isNaN(colorIncorrect)) ? colorIncorrect : '???'} incorrect pixels. Completed: ${colorPercent}`;}
if (pixelDesc) {pixelDesc.textContent = `${((typeof colorIncorrect == 'number') && !isNaN(colorIncorrect)) ? colorIncorrect : '???'} incorrect pixel${(colorIncorrect == 1) ? '' : 's'}. Completed: ${colorPercent}`;}
}
// Since the dataset has changed, we need to sort again

View file

@ -163,6 +163,11 @@ export default class WindowMain extends Overlay {
.addDiv({'class': 'bm-container bm-flex-between', 'style': 'margin-bottom: 0; flex-direction: column;'})
.addDiv({'class': 'bm-flex-between'})
// .addButton({'class': 'bm-button-circle', 'innerHTML': '🖌'}).buildElement()
.addButton({'class': 'bm-button-circle', 'innerHTML': '⚙️', 'title': 'Settings'}, (instance, button) => {
button.onclick = () => {
instance.settingsManager.buildWindow();
}
}).buildElement()
.addButton({'class': 'bm-button-circle', 'innerHTML': '🧙', 'title': 'Template Wizard'}, (instance, button) => {
button.onclick = () => {
const templateManager = instance.apiManager?.templateManager;

79
src/WindowSettings.css Normal file
View file

@ -0,0 +1,79 @@
/* @since 0.91.22 */
/* Highlight preset group container */
#bm-window-settings div:has(> .bm-highlight-preset-container) {
width: fit-content;
justify-content: flex-start;
}
/* Highlight preset container */
#bm-window-settings .bm-highlight-preset-container {
display: flex;
flex-direction: column;
width: 13%;
}
/* Highlight preset title */
#bm-window-settings .bm-highlight-preset-container span {
width: fit-content;
margin: auto;
font-size: 0.7em;
}
/* Highlight preset button */
#bm-window-settings .bm-highlight-preset-container button {
width: fit-content;
padding: 0;
border-radius: 0;
}
/* Highlight preset SVG */
#bm-window-settings .bm-highlight-preset-container svg {
stroke: #333;
stroke-width: 0.02px;
width: 100%;
min-width: 1.5ch;
max-width: 14.5ch;
}
/* Highlight preset SVG on hover/focus */
#bm-window-settings .bm-highlight-preset-container button:hover svg,
#bm-window-settings .bm-highlight-preset-container button:focus svg {
opacity: 0.9;
}
/* Highlight pattern container */
#bm-window-settings .bm-highlight-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
width: 25%;
min-width: 3ch;
max-width: 15ch;
}
/* Highlight pattern button */
#bm-window-settings .bm-highlight-grid > button {
width: 100%;
padding: 0;
aspect-ratio: 1 / 1;
background-color: white;
border: #333 1px solid;
border-radius: 0;
box-sizing: border-box;
}
/* Highlight pattern button in 'Incorrect' mode */
#bm-window-settings .bm-highlight-grid > button[data-status="Incorrect"] {
background-color: brown;
}
/* Highlight pattern button in 'Template' mode */
#bm-window-settings .bm-highlight-grid > button[data-status="Template"] {
background-color: darkslategray;
}
/* Highlight pattern button when hovered/focused */
#bm-window-settings .bm-highlight-grid > button:hover,
#bm-window-settings .bm-highlight-grid > button:focus {
opacity: 0.8;
}

97
src/WindowSettings.js Normal file
View file

@ -0,0 +1,97 @@
import Overlay from "./Overlay";
/** The overlay builder for the settings window in Blue Marble.
* The logic for this window is managed in {@link SettingsManager}
* @description This class handles the overlay UI for the settings window of the Blue Marble userscript.
* @class WindowSettings
* @since 0.91.11
* @see {@link Overlay} for examples
*/
export default class WindowSettings extends Overlay {
/** Constructor for the Settings window
* @param {string} name - The name of the userscript
* @param {string} version - The version of the userscript
* @since 0.91.11
* @see {@link Overlay#constructor} for examples
*/
constructor(name, version) {
super(name, version); // Executes the code in the Overlay constructor
this.window = null; // Contains the *window* DOM tree
this.windowID = 'bm-window-settings'; // The ID attribute for this window
this.windowParent = document.body; // The parent of the window DOM tree
}
/** Spawns a Settings window.
* If another settings window already exists, we DON'T spawn another!
* Parent/child relationships in the DOM structure below are indicated by indentation.
* @since 0.91.11
*/
buildWindow() {
// If a settings window already exists, close it
if (document.querySelector(`#${this.windowID}`)) {
document.querySelector(`#${this.windowID}`).remove();
return;
}
this.window = this.addDiv({'id': this.windowID, 'class': 'bm-window'})
.addDragbar()
.addButton({'class': 'bm-button-circle', 'textContent': '▼', 'aria-label': 'Minimize window "Color Filter"', 'data-button-status': 'expanded'}, (instance, button) => {
button.onclick = () => instance.handleMinimization(button);
button.ontouchend = () => {button.click()}; // Needed only to negate weird interaction with dragbar
}).buildElement()
.addDiv().buildElement() // Contains the minimized h1 element
.addDiv({'class': 'bm-flex-center'})
.addButton({'class': 'bm-button-circle', 'textContent': '✖', 'aria-label': 'Close window "Color Filter"'}, (instance, button) => {
button.onclick = () => {document.querySelector(`#${this.windowID}`)?.remove();};
button.ontouchend = () => {button.click();}; // Needed only to negate weird interaction with dragbar
}).buildElement()
.buildElement()
.buildElement()
.addDiv({'class': 'bm-window-content'})
.addDiv({'class': 'bm-container bm-center-vertically'})
.addHeader(1, {'textContent': 'Settings'}).buildElement()
.buildElement()
.addHr().buildElement()
.addP({'textContent': 'Settings take 5 seconds to save.'}).buildElement()
.addDiv({'class': 'bm-container bm-scrollable'}, (instance, div) => {
// Each category in the settings window
this.buildHighlight();
this.buildTemplate();
}).buildElement()
.buildElement()
.buildElement().buildOverlay(this.windowParent);
// Creates dragging capability on the drag bar for dragging the window
this.handleDrag(`#${this.windowID}.bm-window`, `#${this.windowID} .bm-dragbar`);
}
/** Displays an error when a settings category fails to load.
* @param {string} name - The name of the category
* @since 0.91.11
*/
#errorOverrideFailure(name) {
this.window = this.addDiv({'class': 'bm-container'})
.addHeader(2, {'textContent': name}).buildElement()
.addHr().buildElement()
.addP({'innerHTML': `An error occured loading the ${name} category. <code>SettingsManager</code> failed to override the ${name} function inside <code>WindowSettings</code>.`}).buildElement()
.buildElement();
}
/** Builds the highlight section of the window.
* This should be overriden by {@link SettingsManager}
* @since 0.91.11
*/
buildHighlight() {
this.#errorOverrideFailure('Pixel Highlight');
}
/** Builds the template section of the window.
* This should be overriden by {@link SettingsManager}
* @since 0.91.68
*/
buildTemplate() {
this.#errorOverrideFailure('Template');
}
}

View file

@ -6,4 +6,5 @@
@import './confettiManager.css';
@import './overlay.css';
@import './WindowFilter.css';
@import './WindowSettings.css';
@import './WindowWizard.css';

View file

@ -8,6 +8,7 @@ import TemplateManager from './templateManager.js';
import { consoleLog, consoleWarn } from './utils.js';
import WindowMain from './WindowMain.js';
import WindowTelemetry from './WindowTelemetry.js';
import SettingsManager from './settingsManager.js';
const name = GM_info.script.name.toString(); // Name of userscript
const version = GM_info.script.version.toString(); // Version of userscript
@ -186,19 +187,25 @@ if (!!(robotoMonoInjectionPoint.indexOf('@font-face') + 1)) {
document.head?.appendChild(stylesheetLink);
}
const userSettings = JSON.parse(GM_getValue('bmUserSettings', '{}')); // Loads the user settings
// CONSTRUCTORS
const observers = new Observers(); // Constructs a new Observers object
const windowMain = new WindowMain(name, version); // Constructs a new Overlay object for the main overlay
const templateManager = new TemplateManager(name, version, windowMain); // Constructs a new TemplateManager object
const templateManager = new TemplateManager(name, version); // Constructs a new TemplateManager object
const apiManager = new ApiManager(templateManager); // Constructs a new ApiManager object
const settingsManager = new SettingsManager(name, version, userSettings); // Constructs a new SettingsManager
windowMain.setSettingsManager(settingsManager); // Sets the settings manager
windowMain.setApiManager(apiManager); // Sets the API manager
templateManager.setWindowMain(windowMain);
templateManager.setSettingsManager(settingsManager); // Sets the settings manager
const storageTemplates = JSON.parse(GM_getValue('bmTemplates', '{}'));
console.log(storageTemplates);
templateManager.importJSON(storageTemplates); // Loads the templates
const userSettings = JSON.parse(GM_getValue('bmUserSettings', '{}')); // Loads the user settings
console.log(userSettings);
console.log(Object.keys(userSettings).length);

View file

@ -92,7 +92,9 @@
}
/* Header 1 when inside dragbar */
.bm-dragbar h1 {
/* Or, when the custom class is used */
.bm-dragbar h1,
.bm-dragbar-text {
font-size: 1.2em;
user-select: none;
overflow: hidden;
@ -345,8 +347,10 @@ input[type="file"] {
/* Containers for "sections" of elements in windowed mode */
/* Does not apply to the main window */
.bm-container:not(#bm-window-main .bm-container) {
margin: 0.25em 0;
.bm-windowed .bm-container:not(#bm-window-main .bm-container) {
margin-top: 0.25em;
margin-bottom: 0.25em;
/* Do not use 'margin' shorthand, as it will override left/right margin */
}
/* Header 1 in windowed mode */

331
src/settingsManager.js Normal file
View file

@ -0,0 +1,331 @@
import { sleep } from "./utils";
import WindowSettings from "./WindowSettings";
/** SettingsManager class for handling user settings and making them persist between sessions.
* Logic for {@link WindowSettings} is managed here.
* "Flags" should follow the same styling as `.classList()` and should not contain spaces.
* A flag should always be false by default.
* When a flag is false, it will not exist in the "flags" Array.
* (Therefore, "flags" should be `[]` by default)
* If it exists in the "flags" Array, then the flag is `true`.
* @class SettingsManager
* @since 0.91.11
* @example
* {
* "uuid": "497dcba3-ecbf-4587-a2dd-5eb0665e6880",
* "telemetry": 1,
* "flags": ["hl-noTrans", "ftr-oWin", "te-noSkip"],
* "highlight": [[1,0,-1],[1,-1,0],[2,1,0],[1,0,1]],
* "filter": [-2,0,4,5,6,29,63]
* }
*/
export default class SettingsManager extends WindowSettings {
/** Constructor for the SettingsManager class
* @param {string} name - The name of the userscript
* @param {string} version - The version of the userscript
* @param {Object} userSettings - The user settings as an object
* @since 0.91.11
*/
constructor(name, version, userSettings) {
super(name, version); // Executes WindowSettings constructor
this.userSettings = userSettings; // User settings as an Object
this.userSettings.flags ??= []; // Makes sure the key "flags" always exists
this.userSettingsOld = structuredClone(this.userSettings); // Creates a duplicate of the user settings to store the old version of user settings from 5+ seconds ago
this.userSettingsSaveLocation = 'bmUserSettings'; // Storage save location
this.updateFrequency = 5000; // Cooldown between saving to storage (throttle)
this.lastUpdateTime = 0; // When this unix timestamp is within the last 5 seconds, we should save this.userSettings to storage
setInterval(this.updateUserStorage.bind(this), this.updateFrequency); // Runs every X seconds (see updateFrequency)
}
/** Updates the user settings in userscript storage
* @since 0.91.39
*/
async updateUserStorage() {
// Turns the objects into a string
const userSettingsCurrent = JSON.stringify(this.userSettings);
const userSettingsOld = JSON.stringify(this.userSettingsOld);
// If the user settings have changed, AND the last update to user storage was over 5 seconds ago (5sec throttle)...
if ((userSettingsCurrent != userSettingsOld) && ((Date.now() - this.lastUpdateTime) > this.updateFrequency)) {
await GM.setValue(this.userSettingsSaveLocation, userSettingsCurrent); // Updates user storage
this.userSettingsOld = structuredClone(this.userSettings); // Updates the old user settings with a duplicate of the current user settings
this.lastUpdateTime = Date.now(); // Updates the variable that contains the last time updated
console.log(userSettingsCurrent);
}
}
/** Toggles a boolean flag to the state that was passed in.
* If no state was passed in, the flag will flip to the opposite state.
* The existence of the flag determines its state. If it exists, it is `true`.
* @param {string} flagName - The name of the flag to toggle
* @param {boolean} [state=undefined] - (Optional) The state to change the flag to
* @since 0.91.60
*/
toggleFlag(flagName, state = undefined) {
const flagIndex = this.userSettings?.flags?.indexOf(flagName) ?? -1; // Is the flag `true`?
// If the flag is enabled, AND the user does not want to force the flag to be true...
if ((flagIndex != -1) && (state !== true)) {
this.userSettings?.flags?.splice(flagIndex, 1); // Remove the flag (makes it false)
} else if ((flagIndex == -1) && (state !== false)) {
// Else if the flag is disabled, AND the user does not want to force the flag to be false...
this.userSettings?.flags?.push(flagName); // Add the flag (makes it true)
}
}
// This is one of the most insane OOP setups I have ever laid my eyes on
/** Builds the "highlight" category of the settings window
* @since 0.91.18
* @see WindowSettings#buildHighlight
*/
buildHighlight() {
const highlightPresetOff = '<svg viewBox="0 0 3 3"><path d="M0,0H3V3H0ZM0,1H3M0,2H3M1,0V3M2,0V3" fill="#fff"/><path d="M1,1H2V2H1Z" fill="#2f4f4f"/></svg>';
const highlightPresetCross = '<svg viewBox="0 0 3 3"><path d="M0,0H3V3H0Z" fill="#fff"/><path d="M1,0H2V1H3V2H2V3H1V2H0V1H1Z" fill="brown"/><path d="M1,1H2V2H1Z" fill="#2f4f4f"/></svg>';
// Obtains user settings for highlight from storage, or the default array if nothing was found
const storedHighlight = this.userSettings?.highlight ?? [[1, 0, 1], [2, 0, 0], [1, -1, 0], [1, 1, 0], [1, 0, -1]];
// Constructs the category and adds it to the window
this.window = this.addDiv({'class': 'bm-container'})
.addHeader(2, {'textContent': 'Pixel Highlight'}).buildElement()
.addHr().buildElement()
.addDiv({'class': 'bm-container', 'style': 'margin-left: 1.5ch;'})
.addCheckbox({'textContent': 'Highlight transparent pixels'}, (instance, label, checkbox) => {
checkbox.checked = !this.userSettings?.flags?.includes('hl-noTrans'); // Makes the checkbox match the last stored user setting
checkbox.onchange = (event) => this.toggleFlag('hl-noTrans', !event.target.checked); // Forces the flag to be the opposite state as the checkbox. E.g. "Checked" means 'hl-noTrans' is false (does not exist).
}).buildElement()
.addP({'id': 'bm-highlight-preset-label', 'textContent': 'Choose a preset:', 'style': 'font-weight: 700;'}).buildElement()
.addDiv({'class': 'bm-flex-center', 'role': 'group', 'aria-labelledby': 'bm-highlight-preset-label'})
.addDiv({'class': 'bm-highlight-preset-container'})
.addSpan({'textContent': 'None'}).buildElement()
.addButton({'innerHTML': highlightPresetOff, 'aria-label': 'Preset "None"'}, (instance, button) => {button.onclick = () => this.#updateHighlightToPreset('None')}).buildElement()
.buildElement()
.addDiv({'class': 'bm-highlight-preset-container'})
.addSpan({'textContent': 'Cross'}).buildElement()
.addButton({'innerHTML': highlightPresetCross, 'aria-label': 'Preset "Cross Shape"'}, (instance, button) => {button.onclick = () => this.#updateHighlightToPreset('Cross')}).buildElement()
.buildElement()
.addDiv({'class': 'bm-highlight-preset-container'})
.addSpan({'textContent': 'X'}).buildElement()
.addButton({'innerHTML': highlightPresetCross.replace('d="M1,0H2V1H3V2H2V3H1V2H0V1H1Z"', 'd="M0,0V1H3V0H2V3H3V2H0V3H1V0Z"'), 'aria-label': 'Preset "X Shape"'}, (instance, button) => {button.onclick = () => this.#updateHighlightToPreset('X')}).buildElement()
.buildElement()
.addDiv({'class': 'bm-highlight-preset-container'})
.addSpan({'textContent': 'Full'}).buildElement()
.addButton({'innerHTML': highlightPresetOff.replace('#fff', '#2f4f4f'), 'aria-label': 'Preset "Full Template"'}, (instance, button) => {button.onclick = () => this.#updateHighlightToPreset('Full')}).buildElement()
.buildElement()
.buildElement()
.addP({'id': 'bm-highlight-grid-label', 'textContent': 'Create a custom pattern:', 'style': 'font-weight: 700;'}).buildElement()
.addDiv({'class': 'bm-highlight-grid', 'role': 'group', 'aria-labelledby': 'bm-highlight-grid-label'});
// We leave this open so we can add buttons
// For each of the 9 buttons...
for (let buttonY = -1; buttonY <= 1; buttonY++) {
for (let buttonX = -1; buttonX <= 1; buttonX++) {
const buttonState = storedHighlight[storedHighlight.findIndex(([, x, y]) => ((x == buttonX) && (y == buttonY)))]?.[0] ?? 0;
let buttonStateName = 'Disabled';
if (buttonState == 1) {
buttonStateName = 'Incorrect';
} else if (buttonState == 2) {
buttonStateName = 'Template';
}
this.window = this.addButton({
'data-status': buttonStateName,
'aria-label': `Sub-pixel ${buttonStateName.toLowerCase()}`
}, (instance, button) => {
button.onclick = () => this.#updateHighlightSettings(button, [buttonX, buttonY])
}).buildElement();
}
}
// Resumes from where we left off before we added buttons
this.window = this.buildElement()
.buildElement()
.buildElement();
}
/** Updates the display of the highlight buttons in the settings window.
* Additionally, it will update user settings with the new selection.
* @param {HTMLButtonElement} button - The button that was pressed
* @param {Array<number, number>} coords - The relative coordinates of the button
* @since 0.91.46
*/
#updateHighlightSettings(button, coords) {
button.disabled = true; // Disabled the button until we are done
const status = button.dataset['status']; // Obtains the current status of the button
/** Obtains the old highlight storage, or sets it to default. @type {Array<number[]>} */
const userStorageOld = this.userSettings?.highlight ?? [[1, 0, 1], [2, 0, 0], [1, -1, 0], [1, 1, 0], [1, 0, -1]];
let userStorageChange = [2, 0, 0]; // The new change to the user storage
const userStorageNew = userStorageOld; // The old storage with the new change
// For each different type of status...
switch (status) {
// If the button was in the "Disabled" state
case 'Disabled':
// Change to "Incorrect"
button.dataset['status'] = 'Incorrect';
button.ariaLabel = 'Sub-pixel incorrect';
userStorageChange = [1, ...coords];
break;
// If the button was in the "Incorrect" state
case 'Incorrect':
// Change to "Template"
button.dataset['status'] = 'Template';
button.ariaLabel = 'Sub-pixel template';
userStorageChange = [2, ...coords];
break;
// If the button was in the "Template" state
case 'Template':
// Change to "Disabled"
button.dataset['status'] = 'Disabled';
button.ariaLabel = 'Sub-pixel disabled';
userStorageChange = [0, ...coords];
break;
}
// Finds the index of the pixel to change
const indexOfChange = userStorageOld.findIndex(([, x, y]) => ((x == userStorageChange[1]) && (y == userStorageChange[2])));
// If the new sub-pixel state is NOT disabled
if (userStorageChange[0] != 0) {
// If a sub-pixel was found...
if (indexOfChange != -1) {
userStorageNew[indexOfChange] = userStorageChange;
} else {
userStorageNew.push(userStorageChange);
}
} else if (indexOfChange != -1) {
// Else, it is disabled. We want to remove it if it exists.
userStorageNew.splice(indexOfChange, 1); // Removes 1 index from the array at the index of the pixel change
}
this.userSettings['highlight'] = userStorageNew;
// TODO: Add timer update here
button.disabled = false; // Reenables the button since we are done
}
/** Changes the highlight buttons to the clicked preset.
* @param {string} preset - The name of the preset
* @since 0.91.49
*/
async #updateHighlightToPreset(preset) {
// Obtains all preset buttons as a NodeList
const presetButtons = document.querySelectorAll('.bm-highlight-preset-container button');
// For each preset...
for (const button of presetButtons) {
button.disabled = true; // Disables the button
}
let presetArray = [0,0,0,0,2,0,0,0,0]; // The preset "None"
// Selects the preset passed in
switch (preset) {
case 'Cross':
presetArray = [0,1,0,1,2,1,0,1,0]; // The preset "Cross"
break;
case 'X':
presetArray = [1,0,1,0,2,0,1,0,1]; // The preset "X"
break;
case 'Full':
presetArray = [2,2,2,2,2,2,2,2,2]; // The preset "Full"
break;
}
// Obtains the buttons to click as a NodeList
const buttons = document.querySelector('.bm-highlight-grid')?.childNodes ?? [];
// For each button...
for (let buttonIndex = 0; buttonIndex < buttons.length; buttonIndex++) {
const button = buttons[buttonIndex]; // Gets the current button to check
// Gets the state of the button as a number
let buttonState = button.dataset['status'];
buttonState = (buttonState != 'Disabled') ? ((buttonState != 'Incorrect') ? 2 : 1) : 0;
// Finds the difference between the preset and the button
let buttonStateDelta = presetArray[buttonIndex] - buttonState;
// Since there is no difference, the button matches, so we skip it
if (buttonStateDelta == 0) {continue;}
// Makes the difference positive
buttonStateDelta += (buttonStateDelta < 0) ? 3 : 0;
/** At this point, these are the possible options:
* 1. The preset is zero and the button is two (-2) so we need to click once
* 2. The preset is one and the button is two (-1) so we need to click twice
* 3. The preset is one ahead of the button (1) so we need to click once
* 4. The preset is two ahead of the button (2) so we need to click twice
* Due to the addition of three in the line above, options 1 & 3 combine, and options 2 & 4 combine.
* Now the only options we have are:
* 1. If (1) then click once
* 2. If (2) then click twice
* Also due to the addition of three in the line above, our two options are POSITIVE numbers
*/
button.click(); // Clicks once
// Clicks a second time if needed
if (buttonStateDelta == 2) {
// For 0.2 seconds, or when the button is NOT disabled, wait for 10 milliseconds before attempting to continue
for (let timeWaited = 0; timeWaited < 200; timeWaited += 10) {
if (!button.disabled) {break;} // Breaks early once the button is enabled
await sleep(10);
}
button.click(); // Clicks again
}
}
// For each preset...
for (const button of presetButtons) {
button.disabled = false; // Re-enables the button
}
}
/** Build the "template" category of settings window
* @since 0.91.68
* @see WindowSettings#buildTemplate
*/
buildTemplate() {
this.window = this.addDiv({'class': 'bm-container'})
.addHeader(2, {'textContent': 'Pixel Highlight'}).buildElement()
.addHr().buildElement()
.addDiv({'class': 'bm-container', 'style': 'margin-left: 1.5ch;'})
.addCheckbox({'textContent': 'Template creation should skip transparent tiles'}, (instance, label, checkbox) => {
checkbox.checked = !this.userSettings?.flags?.includes('hl-noSkip'); // Makes the checkbox match the last stored user setting
checkbox.onchange = (event) => this.toggleFlag('hl-noSkip', !event.target.checked); // If the user wants to skip, then the checkbox is NOT checked
}).buildElement()
.addCheckbox({'innerHTML': 'Experimental: Template creation should <em>aggressively</em> skip transparent tiles'}, (instance, label, checkbox) => {
checkbox.checked = this.userSettings?.flags?.includes('hl-agSkip'); // Makes the checkbox match the last stored user setting
checkbox.onchange = (event) => this.toggleFlag('hl-agSkip', event.target.checked); // If the user wants to aggressively skip, then the checkbox is checked
}).buildElement()
.buildElement()
.buildElement()
}
}

View file

@ -1,5 +1,7 @@
import SettingsManager from "./settingsManager";
import Template from "./Template";
import { base64ToUint8, colorpaletteForBlueMarble, consoleError, consoleLog, consoleWarn, localizeNumber, numberToEncoded, sleep, viewCanvasInNewTab } from "./utils";
import WindowMain from "./WindowMain";
import WindowWizard from "./WindowWizard";
/** Manages the template system.
@ -83,14 +85,17 @@ import WindowWizard from "./WindowWizard";
export default class TemplateManager {
/** The constructor for the {@link TemplateManager} class.
* @param {string} name - The name of the userscript
* @param {string} version - The version of the userscript (SemVer as string)
* @since 0.55.8
*/
constructor(name, version, overlay) {
constructor(name, version) {
// Meta
this.name = name; // Name of userscript
this.version = version; // Version of userscript
this.overlay = overlay; // The main instance of the Overlay class
this.windowMain = null; // The main instance of the Overlay class
this.settingsManager = null; // The main instance of the SettingsManager class
this.schemaVersion = '2.0.0'; // Version of JSON schema
this.userID = null; // The ID of the current user
this.encodingBase = '!#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_`abcdefghijklmnopqrstuvwxyz{|}~'; // Characters to use for encoding/decoding
@ -111,6 +116,22 @@ export default class TemplateManager {
this.shouldFilterColor = new Map();
}
/** Updates the stored instance of the main window.
* @param {WindowMain} windowMain - The main window instance
* @since 0.91.54
*/
setWindowMain(windowMain) {
this.windowMain = windowMain;
}
/** Updates the stored instance of the SettingsManager.
* @param {SettingsManager} settingsManager - The settings manager instance
* @since 0.91.54
*/
setSettingsManager(settingsManager) {
this.settingsManager = settingsManager;
}
/** Creates the JSON object to store templates in
* @returns {{ whoami: string, scriptVersion: string, schemaVersion: string, templates: Object }} The JSON object
* @since 0.65.4
@ -135,7 +156,7 @@ export default class TemplateManager {
// Creates the JSON object if it does not already exist
if (!this.templatesJSON) {this.templatesJSON = await this.createJSON(); console.log(`Creating JSON...`);}
this.overlay.handleDisplayStatus(`Creating template at ${coords.join(', ')}...`);
this.windowMain.handleDisplayStatus(`Creating template at ${coords.join(', ')}...`);
// Creates a new template instance
const template = new Template({
@ -145,8 +166,16 @@ export default class TemplateManager {
file: blob,
coords: coords
});
// Does the user want to skip transparent tiles while creating templates?
const shouldSkipTransTiles = !this.settingsManager?.userSettings?.flags?.includes('hl-noSkip');
// Does the user want to aggressively skip transparent tiles while creating templates?
const shouldAggSkipTransTiles = this.settingsManager?.userSettings?.flags?.includes('hl-agSkip');
console.log(`Should Skip: ${shouldSkipTransTiles}; Should Agg Skip: ${shouldAggSkipTransTiles}`);
const { templateTiles, templateTilesBuffers } = await template.createTemplateTiles(this.tileSize, this.paletteBM); // Chunks the tiles
const { templateTiles, templateTilesBuffers } = await template.createTemplateTiles(this.tileSize, this.paletteBM, shouldSkipTransTiles, shouldAggSkipTransTiles); // Chunks the tiles
template.chunked = templateTiles; // Stores the chunked tile bitmaps
@ -166,7 +195,7 @@ export default class TemplateManager {
this.templatesArray = []; // Remove this to enable multiple templates (2/2)
this.templatesArray.push(template); // Pushes the Template object instance to the Template Array
this.overlay.handleDisplayStatus(`Template created at ${coords.join(', ')}!`);
this.windowMain.handleDisplayStatus(`Template created at ${coords.join(', ')}!`);
console.log(Object.keys(this.templatesJSON.templates).length);
console.log(this.templatesJSON);
@ -497,12 +526,12 @@ export default class TemplateManager {
const pixelCountFormatted = localizeNumber(totalPixels);
// Display status information about the templates being rendered
this.overlay.handleDisplayStatus(
this.windowMain.handleDisplayStatus(
`Displaying ${templateCount} template${templateCount == 1 ? '' : 's'}.\nTotal pixels: ${pixelCountFormatted}`
);
} else {
//this.overlay.handleDisplayStatus(`Displaying ${templateCount} templates.`);
this.overlay.handleDisplayStatus(`Sleeping\nVersion: ${this.version}`);
this.windowMain.handleDisplayStatus(`Sleeping\nVersion: ${this.version}`);
return tileBlob; // No templates are on this tile. Return the original tile early
}
@ -523,6 +552,26 @@ export default class TemplateManager {
const tileBeforeTemplates = context.getImageData(0, 0, drawSize, drawSize);
const tileBeforeTemplates32 = new Uint32Array(tileBeforeTemplates.data.buffer);
// Obtains the highlight pattern
const highlightPattern = this.settingsManager?.userSettings?.highlight || [[2, 0, 0]];
// The code demands that a highlight pattern always exists.
// Therefore, to disable highlighting, the highlight pattern is `[[2, 0, 0]]`.
// `[[2, 0, 0]]` is special, and will skip the highlighting code altogether.
// As a side-effect, the template will always display while enabled.
// You can't disable all sub-pixels in order to hide the template.
// Contains the first index of the highlight pattern.
const highlightPatternIndexZero = highlightPattern?.[0];
// This is so we can later determine if the pattern is the preset "None"
// Should highlighting be disabled?
const highlightDisabled = (
(highlightPattern?.length == 1)
&& (highlightPatternIndexZero?.[0] == 2)
&& (highlightPatternIndexZero?.[1] == 0)
&& (highlightPatternIndexZero?.[2] == 0)
)
// For each template in this tile, draw them.
for (const template of templatesToDraw) {
@ -557,7 +606,9 @@ export default class TemplateManager {
} = this.#calculateCorrectPixelsOnTile_And_FilterTile({
tile: tileBeforeTemplates32,
template: templateBeforeFilter32,
templateInfo: [coordXtoDrawAt, coordYtoDrawAt, template.bitmap.width, template.bitmap.height]
templateInfo: [coordXtoDrawAt, coordYtoDrawAt, template.bitmap.width, template.bitmap.height],
highlightPattern: highlightPattern,
highlightDisabled: highlightDisabled
});
let pixelsCorrectTotal = 0;
@ -573,7 +624,8 @@ export default class TemplateManager {
// If there are colors to filter, then we draw the filtered template on the canvas
// Or, if there are Erased (#deface) pixels, then we draw the modified template on the canvas
if ((this.shouldFilterColor.size != 0) || templateHasErased) {
// Or, if the user has enabled highlighting, then we draw the modified template on the canvas
if ((this.shouldFilterColor.size != 0) || templateHasErased || !highlightDisabled) {
console.log('Colors to filter: ', this.shouldFilterColor);
//context.putImageData(new ImageData(new Uint8ClampedArray(templateAfterFilter.buffer), template.bitmap.width, template.bitmap.height), coordXtoDrawAt, coordYtoDrawAt);
context.drawImage(await createImageBitmap(new ImageData(new Uint8ClampedArray(templateAfterFilter.buffer), template.bitmap.width, template.bitmap.height)), coordXtoDrawAt, coordYtoDrawAt);
@ -654,7 +706,7 @@ export default class TemplateManager {
} else {
// We don't know what the schema is. Unsupported?
this.overlay.handleDisplayError(`Template version ${schemaVersion} is unsupported.\nUse Blue Marble version ${scriptVersion} or load a new template.`);
this.windowMain.handleDisplayError(`Template version ${schemaVersion} is unsupported.\nUse Blue Marble version ${scriptVersion} or load a new template.`);
}
/** Loads schema of Blue Marble template storage
@ -757,17 +809,22 @@ export default class TemplateManager {
/** Calculates the correct pixels on this tile.
* In addition, this function filters colors based on user input.
* In addition, this function modifies colors to properly display (#deface).
* In addition, this function modifies incorrect pixels to display highlighting.
* This function has multiple purposes only to reduce iterations of scans over every pixel on the template.
* @param {Object} params - Object containing all parameters
* @param {Uint32Array} params.tile - The tile without templates as a Uint32Array
* @param {Uint32Array} params.template - The template without filtering as a Uint32Array
* @param {Array<Number, Number, Number, Number>} params.templateInfo - Information about template location and size
* @param {Array<number[]>} params.highlightPattern - The highlight pattern selected by the user
* @param {boolean} params.highlightDisabled - Should highlighting be disabled?
* @returns {{correctPixels: Map<number, number>, filteredTemplate: Uint32Array}} A Map containing the color IDs (keys) and how many correct pixels there are for that color (values)
*/
#calculateCorrectPixelsOnTile_And_FilterTile({
tile: tile32,
template: template32,
templateInfo: templateInformation
templateInfo: templateInformation,
highlightPattern: highlightPattern,
highlightDisabled: highlightDisabled
}) {
// Size of a pixel in actuality
@ -788,6 +845,10 @@ export default class TemplateManager {
//console.log(`TemplateX: ${templateCoordX}\nTemplateY: ${templateCoordY}\nStarting Row:${templateCoordY+tilePixelOffsetY}\nStarting Column:${templateCoordX+tilePixelOffsetX}`);
// Obtains if the user wants to highlight tile pixels that are transparent, but the template pixel is not
const shouldTransparentTilePixelsBeHighlighted = !this.settingsManager?.userSettings?.flags?.includes('hl-noTrans');
// The actual logic of this boolean is "should all pixels be highlighted"
const { palette: _, LUT: lookupTable } = this.paletteBM; // Obtains the palette and LUT
// Makes a copy of the color palette Blue Marble uses, turns it into a Map, and adds data to count the amount of each color
@ -796,6 +857,10 @@ export default class TemplateManager {
// For each center pixel...
for (let templateRow = 1; templateRow < templateHeight; templateRow += pixelSize) {
for (let templateColumn = 1; templateColumn < templateWidth; templateColumn += pixelSize) {
// ROWS ARE VERTICAL. "ROWS" AS IN, LIKE ON A SPREADSHEET
// COLUMNS ARE HORIZONTAL. "COLUMNS" AS IN, LIKE ON A SPREADSHEET
// THE FIFTH ROW IS FIVE DOWN FROM THE ZEROTH ROW
// THE THIRD COLUMN IS TO THE RIGHT OF THE FIRST COLUMN
// The pixel on the tile to target (1 pixel above the template)
const tileRow = (templateCoordY + templateRow) + tilePixelOffsetY; // (Template offset + current row) - 1
@ -812,6 +877,9 @@ export default class TemplateManager {
// Finds the best matching color ID for the template pixel. If none is found, default to "-2"
const bestTemplateColorID = lookupTable.get(templatePixel) ?? -2;
// Finds the best matching color ID for the tile pixel. If none is found, default to "-2"
const bestTileColorID = lookupTable.get(tilePixelAbove) ?? -2;
// ----- COLOR FILTER -----
// If this pixel on the template is a color the user wants to hide on the canvas...
if (this.shouldFilterColor.get(bestTemplateColorID)) {
@ -857,6 +925,39 @@ export default class TemplateManager {
}
// ----- END OF ERASED -----
// ----- HIGHLIGHTING -----
// If highlighting is enabled, AND the template pixel is NOT transparent AND the template pixel does NOT match the tile pixel
if (!highlightDisabled && (templatePixelAlpha > tolerance) && (bestTileColorID != bestTemplateColorID)) {
// If the tile pixel is NOT transparent, OR the user wants to highlight transparent pixels
if (shouldTransparentTilePixelsBeHighlighted || (tilePixelAlpha > tolerance)) {
// Obtains the template color of this pixel
const templatePixelColor = template32[(templateRow * templateWidth) + templateColumn];
// This will retrieve the tile background instead if the color is filtered!
// For each of the 9 subpixels inside the pixel...
for (const subpixelPattern of highlightPattern) {
// Deconstructs the sub pixel
const [subpixelState, subpixelColumnDelta, subpixelRowDelta] = subpixelPattern;
// "Delta" because the coordinate of the sub-pixel is relative to the center of the pixel
// Obtains the subpixel color to use
const subpixelColor = (subpixelState != 0) ? ((subpixelState != 1) ? templatePixelColor : 0xFF0000FF) : 0x00000000;
// 0 = Transparent (black)
// 1 = Red (#FF0000)
// 2 = Template (matches template or hides if filtered)
// Sets the subpixel to match the color on the highlight pattern
template32[((templateRow + subpixelRowDelta) * templateWidth) + (templateColumn + subpixelColumnDelta)] = subpixelColor;
}
}
}
// ----- END OF HIGHLIGHTING -----
// If the template pixel is Erased, and the tile pixel is transparent...
if ((bestTemplateColorID == -1) && (tilePixelAbove <= tolerance)) {
@ -874,11 +975,10 @@ export default class TemplateManager {
}
// If the code passes this point, both pixels are opaque & not Erased.
// Finds the best matching color ID for the tile pixel. If none is found, default to "-2"
const bestTileColorID = lookupTable.get(tilePixelAbove) ?? -2;
// If the template pixel does not match the tile pixel, then the pixel is skipped.
if (bestTileColorID != bestTemplateColorID) {continue;}
// If the template pixel does not match the tile pixel, then the pixel is skipped after highlighting.
if (bestTileColorID != bestTemplateColorID) {
continue;
}
// If the code passes this point, the template pixel matches the tile pixel.
// Increments the count by 1 for the best matching color ID (which can be negative).

View file

@ -15,7 +15,7 @@ export function getWplaceVersion() {
/** Halts execution of this specific userscript, for the specified time.
* This will not block the thread.
* @param {number} - Time to wait in milliseconds
* @param {number} time - Time to wait in milliseconds
* @since 0.88.483
* @returns {Promise} Promise of a setTimeout()
*/