This commit is contained in:
Kim Ravn Hansen
2025-09-17 15:00:32 +02:00
parent 2eefe8aae5
commit b1d667d7cb
19 changed files with 2312 additions and 261 deletions

88
frontend/TrainingCell.js Executable file
View File

@@ -0,0 +1,88 @@
import { Direction } from "./WfcConstants.js";
/**
* Represents a 3x3 grid of values (sub-cells), that are used as building blocks for procedurally
* generated grids. In reality, only the center value will be included in the outputted WfcGrid;
* the 8 surrounding colors are there to establish which TrainingCells can live next to each other.
*/
export class TrainingCell {
/** @param {Uint8Array} values The 9 sub cells that make up this TrainingCell */
constructor() {
this.values = new Uint8Array(9);
}
/** @returns {string} The actual value of this Trainin gCell is represented by its center value */
get value() {
return this.values[Direction.C];
}
/**
* @param {TrainingCell} other
* @param {number} direction
*
* @returns {boolean}
*/
potentialNeighbours(other, direction) {
// sadly, we're not allowed to be friends with ourselves.
if (this === other) {
console.log("WTF were checking to be friends with ourselves!", this, other, direction);
return false;
}
return (
//
// if they want to live to my east,
// their two westmost columns must match
// my two eastmost columns
(direction == Direction.E &&
// My center col must match their west col
this.values[Direction.N] === other.values[Direction.NW] &&
this.values[Direction.C] === other.values[Direction.W] &&
this.values[Direction.S] === other.values[Direction.SW] &&
// My east col must match their center col
this.values[Direction.NE] === other.values[Direction.N] &&
this.values[Direction.E] === other.values[Direction.C] &&
this.values[Direction.SE] === other.values[Direction.S]) ||
//
// if they want to live to my west,
// their two eastmost columns must match
// my two westmost columns
(direction == Direction.W &&
// My center col must match their east col
this.values[Direction.N] === other.values[Direction.NE] &&
this.values[Direction.C] === other.values[Direction.E] &&
this.values[Direction.S] === other.values[Direction.SE] &&
// My west col must match their center col
this.values[Direction.NW] === other.values[Direction.N] &&
this.values[Direction.W] === other.values[Direction.C] &&
this.values[Direction.SW] === other.values[Direction.S]) ||
//
// if they want to live to my north,
// their two souther rows must match
// my two northern rows
(direction == Direction.N &&
// my middle row must match their south row
this.values[Direction.W] === other.values[Direction.SW] &&
this.values[Direction.C] === other.values[Direction.S] &&
this.values[Direction.E] === other.values[Direction.SE] &&
// my north row must match their middle row
this.values[Direction.NW] === other.values[Direction.W] &&
this.values[Direction.N] === other.values[Direction.C] &&
this.values[Direction.Direction.NE] === other.values[Direction.E]) ||
//
// if they want to live to my south,
// their two northern rows must match
// my two southern rows
(direction == Direction.S &&
// my middle row must match their north row
this.values[Direction.W] === other.values[Direction.NW] &&
this.values[Direction.C] === other.values[Direction.N] &&
this.values[Direction.E] === other.values[Direction.SE] &&
// my south row must match their middle row
this.values[Direction.SW] === other.values[Direction.W] &&
this.values[Direction.S] === other.values[Direction.C] &&
this.values[Direction.SE] === other.values[Direction.E])
);
}
}

30
frontend/TrainingGrid.js Normal file
View File

@@ -0,0 +1,30 @@
import { TrainingCell } from "./TrainingCell.js";
export class TrainingGrid {
/**
* @param {TrainingCell[]} cells
*/
constructor(cells) {
if (cells[0] === undefined) {
throw new Error("cells must be a non empty array");
}
if (!(cells[0] instanceof TrainingCell)) {
throw new Error("cells arg must be an array of TrainingCell, but it isn't");
}
/** @type {TrainingCell[]} cells*/
this.cells = cells;
/** @type {number} the width and/or height of the training grid */
this.dim = Math.round(Math.sqrt(cells.length));
if (this.dim ** 2 !== cells.length) {
throw new Error("Training grid must be quadratic (height === width), but it isn't");
}
}
clone() {
return new TrainingGrid(this.cells.slice());
}
}

50
frontend/WfcCell.js Executable file
View File

