@logic-pad/core
Version:
1,037 lines (1,036 loc) • 40.9 kB
JavaScript
import { handlesGridChange } from './events/onGridChange.js';
import { handlesGridResize } from './events/onGridResize.js';
import { handlesSetGrid } from './events/onSetGrid.js';
import GridConnections from './gridConnections.js';
import { CachedAccess, array, move } from './dataHelper.js';
import { Color, MajorRule, } from './primitives.js';
import TileData from './tile.js';
import GridZones from './gridZones.js';
export const NEIGHBOR_OFFSETS = [
{ x: -1, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: -1 },
{ x: 0, y: 1 },
];
export default class GridData {
/* eslint-enable @typescript-eslint/no-unsafe-enum-comparison */
/**
* Create a new grid with tiles, connections, symbols and rules.
*
* @param width The width of the grid.
* @param height The height of the grid.
* @param tiles The tiles of the grid.
* @param connections The connections of the grid, which determines which tiles are merged.
* @param zones The zones of the grid.
* @param symbols The symbols in the grid.
* @param rules The rules of the grid.
*/
constructor(width, height, tiles, connections, zones, symbols, rules) {
Object.defineProperty(this, "width", {
enumerable: true,
configurable: true,
writable: true,
value: width
});
Object.defineProperty(this, "height", {
enumerable: true,
configurable: true,
writable: true,
value: height
});
Object.defineProperty(this, "tiles", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "connections", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "zones", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "symbols", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "rules", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
// Important rules are cached for quick access
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
Object.defineProperty(this, "musicGrid", {
enumerable: true,
configurable: true,
writable: true,
value: CachedAccess.of(() => this.findRule(rule => rule.id === MajorRule.MusicGrid))
});
Object.defineProperty(this, "completePattern", {
enumerable: true,
configurable: true,
writable: true,
value: CachedAccess.of(() => this.findRule(rule => rule.id === MajorRule.CompletePattern))
});
Object.defineProperty(this, "underclued", {
enumerable: true,
configurable: true,
writable: true,
value: CachedAccess.of(() => this.findRule(rule => rule.id === MajorRule.Underclued))
});
Object.defineProperty(this, "wrapAround", {
enumerable: true,
configurable: true,
writable: true,
value: CachedAccess.of(() => this.findRule(rule => rule.id === MajorRule.WrapAround))
});
this.width = width;
this.height = height;
this.tiles = tiles ?? array(width, height, () => TileData.empty());
this.connections = connections ?? new GridConnections();
this.zones = zones ?? new GridZones();
this.symbols = symbols ?? new Map();
this.rules = rules ?? [];
}
static create(arrayOrWidth, height, tiles, connections, zones, symbols, rules, sanitize, triggerEvents) {
if (typeof arrayOrWidth === 'number') {
let hasGridChangeSymbols = false;
let hasGridChangeRules = false;
if (triggerEvents) {
symbols?.forEach(list => {
list.forEach(sym => {
if (handlesGridChange(sym)) {
hasGridChangeSymbols = true;
}
});
});
rules?.forEach(rule => {
if (handlesGridChange(rule)) {
hasGridChangeRules = true;
}
});
}
const newSymbols = symbols
? sanitize
? GridData.deduplicateSymbols(symbols)
: triggerEvents && hasGridChangeSymbols
? new Map([...symbols.entries()].map(([id, list]) => [id, list.slice()]))
: symbols
: new Map();
// do not deduplicate all rules because it makes for bad editor experience
const newRules = rules
? sanitize
? GridData.deduplicateSingletonRules(rules)
: triggerEvents && hasGridChangeRules
? rules.slice()
: rules
: [];
const newGrid = new GridData(arrayOrWidth, height, tiles, connections
? sanitize
? GridConnections.validateEdges(connections, arrayOrWidth, height)
: connections
: undefined, zones
? sanitize
? GridZones.validateEdges(zones, arrayOrWidth, height)
: zones
: undefined, newSymbols, newRules);
if (triggerEvents) {
newSymbols.forEach(list => {
list.forEach((sym, i) => {
if (handlesGridChange(sym)) {
list[i] = sym.onGridChange(newGrid);
}
});
});
newRules.forEach((rule, i) => {
if (handlesGridChange(rule)) {
newRules[i] = rule.onGridChange(newGrid);
}
});
}
return newGrid;
}
else {
const tiles = GridData.createTiles(arrayOrWidth);
return GridData.create(tiles[0]?.length ?? 0, tiles.length, tiles);
}
}
/**
* Copy the current grid while modifying the provided properties.
* @param param0 The properties to modify.
* @returns The new grid with the modified properties.
*/
copyWith({ width, height, tiles, connections, zones, symbols, rules, }, sanitize = true, triggerEvents = true) {
return GridData.create(width ?? this.width, height ?? this.height, tiles ?? this.tiles, connections ?? this.connections, zones ?? this.zones, symbols ?? this.symbols, rules ?? this.rules, sanitize, triggerEvents);
}
toArrayCoordinates(x, y) {
// // This is the preferred way to compute tile coordinates, but for performance reasons we will just access the
// // wrap-around rule directly.
// this.rules.forEach(rule => {
// if (handlesGetTile(rule)) {
// ({ x, y } = rule.onGetTile(x, y));
// }
// });
// this.symbols.forEach(list =>
// list.forEach(symbol => {
// if (handlesGetTile(symbol)) {
// ({ x, y } = symbol.onGetTile(x, y));
// }
// })
// );
if (this.wrapAround.value) {
return this.wrapAround.value.onGetTile(x, y, this);
}
else {
return { x, y };
}
}
isPositionValid(x, y) {
({ x, y } = this.toArrayCoordinates(x, y));
return x >= 0 && x < this.width && y >= 0 && y < this.height;
}
/**
* Safely get the tile at the given position.
* @param x The x-coordinate of the tile.
* @param y The y-coordinate of the tile.
* @returns The tile at the given position, or a non-existent tile if the position is invalid.
*/
getTile(x, y) {
({ x, y } = this.toArrayCoordinates(x, y));
if (x < 0 || x >= this.width || y < 0 || y >= this.height)
return TileData.doesNotExist();
return this.tiles[y][x];
}
/**
* Safely set the tile at the given position.
* If the position is invalid, the tile array is returned unchanged.
* If the tile is merged with other tiles, the colors of all connected tiles are changed.
*
* @param x The x-coordinate of the tile.
* @param y The y-coordinate of the tile.
* @param tile The new tile to set.
* @returns The new tile array with updated tiles.
*/
setTile(x, y, tile) {
({ x, y } = this.toArrayCoordinates(x, y));
if (x < 0 || x >= this.width || y < 0 || y >= this.height) {
return this.tiles;
}
const changing = this.connections.getConnectedTiles({ x, y });
const tiles = this.tiles.map(row => [...row]);
const newTile = typeof tile === 'function' ? tile(tiles[y][x]) : tile;
changing.forEach(({ x, y }) => {
({ x, y } = this.toArrayCoordinates(x, y));
tiles[y][x] = tiles[y][x].withColor(newTile.color);
});
tiles[y][x] = newTile;
return tiles;
}
/**
* Replace or modify all tiles in the grid.
*
* @param tiles The new tile array or a function to mutate the existing tile array.
* @returns The new grid with the new tiles.
*/
withTiles(tiles) {
return this.copyWith({
tiles: typeof tiles === 'function'
? tiles(this.tiles.map(row => row.slice()))
: tiles,
});
}
/**
* Add or modify the connections in the grid.
* @param connections The new connections to add or modify.
* @returns The new grid with the new connections.
*/
withConnections(connections) {
return this.copyWith({
connections: typeof connections === 'function'
? connections(this.connections)
: connections,
});
}
/**
* Add or modify the zones in the grid.
* @param zones The new zones to add or modify.
* @returns The new grid with the new zones.
*/
withZones(zones) {
return this.copyWith({
zones: typeof zones === 'function' ? zones(this.zones) : zones,
});
}
/**
* Add or modify the symbols in the grid.
* @param symbols The new symbols to add or modify.
* @returns The new grid with the new symbols.
*/
withSymbols(symbols) {
if (symbols instanceof Array) {
const map = new Map();
for (const symbol of symbols) {
if (map.has(symbol.id)) {
map.set(symbol.id, [...map.get(symbol.id), symbol]);
}
else {
map.set(symbol.id, [symbol]);
}
}
return this.copyWith({ symbols: map });
}
return this.copyWith({
symbols: typeof symbols === 'function'
? symbols(new Map(this.symbols))
: symbols,
});
}
/**
* Add a new symbol to the grid.
* @param symbol The symbol to add.
* @returns The new grid with the new symbol.
*/
addSymbol(symbol) {
return this.withSymbols(map => {
if (map.has(symbol.id)) {
return map.set(symbol.id, [...map.get(symbol.id), symbol]);
}
else {
return map.set(symbol.id, [symbol]);
}
});
}
/**
* Remove an instance of the symbol from the grid.
* @param symbol The symbol to remove.
* @returns The new grid with the symbol removed.
*/
removeSymbol(symbol) {
return this.withSymbols(map => {
if (map.has(symbol.id)) {
const symbols = map.get(symbol.id).filter(s => s !== symbol);
if (symbols.length === 0) {
map.delete(symbol.id);
}
else {
map.set(symbol.id, symbols);
}
}
return map;
});
}
/**
* Remove all symbols that satisfy the predicate.
* @param predicate The predicate to test each symbol with.
* @returns The new grid with the symbols removed.
*/
removeSymbolIf(predicate) {
return this.withSymbols(map => {
for (const [id, symbols] of map) {
const newSymbols = symbols.filter(sym => !predicate(sym));
if (newSymbols.length === 0) {
map.delete(id);
}
else {
map.set(id, newSymbols);
}
}
return map;
});
}
/**
* Find the first symbol that satisfies the predicate.
* @param predicate The predicate to test each symbol with.
* @returns The first symbol that satisfies the predicate, or undefined if no symbol is found.
*/
findSymbol(predicate) {
for (const symbols of this.symbols.values()) {
const symbol = symbols.find(predicate);
if (symbol)
return symbol;
}
}
/**
* Replace an existing symbol with a new symbol.
* @param oldSymbol The symbol to replace.
* @param newSymbol The new symbol to replace with.
* @returns The new grid with the symbol replaced.
*/
replaceSymbol(oldSymbol, newSymbol) {
return this.withSymbols(map => {
if (map.has(oldSymbol.id)) {
const symbols = map
.get(oldSymbol.id)
.map(s => (s === oldSymbol ? newSymbol : s));
map.set(oldSymbol.id, symbols);
}
return map;
});
}
/**
* Add or modify the rules in the grid.
* @param rules The new rules to add or modify.
* @returns The new grid with the new rules.
*/
withRules(rules) {
return this.copyWith({
rules: typeof rules === 'function' ? rules(this.rules) : rules,
});
}
/**
* Add a new rule to the grid.
* @param rule The rule to add.
* @returns The new grid with the new rule.
*/
addRule(rule) {
return this.withRules(rules => [...rules, rule]);
}
/**
* Remove an instance of the rule from the grid.
* @param rule The rule to remove.
* @returns The new grid with the rule removed.
*/
removeRule(rule) {
return this.withRules(rules => rules.filter(r => r !== rule));
}
/**
* Remove all rules that satisfy the predicate.
* @param predicate The predicate to test each rule with.
* @returns The new grid with the rules removed.
*/
removeRuleIf(predicate) {
return this.withRules(rules => rules.filter(r => !predicate(r)));
}
/**
* Find the first rule that satisfies the predicate.
* @param predicate The predicate to test each rule with.
* @returns The first rule that satisfies the predicate, or undefined if no rule is found.
*/
findRule(predicate) {
return this.rules.find(predicate);
}
/**
* Replace an existing rule with a new rule.
* @param oldRule The rule to replace.
* @param newRule The new rule to replace with.
* @returns The new grid with the rule replaced.
*/
replaceRule(oldRule, newRule) {
return this.withRules(rules => rules.map(r => (r === oldRule ? newRule : r)));
}
/**
* Insert a new column at the given index, shifting all components of the grid accordingly. Newly inserted tiles are gray.
* @param index The index to insert the column at.
* @returns The new grid with the new column inserted.
*/
insertColumn(index) {
if (index < 0 || index > this.width)
return this;
const tiles = array(this.width + 1, this.height, (x, y) => {
if (x < index)
return this.getTile(x, y);
if (x === index)
return TileData.empty();
return this.getTile(x - 1, y);
});
const connections = this.connections.insertColumn(index);
const zones = this.zones.insertColumn(index);
const rules = this.rules
.map(rule => {
if (handlesGridResize(rule))
return rule.onGridResize(this, 'insert', 'column', index);
else
return rule;
})
.filter(rule => rule !== null);
const symbols = new Map();
for (const [id, symbolList] of this.symbols) {
const newList = symbolList
.map(symbol => symbol.onGridResize(this, 'insert', 'column', index))
.filter(symbol => symbol !== null);
if (newList.length > 0)
symbols.set(id, newList);
}
return this.copyWith({
width: this.width + 1,
tiles,
connections,
zones,
rules,
symbols,
});
}
/**
* Insert a new row at the given index, shifting all components of the grid accordingly. Newly inserted tiles are gray.
* @param index The index to insert the row at.
* @returns The new grid with the new row inserted.
*/
insertRow(index) {
if (index < 0 || index > this.height)
return this;
const tiles = array(this.width, this.height + 1, (x, y) => {
if (y < index)
return this.getTile(x, y);
if (y === index)
return TileData.empty();
return this.getTile(x, y - 1);
});
const connections = this.connections.insertRow(index);
const zones = this.zones.insertRow(index);
const rules = this.rules
.map(rule => {
if (handlesGridResize(rule))
return rule.onGridResize(this, 'insert', 'row', index);
else
return rule;
})
.filter(rule => rule !== null);
const symbols = new Map();
for (const [id, symbolList] of this.symbols) {
const newList = symbolList
.map(symbol => symbol.onGridResize(this, 'insert', 'row', index))
.filter(symbol => symbol !== null);
if (newList.length > 0)
symbols.set(id, newList);
}
return this.copyWith({
height: this.height + 1,
tiles,
connections,
zones,
rules,
symbols,
});
}
/**
* Remove a column at the given index, shifting all components of the grid accordingly.
* @param index The index to remove the column at.
* @returns The new grid with the column removed.
*/
removeColumn(index) {
if (index < 0 || index >= this.width)
return this;
const tiles = array(this.width - 1, this.height, (x, y) => x < index ? this.getTile(x, y) : this.getTile(x + 1, y));
const connections = this.connections.removeColumn(index);
const zones = this.zones.removeColumn(index);
const rules = this.rules
.map(rule => {
if (handlesGridResize(rule))
return rule.onGridResize(this, 'remove', 'column', index);
else
return rule;
})
.filter(rule => rule !== null);
const symbols = new Map();
for (const [id, symbolList] of this.symbols) {
const newList = symbolList
.map(symbol => symbol.onGridResize(this, 'remove', 'column', index))
.filter(symbol => symbol !== null);
if (newList.length > 0)
symbols.set(id, newList);
}
return this.copyWith({
width: this.width - 1,
tiles,
connections,
zones,
rules,
symbols,
});
}
/**
* Remove a row at the given index, shifting all components of the grid accordingly.
* @param index The index to remove the row at.
* @returns The new grid with the row removed.
*/
removeRow(index) {
if (index < 0 || index >= this.height)
return this;
const tiles = array(this.width, this.height - 1, (x, y) => y < index ? this.getTile(x, y) : this.getTile(x, y + 1));
const connections = this.connections.removeRow(index);
const zones = this.zones.removeRow(index);
const rules = this.rules
.map(rule => {
if (handlesGridResize(rule))
return rule.onGridResize(this, 'remove', 'row', index);
else
return rule;
})
.filter(rule => rule !== null);
const symbols = new Map();
for (const [id, symbolList] of this.symbols) {
const newList = symbolList
.map(symbol => symbol.onGridResize(this, 'remove', 'row', index))
.filter(symbol => symbol !== null);
if (newList.length > 0)
symbols.set(id, newList);
}
return this.copyWith({
height: this.height - 1,
tiles,
connections,
zones,
rules,
symbols,
});
}
/**
* Resize the grid to the new width and height, shifting all components of the grid accordingly. Newly inserted tiles are gray.
* @param width The new width of the grid.
* @param height The new height of the grid.
* @returns The new grid with the new dimensions.
*/
resize(width, height) {
if (width < 0 || height < 0)
throw new Error(`Invalid grid size: ${width}x${height}`);
// eslint-disable-next-line @typescript-eslint/no-this-alias
let newGrid = this;
while (newGrid.width < width)
newGrid = newGrid.insertColumn(newGrid.width);
while (newGrid.width > width)
newGrid = newGrid.removeColumn(newGrid.width - 1);
while (newGrid.height < height)
newGrid = newGrid.insertRow(newGrid.height);
while (newGrid.height > height)
newGrid = newGrid.removeRow(newGrid.height - 1);
return newGrid;
}
/**
* Create a new mutable TileData array from a string array.
*
* - Use `b` for dark cells, `w` for light cells, and `n` for gray cells.
* - Capitalize the letter to make the tile fixed.
* - Use `.` to represent empty space.
*
* @param array - The string array to create the tiles from.
* @returns The created tile array.
*/
static createTiles(array) {
const width = array.reduce((max, row) => Math.max(max, row.length), 0);
return array.map(row => Array.from({ length: width }, (_, x) => {
return TileData.create(row.charAt(x));
}));
}
/**
* Find a tile in the grid that satisfies the predicate.
*
* @param predicate The predicate to test each tile with.
* @returns The position of the first tile that satisfies the predicate, or undefined if no tile is found.
*/
find(predicate) {
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
if (predicate(this.getTile(x, y), x, y)) {
return { x, y };
}
}
}
return undefined;
}
/**
* Iterate over all tiles in the same region as the given position that satisfy the predicate.
* The iteration stops when the callback returns a value that is not undefined.
* Non-existent tiles are not included in the iteration.
*
* @param position The position to start the iteration from. This position is included in the iteration.
* @param predicate The predicate to test each tile with. The callback is only called for tiles that satisfy this predicate.
* @param callback The callback to call for each tile that satisfies the predicate. The iteration stops when this callback returns a value that is not undefined.
* @param visited A 2D array to keep track of visited tiles. This array is modified by the function.
* @returns The value returned by the callback that stopped the iteration, or undefined if the iteration completed.
*/
iterateArea(position, predicate, callback, visited = array(this.width, this.height, () => false)) {
const tile = this.getTile(position.x, position.y);
if (!tile.exists || !predicate(tile)) {
return;
}
const stack = [position];
while (stack.length > 0) {
const { x, y } = stack.pop();
const { x: arrX, y: arrY } = this.toArrayCoordinates(x, y);
if (visited[arrY][arrX]) {
continue;
}
visited[arrY][arrX] = true;
const ret = callback(this.getTile(x, y), arrX, arrY, x, y);
if (ret !== undefined)
return ret;
for (const offset of NEIGHBOR_OFFSETS) {
const next = { x: x + offset.x, y: y + offset.y };
if (this.isPositionValid(next.x, next.y)) {
const nextTile = this.getTile(next.x, next.y);
if (nextTile.exists && predicate(nextTile))
stack.push(next);
}
}
}
}
/**
* Iterate over all tiles in a straight line from the given position in the given direction that satisfy the predicate.
* The iteration stops when the callback returns a value that is not undefined.
* Non-existent tiles break the iteration.
*
* @param position The position to start the iteration from. This position is included in the iteration.
* @param direction The direction to iterate in.
* @param predicate The predicate to test each tile with. The callback is only called for tiles that satisfy this predicate.
* @param callback The callback to call for each tile that satisfies the predicate. The iteration stops when this callback returns a value that is not undefined.
* @param visited A 2D array to keep track of visited tiles. This array is modified by the function.
* @returns The value returned by the callback that stopped the iteration, or undefined if the iteration completed.
*/
iterateDirection(position, direction, predicate, callback, visited = array(this.width, this.height, () => false)) {
return this.iterateDirectionAll(position, direction, tile => tile.exists && predicate(tile), callback, visited);
}
/**
* Iterate over all tiles in a straight line from the given position in the given direction that satisfy the predicate.
* The iteration stops when the callback returns a value that is not undefined.
* Non-existent tiles are included in the iteration.
*
* @param position The position to start the iteration from. This position is included in the iteration.
* @param direction The direction to iterate in.
* @param predicate The predicate to test each tile with. The callback is only called for tiles that satisfy this predicate.
* @param callback The callback to call for each tile that satisfies the predicate. The iteration stops when this callback returns a value that is not undefined.
* @param visited A 2D array to keep track of visited tiles. This array is modified by the function.
* @returns The value returned by the callback that stopped the iteration, or undefined if the iteration completed.
*/
iterateDirectionAll(position, direction, predicate, callback, visited = array(this.width, this.height, () => false)) {
let current = position;
while (this.isPositionValid(current.x, current.y)) {
const arrPos = this.toArrayCoordinates(current.x, current.y);
if (visited[arrPos.y][arrPos.x]) {
break;
}
visited[arrPos.y][arrPos.x] = true;
const tile = this.getTile(current.x, current.y);
if (!predicate(tile)) {
break;
}
const ret = callback(tile, arrPos.x, arrPos.y, current.x, current.y);
if (ret !== undefined)
return ret;
current = move(current, direction);
}
}
/**
* Check if every tile in the grid is filled with a color other than gray.
*
* @returns True if every tile is filled with a color other than gray, false otherwise.
*/
isComplete() {
return this.tiles.every(row => row.every(tile => !tile.exists || tile.color !== Color.Gray));
}
/**
* Iterate over all tiles in the grid.
* The iteration stops when the callback returns a value that is not undefined.
*
* @param callback The callback to call for each tile.
* @returns The value returned by the callback that stopped the iteration, or undefined if the iteration completed.
*/
forEach(callback) {
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
const ret = callback(this.getTile(x, y), x, y);
if (ret !== undefined)
return ret;
}
}
}
/**
* Flood fill a continuous region starting from the given position with the given color.
*
* @param position The position to start the flood fill from.
* @param from The color of the tiles to fill.
* @param to The color to fill the tiles with.
* @param allowFixed Whether to fill fixed tiles.
* @returns The new grid with the region filled with the new color.
*/
floodFill(position, from, to, allowFixed) {
const tiles = array(this.width, this.height, (x, y) => this.getTile(x, y));
this.iterateArea(position, t => t.color === from && (allowFixed || !t.fixed), (tile, x, y) => {
tiles[y][x] = tile.withColor(to);
});
return this.copyWith({ tiles }, false);
}
/**
* Flood fill all tiles with the given color to a new color, even if they are not connected.
*
* @param from The color of the tiles to fill.
* @param to The color to fill the tiles with.
* @param allowFixed Whether to fill fixed tiles.
* @returns The new grid with all tiles filled with the new color.
*/
floodFillAll(from, to, allowFixed) {
return this.copyWith({
tiles: this.tiles.map(row => row.map(tile => tile.color === from && (allowFixed || !tile.fixed)
? tile.withColor(to)
: tile)),
}, false);
}
/**
* Check if the grid has any instructions that require a custom solution.
* @returns True if the grid has any instructions that require a custom solution, false otherwise.
*/
requireSolution() {
if (this.rules.some(rule => rule.validateWithSolution))
return true;
if ([...this.symbols.values()].some(list => list.some(symbol => symbol.validateWithSolution)))
return true;
return false;
}
/**
* Reset all non-fixed tiles to gray.
*
* @returns The new grid with all non-fixed tiles reset to gray.
*/
resetTiles() {
let changed = false;
const newTiles = array(this.width, this.height, (x, y) => {
const tile = this.getTile(x, y);
if (tile.exists && !tile.fixed && tile.color !== Color.Gray) {
changed = true;
return tile.withColor(Color.Gray);
}
return tile;
});
if (!changed)
return this;
let newGrid = this.copyWith({ tiles: newTiles }, false);
this.symbols.forEach(list => {
list.forEach(symbol => {
if (handlesSetGrid(symbol)) {
newGrid = symbol.onSetGrid(this, newGrid, null);
}
});
});
this.rules.forEach(rule => {
if (handlesSetGrid(rule)) {
newGrid = rule.onSetGrid(this, newGrid, null);
}
});
return newGrid;
}
/**
* Copy the tiles in the given region to a new grid.
* All connections and symbols within the selected region are copied.
* All rules are included as well.
*
* @param origin The top-left corner of the region to copy.
* @param width The width of the region to copy.
* @param height The height of the region to copy.
* @returns The new grid with the copied tiles.
*/
copyTiles(origin, width, height) {
const newTiles = array(width, height, (x, y) => this.getTile(origin.x + x, origin.y + y));
const connections = new GridConnections(this.connections.edges
.filter(edge => edge.x1 >= origin.x &&
edge.y1 >= origin.y &&
edge.x2 >= origin.x &&
edge.y2 >= origin.y &&
edge.x1 < origin.x + width &&
edge.y1 < origin.y + height &&
edge.x2 < origin.x + width &&
edge.y2 < origin.y + height)
.map(edge => ({
x1: edge.x1 - origin.x,
y1: edge.y1 - origin.y,
x2: edge.x2 - origin.x,
y2: edge.y2 - origin.y,
})));
const zones = new GridZones(this.zones.edges
.filter(edge => edge.x1 >= origin.x &&
edge.y1 >= origin.y &&
edge.x2 >= origin.x &&
edge.y2 >= origin.y &&
edge.x1 < origin.x + width &&
edge.y1 < origin.y + height &&
edge.x2 < origin.x + width &&
edge.y2 < origin.y + height)
.map(edge => ({
x1: edge.x1 - origin.x,
y1: edge.y1 - origin.y,
x2: edge.x2 - origin.x,
y2: edge.y2 - origin.y,
})));
const symbols = new Map();
for (const [id, symbolList] of this.symbols) {
const newSymbolList = symbolList.filter(symbol => symbol.x >= origin.x &&
symbol.y >= origin.y &&
symbol.x < origin.x + width &&
symbol.y < origin.y + height);
if (newSymbolList.length > 0)
symbols.set(id, newSymbolList);
}
return GridData.create(width, height, newTiles, connections, zones, symbols, this.rules);
}
pasteTiles(origin, grid) {
if (!(grid instanceof GridData))
return this.pasteTiles(origin, new GridData(grid[0].length, grid.length, grid));
const newTiles = this.tiles.map(row => [...row]);
grid.forEach((tile, x, y) => {
if (this.isPositionValid(origin.x + x, origin.y + y))
newTiles[origin.y + y][origin.x + x] = tile;
});
const connections = new GridConnections([
...this.connections.edges,
...grid.connections.edges.map(edge => ({
x1: edge.x1 + origin.x,
y1: edge.y1 + origin.y,
x2: edge.x2 + origin.x,
y2: edge.y2 + origin.y,
})),
]);
const zones = new GridZones([
...this.zones.edges,
...grid.zones.edges.map(edge => ({
x1: edge.x1 + origin.x,
y1: edge.y1 + origin.y,
x2: edge.x2 + origin.x,
y2: edge.y2 + origin.y,
})),
]);
const symbols = new Map(this.symbols);
for (const [id, sourceList] of grid.symbols) {
const symbolList = sourceList.map(symbol => symbol.copyWith({ x: symbol.x + origin.x, y: symbol.y + origin.y }));
if (symbols.has(id)) {
symbols.set(id, [...symbols.get(id), ...symbolList]);
}
else {
symbols.set(id, symbolList);
}
}
const rules = [...this.rules, ...grid.rules];
return this.copyWith({
tiles: newTiles,
connections,
zones,
symbols,
rules,
});
}
/**
* Check if this grid is equal to another grid in terms of size and tile colors.
* Rules, symbols, and connections are not compared.
*
* @param grid The grid to compare with.
* @returns True if the grids are equal in size and tile colors, false otherwise.
*/
colorEquals(grid) {
return (this.width === grid.width &&
this.height === grid.height &&
this.tiles.every((row, y) => row.every((tile, x) => (!tile.exists && !grid.getTile(x, y).exists) ||
tile.color === grid.getTile(x, y).color)));
}
/**
* Check if this grid is equal to another grid in terms of size, tile colors, connections, symbols, and rules.
*
* @param other The grid to compare with.
* @returns True if the grids are equal, false otherwise.
*/
equals(other) {
if (this.width !== other.width)
return false;
if (this.height !== other.height)
return false;
if (this.tiles.some((row, y) => row.some((tile, x) => !tile.equals(other.getTile(x, y)))))
return false;
if (!this.connections.equals(other.connections))
return false;
if (!this.zones.equals(other.zones))
return false;
if (this.symbols.size !== other.symbols.size)
return false;
for (const [id, symbols] of this.symbols) {
const otherSymbols = other.symbols.get(id);
if (!otherSymbols || symbols.length !== otherSymbols.length)
return false;
for (const symbol of symbols) {
if (!otherSymbols.some(s => symbol.equals(s)))
return false;
}
}
if (this.rules.length !== other.rules.length)
return false;
for (const rule of this.rules) {
if (!other.rules.some(r => rule.equals(r)))
return false;
}
return true;
}
/**
* Get the count of tiles that satisfy the given conditions.
* @param exists Whether the tile exists or not.
* @param fixed Whether the tile is fixed or not. If undefined, the fixed state is ignored.
* @param color The color of the tile. If undefined, all colors are included.
* @returns The count of tiles that satisfy the given conditions.
*/
getTileCount(exists, fixed, color) {
let count = 0;
this.forEach(tile => {
if (tile.exists !== exists)
return;
if (fixed !== undefined && tile.fixed !== fixed)
return;
if (color !== undefined && tile.color !== color)
return;
count++;
});
return count;
}
/**
* Get the count of tiles that satisfy the given conditions for each color.
* @param color The color of the tiles.
* @returns The count of tiles that satisfy the given conditions for each color.
*/
getColorCount(color) {
let min = 0;
let max = this.width * this.height;
this.forEach(tile => {
if (!tile.exists || (tile.fixed && tile.color !== color)) {
max--;
}
if (tile.exists && tile.fixed && tile.color === color) {
min++;
}
});
return { min, max };
}
/**
* Deduplicate the rules in the given list.
*
* @param rules The list of rules to deduplicate.
* @returns The deduplicated list of rules.
*/
static deduplicateRules(rules) {
return rules.filter((rule, index, self) => self.findIndex(r => r.equals(rule)) === index);
}
/**
* Deduplicate the singleton rules in the given list.
*
* @param rules The list of rules to deduplicate.
* @returns The deduplicated list of rules.
*/
static deduplicateSingletonRules(rules) {
return rules.filter((rule, index, self) => !rule.isSingleton || self.findIndex(r => r.id === rule.id) === index);
}
/**
* Deduplicate the symbols in the given map.
*
* @param symbols The map of symbols to deduplicate.
* @returns The deduplicated map of symbols.
*/
static deduplicateSymbols(symbols) {
const map = new Map();
for (const [id, symbolList] of symbols) {
map.set(id, symbolList.filter((symbol, index, self) => self.findIndex(s => symbol.equals(s)) === index));
}
return map;
}
}