From 0929719a845897cc8567cf972e07a69a71f0fa6f Mon Sep 17 00:00:00 2001 From: Star Rauchenberger Date: Thu, 30 Nov 2023 13:29:08 -0500 Subject: Migrate to a full rails app --- app/assets/javascripts/application.js | 2 + app/assets/javascripts/custom_mechanics.js | 201 ++++ app/assets/javascripts/display2.js | 316 ++++++ app/assets/javascripts/polyominos.js | 331 +++++++ app/assets/javascripts/puzzle.js | 538 +++++++++++ app/assets/javascripts/serializer.js | 365 +++++++ app/assets/javascripts/solve.js | 531 +++++++++++ app/assets/javascripts/svg.js | 422 +++++++++ app/assets/javascripts/trace2.js | 1055 +++++++++++++++++++++ app/assets/javascripts/utilities.js.erb | 498 ++++++++++ app/assets/javascripts/validate.js | 391 ++++++++ app/assets/javascripts/wittle.js | 5 + app/assets/javascripts/wittle/application.js | 14 - app/assets/javascripts/wittle/custom_mechanics.js | 201 ---- app/assets/javascripts/wittle/display2.js | 316 ------ app/assets/javascripts/wittle/polyominos.js | 331 ------- app/assets/javascripts/wittle/puzzle.js | 538 ----------- app/assets/javascripts/wittle/serializer.js | 365 ------- app/assets/javascripts/wittle/solve.js | 531 ----------- app/assets/javascripts/wittle/svg.js | 422 --------- app/assets/javascripts/wittle/trace2.js | 1055 --------------------- app/assets/javascripts/wittle/utilities.js.erb | 498 ---------- app/assets/javascripts/wittle/validate.js | 391 -------- app/assets/javascripts/wittle/wittle.js | 5 - 24 files changed, 4655 insertions(+), 4667 deletions(-) create mode 100644 app/assets/javascripts/application.js create mode 100644 app/assets/javascripts/custom_mechanics.js create mode 100644 app/assets/javascripts/display2.js create mode 100644 app/assets/javascripts/polyominos.js create mode 100644 app/assets/javascripts/puzzle.js create mode 100644 app/assets/javascripts/serializer.js create mode 100644 app/assets/javascripts/solve.js create mode 100644 app/assets/javascripts/svg.js create mode 100644 app/assets/javascripts/trace2.js create mode 100644 app/assets/javascripts/utilities.js.erb create mode 100644 app/assets/javascripts/validate.js create mode 100644 app/assets/javascripts/wittle.js delete mode 100644 app/assets/javascripts/wittle/application.js delete mode 100644 app/assets/javascripts/wittle/custom_mechanics.js delete mode 100644 app/assets/javascripts/wittle/display2.js delete mode 100644 app/assets/javascripts/wittle/polyominos.js delete mode 100644 app/assets/javascripts/wittle/puzzle.js delete mode 100644 app/assets/javascripts/wittle/serializer.js delete mode 100644 app/assets/javascripts/wittle/solve.js delete mode 100644 app/assets/javascripts/wittle/svg.js delete mode 100644 app/assets/javascripts/wittle/trace2.js delete mode 100644 app/assets/javascripts/wittle/utilities.js.erb delete mode 100644 app/assets/javascripts/wittle/validate.js delete mode 100644 app/assets/javascripts/wittle/wittle.js (limited to 'app/assets/javascripts') diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js new file mode 100644 index 0000000..e614ed0 --- /dev/null +++ b/app/assets/javascripts/application.js @@ -0,0 +1,2 @@ +//= require jquery3 +//= require_tree . diff --git a/app/assets/javascripts/custom_mechanics.js b/app/assets/javascripts/custom_mechanics.js new file mode 100644 index 0000000..d4733db --- /dev/null +++ b/app/assets/javascripts/custom_mechanics.js @@ -0,0 +1,201 @@ +namespace(function() { + +function isCellBridgePathFriendly(puzzle, color, pos) { + if (pos.x%2 === 0 && pos.y%2 === 0) return false + var cell = puzzle.getCell(pos.x, pos.y) + return cell == null || cell.color == null || cell.color === color +} + +function makeMinimalTree(graph, root, required) { + var seen = Array(graph.length).fill(false) + var result = Array(graph.length).fill(false) + result[root] = true + function dfs(node) { + seen[node] = true + result[node] = required[node] + for (var child of graph[node]) { + if (!seen[child]) { + dfs(child) + result[node] = result[node] || result[child] + } + } + } + dfs(root) + return result +} + +function isTreeUnique(graph, isInTree) { + var seen = isInTree.slice() + function dfs(node) { + seen[node] = true + var reachableTreeNode = null + for (var child of graph[node]) { + var candidate = null + if (isInTree[child]) { + candidate = child + } else if (!seen[child]) { + candidate = dfs(child) + } + if (candidate != null && candidate !== reachableTreeNode) { + if (reachableTreeNode == null) { + reachableTreeNode = candidate + } else { + return -1 + } + } + } + return reachableTreeNode + } + for (var i = 0; i < graph.length; i++) { + if (!seen[i]) { + if (dfs(i) === -1) return false + } + } + return true +} + +function puzzleCellsAdjacent(first, second, pillar) { + if (pillar && first.y == second.y && Math.abs(second.x - first.x) === puzzle.width - 1) + return true + return Math.abs(second.x - first.x) + Math.abs(second.y - first.y) === 1 +} + +function bridgeTest(region, puzzle, color, bridges) { + var nodes = region.cells.filter(pos => isCellBridgePathFriendly(puzzle, color, pos)) + var graph = Array.from(Array(nodes.length), () => []) + for (var ir = 1; ir < nodes.length; ir++) { + var right = nodes[ir] + for (var il = 0; il < ir; il++) { + var left = nodes[il] + if (puzzleCellsAdjacent(left, right, puzzle.pillar)) { + graph[il].push(ir) + graph[ir].push(il) + } + } + } + var isBridge = nodes.map(node => bridges.some(bridge => node.x === bridge.x && node.y === bridge.y)) + var isInTree = makeMinimalTree(graph, isBridge.indexOf(true), isBridge) + for (var i = 0; i < nodes.length; i++) { + if (isBridge[i] && !isInTree[i]) return false + } + return isTreeUnique(graph, isInTree) +} + +window.validateBridges = function(puzzle, region, regionData) { + var bridges = {} + for (var pos of region) { + var cell = puzzle.getCell(pos.x, pos.y) + if (cell == null) continue + + // Count color-based elements + if (cell.color != null) { + if (cell.type === 'bridge') { + if (bridges[cell.color] == null) { + bridges[cell.color] = [] + } + bridges[cell.color].push(pos) + } + } + } + + for (var color in bridges) { + var total = 0 + var discardable = 0 + for (var x=1; x < puzzle.width; x+=2) { + for (var y=1; y < puzzle.height; y+=2) { + var cell = puzzle.getCell(x, y) + if (cell != null) { + if (cell.type === 'bridge' && cell.color === color) total++ + if (cell.type === 'nega') discardable++ + } + } + } + + if (bridges[color].length != total) { + if (bridges[color].length >= total - discardable) { + // TODO: Negations in other regions can validate the solution + for (var bridge of bridges[color]) { + regionData.addInvalid(bridge) + } + } else { + for (var bridge of bridges[color]) { + regionData.addVeryInvalid(bridge) + } + } + } else if (!window.bridgeTest(region, puzzle, color, bridges[color])) { + for (var bridge of bridges[color]) { + regionData.addInvalid(bridge) + } + } + } +} + +var DIRECTIONS = [ + {'x': 0, 'y':-1}, + {'x': 1, 'y':-1}, + {'x': 1, 'y': 0}, + {'x': 1, 'y': 1}, + {'x': 0, 'y': 1}, + {'x':-1, 'y': 1}, + {'x':-1, 'y': 0}, + {'x':-1, 'y':-1}, +] + +window.validateArrows = function(puzzle, region, regionData) { + for (var pos of region) { + var cell = puzzle.getCell(pos.x, pos.y) + if (cell == null) continue + if (cell.type != 'arrow') continue + dir = DIRECTIONS[cell.rot] + + var count = 0 + var x = pos.x + dir.x + var y = pos.y + dir.y + for (var i=0; i<100; i++) { // 100 is arbitrary, it's just here to avoid infinite loops. + var line = puzzle.getLine(x, y) + console.spam('Testing', x, y, 'for arrow at', pos.x, pos.y, 'found', line) + if (line == null && (x%2 !== 1 || y%2 !== 1)) break + if (line > window.LINE_NONE) count++ + if (count > cell.count) break + x += dir.x * 2 + y += dir.y * 2 + if (puzzle.matchesSymmetricalPos(x, y, pos.x + dir.x, pos.y + dir.y)) break // Pillar exit condition (in case of looping) + } + if (count !== cell.count) { + console.log('Arrow at', pos.x, pos.y, 'crosses', count, 'lines, but should cross', cell.count) + regionData.addInvalid(pos) + } + } +} + +window.validateSizers = function(puzzle, region, regionData) { + var sizers = [] + var regionSize = 0 + for (var pos of region) { + if (pos.x%2 === 1 && pos.y%2 === 1) regionSize++ // Only count cells for the region + var cell = puzzle.getCell(pos.x, pos.y) + if (cell == null) continue + if (cell.type == 'sizer') sizers.push(pos) + } + console.debug('Found', sizers.length, 'sizers') + if (sizers.length == 0) return // No sizers -- no impact on sizer validity + + var sizerCount = regionSize / sizers.length + if (sizerCount % 1 != 0) { + console.log('Region size', regionSize, 'is not a multiple of # sizers', sizers.length) + for (var sizer of sizers) { + regionData.addInvalid(sizer) + } + return + } + + if (puzzle.sizerCount == null) puzzle.sizerCount = sizerCount // No other sizes have been defined + if (puzzle.sizerCount != sizerCount) { + console.log('sizerCount', sizerCount, 'does not match puzzle sizerCount', puzzle.sizerCount) + for (var sizer of sizers) { + regionData.addInvalid(sizer) + } + } +} + +}) diff --git a/app/assets/javascripts/display2.js b/app/assets/javascripts/display2.js new file mode 100644 index 0000000..ddf3968 --- /dev/null +++ b/app/assets/javascripts/display2.js @@ -0,0 +1,316 @@ +var SYM_TYPE_NONE = 0 +var SYM_TYPE_HORIZONTAL = 1 +var SYM_TYPE_VERTICAL = 2 +var SYM_TYPE_ROTATIONAL = 3 +var SYM_TYPE_ROTATE_LEFT = 4 +var SYM_TYPE_ROTATE_RIGHT = 5 +var SYM_TYPE_FLIP_XY = 6 +var SYM_TYPE_FLIP_NEG_XY = 7 +var SYM_TYPE_PARALLEL_H = 8 +var SYM_TYPE_PARALLEL_V = 9 +var SYM_TYPE_PARALLEL_H_FLIP = 10 +var SYM_TYPE_PARALLEL_V_FLIP = 11 +var SYM_TYPE_PILLAR_PARALLEL = 12 +var SYM_TYPE_PILLAR_HORIZONTAL = 13 +var SYM_TYPE_PILLAR_VERTICAL = 14 +var SYM_TYPE_PILLAR_ROTATIONAL = 15 + +namespace(function() { + +window.draw = function(puzzle, target='puzzle') { + if (puzzle == null) return + var svg = document.getElementById(target) + console.info('Drawing', puzzle, 'into', svg) + while (svg.firstChild) svg.removeChild(svg.firstChild) + + // Prevent context menu popups within the puzzle + svg.oncontextmenu = function(event) { + event.preventDefault() + } + + if (puzzle.pillar === true) { + // 41*width + 30*2 (padding) + 10*2 (border) + var pixelWidth = 41 * puzzle.width + 80 + } else { + // 41*(width-1) + 24 (extra edge) + 30*2 (padding) + 10*2 (border) + var pixelWidth = 41 * puzzle.width + 63 + } + var pixelHeight = 41 * puzzle.height + 63 + svg.setAttribute('viewbox', '0 0 ' + pixelWidth + ' ' + pixelHeight) + svg.setAttribute('width', pixelWidth) + svg.setAttribute('height', pixelHeight) + + var rect = createElement('rect') + svg.appendChild(rect) + rect.setAttribute('stroke-width', 10) + rect.setAttribute('stroke', window.BORDER) + rect.setAttribute('fill', window.OUTER_BACKGROUND) + // Accounting for the border thickness + rect.setAttribute('x', 5) + rect.setAttribute('y', 5) + rect.setAttribute('width', pixelWidth - 10) // Removing border + rect.setAttribute('height', pixelHeight - 10) // Removing border + + drawCenters(puzzle, svg) + drawGrid(puzzle, svg, target) + drawStartAndEnd(puzzle, svg) + // Draw cell symbols after so they overlap the lines, if necessary + drawSymbols(puzzle, svg, target) + + // For pillar puzzles, add faders for the left and right sides + if (puzzle.pillar === true) { + var defs = window.createElement('defs') + defs.id = 'cursorPos' + defs.innerHTML = '' + + '\n' + + ' \n' + + ' \n' + + ' \n' + + '\n' + + '\n' + + ' \n' + + ' \n' + + '\n' + svg.appendChild(defs) + + var leftBox = window.createElement('rect') + leftBox.setAttribute('x', 16) + leftBox.setAttribute('y', 10) + leftBox.setAttribute('width', 48) + leftBox.setAttribute('height', 41 * puzzle.height + 43) + leftBox.setAttribute('fill', 'url(#fadeInLeft)') + leftBox.setAttribute('style', 'pointer-events: none') + svg.appendChild(leftBox) + + var rightBox = window.createElement('rect') + rightBox.setAttribute('x', 41 * puzzle.width + 22) + rightBox.setAttribute('y', 10) + rightBox.setAttribute('width', 30) + rightBox.setAttribute('height', 41 * puzzle.height + 43) + rightBox.setAttribute('fill', 'url(#fadeOutRight)') + rightBox.setAttribute('style', 'pointer-events: none') + svg.appendChild(rightBox) + } +} + +function drawCenters(puzzle, svg) { + // @Hack that I am not fixing. This switches the puzzle's grid to a floodfilled grid + // where null represents cells which are part of the outside + var savedGrid = puzzle.switchToMaskedGrid() + if (puzzle.pillar === true) { + for (var y=1; y 1) { + // Add rounding for other intersections (handling gap-only corners) + var circ = createElement('circle') + circ.setAttribute('cx', x*41 + 52) + circ.setAttribute('cy', y*41 + 52) + circ.setAttribute('r', 12) + circ.setAttribute('fill', window.FOREGROUND) + svg.appendChild(circ) + } + } + } + } + // Determine if left-side needs a 'wrap indicator' + if (puzzle.pillar === true) { + var x = 0; + for (var y=0; y window.DOT_NONE) { + params.type = 'dot' + if (cell.dot === window.DOT_BLACK) params.color = 'black' + else if (cell.dot === window.DOT_BLUE) params.color = window.LINE_PRIMARY + else if (cell.dot === window.DOT_YELLOW) params.color = window.LINE_SECONDARY + else if (cell.dot === window.DOT_INVISIBLE) { + params.color = window.FOREGROUND + // This makes the invisible dots visible, but only while we're in the editor. + if (document.getElementById('metaButtons') != null) { + params.stroke = 'black' + params.strokeWidth = '2px' + } + } + drawSymbolWithSvg(svg, params) + } else if (cell.gap === window.GAP_BREAK) { + // Gaps were handled above, while drawing the grid. + } else if (x%2 === 1 && y%2 === 1) { + // Generic draw for all other elements + Object.assign(params, cell) + window.drawSymbolWithSvg(svg, params, puzzle.settings.CUSTOM_MECHANICS) + } + } + } +} + +function drawStartAndEnd(puzzle, svg) { + for (var x=0; x= 4 || y >= 4) return false + return (polyshape & mask(x, y)) !== 0 +} + +// This is 2^20, whereas all the other bits fall into 2^(0-15) +window.ROTATION_BIT = (1 << 20) + +window.isRotated = function(polyshape) { + return (polyshape & ROTATION_BIT) !== 0 +} + +function getRotations(polyshape) { + if (!isRotated(polyshape)) return [polyshape] + + var rotations = [0, 0, 0, 0] + for (var x=0; x<4; x++) { + for (var y=0; y<4; y++) { + if (isSet(polyshape, x, y)) { + rotations[0] ^= mask(x, y) + rotations[1] ^= mask(y, 3-x) + rotations[2] ^= mask(3-x, 3-y) + rotations[3] ^= mask(3-y, x) + } + } + } + + return rotations +} + +// 90 degree rotations of the polyomino +window.rotatePolyshape = function(polyshape, count=1) { + var rotations = getRotations(polyshape | window.ROTATION_BIT) + return rotations[count % 4] +} + +// IMPORTANT NOTE: When formulating these, the top row must contain (0, 0) +// That means there will never be any negative y values. +// (0, 0) must also be a cell in the shape, so that +// placing the shape at (x, y) will fill (x, y) +// Ylops will have -1s on all adjacent cells, to break "overlaps" for polyominos. +window.polyominoFromPolyshape = function(polyshape, ylop=false, precise=true) { + var topLeft = null + for (var y=0; y<4; y++) { + for (var x=0; x<4; x++) { + if (isSet(polyshape, x, y)) { + topLeft = {'x':x, 'y':y} + break + } + } + if (topLeft != null) break + } + if (topLeft == null) return [] // Empty polyomino + + var polyomino = [] + for (var x=0; x<4; x++) { + for (var y=0; y<4; y++) { + if (!isSet(polyshape, x, y)) continue + polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y)}) + + // "Precise" polyominos adds cells in between the apparent squares in the polyomino. + // This prevents the solution line from going through polyominos in the solution. + if (precise) { + if (ylop) { + // Ylops fill up/left if no adjacent cell, and always fill bottom/right + if (!isSet(polyshape, x - 1, y)) { + polyomino.push({'x':2*(x - topLeft.x) - 1, 'y':2*(y - topLeft.y)}) + } + if (!isSet(polyshape, x, y - 1)) { + polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y) - 1}) + } + polyomino.push({'x':2*(x - topLeft.x) + 1, 'y':2*(y - topLeft.y)}) + polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y) + 1}) + } else { + // Normal polys only fill bottom/right if there is an adjacent cell. + if (isSet(polyshape, x + 1, y)) { + polyomino.push({'x':2*(x - topLeft.x) + 1, 'y':2*(y - topLeft.y)}) + } + if (isSet(polyshape, x, y + 1)) { + polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y) + 1}) + } + } + } + } + } + return polyomino +} + +window.polyshapeFromPolyomino = function(polyomino) { + var topLeft = {'x': 9999, 'y': 9999} + for (var pos of polyomino) { + if (pos.x%2 != 1 || pos.y%2 != 1) continue // We only care about cells, not edges or intersections + + // Unlike when we're making a polyomino, we just want to top and left flush the shape, + // we don't actually need (0, 0) to be filled. + if (pos.x < topLeft.x) topLeft.x = pos.x + if (pos.y < topLeft.y) topLeft.y = pos.y + } + if (topLeft == null) return 0 // Empty polyomino + + var polyshape = 0 + for (var pos of polyomino) { + if (pos.x%2 != 1 || pos.y%2 != 1) continue // We only care about cells, not edges or intersections + var x = (pos.x - topLeft.x) / 2 // 0.5x to convert from puzzle coordinates to polyshape coordinates + var y = (pos.y - topLeft.y) / 2 // 0.5x to convert from puzzle coordinates to polyshape coordinates + polyshape |= mask(x, y) + } + + return polyshape +} + +// In some cases, polyominos and onimoylops will fully cancel each other out. +// However, even if they are the same size, that doesn't guarantee that they fit together. +// As an optimization, we save the results for known combinations of shapes, since there are likely many +// fewer pairings of shapes than paths through the grid. +var knownCancellations = {} + +// Attempt to fit polyominos in a region into the puzzle. +// This function checks for early exits, then simplifies the grid to a numerical representation: +// * 1 represents a square that has been double-covered (by two polyominos) +// * Or, in the cancellation case, it represents a square that was covered by a polyomino and not by an onimoylop +// * 0 represents a square that is satisfied, either because: +// * it is outside the region +// * (In the normal case) it was inside the region, and has been covered by a polyomino +// * (In the cancellation case) it was covered by an equal number of polyominos and onimoylops +// * -1 represents a square that needs to be covered once (inside the region, or outside but covered by an onimoylop) +// * -2 represents a square that needs to be covered twice (inside the region & covered by an onimoylop) +// * And etc, for additional layers of polyominos/onimoylops. +window.polyFit = function(region, puzzle) { + var polys = [] + var ylops = [] + var polyCount = 0 + var regionSize = 0 + for (var pos of region) { + if (pos.x%2 === 1 && pos.y%2 === 1) regionSize++ + var cell = puzzle.grid[pos.x][pos.y] + if (cell == null) continue + if (cell.polyshape === 0) continue + if (cell.type === 'poly') { + polys.push(cell) + polyCount += getPolySize(cell.polyshape) + } else if (cell.type === 'ylop') { + ylops.push(cell) + polyCount -= getPolySize(cell.polyshape) + } + } + if (polys.length + ylops.length === 0) { + console.log('No polyominos or onimoylops inside the region, vacuously true') + return true + } + if (polyCount > 0 && polyCount !== regionSize) { + console.log('Combined size of polyominos and onimoylops', polyCount, 'does not match region size', regionSize) + return false + } + if (polyCount < 0) { + console.log('Combined size of onimoylops is greater than polyominos by', -polyCount) + return false + } + var key = null + if (polyCount === 0) { + if (puzzle.settings.SHAPELESS_ZERO_POLY) { + console.log('Combined size of polyominos and onimoylops is zero') + return true + } + // These will be ordered by the order of cells in the region, which isn't exactly consistent. + // In practice, it seems to be good enough. + key = '' + for (var ylop of ylops) key += ' ' + ylop.polyshape + key += '|' + for (var poly of polys) key += ' ' + poly.polyshape + var ret = knownCancellations[key] + if (ret != null) return ret + } + + // For polyominos, we clear the grid to mark it up again: + var savedGrid = puzzle.grid + puzzle.newGrid() + // First, we mark all cells as 0: Cells outside the target region should be unaffected. + for (var x=0; x 0) { + for (var pos of region) puzzle.grid[pos.x][pos.y] = -1 + } + // In the exact match case, we leave every cell marked 0: Polys and ylops need to cancel. + + var ret = placeYlops(ylops, 0, polys, puzzle) + if (polyCount === 0) knownCancellations[key] = ret + puzzle.grid = savedGrid + return ret +} + +// If false, poly doesn't fit and grid is unmodified +// If true, poly fits and grid is modified (with the placement) +function tryPlacePolyshape(cells, x, y, puzzle, sign) { + console.spam('Placing at', x, y, 'with sign', sign) + var numCells = cells.length + for (var i=0; i 0) { + console.log('Cell', x, y, 'has been overfilled and no ylops left to place') + return false + } + if (allPolysPlaced && cell < 0 && x%2 === 1 && y%2 === 1) { + // Normal, center cell with a negative value & no polys remaining. + console.log('All polys placed, but grid not full') + return false + } + } + } + if (allPolysPlaced) { + console.log('All polys placed, and grid full') + return true + } + + // The top-left (first open cell) must be filled by a polyomino. + // However in the case of pillars, there is no top-left, so we try all open cells in the + // top-most open row + var openCells = [] + for (var y=1; y= 0) continue + openCells.push({'x':x, 'y':y}) + if (puzzle.pillar === false) break + } + if (openCells.length > 0) break + } + + if (openCells.length === 0) { + console.log('Polys remaining but grid full') + return false + } + + for (var openCell of openCells) { + var attemptedPolyshapes = [] + for (var i=0; i0 polys, but no valid recursion.') + return false +} + +}) diff --git a/app/assets/javascripts/puzzle.js b/app/assets/javascripts/puzzle.js new file mode 100644 index 0000000..4889e96 --- /dev/null +++ b/app/assets/javascripts/puzzle.js @@ -0,0 +1,538 @@ +namespace(function() { + +// A 2x2 grid is internally a 5x5: +// corner, edge, corner, edge, corner +// edge, cell, edge, cell, edge +// corner, edge, corner, edge, corner +// edge, cell, edge, cell, edge +// corner, edge, corner, edge, corner +// +// Corners and edges will have a value of true if the line passes through them +// Cells will contain an object if there is an element in them +window.Puzzle = class { + constructor(width, height, pillar=false) { + if (pillar === true) { + this.newGrid(2 * width, 2 * height + 1) + } else { + this.newGrid(2 * width + 1, 2 * height + 1) + } + this.pillar = pillar + this.settings = { + // If true, negation symbols are allowed to cancel other negation symbols. + NEGATIONS_CANCEL_NEGATIONS: true, + + // If true, and the count of polyominos and onimoylops is zero, they cancel regardless of shape. + SHAPELESS_ZERO_POLY: false, + + // If true, the traced line cannot go through the placement of a polyomino. + PRECISE_POLYOMINOS: true, + + // If false, incorrect elements will not flash when failing the puzzle. + FLASH_FOR_ERRORS: true, + + // If true, mid-segment startpoints will constitute solid lines, and form boundaries for the region. + FAT_STARTPOINTS: false, + + // If true, custom mechanics are displayed (and validated) in this puzzle. + CUSTOM_MECHANICS: false, + + // If true, polyominos may be placed partially off of the grid as an intermediate solution step. + // OUT_OF_BOUNDS_POLY: false, + + // If true, the symmetry line will be invisible. + INVISIBLE_SYMMETRY: false, + } + } + + static deserialize(json) { + var parsed = JSON.parse(json) + // Claim that it's not a pillar (for consistent grid sizing), then double-check ourselves later. + var puzzle = new Puzzle((parsed.grid.length - 1)/2, (parsed.grid[0].length - 1)/2) + puzzle.name = parsed.name + puzzle.autoSolved = parsed.autoSolved + puzzle.grid = parsed.grid + // Legacy: Grid squares used to use 'false' to indicate emptiness. + // Legacy: Cells may use {} to represent emptiness + // Now, we use: + // Cells default to null + // During onTraceStart, empty cells that are still inbounds are changed to {'type': 'nonce'} for tracing purposes. + // Lines default to {'type':'line', 'line':0} + for (var x=0; x= this.width) return false + if (y < 0 || y >= this.height) return false + return true + } + + getCell(x, y) { + x = this._mod(x) + if (!this._safeCell(x, y)) return null + return this.grid[x][y] + } + + setCell(x, y, value) { + x = this._mod(x) + if (!this._safeCell(x, y)) return + this.grid[x][y] = value + } + + getSymmetricalDir(dir) { + if (this.symType == SYM_TYPE_VERTICAL || this.symType == SYM_TYPE_ROTATIONAL || this.symType == SYM_TYPE_PARALLEL_H_FLIP) { + if (dir === 'left') return 'right' + if (dir === 'right') return 'left' + } + if (this.symType == SYM_TYPE_HORIZONTAL || this.symType == SYM_TYPE_ROTATIONAL || this.symType == SYM_TYPE_PARALLEL_V_FLIP) { + if (dir === 'top') return 'bottom' + if (dir === 'bottom') return 'top' + } + if (this.symType == SYM_TYPE_ROTATE_LEFT || this.symType == SYM_TYPE_FLIP_NEG_XY) { + if (dir === 'left') return 'bottom' + if (dir === 'right') return 'top' + } + if (this.symType == SYM_TYPE_ROTATE_RIGHT || this.symType == SYM_TYPE_FLIP_NEG_XY) { + if (dir === 'top') return 'right' + if (dir === 'bottom') return 'left' + } + if (this.symType == SYM_TYPE_ROTATE_LEFT || this.symType == SYM_TYPE_FLIP_XY) { + if (dir === 'top') return 'left' + if (dir === 'bottom') return 'right' + } + if (this.symType == SYM_TYPE_ROTATE_RIGHT || this.symType == SYM_TYPE_FLIP_XY) { + if (dir === 'right') return 'bottom' + if (dir === 'left') return 'top' + } + return dir + } + + // The resulting position is guaranteed to be gridsafe. + getSymmetricalPos(x, y) { + var origx = x + var origy = y + + if (this.symType == SYM_TYPE_VERTICAL || this.symType == SYM_TYPE_ROTATIONAL || this.symType == SYM_TYPE_PARALLEL_H_FLIP) { + x = (this.width - 1) - origx + } + if (this.symType == SYM_TYPE_HORIZONTAL || this.symType == SYM_TYPE_ROTATIONAL || this.symType == SYM_TYPE_PARALLEL_V_FLIP) { + y = (this.height - 1) - origy + } + if (this.symType == SYM_TYPE_ROTATE_LEFT || this.symType == SYM_TYPE_FLIP_XY) { + x = origy + } + if (this.symType == SYM_TYPE_ROTATE_RIGHT || this.symType == SYM_TYPE_FLIP_XY) { + y = origx + } + if (this.symType == SYM_TYPE_ROTATE_LEFT || this.symType == SYM_TYPE_FLIP_NEG_XY) { + y = (this.width - 1) - origx + } + if (this.symType == SYM_TYPE_ROTATE_RIGHT || this.symType == SYM_TYPE_FLIP_NEG_XY) { + x = (this.height - 1) - origy + } + if (this.symType == SYM_TYPE_PARALLEL_H || this.symType == SYM_TYPE_PARALLEL_H_FLIP) { + y = (origy == this.height / 2) ? (this.height / 2) : ((origy + (this.height + 1) / 2) % (this.height + 1)) + } + if (this.symType == SYM_TYPE_PARALLEL_V || this.symType == SYM_TYPE_PARALLEL_V_FLIP) { + x = (origx == this.width / 2) ? (this.width / 2) : ((origx + (this.width + 1) / 2) % (this.width + 1)) + } + + return {'x':this._mod(x), 'y':y} + } + + getSymmetricalCell(x, y) { + var pos = this.getSymmetricalPos(x, y) + return this.getCell(pos.x, pos.y) + } + + matchesSymmetricalPos(x1, y1, x2, y2) { + return (y1 === y2 && this._mod(x1) === x2) + } + + // A variant of getCell which specifically returns line values, + // and treats objects as being out-of-bounds + getLine(x, y) { + var cell = this.getCell(x, y) + if (cell == null) return null + if (cell.type !== 'line') return null + return cell.line + } + + updateCell2(x, y, key, value) { + x = this._mod(x) + if (!this._safeCell(x, y)) return + var cell = this.grid[x][y] + if (cell == null) return + cell[key] = value + } + + getValidEndDirs(x, y) { + x = this._mod(x) + if (!this._safeCell(x, y)) return [] + + var dirs = [] + var leftCell = this.getCell(x - 1, y) + if (leftCell == null || leftCell.gap === window.GAP_FULL) dirs.push('left') + var topCell = this.getCell(x, y - 1) + if (topCell == null || topCell.gap === window.GAP_FULL) dirs.push('top') + var rightCell = this.getCell(x + 1, y) + if (rightCell == null || rightCell.gap === window.GAP_FULL) dirs.push('right') + var bottomCell = this.getCell(x, y + 1) + if (bottomCell == null || bottomCell.gap === window.GAP_FULL) dirs.push('bottom') + return dirs + } + + // Note: Does not use this.width/this.height, so that it may be used to ask about resizing. + getSizeError(width, height) { + if (this.pillar && width < 4) return 'Pillars may not have a width of 1' + if (width * height < 25) return 'Puzzles may not be smaller than 2x2 or 1x4' + if (width > 21 || height > 21) return 'Puzzles may not be larger than 10 in either dimension' + if (this.symmetry != null) { + if (this.symmetry.x && width <= 2) return 'Symmetrical puzzles must be sufficiently wide for both lines' + if (this.symmetry.y && height <= 2) return 'Symmetrical puzzles must be sufficiently wide for both lines' + if (this.pillar && this.symmetry.x && width % 4 !== 0) return 'X + Pillar symmetry must be an even number of rows, to keep both startpoints at the same parity' + } + + return null + } + + + // Called on a solution. Computes a list of gaps to show as hints which *do not* + // break the path. + loadHints() { + this.hints = [] + for (var x=0; x window.LINE_NONE) { + this.hints.push({'x':x, 'y':y}) + } + } + } + } + + // Show a hint on the grid. + // If no hint is provided, will select the best one it can find, + // prioritizing breaking current lines on the grid. + // Returns the shown hint. + showHint(hint) { + if (hint != null) { + this.grid[hint.x][hint.y].gap = window.GAP_BREAK + return + } + + var goodHints = [] + var badHints = [] + + for (var hint of this.hints) { + if (this.getLine(hint.x, hint.y) > window.LINE_NONE) { + // Solution will be broken by this hint + goodHints.push(hint) + } else { + badHints.push(hint) + } + } + if (goodHints.length > 0) { + var hint = goodHints.splice(window.randInt(goodHints.length), 1)[0] + } else if (badHints.length > 0) { + var hint = badHints.splice(window.randInt(badHints.length), 1)[0] + } else { + return + } + this.grid[hint.x][hint.y].gap = window.GAP_BREAK + this.hints = badHints.concat(goodHints) + return hint + } + + clearLines() { + for (var x=0; x 0) this._floodFill(x, y - 1, region, col) + if (x < this.width - 1) this._floodFill(x + 1, y, region, this.grid[x+1]) + else if (this.pillar !== false) this._floodFill(0, y, region, this.grid[0]) + if (x > 0) this._floodFill(x - 1, y, region, this.grid[x-1]) + else if (this.pillar !== false) this._floodFill(this.width-1, y, region, this.grid[this.width-1]) + } + + // Re-uses the same grid, but only called on edges which border the outside + // Called first to mark cells that are connected to the outside, i.e. should not be part of any region. + _floodFillOutside(x, y, col) { + var cell = col[y] + if (cell === MASKED_PROCESSED) return + if (x%2 !== y%2 && cell !== MASKED_GAP2) return // Only flood-fill through gap-2 + if (x%2 === 0 && y%2 === 0 && cell === MASKED_DOT) return // Don't flood-fill through dots + col[y] = MASKED_PROCESSED + + if (x%2 === 0 && y%2 === 0) return // Don't flood fill through corners (what? Clarify.) + + if (y < this.height - 1) this._floodFillOutside(x, y + 1, col) + if (y > 0) this._floodFillOutside(x, y - 1, col) + if (x < this.width - 1) this._floodFillOutside(x + 1, y, this.grid[x+1]) + else if (this.pillar !== false) this._floodFillOutside(0, y, this.grid[0]) + if (x > 0) this._floodFillOutside(x - 1, y, this.grid[x-1]) + else if (this.pillar !== false) this._floodFillOutside(this.width-1, y, this.grid[this.width-1]) + } + + // Returns the original grid (pre-masking). You will need to switch back once you are done flood filling. + switchToMaskedGrid() { + // Make a copy of the grid -- we will be overwriting it + var savedGrid = this.grid + this.grid = new Array(this.width) + // Override all elements with empty lines -- this means that flood fill is just + // looking for lines with line=0. + for (var x=0; x window.LINE_NONE) { + row[y] = MASKED_PROCESSED // Traced lines should not be a part of the region + } else if (cell.gap === window.GAP_FULL) { + row[y] = MASKED_GAP2 + } else if (cell.dot > window.DOT_NONE) { + row[y] = MASKED_DOT + } else { + row[y] = MASKED_INB_COUNT + } + } + this.grid[x] = row + } + + // Starting at a mid-segment startpoint + if (this.startPoint != null && this.startPoint.x%2 !== this.startPoint.y%2) { + if (this.settings.FAT_STARTPOINTS) { + // This segment is not in any region (acts as a barrier) + this.grid[this.startPoint.x][this.startPoint.y] = MASKED_OOB + } else { + // This segment is part of this region (acts as an empty cell) + this.grid[this.startPoint.x][this.startPoint.y] = MASKED_INB_NONCOUNT + } + } + + // Ending at a mid-segment endpoint + if (this.endPoint != null && this.endPoint.x%2 !== this.endPoint.y%2) { + // This segment is part of this region (acts as an empty cell) + this.grid[this.endPoint.x][this.endPoint.y] = MASKED_INB_NONCOUNT + } + + // Mark all outside cells as 'not in any region' (aka null) + + // Top and bottom edges + for (var x=1; x 0) row[x] = ' ' + if (cell.dot > 0) row[x] = 'X' + if (cell.line === 0) row[x] = '.' + if (cell.line === 1) row[x] = '#' + if (cell.line === 2) row[x] = '#' + if (cell.line === 3) row[x] = 'o' + } else row[x] = '?' + } + output += row.join('') + '\n' + } + console.info(output) + } +} + +// The grid contains 5 colors: +// null: Out of bounds or already processed +var MASKED_OOB = null +var MASKED_PROCESSED = null +// 0: In bounds, awaiting processing, but should not be part of the final region. +var MASKED_INB_NONCOUNT = 0 +// 1: In bounds, awaiting processing +var MASKED_INB_COUNT = 1 +// 2: Gap-2. After _floodFillOutside, this means "treat normally" (it will be null if oob) +var MASKED_GAP2 = 2 +// 3: Dot (of any kind), otherwise identical to 1. Should not be flood-filled through (why the f do we need this) +var MASKED_DOT = 3 + +}) diff --git a/app/assets/javascripts/serializer.js b/app/assets/javascripts/serializer.js new file mode 100644 index 0000000..70c7f0f --- /dev/null +++ b/app/assets/javascripts/serializer.js @@ -0,0 +1,365 @@ +namespace(function() { + +window.serializePuzzle = function(puzzle) { + var s = new Serializer('w') + var version = 0 + + s.writeInt(version) + s.writeByte(puzzle.width) + s.writeByte(puzzle.height) + s.writeString(puzzle.name) + + var genericFlags = 0 + if (puzzle.autoSolved) genericFlags |= GENERIC_FLAG_AUTOSOLVED + if (puzzle.symmetry) { + genericFlags |= GENERIC_FLAG_SYMMETRICAL + if (puzzle.symmetry.x) genericFlags |= GENERIC_FLAG_SYMMETRY_X + if (puzzle.symmetry.y) genericFlags |= GENERIC_FLAG_SYMMETRY_Y + } + if (puzzle.pillar) genericFlags |= GENERIC_FLAG_PILLAR + s.writeByte(genericFlags) + for (var x=0; x 0) { + s.writeInt(puzzle.path.length) + s.writeByte(startPos.x) + s.writeByte(startPos.y) + for (var dir of puzzle.path) s.writeByte(dir) + } + } else { + s.writeInt(0) + } + + var settingsFlags = 0 + if (puzzle.settings.NEGATIONS_CANCEL_NEGATIONS) settingsFlags |= SETTINGS_FLAG_NCN + if (puzzle.settings.SHAPELESS_ZERO_POLY) settingsFlags |= SETTINGS_FLAG_SZP + if (puzzle.settings.PRECISE_POLYOMINOS) settingsFlags |= SETTINGS_FLAG_PP + if (puzzle.settings.FLASH_FOR_ERRORS) settingsFlags |= SETTINGS_FLAG_FFE + if (puzzle.settings.FAT_STARTPOINTS) settingsFlags |= SETTINGS_FLAG_FS + if (puzzle.settings.CUSTOM_MECHANICS) settingsFlags |= SETTINGS_FLAG_CM + if (puzzle.settings.INVISIBLE_SYMMETRY) settingsFlags |= SETTINGS_FLAG_IS + s.writeByte(settingsFlags) + + s.writeByte(puzzle.symType) + + return s.str() +} + +window.deserializePuzzle = function(data) { + // Data is JSON, so decode it with the old deserializer + if (data[0] == '{') return Puzzle.deserialize(data) + + var s = new Serializer('r', data) + var version = s.readInt() + if (version > 0) throw Error('Cannot read data from unknown version: ' + version) + + var width = s.readByte() + var height = s.readByte() + var puzzle = new Puzzle(Math.floor(width / 2), Math.floor(height / 2)) + puzzle.name = s.readString() + + var genericFlags = s.readByte() + puzzle.autoSolved = genericFlags & GENERIC_FLAG_AUTOSOLVED + puzzle.symType = SYM_TYPE_NONE + if ((genericFlags & GENERIC_FLAG_SYMMETRICAL) != 0) { + if ((genericFlags & GENERIC_FLAG_SYMMETRY_X) != 0) { + if ((genericFlags & GENERIC_FLAG_SYMMETRY_Y) != 0) { + puzzle.symType = SYM_TYPE_ROTATIONAL + } else { + puzzle.symType = SYM_TYPE_VERTICAL + } + } else if ((genericFlags & GENERIC_FLAG_SYMMETRY_Y) != 0) { + puzzle.symType = SYM_TYPE_HORIZONTAL + } + } + puzzle.pillar = (genericFlags & GENERIC_FLAG_PILLAR) != 0 + for (var x=0; x 0) { + var path = [{ + 'x': s.readByte(), + 'y': s.readByte(), + }] + for (var i=0; i= numBytes) + } + + readByte() { + this._checkRead() + return this.data.charCodeAt(this.index++) + } + + writeByte(b) { + if (b < 0 || b > 0xFF) throw Error('Cannot write out-of-range byte ' + b) + this.data += String.fromCharCode(b) + } + + readInt() { + var b1 = this.readByte() << 0 + var b2 = this.readByte() << 8 + var b3 = this.readByte() << 16 + var b4 = this.readByte() << 24 + return b1 | b2 | b3 | b4 + } + + writeInt(i) { + if (i < 0 || i > 0xFFFFFFFF) throw Error('Cannot write out-of-range int ' + i) + var b1 = (i & 0x000000FF) >> 0 + var b2 = (i & 0x0000FF00) >> 8 + var b3 = (i & 0x00FF0000) >> 16 + var b4 = (i & 0xFF000000) >> 24 + this.writeByte(b1) + this.writeByte(b2) + this.writeByte(b3) + this.writeByte(b4) + } + + readLong() { + var i1 = this.readInt() << 32 + var i2 = this.readInt() + return i1 | i2 + } + + writeLong(l) { + if (l < 0 || l > 0xFFFFFFFFFFFFFFFF) throw Error('Cannot write out-of-range long ' + l) + var i1 = l & 0xFFFFFFFF + var i2 = (l - i1) / 0x100000000 + this.writeInt(i1) + this.writeInt(i2) + } + + readString() { + var len = this.readInt() + this._checkRead(len) + var str = this.data.substr(this.index, len) + this.index += len + return str + } + + writeString(s) { + if (s == null) { + this.writeInt(0) + return + } + this.writeInt(s.length) + this.data += s + } + + readColor() { + var r = this.readByte().toString() + var g = this.readByte().toString() + var b = this.readByte().toString() + var a = this.readByte().toString() + return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + a + ')' + } + + writeColor(c) { + // Adapted from https://gist.github.com/njvack/02ad8efcb0d552b0230d + this.colorConverter.fillStyle = 'rgba(0, 0, 0, 0)' // Load a default in case we are passed garbage + this.colorConverter.clearRect(0, 0, 1, 1) + this.colorConverter.fillStyle = c + this.colorConverter.fillRect(0, 0, 1, 1) + var rgba = this.colorConverter.getImageData(0, 0, 1, 1).data + this.writeByte(rgba[0]) + this.writeByte(rgba[1]) + this.writeByte(rgba[2]) + this.writeByte(rgba[3]) + } + + readCell() { + var cellType = this.readByte() + if (cellType === CELL_TYPE_NULL) return null + + var cell = {} + cell.dir = null + cell.line = 0 + if (cellType === CELL_TYPE_LINE) { + cell.type = 'line' + cell.line = this.readByte() + var dot = this.readByte() + if (dot != 0) cell.dot = dot + var gap = this.readByte() + if (gap != 0) cell.gap = gap + } else if (cellType === CELL_TYPE_SQUARE) { + cell.type = 'square' + cell.color = this.readColor() + } else if (cellType === CELL_TYPE_STAR) { + cell.type = 'star' + cell.color = this.readColor() + } else if (cellType === CELL_TYPE_NEGA) { + cell.type = 'nega' + cell.color = this.readColor() + } else if (cellType === CELL_TYPE_TRIANGLE) { + cell.type = 'triangle' + cell.color = this.readColor() + cell.count = this.readByte() + } else if (cellType === CELL_TYPE_POLY) { + cell.type = 'poly' + cell.color = this.readColor() + cell.polyshape = this.readLong() + } else if (cellType === CELL_TYPE_YLOP) { + cell.type = 'ylop' + cell.color = this.readColor() + cell.polyshape = this.readLong() + } else if (cellType == CELL_TYPE_NONCE) { + cell.type = 'nonce' + } + + var startEnd = this.readByte() + if (startEnd & CELL_START) cell.start = true + if (startEnd & CELL_END_LEFT) cell.end = 'left' + if (startEnd & CELL_END_RIGHT) cell.end = 'right' + if (startEnd & CELL_END_TOP) cell.end = 'top' + if (startEnd & CELL_END_BOTTOM) cell.end = 'bottom' + + return cell + } + + + writeCell(cell) { + if (cell == null) { + this.writeByte(CELL_TYPE_NULL) + return + } + + // Write cell type, then cell data, then generic data. + // Note that cell type starts at 1, since 0 is the "null type". + if (cell.type == 'line') { + this.writeByte(CELL_TYPE_LINE) + this.writeByte(cell.line) + this.writeByte(cell.dot) + this.writeByte(cell.gap) + } else if (cell.type == 'square') { + this.writeByte(CELL_TYPE_SQUARE) + this.writeColor(cell.color) + } else if (cell.type == 'star') { + this.writeByte(CELL_TYPE_STAR) + this.writeColor(cell.color) + } else if (cell.type == 'nega') { + this.writeByte(CELL_TYPE_NEGA) + this.writeColor(cell.color) + } else if (cell.type == 'triangle') { + this.writeByte(CELL_TYPE_TRIANGLE) + this.writeColor(cell.color) + this.writeByte(cell.count) + } else if (cell.type == 'poly') { + this.writeByte(CELL_TYPE_POLY) + this.writeColor(cell.color) + this.writeLong(cell.polyshape) + } else if (cell.type == 'ylop') { + this.writeByte(CELL_TYPE_YLOP) + this.writeColor(cell.color) + this.writeLong(cell.polyshape) + } + + var startEnd = 0 + if (cell.start === true) startEnd |= CELL_START + if (cell.end == 'left') startEnd |= CELL_END_LEFT + if (cell.end == 'right') startEnd |= CELL_END_RIGHT + if (cell.end == 'top') startEnd |= CELL_END_TOP + if (cell.end == 'bottom') startEnd |= CELL_END_BOTTOM + this.writeByte(startEnd) + } +} + +var CELL_TYPE_NULL = 0 +var CELL_TYPE_LINE = 1 +var CELL_TYPE_SQUARE = 2 +var CELL_TYPE_STAR = 3 +var CELL_TYPE_NEGA = 4 +var CELL_TYPE_TRIANGLE = 5 +var CELL_TYPE_POLY = 6 +var CELL_TYPE_YLOP = 7 +var CELL_TYPE_NONCE = 8 + +var CELL_START = 1 +var CELL_END_LEFT = 2 +var CELL_END_RIGHT = 4 +var CELL_END_TOP = 8 +var CELL_END_BOTTOM = 16 + +var GENERIC_FLAG_AUTOSOLVED = 1 +var GENERIC_FLAG_SYMMETRICAL = 2 +var GENERIC_FLAG_SYMMETRY_X = 4 +var GENERIC_FLAG_SYMMETRY_Y = 8 +var GENERIC_FLAG_PILLAR = 16 + +var SETTINGS_FLAG_NCN = 1 +var SETTINGS_FLAG_SZP = 2 +var SETTINGS_FLAG_PP = 4 +var SETTINGS_FLAG_FFE = 8 +var SETTINGS_FLAG_FS = 16 +var SETTINGS_FLAG_CM = 32 +var SETTINGS_FLAG_IS = 64 + +}) diff --git a/app/assets/javascripts/solve.js b/app/assets/javascripts/solve.js new file mode 100644 index 0000000..8695291 --- /dev/null +++ b/app/assets/javascripts/solve.js @@ -0,0 +1,531 @@ +namespace(function() { + +// @Volatile -- must match order of MOVE_* in trace2 +// Move these, dummy. +var PATH_NONE = 0 +var PATH_LEFT = 1 +var PATH_RIGHT = 2 +var PATH_TOP = 3 +var PATH_BOTTOM = 4 + +window.MAX_SOLUTIONS = 0 +var solutionPaths = [] +var asyncTimer = 0 +var task = null +var puzzle = null +var path = [] +var SOLVE_SYNC = false +var SYNC_THRESHOLD = 9 // Depth at which we switch to a synchronous solver (for perf) +var doPruning = false + +var percentages = [] +var NODE_DEPTH = 9 +var nodes = 0 +function countNodes(x, y, depth) { + // Check for collisions (outside, gap, self, other) + var cell = puzzle.getCell(x, y) + if (cell == null) return + if (cell.gap > window.GAP_NONE) return + if (cell.line !== window.LINE_NONE) return + + if (puzzle.symType == SYM_TYPE_NONE) { + puzzle.updateCell2(x, y, 'line', window.LINE_BLACK) + } else { + var sym = puzzle.getSymmetricalPos(x, y) + if (puzzle.matchesSymmetricalPos(x, y, sym.x, sym.y)) return // Would collide with our reflection + + var symCell = puzzle.getCell(sym.x, sym.y) + if (symCell.gap > window.GAP_NONE) return + + puzzle.updateCell2(x, y, 'line', window.LINE_BLUE) + puzzle.updateCell2(sym.x, sym.y, 'line', window.LINE_YELLOW) + } + + if (depth < NODE_DEPTH) { + nodes++ + + if (y%2 === 0) { + countNodes(x - 1, y, depth + 1) + countNodes(x + 1, y, depth + 1) + } + + if (x%2 === 0) { + countNodes(x, y - 1, depth + 1) + countNodes(x, y + 1, depth + 1) + } + } + + tailRecurse(x, y) +} + +// Generates a solution via DFS recursive backtracking +window.solve = function(p, partialCallback, finalCallback) { + if (task != null) throw Error('Cannot start another solve() while one is already in progress') + var start = (new Date()).getTime() + + puzzle = p + var startPoints = [] + var numEndpoints = 0 + puzzle.hasNegations = false + puzzle.hasPolyominos = false + for (var x=0; x 0) { + // Tasks are pushed in order. To do DFS, we need to enqueue them in reverse order. + for (var i=newTasks.length - 1; i >= 0; i--) { + task = { + 'code': newTasks[i], + 'nextTask': task, + } + } + } + + // Asynchronizing is expensive. As such, we don't want to do it too often. + // However, we would like 'cancel solving' to be responsive. So, we call setTimeout every so often. + var doAsync = false + if (!SOLVE_SYNC) { + doAsync = (asyncTimer++ % 100 === 0) + while (nodes >= percentages[0]) { + if (partialCallback) partialCallback(100 - percentages.length) + percentages.shift() + doAsync = true + } + } + + if (doAsync) { + setTimeout(function() { + taskLoop(partialCallback, finalCallback) + }, 0) + } else { + taskLoop(partialCallback, finalCallback) + } +} + +function tailRecurse(x, y) { + // Tail recursion: Back out of this cell + puzzle.updateCell2(x, y, 'line', window.LINE_NONE) + if (puzzle.symType != SYM_TYPE_NONE) { + var sym = puzzle.getSymmetricalPos(x, y) + puzzle.updateCell2(sym.x, sym.y, 'line', window.LINE_NONE) + } +} + +// @Performance: This is the most central loop in this code. +// Any performance efforts should be focused here. +// Note: Most mechanics are NP (or harder), so don't feel bad about solving them by brute force. +// https://arxiv.org/pdf/1804.10193.pdf +function solveLoop(x, y, numEndpoints, earlyExitData) { + // Stop trying to solve once we reach our goal + if (solutionPaths.length >= window.MAX_SOLUTIONS) return + + // Check for collisions (outside, gap, self, other) + var cell = puzzle.getCell(x, y) + if (cell == null) return + if (cell.gap > window.GAP_NONE) return + if (cell.line !== window.LINE_NONE) return + + if (puzzle.symType == SYM_TYPE_NONE) { + puzzle.updateCell2(x, y, 'line', window.LINE_BLACK) + } else { + var sym = puzzle.getSymmetricalPos(x, y) + if (puzzle.matchesSymmetricalPos(x, y, sym.x, sym.y)) return // Would collide with our reflection + + var symCell = puzzle.getCell(sym.x, sym.y) + if (symCell.gap > window.GAP_NONE) return + + puzzle.updateCell2(x, y, 'line', window.LINE_BLUE) + puzzle.updateCell2(sym.x, sym.y, 'line', window.LINE_YELLOW) + } + + if (path.length < NODE_DEPTH) nodes++ + + if (cell.end != null) { + path.push(PATH_NONE) + puzzle.endPoint = {'x': x, 'y': y} + var puzzleData = window.validate(puzzle, true) + if (puzzleData.valid()) solutionPaths.push(path.slice()) + path.pop() + + // If there are no further endpoints, tail recurse. + // Otherwise, keep going -- we might be able to reach another endpoint. + numEndpoints-- + if (numEndpoints === 0) return tailRecurse(x, y) + } + + var newEarlyExitData = null + if (doPruning) { + var isEdge = x <= 0 || y <= 0 || x >= puzzle.width - 1 || y >= puzzle.height - 1 + newEarlyExitData = [ + earlyExitData[0] || (!isEdge && earlyExitData[2].isEdge), // Have we ever left an edge? + earlyExitData[2], // The position before our current one + {'x':x, 'y':y, 'isEdge':isEdge} // Our current position. + ] + if (earlyExitData[0] && !earlyExitData[1].isEdge && earlyExitData[2].isEdge && isEdge) { + // See the above comment for an explanation of this math. + var floodX = earlyExitData[2].x + (earlyExitData[1].x - x) + var floodY = earlyExitData[2].y + (earlyExitData[1].y - y) + var region = puzzle.getRegion(floodX, floodY) + if (region != null) { + var regionData = window.validateRegion(puzzle, region, true) + if (!regionData.valid()) return tailRecurse(x, y) + + // Additionally, we might have left an endpoint in the enclosed region. + // If so, we should decrement the number of remaining endpoints (and possibly tail recurse). + for (var pos of region) { + var endCell = puzzle.grid[pos.x][pos.y] + if (endCell != null && endCell.end != null) numEndpoints-- + } + + if (numEndpoints === 0) return tailRecurse(x, y) + } + } + } + + if (SOLVE_SYNC || path.length > SYNC_THRESHOLD) { + path.push(PATH_NONE) + + // Recursion order (LRUD) is optimized for BL->TR and mid-start puzzles + if (y%2 === 0) { + path[path.length-1] = PATH_LEFT + solveLoop(x - 1, y, numEndpoints, newEarlyExitData) + + path[path.length-1] = PATH_RIGHT + solveLoop(x + 1, y, numEndpoints, newEarlyExitData) + } + + if (x%2 === 0) { + path[path.length-1] = PATH_TOP + solveLoop(x, y - 1, numEndpoints, newEarlyExitData) + + path[path.length-1] = PATH_BOTTOM + solveLoop(x, y + 1, numEndpoints, newEarlyExitData) + } + + path.pop() + tailRecurse(x, y) + + } else { + // Push a dummy element on the end of the path, so that we can fill it correctly as we DFS. + // This element is popped when we tail recurse (which always happens *after* all of our DFS!) + path.push(PATH_NONE) + + // Recursion order (LRUD) is optimized for BL->TR and mid-start puzzles + var newTasks = [] + if (y%2 === 0) { + newTasks.push(function() { + path[path.length-1] = PATH_LEFT + return solveLoop(x - 1, y, numEndpoints, newEarlyExitData) + }) + newTasks.push(function() { + path[path.length-1] = PATH_RIGHT + return solveLoop(x + 1, y, numEndpoints, newEarlyExitData) + }) + } + + if (x%2 === 0) { + newTasks.push(function() { + path[path.length-1] = PATH_TOP + return solveLoop(x, y - 1, numEndpoints, newEarlyExitData) + }) + newTasks.push(function() { + path[path.length-1] = PATH_BOTTOM + return solveLoop(x, y + 1, numEndpoints, newEarlyExitData) + }) + } + + newTasks.push(function() { + path.pop() + tailRecurse(x, y) + }) + + return newTasks + } +} + +window.cancelSolving = function() { + console.info('Cancelled solving') + window.MAX_SOLUTIONS = 0 // Causes all new solveLoop calls to exit immediately. + tasks = [] +} + +// Only modifies the puzzle object (does not do any graphics updates). Used by metapuzzle.js to determine subpuzzle polyshapes. +window.drawPathNoUI = function(puzzle, path) { + puzzle.clearLines() + + // Extract the start data from the first path element + var x = path[0].x + var y = path[0].y + var cell = puzzle.getCell(x, y) + if (cell == null || cell.start !== true) throw Error('Path does not begin with a startpoint: ' + JSON.stringify(cell)) + + for (var i=1; i max ? max : value +} + +class BoundingBox { + constructor(x1, x2, y1, y2, sym=false) { + this.raw = {'x1':x1, 'x2':x2, 'y1':y1, 'y2':y2} + this.sym = sym + if (BBOX_DEBUG === true) { + this.debug = createElement('rect') + data.svg.appendChild(this.debug) + this.debug.setAttribute('opacity', 0.5) + this.debug.setAttribute('style', 'pointer-events: none;') + if (data.puzzle.symType == SYM_TYPE_NONE) { + this.debug.setAttribute('fill', 'white') + } else { + if (this.sym !== true) { + this.debug.setAttribute('fill', 'blue') + } else { + this.debug.setAttribute('fill', 'orange') + } + } + } + this._update() + } + + shift(dir, pixels) { + if (dir === 'left') { + this.raw.x2 = this.raw.x1 + this.raw.x1 -= pixels + } else if (dir === 'right') { + this.raw.x1 = this.raw.x2 + this.raw.x2 += pixels + } else if (dir === 'top') { + this.raw.y2 = this.raw.y1 + this.raw.y1 -= pixels + } else if (dir === 'bottom') { + this.raw.y1 = this.raw.y2 + this.raw.y2 += pixels + } + this._update() + } + + inMain(x, y) { + var inMainBox = + (this.x1 < x && x < this.x2) && + (this.y1 < y && y < this.y2) + var inRawBox = + (this.raw.x1 < x && x < this.raw.x2) && + (this.raw.y1 < y && y < this.raw.y2) + + return inMainBox && !inRawBox + } + + _update() { + this.x1 = this.raw.x1 + this.x2 = this.raw.x2 + this.y1 = this.raw.y1 + this.y2 = this.raw.y2 + + // Check for endpoint adjustment. + // Pretend it's not an endpoint if the sym cell isn't an endpoint. + if (data.puzzle.symType != SYM_TYPE_NONE) { + var cell1 = data.puzzle.getCell(data.pos.x, data.pos.y) + var cell2 = data.puzzle.getSymmetricalCell(data.pos.x, data.pos.y) + + if ((cell1.end == null) != (cell2.end == null)) { + var cell = {'end': 'none'} + } else if (this.sym !== true) { + var cell = cell1 + } else { + var cell = cell2 + } + } else { + var cell = data.puzzle.getCell(data.pos.x, data.pos.y) + } + if (cell.end === 'left') { + this.x1 -= 24 + } else if (cell.end === 'right') { + this.x2 += 24 + } else if (cell.end === 'top') { + this.y1 -= 24 + } else if (cell.end === 'bottom') { + this.y2 += 24 + } + + this.middle = { // Note: Middle of the raw object + 'x':(this.raw.x1 + this.raw.x2)/2, + 'y':(this.raw.y1 + this.raw.y2)/2 + } + + if (this.debug != null) { + this.debug.setAttribute('x', this.x1) + this.debug.setAttribute('y', this.y1) + this.debug.setAttribute('width', this.x2 - this.x1) + this.debug.setAttribute('height', this.y2 - this.y1) + } + } +} + +class PathSegment { + constructor(dir) { + this.poly1 = createElement('polygon') + this.circ = createElement('circle') + this.poly2 = createElement('polygon') + this.pillarCirc = createElement('circle') + this.dir = dir + data.svg.insertBefore(this.circ, data.cursor) + data.svg.insertBefore(this.poly2, data.cursor) + data.svg.insertBefore(this.pillarCirc, data.cursor) + this.circ.setAttribute('cx', data.bbox.middle.x) + this.circ.setAttribute('cy', data.bbox.middle.y) + + if (data.puzzle.pillar === true) { + // cx/cy are updated in redraw(), since pillarCirc tracks the cursor + this.pillarCirc.setAttribute('cy', data.bbox.middle.y) + this.pillarCirc.setAttribute('r', 12) + if (data.pos.x === 0 && this.dir === MOVE_RIGHT) { + this.pillarCirc.setAttribute('cx', data.bbox.x1) + this.pillarCirc.setAttribute('static', true) + } else if (data.pos.x === data.puzzle.width - 1 && this.dir === MOVE_LEFT) { + this.pillarCirc.setAttribute('cx', data.bbox.x2) + this.pillarCirc.setAttribute('static', true) + } else { + this.pillarCirc.setAttribute('cx', data.bbox.middle.x) + } + } + + if (data.puzzle.symType == SYM_TYPE_NONE) { + this.poly1.setAttribute('class', 'line-1 ' + data.svg.id) + this.circ.setAttribute('class', 'line-1 ' + data.svg.id) + this.poly2.setAttribute('class', 'line-1 ' + data.svg.id) + this.pillarCirc.setAttribute('class', 'line-1 ' + data.svg.id) + } else { + this.poly1.setAttribute('class', 'line-2 ' + data.svg.id) + this.circ.setAttribute('class', 'line-2 ' + data.svg.id) + this.poly2.setAttribute('class', 'line-2 ' + data.svg.id) + this.pillarCirc.setAttribute('class', 'line-2 ' + data.svg.id) + + this.symPoly1 = createElement('polygon') + this.symCirc = createElement('circle') + this.symPoly2 = createElement('polygon') + this.symPillarCirc = createElement('circle') + data.svg.insertBefore(this.symCirc, data.cursor) + data.svg.insertBefore(this.symPoly2, data.cursor) + data.svg.insertBefore(this.symPillarCirc, data.cursor) + + if (data.puzzle.settings.INVISIBLE_SYMMETRY) { + this.symPoly1.setAttribute('class', 'line-4 ' + data.svg.id) + this.symCirc.setAttribute('class', 'line-4 ' + data.svg.id) + this.symPoly2.setAttribute('class', 'line-4 ' + data.svg.id) + this.symPillarCirc.setAttribute('class', 'line-4 ' + data.svg.id) + } else { + this.symPoly1.setAttribute('class', 'line-3 ' + data.svg.id) + this.symCirc.setAttribute('class', 'line-3 ' + data.svg.id) + this.symPoly2.setAttribute('class', 'line-3 ' + data.svg.id) + this.symPillarCirc.setAttribute('class', 'line-3 ' + data.svg.id) + } + + this.symCirc.setAttribute('cx', data.symbbox.middle.x) + this.symCirc.setAttribute('cy', data.symbbox.middle.y) + + if (data.puzzle.pillar === true) { + // cx/cy are updated in redraw(), since symPillarCirc tracks the cursor + this.symPillarCirc.setAttribute('cy', data.symbbox.middle.y) + this.symPillarCirc.setAttribute('r', 12) + var symmetricalDir = getSymmetricalDir(data.puzzle, this.dir) + if (data.sym.x === 0 && symmetricalDir === MOVE_RIGHT) { + this.symPillarCirc.setAttribute('cx', data.symbbox.x1) + this.symPillarCirc.setAttribute('static', true) + } else if (data.sym.x === data.puzzle.width - 1 && symmetricalDir === MOVE_LEFT) { + this.symPillarCirc.setAttribute('cx', data.symbbox.x2) + this.symPillarCirc.setAttribute('static', true) + } else { + this.symPillarCirc.setAttribute('cx', data.symbbox.middle.x) + } + } + } + + if (this.dir === MOVE_NONE) { // Start point + this.circ.setAttribute('r', 24) + this.circ.setAttribute('class', this.circ.getAttribute('class') + ' start') + if (data.puzzle.symType != SYM_TYPE_NONE) { + this.symCirc.setAttribute('r', 24) + this.symCirc.setAttribute('class', this.symCirc.getAttribute('class') + ' start') + } + } else { + // Only insert poly1 in non-startpoints + data.svg.insertBefore(this.poly1, data.cursor) + this.circ.setAttribute('r', 12) + if (data.puzzle.symType != SYM_TYPE_NONE) { + data.svg.insertBefore(this.symPoly1, data.cursor) + this.symCirc.setAttribute('r', 12) + } + } + } + + destroy() { + data.svg.removeChild(this.poly1) + data.svg.removeChild(this.circ) + data.svg.removeChild(this.poly2) + data.svg.removeChild(this.pillarCirc) + if (data.puzzle.symType != SYM_TYPE_NONE) { + data.svg.removeChild(this.symPoly1) + data.svg.removeChild(this.symCirc) + data.svg.removeChild(this.symPoly2) + data.svg.removeChild(this.symPillarCirc) + } + } + + redraw() { // Uses raw bbox because of endpoints + // Move the cursor and related objects + var x = clamp(data.x, data.bbox.x1, data.bbox.x2) + var y = clamp(data.y, data.bbox.y1, data.bbox.y2) + data.cursor.setAttribute('cx', x) + data.cursor.setAttribute('cy', y) + if (data.puzzle.symType != SYM_TYPE_NONE) { + data.symcursor.setAttribute('cx', this._reflX(x,y)) + data.symcursor.setAttribute('cy', this._reflY(x,y)) + } + if (data.puzzle.pillar === true) { + if (this.pillarCirc.getAttribute('static') == null) { + this.pillarCirc.setAttribute('cx', x) + this.pillarCirc.setAttribute('cy', y) + } + if (data.puzzle.symType != SYM_TYPE_NONE) { + if (this.symPillarCirc.getAttribute('static') == null) { + this.symPillarCirc.setAttribute('cx', this._reflX(x,y)) + this.symPillarCirc.setAttribute('cy', this._reflY(x,y)) + } + } + } + + // Draw the first-half box + var points1 = JSON.parse(JSON.stringify(data.bbox.raw)) + if (this.dir === MOVE_LEFT) { + points1.x1 = clamp(data.x, data.bbox.middle.x, data.bbox.x2) + } else if (this.dir === MOVE_RIGHT) { + points1.x2 = clamp(data.x, data.bbox.x1, data.bbox.middle.x) + } else if (this.dir === MOVE_TOP) { + points1.y1 = clamp(data.y, data.bbox.middle.y, data.bbox.y2) + } else if (this.dir === MOVE_BOTTOM) { + points1.y2 = clamp(data.y, data.bbox.y1, data.bbox.middle.y) + } + this.poly1.setAttribute('points', + points1.x1 + ' ' + points1.y1 + ',' + + points1.x1 + ' ' + points1.y2 + ',' + + points1.x2 + ' ' + points1.y2 + ',' + + points1.x2 + ' ' + points1.y1 + ) + + var firstHalf = false + var isEnd = (data.puzzle.grid[data.pos.x][data.pos.y].end != null) + // The second half of the line uses the raw so that it can enter the endpoint properly. + var points2 = JSON.parse(JSON.stringify(data.bbox.raw)) + if (data.x < data.bbox.middle.x && this.dir !== MOVE_RIGHT) { + points2.x1 = clamp(data.x, data.bbox.x1, data.bbox.middle.x) + points2.x2 = data.bbox.middle.x + if (isEnd && data.pos.x%2 === 0 && data.pos.y%2 === 1) { + points2.y1 += 17 + points2.y2 -= 17 + } + } else if (data.x > data.bbox.middle.x && this.dir !== MOVE_LEFT) { + points2.x1 = data.bbox.middle.x + points2.x2 = clamp(data.x, data.bbox.middle.x, data.bbox.x2) + if (isEnd && data.pos.x%2 === 0 && data.pos.y%2 === 1) { + points2.y1 += 17 + points2.y2 -= 17 + } + } else if (data.y < data.bbox.middle.y && this.dir !== MOVE_BOTTOM) { + points2.y1 = clamp(data.y, data.bbox.y1, data.bbox.middle.y) + points2.y2 = data.bbox.middle.y + if (isEnd && data.pos.x%2 === 1 && data.pos.y%2 === 0) { + points2.x1 += 17 + points2.x2 -= 17 + } + } else if (data.y > data.bbox.middle.y && this.dir !== MOVE_TOP) { + points2.y1 = data.bbox.middle.y + points2.y2 = clamp(data.y, data.bbox.middle.y, data.bbox.y2) + if (isEnd && data.pos.x%2 === 1 && data.pos.y%2 === 0) { + points2.x1 += 17 + points2.x2 -= 17 + } + } else { + firstHalf = true + } + + this.poly2.setAttribute('points', + points2.x1 + ' ' + points2.y1 + ',' + + points2.x1 + ' ' + points2.y2 + ',' + + points2.x2 + ' ' + points2.y2 + ',' + + points2.x2 + ' ' + points2.y1 + ) + + // Show the second poly only in the second half of the cell + this.poly2.setAttribute('opacity', (firstHalf ? 0 : 1)) + // Show the circle in the second half of the cell AND in the start + if (firstHalf && this.dir !== MOVE_NONE) { + this.circ.setAttribute('opacity', 0) + } else { + this.circ.setAttribute('opacity', 1) + } + + // Draw the symmetrical path based on the original one + if (data.puzzle.symType != SYM_TYPE_NONE) { + this.symPoly1.setAttribute('points', + this._reflX(points1.x2, points1.y2) + ' ' + this._reflY(points1.x2, points1.y2) + ',' + + this._reflX(points1.x2, points1.y2) + ' ' + this._reflY(points1.x1, points1.y1) + ',' + + this._reflX(points1.x1, points1.y1) + ' ' + this._reflY(points1.x1, points1.y1) + ',' + + this._reflX(points1.x1, points1.y1) + ' ' + this._reflY(points1.x2, points1.y2) + ) + + this.symPoly2.setAttribute('points', + this._reflX(points2.x2, points2.y2) + ' ' + this._reflY(points2.x2, points2.y2) + ',' + + this._reflX(points2.x2, points2.y2) + ' ' + this._reflY(points2.x1, points2.y1) + ',' + + this._reflX(points2.x1, points2.y1) + ' ' + this._reflY(points2.x1, points2.y1) + ',' + + this._reflX(points2.x1, points2.y1) + ' ' + this._reflY(points2.x2, points2.y2) + ) + + this.symCirc.setAttribute('opacity', this.circ.getAttribute('opacity')) + this.symPoly2.setAttribute('opacity', this.poly2.getAttribute('opacity')) + } + } + + _reflX(x,y) { + if (data.puzzle.symType == SYM_TYPE_NONE) return x + + if (data.puzzle.symType == SYM_TYPE_VERTICAL || data.puzzle.symType == SYM_TYPE_ROTATIONAL || data.puzzle.symType == SYM_TYPE_PARALLEL_H_FLIP) { + // Mirror position inside the bounding box + return (data.bbox.middle.x - x) + data.symbbox.middle.x + } + if (data.puzzle.symType == SYM_TYPE_HORIZONTAL || data.puzzle.symType == SYM_TYPE_PARALLEL_H || data.puzzle.symType == SYM_TYPE_PARALLEL_V || data.puzzle.symType == SYM_TYPE_PARALLEL_V_FLIP) { + // Copy position inside the bounding box + return (x - data.bbox.middle.x) + data.symbbox.middle.x + } + if (data.puzzle.symType == SYM_TYPE_ROTATE_LEFT || data.puzzle.symType == SYM_TYPE_FLIP_XY) { + // Rotate position left inside the bounding box + return (y - data.bbox.middle.y) + data.symbbox.middle.x + } + if (data.puzzle.symType == SYM_TYPE_ROTATE_RIGHT || data.puzzle.symType == SYM_TYPE_FLIP_NEG_XY) { + // Rotate position right inside the bounding box + return (data.bbox.middle.y - y) + data.symbbox.middle.x + } + } + + _reflY(x,y) { + if (data.puzzle.symType == SYM_TYPE_NONE) return y + + if (data.puzzle.symType == SYM_TYPE_HORIZONTAL || data.puzzle.symType == SYM_TYPE_ROTATIONAL || data.puzzle.symType == SYM_TYPE_PARALLEL_V_FLIP) { + // Mirror position inside the bounding box + return (data.bbox.middle.y - y) + data.symbbox.middle.y + } + if (data.puzzle.symType == SYM_TYPE_VERTICAL || data.puzzle.symType == SYM_TYPE_PARALLEL_V || data.puzzle.symType == SYM_TYPE_PARALLEL_H || data.puzzle.symType == SYM_TYPE_PARALLEL_H_FLIP) { + // Copy position inside the bounding box + return (y - data.bbox.middle.y) + data.symbbox.middle.y + } + if (data.puzzle.symType == SYM_TYPE_ROTATE_RIGHT || data.puzzle.symType == SYM_TYPE_FLIP_XY) { + // Rotate position left inside the bounding box + return (x - data.bbox.middle.x) + data.symbbox.middle.y + } + if (data.puzzle.symType == SYM_TYPE_ROTATE_LEFT || data.puzzle.symType == SYM_TYPE_FLIP_NEG_XY) { + // Rotate position right inside the bounding box + return (data.bbox.middle.x - x) + data.symbbox.middle.y + } + } +} + +var data = {} + +function clearGrid(svg, puzzle) { + if (data.bbox != null && data.bbox.debug != null) { + data.svg.removeChild(data.bbox.debug) + data.bbox = null + } + if (data.symbbox != null && data.symbbox.debug != null) { + data.svg.removeChild(data.symbbox.debug) + data.symbbox = null + } + + window.deleteElementsByClassName(svg, 'cursor') + window.deleteElementsByClassName(svg, 'line-1') + window.deleteElementsByClassName(svg, 'line-2') + window.deleteElementsByClassName(svg, 'line-3') + window.deleteElementsByClassName(svg, 'line-4') + puzzle.clearLines() +} + +// This copy is an exact copy of puzzle.getSymmetricalDir, except that it uses MOVE_* values instead of strings +function getSymmetricalDir(puzzle, dir) { + if (puzzle.symType == SYM_TYPE_VERTICAL || puzzle.symType == SYM_TYPE_ROTATIONAL || puzzle.symType == SYM_TYPE_PARALLEL_H_FLIP) { + if (dir === MOVE_LEFT) return MOVE_RIGHT + if (dir === MOVE_RIGHT) return MOVE_LEFT + } + if (puzzle.symType == SYM_TYPE_HORIZONTAL || puzzle.symType == SYM_TYPE_ROTATIONAL || puzzle.symType == SYM_TYPE_PARALLEL_V_FLIP) { + if (dir === MOVE_TOP) return MOVE_BOTTOM + if (dir === MOVE_BOTTOM) return MOVE_TOP + } + if (puzzle.symType == SYM_TYPE_ROTATE_LEFT || puzzle.symType == SYM_TYPE_FLIP_NEG_XY) { + if (dir === MOVE_LEFT) return MOVE_BOTTOM + if (dir === MOVE_RIGHT) return MOVE_TOP + } + if (puzzle.symType == SYM_TYPE_ROTATE_RIGHT || puzzle.symType == SYM_TYPE_FLIP_NEG_XY) { + if (dir === MOVE_TOP) return MOVE_RIGHT + if (dir === MOVE_BOTTOM) return MOVE_LEFT + } + if (puzzle.symType == SYM_TYPE_ROTATE_LEFT || puzzle.symType == SYM_TYPE_FLIP_XY) { + if (dir === MOVE_TOP) return MOVE_LEFT + if (dir === MOVE_BOTTOM) return MOVE_RIGHT + } + if (puzzle.symType == SYM_TYPE_ROTATE_RIGHT || puzzle.symType == SYM_TYPE_FLIP_XY) { + if (dir === MOVE_RIGHT) return MOVE_BOTTOM + if (dir === MOVE_LEFT) return MOVE_TOP + } + return dir +} + +window.trace = function(event, puzzle, pos, start, symStart=null) { + /*if (data.start == null) {*/ + if (data.tracing !== true) { // could be undefined or false + var svg = start.parentElement + data.tracing = true + window.PLAY_SOUND('start') + // Cleans drawn lines & puzzle state + clearGrid(svg, puzzle) + onTraceStart(puzzle, pos, svg, start, symStart) + data.animations.insertRule('.' + svg.id + '.start {animation: 150ms 1 forwards start-grow}\n') + + hookMovementEvents(start) + } else { + event.stopPropagation() + // Signal the onMouseMove to stop accepting input (race condition) + data.tracing = false + + // At endpoint and in main box + var cell = puzzle.getCell(data.pos.x, data.pos.y) + if (cell.end != null && data.bbox.inMain(data.x, data.y)) { + data.cursor.onpointerdown = null + setTimeout(function() { // Run validation asynchronously so we can free the pointer immediately. + puzzle.endPoint = data.pos + var puzzleData = window.validate(puzzle, false) // We want all invalid elements so we can show the user. + + for (var negation of puzzleData.negations) { + console.debug('Rendering negation', negation) + data.animations.insertRule('.' + data.svg.id + '_' + negation.source.x + '_' + negation.source.y + ' {animation: 0.75s 1 forwards fade}\n') + data.animations.insertRule('.' + data.svg.id + '_' + negation.target.x + '_' + negation.target.y + ' {animation: 0.75s 1 forwards fade}\n') + } + + if (puzzleData.valid()) { + window.PLAY_SOUND('success') + // !important to override the child animation + data.animations.insertRule('.' + data.svg.id + ' {animation: 1s 1 forwards line-success !important}\n') + + // Convert the traced path into something suitable for solve.drawPath (for publishing purposes) + var rawPath = [puzzle.startPoint] + for (var i=1; i 1) { + // Stop tracing for two+ finger touches (the equivalent of a right click on desktop) + window.trace(event, data.puzzle, null, null, null) + return + } + data.lastTouchPos = event.position + } + document.ontouchmove = function(event) { + if (data.tracing !== true) return + + var eventIsWithinPuzzle = false + for (var node = event.target; node != null; node = node.parentElement) { + if (node == data.svg) { + eventIsWithinPuzzle = true + break + } + } + if (!eventIsWithinPuzzle) return // Ignore drag events that aren't within the puzzle + event.preventDefault() // Prevent accidental scrolling if the touch event is within the puzzle. + + var newPos = event.position + onMove(newPos.x - data.lastTouchPos.x, newPos.y - data.lastTouchPos.y) + data.lastTouchPos = newPos + } + document.ontouchend = function(event) { + data.lastTouchPos = null + // Only call window.trace (to stop tracing) if we're really in an endpoint. + var cell = data.puzzle.getCell(data.pos.x, data.pos.y) + if (cell.end != null && data.bbox.inMain(data.x, data.y)) { + window.trace(event, data.puzzle, null, null, null) + } + } +} + +// @Volatile -- must match order of PATH_* in solve +var MOVE_NONE = 0 +var MOVE_LEFT = 1 +var MOVE_RIGHT = 2 +var MOVE_TOP = 3 +var MOVE_BOTTOM = 4 + +window.onMove = function(dx, dy) { + { + // Also handles some collision + var collidedWith = pushCursor(dx, dy) + console.spam('Collided with', collidedWith) + } + + while (true) { + hardCollision() + + // Potentially move the location to a new cell, and make absolute boundary checks + var moveDir = move() + data.path[data.path.length - 1].redraw() + if (moveDir === MOVE_NONE) break + console.debug('Moved', ['none', 'left', 'right', 'top', 'bottom'][moveDir]) + + // Potentially adjust data.x/data.y if our position went around a pillar + if (data.puzzle.pillar === true) pillarWrap(moveDir) + + var lastDir = data.path[data.path.length - 1].dir + var backedUp = ((moveDir === MOVE_LEFT && lastDir === MOVE_RIGHT) + || (moveDir === MOVE_RIGHT && lastDir === MOVE_LEFT) + || (moveDir === MOVE_TOP && lastDir === MOVE_BOTTOM) + || (moveDir === MOVE_BOTTOM && lastDir === MOVE_TOP)) + + if (data.puzzle.symType != SYM_TYPE_NONE) { + var symMoveDir = getSymmetricalDir(data.puzzle, moveDir) + } + + // If we backed up, remove a path segment and mark the old cell as unvisited + if (backedUp) { + data.path.pop().destroy() + data.puzzle.updateCell2(data.pos.x, data.pos.y, 'line', window.LINE_NONE) + if (data.puzzle.symType != SYM_TYPE_NONE) { + if (data.puzzle.getLine(data.sym.x, data.sym.y) == window.LINE_OVERLAP) { + data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_BLUE) + } else { + data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_NONE) + } + } + } + + // Move to the next cell + changePos(data.bbox, data.pos, moveDir) + if (data.puzzle.symType != SYM_TYPE_NONE) { + changePos(data.symbbox, data.sym, symMoveDir) + } + + // If we didn't back up, add a path segment and mark the new cell as visited + if (!backedUp) { + data.path.push(new PathSegment(moveDir)) + if (data.puzzle.symType == SYM_TYPE_NONE) { + data.puzzle.updateCell2(data.pos.x, data.pos.y, 'line', window.LINE_BLACK) + } else { + data.puzzle.updateCell2(data.pos.x, data.pos.y, 'line', window.LINE_BLUE) + if (data.puzzle.getLine(data.sym.x, data.sym.y) == window.LINE_BLUE) { + data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_OVERLAP) + } else { + data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_YELLOW) + } + } + } + } +} + +// Helper function for pushCursor. Used to determine the direction and magnitude of redirection. +function push(dx, dy, dir, targetDir) { + // Fraction of movement to redirect in the other direction + var movementRatio = null + if (targetDir === 'left' || targetDir === 'top') { + movementRatio = -3 + } else if (targetDir === 'right' || targetDir === 'bottom') { + movementRatio = 3 + } + if (window.settings.disablePushing === true) movementRatio *= 1000 + + if (dir === 'left') { + var overshoot = data.bbox.x1 - (data.x + dx) + 12 + if (overshoot > 0) { + data.y += dy + overshoot / movementRatio + data.x = data.bbox.x1 + 12 + return true + } + } else if (dir === 'right') { + var overshoot = (data.x + dx) - data.bbox.x2 + 12 + if (overshoot > 0) { + data.y += dy + overshoot / movementRatio + data.x = data.bbox.x2 - 12 + return true + } + } else if (dir === 'leftright') { + data.y += dy + Math.abs(dx) / movementRatio + return true + } else if (dir === 'top') { + var overshoot = data.bbox.y1 - (data.y + dy) + 12 + if (overshoot > 0) { + data.x += dx + overshoot / movementRatio + data.y = data.bbox.y1 + 12 + return true + } + } else if (dir === 'bottom') { + var overshoot = (data.y + dy) - data.bbox.y2 + 12 + if (overshoot > 0) { + data.x += dx + overshoot / movementRatio + data.y = data.bbox.y2 - 12 + return true + } + } else if (dir === 'topbottom') { + data.x += dx + Math.abs(dy) / movementRatio + return true + } + return false +} + +// Redirect momentum from pushing against walls, so that all further moment steps +// will be strictly linear. Returns a string for logging purposes only. +function pushCursor(dx, dy) { + // Outer wall collision + var cell = data.puzzle.getCell(data.pos.x, data.pos.y) + if (cell == null) return 'nothing' + + // Only consider non-endpoints or endpoints which are parallel + if ([undefined, 'top', 'bottom'].includes(cell.end)) { + var leftCell = data.puzzle.getCell(data.pos.x - 1, data.pos.y) + if (leftCell == null || leftCell.gap === window.GAP_FULL) { + if (push(dx, dy, 'left', 'top')) return 'left outer wall' + } + var rightCell = data.puzzle.getCell(data.pos.x + 1, data.pos.y) + if (rightCell == null || rightCell.gap === window.GAP_FULL) { + if (push(dx, dy, 'right', 'top')) return 'right outer wall' + } + } + // Only consider non-endpoints or endpoints which are parallel + if ([undefined, 'left', 'right'].includes(cell.end)) { + var topCell = data.puzzle.getCell(data.pos.x, data.pos.y - 1) + if (topCell == null || topCell.gap === window.GAP_FULL) { + if (push(dx, dy, 'top', 'right')) return 'top outer wall' + } + var bottomCell = data.puzzle.getCell(data.pos.x, data.pos.y + 1) + if (bottomCell == null || bottomCell.gap === window.GAP_FULL) { + if (push(dx, dy, 'bottom', 'right')) return 'bottom outer wall' + } + } + + // Inner wall collision + if (cell.end == null) { + if (data.pos.x%2 === 1 && data.pos.y%2 === 0) { // Horizontal cell + if (data.x < data.bbox.middle.x) { + push(dx, dy, 'topbottom', 'left') + return 'topbottom inner wall, moved left' + } else { + push(dx, dy, 'topbottom', 'right') + return 'topbottom inner wall, moved right' + } + } else if (data.pos.x%2 === 0 && data.pos.y%2 === 1) { // Vertical cell + if (data.y < data.bbox.middle.y) { + push(dx, dy, 'leftright', 'top') + return 'leftright inner wall, moved up' + } else { + push(dx, dy, 'leftright', 'bottom') + return 'leftright inner wall, moved down' + } + } + } + + // Intersection & endpoint collision + // Ratio of movement to be considered turning at an intersection + var turnMod = 2 + if ((data.pos.x%2 === 0 && data.pos.y%2 === 0) || cell.end != null) { + if (data.x < data.bbox.middle.x) { + push(dx, dy, 'topbottom', 'right') + // Overshot the intersection and appears to be trying to turn + if (data.x > data.bbox.middle.x && Math.abs(dy) * turnMod > Math.abs(dx)) { + data.y += Math.sign(dy) * (data.x - data.bbox.middle.x) + data.x = data.bbox.middle.x + return 'overshot moving right' + } + return 'intersection moving right' + } else if (data.x > data.bbox.middle.x) { + push(dx, dy, 'topbottom', 'left') + // Overshot the intersection and appears to be trying to turn + if (data.x < data.bbox.middle.x && Math.abs(dy) * turnMod > Math.abs(dx)) { + data.y += Math.sign(dy) * (data.bbox.middle.x - data.x) + data.x = data.bbox.middle.x + return 'overshot moving left' + } + return 'intersection moving left' + } + if (data.y < data.bbox.middle.y) { + push(dx, dy, 'leftright', 'bottom') + // Overshot the intersection and appears to be trying to turn + if (data.y > data.bbox.middle.y && Math.abs(dx) * turnMod > Math.abs(dy)) { + data.x += Math.sign(dx) * (data.y - data.bbox.middle.y) + data.y = data.bbox.middle.y + return 'overshot moving down' + } + return 'intersection moving down' + } else if (data.y > data.bbox.middle.y) { + push(dx, dy, 'leftright', 'top') + // Overshot the intersection and appears to be trying to turn + if (data.y < data.bbox.middle.y && Math.abs(dx) * turnMod > Math.abs(dy)) { + data.x += Math.sign(dx) * (data.bbox.middle.y - data.y) + data.y = data.bbox.middle.y + return 'overshot moving up' + } + return 'intersection moving up' + } + } + + // No collision, limit movement to X or Y only to prevent out-of-bounds + if (Math.abs(dx) > Math.abs(dy)) { + data.x += dx + return 'nothing, x' + } else { + data.y += dy + return 'nothing, y' + } +} + +// Check to see if we collided with any gaps, or with a symmetrical line, or a startpoint. +// In any case, abruptly zero momentum. +function hardCollision() { + var lastDir = data.path[data.path.length - 1].dir + var cell = data.puzzle.getCell(data.pos.x, data.pos.y) + if (cell == null) return + + var gapSize = 0 + if (cell.gap === window.GAP_BREAK) { + console.spam('Collided with a gap') + gapSize = 21 + } else { + var nextCell = null + if (lastDir === MOVE_LEFT) nextCell = data.puzzle.getCell(data.pos.x - 1, data.pos.y) + if (lastDir === MOVE_RIGHT) nextCell = data.puzzle.getCell(data.pos.x + 1, data.pos.y) + if (lastDir === MOVE_TOP) nextCell = data.puzzle.getCell(data.pos.x, data.pos.y - 1) + if (lastDir === MOVE_BOTTOM) nextCell = data.puzzle.getCell(data.pos.x, data.pos.y + 1) + if (nextCell != null && nextCell.start === true && nextCell.line > window.LINE_NONE) { + gapSize = -5 + } + } + + if (data.puzzle.symType != SYM_TYPE_NONE) { + if (data.sym.x === data.pos.x && data.sym.y === data.pos.y) { + console.spam('Collided with our symmetrical line') + gapSize = 13 + } else if (data.puzzle.getCell(data.sym.x, data.sym.y).gap === window.GAP_BREAK) { + console.spam('Symmetrical line hit a gap') + gapSize = 21 + } + } + if (gapSize === 0) return // Didn't collide with anything + + if (lastDir === MOVE_LEFT) { + data.x = Math.max(data.bbox.middle.x + gapSize, data.x) + } else if (lastDir === MOVE_RIGHT) { + data.x = Math.min(data.x, data.bbox.middle.x - gapSize) + } else if (lastDir === MOVE_TOP) { + data.y = Math.max(data.bbox.middle.y + gapSize, data.y) + } else if (lastDir === MOVE_BOTTOM) { + data.y = Math.min(data.y, data.bbox.middle.y - gapSize) + } +} + +// Check to see if we've gone beyond the edge of puzzle cell, and if the next cell is safe, +// i.e. not out of bounds. Reports the direction we are going to move (or none), +// but does not actually change data.pos +function move() { + var lastDir = data.path[data.path.length - 1].dir + + if (data.x < data.bbox.x1 + 12) { // Moving left + var cell = data.puzzle.getCell(data.pos.x - 1, data.pos.y) + if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { + console.spam('Collided with outside / gap-2', cell) + data.x = data.bbox.x1 + 12 + } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_RIGHT) { + console.spam('Collided with other line', cell.line) + data.x = data.bbox.x1 + 12 + } else if (data.puzzle.symType != SYM_TYPE_NONE) { + var symCell = data.puzzle.getSymmetricalCell(data.pos.x - 1, data.pos.y) + if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { + console.spam('Collided with symmetrical outside / gap-2', cell) + data.x = data.bbox.x1 + 12 + } + } + if (data.x < data.bbox.x1) { + return MOVE_LEFT + } + } else if (data.x > data.bbox.x2 - 12) { // Moving right + var cell = data.puzzle.getCell(data.pos.x + 1, data.pos.y) + if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { + console.spam('Collided with outside / gap-2', cell) + data.x = data.bbox.x2 - 12 + } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_LEFT) { + console.spam('Collided with other line', cell.line) + data.x = data.bbox.x2 - 12 + } else if (data.puzzle.symType != SYM_TYPE_NONE) { + var symCell = data.puzzle.getSymmetricalCell(data.pos.x + 1, data.pos.y) + if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { + console.spam('Collided with symmetrical outside / gap-2', cell) + data.x = data.bbox.x2 - 12 + } + } + if (data.x > data.bbox.x2) { + return MOVE_RIGHT + } + } else if (data.y < data.bbox.y1 + 12) { // Moving up + var cell = data.puzzle.getCell(data.pos.x, data.pos.y - 1) + if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { + console.spam('Collided with outside / gap-2', cell) + data.y = data.bbox.y1 + 12 + } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_BOTTOM) { + console.spam('Collided with other line', cell.line) + data.y = data.bbox.y1 + 12 + } else if (data.puzzle.symType != SYM_TYPE_NONE) { + var symCell = data.puzzle.getSymmetricalCell(data.pos.x, data.pos.y - 1) + if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { + console.spam('Collided with symmetrical outside / gap-2', cell) + data.y = data.bbox.y1 + 12 + } + } + if (data.y < data.bbox.y1) { + return MOVE_TOP + } + } else if (data.y > data.bbox.y2 - 12) { // Moving down + var cell = data.puzzle.getCell(data.pos.x, data.pos.y + 1) + if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { + console.spam('Collided with outside / gap-2') + data.y = data.bbox.y2 - 12 + } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_TOP) { + console.spam('Collided with other line', cell.line) + data.y = data.bbox.y2 - 12 + } else if (data.puzzle.symType != SYM_TYPE_NONE) { + var symCell = data.puzzle.getSymmetricalCell(data.pos.x, data.pos.y + 1) + if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { + console.spam('Collided with symmetrical outside / gap-2', cell) + data.y = data.bbox.y2 - 12 + } + } + if (data.y > data.bbox.y2) { + return MOVE_BOTTOM + } + } + return MOVE_NONE +} + +// Check to see if you moved beyond the edge of a pillar. +// If so, wrap the cursor x to preserve momentum. +// Note that this still does not change the position. +function pillarWrap(moveDir) { + if (moveDir === MOVE_LEFT && data.pos.x === 0) { + data.x += data.puzzle.width * 41 + } + if (moveDir === MOVE_RIGHT && data.pos.x === data.puzzle.width - 1) { + data.x -= data.puzzle.width * 41 + } +} + +// Actually change the data position. (Note that this takes in pos to allow easier symmetry). +// Note that this doesn't zero the momentum, so that we can adjust appropriately on further loops. +// This function also shifts the bounding box that we use to determine the bounds of the cell. +function changePos(bbox, pos, moveDir) { + if (moveDir === MOVE_LEFT) { + pos.x-- + // Wrap around the left + if (data.puzzle.pillar === true && pos.x < 0) { + pos.x += data.puzzle.width + bbox.shift('right', data.puzzle.width * 41 - 82) + bbox.shift('right', 58) + } else { + bbox.shift('left', (pos.x%2 === 0 ? 24 : 58)) + } + } else if (moveDir === MOVE_RIGHT) { + pos.x++ + // Wrap around to the right + if (data.puzzle.pillar === true && pos.x >= data.puzzle.width) { + pos.x -= data.puzzle.width + bbox.shift('left', data.puzzle.width * 41 - 82) + bbox.shift('left', 24) + } else { + bbox.shift('right', (pos.x%2 === 0 ? 24 : 58)) + } + } else if (moveDir === MOVE_TOP) { + pos.y-- + bbox.shift('top', (pos.y%2 === 0 ? 24 : 58)) + } else if (moveDir === MOVE_BOTTOM) { + pos.y++ + bbox.shift('bottom', (pos.y%2 === 0 ? 24 : 58)) + } +} + +}) diff --git a/app/assets/javascripts/utilities.js.erb b/app/assets/javascripts/utilities.js.erb new file mode 100644 index 0000000..0414ce8 --- /dev/null +++ b/app/assets/javascripts/utilities.js.erb @@ -0,0 +1,498 @@ +function namespace(code) { + code() +} + +namespace(function() { + +/*** Start cross-compatibility ***/ +// Used to detect if IDs include a direction, e.g. resize-top-left +if (!String.prototype.includes) { + String.prototype.includes = function() { + return String.prototype.indexOf.apply(this, arguments) !== -1 + } +} +Event.prototype.movementX = Event.prototype.movementX || Event.prototype.mozMovementX +Event.prototype.movementY = Event.prototype.movementY || Event.prototype.mozMovementY +Event.prototype.isRightClick = function() { + return this.which === 3 || (this.touches && this.touches.length > 1) +} +Element.prototype.disable = function() { + this.disabled = true + this.style.pointerEvents = 'none' + this.className = 'noselect' +} +Element.prototype.enable = function() { + this.disabled = false + this.style.pointerEvents = null + this.className = null +} +Object.defineProperty(Event.prototype, 'position', { + 'get': function() { + return { + 'x': event.pageX || event.clientX || (event.touches && event.touches[0].pageX) || null, + 'y': event.pageY || event.clientY || (event.touches && event.touches[0].pageY) || null, + } + } +}) +/*** End cross-compatibility ***/ + +var proxy = { + 'get': function(_, key) { + try { + return this._map[key] + } catch (e) { + return null + } + }, + 'set': function(_, key, value) { + if (value == null) { + delete this._map[key] + } else { + this._map[key] = value.toString() + window.localStorage.setItem('settings', JSON.stringify(this._map)) + } + }, + 'init': function() { + this._map = {} + try { + var j = window.localStorage.getItem('settings') + if (j != null) this._map = JSON.parse(j) + } catch (e) {/* Do nothing */} + + function setIfNull(map, key, value) { + if (map[key] == null) map[key] = value + } + + // Set any values which are not defined + setIfNull(this._map, 'theme', 'light') + setIfNull(this._map, 'volume', '0.12') + setIfNull(this._map, 'sensitivity', '0.7') + setIfNull(this._map, 'expanded', 'false') + setIfNull(this._map, 'customMechanics', 'false') + return this + }, +} +window.settings = new Proxy({}, proxy.init()) + +var tracks = { + 'start': new Audio(src = '<%= asset_url("panel_start_tracing.aac") %>'), + 'success': new Audio(src = '<%= asset_url("panel_success.aac") %>'), + 'fail': new Audio(src = '<%= asset_url("panel_failure.aac") %>'), + 'abort': new Audio(src = '<%= asset_url("panel_abort_tracing.aac") %>'), +} + +var currentAudio = null +window.PLAY_SOUND = function(name) { + if (currentAudio) currentAudio.pause() + var audio = tracks[name] + audio.load() + audio.volume = parseFloat(window.settings.volume) + audio.play().then(function() { + currentAudio = audio + }).catch(function() { + // Do nothing. + }) +} + +window.LINE_PRIMARY = '#8FF' +window.LINE_SECONDARY = '#FF2' + +if (window.settings.theme == 'night') { + window.BACKGROUND = '#221' + window.OUTER_BACKGROUND = '#070704' + window.FOREGROUND = '#751' + window.BORDER = '#666' + window.LINE_DEFAULT = '#888' + window.LINE_SUCCESS = '#BBB' + window.LINE_FAIL = '#000' + window.CURSOR = '#FFF' + window.TEXT_COLOR = '#AAA' + window.PAGE_BACKGROUND = '#000' + window.ALT_BACKGROUND = '#333' // An off-black. Good for mild contrast. + window.ACTIVE_COLOR = '#555' // Color for 'while the element is being pressed' +} else if (window.settings.theme == 'light') { + window.BACKGROUND = '#0A8' + window.OUTER_BACKGROUND = '#113833' + window.FOREGROUND = '#344' + window.BORDER = '#000' + window.LINE_DEFAULT = '#AAA' + window.LINE_SUCCESS = '#FFF' + window.LINE_FAIL = '#000' + window.CURSOR = '#FFF' + window.TEXT_COLOR = '#000' + window.PAGE_BACKGROUND = '#FFF' + window.ALT_BACKGROUND = '#EEE' // An off-white. Good for mild contrast. + window.ACTIVE_COLOR = '#DDD' // Color for 'while the element is being pressed' +} + +window.LINE_NONE = 0 +window.LINE_BLACK = 1 +window.LINE_BLUE = 2 +window.LINE_YELLOW = 3 +window.LINE_OVERLAP = 4 +window.DOT_NONE = 0 +window.DOT_BLACK = 1 +window.DOT_BLUE = 2 +window.DOT_YELLOW = 3 +window.DOT_INVISIBLE = 4 +window.GAP_NONE = 0 +window.GAP_BREAK = 1 +window.GAP_FULL = 2 + +var animations = '' +var l = function(line) {animations += line + '\n'} +// pointer-events: none; allows for events to bubble up (so that editor hooks still work) +l('.line-1 {') +l(' fill: ' + window.LINE_DEFAULT + ';') +l(' pointer-events: none;') +l('}') +l('.line-2 {') +l(' fill: ' + window.LINE_PRIMARY + ';') +l(' pointer-events: none;') +l('}') +l('.line-3 {') +l(' fill: ' + window.LINE_SECONDARY + ';') +l(' pointer-events: none;') +l('}') +l('.line-4 {') +l(' display: none;') +l(' pointer-events: none;') +l('}') +l('@keyframes line-success {to {fill: ' + window.LINE_SUCCESS + ';}}') +l('@keyframes line-fail {to {fill: ' + window.LINE_FAIL + ';}}') +l('@keyframes error {to {fill: red;}}') +l('@keyframes fade {to {opacity: 0.35;}}') +l('@keyframes start-grow {from {r:12;} to {r:24;}}') +// Neutral button style +l('button {') +l(' background-color: ' + window.ALT_BACKGROUND + ';') +l(' border: 1px solid ' + window.BORDER + ';') +l(' border-radius: 2px;') +l(' color: ' + window.TEXT_COLOR + ';') +l(' display: inline-block;') +l(' margin: 0px;') +l(' outline: none;') +l(' opacity: 1.0;') +l(' padding: 1px 6px;') +l(' -moz-appearance: none;') +l(' -webkit-appearance: none;') +l('}') +// Active (while held down) button style +l('button:active {background-color: ' + window.ACTIVE_COLOR + ';}') +// Disabled button style +l('button:disabled {opacity: 0.5;}') +// Selected button style (see https://stackoverflow.com/a/63108630) +l('button:focus {outline: none;}') +l = null + +var style = document.createElement('style') +style.type = 'text/css' +style.title = 'animations' +style.appendChild(document.createTextNode(animations)) +document.head.appendChild(style) + +// Custom logging to allow leveling +var consoleError = console.error +var consoleWarn = console.warn +var consoleInfo = console.log +var consoleLog = console.log +var consoleDebug = console.log +var consoleSpam = console.log +var consoleGroup = console.group +var consoleGroupEnd = console.groupEnd + +window.setLogLevel = function(level) { + console.error = function() {} + console.warn = function() {} + console.info = function() {} + console.log = function() {} + console.debug = function() {} + console.spam = function() {} + console.group = function() {} + console.groupEnd = function() {} + + if (level === 'none') return + + // Instead of throw, but still red flags and is easy to find + console.error = consoleError + if (level === 'error') return + + // Less serious than error, but flagged nonetheless + console.warn = consoleWarn + if (level === 'warn') return + + // Default visible, important information + console.info = consoleInfo + if (level === 'info') return + + // Useful for debugging (mainly validation) + console.log = consoleLog + if (level === 'log') return + + // Useful for serious debugging (mainly graphics/misc) + console.debug = consoleDebug + if (level === 'debug') return + + // Useful for insane debugging (mainly tracing/recursion) + console.spam = consoleSpam + console.group = consoleGroup + console.groupEnd = consoleGroupEnd + if (level === 'spam') return +} +setLogLevel('info') + +window.deleteElementsByClassName = function(rootElem, className) { + var elems = [] + while (true) { + elems = rootElem.getElementsByClassName(className) + if (elems.length === 0) break + elems[0].remove() + } +} + +// Automatically solve the puzzle +window.solvePuzzle = function() { + if (window.setSolveMode) window.setSolveMode(false) + document.getElementById('solutionViewer').style.display = 'none' + document.getElementById('progressBox').style.display = null + document.getElementById('solveAuto').innerText = 'Cancel Solving' + document.getElementById('solveAuto').onpointerdown = function() { + this.innerText = 'Cancelling...' + this.onpointerdown = null + window.setTimeout(window.cancelSolving, 0) + } + + window.solve(window.puzzle, function(percent) { + document.getElementById('progressPercent').innerText = percent + '%' + document.getElementById('progress').style.width = percent + '%' + }, function(paths) { + document.getElementById('progressBox').style.display = 'none' + document.getElementById('solutionViewer').style.display = null + document.getElementById('progressPercent').innerText = '0%' + document.getElementById('progress').style.width = '0%' + document.getElementById('solveAuto').innerText = 'Solve (automatically)' + document.getElementById('solveAuto').onpointerdown = solvePuzzle + + window.puzzle.autoSolved = true + paths = window.onSolvedPuzzle(paths) + window.showSolution(window.puzzle, paths, 0) + }) +} + +window.showSolution = function(puzzle, paths, num, suffix) { + if (suffix == null) { + var previousSolution = document.getElementById('previousSolution') + var solutionCount = document.getElementById('solutionCount') + var nextSolution = document.getElementById('nextSolution') + } else if (suffix instanceof Array) { + var previousSolution = document.getElementById('previousSolution-' + suffix[0]) + var solutionCount = document.getElementById('solutionCount-' + suffix[0]) + var nextSolution = document.getElementById('nextSolution-' + suffix[0]) + } else { + var previousSolution = document.getElementById('previousSolution-' + suffix) + var solutionCount = document.getElementById('solutionCount-' + suffix) + var nextSolution = document.getElementById('nextSolution-' + suffix) + } + + if (paths.length === 0) { // 0 paths, arrows are useless + solutionCount.innerText = '0 of 0' + previousSolution.disable() + nextSolution.disable() + return + } + + while (num < 0) num = paths.length + num + while (num >= paths.length) num = num - paths.length + + if (paths.length === 1) { // 1 path, arrows are useless + solutionCount.innerText = '1 of 1' + if (paths.length >= window.MAX_SOLUTIONS) solutionCount.innerText += '+' + previousSolution.disable() + nextSolution.disable() + } else { + solutionCount.innerText = (num + 1) + ' of ' + paths.length + if (paths.length >= window.MAX_SOLUTIONS) solutionCount.innerText += '+' + previousSolution.enable() + nextSolution.enable() + previousSolution.onpointerdown = function(event) { + if (event.shiftKey) { + window.showSolution(puzzle, paths, num - 10, suffix) + } else { + window.showSolution(puzzle, paths, num - 1, suffix) + } + } + nextSolution.onpointerdown = function(event) { + if (event.shiftKey) { + window.showSolution(puzzle, paths, num + 10, suffix) + } else { + window.showSolution(puzzle, paths, num + 1, suffix) + } + } + } + + if (paths[num] != null) { + if (puzzle instanceof Array) { // Special case for multiple related panels + for (var i = 0; i < puzzle.length; i++) { + // Save the current path on the puzzle object (so that we can pass it along with publishing) + puzzle.path = paths[num][i] + // Draws the given path, and also updates the puzzle to have path annotations on it. + window.drawPath(puzzle[i], paths[num][i], suffix[i]) + } + } else { // Default case for a single panel + // Save the current path on the puzzle object (so that we can pass it along with publishing) + puzzle.path = paths[num] + // Draws the given path, and also updates the puzzle to have path annotations on it. + window.drawPath(puzzle, paths[num], suffix) + } + } +} + +window.createCheckbox = function() { + var checkbox = document.createElement('div') + checkbox.style.width = '22px' + checkbox.style.height = '22px' + checkbox.style.borderRadius = '6px' + checkbox.style.display = 'inline-block' + checkbox.style.verticalAlign = 'text-bottom' + checkbox.style.marginRight = '6px' + checkbox.style.borderWidth = '1.5px' + checkbox.style.borderStyle = 'solid' + checkbox.style.borderColor = window.BORDER + checkbox.style.background = window.PAGE_BACKGROUND + checkbox.style.color = window.TEXT_COLOR + return checkbox +} + +// Required global variables/functions: <-- HINT: This means you're writing bad code. +// window.puzzle +// window.onSolvedPuzzle() +// window.MAX_SOLUTIONS // defined by solve.js +window.addSolveButtons = function() { + var parent = document.currentScript.parentElement + + var solveMode = createCheckbox() + solveMode.id = 'solveMode' + parent.appendChild(solveMode) + + solveMode.onpointerdown = function() { + this.checked = !this.checked + this.style.background = (this.checked ? window.BORDER : window.PAGE_BACKGROUND) + document.getElementById('solutionViewer').style.display = 'none' + if (window.setSolveMode) window.setSolveMode(this.checked) + } + + var solveManual = document.createElement('label') + parent.appendChild(solveManual) + solveManual.id = 'solveManual' + solveManual.onpointerdown = function() {solveMode.onpointerdown()} + solveManual.innerText = 'Solve (manually)' + solveManual.style = 'margin-right: 8px' + + var solveAuto = document.createElement('button') + parent.appendChild(solveAuto) + solveAuto.id = 'solveAuto' + solveAuto.innerText = 'Solve (automatically)' + solveAuto.onpointerdown = solvePuzzle + solveAuto.style = 'margin-right: 8px' + + var div = document.createElement('div') + parent.appendChild(div) + div.style = 'display: inline-block; vertical-align:top' + + var progressBox = document.createElement('div') + div.appendChild(progressBox) + progressBox.id = 'progressBox' + progressBox.style = 'display: none; width: 220px; border: 1px solid black; margin-top: 2px' + + var progressPercent = document.createElement('label') + progressBox.appendChild(progressPercent) + progressPercent.id = 'progressPercent' + progressPercent.style = 'float: left; margin-left: 4px' + progressPercent.innerText = '0%' + + var progress = document.createElement('div') + progressBox.appendChild(progress) + progress.id = 'progress' + progress.style = 'z-index: -1; height: 38px; width: 0%; background-color: #390' + + var solutionViewer = document.createElement('div') + div.appendChild(solutionViewer) + solutionViewer.id = 'solutionViewer' + solutionViewer.style = 'display: none' + + var previousSolution = document.createElement('button') + solutionViewer.appendChild(previousSolution) + previousSolution.id = 'previousSolution' + previousSolution.innerHTML = '←' + + var solutionCount = document.createElement('label') + solutionViewer.appendChild(solutionCount) + solutionCount.id = 'solutionCount' + solutionCount.style = 'padding: 6px' + + var nextSolution = document.createElement('button') + solutionViewer.appendChild(nextSolution) + nextSolution.id = 'nextSolution' + nextSolution.innerHTML = '→' +} + +var SECONDS_PER_LOOP = 1 +window.httpGetLoop = function(url, maxTimeout, action, onError, onSuccess) { + if (maxTimeout <= 0) { + onError() + return + } + + sendHttpRequest('GET', url, SECONDS_PER_LOOP, null, function(httpCode, response) { + if (httpCode >= 200 && httpCode <= 299) { + var output = action(JSON.parse(response)) + if (output) { + onSuccess(output) + return + } // Retry if action returns null + } // Retry on non-success HTTP codes + + window.setTimeout(function() { + httpGetLoop(url, maxTimeout - SECONDS_PER_LOOP, action, onError, onSuccess) + }, 1000) + }) +} + +window.fireAndForget = function(verb, url, body) { + sendHttpRequest(verb, url, 600, body, function() {}) +} + +// Only used for errors +var HTTP_STATUS = { + 401: '401 unauthorized', 403: '403 forbidden', 404: '404 not found', 409: '409 conflict', 413: '413 payload too large', + 500: '500 internal server error', +} + +var etagCache = {} +function sendHttpRequest(verb, url, timeoutSeconds, data, onResponse) { + currentHttpRequest = new XMLHttpRequest() + currentHttpRequest.onreadystatechange = function() { + if (this.readyState != XMLHttpRequest.DONE) return + etagCache[url] = this.getResponseHeader('ETag') + currentHttpRequest = null + onResponse(this.status, this.responseText || HTTP_STATUS[this.status]) + } + currentHttpRequest.ontimeout = function() { + currentHttpRequest = null + onResponse(0, 'Request timed out') + } + currentHttpRequest.timeout = timeoutSeconds * 1000 + currentHttpRequest.open(verb, url, true) + currentHttpRequest.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded') + + var etag = etagCache[url] + if (etag != null) currentHttpRequest.setRequestHeader('If-None-Match', etag) + + currentHttpRequest.send(data) +} + +function sendFeedback(feedback) { + console.error('Please disregard the following CORS exception. It is expected and the request will succeed regardless.') +} + +}) diff --git a/app/assets/javascripts/validate.js b/app/assets/javascripts/validate.js new file mode 100644 index 0000000..d6e6484 --- /dev/null +++ b/app/assets/javascripts/validate.js @@ -0,0 +1,391 @@ +namespace(function() { + +class RegionData { + constructor() { + this.invalidElements = [] + this.veryInvalidElements = [] + this.negations = [] + } + + addInvalid(elem) { + this.invalidElements.push(elem) + } + + addVeryInvalid(elem) { + this.veryInvalidElements.push(elem) + } + + valid() { + return (this.invalidElements.length === 0 && this.veryInvalidElements.length === 0) + } +} + +// Sanity checks for data which comes from the user. Now that people have learned that /publish is an open endpoint, +// we have to make sure they don't submit data which passes validation but is untrustworthy. +// These checks should always pass for puzzles created by the built-in editor. +window.validateUserData = function(puzzle, path) { + if (path == null) throw Error('Path cannot be null') + + var sizeError = puzzle.getSizeError(puzzle.width, puzzle.height) + if (sizeError != null) throw Error(sizeError) + + var puzzleHasStart = false + var puzzleHasEnd = false + + if (puzzle.grid.length !== puzzle.width) throw Error('Puzzle width does not match grid size') + for (var x=0; x window.LINE_NONE) { + if (cell.gap > window.GAP_NONE) { + console.log('Solution line goes over a gap at', x, y) + puzzleData.invalidElements.push({'x': x, 'y': y}) + if (quick) return puzzleData + } + if ((cell.dot === window.DOT_BLUE && cell.line === window.LINE_YELLOW) || + (cell.dot === window.DOT_YELLOW && cell.line === window.LINE_BLUE)) { + console.log('Incorrectly covered dot: Dot is', cell.dot, 'but line is', cell.line) + puzzleData.invalidElements.push({'x': x, 'y': y}) + if (quick) return puzzleData + } + } + } + } + + if (needsRegions) { + var regions = puzzle.getRegions() + } else { + var monoRegion = [] + for (var x=0; x 0 && veryInvalidElements.length > 0) { + var source = negationSymbols.pop() + var target = veryInvalidElements.pop() + puzzle.setCell(source.x, source.y, null) + puzzle.setCell(target.x, target.y, null) + baseCombination.push({'source':source, 'target':target}) + } + + var regionData = regionCheckNegations2(puzzle, region, negationSymbols, invalidElements) + + // Restore required negations + for (var combination of baseCombination) { + puzzle.setCell(combination.source.x, combination.source.y, combination.source.cell) + puzzle.setCell(combination.target.x, combination.target.y, combination.target.cell) + regionData.negations.push(combination) + } + return regionData +} + +// Recursively matches negations and invalid elements from the grid. Note that this function +// doesn't actually modify the two lists, it just iterates through them with index/index2. +function regionCheckNegations2(puzzle, region, negationSymbols, invalidElements, index=0, index2=0) { + if (index2 >= negationSymbols.length) { + console.debug('0 negation symbols left, returning negation-less regionCheck') + return regionCheck(puzzle, region, false) // @Performance: We could pass quick here. + } + + if (index >= invalidElements.length) { + var i = index2 + // pair off all negation symbols, 2 at a time + if (puzzle.settings.NEGATIONS_CANCEL_NEGATIONS) { + for (; i window.DOT_NONE) { + console.log('Dot at', pos.x, pos.y, 'is not covered') + regionData.addVeryInvalid(pos) + if (quick) return regionData + } + + // Check for triangles + if (cell.type === 'triangle') { + var count = 0 + if (puzzle.getLine(pos.x - 1, pos.y) > window.LINE_NONE) count++ + if (puzzle.getLine(pos.x + 1, pos.y) > window.LINE_NONE) count++ + if (puzzle.getLine(pos.x, pos.y - 1) > window.LINE_NONE) count++ + if (puzzle.getLine(pos.x, pos.y + 1) > window.LINE_NONE) count++ + if (cell.count !== count) { + console.log('Triangle at grid['+pos.x+']['+pos.y+'] has', count, 'borders') + regionData.addVeryInvalid(pos) + if (quick) return regionData + } + } + + // Count color-based elements + if (cell.color != null) { + var count = coloredObjects[cell.color] + if (count == null) { + count = 0 + } + coloredObjects[cell.color] = count + 1 + + if (cell.type === 'square') { + squares.push(pos) + if (squareColor == null) { + squareColor = cell.color + } else if (squareColor != cell.color) { + squareColor = -1 // Signal value which indicates square color collision + } + } + + if (cell.type === 'star') { + pos.color = cell.color + stars.push(pos) + } + } + } + + if (squareColor === -1) { + regionData.invalidElements = regionData.invalidElements.concat(squares) + if (quick) return regionData + } + + for (var star of stars) { + var count = coloredObjects[star.color] + if (count === 1) { + console.log('Found a', star.color, 'star in a region with 1', star.color, 'object') + regionData.addVeryInvalid(star) + if (quick) return regionData + } else if (count > 2) { + console.log('Found a', star.color, 'star in a region with', count, star.color, 'objects') + regionData.addInvalid(star) + if (quick) return regionData + } + } + + if (puzzle.hasPolyominos) { + if (!window.polyFit(region, puzzle)) { + for (var pos of region) { + var cell = puzzle.grid[pos.x][pos.y] + if (cell == null) continue + if (cell.type === 'poly' || cell.type === 'ylop') { + regionData.addInvalid(pos) + if (quick) return regionData + } + } + } + } + + if (puzzle.settings.CUSTOM_MECHANICS) { + window.validateBridges(puzzle, region, regionData) + window.validateArrows(puzzle, region, regionData) + window.validateSizers(puzzle, region, regionData) + } + + console.debug('Region has', regionData.veryInvalidElements.length, 'very invalid elements') + console.debug('Region has', regionData.invalidElements.length, 'invalid elements') + return regionData +} +}) diff --git a/app/assets/javascripts/wittle.js b/app/assets/javascripts/wittle.js new file mode 100644 index 0000000..883a4b8 --- /dev/null +++ b/app/assets/javascripts/wittle.js @@ -0,0 +1,5 @@ +$.ajaxSetup({ + headers: { + 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') + } +}); diff --git a/app/assets/javascripts/wittle/application.js b/app/assets/javascripts/wittle/application.js deleted file mode 100644 index 52d2214..0000000 --- a/app/assets/javascripts/wittle/application.js +++ /dev/null @@ -1,14 +0,0 @@ -// This is a manifest file that'll be compiled into application.js, which will include all the files -// listed below. -// -// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, -// or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. -// -// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the -// compiled file. JavaScript code in this file should be added after the last require_* statement. -// -// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details -// about supported directives. -// -//= require jquery3 -//= require_tree . diff --git a/app/assets/javascripts/wittle/custom_mechanics.js b/app/assets/javascripts/wittle/custom_mechanics.js deleted file mode 100644 index d4733db..0000000 --- a/app/assets/javascripts/wittle/custom_mechanics.js +++ /dev/null @@ -1,201 +0,0 @@ -namespace(function() { - -function isCellBridgePathFriendly(puzzle, color, pos) { - if (pos.x%2 === 0 && pos.y%2 === 0) return false - var cell = puzzle.getCell(pos.x, pos.y) - return cell == null || cell.color == null || cell.color === color -} - -function makeMinimalTree(graph, root, required) { - var seen = Array(graph.length).fill(false) - var result = Array(graph.length).fill(false) - result[root] = true - function dfs(node) { - seen[node] = true - result[node] = required[node] - for (var child of graph[node]) { - if (!seen[child]) { - dfs(child) - result[node] = result[node] || result[child] - } - } - } - dfs(root) - return result -} - -function isTreeUnique(graph, isInTree) { - var seen = isInTree.slice() - function dfs(node) { - seen[node] = true - var reachableTreeNode = null - for (var child of graph[node]) { - var candidate = null - if (isInTree[child]) { - candidate = child - } else if (!seen[child]) { - candidate = dfs(child) - } - if (candidate != null && candidate !== reachableTreeNode) { - if (reachableTreeNode == null) { - reachableTreeNode = candidate - } else { - return -1 - } - } - } - return reachableTreeNode - } - for (var i = 0; i < graph.length; i++) { - if (!seen[i]) { - if (dfs(i) === -1) return false - } - } - return true -} - -function puzzleCellsAdjacent(first, second, pillar) { - if (pillar && first.y == second.y && Math.abs(second.x - first.x) === puzzle.width - 1) - return true - return Math.abs(second.x - first.x) + Math.abs(second.y - first.y) === 1 -} - -function bridgeTest(region, puzzle, color, bridges) { - var nodes = region.cells.filter(pos => isCellBridgePathFriendly(puzzle, color, pos)) - var graph = Array.from(Array(nodes.length), () => []) - for (var ir = 1; ir < nodes.length; ir++) { - var right = nodes[ir] - for (var il = 0; il < ir; il++) { - var left = nodes[il] - if (puzzleCellsAdjacent(left, right, puzzle.pillar)) { - graph[il].push(ir) - graph[ir].push(il) - } - } - } - var isBridge = nodes.map(node => bridges.some(bridge => node.x === bridge.x && node.y === bridge.y)) - var isInTree = makeMinimalTree(graph, isBridge.indexOf(true), isBridge) - for (var i = 0; i < nodes.length; i++) { - if (isBridge[i] && !isInTree[i]) return false - } - return isTreeUnique(graph, isInTree) -} - -window.validateBridges = function(puzzle, region, regionData) { - var bridges = {} - for (var pos of region) { - var cell = puzzle.getCell(pos.x, pos.y) - if (cell == null) continue - - // Count color-based elements - if (cell.color != null) { - if (cell.type === 'bridge') { - if (bridges[cell.color] == null) { - bridges[cell.color] = [] - } - bridges[cell.color].push(pos) - } - } - } - - for (var color in bridges) { - var total = 0 - var discardable = 0 - for (var x=1; x < puzzle.width; x+=2) { - for (var y=1; y < puzzle.height; y+=2) { - var cell = puzzle.getCell(x, y) - if (cell != null) { - if (cell.type === 'bridge' && cell.color === color) total++ - if (cell.type === 'nega') discardable++ - } - } - } - - if (bridges[color].length != total) { - if (bridges[color].length >= total - discardable) { - // TODO: Negations in other regions can validate the solution - for (var bridge of bridges[color]) { - regionData.addInvalid(bridge) - } - } else { - for (var bridge of bridges[color]) { - regionData.addVeryInvalid(bridge) - } - } - } else if (!window.bridgeTest(region, puzzle, color, bridges[color])) { - for (var bridge of bridges[color]) { - regionData.addInvalid(bridge) - } - } - } -} - -var DIRECTIONS = [ - {'x': 0, 'y':-1}, - {'x': 1, 'y':-1}, - {'x': 1, 'y': 0}, - {'x': 1, 'y': 1}, - {'x': 0, 'y': 1}, - {'x':-1, 'y': 1}, - {'x':-1, 'y': 0}, - {'x':-1, 'y':-1}, -] - -window.validateArrows = function(puzzle, region, regionData) { - for (var pos of region) { - var cell = puzzle.getCell(pos.x, pos.y) - if (cell == null) continue - if (cell.type != 'arrow') continue - dir = DIRECTIONS[cell.rot] - - var count = 0 - var x = pos.x + dir.x - var y = pos.y + dir.y - for (var i=0; i<100; i++) { // 100 is arbitrary, it's just here to avoid infinite loops. - var line = puzzle.getLine(x, y) - console.spam('Testing', x, y, 'for arrow at', pos.x, pos.y, 'found', line) - if (line == null && (x%2 !== 1 || y%2 !== 1)) break - if (line > window.LINE_NONE) count++ - if (count > cell.count) break - x += dir.x * 2 - y += dir.y * 2 - if (puzzle.matchesSymmetricalPos(x, y, pos.x + dir.x, pos.y + dir.y)) break // Pillar exit condition (in case of looping) - } - if (count !== cell.count) { - console.log('Arrow at', pos.x, pos.y, 'crosses', count, 'lines, but should cross', cell.count) - regionData.addInvalid(pos) - } - } -} - -window.validateSizers = function(puzzle, region, regionData) { - var sizers = [] - var regionSize = 0 - for (var pos of region) { - if (pos.x%2 === 1 && pos.y%2 === 1) regionSize++ // Only count cells for the region - var cell = puzzle.getCell(pos.x, pos.y) - if (cell == null) continue - if (cell.type == 'sizer') sizers.push(pos) - } - console.debug('Found', sizers.length, 'sizers') - if (sizers.length == 0) return // No sizers -- no impact on sizer validity - - var sizerCount = regionSize / sizers.length - if (sizerCount % 1 != 0) { - console.log('Region size', regionSize, 'is not a multiple of # sizers', sizers.length) - for (var sizer of sizers) { - regionData.addInvalid(sizer) - } - return - } - - if (puzzle.sizerCount == null) puzzle.sizerCount = sizerCount // No other sizes have been defined - if (puzzle.sizerCount != sizerCount) { - console.log('sizerCount', sizerCount, 'does not match puzzle sizerCount', puzzle.sizerCount) - for (var sizer of sizers) { - regionData.addInvalid(sizer) - } - } -} - -}) diff --git a/app/assets/javascripts/wittle/display2.js b/app/assets/javascripts/wittle/display2.js deleted file mode 100644 index ddf3968..0000000 --- a/app/assets/javascripts/wittle/display2.js +++ /dev/null @@ -1,316 +0,0 @@ -var SYM_TYPE_NONE = 0 -var SYM_TYPE_HORIZONTAL = 1 -var SYM_TYPE_VERTICAL = 2 -var SYM_TYPE_ROTATIONAL = 3 -var SYM_TYPE_ROTATE_LEFT = 4 -var SYM_TYPE_ROTATE_RIGHT = 5 -var SYM_TYPE_FLIP_XY = 6 -var SYM_TYPE_FLIP_NEG_XY = 7 -var SYM_TYPE_PARALLEL_H = 8 -var SYM_TYPE_PARALLEL_V = 9 -var SYM_TYPE_PARALLEL_H_FLIP = 10 -var SYM_TYPE_PARALLEL_V_FLIP = 11 -var SYM_TYPE_PILLAR_PARALLEL = 12 -var SYM_TYPE_PILLAR_HORIZONTAL = 13 -var SYM_TYPE_PILLAR_VERTICAL = 14 -var SYM_TYPE_PILLAR_ROTATIONAL = 15 - -namespace(function() { - -window.draw = function(puzzle, target='puzzle') { - if (puzzle == null) return - var svg = document.getElementById(target) - console.info('Drawing', puzzle, 'into', svg) - while (svg.firstChild) svg.removeChild(svg.firstChild) - - // Prevent context menu popups within the puzzle - svg.oncontextmenu = function(event) { - event.preventDefault() - } - - if (puzzle.pillar === true) { - // 41*width + 30*2 (padding) + 10*2 (border) - var pixelWidth = 41 * puzzle.width + 80 - } else { - // 41*(width-1) + 24 (extra edge) + 30*2 (padding) + 10*2 (border) - var pixelWidth = 41 * puzzle.width + 63 - } - var pixelHeight = 41 * puzzle.height + 63 - svg.setAttribute('viewbox', '0 0 ' + pixelWidth + ' ' + pixelHeight) - svg.setAttribute('width', pixelWidth) - svg.setAttribute('height', pixelHeight) - - var rect = createElement('rect') - svg.appendChild(rect) - rect.setAttribute('stroke-width', 10) - rect.setAttribute('stroke', window.BORDER) - rect.setAttribute('fill', window.OUTER_BACKGROUND) - // Accounting for the border thickness - rect.setAttribute('x', 5) - rect.setAttribute('y', 5) - rect.setAttribute('width', pixelWidth - 10) // Removing border - rect.setAttribute('height', pixelHeight - 10) // Removing border - - drawCenters(puzzle, svg) - drawGrid(puzzle, svg, target) - drawStartAndEnd(puzzle, svg) - // Draw cell symbols after so they overlap the lines, if necessary - drawSymbols(puzzle, svg, target) - - // For pillar puzzles, add faders for the left and right sides - if (puzzle.pillar === true) { - var defs = window.createElement('defs') - defs.id = 'cursorPos' - defs.innerHTML = '' + - '\n' + - ' \n' + - ' \n' + - ' \n' + - '\n' + - '\n' + - ' \n' + - ' \n' + - '\n' - svg.appendChild(defs) - - var leftBox = window.createElement('rect') - leftBox.setAttribute('x', 16) - leftBox.setAttribute('y', 10) - leftBox.setAttribute('width', 48) - leftBox.setAttribute('height', 41 * puzzle.height + 43) - leftBox.setAttribute('fill', 'url(#fadeInLeft)') - leftBox.setAttribute('style', 'pointer-events: none') - svg.appendChild(leftBox) - - var rightBox = window.createElement('rect') - rightBox.setAttribute('x', 41 * puzzle.width + 22) - rightBox.setAttribute('y', 10) - rightBox.setAttribute('width', 30) - rightBox.setAttribute('height', 41 * puzzle.height + 43) - rightBox.setAttribute('fill', 'url(#fadeOutRight)') - rightBox.setAttribute('style', 'pointer-events: none') - svg.appendChild(rightBox) - } -} - -function drawCenters(puzzle, svg) { - // @Hack that I am not fixing. This switches the puzzle's grid to a floodfilled grid - // where null represents cells which are part of the outside - var savedGrid = puzzle.switchToMaskedGrid() - if (puzzle.pillar === true) { - for (var y=1; y 1) { - // Add rounding for other intersections (handling gap-only corners) - var circ = createElement('circle') - circ.setAttribute('cx', x*41 + 52) - circ.setAttribute('cy', y*41 + 52) - circ.setAttribute('r', 12) - circ.setAttribute('fill', window.FOREGROUND) - svg.appendChild(circ) - } - } - } - } - // Determine if left-side needs a 'wrap indicator' - if (puzzle.pillar === true) { - var x = 0; - for (var y=0; y window.DOT_NONE) { - params.type = 'dot' - if (cell.dot === window.DOT_BLACK) params.color = 'black' - else if (cell.dot === window.DOT_BLUE) params.color = window.LINE_PRIMARY - else if (cell.dot === window.DOT_YELLOW) params.color = window.LINE_SECONDARY - else if (cell.dot === window.DOT_INVISIBLE) { - params.color = window.FOREGROUND - // This makes the invisible dots visible, but only while we're in the editor. - if (document.getElementById('metaButtons') != null) { - params.stroke = 'black' - params.strokeWidth = '2px' - } - } - drawSymbolWithSvg(svg, params) - } else if (cell.gap === window.GAP_BREAK) { - // Gaps were handled above, while drawing the grid. - } else if (x%2 === 1 && y%2 === 1) { - // Generic draw for all other elements - Object.assign(params, cell) - window.drawSymbolWithSvg(svg, params, puzzle.settings.CUSTOM_MECHANICS) - } - } - } -} - -function drawStartAndEnd(puzzle, svg) { - for (var x=0; x= 4 || y >= 4) return false - return (polyshape & mask(x, y)) !== 0 -} - -// This is 2^20, whereas all the other bits fall into 2^(0-15) -window.ROTATION_BIT = (1 << 20) - -window.isRotated = function(polyshape) { - return (polyshape & ROTATION_BIT) !== 0 -} - -function getRotations(polyshape) { - if (!isRotated(polyshape)) return [polyshape] - - var rotations = [0, 0, 0, 0] - for (var x=0; x<4; x++) { - for (var y=0; y<4; y++) { - if (isSet(polyshape, x, y)) { - rotations[0] ^= mask(x, y) - rotations[1] ^= mask(y, 3-x) - rotations[2] ^= mask(3-x, 3-y) - rotations[3] ^= mask(3-y, x) - } - } - } - - return rotations -} - -// 90 degree rotations of the polyomino -window.rotatePolyshape = function(polyshape, count=1) { - var rotations = getRotations(polyshape | window.ROTATION_BIT) - return rotations[count % 4] -} - -// IMPORTANT NOTE: When formulating these, the top row must contain (0, 0) -// That means there will never be any negative y values. -// (0, 0) must also be a cell in the shape, so that -// placing the shape at (x, y) will fill (x, y) -// Ylops will have -1s on all adjacent cells, to break "overlaps" for polyominos. -window.polyominoFromPolyshape = function(polyshape, ylop=false, precise=true) { - var topLeft = null - for (var y=0; y<4; y++) { - for (var x=0; x<4; x++) { - if (isSet(polyshape, x, y)) { - topLeft = {'x':x, 'y':y} - break - } - } - if (topLeft != null) break - } - if (topLeft == null) return [] // Empty polyomino - - var polyomino = [] - for (var x=0; x<4; x++) { - for (var y=0; y<4; y++) { - if (!isSet(polyshape, x, y)) continue - polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y)}) - - // "Precise" polyominos adds cells in between the apparent squares in the polyomino. - // This prevents the solution line from going through polyominos in the solution. - if (precise) { - if (ylop) { - // Ylops fill up/left if no adjacent cell, and always fill bottom/right - if (!isSet(polyshape, x - 1, y)) { - polyomino.push({'x':2*(x - topLeft.x) - 1, 'y':2*(y - topLeft.y)}) - } - if (!isSet(polyshape, x, y - 1)) { - polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y) - 1}) - } - polyomino.push({'x':2*(x - topLeft.x) + 1, 'y':2*(y - topLeft.y)}) - polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y) + 1}) - } else { - // Normal polys only fill bottom/right if there is an adjacent cell. - if (isSet(polyshape, x + 1, y)) { - polyomino.push({'x':2*(x - topLeft.x) + 1, 'y':2*(y - topLeft.y)}) - } - if (isSet(polyshape, x, y + 1)) { - polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y) + 1}) - } - } - } - } - } - return polyomino -} - -window.polyshapeFromPolyomino = function(polyomino) { - var topLeft = {'x': 9999, 'y': 9999} - for (var pos of polyomino) { - if (pos.x%2 != 1 || pos.y%2 != 1) continue // We only care about cells, not edges or intersections - - // Unlike when we're making a polyomino, we just want to top and left flush the shape, - // we don't actually need (0, 0) to be filled. - if (pos.x < topLeft.x) topLeft.x = pos.x - if (pos.y < topLeft.y) topLeft.y = pos.y - } - if (topLeft == null) return 0 // Empty polyomino - - var polyshape = 0 - for (var pos of polyomino) { - if (pos.x%2 != 1 || pos.y%2 != 1) continue // We only care about cells, not edges or intersections - var x = (pos.x - topLeft.x) / 2 // 0.5x to convert from puzzle coordinates to polyshape coordinates - var y = (pos.y - topLeft.y) / 2 // 0.5x to convert from puzzle coordinates to polyshape coordinates - polyshape |= mask(x, y) - } - - return polyshape -} - -// In some cases, polyominos and onimoylops will fully cancel each other out. -// However, even if they are the same size, that doesn't guarantee that they fit together. -// As an optimization, we save the results for known combinations of shapes, since there are likely many -// fewer pairings of shapes than paths through the grid. -var knownCancellations = {} - -// Attempt to fit polyominos in a region into the puzzle. -// This function checks for early exits, then simplifies the grid to a numerical representation: -// * 1 represents a square that has been double-covered (by two polyominos) -// * Or, in the cancellation case, it represents a square that was covered by a polyomino and not by an onimoylop -// * 0 represents a square that is satisfied, either because: -// * it is outside the region -// * (In the normal case) it was inside the region, and has been covered by a polyomino -// * (In the cancellation case) it was covered by an equal number of polyominos and onimoylops -// * -1 represents a square that needs to be covered once (inside the region, or outside but covered by an onimoylop) -// * -2 represents a square that needs to be covered twice (inside the region & covered by an onimoylop) -// * And etc, for additional layers of polyominos/onimoylops. -window.polyFit = function(region, puzzle) { - var polys = [] - var ylops = [] - var polyCount = 0 - var regionSize = 0 - for (var pos of region) { - if (pos.x%2 === 1 && pos.y%2 === 1) regionSize++ - var cell = puzzle.grid[pos.x][pos.y] - if (cell == null) continue - if (cell.polyshape === 0) continue - if (cell.type === 'poly') { - polys.push(cell) - polyCount += getPolySize(cell.polyshape) - } else if (cell.type === 'ylop') { - ylops.push(cell) - polyCount -= getPolySize(cell.polyshape) - } - } - if (polys.length + ylops.length === 0) { - console.log('No polyominos or onimoylops inside the region, vacuously true') - return true - } - if (polyCount > 0 && polyCount !== regionSize) { - console.log('Combined size of polyominos and onimoylops', polyCount, 'does not match region size', regionSize) - return false - } - if (polyCount < 0) { - console.log('Combined size of onimoylops is greater than polyominos by', -polyCount) - return false - } - var key = null - if (polyCount === 0) { - if (puzzle.settings.SHAPELESS_ZERO_POLY) { - console.log('Combined size of polyominos and onimoylops is zero') - return true - } - // These will be ordered by the order of cells in the region, which isn't exactly consistent. - // In practice, it seems to be good enough. - key = '' - for (var ylop of ylops) key += ' ' + ylop.polyshape - key += '|' - for (var poly of polys) key += ' ' + poly.polyshape - var ret = knownCancellations[key] - if (ret != null) return ret - } - - // For polyominos, we clear the grid to mark it up again: - var savedGrid = puzzle.grid - puzzle.newGrid() - // First, we mark all cells as 0: Cells outside the target region should be unaffected. - for (var x=0; x 0) { - for (var pos of region) puzzle.grid[pos.x][pos.y] = -1 - } - // In the exact match case, we leave every cell marked 0: Polys and ylops need to cancel. - - var ret = placeYlops(ylops, 0, polys, puzzle) - if (polyCount === 0) knownCancellations[key] = ret - puzzle.grid = savedGrid - return ret -} - -// If false, poly doesn't fit and grid is unmodified -// If true, poly fits and grid is modified (with the placement) -function tryPlacePolyshape(cells, x, y, puzzle, sign) { - console.spam('Placing at', x, y, 'with sign', sign) - var numCells = cells.length - for (var i=0; i 0) { - console.log('Cell', x, y, 'has been overfilled and no ylops left to place') - return false - } - if (allPolysPlaced && cell < 0 && x%2 === 1 && y%2 === 1) { - // Normal, center cell with a negative value & no polys remaining. - console.log('All polys placed, but grid not full') - return false - } - } - } - if (allPolysPlaced) { - console.log('All polys placed, and grid full') - return true - } - - // The top-left (first open cell) must be filled by a polyomino. - // However in the case of pillars, there is no top-left, so we try all open cells in the - // top-most open row - var openCells = [] - for (var y=1; y= 0) continue - openCells.push({'x':x, 'y':y}) - if (puzzle.pillar === false) break - } - if (openCells.length > 0) break - } - - if (openCells.length === 0) { - console.log('Polys remaining but grid full') - return false - } - - for (var openCell of openCells) { - var attemptedPolyshapes = [] - for (var i=0; i0 polys, but no valid recursion.') - return false -} - -}) diff --git a/app/assets/javascripts/wittle/puzzle.js b/app/assets/javascripts/wittle/puzzle.js deleted file mode 100644 index cb0b20a..0000000 --- a/app/assets/javascripts/wittle/puzzle.js +++ /dev/null @@ -1,538 +0,0 @@ -namespace(function() { - -// A 2x2 grid is internally a 5x5: -// corner, edge, corner, edge, corner -// edge, cell, edge, cell, edge -// corner, edge, corner, edge, corner -// edge, cell, edge, cell, edge -// corner, edge, corner, edge, corner -// -// Corners and edges will have a value of true if the line passes through them -// Cells will contain an object if there is an element in them -window.Puzzle = class { - constructor(width, height, pillar=false) { - if (pillar === true) { - this.newGrid(2 * width, 2 * height + 1) - } else { - this.newGrid(2 * width + 1, 2 * height + 1) - } - this.pillar = pillar - this.settings = { - // If true, negation symbols are allowed to cancel other negation symbols. - NEGATIONS_CANCEL_NEGATIONS: true, - - // If true, and the count of polyominos and onimoylops is zero, they cancel regardless of shape. - SHAPELESS_ZERO_POLY: false, - - // If true, the traced line cannot go through the placement of a polyomino. - PRECISE_POLYOMINOS: true, - - // If false, incorrect elements will not flash when failing the puzzle. - FLASH_FOR_ERRORS: true, - - // If true, mid-segment startpoints will constitute solid lines, and form boundaries for the region. - FAT_STARTPOINTS: false, - - // If true, custom mechanics are displayed (and validated) in this puzzle. - CUSTOM_MECHANICS: false, - - // If true, polyominos may be placed partially off of the grid as an intermediate solution step. - // OUT_OF_BOUNDS_POLY: false, - - // If true, the symmetry line will be invisible. - INVISIBLE_SYMMETRY: false, - } - } - - static deserialize(json) { - var parsed = JSON.parse(json) - // Claim that it's not a pillar (for consistent grid sizing), then double-check ourselves later. - var puzzle = new Puzzle((parsed.grid.length - 1)/2, (parsed.grid[0].length - 1)/2) - puzzle.name = parsed.name - puzzle.autoSolved = parsed.autoSolved - puzzle.grid = parsed.grid - // Legacy: Grid squares used to use 'false' to indicate emptiness. - // Legacy: Cells may use {} to represent emptiness - // Now, we use: - // Cells default to null - // During onTraceStart, empty cells that are still inbounds are changed to {'type': 'nonce'} for tracing purposes. - // Lines default to {'type':'line', 'line':0} - for (var x=0; x= this.width) return false - if (y < 0 || y >= this.height) return false - return true - } - - getCell(x, y) { - x = this._mod(x) - if (!this._safeCell(x, y)) return null - return this.grid[x][y] - } - - setCell(x, y, value) { - x = this._mod(x) - if (!this._safeCell(x, y)) return - this.grid[x][y] = value - } - - getSymmetricalDir(dir) { - if (this.symType == SYM_TYPE_VERTICAL || this.symType == SYM_TYPE_ROTATIONAL || this.symType == SYM_TYPE_PARALLEL_H_FLIP) { - if (dir === 'left') return 'right' - if (dir === 'right') return 'left' - } - if (this.symType == SYM_TYPE_HORIZONTAL || this.symType == SYM_TYPE_ROTATIONAL || this.symType == SYM_TYPE_PARALLEL_V_FLIP) { - if (dir === 'top') return 'bottom' - if (dir === 'bottom') return 'top' - } - if (this.symType == SYM_TYPE_ROTATE_LEFT || this.symType == SYM_TYPE_FLIP_NEG_XY) { - if (dir === 'left') return 'bottom' - if (dir === 'right') return 'top' - } - if (this.symType == SYM_TYPE_ROTATE_RIGHT || this.symType == SYM_TYPE_FLIP_NEG_XY) { - if (dir === 'top') return 'right' - if (dir === 'bottom') return 'left' - } - if (this.symType == SYM_TYPE_ROTATE_LEFT || this.symType == SYM_TYPE_FLIP_XY) { - if (dir === 'top') return 'left' - if (dir === 'bottom') return 'right' - } - if (this.symType == SYM_TYPE_ROTATE_RIGHT || this.symType == SYM_TYPE_FLIP_XY) { - if (dir === 'right') return 'bottom' - if (dir === 'left') return 'top' - } - return dir - } - - // The resulting position is guaranteed to be gridsafe. - getSymmetricalPos(x, y) { - var origx = x - var origy = y - - if (this.symType == SYM_TYPE_VERTICAL || this.symType == SYM_TYPE_ROTATIONAL || this.symType == SYM_TYPE_PARALLEL_H_FLIP) { - x = (this.width - 1) - origx - } - if (this.symType == SYM_TYPE_HORIZONTAL || this.symType == SYM_TYPE_ROTATIONAL || this.symType == SYM_TYPE_PARALLEL_V_FLIP) { - y = (this.height - 1) - origy - } - if (this.symType == SYM_TYPE_ROTATE_LEFT || this.symType == SYM_TYPE_FLIP_XY) { - x = origy - } - if (this.symType == SYM_TYPE_ROTATE_RIGHT || this.symType == SYM_TYPE_FLIP_XY) { - y = origx - } - if (this.symType == SYM_TYPE_ROTATE_LEFT || this.symType == SYM_TYPE_FLIP_NEG_XY) { - y = (this.width - 1) - origx - } - if (this.symType == SYM_TYPE_ROTATE_RIGHT || this.symType == SYM_TYPE_FLIP_NEG_XY) { - x = (this.height - 1) - origy - } - if (this.symType == SYM_TYPE_PARALLEL_H || this.symType == SYM_TYPE_PARALLEL_H_FLIP) { - y = (origy == this.height / 2) ? (this.height / 2) : ((origy + (this.height + 1) / 2) % (this.height + 1)) - } - if (this.symType == SYM_TYPE_PARALLEL_V || this.symType == SYM_TYPE_PARALLEL_V_FLIP) { - x = (origx == this.width / 2) ? (this.width / 2) : ((origx + (this.width + 1) / 2) % (this.width + 1)) - } - - return {'x':this._mod(x), 'y':y} - } - - getSymmetricalCell(x, y) { - var pos = this.getSymmetricalPos(x, y) - return this.getCell(pos.x, pos.y) - } - - matchesSymmetricalPos(x1, y1, x2, y2) { - return (y1 === y2 && this._mod(x1) === x2) - } - - // A variant of getCell which specifically returns line values, - // and treats objects as being out-of-bounds - getLine(x, y) { - var cell = this.getCell(x, y) - if (cell == null) return null - if (cell.type !== 'line') return null - return cell.line - } - - updateCell2(x, y, key, value) { - x = this._mod(x) - if (!this._safeCell(x, y)) return - var cell = this.grid[x][y] - if (cell == null) return - cell[key] = value - } - - getValidEndDirs(x, y) { - x = this._mod(x) - if (!this._safeCell(x, y)) return [] - - var dirs = [] - var leftCell = this.getCell(x - 1, y) - if (leftCell == null || leftCell.gap === window.GAP_FULL) dirs.push('left') - var topCell = this.getCell(x, y - 1) - if (topCell == null || topCell.gap === window.GAP_FULL) dirs.push('top') - var rightCell = this.getCell(x + 1, y) - if (rightCell == null || rightCell.gap === window.GAP_FULL) dirs.push('right') - var bottomCell = this.getCell(x, y + 1) - if (bottomCell == null || bottomCell.gap === window.GAP_FULL) dirs.push('bottom') - return dirs - } - - // Note: Does not use this.width/this.height, so that it may be used to ask about resizing. - getSizeError(width, height) { - if (this.pillar && width < 4) return 'Pillars may not have a width of 1' - if (width * height < 25) return 'Puzzles may not be smaller than 2x2 or 1x4' - if (width > 21 || height > 21) return 'Puzzles may not be larger than 10 in either dimension' - if (this.symmetry != null) { - if (this.symmetry.x && width <= 2) return 'Symmetrical puzzles must be sufficiently wide for both lines' - if (this.symmetry.y && height <= 2) return 'Symmetrical puzzles must be sufficiently wide for both lines' - if (this.pillar && this.symmetry.x && width % 4 !== 0) return 'X + Pillar symmetry must be an even number of rows, to keep both startpoints at the same parity' - } - - return null - } - - - // Called on a solution. Computes a list of gaps to show as hints which *do not* - // break the path. - loadHints() { - this.hints = [] - for (var x=0; x window.LINE_NONE) { - this.hints.push({'x':x, 'y':y}) - } - } - } - } - - // Show a hint on the grid. - // If no hint is provided, will select the best one it can find, - // prioritizing breaking current lines on the grid. - // Returns the shown hint. - showHint(hint) { - if (hint != null) { - this.grid[hint.x][hint.y].gap = window.GAP_BREAK - return - } - - var goodHints = [] - var badHints = [] - - for (var hint of this.hints) { - if (this.getLine(hint.x, hint.y) > window.LINE_NONE) { - // Solution will be broken by this hint - goodHints.push(hint) - } else { - badHints.push(hint) - } - } - if (goodHints.length > 0) { - var hint = goodHints.splice(window.randInt(goodHints.length), 1)[0] - } else if (badHints.length > 0) { - var hint = badHints.splice(window.randInt(badHints.length), 1)[0] - } else { - return - } - this.grid[hint.x][hint.y].gap = window.GAP_BREAK - this.hints = badHints.concat(goodHints) - return hint - } - - clearLines() { - for (var x=0; x 0) this._floodFill(x, y - 1, region, col) - if (x < this.width - 1) this._floodFill(x + 1, y, region, this.grid[x+1]) - else if (this.pillar !== false) this._floodFill(0, y, region, this.grid[0]) - if (x > 0) this._floodFill(x - 1, y, region, this.grid[x-1]) - else if (this.pillar !== false) this._floodFill(this.width-1, y, region, this.grid[this.width-1]) - } - - // Re-uses the same grid, but only called on edges which border the outside - // Called first to mark cells that are connected to the outside, i.e. should not be part of any region. - _floodFillOutside(x, y, col) { - var cell = col[y] - if (cell === MASKED_PROCESSED) return - if (x%2 !== y%2 && cell !== MASKED_GAP2) return // Only flood-fill through gap-2 - if (x%2 === 0 && y%2 === 0 && cell === MASKED_DOT) return // Don't flood-fill through dots - col[y] = MASKED_PROCESSED - - if (x%2 === 0 && y%2 === 0) return // Don't flood fill through corners (what? Clarify.) - - if (y < this.height - 1) this._floodFillOutside(x, y + 1, col) - if (y > 0) this._floodFillOutside(x, y - 1, col) - if (x < this.width - 1) this._floodFillOutside(x + 1, y, this.grid[x+1]) - else if (this.pillar !== false) this._floodFillOutside(0, y, this.grid[0]) - if (x > 0) this._floodFillOutside(x - 1, y, this.grid[x-1]) - else if (this.pillar !== false) this._floodFillOutside(this.width-1, y, this.grid[this.width-1]) - } - - // Returns the original grid (pre-masking). You will need to switch back once you are done flood filling. - switchToMaskedGrid() { - // Make a copy of the grid -- we will be overwriting it - var savedGrid = this.grid - this.grid = new Array(this.width) - // Override all elements with empty lines -- this means that flood fill is just - // looking for lines with line=0. - for (var x=0; x window.LINE_NONE) { - row[y] = MASKED_PROCESSED // Traced lines should not be a part of the region - } else if (cell.gap === window.GAP_FULL) { - row[y] = MASKED_GAP2 - } else if (cell.dot > window.DOT_NONE) { - row[y] = MASKED_DOT - } else { - row[y] = MASKED_INB_COUNT - } - } - this.grid[x] = row - } - - // Starting at a mid-segment startpoint - if (this.startPoint != null && this.startPoint.x%2 !== this.startPoint.y%2) { - if (this.settings.FAT_STARTPOINTS) { - // This segment is not in any region (acts as a barrier) - this.grid[this.startPoint.x][this.startPoint.y] = MASKED_OOB - } else { - // This segment is part of this region (acts as an empty cell) - this.grid[this.startPoint.x][this.startPoint.y] = MASKED_INB_NONCOUNT - } - } - - // Ending at a mid-segment endpoint - if (this.endPoint != null && this.endPoint.x%2 !== this.endPoint.y%2) { - // This segment is part of this region (acts as an empty cell) - this.grid[this.endPoint.x][this.endPoint.y] = MASKED_INB_NONCOUNT - } - - // Mark all outside cells as 'not in any region' (aka null) - - // Top and bottom edges - for (var x=1; x 0) row[x] = ' ' - if (cell.dot > 0) row[x] = 'X' - if (cell.line === 0) row[x] = '.' - if (cell.line === 1) row[x] = '#' - if (cell.line === 2) row[x] = '#' - if (cell.line === 3) row[x] = 'o' - } else row[x] = '?' - } - output += row.join('') + '\n' - } - console.info(output) - } -} - -// The grid contains 5 colors: -// null: Out of bounds or already processed -var MASKED_OOB = null -var MASKED_PROCESSED = null -// 0: In bounds, awaiting processing, but should not be part of the final region. -var MASKED_INB_NONCOUNT = 0 -// 1: In bounds, awaiting processing -var MASKED_INB_COUNT = 1 -// 2: Gap-2. After _floodFillOutside, this means "treat normally" (it will be null if oob) -var MASKED_GAP2 = 2 -// 3: Dot (of any kind), otherwise identical to 1. Should not be flood-filled through (why the f do we need this) -var MASKED_DOT = 3 - -}) diff --git a/app/assets/javascripts/wittle/serializer.js b/app/assets/javascripts/wittle/serializer.js deleted file mode 100644 index 70c7f0f..0000000 --- a/app/assets/javascripts/wittle/serializer.js +++ /dev/null @@ -1,365 +0,0 @@ -namespace(function() { - -window.serializePuzzle = function(puzzle) { - var s = new Serializer('w') - var version = 0 - - s.writeInt(version) - s.writeByte(puzzle.width) - s.writeByte(puzzle.height) - s.writeString(puzzle.name) - - var genericFlags = 0 - if (puzzle.autoSolved) genericFlags |= GENERIC_FLAG_AUTOSOLVED - if (puzzle.symmetry) { - genericFlags |= GENERIC_FLAG_SYMMETRICAL - if (puzzle.symmetry.x) genericFlags |= GENERIC_FLAG_SYMMETRY_X - if (puzzle.symmetry.y) genericFlags |= GENERIC_FLAG_SYMMETRY_Y - } - if (puzzle.pillar) genericFlags |= GENERIC_FLAG_PILLAR - s.writeByte(genericFlags) - for (var x=0; x 0) { - s.writeInt(puzzle.path.length) - s.writeByte(startPos.x) - s.writeByte(startPos.y) - for (var dir of puzzle.path) s.writeByte(dir) - } - } else { - s.writeInt(0) - } - - var settingsFlags = 0 - if (puzzle.settings.NEGATIONS_CANCEL_NEGATIONS) settingsFlags |= SETTINGS_FLAG_NCN - if (puzzle.settings.SHAPELESS_ZERO_POLY) settingsFlags |= SETTINGS_FLAG_SZP - if (puzzle.settings.PRECISE_POLYOMINOS) settingsFlags |= SETTINGS_FLAG_PP - if (puzzle.settings.FLASH_FOR_ERRORS) settingsFlags |= SETTINGS_FLAG_FFE - if (puzzle.settings.FAT_STARTPOINTS) settingsFlags |= SETTINGS_FLAG_FS - if (puzzle.settings.CUSTOM_MECHANICS) settingsFlags |= SETTINGS_FLAG_CM - if (puzzle.settings.INVISIBLE_SYMMETRY) settingsFlags |= SETTINGS_FLAG_IS - s.writeByte(settingsFlags) - - s.writeByte(puzzle.symType) - - return s.str() -} - -window.deserializePuzzle = function(data) { - // Data is JSON, so decode it with the old deserializer - if (data[0] == '{') return Puzzle.deserialize(data) - - var s = new Serializer('r', data) - var version = s.readInt() - if (version > 0) throw Error('Cannot read data from unknown version: ' + version) - - var width = s.readByte() - var height = s.readByte() - var puzzle = new Puzzle(Math.floor(width / 2), Math.floor(height / 2)) - puzzle.name = s.readString() - - var genericFlags = s.readByte() - puzzle.autoSolved = genericFlags & GENERIC_FLAG_AUTOSOLVED - puzzle.symType = SYM_TYPE_NONE - if ((genericFlags & GENERIC_FLAG_SYMMETRICAL) != 0) { - if ((genericFlags & GENERIC_FLAG_SYMMETRY_X) != 0) { - if ((genericFlags & GENERIC_FLAG_SYMMETRY_Y) != 0) { - puzzle.symType = SYM_TYPE_ROTATIONAL - } else { - puzzle.symType = SYM_TYPE_VERTICAL - } - } else if ((genericFlags & GENERIC_FLAG_SYMMETRY_Y) != 0) { - puzzle.symType = SYM_TYPE_HORIZONTAL - } - } - puzzle.pillar = (genericFlags & GENERIC_FLAG_PILLAR) != 0 - for (var x=0; x 0) { - var path = [{ - 'x': s.readByte(), - 'y': s.readByte(), - }] - for (var i=0; i= numBytes) - } - - readByte() { - this._checkRead() - return this.data.charCodeAt(this.index++) - } - - writeByte(b) { - if (b < 0 || b > 0xFF) throw Error('Cannot write out-of-range byte ' + b) - this.data += String.fromCharCode(b) - } - - readInt() { - var b1 = this.readByte() << 0 - var b2 = this.readByte() << 8 - var b3 = this.readByte() << 16 - var b4 = this.readByte() << 24 - return b1 | b2 | b3 | b4 - } - - writeInt(i) { - if (i < 0 || i > 0xFFFFFFFF) throw Error('Cannot write out-of-range int ' + i) - var b1 = (i & 0x000000FF) >> 0 - var b2 = (i & 0x0000FF00) >> 8 - var b3 = (i & 0x00FF0000) >> 16 - var b4 = (i & 0xFF000000) >> 24 - this.writeByte(b1) - this.writeByte(b2) - this.writeByte(b3) - this.writeByte(b4) - } - - readLong() { - var i1 = this.readInt() << 32 - var i2 = this.readInt() - return i1 | i2 - } - - writeLong(l) { - if (l < 0 || l > 0xFFFFFFFFFFFFFFFF) throw Error('Cannot write out-of-range long ' + l) - var i1 = l & 0xFFFFFFFF - var i2 = (l - i1) / 0x100000000 - this.writeInt(i1) - this.writeInt(i2) - } - - readString() { - var len = this.readInt() - this._checkRead(len) - var str = this.data.substr(this.index, len) - this.index += len - return str - } - - writeString(s) { - if (s == null) { - this.writeInt(0) - return - } - this.writeInt(s.length) - this.data += s - } - - readColor() { - var r = this.readByte().toString() - var g = this.readByte().toString() - var b = this.readByte().toString() - var a = this.readByte().toString() - return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + a + ')' - } - - writeColor(c) { - // Adapted from https://gist.github.com/njvack/02ad8efcb0d552b0230d - this.colorConverter.fillStyle = 'rgba(0, 0, 0, 0)' // Load a default in case we are passed garbage - this.colorConverter.clearRect(0, 0, 1, 1) - this.colorConverter.fillStyle = c - this.colorConverter.fillRect(0, 0, 1, 1) - var rgba = this.colorConverter.getImageData(0, 0, 1, 1).data - this.writeByte(rgba[0]) - this.writeByte(rgba[1]) - this.writeByte(rgba[2]) - this.writeByte(rgba[3]) - } - - readCell() { - var cellType = this.readByte() - if (cellType === CELL_TYPE_NULL) return null - - var cell = {} - cell.dir = null - cell.line = 0 - if (cellType === CELL_TYPE_LINE) { - cell.type = 'line' - cell.line = this.readByte() - var dot = this.readByte() - if (dot != 0) cell.dot = dot - var gap = this.readByte() - if (gap != 0) cell.gap = gap - } else if (cellType === CELL_TYPE_SQUARE) { - cell.type = 'square' - cell.color = this.readColor() - } else if (cellType === CELL_TYPE_STAR) { - cell.type = 'star' - cell.color = this.readColor() - } else if (cellType === CELL_TYPE_NEGA) { - cell.type = 'nega' - cell.color = this.readColor() - } else if (cellType === CELL_TYPE_TRIANGLE) { - cell.type = 'triangle' - cell.color = this.readColor() - cell.count = this.readByte() - } else if (cellType === CELL_TYPE_POLY) { - cell.type = 'poly' - cell.color = this.readColor() - cell.polyshape = this.readLong() - } else if (cellType === CELL_TYPE_YLOP) { - cell.type = 'ylop' - cell.color = this.readColor() - cell.polyshape = this.readLong() - } else if (cellType == CELL_TYPE_NONCE) { - cell.type = 'nonce' - } - - var startEnd = this.readByte() - if (startEnd & CELL_START) cell.start = true - if (startEnd & CELL_END_LEFT) cell.end = 'left' - if (startEnd & CELL_END_RIGHT) cell.end = 'right' - if (startEnd & CELL_END_TOP) cell.end = 'top' - if (startEnd & CELL_END_BOTTOM) cell.end = 'bottom' - - return cell - } - - - writeCell(cell) { - if (cell == null) { - this.writeByte(CELL_TYPE_NULL) - return - } - - // Write cell type, then cell data, then generic data. - // Note that cell type starts at 1, since 0 is the "null type". - if (cell.type == 'line') { - this.writeByte(CELL_TYPE_LINE) - this.writeByte(cell.line) - this.writeByte(cell.dot) - this.writeByte(cell.gap) - } else if (cell.type == 'square') { - this.writeByte(CELL_TYPE_SQUARE) - this.writeColor(cell.color) - } else if (cell.type == 'star') { - this.writeByte(CELL_TYPE_STAR) - this.writeColor(cell.color) - } else if (cell.type == 'nega') { - this.writeByte(CELL_TYPE_NEGA) - this.writeColor(cell.color) - } else if (cell.type == 'triangle') { - this.writeByte(CELL_TYPE_TRIANGLE) - this.writeColor(cell.color) - this.writeByte(cell.count) - } else if (cell.type == 'poly') { - this.writeByte(CELL_TYPE_POLY) - this.writeColor(cell.color) - this.writeLong(cell.polyshape) - } else if (cell.type == 'ylop') { - this.writeByte(CELL_TYPE_YLOP) - this.writeColor(cell.color) - this.writeLong(cell.polyshape) - } - - var startEnd = 0 - if (cell.start === true) startEnd |= CELL_START - if (cell.end == 'left') startEnd |= CELL_END_LEFT - if (cell.end == 'right') startEnd |= CELL_END_RIGHT - if (cell.end == 'top') startEnd |= CELL_END_TOP - if (cell.end == 'bottom') startEnd |= CELL_END_BOTTOM - this.writeByte(startEnd) - } -} - -var CELL_TYPE_NULL = 0 -var CELL_TYPE_LINE = 1 -var CELL_TYPE_SQUARE = 2 -var CELL_TYPE_STAR = 3 -var CELL_TYPE_NEGA = 4 -var CELL_TYPE_TRIANGLE = 5 -var CELL_TYPE_POLY = 6 -var CELL_TYPE_YLOP = 7 -var CELL_TYPE_NONCE = 8 - -var CELL_START = 1 -var CELL_END_LEFT = 2 -var CELL_END_RIGHT = 4 -var CELL_END_TOP = 8 -var CELL_END_BOTTOM = 16 - -var GENERIC_FLAG_AUTOSOLVED = 1 -var GENERIC_FLAG_SYMMETRICAL = 2 -var GENERIC_FLAG_SYMMETRY_X = 4 -var GENERIC_FLAG_SYMMETRY_Y = 8 -var GENERIC_FLAG_PILLAR = 16 - -var SETTINGS_FLAG_NCN = 1 -var SETTINGS_FLAG_SZP = 2 -var SETTINGS_FLAG_PP = 4 -var SETTINGS_FLAG_FFE = 8 -var SETTINGS_FLAG_FS = 16 -var SETTINGS_FLAG_CM = 32 -var SETTINGS_FLAG_IS = 64 - -}) diff --git a/app/assets/javascripts/wittle/solve.js b/app/assets/javascripts/wittle/solve.js deleted file mode 100644 index 8695291..0000000 --- a/app/assets/javascripts/wittle/solve.js +++ /dev/null @@ -1,531 +0,0 @@ -namespace(function() { - -// @Volatile -- must match order of MOVE_* in trace2 -// Move these, dummy. -var PATH_NONE = 0 -var PATH_LEFT = 1 -var PATH_RIGHT = 2 -var PATH_TOP = 3 -var PATH_BOTTOM = 4 - -window.MAX_SOLUTIONS = 0 -var solutionPaths = [] -var asyncTimer = 0 -var task = null -var puzzle = null -var path = [] -var SOLVE_SYNC = false -var SYNC_THRESHOLD = 9 // Depth at which we switch to a synchronous solver (for perf) -var doPruning = false - -var percentages = [] -var NODE_DEPTH = 9 -var nodes = 0 -function countNodes(x, y, depth) { - // Check for collisions (outside, gap, self, other) - var cell = puzzle.getCell(x, y) - if (cell == null) return - if (cell.gap > window.GAP_NONE) return - if (cell.line !== window.LINE_NONE) return - - if (puzzle.symType == SYM_TYPE_NONE) { - puzzle.updateCell2(x, y, 'line', window.LINE_BLACK) - } else { - var sym = puzzle.getSymmetricalPos(x, y) - if (puzzle.matchesSymmetricalPos(x, y, sym.x, sym.y)) return // Would collide with our reflection - - var symCell = puzzle.getCell(sym.x, sym.y) - if (symCell.gap > window.GAP_NONE) return - - puzzle.updateCell2(x, y, 'line', window.LINE_BLUE) - puzzle.updateCell2(sym.x, sym.y, 'line', window.LINE_YELLOW) - } - - if (depth < NODE_DEPTH) { - nodes++ - - if (y%2 === 0) { - countNodes(x - 1, y, depth + 1) - countNodes(x + 1, y, depth + 1) - } - - if (x%2 === 0) { - countNodes(x, y - 1, depth + 1) - countNodes(x, y + 1, depth + 1) - } - } - - tailRecurse(x, y) -} - -// Generates a solution via DFS recursive backtracking -window.solve = function(p, partialCallback, finalCallback) { - if (task != null) throw Error('Cannot start another solve() while one is already in progress') - var start = (new Date()).getTime() - - puzzle = p - var startPoints = [] - var numEndpoints = 0 - puzzle.hasNegations = false - puzzle.hasPolyominos = false - for (var x=0; x 0) { - // Tasks are pushed in order. To do DFS, we need to enqueue them in reverse order. - for (var i=newTasks.length - 1; i >= 0; i--) { - task = { - 'code': newTasks[i], - 'nextTask': task, - } - } - } - - // Asynchronizing is expensive. As such, we don't want to do it too often. - // However, we would like 'cancel solving' to be responsive. So, we call setTimeout every so often. - var doAsync = false - if (!SOLVE_SYNC) { - doAsync = (asyncTimer++ % 100 === 0) - while (nodes >= percentages[0]) { - if (partialCallback) partialCallback(100 - percentages.length) - percentages.shift() - doAsync = true - } - } - - if (doAsync) { - setTimeout(function() { - taskLoop(partialCallback, finalCallback) - }, 0) - } else { - taskLoop(partialCallback, finalCallback) - } -} - -function tailRecurse(x, y) { - // Tail recursion: Back out of this cell - puzzle.updateCell2(x, y, 'line', window.LINE_NONE) - if (puzzle.symType != SYM_TYPE_NONE) { - var sym = puzzle.getSymmetricalPos(x, y) - puzzle.updateCell2(sym.x, sym.y, 'line', window.LINE_NONE) - } -} - -// @Performance: This is the most central loop in this code. -// Any performance efforts should be focused here. -// Note: Most mechanics are NP (or harder), so don't feel bad about solving them by brute force. -// https://arxiv.org/pdf/1804.10193.pdf -function solveLoop(x, y, numEndpoints, earlyExitData) { - // Stop trying to solve once we reach our goal - if (solutionPaths.length >= window.MAX_SOLUTIONS) return - - // Check for collisions (outside, gap, self, other) - var cell = puzzle.getCell(x, y) - if (cell == null) return - if (cell.gap > window.GAP_NONE) return - if (cell.line !== window.LINE_NONE) return - - if (puzzle.symType == SYM_TYPE_NONE) { - puzzle.updateCell2(x, y, 'line', window.LINE_BLACK) - } else { - var sym = puzzle.getSymmetricalPos(x, y) - if (puzzle.matchesSymmetricalPos(x, y, sym.x, sym.y)) return // Would collide with our reflection - - var symCell = puzzle.getCell(sym.x, sym.y) - if (symCell.gap > window.GAP_NONE) return - - puzzle.updateCell2(x, y, 'line', window.LINE_BLUE) - puzzle.updateCell2(sym.x, sym.y, 'line', window.LINE_YELLOW) - } - - if (path.length < NODE_DEPTH) nodes++ - - if (cell.end != null) { - path.push(PATH_NONE) - puzzle.endPoint = {'x': x, 'y': y} - var puzzleData = window.validate(puzzle, true) - if (puzzleData.valid()) solutionPaths.push(path.slice()) - path.pop() - - // If there are no further endpoints, tail recurse. - // Otherwise, keep going -- we might be able to reach another endpoint. - numEndpoints-- - if (numEndpoints === 0) return tailRecurse(x, y) - } - - var newEarlyExitData = null - if (doPruning) { - var isEdge = x <= 0 || y <= 0 || x >= puzzle.width - 1 || y >= puzzle.height - 1 - newEarlyExitData = [ - earlyExitData[0] || (!isEdge && earlyExitData[2].isEdge), // Have we ever left an edge? - earlyExitData[2], // The position before our current one - {'x':x, 'y':y, 'isEdge':isEdge} // Our current position. - ] - if (earlyExitData[0] && !earlyExitData[1].isEdge && earlyExitData[2].isEdge && isEdge) { - // See the above comment for an explanation of this math. - var floodX = earlyExitData[2].x + (earlyExitData[1].x - x) - var floodY = earlyExitData[2].y + (earlyExitData[1].y - y) - var region = puzzle.getRegion(floodX, floodY) - if (region != null) { - var regionData = window.validateRegion(puzzle, region, true) - if (!regionData.valid()) return tailRecurse(x, y) - - // Additionally, we might have left an endpoint in the enclosed region. - // If so, we should decrement the number of remaining endpoints (and possibly tail recurse). - for (var pos of region) { - var endCell = puzzle.grid[pos.x][pos.y] - if (endCell != null && endCell.end != null) numEndpoints-- - } - - if (numEndpoints === 0) return tailRecurse(x, y) - } - } - } - - if (SOLVE_SYNC || path.length > SYNC_THRESHOLD) { - path.push(PATH_NONE) - - // Recursion order (LRUD) is optimized for BL->TR and mid-start puzzles - if (y%2 === 0) { - path[path.length-1] = PATH_LEFT - solveLoop(x - 1, y, numEndpoints, newEarlyExitData) - - path[path.length-1] = PATH_RIGHT - solveLoop(x + 1, y, numEndpoints, newEarlyExitData) - } - - if (x%2 === 0) { - path[path.length-1] = PATH_TOP - solveLoop(x, y - 1, numEndpoints, newEarlyExitData) - - path[path.length-1] = PATH_BOTTOM - solveLoop(x, y + 1, numEndpoints, newEarlyExitData) - } - - path.pop() - tailRecurse(x, y) - - } else { - // Push a dummy element on the end of the path, so that we can fill it correctly as we DFS. - // This element is popped when we tail recurse (which always happens *after* all of our DFS!) - path.push(PATH_NONE) - - // Recursion order (LRUD) is optimized for BL->TR and mid-start puzzles - var newTasks = [] - if (y%2 === 0) { - newTasks.push(function() { - path[path.length-1] = PATH_LEFT - return solveLoop(x - 1, y, numEndpoints, newEarlyExitData) - }) - newTasks.push(function() { - path[path.length-1] = PATH_RIGHT - return solveLoop(x + 1, y, numEndpoints, newEarlyExitData) - }) - } - - if (x%2 === 0) { - newTasks.push(function() { - path[path.length-1] = PATH_TOP - return solveLoop(x, y - 1, numEndpoints, newEarlyExitData) - }) - newTasks.push(function() { - path[path.length-1] = PATH_BOTTOM - return solveLoop(x, y + 1, numEndpoints, newEarlyExitData) - }) - } - - newTasks.push(function() { - path.pop() - tailRecurse(x, y) - }) - - return newTasks - } -} - -window.cancelSolving = function() { - console.info('Cancelled solving') - window.MAX_SOLUTIONS = 0 // Causes all new solveLoop calls to exit immediately. - tasks = [] -} - -// Only modifies the puzzle object (does not do any graphics updates). Used by metapuzzle.js to determine subpuzzle polyshapes. -window.drawPathNoUI = function(puzzle, path) { - puzzle.clearLines() - - // Extract the start data from the first path element - var x = path[0].x - var y = path[0].y - var cell = puzzle.getCell(x, y) - if (cell == null || cell.start !== true) throw Error('Path does not begin with a startpoint: ' + JSON.stringify(cell)) - - for (var i=1; i max ? max : value -} - -class BoundingBox { - constructor(x1, x2, y1, y2, sym=false) { - this.raw = {'x1':x1, 'x2':x2, 'y1':y1, 'y2':y2} - this.sym = sym - if (BBOX_DEBUG === true) { - this.debug = createElement('rect') - data.svg.appendChild(this.debug) - this.debug.setAttribute('opacity', 0.5) - this.debug.setAttribute('style', 'pointer-events: none;') - if (data.puzzle.symType == SYM_TYPE_NONE) { - this.debug.setAttribute('fill', 'white') - } else { - if (this.sym !== true) { - this.debug.setAttribute('fill', 'blue') - } else { - this.debug.setAttribute('fill', 'orange') - } - } - } - this._update() - } - - shift(dir, pixels) { - if (dir === 'left') { - this.raw.x2 = this.raw.x1 - this.raw.x1 -= pixels - } else if (dir === 'right') { - this.raw.x1 = this.raw.x2 - this.raw.x2 += pixels - } else if (dir === 'top') { - this.raw.y2 = this.raw.y1 - this.raw.y1 -= pixels - } else if (dir === 'bottom') { - this.raw.y1 = this.raw.y2 - this.raw.y2 += pixels - } - this._update() - } - - inMain(x, y) { - var inMainBox = - (this.x1 < x && x < this.x2) && - (this.y1 < y && y < this.y2) - var inRawBox = - (this.raw.x1 < x && x < this.raw.x2) && - (this.raw.y1 < y && y < this.raw.y2) - - return inMainBox && !inRawBox - } - - _update() { - this.x1 = this.raw.x1 - this.x2 = this.raw.x2 - this.y1 = this.raw.y1 - this.y2 = this.raw.y2 - - // Check for endpoint adjustment. - // Pretend it's not an endpoint if the sym cell isn't an endpoint. - if (data.puzzle.symType != SYM_TYPE_NONE) { - var cell1 = data.puzzle.getCell(data.pos.x, data.pos.y) - var cell2 = data.puzzle.getSymmetricalCell(data.pos.x, data.pos.y) - - if ((cell1.end == null) != (cell2.end == null)) { - var cell = {'end': 'none'} - } else if (this.sym !== true) { - var cell = cell1 - } else { - var cell = cell2 - } - } else { - var cell = data.puzzle.getCell(data.pos.x, data.pos.y) - } - if (cell.end === 'left') { - this.x1 -= 24 - } else if (cell.end === 'right') { - this.x2 += 24 - } else if (cell.end === 'top') { - this.y1 -= 24 - } else if (cell.end === 'bottom') { - this.y2 += 24 - } - - this.middle = { // Note: Middle of the raw object - 'x':(this.raw.x1 + this.raw.x2)/2, - 'y':(this.raw.y1 + this.raw.y2)/2 - } - - if (this.debug != null) { - this.debug.setAttribute('x', this.x1) - this.debug.setAttribute('y', this.y1) - this.debug.setAttribute('width', this.x2 - this.x1) - this.debug.setAttribute('height', this.y2 - this.y1) - } - } -} - -class PathSegment { - constructor(dir) { - this.poly1 = createElement('polygon') - this.circ = createElement('circle') - this.poly2 = createElement('polygon') - this.pillarCirc = createElement('circle') - this.dir = dir - data.svg.insertBefore(this.circ, data.cursor) - data.svg.insertBefore(this.poly2, data.cursor) - data.svg.insertBefore(this.pillarCirc, data.cursor) - this.circ.setAttribute('cx', data.bbox.middle.x) - this.circ.setAttribute('cy', data.bbox.middle.y) - - if (data.puzzle.pillar === true) { - // cx/cy are updated in redraw(), since pillarCirc tracks the cursor - this.pillarCirc.setAttribute('cy', data.bbox.middle.y) - this.pillarCirc.setAttribute('r', 12) - if (data.pos.x === 0 && this.dir === MOVE_RIGHT) { - this.pillarCirc.setAttribute('cx', data.bbox.x1) - this.pillarCirc.setAttribute('static', true) - } else if (data.pos.x === data.puzzle.width - 1 && this.dir === MOVE_LEFT) { - this.pillarCirc.setAttribute('cx', data.bbox.x2) - this.pillarCirc.setAttribute('static', true) - } else { - this.pillarCirc.setAttribute('cx', data.bbox.middle.x) - } - } - - if (data.puzzle.symType == SYM_TYPE_NONE) { - this.poly1.setAttribute('class', 'line-1 ' + data.svg.id) - this.circ.setAttribute('class', 'line-1 ' + data.svg.id) - this.poly2.setAttribute('class', 'line-1 ' + data.svg.id) - this.pillarCirc.setAttribute('class', 'line-1 ' + data.svg.id) - } else { - this.poly1.setAttribute('class', 'line-2 ' + data.svg.id) - this.circ.setAttribute('class', 'line-2 ' + data.svg.id) - this.poly2.setAttribute('class', 'line-2 ' + data.svg.id) - this.pillarCirc.setAttribute('class', 'line-2 ' + data.svg.id) - - this.symPoly1 = createElement('polygon') - this.symCirc = createElement('circle') - this.symPoly2 = createElement('polygon') - this.symPillarCirc = createElement('circle') - data.svg.insertBefore(this.symCirc, data.cursor) - data.svg.insertBefore(this.symPoly2, data.cursor) - data.svg.insertBefore(this.symPillarCirc, data.cursor) - - if (data.puzzle.settings.INVISIBLE_SYMMETRY) { - this.symPoly1.setAttribute('class', 'line-4 ' + data.svg.id) - this.symCirc.setAttribute('class', 'line-4 ' + data.svg.id) - this.symPoly2.setAttribute('class', 'line-4 ' + data.svg.id) - this.symPillarCirc.setAttribute('class', 'line-4 ' + data.svg.id) - } else { - this.symPoly1.setAttribute('class', 'line-3 ' + data.svg.id) - this.symCirc.setAttribute('class', 'line-3 ' + data.svg.id) - this.symPoly2.setAttribute('class', 'line-3 ' + data.svg.id) - this.symPillarCirc.setAttribute('class', 'line-3 ' + data.svg.id) - } - - this.symCirc.setAttribute('cx', data.symbbox.middle.x) - this.symCirc.setAttribute('cy', data.symbbox.middle.y) - - if (data.puzzle.pillar === true) { - // cx/cy are updated in redraw(), since symPillarCirc tracks the cursor - this.symPillarCirc.setAttribute('cy', data.symbbox.middle.y) - this.symPillarCirc.setAttribute('r', 12) - var symmetricalDir = getSymmetricalDir(data.puzzle, this.dir) - if (data.sym.x === 0 && symmetricalDir === MOVE_RIGHT) { - this.symPillarCirc.setAttribute('cx', data.symbbox.x1) - this.symPillarCirc.setAttribute('static', true) - } else if (data.sym.x === data.puzzle.width - 1 && symmetricalDir === MOVE_LEFT) { - this.symPillarCirc.setAttribute('cx', data.symbbox.x2) - this.symPillarCirc.setAttribute('static', true) - } else { - this.symPillarCirc.setAttribute('cx', data.symbbox.middle.x) - } - } - } - - if (this.dir === MOVE_NONE) { // Start point - this.circ.setAttribute('r', 24) - this.circ.setAttribute('class', this.circ.getAttribute('class') + ' start') - if (data.puzzle.symType != SYM_TYPE_NONE) { - this.symCirc.setAttribute('r', 24) - this.symCirc.setAttribute('class', this.symCirc.getAttribute('class') + ' start') - } - } else { - // Only insert poly1 in non-startpoints - data.svg.insertBefore(this.poly1, data.cursor) - this.circ.setAttribute('r', 12) - if (data.puzzle.symType != SYM_TYPE_NONE) { - data.svg.insertBefore(this.symPoly1, data.cursor) - this.symCirc.setAttribute('r', 12) - } - } - } - - destroy() { - data.svg.removeChild(this.poly1) - data.svg.removeChild(this.circ) - data.svg.removeChild(this.poly2) - data.svg.removeChild(this.pillarCirc) - if (data.puzzle.symType != SYM_TYPE_NONE) { - data.svg.removeChild(this.symPoly1) - data.svg.removeChild(this.symCirc) - data.svg.removeChild(this.symPoly2) - data.svg.removeChild(this.symPillarCirc) - } - } - - redraw() { // Uses raw bbox because of endpoints - // Move the cursor and related objects - var x = clamp(data.x, data.bbox.x1, data.bbox.x2) - var y = clamp(data.y, data.bbox.y1, data.bbox.y2) - data.cursor.setAttribute('cx', x) - data.cursor.setAttribute('cy', y) - if (data.puzzle.symType != SYM_TYPE_NONE) { - data.symcursor.setAttribute('cx', this._reflX(x,y)) - data.symcursor.setAttribute('cy', this._reflY(x,y)) - } - if (data.puzzle.pillar === true) { - if (this.pillarCirc.getAttribute('static') == null) { - this.pillarCirc.setAttribute('cx', x) - this.pillarCirc.setAttribute('cy', y) - } - if (data.puzzle.symType != SYM_TYPE_NONE) { - if (this.symPillarCirc.getAttribute('static') == null) { - this.symPillarCirc.setAttribute('cx', this._reflX(x,y)) - this.symPillarCirc.setAttribute('cy', this._reflY(x,y)) - } - } - } - - // Draw the first-half box - var points1 = JSON.parse(JSON.stringify(data.bbox.raw)) - if (this.dir === MOVE_LEFT) { - points1.x1 = clamp(data.x, data.bbox.middle.x, data.bbox.x2) - } else if (this.dir === MOVE_RIGHT) { - points1.x2 = clamp(data.x, data.bbox.x1, data.bbox.middle.x) - } else if (this.dir === MOVE_TOP) { - points1.y1 = clamp(data.y, data.bbox.middle.y, data.bbox.y2) - } else if (this.dir === MOVE_BOTTOM) { - points1.y2 = clamp(data.y, data.bbox.y1, data.bbox.middle.y) - } - this.poly1.setAttribute('points', - points1.x1 + ' ' + points1.y1 + ',' + - points1.x1 + ' ' + points1.y2 + ',' + - points1.x2 + ' ' + points1.y2 + ',' + - points1.x2 + ' ' + points1.y1 - ) - - var firstHalf = false - var isEnd = (data.puzzle.grid[data.pos.x][data.pos.y].end != null) - // The second half of the line uses the raw so that it can enter the endpoint properly. - var points2 = JSON.parse(JSON.stringify(data.bbox.raw)) - if (data.x < data.bbox.middle.x && this.dir !== MOVE_RIGHT) { - points2.x1 = clamp(data.x, data.bbox.x1, data.bbox.middle.x) - points2.x2 = data.bbox.middle.x - if (isEnd && data.pos.x%2 === 0 && data.pos.y%2 === 1) { - points2.y1 += 17 - points2.y2 -= 17 - } - } else if (data.x > data.bbox.middle.x && this.dir !== MOVE_LEFT) { - points2.x1 = data.bbox.middle.x - points2.x2 = clamp(data.x, data.bbox.middle.x, data.bbox.x2) - if (isEnd && data.pos.x%2 === 0 && data.pos.y%2 === 1) { - points2.y1 += 17 - points2.y2 -= 17 - } - } else if (data.y < data.bbox.middle.y && this.dir !== MOVE_BOTTOM) { - points2.y1 = clamp(data.y, data.bbox.y1, data.bbox.middle.y) - points2.y2 = data.bbox.middle.y - if (isEnd && data.pos.x%2 === 1 && data.pos.y%2 === 0) { - points2.x1 += 17 - points2.x2 -= 17 - } - } else if (data.y > data.bbox.middle.y && this.dir !== MOVE_TOP) { - points2.y1 = data.bbox.middle.y - points2.y2 = clamp(data.y, data.bbox.middle.y, data.bbox.y2) - if (isEnd && data.pos.x%2 === 1 && data.pos.y%2 === 0) { - points2.x1 += 17 - points2.x2 -= 17 - } - } else { - firstHalf = true - } - - this.poly2.setAttribute('points', - points2.x1 + ' ' + points2.y1 + ',' + - points2.x1 + ' ' + points2.y2 + ',' + - points2.x2 + ' ' + points2.y2 + ',' + - points2.x2 + ' ' + points2.y1 - ) - - // Show the second poly only in the second half of the cell - this.poly2.setAttribute('opacity', (firstHalf ? 0 : 1)) - // Show the circle in the second half of the cell AND in the start - if (firstHalf && this.dir !== MOVE_NONE) { - this.circ.setAttribute('opacity', 0) - } else { - this.circ.setAttribute('opacity', 1) - } - - // Draw the symmetrical path based on the original one - if (data.puzzle.symType != SYM_TYPE_NONE) { - this.symPoly1.setAttribute('points', - this._reflX(points1.x2, points1.y2) + ' ' + this._reflY(points1.x2, points1.y2) + ',' + - this._reflX(points1.x2, points1.y2) + ' ' + this._reflY(points1.x1, points1.y1) + ',' + - this._reflX(points1.x1, points1.y1) + ' ' + this._reflY(points1.x1, points1.y1) + ',' + - this._reflX(points1.x1, points1.y1) + ' ' + this._reflY(points1.x2, points1.y2) - ) - - this.symPoly2.setAttribute('points', - this._reflX(points2.x2, points2.y2) + ' ' + this._reflY(points2.x2, points2.y2) + ',' + - this._reflX(points2.x2, points2.y2) + ' ' + this._reflY(points2.x1, points2.y1) + ',' + - this._reflX(points2.x1, points2.y1) + ' ' + this._reflY(points2.x1, points2.y1) + ',' + - this._reflX(points2.x1, points2.y1) + ' ' + this._reflY(points2.x2, points2.y2) - ) - - this.symCirc.setAttribute('opacity', this.circ.getAttribute('opacity')) - this.symPoly2.setAttribute('opacity', this.poly2.getAttribute('opacity')) - } - } - - _reflX(x,y) { - if (data.puzzle.symType == SYM_TYPE_NONE) return x - - if (data.puzzle.symType == SYM_TYPE_VERTICAL || data.puzzle.symType == SYM_TYPE_ROTATIONAL || data.puzzle.symType == SYM_TYPE_PARALLEL_H_FLIP) { - // Mirror position inside the bounding box - return (data.bbox.middle.x - x) + data.symbbox.middle.x - } - if (data.puzzle.symType == SYM_TYPE_HORIZONTAL || data.puzzle.symType == SYM_TYPE_PARALLEL_H || data.puzzle.symType == SYM_TYPE_PARALLEL_V || data.puzzle.symType == SYM_TYPE_PARALLEL_V_FLIP) { - // Copy position inside the bounding box - return (x - data.bbox.middle.x) + data.symbbox.middle.x - } - if (data.puzzle.symType == SYM_TYPE_ROTATE_LEFT || data.puzzle.symType == SYM_TYPE_FLIP_XY) { - // Rotate position left inside the bounding box - return (y - data.bbox.middle.y) + data.symbbox.middle.x - } - if (data.puzzle.symType == SYM_TYPE_ROTATE_RIGHT || data.puzzle.symType == SYM_TYPE_FLIP_NEG_XY) { - // Rotate position right inside the bounding box - return (data.bbox.middle.y - y) + data.symbbox.middle.x - } - } - - _reflY(x,y) { - if (data.puzzle.symType == SYM_TYPE_NONE) return y - - if (data.puzzle.symType == SYM_TYPE_HORIZONTAL || data.puzzle.symType == SYM_TYPE_ROTATIONAL || data.puzzle.symType == SYM_TYPE_PARALLEL_V_FLIP) { - // Mirror position inside the bounding box - return (data.bbox.middle.y - y) + data.symbbox.middle.y - } - if (data.puzzle.symType == SYM_TYPE_VERTICAL || data.puzzle.symType == SYM_TYPE_PARALLEL_V || data.puzzle.symType == SYM_TYPE_PARALLEL_H || data.puzzle.symType == SYM_TYPE_PARALLEL_H_FLIP) { - // Copy position inside the bounding box - return (y - data.bbox.middle.y) + data.symbbox.middle.y - } - if (data.puzzle.symType == SYM_TYPE_ROTATE_RIGHT || data.puzzle.symType == SYM_TYPE_FLIP_XY) { - // Rotate position left inside the bounding box - return (x - data.bbox.middle.x) + data.symbbox.middle.y - } - if (data.puzzle.symType == SYM_TYPE_ROTATE_LEFT || data.puzzle.symType == SYM_TYPE_FLIP_NEG_XY) { - // Rotate position right inside the bounding box - return (data.bbox.middle.x - x) + data.symbbox.middle.y - } - } -} - -var data = {} - -function clearGrid(svg, puzzle) { - if (data.bbox != null && data.bbox.debug != null) { - data.svg.removeChild(data.bbox.debug) - data.bbox = null - } - if (data.symbbox != null && data.symbbox.debug != null) { - data.svg.removeChild(data.symbbox.debug) - data.symbbox = null - } - - window.deleteElementsByClassName(svg, 'cursor') - window.deleteElementsByClassName(svg, 'line-1') - window.deleteElementsByClassName(svg, 'line-2') - window.deleteElementsByClassName(svg, 'line-3') - window.deleteElementsByClassName(svg, 'line-4') - puzzle.clearLines() -} - -// This copy is an exact copy of puzzle.getSymmetricalDir, except that it uses MOVE_* values instead of strings -function getSymmetricalDir(puzzle, dir) { - if (puzzle.symType == SYM_TYPE_VERTICAL || puzzle.symType == SYM_TYPE_ROTATIONAL || puzzle.symType == SYM_TYPE_PARALLEL_H_FLIP) { - if (dir === MOVE_LEFT) return MOVE_RIGHT - if (dir === MOVE_RIGHT) return MOVE_LEFT - } - if (puzzle.symType == SYM_TYPE_HORIZONTAL || puzzle.symType == SYM_TYPE_ROTATIONAL || puzzle.symType == SYM_TYPE_PARALLEL_V_FLIP) { - if (dir === MOVE_TOP) return MOVE_BOTTOM - if (dir === MOVE_BOTTOM) return MOVE_TOP - } - if (puzzle.symType == SYM_TYPE_ROTATE_LEFT || puzzle.symType == SYM_TYPE_FLIP_NEG_XY) { - if (dir === MOVE_LEFT) return MOVE_BOTTOM - if (dir === MOVE_RIGHT) return MOVE_TOP - } - if (puzzle.symType == SYM_TYPE_ROTATE_RIGHT || puzzle.symType == SYM_TYPE_FLIP_NEG_XY) { - if (dir === MOVE_TOP) return MOVE_RIGHT - if (dir === MOVE_BOTTOM) return MOVE_LEFT - } - if (puzzle.symType == SYM_TYPE_ROTATE_LEFT || puzzle.symType == SYM_TYPE_FLIP_XY) { - if (dir === MOVE_TOP) return MOVE_LEFT - if (dir === MOVE_BOTTOM) return MOVE_RIGHT - } - if (puzzle.symType == SYM_TYPE_ROTATE_RIGHT || puzzle.symType == SYM_TYPE_FLIP_XY) { - if (dir === MOVE_RIGHT) return MOVE_BOTTOM - if (dir === MOVE_LEFT) return MOVE_TOP - } - return dir -} - -window.trace = function(event, puzzle, pos, start, symStart=null) { - /*if (data.start == null) {*/ - if (data.tracing !== true) { // could be undefined or false - var svg = start.parentElement - data.tracing = true - window.PLAY_SOUND('start') - // Cleans drawn lines & puzzle state - clearGrid(svg, puzzle) - onTraceStart(puzzle, pos, svg, start, symStart) - data.animations.insertRule('.' + svg.id + '.start {animation: 150ms 1 forwards start-grow}\n') - - hookMovementEvents(start) - } else { - event.stopPropagation() - // Signal the onMouseMove to stop accepting input (race condition) - data.tracing = false - - // At endpoint and in main box - var cell = puzzle.getCell(data.pos.x, data.pos.y) - if (cell.end != null && data.bbox.inMain(data.x, data.y)) { - data.cursor.onpointerdown = null - setTimeout(function() { // Run validation asynchronously so we can free the pointer immediately. - puzzle.endPoint = data.pos - var puzzleData = window.validate(puzzle, false) // We want all invalid elements so we can show the user. - - for (var negation of puzzleData.negations) { - console.debug('Rendering negation', negation) - data.animations.insertRule('.' + data.svg.id + '_' + negation.source.x + '_' + negation.source.y + ' {animation: 0.75s 1 forwards fade}\n') - data.animations.insertRule('.' + data.svg.id + '_' + negation.target.x + '_' + negation.target.y + ' {animation: 0.75s 1 forwards fade}\n') - } - - if (puzzleData.valid()) { - window.PLAY_SOUND('success') - // !important to override the child animation - data.animations.insertRule('.' + data.svg.id + ' {animation: 1s 1 forwards line-success !important}\n') - - // Convert the traced path into something suitable for solve.drawPath (for publishing purposes) - var rawPath = [puzzle.startPoint] - for (var i=1; i 1) { - // Stop tracing for two+ finger touches (the equivalent of a right click on desktop) - window.trace(event, data.puzzle, null, null, null) - return - } - data.lastTouchPos = event.position - } - document.ontouchmove = function(event) { - if (data.tracing !== true) return - - var eventIsWithinPuzzle = false - for (var node = event.target; node != null; node = node.parentElement) { - if (node == data.svg) { - eventIsWithinPuzzle = true - break - } - } - if (!eventIsWithinPuzzle) return // Ignore drag events that aren't within the puzzle - event.preventDefault() // Prevent accidental scrolling if the touch event is within the puzzle. - - var newPos = event.position - onMove(newPos.x - data.lastTouchPos.x, newPos.y - data.lastTouchPos.y) - data.lastTouchPos = newPos - } - document.ontouchend = function(event) { - data.lastTouchPos = null - // Only call window.trace (to stop tracing) if we're really in an endpoint. - var cell = data.puzzle.getCell(data.pos.x, data.pos.y) - if (cell.end != null && data.bbox.inMain(data.x, data.y)) { - window.trace(event, data.puzzle, null, null, null) - } - } -} - -// @Volatile -- must match order of PATH_* in solve -var MOVE_NONE = 0 -var MOVE_LEFT = 1 -var MOVE_RIGHT = 2 -var MOVE_TOP = 3 -var MOVE_BOTTOM = 4 - -window.onMove = function(dx, dy) { - { - // Also handles some collision - var collidedWith = pushCursor(dx, dy) - console.spam('Collided with', collidedWith) - } - - while (true) { - hardCollision() - - // Potentially move the location to a new cell, and make absolute boundary checks - var moveDir = move() - data.path[data.path.length - 1].redraw() - if (moveDir === MOVE_NONE) break - console.debug('Moved', ['none', 'left', 'right', 'top', 'bottom'][moveDir]) - - // Potentially adjust data.x/data.y if our position went around a pillar - if (data.puzzle.pillar === true) pillarWrap(moveDir) - - var lastDir = data.path[data.path.length - 1].dir - var backedUp = ((moveDir === MOVE_LEFT && lastDir === MOVE_RIGHT) - || (moveDir === MOVE_RIGHT && lastDir === MOVE_LEFT) - || (moveDir === MOVE_TOP && lastDir === MOVE_BOTTOM) - || (moveDir === MOVE_BOTTOM && lastDir === MOVE_TOP)) - - if (data.puzzle.symType != SYM_TYPE_NONE) { - var symMoveDir = getSymmetricalDir(data.puzzle, moveDir) - } - - // If we backed up, remove a path segment and mark the old cell as unvisited - if (backedUp) { - data.path.pop().destroy() - data.puzzle.updateCell2(data.pos.x, data.pos.y, 'line', window.LINE_NONE) - if (data.puzzle.symType != SYM_TYPE_NONE) { - if (data.puzzle.getLine(data.sym.x, data.sym.y) == window.LINE_OVERLAP) { - data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_BLUE) - } else { - data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_NONE) - } - } - } - - // Move to the next cell - changePos(data.bbox, data.pos, moveDir) - if (data.puzzle.symType != SYM_TYPE_NONE) { - changePos(data.symbbox, data.sym, symMoveDir) - } - - // If we didn't back up, add a path segment and mark the new cell as visited - if (!backedUp) { - data.path.push(new PathSegment(moveDir)) - if (data.puzzle.symType == SYM_TYPE_NONE) { - data.puzzle.updateCell2(data.pos.x, data.pos.y, 'line', window.LINE_BLACK) - } else { - data.puzzle.updateCell2(data.pos.x, data.pos.y, 'line', window.LINE_BLUE) - if (data.puzzle.getLine(data.sym.x, data.sym.y) == window.LINE_BLUE) { - data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_OVERLAP) - } else { - data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_YELLOW) - } - } - } - } -} - -// Helper function for pushCursor. Used to determine the direction and magnitude of redirection. -function push(dx, dy, dir, targetDir) { - // Fraction of movement to redirect in the other direction - var movementRatio = null - if (targetDir === 'left' || targetDir === 'top') { - movementRatio = -3 - } else if (targetDir === 'right' || targetDir === 'bottom') { - movementRatio = 3 - } - if (window.settings.disablePushing === true) movementRatio *= 1000 - - if (dir === 'left') { - var overshoot = data.bbox.x1 - (data.x + dx) + 12 - if (overshoot > 0) { - data.y += dy + overshoot / movementRatio - data.x = data.bbox.x1 + 12 - return true - } - } else if (dir === 'right') { - var overshoot = (data.x + dx) - data.bbox.x2 + 12 - if (overshoot > 0) { - data.y += dy + overshoot / movementRatio - data.x = data.bbox.x2 - 12 - return true - } - } else if (dir === 'leftright') { - data.y += dy + Math.abs(dx) / movementRatio - return true - } else if (dir === 'top') { - var overshoot = data.bbox.y1 - (data.y + dy) + 12 - if (overshoot > 0) { - data.x += dx + overshoot / movementRatio - data.y = data.bbox.y1 + 12 - return true - } - } else if (dir === 'bottom') { - var overshoot = (data.y + dy) - data.bbox.y2 + 12 - if (overshoot > 0) { - data.x += dx + overshoot / movementRatio - data.y = data.bbox.y2 - 12 - return true - } - } else if (dir === 'topbottom') { - data.x += dx + Math.abs(dy) / movementRatio - return true - } - return false -} - -// Redirect momentum from pushing against walls, so that all further moment steps -// will be strictly linear. Returns a string for logging purposes only. -function pushCursor(dx, dy) { - // Outer wall collision - var cell = data.puzzle.getCell(data.pos.x, data.pos.y) - if (cell == null) return 'nothing' - - // Only consider non-endpoints or endpoints which are parallel - if ([undefined, 'top', 'bottom'].includes(cell.end)) { - var leftCell = data.puzzle.getCell(data.pos.x - 1, data.pos.y) - if (leftCell == null || leftCell.gap === window.GAP_FULL) { - if (push(dx, dy, 'left', 'top')) return 'left outer wall' - } - var rightCell = data.puzzle.getCell(data.pos.x + 1, data.pos.y) - if (rightCell == null || rightCell.gap === window.GAP_FULL) { - if (push(dx, dy, 'right', 'top')) return 'right outer wall' - } - } - // Only consider non-endpoints or endpoints which are parallel - if ([undefined, 'left', 'right'].includes(cell.end)) { - var topCell = data.puzzle.getCell(data.pos.x, data.pos.y - 1) - if (topCell == null || topCell.gap === window.GAP_FULL) { - if (push(dx, dy, 'top', 'right')) return 'top outer wall' - } - var bottomCell = data.puzzle.getCell(data.pos.x, data.pos.y + 1) - if (bottomCell == null || bottomCell.gap === window.GAP_FULL) { - if (push(dx, dy, 'bottom', 'right')) return 'bottom outer wall' - } - } - - // Inner wall collision - if (cell.end == null) { - if (data.pos.x%2 === 1 && data.pos.y%2 === 0) { // Horizontal cell - if (data.x < data.bbox.middle.x) { - push(dx, dy, 'topbottom', 'left') - return 'topbottom inner wall, moved left' - } else { - push(dx, dy, 'topbottom', 'right') - return 'topbottom inner wall, moved right' - } - } else if (data.pos.x%2 === 0 && data.pos.y%2 === 1) { // Vertical cell - if (data.y < data.bbox.middle.y) { - push(dx, dy, 'leftright', 'top') - return 'leftright inner wall, moved up' - } else { - push(dx, dy, 'leftright', 'bottom') - return 'leftright inner wall, moved down' - } - } - } - - // Intersection & endpoint collision - // Ratio of movement to be considered turning at an intersection - var turnMod = 2 - if ((data.pos.x%2 === 0 && data.pos.y%2 === 0) || cell.end != null) { - if (data.x < data.bbox.middle.x) { - push(dx, dy, 'topbottom', 'right') - // Overshot the intersection and appears to be trying to turn - if (data.x > data.bbox.middle.x && Math.abs(dy) * turnMod > Math.abs(dx)) { - data.y += Math.sign(dy) * (data.x - data.bbox.middle.x) - data.x = data.bbox.middle.x - return 'overshot moving right' - } - return 'intersection moving right' - } else if (data.x > data.bbox.middle.x) { - push(dx, dy, 'topbottom', 'left') - // Overshot the intersection and appears to be trying to turn - if (data.x < data.bbox.middle.x && Math.abs(dy) * turnMod > Math.abs(dx)) { - data.y += Math.sign(dy) * (data.bbox.middle.x - data.x) - data.x = data.bbox.middle.x - return 'overshot moving left' - } - return 'intersection moving left' - } - if (data.y < data.bbox.middle.y) { - push(dx, dy, 'leftright', 'bottom') - // Overshot the intersection and appears to be trying to turn - if (data.y > data.bbox.middle.y && Math.abs(dx) * turnMod > Math.abs(dy)) { - data.x += Math.sign(dx) * (data.y - data.bbox.middle.y) - data.y = data.bbox.middle.y - return 'overshot moving down' - } - return 'intersection moving down' - } else if (data.y > data.bbox.middle.y) { - push(dx, dy, 'leftright', 'top') - // Overshot the intersection and appears to be trying to turn - if (data.y < data.bbox.middle.y && Math.abs(dx) * turnMod > Math.abs(dy)) { - data.x += Math.sign(dx) * (data.bbox.middle.y - data.y) - data.y = data.bbox.middle.y - return 'overshot moving up' - } - return 'intersection moving up' - } - } - - // No collision, limit movement to X or Y only to prevent out-of-bounds - if (Math.abs(dx) > Math.abs(dy)) { - data.x += dx - return 'nothing, x' - } else { - data.y += dy - return 'nothing, y' - } -} - -// Check to see if we collided with any gaps, or with a symmetrical line, or a startpoint. -// In any case, abruptly zero momentum. -function hardCollision() { - var lastDir = data.path[data.path.length - 1].dir - var cell = data.puzzle.getCell(data.pos.x, data.pos.y) - if (cell == null) return - - var gapSize = 0 - if (cell.gap === window.GAP_BREAK) { - console.spam('Collided with a gap') - gapSize = 21 - } else { - var nextCell = null - if (lastDir === MOVE_LEFT) nextCell = data.puzzle.getCell(data.pos.x - 1, data.pos.y) - if (lastDir === MOVE_RIGHT) nextCell = data.puzzle.getCell(data.pos.x + 1, data.pos.y) - if (lastDir === MOVE_TOP) nextCell = data.puzzle.getCell(data.pos.x, data.pos.y - 1) - if (lastDir === MOVE_BOTTOM) nextCell = data.puzzle.getCell(data.pos.x, data.pos.y + 1) - if (nextCell != null && nextCell.start === true && nextCell.line > window.LINE_NONE) { - gapSize = -5 - } - } - - if (data.puzzle.symType != SYM_TYPE_NONE) { - if (data.sym.x === data.pos.x && data.sym.y === data.pos.y) { - console.spam('Collided with our symmetrical line') - gapSize = 13 - } else if (data.puzzle.getCell(data.sym.x, data.sym.y).gap === window.GAP_BREAK) { - console.spam('Symmetrical line hit a gap') - gapSize = 21 - } - } - if (gapSize === 0) return // Didn't collide with anything - - if (lastDir === MOVE_LEFT) { - data.x = Math.max(data.bbox.middle.x + gapSize, data.x) - } else if (lastDir === MOVE_RIGHT) { - data.x = Math.min(data.x, data.bbox.middle.x - gapSize) - } else if (lastDir === MOVE_TOP) { - data.y = Math.max(data.bbox.middle.y + gapSize, data.y) - } else if (lastDir === MOVE_BOTTOM) { - data.y = Math.min(data.y, data.bbox.middle.y - gapSize) - } -} - -// Check to see if we've gone beyond the edge of puzzle cell, and if the next cell is safe, -// i.e. not out of bounds. Reports the direction we are going to move (or none), -// but does not actually change data.pos -function move() { - var lastDir = data.path[data.path.length - 1].dir - - if (data.x < data.bbox.x1 + 12) { // Moving left - var cell = data.puzzle.getCell(data.pos.x - 1, data.pos.y) - if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { - console.spam('Collided with outside / gap-2', cell) - data.x = data.bbox.x1 + 12 - } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_RIGHT) { - console.spam('Collided with other line', cell.line) - data.x = data.bbox.x1 + 12 - } else if (data.puzzle.symType != SYM_TYPE_NONE) { - var symCell = data.puzzle.getSymmetricalCell(data.pos.x - 1, data.pos.y) - if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { - console.spam('Collided with symmetrical outside / gap-2', cell) - data.x = data.bbox.x1 + 12 - } - } - if (data.x < data.bbox.x1) { - return MOVE_LEFT - } - } else if (data.x > data.bbox.x2 - 12) { // Moving right - var cell = data.puzzle.getCell(data.pos.x + 1, data.pos.y) - if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { - console.spam('Collided with outside / gap-2', cell) - data.x = data.bbox.x2 - 12 - } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_LEFT) { - console.spam('Collided with other line', cell.line) - data.x = data.bbox.x2 - 12 - } else if (data.puzzle.symType != SYM_TYPE_NONE) { - var symCell = data.puzzle.getSymmetricalCell(data.pos.x + 1, data.pos.y) - if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { - console.spam('Collided with symmetrical outside / gap-2', cell) - data.x = data.bbox.x2 - 12 - } - } - if (data.x > data.bbox.x2) { - return MOVE_RIGHT - } - } else if (data.y < data.bbox.y1 + 12) { // Moving up - var cell = data.puzzle.getCell(data.pos.x, data.pos.y - 1) - if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { - console.spam('Collided with outside / gap-2', cell) - data.y = data.bbox.y1 + 12 - } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_BOTTOM) { - console.spam('Collided with other line', cell.line) - data.y = data.bbox.y1 + 12 - } else if (data.puzzle.symType != SYM_TYPE_NONE) { - var symCell = data.puzzle.getSymmetricalCell(data.pos.x, data.pos.y - 1) - if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { - console.spam('Collided with symmetrical outside / gap-2', cell) - data.y = data.bbox.y1 + 12 - } - } - if (data.y < data.bbox.y1) { - return MOVE_TOP - } - } else if (data.y > data.bbox.y2 - 12) { // Moving down - var cell = data.puzzle.getCell(data.pos.x, data.pos.y + 1) - if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { - console.spam('Collided with outside / gap-2') - data.y = data.bbox.y2 - 12 - } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_TOP) { - console.spam('Collided with other line', cell.line) - data.y = data.bbox.y2 - 12 - } else if (data.puzzle.symType != SYM_TYPE_NONE) { - var symCell = data.puzzle.getSymmetricalCell(data.pos.x, data.pos.y + 1) - if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { - console.spam('Collided with symmetrical outside / gap-2', cell) - data.y = data.bbox.y2 - 12 - } - } - if (data.y > data.bbox.y2) { - return MOVE_BOTTOM - } - } - return MOVE_NONE -} - -// Check to see if you moved beyond the edge of a pillar. -// If so, wrap the cursor x to preserve momentum. -// Note that this still does not change the position. -function pillarWrap(moveDir) { - if (moveDir === MOVE_LEFT && data.pos.x === 0) { - data.x += data.puzzle.width * 41 - } - if (moveDir === MOVE_RIGHT && data.pos.x === data.puzzle.width - 1) { - data.x -= data.puzzle.width * 41 - } -} - -// Actually change the data position. (Note that this takes in pos to allow easier symmetry). -// Note that this doesn't zero the momentum, so that we can adjust appropriately on further loops. -// This function also shifts the bounding box that we use to determine the bounds of the cell. -function changePos(bbox, pos, moveDir) { - if (moveDir === MOVE_LEFT) { - pos.x-- - // Wrap around the left - if (data.puzzle.pillar === true && pos.x < 0) { - pos.x += data.puzzle.width - bbox.shift('right', data.puzzle.width * 41 - 82) - bbox.shift('right', 58) - } else { - bbox.shift('left', (pos.x%2 === 0 ? 24 : 58)) - } - } else if (moveDir === MOVE_RIGHT) { - pos.x++ - // Wrap around to the right - if (data.puzzle.pillar === true && pos.x >= data.puzzle.width) { - pos.x -= data.puzzle.width - bbox.shift('left', data.puzzle.width * 41 - 82) - bbox.shift('left', 24) - } else { - bbox.shift('right', (pos.x%2 === 0 ? 24 : 58)) - } - } else if (moveDir === MOVE_TOP) { - pos.y-- - bbox.shift('top', (pos.y%2 === 0 ? 24 : 58)) - } else if (moveDir === MOVE_BOTTOM) { - pos.y++ - bbox.shift('bottom', (pos.y%2 === 0 ? 24 : 58)) - } -} - -}) diff --git a/app/assets/javascripts/wittle/utilities.js.erb b/app/assets/javascripts/wittle/utilities.js.erb deleted file mode 100644 index b621003..0000000 --- a/app/assets/javascripts/wittle/utilities.js.erb +++ /dev/null @@ -1,498 +0,0 @@ -function namespace(code) { - code() -} - -namespace(function() { - -/*** Start cross-compatibility ***/ -// Used to detect if IDs include a direction, e.g. resize-top-left -if (!String.prototype.includes) { - String.prototype.includes = function() { - return String.prototype.indexOf.apply(this, arguments) !== -1 - } -} -Event.prototype.movementX = Event.prototype.movementX || Event.prototype.mozMovementX -Event.prototype.movementY = Event.prototype.movementY || Event.prototype.mozMovementY -Event.prototype.isRightClick = function() { - return this.which === 3 || (this.touches && this.touches.length > 1) -} -Element.prototype.disable = function() { - this.disabled = true - this.style.pointerEvents = 'none' - this.className = 'noselect' -} -Element.prototype.enable = function() { - this.disabled = false - this.style.pointerEvents = null - this.className = null -} -Object.defineProperty(Event.prototype, 'position', { - 'get': function() { - return { - 'x': event.pageX || event.clientX || (event.touches && event.touches[0].pageX) || null, - 'y': event.pageY || event.clientY || (event.touches && event.touches[0].pageY) || null, - } - } -}) -/*** End cross-compatibility ***/ - -var proxy = { - 'get': function(_, key) { - try { - return this._map[key] - } catch (e) { - return null - } - }, - 'set': function(_, key, value) { - if (value == null) { - delete this._map[key] - } else { - this._map[key] = value.toString() - window.localStorage.setItem('settings', JSON.stringify(this._map)) - } - }, - 'init': function() { - this._map = {} - try { - var j = window.localStorage.getItem('settings') - if (j != null) this._map = JSON.parse(j) - } catch (e) {/* Do nothing */} - - function setIfNull(map, key, value) { - if (map[key] == null) map[key] = value - } - - // Set any values which are not defined - setIfNull(this._map, 'theme', 'light') - setIfNull(this._map, 'volume', '0.12') - setIfNull(this._map, 'sensitivity', '0.7') - setIfNull(this._map, 'expanded', 'false') - setIfNull(this._map, 'customMechanics', 'false') - return this - }, -} -window.settings = new Proxy({}, proxy.init()) - -var tracks = { - 'start': new Audio(src = '<%= asset_url("wittle/panel_start_tracing.aac") %>'), - 'success': new Audio(src = '<%= asset_url("wittle/panel_success.aac") %>'), - 'fail': new Audio(src = '<%= asset_url("wittle/panel_failure.aac") %>'), - 'abort': new Audio(src = '<%= asset_url("wittle/panel_abort_tracing.aac") %>'), -} - -var currentAudio = null -window.PLAY_SOUND = function(name) { - if (currentAudio) currentAudio.pause() - var audio = tracks[name] - audio.load() - audio.volume = parseFloat(window.settings.volume) - audio.play().then(function() { - currentAudio = audio - }).catch(function() { - // Do nothing. - }) -} - -window.LINE_PRIMARY = '#8FF' -window.LINE_SECONDARY = '#FF2' - -if (window.settings.theme == 'night') { - window.BACKGROUND = '#221' - window.OUTER_BACKGROUND = '#070704' - window.FOREGROUND = '#751' - window.BORDER = '#666' - window.LINE_DEFAULT = '#888' - window.LINE_SUCCESS = '#BBB' - window.LINE_FAIL = '#000' - window.CURSOR = '#FFF' - window.TEXT_COLOR = '#AAA' - window.PAGE_BACKGROUND = '#000' - window.ALT_BACKGROUND = '#333' // An off-black. Good for mild contrast. - window.ACTIVE_COLOR = '#555' // Color for 'while the element is being pressed' -} else if (window.settings.theme == 'light') { - window.BACKGROUND = '#0A8' - window.OUTER_BACKGROUND = '#113833' - window.FOREGROUND = '#344' - window.BORDER = '#000' - window.LINE_DEFAULT = '#AAA' - window.LINE_SUCCESS = '#FFF' - window.LINE_FAIL = '#000' - window.CURSOR = '#FFF' - window.TEXT_COLOR = '#000' - window.PAGE_BACKGROUND = '#FFF' - window.ALT_BACKGROUND = '#EEE' // An off-white. Good for mild contrast. - window.ACTIVE_COLOR = '#DDD' // Color for 'while the element is being pressed' -} - -window.LINE_NONE = 0 -window.LINE_BLACK = 1 -window.LINE_BLUE = 2 -window.LINE_YELLOW = 3 -window.LINE_OVERLAP = 4 -window.DOT_NONE = 0 -window.DOT_BLACK = 1 -window.DOT_BLUE = 2 -window.DOT_YELLOW = 3 -window.DOT_INVISIBLE = 4 -window.GAP_NONE = 0 -window.GAP_BREAK = 1 -window.GAP_FULL = 2 - -var animations = '' -var l = function(line) {animations += line + '\n'} -// pointer-events: none; allows for events to bubble up (so that editor hooks still work) -l('.line-1 {') -l(' fill: ' + window.LINE_DEFAULT + ';') -l(' pointer-events: none;') -l('}') -l('.line-2 {') -l(' fill: ' + window.LINE_PRIMARY + ';') -l(' pointer-events: none;') -l('}') -l('.line-3 {') -l(' fill: ' + window.LINE_SECONDARY + ';') -l(' pointer-events: none;') -l('}') -l('.line-4 {') -l(' display: none;') -l(' pointer-events: none;') -l('}') -l('@keyframes line-success {to {fill: ' + window.LINE_SUCCESS + ';}}') -l('@keyframes line-fail {to {fill: ' + window.LINE_FAIL + ';}}') -l('@keyframes error {to {fill: red;}}') -l('@keyframes fade {to {opacity: 0.35;}}') -l('@keyframes start-grow {from {r:12;} to {r:24;}}') -// Neutral button style -l('button {') -l(' background-color: ' + window.ALT_BACKGROUND + ';') -l(' border: 1px solid ' + window.BORDER + ';') -l(' border-radius: 2px;') -l(' color: ' + window.TEXT_COLOR + ';') -l(' display: inline-block;') -l(' margin: 0px;') -l(' outline: none;') -l(' opacity: 1.0;') -l(' padding: 1px 6px;') -l(' -moz-appearance: none;') -l(' -webkit-appearance: none;') -l('}') -// Active (while held down) button style -l('button:active {background-color: ' + window.ACTIVE_COLOR + ';}') -// Disabled button style -l('button:disabled {opacity: 0.5;}') -// Selected button style (see https://stackoverflow.com/a/63108630) -l('button:focus {outline: none;}') -l = null - -var style = document.createElement('style') -style.type = 'text/css' -style.title = 'animations' -style.appendChild(document.createTextNode(animations)) -document.head.appendChild(style) - -// Custom logging to allow leveling -var consoleError = console.error -var consoleWarn = console.warn -var consoleInfo = console.log -var consoleLog = console.log -var consoleDebug = console.log -var consoleSpam = console.log -var consoleGroup = console.group -var consoleGroupEnd = console.groupEnd - -window.setLogLevel = function(level) { - console.error = function() {} - console.warn = function() {} - console.info = function() {} - console.log = function() {} - console.debug = function() {} - console.spam = function() {} - console.group = function() {} - console.groupEnd = function() {} - - if (level === 'none') return - - // Instead of throw, but still red flags and is easy to find - console.error = consoleError - if (level === 'error') return - - // Less serious than error, but flagged nonetheless - console.warn = consoleWarn - if (level === 'warn') return - - // Default visible, important information - console.info = consoleInfo - if (level === 'info') return - - // Useful for debugging (mainly validation) - console.log = consoleLog - if (level === 'log') return - - // Useful for serious debugging (mainly graphics/misc) - console.debug = consoleDebug - if (level === 'debug') return - - // Useful for insane debugging (mainly tracing/recursion) - console.spam = consoleSpam - console.group = consoleGroup - console.groupEnd = consoleGroupEnd - if (level === 'spam') return -} -setLogLevel('info') - -window.deleteElementsByClassName = function(rootElem, className) { - var elems = [] - while (true) { - elems = rootElem.getElementsByClassName(className) - if (elems.length === 0) break - elems[0].remove() - } -} - -// Automatically solve the puzzle -window.solvePuzzle = function() { - if (window.setSolveMode) window.setSolveMode(false) - document.getElementById('solutionViewer').style.display = 'none' - document.getElementById('progressBox').style.display = null - document.getElementById('solveAuto').innerText = 'Cancel Solving' - document.getElementById('solveAuto').onpointerdown = function() { - this.innerText = 'Cancelling...' - this.onpointerdown = null - window.setTimeout(window.cancelSolving, 0) - } - - window.solve(window.puzzle, function(percent) { - document.getElementById('progressPercent').innerText = percent + '%' - document.getElementById('progress').style.width = percent + '%' - }, function(paths) { - document.getElementById('progressBox').style.display = 'none' - document.getElementById('solutionViewer').style.display = null - document.getElementById('progressPercent').innerText = '0%' - document.getElementById('progress').style.width = '0%' - document.getElementById('solveAuto').innerText = 'Solve (automatically)' - document.getElementById('solveAuto').onpointerdown = solvePuzzle - - window.puzzle.autoSolved = true - paths = window.onSolvedPuzzle(paths) - window.showSolution(window.puzzle, paths, 0) - }) -} - -window.showSolution = function(puzzle, paths, num, suffix) { - if (suffix == null) { - var previousSolution = document.getElementById('previousSolution') - var solutionCount = document.getElementById('solutionCount') - var nextSolution = document.getElementById('nextSolution') - } else if (suffix instanceof Array) { - var previousSolution = document.getElementById('previousSolution-' + suffix[0]) - var solutionCount = document.getElementById('solutionCount-' + suffix[0]) - var nextSolution = document.getElementById('nextSolution-' + suffix[0]) - } else { - var previousSolution = document.getElementById('previousSolution-' + suffix) - var solutionCount = document.getElementById('solutionCount-' + suffix) - var nextSolution = document.getElementById('nextSolution-' + suffix) - } - - if (paths.length === 0) { // 0 paths, arrows are useless - solutionCount.innerText = '0 of 0' - previousSolution.disable() - nextSolution.disable() - return - } - - while (num < 0) num = paths.length + num - while (num >= paths.length) num = num - paths.length - - if (paths.length === 1) { // 1 path, arrows are useless - solutionCount.innerText = '1 of 1' - if (paths.length >= window.MAX_SOLUTIONS) solutionCount.innerText += '+' - previousSolution.disable() - nextSolution.disable() - } else { - solutionCount.innerText = (num + 1) + ' of ' + paths.length - if (paths.length >= window.MAX_SOLUTIONS) solutionCount.innerText += '+' - previousSolution.enable() - nextSolution.enable() - previousSolution.onpointerdown = function(event) { - if (event.shiftKey) { - window.showSolution(puzzle, paths, num - 10, suffix) - } else { - window.showSolution(puzzle, paths, num - 1, suffix) - } - } - nextSolution.onpointerdown = function(event) { - if (event.shiftKey) { - window.showSolution(puzzle, paths, num + 10, suffix) - } else { - window.showSolution(puzzle, paths, num + 1, suffix) - } - } - } - - if (paths[num] != null) { - if (puzzle instanceof Array) { // Special case for multiple related panels - for (var i = 0; i < puzzle.length; i++) { - // Save the current path on the puzzle object (so that we can pass it along with publishing) - puzzle.path = paths[num][i] - // Draws the given path, and also updates the puzzle to have path annotations on it. - window.drawPath(puzzle[i], paths[num][i], suffix[i]) - } - } else { // Default case for a single panel - // Save the current path on the puzzle object (so that we can pass it along with publishing) - puzzle.path = paths[num] - // Draws the given path, and also updates the puzzle to have path annotations on it. - window.drawPath(puzzle, paths[num], suffix) - } - } -} - -window.createCheckbox = function() { - var checkbox = document.createElement('div') - checkbox.style.width = '22px' - checkbox.style.height = '22px' - checkbox.style.borderRadius = '6px' - checkbox.style.display = 'inline-block' - checkbox.style.verticalAlign = 'text-bottom' - checkbox.style.marginRight = '6px' - checkbox.style.borderWidth = '1.5px' - checkbox.style.borderStyle = 'solid' - checkbox.style.borderColor = window.BORDER - checkbox.style.background = window.PAGE_BACKGROUND - checkbox.style.color = window.TEXT_COLOR - return checkbox -} - -// Required global variables/functions: <-- HINT: This means you're writing bad code. -// window.puzzle -// window.onSolvedPuzzle() -// window.MAX_SOLUTIONS // defined by solve.js -window.addSolveButtons = function() { - var parent = document.currentScript.parentElement - - var solveMode = createCheckbox() - solveMode.id = 'solveMode' - parent.appendChild(solveMode) - - solveMode.onpointerdown = function() { - this.checked = !this.checked - this.style.background = (this.checked ? window.BORDER : window.PAGE_BACKGROUND) - document.getElementById('solutionViewer').style.display = 'none' - if (window.setSolveMode) window.setSolveMode(this.checked) - } - - var solveManual = document.createElement('label') - parent.appendChild(solveManual) - solveManual.id = 'solveManual' - solveManual.onpointerdown = function() {solveMode.onpointerdown()} - solveManual.innerText = 'Solve (manually)' - solveManual.style = 'margin-right: 8px' - - var solveAuto = document.createElement('button') - parent.appendChild(solveAuto) - solveAuto.id = 'solveAuto' - solveAuto.innerText = 'Solve (automatically)' - solveAuto.onpointerdown = solvePuzzle - solveAuto.style = 'margin-right: 8px' - - var div = document.createElement('div') - parent.appendChild(div) - div.style = 'display: inline-block; vertical-align:top' - - var progressBox = document.createElement('div') - div.appendChild(progressBox) - progressBox.id = 'progressBox' - progressBox.style = 'display: none; width: 220px; border: 1px solid black; margin-top: 2px' - - var progressPercent = document.createElement('label') - progressBox.appendChild(progressPercent) - progressPercent.id = 'progressPercent' - progressPercent.style = 'float: left; margin-left: 4px' - progressPercent.innerText = '0%' - - var progress = document.createElement('div') - progressBox.appendChild(progress) - progress.id = 'progress' - progress.style = 'z-index: -1; height: 38px; width: 0%; background-color: #390' - - var solutionViewer = document.createElement('div') - div.appendChild(solutionViewer) - solutionViewer.id = 'solutionViewer' - solutionViewer.style = 'display: none' - - var previousSolution = document.createElement('button') - solutionViewer.appendChild(previousSolution) - previousSolution.id = 'previousSolution' - previousSolution.innerHTML = '←' - - var solutionCount = document.createElement('label') - solutionViewer.appendChild(solutionCount) - solutionCount.id = 'solutionCount' - solutionCount.style = 'padding: 6px' - - var nextSolution = document.createElement('button') - solutionViewer.appendChild(nextSolution) - nextSolution.id = 'nextSolution' - nextSolution.innerHTML = '→' -} - -var SECONDS_PER_LOOP = 1 -window.httpGetLoop = function(url, maxTimeout, action, onError, onSuccess) { - if (maxTimeout <= 0) { - onError() - return - } - - sendHttpRequest('GET', url, SECONDS_PER_LOOP, null, function(httpCode, response) { - if (httpCode >= 200 && httpCode <= 299) { - var output = action(JSON.parse(response)) - if (output) { - onSuccess(output) - return - } // Retry if action returns null - } // Retry on non-success HTTP codes - - window.setTimeout(function() { - httpGetLoop(url, maxTimeout - SECONDS_PER_LOOP, action, onError, onSuccess) - }, 1000) - }) -} - -window.fireAndForget = function(verb, url, body) { - sendHttpRequest(verb, url, 600, body, function() {}) -} - -// Only used for errors -var HTTP_STATUS = { - 401: '401 unauthorized', 403: '403 forbidden', 404: '404 not found', 409: '409 conflict', 413: '413 payload too large', - 500: '500 internal server error', -} - -var etagCache = {} -function sendHttpRequest(verb, url, timeoutSeconds, data, onResponse) { - currentHttpRequest = new XMLHttpRequest() - currentHttpRequest.onreadystatechange = function() { - if (this.readyState != XMLHttpRequest.DONE) return - etagCache[url] = this.getResponseHeader('ETag') - currentHttpRequest = null - onResponse(this.status, this.responseText || HTTP_STATUS[this.status]) - } - currentHttpRequest.ontimeout = function() { - currentHttpRequest = null - onResponse(0, 'Request timed out') - } - currentHttpRequest.timeout = timeoutSeconds * 1000 - currentHttpRequest.open(verb, url, true) - currentHttpRequest.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded') - - var etag = etagCache[url] - if (etag != null) currentHttpRequest.setRequestHeader('If-None-Match', etag) - - currentHttpRequest.send(data) -} - -function sendFeedback(feedback) { - console.error('Please disregard the following CORS exception. It is expected and the request will succeed regardless.') -} - -}) diff --git a/app/assets/javascripts/wittle/validate.js b/app/assets/javascripts/wittle/validate.js deleted file mode 100644 index d6e6484..0000000 --- a/app/assets/javascripts/wittle/validate.js +++ /dev/null @@ -1,391 +0,0 @@ -namespace(function() { - -class RegionData { - constructor() { - this.invalidElements = [] - this.veryInvalidElements = [] - this.negations = [] - } - - addInvalid(elem) { - this.invalidElements.push(elem) - } - - addVeryInvalid(elem) { - this.veryInvalidElements.push(elem) - } - - valid() { - return (this.invalidElements.length === 0 && this.veryInvalidElements.length === 0) - } -} - -// Sanity checks for data which comes from the user. Now that people have learned that /publish is an open endpoint, -// we have to make sure they don't submit data which passes validation but is untrustworthy. -// These checks should always pass for puzzles created by the built-in editor. -window.validateUserData = function(puzzle, path) { - if (path == null) throw Error('Path cannot be null') - - var sizeError = puzzle.getSizeError(puzzle.width, puzzle.height) - if (sizeError != null) throw Error(sizeError) - - var puzzleHasStart = false - var puzzleHasEnd = false - - if (puzzle.grid.length !== puzzle.width) throw Error('Puzzle width does not match grid size') - for (var x=0; x window.LINE_NONE) { - if (cell.gap > window.GAP_NONE) { - console.log('Solution line goes over a gap at', x, y) - puzzleData.invalidElements.push({'x': x, 'y': y}) - if (quick) return puzzleData - } - if ((cell.dot === window.DOT_BLUE && cell.line === window.LINE_YELLOW) || - (cell.dot === window.DOT_YELLOW && cell.line === window.LINE_BLUE)) { - console.log('Incorrectly covered dot: Dot is', cell.dot, 'but line is', cell.line) - puzzleData.invalidElements.push({'x': x, 'y': y}) - if (quick) return puzzleData - } - } - } - } - - if (needsRegions) { - var regions = puzzle.getRegions() - } else { - var monoRegion = [] - for (var x=0; x 0 && veryInvalidElements.length > 0) { - var source = negationSymbols.pop() - var target = veryInvalidElements.pop() - puzzle.setCell(source.x, source.y, null) - puzzle.setCell(target.x, target.y, null) - baseCombination.push({'source':source, 'target':target}) - } - - var regionData = regionCheckNegations2(puzzle, region, negationSymbols, invalidElements) - - // Restore required negations - for (var combination of baseCombination) { - puzzle.setCell(combination.source.x, combination.source.y, combination.source.cell) - puzzle.setCell(combination.target.x, combination.target.y, combination.target.cell) - regionData.negations.push(combination) - } - return regionData -} - -// Recursively matches negations and invalid elements from the grid. Note that this function -// doesn't actually modify the two lists, it just iterates through them with index/index2. -function regionCheckNegations2(puzzle, region, negationSymbols, invalidElements, index=0, index2=0) { - if (index2 >= negationSymbols.length) { - console.debug('0 negation symbols left, returning negation-less regionCheck') - return regionCheck(puzzle, region, false) // @Performance: We could pass quick here. - } - - if (index >= invalidElements.length) { - var i = index2 - // pair off all negation symbols, 2 at a time - if (puzzle.settings.NEGATIONS_CANCEL_NEGATIONS) { - for (; i window.DOT_NONE) { - console.log('Dot at', pos.x, pos.y, 'is not covered') - regionData.addVeryInvalid(pos) - if (quick) return regionData - } - - // Check for triangles - if (cell.type === 'triangle') { - var count = 0 - if (puzzle.getLine(pos.x - 1, pos.y) > window.LINE_NONE) count++ - if (puzzle.getLine(pos.x + 1, pos.y) > window.LINE_NONE) count++ - if (puzzle.getLine(pos.x, pos.y - 1) > window.LINE_NONE) count++ - if (puzzle.getLine(pos.x, pos.y + 1) > window.LINE_NONE) count++ - if (cell.count !== count) { - console.log('Triangle at grid['+pos.x+']['+pos.y+'] has', count, 'borders') - regionData.addVeryInvalid(pos) - if (quick) return regionData - } - } - - // Count color-based elements - if (cell.color != null) { - var count = coloredObjects[cell.color] - if (count == null) { - count = 0 - } - coloredObjects[cell.color] = count + 1 - - if (cell.type === 'square') { - squares.push(pos) - if (squareColor == null) { - squareColor = cell.color - } else if (squareColor != cell.color) { - squareColor = -1 // Signal value which indicates square color collision - } - } - - if (cell.type === 'star') { - pos.color = cell.color - stars.push(pos) - } - } - } - - if (squareColor === -1) { - regionData.invalidElements = regionData.invalidElements.concat(squares) - if (quick) return regionData - } - - for (var star of stars) { - var count = coloredObjects[star.color] - if (count === 1) { - console.log('Found a', star.color, 'star in a region with 1', star.color, 'object') - regionData.addVeryInvalid(star) - if (quick) return regionData - } else if (count > 2) { - console.log('Found a', star.color, 'star in a region with', count, star.color, 'objects') - regionData.addInvalid(star) - if (quick) return regionData - } - } - - if (puzzle.hasPolyominos) { - if (!window.polyFit(region, puzzle)) { - for (var pos of region) { - var cell = puzzle.grid[pos.x][pos.y] - if (cell == null) continue - if (cell.type === 'poly' || cell.type === 'ylop') { - regionData.addInvalid(pos) - if (quick) return regionData - } - } - } - } - - if (puzzle.settings.CUSTOM_MECHANICS) { - window.validateBridges(puzzle, region, regionData) - window.validateArrows(puzzle, region, regionData) - window.validateSizers(puzzle, region, regionData) - } - - console.debug('Region has', regionData.veryInvalidElements.length, 'very invalid elements') - console.debug('Region has', regionData.invalidElements.length, 'invalid elements') - return regionData -} -}) diff --git a/app/assets/javascripts/wittle/wittle.js b/app/assets/javascripts/wittle/wittle.js deleted file mode 100644 index 883a4b8..0000000 --- a/app/assets/javascripts/wittle/wittle.js +++ /dev/null @@ -1,5 +0,0 @@ -$.ajaxSetup({ - headers: { - 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') - } -}); -- cgit 1.4.1