@@ -0,0 +1,50 @@
import { TrainingCell } from "./TrainingCell";
/**
* Represents a single cell in a WfcGrid
*/
export class WfcCell {
/**
* @constructor
* @param {number} i index in the cell-array of this cell
* @param {number} x x-coordinate of cell
* @param {number} y y-coordinate of cell
* @param {TrainingCell[]} options - A list of training cells that could potentially live here.
*/
constructor(i, x, y, options) {
if (!options.length) {
console.log("Bad >>options<< arg in WfcCell constructor. Must not be empty.", options);
throw Error("Bad >>options<< arg in WfcCell constructor. Must not be empty.", options);
}
if (!(options[0] instanceof TrainingCell)) {
console.log("Bad >>options<< arg in WfcCell constructor. Must be array of WfcCells, but wasn't.", options);
throw Error("Bad >>options<< arg in WfcCell constructor. Must be array of WfcCells, but wasn't.", options);
}
this.i = i;
this.x = x;
this.y = y;
this.options = options;
}
getEntropy() {
const result = this.options.length;
return result;
}
get lockedIn() {
return this.getEntropy() === 1;
}
get valid() {
return this.options.length > 0;
}
get value() {
if (this.options[0] === undefined) {
throw new Error("Bad! I do not have any options, and therefore no color");
}
return this.options[0].value;
}
}

186
frontend/WfcGrid.js Executable file
View File

@@ -0,0 +1,186 @@
import { Direction } from "./WfcConstants.js";
import { WfcCell } from "./WfcCell.js";
/**
* A WfcGrid represents the output of a Wave Function Collapse operation.
*/
export class WfcGrid {
/**
* @param {number} w width (in cells)
* @param {number} h height (in cells)
* @param {TrainingGrid} trainingGrid the training grid that will be the source from which we populate this grid.
* @type {Xorshift32} pre-seeded pseudo random number generator
*/
constructor(w, h, trainingGrid, rng) {
this.width = w;
this.height = h;
this.trainingGrid = trainingGrid;
this.rng = rng;
//
// Populate the cells so each has all available options
// For now, this means *copying* all TrainingCell options into each cell
this.reset();
}
reset() {
console.log("Resetting Cells");
const [w, h] = [this.width, this.height];
const len = w * h;
this.cells = new Array(len);
for (let i = 0; i < len; i++) {
const x = i % w;
const y = Math.floor(i / w);
this.cells[i] = new WfcCell(i, x, y, this.trainingGrid.clone().pixels);
}
console.log("Done");
}
/**
* Get the cells that currently have the lowest entropy
* @returns {number[]}
*/
cellsIdsWithLowestEntropy() {
console.log("Finding cells with lowest entopy");
let result = [];
// set lowestEntropy to the highest possible entropy,
// and let's search for lower entropy in the cells
let lowestEntropy = this.trainingGrid.dim ** 2;
this.cells.forEach((cell, idx) => {
console.log("\t checking cell %d (entropy: %d)", idx, cell.getEntropy());
//
// Have we found cells with low entropy?
if (cell.getEntropy() < lowestEntropy) {
// we've found a cell with lower entropy that the ones we've been looking
// at so far Clear the search results and start over with this cell's
// entropy as our target
result = [idx];
lowestEntropy = cell.getEntropy();
return;
}
//
// Cell matches current entropy level, add it to search results.
if (cell.getEntropy() === lowestEntropy) {
// Cell matches our current level of entropy, so we add it to our search results.
// at so far! Clear the results and start over.
result.push(idx);
return;
}
});
if (result.length <= 0) {
console.log("Found zero lowest-entropy cells.", { lowestEntropy });
}
return result;
}
collapse() {
console.log("Starting collaps()");
let count = this.cells.length;
while (count > 0) {
count--;
// Get a list of possible target cells
const lowEntropyCellIds = this.cellIdsWithLowestEntropy();
//
// We've hit a dead end
// No appropriate target cells found.
if (lowEntropyCellIds.length === 0) {
console.log("Found no lowest-entropy cells. This should not happen");
return count;
}
const rCellId = this.rng.randomElement(lowEntropyCellIds);
const rCell = this.cells[rCellId];
/** @type {TrainingCell} a randomly chosen option that was available to rCell */
const rOption = this.rng.randomElement(rCell.options);
// Lock in the choice for this cell
rCell.options = [rOption];
// _____ ____ _ _
// | ____|_ __ _ __ ___ _ __| __ ) ___| | _____ _| |
// | _| | '__| '__/ _ \| '__| _ \ / _ \ |/ _ \ \ /\ / / |
// | |___| | | | | (_) | | | |_) | __/ | (_) \ V V /|_|
// |_____|_| |_| \___/|_| |____/ \___|_|\___/ \_/\_/ (_)
// Locking in this cell has changed the grid.
// We must look at the cell's cardinal neighbours and update their options.
for (let nArr of this.getNeighboursFor(rCell)) {
/** @type {number} direction of the neighbour */
const neighbourDirection = nArr[0];
/** @type {WfcCell} the neighbouring cell */
const neighbourCell = nArr[1];
// Clear the neighbour's options, and
// repopulate with valid options.
const newOptions = [];
for (let neighbourOption of neighbourCell.options) {
if (neighbourOption.potentialNeighbours(rOption, neighbourDirection)) {
newOptions.push(neighbourOption);
}
}
// We've collapsed too deep.
if (newOptions.length === 0) {
console.error("We've removed all options from a neighbour!", {
rCell,
rOption,
neighbourCell,
neighbourDirection,
newOptions,
});
return false;
}
neighbourCell.options = newOptions;
}
}
console.log("Done");
return 0;
}
/**
* Get the neighbours of a cell.
*/
getNeighboursFor(cell) {
const result = [];
const yNorth = cell.y - 1;
if (yNorth >= 0) {
const xNorth = cell.x;
const idx = this.width * yNorth + xNorth;
result.push([Direction.N, this.cells[idx]]);
}
const ySouth = cell.y + 1;
if (ySouth < this.height) {
const xSouth = cell.x;
const idx = this.width * ySouth + xSouth;
result.push([Direction.S, this.cells[idx]]);
}
const xEast = cell.x + 1;
if (xEast < this.width) {
const yEast = cell.y;
const idx = this.width * yEast + xEast;
result.push([Direction.E, this.cells[idx]]);
}
const xWest = cell.x - 1;
if (xWest >= 0) {
const yWest = cell.y;
const idx = this.width * yWest + xWest;
result.push([Direction.W, this.cells[idx]]);
}
return result;
}
}

