UNPKG

@logic-pad/core

Version:
1,037 lines (1,036 loc) 40.9 kB
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; } }