0
frontend/WfcImage.1.js Normal file
View File

View File

@@ -1,11 +0,0 @@
{
"name": "",
"short_name": "",
"icons": [
{ "src": "./img/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "./img/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@@ -17,16 +17,6 @@
<div class="controls-panel">
<div class="color-picker-section">
<h3>Current Color</h3>
<div class="current-color" id="currentColor" onclick="painter.openColorPicker()"></div>
<input
type="color"
id="colorPicker"
value="#000000"
onchange="setCurrentColor(this.value)"
style="display: none"
/>
<h3>Color Palette</h3>
<div class="color-palette" id="colorPalette">
<!-- Color swatches will be generated -->
@@ -53,6 +43,7 @@
<button onclick="painter.exportAsImage()">Export png</button>
<button onclick="painter.exportAsData()">Export json</button>
<button onclick="painter.importData()">Import json</button>
<button onclick="painter.waveFunction()">WaifuCollapse</button>
</div>
</div>

View File

@@ -1,6 +1,12 @@
class PaintProg {
import { sprintf } from "sprintf-js";
import { Xorshift32 } from "../utils/random.js";
import { WfcGrid } from "./WfcGrid.js";
import { TrainingCell } from "./TrainingCell.js";
import { TrainingGrid } from "./TrainingGrid.js";
class PainApp {
/** @type {string} */
currentColor = "#000";
activeColor = "#000";
/** @type {string} */
currentTool = "draw";
/** @type {boolean} */
@@ -9,7 +15,7 @@ class PaintProg {
drawingMode = false;
/**@param {string[]} pal */
constructor(dim, pal, gridElement, paletteElement, currentColorElement, colorPickerElement, previewElement) {
constructor(dim, pal, gridElement, paletteElement, previewElement) {
/** @type {number} */
this.dim = dim;
/** @type {string[]} Default color palette */
@@ -22,18 +28,19 @@ class PaintProg {
this.previewElement = previewElement;
/** @type {HTMLElement} */
this.paletteElement = paletteElement;
/** @type {HTMLElement} */
this.currentColorElement = currentColorElement;
/** @type {HTMLInputElement} */
this.colorPickerElement = colorPickerElement;
this.subImages = Array.from({ length: this.samplePixels.length }, () => [...this.samplePixels]);
this.trainingImage = new TrainingGrid(
this.samplePixels.map(() => {
return new TrainingCell();
}),
);
this.createGrid();
this.createColorPalette();
this.updatePreview();
this.setCurrentColor(pal[0]);
this.updateAllSubimages();
this.setActiveColor(pal[0]);
this.updateTrainingGrid();
}
createGrid() {
@@ -60,38 +67,28 @@ class PaintProg {
createColorPalette() {
this.paletteElement.innerHTML = "";
this.palette.forEach((color) => {
this.palette.forEach((color, paletteIndex) => {
const swatch = document.createElement("div");
swatch.className = "color-swatch";
swatch.classList.add("color-swatch");
swatch.classList.add(`pal-idx-${paletteIndex}`);
swatch.classList.add(`pal-color-${color}`);
swatch.style.backgroundColor = color;
swatch.onclick = () => this.setCurrentColor(color);
swatch.onclick = () => this.setActiveColor(paletteIndex);
this.paletteElement.appendChild(swatch);
});
}
setCurrentColor(color) {
this.currentColor = color;
this.currentColorElement.style.backgroundColor = color;
this.colorPickerElement.value = color;
setActiveColor(paletteIndex) {
//
this.activeColor = this.palette[paletteIndex];
// Update active swatch
// NOTE: this was "document.querySelectorAll "
this.paletteElement.querySelectorAll(".color-swatch").forEach((swatch) => {
swatch.classList.toggle("active", swatch.style.backgroundColor === this.colorToRgb(color));
const colorSwatches = this.paletteElement.querySelectorAll(".color-swatch");
colorSwatches.forEach((swatch) => {
const isActive = swatch.classList.contains(`pal-idx-${paletteIndex}`);
swatch.classList.toggle("active", isActive);
});
}
colorToRgb(hex) {
const r = parseInt(hex.substr(1, 2), 16);
const g = parseInt(hex.substr(3, 2), 16);
const b = parseInt(hex.substr(5, 2), 16);
return `rgb(${r}, ${g}, ${b})`;
}
openColorPicker() {
this.colorPickerElement.click();
}
setTool(tool) {
this.currentTool = tool;
document.querySelectorAll(".tools button").forEach((btn) => btn.classList.remove("active"));
@@ -135,7 +132,7 @@ class PaintProg {
return;
}
this.updateSingleSubImage(index);
this.updateTrainingCell(index);
this.updatePreview(index);
}
@@ -146,10 +143,10 @@ class PaintProg {
applyTool(index) {
switch (this.currentTool) {
case "draw":
this.setPixel(index, this.currentColor);
this.setPixel(index, this.activeColor);
break;
case "fill":
this.floodFill(index, this.samplePixels[index], this.currentColor);
this.floodFill(index, this.samplePixels[index], this.activeColor);
break;
}
}
@@ -246,7 +243,7 @@ class PaintProg {
//
const x = i % 3;
const y = Math.floor(i / 3);
ctx.fillStyle = this.subImages[subImageIdx][i];
ctx.fillStyle = this.trainingImage.pixels[subImageIdx].subPixels[i];
ctx.fillRect(x, y, 1, 1);
}
}
@@ -255,28 +252,24 @@ class PaintProg {
this.previewElement.style.backgroundSize = "100%";
}
updateAllSubimages() {
updateTrainingGrid() {
for (let i = 0; i < this.samplePixels.length; i++) {
this.updateSingleSubImage(i);
this.updateTrainingCell(i);
}
}
updateSingleSubImage(i) {
updateTrainingCell(i) {
const dim = this.dim;
const len = dim ** 2;
const x = i % dim;
const y = Math.floor(i / dim);
const colorAt = (dX, dY) => {
const _x = (x + dim + dX) % dim; // add dim before modulo because JS modulo allows negative results
const _y = (y + dim + dY) % dim;
if (y == 0 && dY < 0) {
console.log(_x, _y);
}
return this.samplePixels[_y * dim + _x];
};
this.subImages[i] = [
this.trainingImage.pixels[i] = new TrainingCell([
// | neighbour
// ---------------------|-----------
colorAt(-1, -1), // | northwest
@@ -290,21 +283,21 @@ class PaintProg {
colorAt(-1, 1), // | southwest
colorAt(0, 1), // | south
colorAt(1, 1), // | southeast
];
]);
}
exportAsImage() {
const canvas = document.createElement("canvas");
canvas.width = 90; // 9x upscale
canvas.height = 90;
canvas.width = this.dim; // 9x upscale
canvas.height = this.dim;
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
for (let i = 0; i < this.dim ** 2; i++) {
const x = (i % this.dim) * 10;
const y = Math.floor(i / this.dim) * 10;
const x = i % this.dim;
const y = Math.floor(i / this.dim);
ctx.fillStyle = this.samplePixels[i];
ctx.fillRect(x, y, 10, 10);
ctx.fillRect(x, y, 1, 1);
}
const link = document.createElement("a");
@@ -341,7 +334,7 @@ class PaintProg {
alert("Invalid data format!");
}
} catch (error) {
alert("Error reading file!");
alert("Error reading file!" + error);
}
};
reader.readAsText(file);
@@ -350,42 +343,86 @@ class PaintProg {
input.click();
}
// Initialize the editor
waveFunction() {
this.updateTrainingGrid();
const wfcImg = new WfcGrid(
// this.previewElement.clientWidth,
// this.previewElement.clientHeight,
30,
30,
this.trainingImage.clone(),
new Xorshift32(Date.now()),
);
// Could not "collapse" the image.
// We should reset and try again?
let its = wfcImg.collapse();
if (its > 0) {
throw new Error(`Function Collapse failed with ${its} iterations left to go`);
}
const canvas = document.createElement("canvas");
canvas.width = wfcImg.width;
canvas.height = wfcImg.height;
// debug values
canvas.width = 30;
canvas.height = 30;
//
const ctx = canvas.getContext("2d");
let i = 0;
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
console.log("pix");
const cell = wfcImg.cells[i++];
if (cell.valid) {
ctx.fillStyle = "magenta";
ctx.fillRect(x, y, 1, 1);
}
}
}
this.previewElement.style.backgroundImage = `url(${canvas.toDataURL()})`;
this.previewElement.style.backgroundSize = "100%";
}
}
const palette = [
const base_palette = [
"#000",
"#008",
"#00f",
"#080",
"#088",
"#007",
"#00F",
"#070",
"#077",
"#0F0",
"#0FF",
"#0f0",
"#0ff",
"#800",
"#808",
"#80f",
"#880",
"#888",
"#88f",
"#8f8",
"#8ff",
"#ccc",
"#f00",
"#f0f",
"#f80",
"#f88",
"#f8f",
"#ff0",
"#ff8",
"#fff",
"#700",
"#707",
"#770",
"#F00",
"#F0F",
"#FF0",
];
window.painter = new PaintProg(
const palette = new Array(base_palette.length * 2);
base_palette.forEach((color, idx) => {
//
// Calc inverted color
const invR = 15 - Number.parseInt(color.substr(1, 1), 16);
const invG = 15 - Number.parseInt(color.substr(2, 1), 16);
const invB = 15 - Number.parseInt(color.substr(3, 1), 16);
const invColor = sprintf("#%x%x%x", invR, invG, invB);
// populate the palette
palette[idx] = color;
palette[7 * 4 - 1 - idx] = invColor;
});
window.painter = new PainApp(
9,
palette,
document.getElementById("gridContainer"), //
document.getElementById("colorPalette"), //
document.getElementById("currentColor"), //
document.getElementById("colorPicker"), //
document.getElementById("preview"), //
);

View File

@@ -1,6 +1,6 @@
// number number of cells per side in the source picture
$dim: 9;
$pixSize: 80px;
$pixSize: 40px;
$gridSize: calc($dim * $pixSize);
body {
@@ -35,21 +35,13 @@ body {
}
.pixel {
background-color: #ffffff;
background-color: #fff;
cursor: pointer;
border: 1px solid #7f8c8d;
transition: transform 0.1s ease;
position: relative;
aspect-ratio: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
.subpixel {
width: 100%;
height: 100%;
background-color: transparent;
}
}
.pixel:hover {
@@ -82,14 +74,14 @@ body {
.color-palette {
display: grid;
grid-template-columns: repeat(8, 1fr);
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin: 10px 0;
}
.color-swatch {
width: 25px;
height: 25px;
width: 28px;
height: 28px;
cursor: pointer;
border: 2px solid transparent;
border-radius: 4px;
@@ -102,8 +94,9 @@ body {
}
.color-swatch.active {
border-color: #3498db;
transform: scale(1.1);
outline: 4px solid #fff;
border: 1px solid #000;
/* transform: scale(1.1); */
}
.tools {

View File

@@ -1,137 +0,0 @@
const NW = 0;
const N = 1;
const NE = 2;
const E = 3;
const C = 4;
const W = 5;
const SW = 6;
const S = 7;
const SE = 8;
export class TrainingImage {
/** @param {number} w Width (in pixels) of the training image */
/** @param {number} h Height (in pixels) of the training image */
/** @param {Array<TrainingPixel>} pixels
*
*/
constructor(w, h, pixels) {
this.pixels = pixels;
this.w = w;
this.h = h;
}
establishFriendships() {
//
// This can be optimized a helluvalot much
this.pixels.forEach((pix1) => {
this.pixels.forEach((pix2) => {
pix1.addFriend(pix2);
});
});
}
}
/**
* Represents a 3x3 grid of trianing-pixels, that are used as building blocks for procedurally generated
* images. In reality, only the center color will be included in the output image. The 8 surrounding
* colors are there to establish restrictions/options for the WFC algorithm.
*/
export class TrainingPixel {
/** @type {string[9]} The 9 sub pixels that make up this TrainingPixel */
subPixels;
/** @type {TrainingPixel[]} The other TrainingPixels that we can share eastern borders with */
friendsEast = new Set();
/** @type {TrainingPixel[]} The other TrainingPixels that we can share western borders with */
friendsWest = new Set();
/** @type {TrainingPixel[]} The other TrainingPixels that we can share northern borders with */
friendsNorth = new Set();
/** @type {TrainingPixel[]} The other TrainingPixels that we can share southern borders with */
friendsSouth = new Set();
/** @type {TrainingPixel[]} Superset of all the friends this TrainingPixel has */
friendsTotal = new Set();
constructor(subPixels) {
this.subPixels = subPixels;
}
/**
* @param {TrainingPixel} other
*
* @returns {N,S,E,W,false}
*/
addFriend(other) {
// sadly, we're not allowed to be friends with ourselves.
if (this === other) {
return false;
}
// Check if other can be placed to the east of me
if (
// My center col must match their west col
this.subPixels[N] === other.subPixels[NW] &&
this.subPixels[C] === other.subPixels[W] &&
this.subPixels[S] === other.subPixels[SW] &&
// My east col must match their center col
this.subPixels[NE] === other.subPixels[N] &&
this.subPixels[E] === other.subPixels[C] &&
this.subPixels[SE] === other.subPixels[S]
) {
this.friendsEast.add(other);
other.friendsWest.add(this);
}
// check if other can be placed west of me
if (
// My center col must match their east col
this.subPixels[N] === other.subPixels[NE] &&
this.subPixels[C] === other.subPixels[E] &&
this.subPixels[S] === other.subPixels[SE] &&
// My west col must match their center col
this.subPixels[NW] === other.subPixels[N] &&
this.subPixels[W] === other.subPixels[C] &&
this.subPixels[SW] === other.subPixels[S]
) {
this.friendsWest.add(other);
other.friendsEast.add(this);
}
// check if other can be placed to my north
if (
// my middle row must match their south row
this.subPixels[W] === other.subPixels[SW] &&
this.subPixels[C] === other.subPixels[S] &&
this.subPixels[E] === other.subPixels[SE] &&
// my north row must match their middle row
this.subPixels[NW] === other.subPixels[W] &&
this.subPixels[NC] === other.subPixels[C] &&
this.subPixels[NE] === other.subPixels[E]
) {
this.friendsNorth.add(other);
other.friendsSouth.add(this);
}
// check if other can be placed to my south
if (
// my middle row must match their north row
this.subPixels[W] === other.subPixels[NW] &&
this.subPixels[C] === other.subPixels[N] &&
this.subPixels[E] === other.subPixels[SE] &&
// my south row must match their middle row
this.subPixels[SW] === other.subPixels[W] &&
this.subPixels[SC] === other.subPixels[c] &&
this.subPixels[SE] === other.subPixels[E]
) {
this.friendsSouth.add(other);
other.friendsNorth.add(this);
}
return false;
}
}