diff options
| author | Star Rauchenberger <fefferburbia@gmail.com> | 2023-10-28 00:16:07 -0400 |
|---|---|---|
| committer | Star Rauchenberger <fefferburbia@gmail.com> | 2023-10-28 00:16:07 -0400 |
| commit | b496a29a7de381765f3c45ced9fa18f026b2e314 (patch) | |
| tree | 34f6558ffed34b0ec922c90e6407d165e9256b59 /app/assets | |
| parent | d09db2d6d0727faba8e5078900f2fbd1e18ea49f (diff) | |
| download | wittle-b496a29a7de381765f3c45ced9fa18f026b2e314.tar.gz wittle-b496a29a7de381765f3c45ced9fa18f026b2e314.tar.bz2 wittle-b496a29a7de381765f3c45ced9fa18f026b2e314.zip | |
serve
Diffstat (limited to 'app/assets')
17 files changed, 4728 insertions, 0 deletions
| diff --git a/app/assets/images/wittle/.keep b/app/assets/audio/wittle/.keep index e69de29..e69de29 100644 --- a/app/assets/images/wittle/.keep +++ b/app/assets/audio/wittle/.keep | |||
| diff --git a/app/assets/audio/wittle/panel_abort_tracing.aac b/app/assets/audio/wittle/panel_abort_tracing.aac new file mode 100644 index 0000000..1871586 --- /dev/null +++ b/app/assets/audio/wittle/panel_abort_tracing.aac | |||
| Binary files differ | |||
| diff --git a/app/assets/audio/wittle/panel_failure.aac b/app/assets/audio/wittle/panel_failure.aac new file mode 100644 index 0000000..c61fe94 --- /dev/null +++ b/app/assets/audio/wittle/panel_failure.aac | |||
| Binary files differ | |||
| diff --git a/app/assets/audio/wittle/panel_start_tracing.aac b/app/assets/audio/wittle/panel_start_tracing.aac new file mode 100644 index 0000000..9b828f1 --- /dev/null +++ b/app/assets/audio/wittle/panel_start_tracing.aac | |||
| Binary files differ | |||
| diff --git a/app/assets/audio/wittle/panel_success.aac b/app/assets/audio/wittle/panel_success.aac new file mode 100644 index 0000000..d6e75fe --- /dev/null +++ b/app/assets/audio/wittle/panel_success.aac | |||
| Binary files differ | |||
| diff --git a/app/assets/config/wittle_manifest.js b/app/assets/config/wittle_manifest.js index af556d6..079ddad 100644 --- a/app/assets/config/wittle_manifest.js +++ b/app/assets/config/wittle_manifest.js | |||
| @@ -1 +1,3 @@ | |||
| 1 | //= link_directory ../stylesheets/wittle .css | 1 | //= link_directory ../stylesheets/wittle .css |
| 2 | //= link_directory ../javascripts/wittle .js | ||
| 3 | //= link_directory ../audio/wittle .aac | ||
| diff --git a/app/assets/javascripts/wittle/application.js b/app/assets/javascripts/wittle/application.js new file mode 100644 index 0000000..e54c646 --- /dev/null +++ b/app/assets/javascripts/wittle/application.js | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files | ||
| 2 | // listed below. | ||
| 3 | // | ||
| 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, | ||
| 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. | ||
| 6 | // | ||
| 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the | ||
| 8 | // compiled file. JavaScript code in this file should be added after the last require_* statement. | ||
| 9 | // | ||
| 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details | ||
| 11 | // about supported directives. | ||
| 12 | // | ||
| 13 | //= require_tree . | ||
| diff --git a/app/assets/javascripts/wittle/custom_mechanics.js b/app/assets/javascripts/wittle/custom_mechanics.js new file mode 100644 index 0000000..d4733db --- /dev/null +++ b/app/assets/javascripts/wittle/custom_mechanics.js | |||
| @@ -0,0 +1,201 @@ | |||
| 1 | namespace(function() { | ||
| 2 | |||
| 3 | function isCellBridgePathFriendly(puzzle, color, pos) { | ||
| 4 | if (pos.x%2 === 0 && pos.y%2 === 0) return false | ||
| 5 | var cell = puzzle.getCell(pos.x, pos.y) | ||
| 6 | return cell == null || cell.color == null || cell.color === color | ||
| 7 | } | ||
| 8 | |||
| 9 | function makeMinimalTree(graph, root, required) { | ||
| 10 | var seen = Array(graph.length).fill(false) | ||
| 11 | var result = Array(graph.length).fill(false) | ||
| 12 | result[root] = true | ||
| 13 | function dfs(node) { | ||
| 14 | seen[node] = true | ||
| 15 | result[node] = required[node] | ||
| 16 | for (var child of graph[node]) { | ||
| 17 | if (!seen[child]) { | ||
| 18 | dfs(child) | ||
| 19 | result[node] = result[node] || result[child] | ||
| 20 | } | ||
| 21 | } | ||
| 22 | } | ||
| 23 | dfs(root) | ||
| 24 | return result | ||
| 25 | } | ||
| 26 | |||
| 27 | function isTreeUnique(graph, isInTree) { | ||
| 28 | var seen = isInTree.slice() | ||
| 29 | function dfs(node) { | ||
| 30 | seen[node] = true | ||
| 31 | var reachableTreeNode = null | ||
| 32 | for (var child of graph[node]) { | ||
| 33 | var candidate = null | ||
| 34 | if (isInTree[child]) { | ||
| 35 | candidate = child | ||
| 36 | } else if (!seen[child]) { | ||
| 37 | candidate = dfs(child) | ||
| 38 | } | ||
| 39 | if (candidate != null && candidate !== reachableTreeNode) { | ||
| 40 | if (reachableTreeNode == null) { | ||
| 41 | reachableTreeNode = candidate | ||
| 42 | } else { | ||
| 43 | return -1 | ||
| 44 | } | ||
| 45 | } | ||
| 46 | } | ||
| 47 | return reachableTreeNode | ||
| 48 | } | ||
| 49 | for (var i = 0; i < graph.length; i++) { | ||
| 50 | if (!seen[i]) { | ||
| 51 | if (dfs(i) === -1) return false | ||
| 52 | } | ||
| 53 | } | ||
| 54 | return true | ||
| 55 | } | ||
| 56 | |||
| 57 | function puzzleCellsAdjacent(first, second, pillar) { | ||
| 58 | if (pillar && first.y == second.y && Math.abs(second.x - first.x) === puzzle.width - 1) | ||
| 59 | return true | ||
| 60 | return Math.abs(second.x - first.x) + Math.abs(second.y - first.y) === 1 | ||
| 61 | } | ||
| 62 | |||
| 63 | function bridgeTest(region, puzzle, color, bridges) { | ||
| 64 | var nodes = region.cells.filter(pos => isCellBridgePathFriendly(puzzle, color, pos)) | ||
| 65 | var graph = Array.from(Array(nodes.length), () => []) | ||
| 66 | for (var ir = 1; ir < nodes.length; ir++) { | ||
| 67 | var right = nodes[ir] | ||
| 68 | for (var il = 0; il < ir; il++) { | ||
| 69 | var left = nodes[il] | ||
| 70 | if (puzzleCellsAdjacent(left, right, puzzle.pillar)) { | ||
| 71 | graph[il].push(ir) | ||
| 72 | graph[ir].push(il) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | } | ||
| 76 | var isBridge = nodes.map(node => bridges.some(bridge => node.x === bridge.x && node.y === bridge.y)) | ||
| 77 | var isInTree = makeMinimalTree(graph, isBridge.indexOf(true), isBridge) | ||
| 78 | for (var i = 0; i < nodes.length; i++) { | ||
| 79 | if (isBridge[i] && !isInTree[i]) return false | ||
| 80 | } | ||
| 81 | return isTreeUnique(graph, isInTree) | ||
| 82 | } | ||
| 83 | |||
| 84 | window.validateBridges = function(puzzle, region, regionData) { | ||
| 85 | var bridges = {} | ||
| 86 | for (var pos of region) { | ||
| 87 | var cell = puzzle.getCell(pos.x, pos.y) | ||
| 88 | if (cell == null) continue | ||
| 89 | |||
| 90 | // Count color-based elements | ||
| 91 | if (cell.color != null) { | ||
| 92 | if (cell.type === 'bridge') { | ||
| 93 | if (bridges[cell.color] == null) { | ||
| 94 | bridges[cell.color] = [] | ||
| 95 | } | ||
| 96 | bridges[cell.color].push(pos) | ||
| 97 | } | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | for (var color in bridges) { | ||
| 102 | var total = 0 | ||
| 103 | var discardable = 0 | ||
| 104 | for (var x=1; x < puzzle.width; x+=2) { | ||
| 105 | for (var y=1; y < puzzle.height; y+=2) { | ||
| 106 | var cell = puzzle.getCell(x, y) | ||
| 107 | if (cell != null) { | ||
| 108 | if (cell.type === 'bridge' && cell.color === color) total++ | ||
| 109 | if (cell.type === 'nega') discardable++ | ||
| 110 | } | ||
| 111 | } | ||
| 112 | } | ||
| 113 | |||
| 114 | if (bridges[color].length != total) { | ||
| 115 | if (bridges[color].length >= total - discardable) { | ||
| 116 | // TODO: Negations in other regions can validate the solution | ||
| 117 | for (var bridge of bridges[color]) { | ||
| 118 | regionData.addInvalid(bridge) | ||
| 119 | } | ||
| 120 | } else { | ||
| 121 | for (var bridge of bridges[color]) { | ||
| 122 | regionData.addVeryInvalid(bridge) | ||
| 123 | } | ||
| 124 | } | ||
| 125 | } else if (!window.bridgeTest(region, puzzle, color, bridges[color])) { | ||
| 126 | for (var bridge of bridges[color]) { | ||
| 127 | regionData.addInvalid(bridge) | ||
| 128 | } | ||
| 129 | } | ||
| 130 | } | ||
| 131 | } | ||
| 132 | |||
| 133 | var DIRECTIONS = [ | ||
| 134 | {'x': 0, 'y':-1}, | ||
| 135 | {'x': 1, 'y':-1}, | ||
| 136 | {'x': 1, 'y': 0}, | ||
| 137 | {'x': 1, 'y': 1}, | ||
| 138 | {'x': 0, 'y': 1}, | ||
| 139 | {'x':-1, 'y': 1}, | ||
| 140 | {'x':-1, 'y': 0}, | ||
| 141 | {'x':-1, 'y':-1}, | ||
| 142 | ] | ||
| 143 | |||
| 144 | window.validateArrows = function(puzzle, region, regionData) { | ||
| 145 | for (var pos of region) { | ||
| 146 | var cell = puzzle.getCell(pos.x, pos.y) | ||
| 147 | if (cell == null) continue | ||
| 148 | if (cell.type != 'arrow') continue | ||
| 149 | dir = DIRECTIONS[cell.rot] | ||
| 150 | |||
| 151 | var count = 0 | ||
| 152 | var x = pos.x + dir.x | ||
| 153 | var y = pos.y + dir.y | ||
| 154 | for (var i=0; i<100; i++) { // 100 is arbitrary, it's just here to avoid infinite loops. | ||
| 155 | var line = puzzle.getLine(x, y) | ||
| 156 | console.spam('Testing', x, y, 'for arrow at', pos.x, pos.y, 'found', line) | ||
| 157 | if (line == null && (x%2 !== 1 || y%2 !== 1)) break | ||
| 158 | if (line > window.LINE_NONE) count++ | ||
| 159 | if (count > cell.count) break | ||
| 160 | x += dir.x * 2 | ||
| 161 | y += dir.y * 2 | ||
| 162 | if (puzzle.matchesSymmetricalPos(x, y, pos.x + dir.x, pos.y + dir.y)) break // Pillar exit condition (in case of looping) | ||
| 163 | } | ||
| 164 | if (count !== cell.count) { | ||
| 165 | console.log('Arrow at', pos.x, pos.y, 'crosses', count, 'lines, but should cross', cell.count) | ||
| 166 | regionData.addInvalid(pos) | ||
| 167 | } | ||
| 168 | } | ||
| 169 | } | ||
| 170 | |||
| 171 | window.validateSizers = function(puzzle, region, regionData) { | ||
| 172 | var sizers = [] | ||
| 173 | var regionSize = 0 | ||
| 174 | for (var pos of region) { | ||
| 175 | if (pos.x%2 === 1 && pos.y%2 === 1) regionSize++ // Only count cells for the region | ||
| 176 | var cell = puzzle.getCell(pos.x, pos.y) | ||
| 177 | if (cell == null) continue | ||
| 178 | if (cell.type == 'sizer') sizers.push(pos) | ||
| 179 | } | ||
| 180 | console.debug('Found', sizers.length, 'sizers') | ||
| 181 | if (sizers.length == 0) return // No sizers -- no impact on sizer validity | ||
| 182 | |||
| 183 | var sizerCount = regionSize / sizers.length | ||
| 184 | if (sizerCount % 1 != 0) { | ||
| 185 | console.log('Region size', regionSize, 'is not a multiple of # sizers', sizers.length) | ||
| 186 | for (var sizer of sizers) { | ||
| 187 | regionData.addInvalid(sizer) | ||
| 188 | } | ||
| 189 | return | ||
| 190 | } | ||
| 191 | |||
| 192 | if (puzzle.sizerCount == null) puzzle.sizerCount = sizerCount // No other sizes have been defined | ||
| 193 | if (puzzle.sizerCount != sizerCount) { | ||
| 194 | console.log('sizerCount', sizerCount, 'does not match puzzle sizerCount', puzzle.sizerCount) | ||
| 195 | for (var sizer of sizers) { | ||
| 196 | regionData.addInvalid(sizer) | ||
| 197 | } | ||
| 198 | } | ||
| 199 | } | ||
| 200 | |||
| 201 | }) | ||
| diff --git a/app/assets/javascripts/wittle/display2.js b/app/assets/javascripts/wittle/display2.js new file mode 100644 index 0000000..52069d6 --- /dev/null +++ b/app/assets/javascripts/wittle/display2.js | |||
| @@ -0,0 +1,299 @@ | |||
| 1 | namespace(function() { | ||
| 2 | |||
| 3 | window.draw = function(puzzle, target='puzzle') { | ||
| 4 | if (puzzle == null) return | ||
| 5 | var svg = document.getElementById(target) | ||
| 6 | console.info('Drawing', puzzle, 'into', svg) | ||
| 7 | while (svg.firstChild) svg.removeChild(svg.firstChild) | ||
| 8 | |||
| 9 | // Prevent context menu popups within the puzzle | ||
| 10 | svg.oncontextmenu = function(event) { | ||
| 11 | event.preventDefault() | ||
| 12 | } | ||
| 13 | |||
| 14 | if (puzzle.pillar === true) { | ||
| 15 | // 41*width + 30*2 (padding) + 10*2 (border) | ||
| 16 | var pixelWidth = 41 * puzzle.width + 80 | ||
| 17 | } else { | ||
| 18 | // 41*(width-1) + 24 (extra edge) + 30*2 (padding) + 10*2 (border) | ||
| 19 | var pixelWidth = 41 * puzzle.width + 63 | ||
| 20 | } | ||
| 21 | var pixelHeight = 41 * puzzle.height + 63 | ||
| 22 | svg.setAttribute('viewbox', '0 0 ' + pixelWidth + ' ' + pixelHeight) | ||
| 23 | svg.setAttribute('width', pixelWidth) | ||
| 24 | svg.setAttribute('height', pixelHeight) | ||
| 25 | |||
| 26 | var rect = createElement('rect') | ||
| 27 | svg.appendChild(rect) | ||
| 28 | rect.setAttribute('stroke-width', 10) | ||
| 29 | rect.setAttribute('stroke', window.BORDER) | ||
| 30 | rect.setAttribute('fill', window.OUTER_BACKGROUND) | ||
| 31 | // Accounting for the border thickness | ||
| 32 | rect.setAttribute('x', 5) | ||
| 33 | rect.setAttribute('y', 5) | ||
| 34 | rect.setAttribute('width', pixelWidth - 10) // Removing border | ||
| 35 | rect.setAttribute('height', pixelHeight - 10) // Removing border | ||
| 36 | |||
| 37 | drawCenters(puzzle, svg) | ||
| 38 | drawGrid(puzzle, svg, target) | ||
| 39 | drawStartAndEnd(puzzle, svg) | ||
| 40 | // Draw cell symbols after so they overlap the lines, if necessary | ||
| 41 | drawSymbols(puzzle, svg, target) | ||
| 42 | |||
| 43 | // For pillar puzzles, add faders for the left and right sides | ||
| 44 | if (puzzle.pillar === true) { | ||
| 45 | var defs = window.createElement('defs') | ||
| 46 | defs.id = 'cursorPos' | ||
| 47 | defs.innerHTML = '' + | ||
| 48 | '<linearGradient id="fadeInLeft">\n' + | ||
| 49 | ' <stop offset="0%" stop-opacity="1.0" stop-color="' + window.OUTER_BACKGROUND + '"></stop>\n' + | ||
| 50 | ' <stop offset="25%" stop-opacity="1.0" stop-color="' + window.OUTER_BACKGROUND + '"></stop>\n' + | ||
| 51 | ' <stop offset="100%" stop-opacity="0.0" stop-color="' + window.OUTER_BACKGROUND + '"></stop>\n' + | ||
| 52 | '</linearGradient>\n' + | ||
| 53 | '<linearGradient id="fadeOutRight">\n' + | ||
| 54 | ' <stop offset="0%" stop-opacity="0.0" stop-color="' + window.OUTER_BACKGROUND + '"></stop>\n' + | ||
| 55 | ' <stop offset="100%" stop-opacity="1.0" stop-color="' + window.OUTER_BACKGROUND + '"></stop>\n' + | ||
| 56 | '</linearGradient>\n' | ||
| 57 | svg.appendChild(defs) | ||
| 58 | |||
| 59 | var leftBox = window.createElement('rect') | ||
| 60 | leftBox.setAttribute('x', 16) | ||
| 61 | leftBox.setAttribute('y', 10) | ||
| 62 | leftBox.setAttribute('width', 48) | ||
| 63 | leftBox.setAttribute('height', 41 * puzzle.height + 43) | ||
| 64 | leftBox.setAttribute('fill', 'url(#fadeInLeft)') | ||
| 65 | leftBox.setAttribute('style', 'pointer-events: none') | ||
| 66 | svg.appendChild(leftBox) | ||
| 67 | |||
| 68 | var rightBox = window.createElement('rect') | ||
| 69 | rightBox.setAttribute('x', 41 * puzzle.width + 22) | ||
| 70 | rightBox.setAttribute('y', 10) | ||
| 71 | rightBox.setAttribute('width', 30) | ||
| 72 | rightBox.setAttribute('height', 41 * puzzle.height + 43) | ||
| 73 | rightBox.setAttribute('fill', 'url(#fadeOutRight)') | ||
| 74 | rightBox.setAttribute('style', 'pointer-events: none') | ||
| 75 | svg.appendChild(rightBox) | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | function drawCenters(puzzle, svg) { | ||
| 80 | // @Hack that I am not fixing. This switches the puzzle's grid to a floodfilled grid | ||
| 81 | // where null represents cells which are part of the outside | ||
| 82 | var savedGrid = puzzle.switchToMaskedGrid() | ||
| 83 | if (puzzle.pillar === true) { | ||
| 84 | for (var y=1; y<puzzle.height; y += 2) { | ||
| 85 | if (puzzle.getCell(-1, y) == null) continue // Cell borders the outside | ||
| 86 | |||
| 87 | var rect = createElement('rect') | ||
| 88 | rect.setAttribute('x', 28) | ||
| 89 | rect.setAttribute('y', 41 * y + 11) | ||
| 90 | rect.setAttribute('width', 24) | ||
| 91 | rect.setAttribute('height', 82) | ||
| 92 | rect.setAttribute('fill', window.BACKGROUND) | ||
| 93 | svg.appendChild(rect) | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | for (var x=1; x<puzzle.width; x += 2) { | ||
| 98 | for (var y=1; y<puzzle.height; y += 2) { | ||
| 99 | if (puzzle.grid[x][y] == null) continue // Cell borders the outside | ||
| 100 | |||
| 101 | var rect = createElement('rect') | ||
| 102 | rect.setAttribute('x', 41 * x + 11) | ||
| 103 | rect.setAttribute('y', 41 * y + 11) | ||
| 104 | rect.setAttribute('width', 82) | ||
| 105 | rect.setAttribute('height', 82) | ||
| 106 | rect.setAttribute('fill', window.BACKGROUND) | ||
| 107 | rect.setAttribute('shape-rendering', 'crispedges') // Otherwise they don't meet behind gaps | ||
| 108 | svg.appendChild(rect) | ||
| 109 | } | ||
| 110 | } | ||
| 111 | puzzle.grid = savedGrid | ||
| 112 | } | ||
| 113 | |||
| 114 | function drawGrid(puzzle, svg, target) { | ||
| 115 | for (var x=0; x<puzzle.width; x++) { | ||
| 116 | for (var y=0; y<puzzle.height; y++) { | ||
| 117 | var cell = puzzle.grid[x][y] | ||
| 118 | if (cell != null && cell.gap === window.GAP_FULL) continue | ||
| 119 | if (cell != null && cell.gap === window.GAP_BREAK) { | ||
| 120 | var params = { | ||
| 121 | 'width':58, | ||
| 122 | 'height':58, | ||
| 123 | 'x': x*41 + 23, | ||
| 124 | 'y': y*41 + 23, | ||
| 125 | 'class': target + '_' + x + '_' + y, | ||
| 126 | 'type': 'gap', | ||
| 127 | } | ||
| 128 | if (x%2 === 0 && y%2 === 1) params.rot = 1 | ||
| 129 | drawSymbolWithSvg(svg, params) | ||
| 130 | continue | ||
| 131 | } | ||
| 132 | |||
| 133 | var line = createElement('line') | ||
| 134 | line.setAttribute('stroke-width', 24) | ||
| 135 | line.setAttribute('stroke-linecap', 'round') | ||
| 136 | line.setAttribute('stroke', window.FOREGROUND) | ||
| 137 | if (x%2 === 1 && y%2 === 0) { // Horizontal | ||
| 138 | if (cell.gap === window.GAP_BREAK) continue | ||
| 139 | line.setAttribute('x1', (x-1)*41 + 52) | ||
| 140 | // Adjust the length if it's a pillar -- the grid is not as wide! | ||
| 141 | if (puzzle.pillar === true && x === puzzle.width - 1) { | ||
| 142 | line.setAttribute('x2', (x+1)*41 + 40) | ||
| 143 | } else { | ||
| 144 | line.setAttribute('x2', (x+1)*41 + 52) | ||
| 145 | } | ||
| 146 | line.setAttribute('y1', y*41 + 52) | ||
| 147 | line.setAttribute('y2', y*41 + 52) | ||
| 148 | svg.appendChild(line) | ||
| 149 | } else if (x%2 === 0 && y%2 === 1) { // Vertical | ||
| 150 | if (cell.gap === window.GAP_BREAK) continue | ||
| 151 | line.setAttribute('x1', x*41 + 52) | ||
| 152 | line.setAttribute('x2', x*41 + 52) | ||
| 153 | line.setAttribute('y1', (y-1)*41 + 52) | ||
| 154 | line.setAttribute('y2', (y+1)*41 + 52) | ||
| 155 | svg.appendChild(line) | ||
| 156 | } else if (x%2 === 0 && y%2 === 0) { // Intersection | ||
| 157 | var surroundingLines = 0 | ||
| 158 | if (cell.end != null) surroundingLines++ | ||
| 159 | var leftCell = puzzle.getCell(x - 1, y) | ||
| 160 | if (leftCell != null && leftCell.gap !== window.GAP_FULL) surroundingLines++ | ||
| 161 | var rightCell = puzzle.getCell(x + 1, y) | ||
| 162 | if (rightCell != null && rightCell.gap !== window.GAP_FULL) surroundingLines++ | ||
| 163 | var topCell = puzzle.getCell(x, y - 1) | ||
| 164 | if (topCell != null && topCell.gap !== window.GAP_FULL) surroundingLines++ | ||
| 165 | var bottomCell = puzzle.getCell(x, y + 1) | ||
| 166 | if (bottomCell != null && bottomCell.gap !== window.GAP_FULL) surroundingLines++ | ||
| 167 | |||
| 168 | if (surroundingLines === 1) { | ||
| 169 | // Add square caps for dead ends which are non-endpoints | ||
| 170 | var rect = createElement('rect') | ||
| 171 | rect.setAttribute('x', x*41 + 40) | ||
| 172 | rect.setAttribute('y', y*41 + 40) | ||
| 173 | rect.setAttribute('width', 24) | ||
| 174 | rect.setAttribute('height', 24) | ||
| 175 | rect.setAttribute('fill', window.FOREGROUND) | ||
| 176 | svg.appendChild(rect) | ||
| 177 | } else if (surroundingLines > 1) { | ||
| 178 | // Add rounding for other intersections (handling gap-only corners) | ||
| 179 | var circ = createElement('circle') | ||
| 180 | circ.setAttribute('cx', x*41 + 52) | ||
| 181 | circ.setAttribute('cy', y*41 + 52) | ||
| 182 | circ.setAttribute('r', 12) | ||
| 183 | circ.setAttribute('fill', window.FOREGROUND) | ||
| 184 | svg.appendChild(circ) | ||
| 185 | } | ||
| 186 | } | ||
| 187 | } | ||
| 188 | } | ||
| 189 | // Determine if left-side needs a 'wrap indicator' | ||
| 190 | if (puzzle.pillar === true) { | ||
| 191 | var x = 0; | ||
| 192 | for (var y=0; y<puzzle.height; y+=2) { | ||
| 193 | var cell = puzzle.getCell(x-1, y) | ||
| 194 | if (cell == null || cell.gap === window.GAP_FULL) continue | ||
| 195 | var line = createElement('line') | ||
| 196 | line.setAttribute('stroke-width', 24) | ||
| 197 | line.setAttribute('stroke-linecap', 'round') | ||
| 198 | line.setAttribute('stroke', window.FOREGROUND) | ||
| 199 | line.setAttribute('x1', x*41 + 40) | ||
| 200 | line.setAttribute('x2', x*41 + 52) | ||
| 201 | line.setAttribute('y1', y*41 + 52) | ||
| 202 | line.setAttribute('y2', y*41 + 52) | ||
| 203 | svg.appendChild(line) | ||
| 204 | } | ||
| 205 | } | ||
| 206 | } | ||
| 207 | |||
| 208 | function drawSymbols(puzzle, svg, target) { | ||
| 209 | for (var x=0; x<puzzle.width; x++) { | ||
| 210 | for (var y=0; y<puzzle.height; y++) { | ||
| 211 | var cell = puzzle.grid[x][y] | ||
| 212 | if (cell == null) continue | ||
| 213 | var params = { | ||
| 214 | 'width':58, | ||
| 215 | 'height':58, | ||
| 216 | 'x': x*41 + 23, | ||
| 217 | 'y': y*41 + 23, | ||
| 218 | 'class': target + '_' + x + '_' + y, | ||
| 219 | } | ||
| 220 | if (cell.dot > window.DOT_NONE) { | ||
| 221 | params.type = 'dot' | ||
| 222 | if (cell.dot === window.DOT_BLACK) params.color = 'black' | ||
| 223 | else if (cell.dot === window.DOT_BLUE) params.color = window.LINE_PRIMARY | ||
| 224 | else if (cell.dot === window.DOT_YELLOW) params.color = window.LINE_SECONDARY | ||
| 225 | else if (cell.dot === window.DOT_INVISIBLE) { | ||
| 226 | params.color = window.FOREGROUND | ||
| 227 | // This makes the invisible dots visible, but only while we're in the editor. | ||
| 228 | if (document.getElementById('metaButtons') != null) { | ||
| 229 | params.stroke = 'black' | ||
| 230 | params.strokeWidth = '2px' | ||
| 231 | } | ||
| 232 | } | ||
| 233 | drawSymbolWithSvg(svg, params) | ||
| 234 | } else if (cell.gap === window.GAP_BREAK) { | ||
| 235 | // Gaps were handled above, while drawing the grid. | ||
| 236 | } else if (x%2 === 1 && y%2 === 1) { | ||
| 237 | // Generic draw for all other elements | ||
| 238 | Object.assign(params, cell) | ||
| 239 | window.drawSymbolWithSvg(svg, params, puzzle.settings.CUSTOM_MECHANICS) | ||
| 240 | } | ||
| 241 | } | ||
| 242 | } | ||
| 243 | } | ||
| 244 | |||
| 245 | function drawStartAndEnd(puzzle, svg) { | ||
| 246 | for (var x=0; x<puzzle.width; x++) { | ||
| 247 | for (var y=0; y<puzzle.height; y++) { | ||
| 248 | var cell = puzzle.grid[x][y] | ||
| 249 | if (cell == null) continue | ||
| 250 | if (cell.end != null) { | ||
| 251 | window.drawSymbolWithSvg(svg, { | ||
| 252 | 'type': 'end', | ||
| 253 | 'width': 58, | ||
| 254 | 'height': 58, | ||
| 255 | 'dir': cell.end, | ||
| 256 | 'x': x*41 + 23, | ||
| 257 | 'y': y*41 + 23, | ||
| 258 | }) | ||
| 259 | } | ||
| 260 | |||
| 261 | if (cell.start === true) { | ||
| 262 | var symStart = null | ||
| 263 | if (puzzle.symmetry != null) { | ||
| 264 | var sym = puzzle.getSymmetricalPos(x, y) | ||
| 265 | window.drawSymbolWithSvg(svg, { | ||
| 266 | 'type': 'start', | ||
| 267 | 'width': 58, | ||
| 268 | 'height': 58, | ||
| 269 | 'x': sym.x*41 + 23, | ||
| 270 | 'y': sym.y*41 + 23, | ||
| 271 | }) | ||
| 272 | symStart = svg.lastChild | ||
| 273 | symStart.style.display = 'none' | ||
| 274 | symStart.id = 'symStart_' + svg.id + '_' + x + '_' + y | ||
| 275 | } | ||
| 276 | |||
| 277 | window.drawSymbolWithSvg(svg, { | ||
| 278 | 'type': 'start', | ||
| 279 | 'width': 58, | ||
| 280 | 'height': 58, | ||
| 281 | 'x': x*41 + 23, | ||
| 282 | 'y': y*41 + 23, | ||
| 283 | }) | ||
| 284 | var start = svg.lastChild | ||
| 285 | start.id = 'start_' + svg.id + '_' + x + '_' + y | ||
| 286 | |||
| 287 | // ;(function(a){}(a)) | ||
| 288 | // This syntax is used to forcibly copy all of the arguments | ||
| 289 | ;(function(puzzle, x, y, start, symStart) { | ||
| 290 | start.onpointerdown = function(event) { | ||
| 291 | window.trace(event, puzzle, {'x':x, 'y':y}, start, symStart) | ||
| 292 | } | ||
| 293 | }(puzzle, x, y, start, symStart)) | ||
| 294 | } | ||
| 295 | } | ||
| 296 | } | ||
| 297 | } | ||
| 298 | |||
| 299 | }) | ||
| diff --git a/app/assets/javascripts/wittle/polyominos.js b/app/assets/javascripts/wittle/polyominos.js new file mode 100644 index 0000000..4d8be6e --- /dev/null +++ b/app/assets/javascripts/wittle/polyominos.js | |||
| @@ -0,0 +1,331 @@ | |||
| 1 | namespace(function() { | ||
| 2 | |||
| 3 | function getPolySize(polyshape) { | ||
| 4 | var size = 0 | ||
| 5 | for (var x=0; x<4; x++) { | ||
| 6 | for (var y=0; y<4; y++) { | ||
| 7 | if (isSet(polyshape, x, y)) size++ | ||
| 8 | } | ||
| 9 | } | ||
| 10 | return size | ||
| 11 | } | ||
| 12 | |||
| 13 | function mask(x, y) { | ||
| 14 | return 1 << ((3-y)*4 + x) | ||
| 15 | } | ||
| 16 | |||
| 17 | function isSet(polyshape, x, y) { | ||
| 18 | if (x < 0 || y < 0) return false | ||
| 19 | if (x >= 4 || y >= 4) return false | ||
| 20 | return (polyshape & mask(x, y)) !== 0 | ||
| 21 | } | ||
| 22 | |||
| 23 | // This is 2^20, whereas all the other bits fall into 2^(0-15) | ||
| 24 | window.ROTATION_BIT = (1 << 20) | ||
| 25 | |||
| 26 | window.isRotated = function(polyshape) { | ||
| 27 | return (polyshape & ROTATION_BIT) !== 0 | ||
| 28 | } | ||
| 29 | |||
| 30 | function getRotations(polyshape) { | ||
| 31 | if (!isRotated(polyshape)) return [polyshape] | ||
| 32 | |||
| 33 | var rotations = [0, 0, 0, 0] | ||
| 34 | for (var x=0; x<4; x++) { | ||
| 35 | for (var y=0; y<4; y++) { | ||
| 36 | if (isSet(polyshape, x, y)) { | ||
| 37 | rotations[0] ^= mask(x, y) | ||
| 38 | rotations[1] ^= mask(y, 3-x) | ||
| 39 | rotations[2] ^= mask(3-x, 3-y) | ||
| 40 | rotations[3] ^= mask(3-y, x) | ||
| 41 | } | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | return rotations | ||
| 46 | } | ||
| 47 | |||
| 48 | // 90 degree rotations of the polyomino | ||
| 49 | window.rotatePolyshape = function(polyshape, count=1) { | ||
| 50 | var rotations = getRotations(polyshape | window.ROTATION_BIT) | ||
| 51 | return rotations[count % 4] | ||
| 52 | } | ||
| 53 | |||
| 54 | // IMPORTANT NOTE: When formulating these, the top row must contain (0, 0) | ||
| 55 | // That means there will never be any negative y values. | ||
| 56 | // (0, 0) must also be a cell in the shape, so that | ||
| 57 | // placing the shape at (x, y) will fill (x, y) | ||
| 58 | // Ylops will have -1s on all adjacent cells, to break "overlaps" for polyominos. | ||
| 59 | window.polyominoFromPolyshape = function(polyshape, ylop=false, precise=true) { | ||
| 60 | var topLeft = null | ||
| 61 | for (var y=0; y<4; y++) { | ||
| 62 | for (var x=0; x<4; x++) { | ||
| 63 | if (isSet(polyshape, x, y)) { | ||
| 64 | topLeft = {'x':x, 'y':y} | ||
| 65 | break | ||
| 66 | } | ||
| 67 | } | ||
| 68 | if (topLeft != null) break | ||
| 69 | } | ||
| 70 | if (topLeft == null) return [] // Empty polyomino | ||
| 71 | |||
| 72 | var polyomino = [] | ||
| 73 | for (var x=0; x<4; x++) { | ||
| 74 | for (var y=0; y<4; y++) { | ||
| 75 | if (!isSet(polyshape, x, y)) continue | ||
| 76 | polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y)}) | ||
| 77 | |||
| 78 | // "Precise" polyominos adds cells in between the apparent squares in the polyomino. | ||
| 79 | // This prevents the solution line from going through polyominos in the solution. | ||
| 80 | if (precise) { | ||
| 81 | if (ylop) { | ||
| 82 | // Ylops fill up/left if no adjacent cell, and always fill bottom/right | ||
| 83 | if (!isSet(polyshape, x - 1, y)) { | ||
| 84 | polyomino.push({'x':2*(x - topLeft.x) - 1, 'y':2*(y - topLeft.y)}) | ||
| 85 | } | ||
| 86 | if (!isSet(polyshape, x, y - 1)) { | ||
| 87 | polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y) - 1}) | ||
| 88 | } | ||
| 89 | polyomino.push({'x':2*(x - topLeft.x) + 1, 'y':2*(y - topLeft.y)}) | ||
| 90 | polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y) + 1}) | ||
| 91 | } else { | ||
| 92 | // Normal polys only fill bottom/right if there is an adjacent cell. | ||
| 93 | if (isSet(polyshape, x + 1, y)) { | ||
| 94 | polyomino.push({'x':2*(x - topLeft.x) + 1, 'y':2*(y - topLeft.y)}) | ||
| 95 | } | ||
| 96 | if (isSet(polyshape, x, y + 1)) { | ||
| 97 | polyomino.push({'x':2*(x - topLeft.x), 'y':2*(y - topLeft.y) + 1}) | ||
| 98 | } | ||
| 99 | } | ||
| 100 | } | ||
| 101 | } | ||
| 102 | } | ||
| 103 | return polyomino | ||
| 104 | } | ||
| 105 | |||
| 106 | window.polyshapeFromPolyomino = function(polyomino) { | ||
| 107 | var topLeft = {'x': 9999, 'y': 9999} | ||
| 108 | for (var pos of polyomino) { | ||
| 109 | if (pos.x%2 != 1 || pos.y%2 != 1) continue // We only care about cells, not edges or intersections | ||
| 110 | |||
| 111 | // Unlike when we're making a polyomino, we just want to top and left flush the shape, | ||
| 112 | // we don't actually need (0, 0) to be filled. | ||
| 113 | if (pos.x < topLeft.x) topLeft.x = pos.x | ||
| 114 | if (pos.y < topLeft.y) topLeft.y = pos.y | ||
| 115 | } | ||
| 116 | if (topLeft == null) return 0 // Empty polyomino | ||
| 117 | |||
| 118 | var polyshape = 0 | ||
| 119 | for (var pos of polyomino) { | ||
| 120 | if (pos.x%2 != 1 || pos.y%2 != 1) continue // We only care about cells, not edges or intersections | ||
| 121 | var x = (pos.x - topLeft.x) / 2 // 0.5x to convert from puzzle coordinates to polyshape coordinates | ||
| 122 | var y = (pos.y - topLeft.y) / 2 // 0.5x to convert from puzzle coordinates to polyshape coordinates | ||
| 123 | polyshape |= mask(x, y) | ||
| 124 | } | ||
| 125 | |||
| 126 | return polyshape | ||
| 127 | } | ||
| 128 | |||
| 129 | // In some cases, polyominos and onimoylops will fully cancel each other out. | ||
| 130 | // However, even if they are the same size, that doesn't guarantee that they fit together. | ||
| 131 | // As an optimization, we save the results for known combinations of shapes, since there are likely many | ||
| 132 | // fewer pairings of shapes than paths through the grid. | ||
| 133 | var knownCancellations = {} | ||
| 134 | |||
| 135 | // Attempt to fit polyominos in a region into the puzzle. | ||
| 136 | // This function checks for early exits, then simplifies the grid to a numerical representation: | ||
| 137 | // * 1 represents a square that has been double-covered (by two polyominos) | ||
| 138 | // * Or, in the cancellation case, it represents a square that was covered by a polyomino and not by an onimoylop | ||
| 139 | // * 0 represents a square that is satisfied, either because: | ||
| 140 | // * it is outside the region | ||
| 141 | // * (In the normal case) it was inside the region, and has been covered by a polyomino | ||
| 142 | // * (In the cancellation case) it was covered by an equal number of polyominos and onimoylops | ||
| 143 | // * -1 represents a square that needs to be covered once (inside the region, or outside but covered by an onimoylop) | ||
| 144 | // * -2 represents a square that needs to be covered twice (inside the region & covered by an onimoylop) | ||
| 145 | // * And etc, for additional layers of polyominos/onimoylops. | ||
| 146 | window.polyFit = function(region, puzzle) { | ||
| 147 | var polys = [] | ||
| 148 | var ylops = [] | ||
| 149 | var polyCount = 0 | ||
| 150 | var regionSize = 0 | ||
| 151 | for (var pos of region) { | ||
| 152 | if (pos.x%2 === 1 && pos.y%2 === 1) regionSize++ | ||
| 153 | var cell = puzzle.grid[pos.x][pos.y] | ||
| 154 | if (cell == null) continue | ||
| 155 | if (cell.polyshape === 0) continue | ||
| 156 | if (cell.type === 'poly') { | ||
| 157 | polys.push(cell) | ||
| 158 | polyCount += getPolySize(cell.polyshape) | ||
| 159 | } else if (cell.type === 'ylop') { | ||
| 160 | ylops.push(cell) | ||
| 161 | polyCount -= getPolySize(cell.polyshape) | ||
| 162 | } | ||
| 163 | } | ||
| 164 | if (polys.length + ylops.length === 0) { | ||
| 165 | console.log('No polyominos or onimoylops inside the region, vacuously true') | ||
| 166 | return true | ||
| 167 | } | ||
| 168 | if (polyCount > 0 && polyCount !== regionSize) { | ||
| 169 | console.log('Combined size of polyominos and onimoylops', polyCount, 'does not match region size', regionSize) | ||
| 170 | return false | ||
| 171 | } | ||
| 172 | if (polyCount < 0) { | ||
| 173 | console.log('Combined size of onimoylops is greater than polyominos by', -polyCount) | ||
| 174 | return false | ||
| 175 | } | ||
| 176 | var key = null | ||
| 177 | if (polyCount === 0) { | ||
| 178 | if (puzzle.settings.SHAPELESS_ZERO_POLY) { | ||
| 179 | console.log('Combined size of polyominos and onimoylops is zero') | ||
| 180 | return true | ||
| 181 | } | ||
| 182 | // These will be ordered by the order of cells in the region, which isn't exactly consistent. | ||
| 183 | // In practice, it seems to be good enough. | ||
| 184 | key = '' | ||
| 185 | for (var ylop of ylops) key += ' ' + ylop.polyshape | ||
| 186 | key += '|' | ||
| 187 | for (var poly of polys) key += ' ' + poly.polyshape | ||
| 188 | var ret = knownCancellations[key] | ||
| 189 | if (ret != null) return ret | ||
| 190 | } | ||
| 191 | |||
| 192 | // For polyominos, we clear the grid to mark it up again: | ||
| 193 | var savedGrid = puzzle.grid | ||
| 194 | puzzle.newGrid() | ||
| 195 | // First, we mark all cells as 0: Cells outside the target region should be unaffected. | ||
| 196 | for (var x=0; x<puzzle.width; x++) { | ||
| 197 | for (var y=0; y<puzzle.height; y++) { | ||
| 198 | puzzle.setCell(x, y, 0) | ||
| 199 | } | ||
| 200 | } | ||
| 201 | // In the normal case, we mark every cell as -1: It needs to be covered by one poly | ||
| 202 | if (polyCount > 0) { | ||
| 203 | for (var pos of region) puzzle.grid[pos.x][pos.y] = -1 | ||
| 204 | } | ||
| 205 | // In the exact match case, we leave every cell marked 0: Polys and ylops need to cancel. | ||
| 206 | |||
| 207 | var ret = placeYlops(ylops, 0, polys, puzzle) | ||
| 208 | if (polyCount === 0) knownCancellations[key] = ret | ||
| 209 | puzzle.grid = savedGrid | ||
| 210 | return ret | ||
| 211 | } | ||
| 212 | |||
| 213 | // If false, poly doesn't fit and grid is unmodified | ||
| 214 | // If true, poly fits and grid is modified (with the placement) | ||
| 215 | function tryPlacePolyshape(cells, x, y, puzzle, sign) { | ||
| 216 | console.spam('Placing at', x, y, 'with sign', sign) | ||
| 217 | var numCells = cells.length | ||
| 218 | for (var i=0; i<numCells; i++) { | ||
| 219 | var cell = cells[i] | ||
| 220 | var puzzleCell = puzzle.getCell(cell.x + x, cell.y + y) | ||
| 221 | if (puzzleCell == null) return false | ||
| 222 | cell.value = puzzleCell | ||
| 223 | } | ||
| 224 | for (var i=0; i<numCells; i++) { | ||
| 225 | var cell = cells[i] | ||
| 226 | puzzle.setCell(cell.x + x, cell.y + y, cell.value + sign) | ||
| 227 | } | ||
| 228 | return true | ||
| 229 | } | ||
| 230 | |||
| 231 | // Places the ylops such that they are inside of the grid, then checks if the polys | ||
| 232 | // zero the region. | ||
| 233 | function placeYlops(ylops, i, polys, puzzle) { | ||
| 234 | // Base case: No more ylops to place, start placing polys | ||
| 235 | if (i === ylops.length) return placePolys(polys, puzzle) | ||
| 236 | |||
| 237 | var ylop = ylops[i] | ||
| 238 | var ylopRotations = getRotations(ylop.polyshape) | ||
| 239 | for (var x=1; x<puzzle.width; x+=2) { | ||
| 240 | for (var y=1; y<puzzle.height; y+=2) { | ||
| 241 | console.log('Placing ylop', ylop, 'at', x, y) | ||
| 242 | for (var polyshape of ylopRotations) { | ||
| 243 | var cells = polyominoFromPolyshape(polyshape, true, puzzle.settings.PRECISE_POLYOMINOS) | ||
| 244 | if (!tryPlacePolyshape(cells, x, y, puzzle, -1)) continue | ||
| 245 | console.group('') | ||
| 246 | if (placeYlops(ylops, i+1, polys, puzzle)) return true | ||
| 247 | console.groupEnd('') | ||
| 248 | if (!tryPlacePolyshape(cells, x, y, puzzle, +1)) continue | ||
| 249 | } | ||
| 250 | } | ||
| 251 | } | ||
| 252 | console.log('Tried all ylop placements with no success.') | ||
| 253 | return false | ||
| 254 | } | ||
| 255 | |||
| 256 | // Returns whether or not a set of polyominos fit into a region. | ||
| 257 | // Solves via recursive backtracking: Some piece must fill the top left square, | ||
| 258 | // so try every piece to fill it, then recurse. | ||
| 259 | function placePolys(polys, puzzle) { | ||
| 260 | // Check for overlapping polyominos, and handle exit cases for all polyominos placed. | ||
| 261 | var allPolysPlaced = (polys.length === 0) | ||
| 262 | for (var x=0; x<puzzle.width; x++) { | ||
| 263 | var row = puzzle.grid[x] | ||
| 264 | for (var y=0; y<puzzle.height; y++) { | ||
| 265 | var cell = row[y] | ||
| 266 | if (cell > 0) { | ||
| 267 | console.log('Cell', x, y, 'has been overfilled and no ylops left to place') | ||
| 268 | return false | ||
| 269 | } | ||
| 270 | if (allPolysPlaced && cell < 0 && x%2 === 1 && y%2 === 1) { | ||
| 271 | // Normal, center cell with a negative value & no polys remaining. | ||
| 272 | console.log('All polys placed, but grid not full') | ||
| 273 | return false | ||
| 274 | } | ||
| 275 | } | ||
| 276 | } | ||
| 277 | if (allPolysPlaced) { | ||
| 278 | console.log('All polys placed, and grid full') | ||
| 279 | return true | ||
| 280 | } | ||
| 281 | |||
| 282 | // The top-left (first open cell) must be filled by a polyomino. | ||
| 283 | // However in the case of pillars, there is no top-left, so we try all open cells in the | ||
| 284 | // top-most open row | ||
| 285 | var openCells = [] | ||
| 286 | for (var y=1; y<puzzle.height; y+=2) { | ||
| 287 | for (var x=1; x<puzzle.width; x+=2) { | ||
| 288 | if (puzzle.grid[x][y] >= 0) continue | ||
| 289 | openCells.push({'x':x, 'y':y}) | ||
| 290 | if (puzzle.pillar === false) break | ||
| 291 | } | ||
| 292 | if (openCells.length > 0) break | ||
| 293 | } | ||
| 294 | |||
| 295 | if (openCells.length === 0) { | ||
| 296 | console.log('Polys remaining but grid full') | ||
| 297 | return false | ||
| 298 | } | ||
| 299 | |||
| 300 | for (var openCell of openCells) { | ||
| 301 | var attemptedPolyshapes = [] | ||
| 302 | for (var i=0; i<polys.length; i++) { | ||
| 303 | var poly = polys[i] | ||
| 304 | console.spam('Selected poly', poly) | ||
| 305 | if (attemptedPolyshapes.includes(poly.polyshape)) { | ||
| 306 | console.spam('Polyshape', poly.polyshape, 'has already been attempted') | ||
| 307 | continue | ||
| 308 | } | ||
| 309 | attemptedPolyshapes.push(poly.polyshape) | ||
| 310 | polys.splice(i, 1) | ||
| 311 | for (var polyshape of getRotations(poly.polyshape)) { | ||
| 312 | console.spam('Selected polyshape', polyshape) | ||
| 313 | var cells = polyominoFromPolyshape(polyshape, false, puzzle.settings.PRECISE_POLYOMINOS) | ||
| 314 | if (!tryPlacePolyshape(cells, openCell.x, openCell.y, puzzle, +1)) { | ||
| 315 | console.spam('Polyshape', polyshape, 'does not fit into', openCell.x, openCell.y) | ||
| 316 | continue | ||
| 317 | } | ||
| 318 | console.group('') | ||
| 319 | if (placePolys(polys, puzzle)) return true | ||
| 320 | console.groupEnd('') | ||
| 321 | // Should not fail, as it's an inversion of the above tryPlacePolyshape | ||
| 322 | tryPlacePolyshape(cells, openCell.x, openCell.y, puzzle, -1) | ||
| 323 | } | ||
| 324 | polys.splice(i, 0, poly) | ||
| 325 | } | ||
| 326 | } | ||
| 327 | console.log('Grid non-empty with >0 polys, but no valid recursion.') | ||
| 328 | return false | ||
| 329 | } | ||
| 330 | |||
| 331 | }) | ||
| diff --git a/app/assets/javascripts/wittle/puzzle.js b/app/assets/javascripts/wittle/puzzle.js new file mode 100644 index 0000000..a7ad10a --- /dev/null +++ b/app/assets/javascripts/wittle/puzzle.js | |||
| @@ -0,0 +1,507 @@ | |||
| 1 | namespace(function() { | ||
| 2 | |||
| 3 | // A 2x2 grid is internally a 5x5: | ||
| 4 | // corner, edge, corner, edge, corner | ||
| 5 | // edge, cell, edge, cell, edge | ||
| 6 | // corner, edge, corner, edge, corner | ||
| 7 | // edge, cell, edge, cell, edge | ||
| 8 | // corner, edge, corner, edge, corner | ||
| 9 | // | ||
| 10 | // Corners and edges will have a value of true if the line passes through them | ||
| 11 | // Cells will contain an object if there is an element in them | ||
| 12 | window.Puzzle = class { | ||
| 13 | constructor(width, height, pillar=false) { | ||
| 14 | if (pillar === true) { | ||
| 15 | this.newGrid(2 * width, 2 * height + 1) | ||
| 16 | } else { | ||
| 17 | this.newGrid(2 * width + 1, 2 * height + 1) | ||
| 18 | } | ||
| 19 | this.pillar = pillar | ||
| 20 | this.settings = { | ||
| 21 | // If true, negation symbols are allowed to cancel other negation symbols. | ||
| 22 | NEGATIONS_CANCEL_NEGATIONS: true, | ||
| 23 | |||
| 24 | // If true, and the count of polyominos and onimoylops is zero, they cancel regardless of shape. | ||
| 25 | SHAPELESS_ZERO_POLY: false, | ||
| 26 | |||
| 27 | // If true, the traced line cannot go through the placement of a polyomino. | ||
| 28 | PRECISE_POLYOMINOS: true, | ||
| 29 | |||
| 30 | // If false, incorrect elements will not flash when failing the puzzle. | ||
| 31 | FLASH_FOR_ERRORS: true, | ||
| 32 | |||
| 33 | // If true, mid-segment startpoints will constitute solid lines, and form boundaries for the region. | ||
| 34 | FAT_STARTPOINTS: false, | ||
| 35 | |||
| 36 | // If true, custom mechanics are displayed (and validated) in this puzzle. | ||
| 37 | CUSTOM_MECHANICS: false, | ||
| 38 | |||
| 39 | // If true, polyominos may be placed partially off of the grid as an intermediate solution step. | ||
| 40 | // OUT_OF_BOUNDS_POLY: false, | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | static deserialize(json) { | ||
| 45 | var parsed = JSON.parse(json) | ||
| 46 | // Claim that it's not a pillar (for consistent grid sizing), then double-check ourselves later. | ||
| 47 | var puzzle = new Puzzle((parsed.grid.length - 1)/2, (parsed.grid[0].length - 1)/2) | ||
| 48 | puzzle.name = parsed.name | ||
| 49 | puzzle.autoSolved = parsed.autoSolved | ||
| 50 | puzzle.grid = parsed.grid | ||
| 51 | // Legacy: Grid squares used to use 'false' to indicate emptiness. | ||
| 52 | // Legacy: Cells may use {} to represent emptiness | ||
| 53 | // Now, we use: | ||
| 54 | // Cells default to null | ||
| 55 | // During onTraceStart, empty cells that are still inbounds are changed to {'type': 'nonce'} for tracing purposes. | ||
| 56 | // Lines default to {'type':'line', 'line':0} | ||
| 57 | for (var x=0; x<puzzle.width; x++) { | ||
| 58 | for (var y=0; y<puzzle.height; y++) { | ||
| 59 | var cell = puzzle.grid[x][y] | ||
| 60 | if (cell === false || cell == null || cell.type == null) { | ||
| 61 | if (x%2 === 1 && y%2 === 1) puzzle.grid[x][y] = null | ||
| 62 | else puzzle.grid[x][y] = {'type':'line', 'line':window.LINE_NONE} | ||
| 63 | } else { | ||
| 64 | if (cell.type === 'poly' || cell.type === 'ylop') { | ||
| 65 | if (cell.rot === 'all') { | ||
| 66 | // Legacy: Polys and ylops used to have a rot value (before I started using polyshape). | ||
| 67 | // rot=all is a holdover that was used to represent rotation polyominos. | ||
| 68 | puzzle.grid[x][y].polyshape |= window.ROTATION_BIT | ||
| 69 | delete puzzle.grid[x][y].rot | ||
| 70 | } | ||
| 71 | // Fixup: Sometimes we have a polyshape which is empty. Just ignore these objects. | ||
| 72 | if (puzzle.grid[x][y].polyshape & ~window.ROTATION_BIT === 0) puzzle.grid[x][y] = null | ||
| 73 | } else if ((x%2 !== 1 || y%2 !== 1) && cell.color != null) { | ||
| 74 | // Legacy: Lines used to use 'color' instead of 'line', but that was redundant with actual colors | ||
| 75 | cell.line = cell.color | ||
| 76 | delete cell.color | ||
| 77 | } else if (cell.gap === true) { | ||
| 78 | // Legacy: Gaps used to be null/true, are now null/1/2 | ||
| 79 | puzzle.grid[x][y].gap = window.GAP_BREAK | ||
| 80 | } | ||
| 81 | } | ||
| 82 | } | ||
| 83 | } | ||
| 84 | // Legacy: Startpoints used to be only parsed.start | ||
| 85 | if (parsed.start) { | ||
| 86 | parsed.startPoints = [parsed.start] | ||
| 87 | } | ||
| 88 | // Legacy: Startpoints used to be a separate array, now they are flags | ||
| 89 | if (parsed.startPoints) { | ||
| 90 | for (var startPoint of parsed.startPoints) { | ||
| 91 | puzzle.grid[startPoint.x][startPoint.y].start = true | ||
| 92 | } | ||
| 93 | } | ||
| 94 | // Legacy: Endpoints used to be only parsed.end | ||
| 95 | if (parsed.end) { | ||
| 96 | parsed.endPoints = [parsed.end] | ||
| 97 | } | ||
| 98 | // Legacy: Endpoints used to be a separate array, now they are flags | ||
| 99 | if (parsed.endPoints) { | ||
| 100 | for (var endPoint of parsed.endPoints) { | ||
| 101 | puzzle.grid[endPoint.x][endPoint.y].end = endPoint.dir | ||
| 102 | } | ||
| 103 | } | ||
| 104 | // Legacy: Dots and gaps used to be separate arrays | ||
| 105 | // Now, they are flags on the individual lines. | ||
| 106 | if (parsed.dots) { | ||
| 107 | for (var dot of parsed.dots) { | ||
| 108 | puzzle.grid[dot.x][dot.y].dot = window.DOT_BLACK | ||
| 109 | } | ||
| 110 | } | ||
| 111 | if (parsed.gaps) { | ||
| 112 | for (var gap of parsed.gaps) { | ||
| 113 | puzzle.grid[gap.x][gap.y].gap = window.GAP_BREAK | ||
| 114 | } | ||
| 115 | } | ||
| 116 | if (parsed.settings) { | ||
| 117 | for (var key of Object.keys(parsed.settings)) { | ||
| 118 | puzzle.settings[key] = parsed.settings[key] | ||
| 119 | } | ||
| 120 | } | ||
| 121 | puzzle.pillar = parsed.pillar | ||
| 122 | puzzle.symmetry = parsed.symmetry | ||
| 123 | puzzle.largezero = puzzle.width * puzzle.height | ||
| 124 | return puzzle | ||
| 125 | } | ||
| 126 | |||
| 127 | serialize() { | ||
| 128 | return JSON.stringify(this) | ||
| 129 | } | ||
| 130 | |||
| 131 | clone() { | ||
| 132 | return Puzzle.deserialize(this.serialize()) | ||
| 133 | } | ||
| 134 | |||
| 135 | // This is explicitly *not* just clearing the grid, so that external references | ||
| 136 | // to the grid are not also cleared. | ||
| 137 | newGrid(width, height) { | ||
| 138 | if (width == null) { // Called by someone who just wants to clear the grid. | ||
| 139 | width = this.width | ||
| 140 | height = this.height | ||
| 141 | } | ||
| 142 | this.grid = [] | ||
| 143 | for (var x=0; x<width; x++) { | ||
| 144 | this.grid[x] = [] | ||
| 145 | for (var y=0; y<height; y++) { | ||
| 146 | if (x%2 === 1 && y%2 === 1) this.grid[x][y] = null | ||
| 147 | else this.grid[x][y] = {'type':'line', 'line':LINE_NONE} | ||
| 148 | } | ||
| 149 | } | ||
| 150 | // Performance: A large value which is === 0 to be used for pillar wrapping. | ||
| 151 | // Performance: Getting the size of the grid has a nonzero cost. | ||
| 152 | // Definitely getting the length of the first element isn't optimized. | ||
| 153 | this.largezero = width * height * 2 | ||
| 154 | this.width = this.grid.length | ||
| 155 | this.height = this.grid[0].length | ||
| 156 | } | ||
| 157 | |||
| 158 | // Wrap a value around at the width of the grid. No-op if not in pillar mode. | ||
| 159 | _mod(val) { | ||
| 160 | if (this.pillar === false) return val | ||
| 161 | return (val + this.largezero) % this.width | ||
| 162 | } | ||
| 163 | |||
| 164 | // Determine if an x, y pair is a safe reference inside the grid. This should be invoked at the start of every | ||
| 165 | // function, but then functions can access the grid directly. | ||
| 166 | _safeCell(x, y) { | ||
| 167 | if (x < 0 || x >= this.width) return false | ||
| 168 | if (y < 0 || y >= this.height) return false | ||
| 169 | return true | ||
| 170 | } | ||
| 171 | |||
| 172 | getCell(x, y) { | ||
| 173 | x = this._mod(x) | ||
| 174 | if (!this._safeCell(x, y)) return null | ||
| 175 | return this.grid[x][y] | ||
| 176 | } | ||
| 177 | |||
| 178 | setCell(x, y, value) { | ||
| 179 | x = this._mod(x) | ||
| 180 | if (!this._safeCell(x, y)) return | ||
| 181 | this.grid[x][y] = value | ||
| 182 | } | ||
| 183 | |||
| 184 | getSymmetricalDir(dir) { | ||
| 185 | if (this.symmetry != null) { | ||
| 186 | if (this.symmetry.x === true) { | ||
| 187 | if (dir === 'left') return 'right' | ||
| 188 | if (dir === 'right') return 'left' | ||
| 189 | } | ||
| 190 | if (this.symmetry.y === true) { | ||
| 191 | if (dir === 'top') return 'bottom' | ||
| 192 | if (dir === 'bottom') return 'top' | ||
| 193 | } | ||
| 194 | } | ||
| 195 | return dir | ||
| 196 | } | ||
| 197 | |||
| 198 | // The resulting position is guaranteed to be gridsafe. | ||
| 199 | getSymmetricalPos(x, y) { | ||
| 200 | if (this.symmetry != null) { | ||
| 201 | if (this.pillar === true) { | ||
| 202 | x += this.width/2 | ||
| 203 | if (this.symmetry.x === true) { | ||
| 204 | x = this.width - x | ||
| 205 | } | ||
| 206 | } else { | ||
| 207 | if (this.symmetry.x === true) { | ||
| 208 | x = (this.width - 1) - x | ||
| 209 | } | ||
| 210 | } | ||
| 211 | if (this.symmetry.y === true) { | ||
| 212 | y = (this.height - 1) - y | ||
| 213 | } | ||
| 214 | } | ||
| 215 | return {'x':this._mod(x), 'y':y} | ||
| 216 | } | ||
| 217 | |||
| 218 | getSymmetricalCell(x, y) { | ||
| 219 | var pos = this.getSymmetricalPos(x, y) | ||
| 220 | return this.getCell(pos.x, pos.y) | ||
| 221 | } | ||
| 222 | |||
| 223 | matchesSymmetricalPos(x1, y1, x2, y2) { | ||
| 224 | return (y1 === y2 && this._mod(x1) === x2) | ||
| 225 | } | ||
| 226 | |||
| 227 | // A variant of getCell which specifically returns line values, | ||
| 228 | // and treats objects as being out-of-bounds | ||
| 229 | getLine(x, y) { | ||
| 230 | var cell = this.getCell(x, y) | ||
| 231 | if (cell == null) return null | ||
| 232 | if (cell.type !== 'line') return null | ||
| 233 | return cell.line | ||
| 234 | } | ||
| 235 | |||
| 236 | updateCell2(x, y, key, value) { | ||
| 237 | x = this._mod(x) | ||
| 238 | if (!this._safeCell(x, y)) return | ||
| 239 | var cell = this.grid[x][y] | ||
| 240 | if (cell == null) return | ||
| 241 | cell[key] = value | ||
| 242 | } | ||
| 243 | |||
| 244 | getValidEndDirs(x, y) { | ||
| 245 | x = this._mod(x) | ||
| 246 | if (!this._safeCell(x, y)) return [] | ||
| 247 | |||
| 248 | var dirs = [] | ||
| 249 | var leftCell = this.getCell(x - 1, y) | ||
| 250 | if (leftCell == null || leftCell.gap === window.GAP_FULL) dirs.push('left') | ||
| 251 | var topCell = this.getCell(x, y - 1) | ||
| 252 | if (topCell == null || topCell.gap === window.GAP_FULL) dirs.push('top') | ||
| 253 | var rightCell = this.getCell(x + 1, y) | ||
| 254 | if (rightCell == null || rightCell.gap === window.GAP_FULL) dirs.push('right') | ||
| 255 | var bottomCell = this.getCell(x, y + 1) | ||
| 256 | if (bottomCell == null || bottomCell.gap === window.GAP_FULL) dirs.push('bottom') | ||
| 257 | return dirs | ||
| 258 | } | ||
| 259 | |||
| 260 | // Note: Does not use this.width/this.height, so that it may be used to ask about resizing. | ||
| 261 | getSizeError(width, height) { | ||
| 262 | if (this.pillar && width < 4) return 'Pillars may not have a width of 1' | ||
| 263 | if (width * height < 25) return 'Puzzles may not be smaller than 2x2 or 1x4' | ||
| 264 | if (width > 21 || height > 21) return 'Puzzles may not be larger than 10 in either dimension' | ||
| 265 | if (this.symmetry != null) { | ||
| 266 | if (this.symmetry.x && width <= 2) return 'Symmetrical puzzles must be sufficiently wide for both lines' | ||
| 267 | if (this.symmetry.y && height <= 2) return 'Symmetrical puzzles must be sufficiently wide for both lines' | ||
| 268 | 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' | ||
| 269 | } | ||
| 270 | |||
| 271 | return null | ||
| 272 | } | ||
| 273 | |||
| 274 | |||
| 275 | // Called on a solution. Computes a list of gaps to show as hints which *do not* | ||
| 276 | // break the path. | ||
| 277 | loadHints() { | ||
| 278 | this.hints = [] | ||
| 279 | for (var x=0; x<this.width; x++) { | ||
| 280 | for (var y=0; y<this.height; y++) { | ||
| 281 | if (x%2 + y%2 === 1 && this.getLine(x, y) > window.LINE_NONE) { | ||
| 282 | this.hints.push({'x':x, 'y':y}) | ||
| 283 | } | ||
| 284 | } | ||
| 285 | } | ||
| 286 | } | ||
| 287 | |||
| 288 | // Show a hint on the grid. | ||
| 289 | // If no hint is provided, will select the best one it can find, | ||
| 290 | // prioritizing breaking current lines on the grid. | ||
| 291 | // Returns the shown hint. | ||
| 292 | showHint(hint) { | ||
| 293 | if (hint != null) { | ||
| 294 | this.grid[hint.x][hint.y].gap = window.GAP_BREAK | ||
| 295 | return | ||
| 296 | } | ||
| 297 | |||
| 298 | var goodHints = [] | ||
| 299 | var badHints = [] | ||
| 300 | |||
| 301 | for (var hint of this.hints) { | ||
| 302 | if (this.getLine(hint.x, hint.y) > window.LINE_NONE) { | ||
| 303 | // Solution will be broken by this hint | ||
| 304 | goodHints.push(hint) | ||
| 305 | } else { | ||
| 306 | badHints.push(hint) | ||
| 307 | } | ||
| 308 | } | ||
| 309 | if (goodHints.length > 0) { | ||
| 310 | var hint = goodHints.splice(window.randInt(goodHints.length), 1)[0] | ||
| 311 | } else if (badHints.length > 0) { | ||
| 312 | var hint = badHints.splice(window.randInt(badHints.length), 1)[0] | ||
| 313 | } else { | ||
| 314 | return | ||
| 315 | } | ||
| 316 | this.grid[hint.x][hint.y].gap = window.GAP_BREAK | ||
| 317 | this.hints = badHints.concat(goodHints) | ||
| 318 | return hint | ||
| 319 | } | ||
| 320 | |||
| 321 | clearLines() { | ||
| 322 | for (var x=0; x<this.width; x++) { | ||
| 323 | for (var y=0; y<this.height; y++) { | ||
| 324 | this.updateCell2(x, y, 'line', 0) | ||
| 325 | this.updateCell2(x, y, 'dir', null) | ||
| 326 | } | ||
| 327 | } | ||
| 328 | } | ||
| 329 | |||
| 330 | _floodFill(x, y, region, col) { | ||
| 331 | var cell = col[y] | ||
| 332 | if (cell === MASKED_PROCESSED) return | ||
| 333 | if (cell !== MASKED_INB_NONCOUNT) { | ||
| 334 | region.push({'x':x, 'y':y}) | ||
| 335 | } | ||
| 336 | col[y] = MASKED_PROCESSED | ||
| 337 | |||
| 338 | if (y < this.height - 1) this._floodFill(x, y + 1, region, col) | ||
| 339 | if (y > 0) this._floodFill(x, y - 1, region, col) | ||
| 340 | if (x < this.width - 1) this._floodFill(x + 1, y, region, this.grid[x+1]) | ||
| 341 | else if (this.pillar !== false) this._floodFill(0, y, region, this.grid[0]) | ||
| 342 | if (x > 0) this._floodFill(x - 1, y, region, this.grid[x-1]) | ||
| 343 | else if (this.pillar !== false) this._floodFill(this.width-1, y, region, this.grid[this.width-1]) | ||
| 344 | } | ||
| 345 | |||
| 346 | // Re-uses the same grid, but only called on edges which border the outside | ||
| 347 | // Called first to mark cells that are connected to the outside, i.e. should not be part of any region. | ||
| 348 | _floodFillOutside(x, y, col) { | ||
| 349 | var cell = col[y] | ||
| 350 | if (cell === MASKED_PROCESSED) return | ||
| 351 | if (x%2 !== y%2 && cell !== MASKED_GAP2) return // Only flood-fill through gap-2 | ||
| 352 | if (x%2 === 0 && y%2 === 0 && cell === MASKED_DOT) return // Don't flood-fill through dots | ||
| 353 | col[y] = MASKED_PROCESSED | ||
| 354 | |||
| 355 | if (x%2 === 0 && y%2 === 0) return // Don't flood fill through corners (what? Clarify.) | ||
| 356 | |||
| 357 | if (y < this.height - 1) this._floodFillOutside(x, y + 1, col) | ||
| 358 | if (y > 0) this._floodFillOutside(x, y - 1, col) | ||
| 359 | if (x < this.width - 1) this._floodFillOutside(x + 1, y, this.grid[x+1]) | ||
| 360 | else if (this.pillar !== false) this._floodFillOutside(0, y, this.grid[0]) | ||
| 361 | if (x > 0) this._floodFillOutside(x - 1, y, this.grid[x-1]) | ||
| 362 | else if (this.pillar !== false) this._floodFillOutside(this.width-1, y, this.grid[this.width-1]) | ||
| 363 | } | ||
| 364 | |||
| 365 | // Returns the original grid (pre-masking). You will need to switch back once you are done flood filling. | ||
| 366 | switchToMaskedGrid() { | ||
| 367 | // Make a copy of the grid -- we will be overwriting it | ||
| 368 | var savedGrid = this.grid | ||
| 369 | this.grid = new Array(this.width) | ||
| 370 | // Override all elements with empty lines -- this means that flood fill is just | ||
| 371 | // looking for lines with line=0. | ||
| 372 | for (var x=0; x<this.width; x++) { | ||
| 373 | var savedRow = savedGrid[x] | ||
| 374 | var row = new Array(this.height) | ||
| 375 | var skip = 1 | ||
| 376 | if (x%2 === 1) { // Cells are always part of the region | ||
| 377 | for (var y=1; y<this.height; y+=2) row[y] = MASKED_INB_COUNT | ||
| 378 | skip = 2 // Skip these cells during iteration | ||
| 379 | } | ||
| 380 | for (var y=0; y<this.height; y+=skip) { | ||
| 381 | var cell = savedRow[y] | ||
| 382 | if (cell.line > window.LINE_NONE) { | ||
| 383 | row[y] = MASKED_PROCESSED // Traced lines should not be a part of the region | ||
| 384 | } else if (cell.gap === window.GAP_FULL) { | ||
| 385 | row[y] = MASKED_GAP2 | ||
| 386 | } else if (cell.dot > window.DOT_NONE) { | ||
| 387 | row[y] = MASKED_DOT | ||
| 388 | } else { | ||
| 389 | row[y] = MASKED_INB_COUNT | ||
| 390 | } | ||
| 391 | } | ||
| 392 | this.grid[x] = row | ||
| 393 | } | ||
| 394 | |||
| 395 | // Starting at a mid-segment startpoint | ||
| 396 | if (this.startPoint != null && this.startPoint.x%2 !== this.startPoint.y%2) { | ||
| 397 | if (this.settings.FAT_STARTPOINTS) { | ||
| 398 | // This segment is not in any region (acts as a barrier) | ||
| 399 | this.grid[this.startPoint.x][this.startPoint.y] = MASKED_OOB | ||
| 400 | } else { | ||
| 401 | // This segment is part of this region (acts as an empty cell) | ||
| 402 | this.grid[this.startPoint.x][this.startPoint.y] = MASKED_INB_NONCOUNT | ||
| 403 | } | ||
| 404 | } | ||
| 405 | |||
| 406 | // Ending at a mid-segment endpoint | ||
| 407 | if (this.endPoint != null && this.endPoint.x%2 !== this.endPoint.y%2) { | ||
| 408 | // This segment is part of this region (acts as an empty cell) | ||
| 409 | this.grid[this.endPoint.x][this.endPoint.y] = MASKED_INB_NONCOUNT | ||
| 410 | } | ||
| 411 | |||
| 412 | // Mark all outside cells as 'not in any region' (aka null) | ||
| 413 | |||
| 414 | // Top and bottom edges | ||
| 415 | for (var x=1; x<this.width; x+=2) { | ||
| 416 | this._floodFillOutside(x, 0, this.grid[x]) | ||
| 417 | this._floodFillOutside(x, this.height - 1, this.grid[x]) | ||
| 418 | } | ||
| 419 | |||
| 420 | // Left and right edges (only applies to non-pillars) | ||
| 421 | if (this.pillar === false) { | ||
| 422 | for (var y=1; y<this.height; y+=2) { | ||
| 423 | this._floodFillOutside(0, y, this.grid[0]) | ||
| 424 | this._floodFillOutside(this.width - 1, y, this.grid[this.width-1]) | ||
| 425 | } | ||
| 426 | } | ||
| 427 | |||
| 428 | return savedGrid | ||
| 429 | } | ||
| 430 | |||
| 431 | getRegions() { | ||
| 432 | var regions = [] | ||
| 433 | var savedGrid = this.switchToMaskedGrid() | ||
| 434 | |||
| 435 | for (var x=0; x<this.width; x++) { | ||
| 436 | for (var y=0; y<this.height; y++) { | ||
| 437 | if (this.grid[x][y] == MASKED_PROCESSED) continue | ||
| 438 | |||
| 439 | // If this cell is empty (aka hasn't already been used by a region), then create a new one | ||
| 440 | // This will also mark all lines inside the new region as used. | ||
| 441 | var region = [] | ||
| 442 | this._floodFill(x, y, region, this.grid[x]) | ||
| 443 | regions.push(region) | ||
| 444 | } | ||
| 445 | } | ||
| 446 | this.grid = savedGrid | ||
| 447 | return regions | ||
| 448 | } | ||
| 449 | |||
| 450 | getRegion(x, y) { | ||
| 451 | x = this._mod(x) | ||
| 452 | if (!this._safeCell(x, y)) return | ||
| 453 | |||
| 454 | var savedGrid = this.switchToMaskedGrid() | ||
| 455 | if (this.grid[x][y] == MASKED_PROCESSED) { | ||
| 456 | this.grid = savedGrid | ||
| 457 | return null | ||
| 458 | } | ||
| 459 | |||
| 460 | // If the masked grid hasn't been used at this point, then create a new region. | ||
| 461 | // This will also mark all lines inside the new region as used. | ||
| 462 | var region = [] | ||
| 463 | this._floodFill(x, y, region, this.grid[x]) | ||
| 464 | |||
| 465 | this.grid = savedGrid | ||
| 466 | return region | ||
| 467 | } | ||
| 468 | |||
| 469 | logGrid() { | ||
| 470 | var output = '' | ||
| 471 | for (var y=0; y<this.height; y++) { | ||
| 472 | var row = [] | ||
| 473 | for (var x=0; x<this.width; x++) { | ||
| 474 | var cell = this.getCell(x, y) | ||
| 475 | if (cell == null) row[x] = ' ' | ||
| 476 | else if (cell.type === 'nonce') row[x] = ' ' | ||
| 477 | else if (cell.start === true) row[x] = 'S' | ||
| 478 | else if (cell.end != null) row[x] = 'E' | ||
| 479 | else if (cell.type === 'line') { | ||
| 480 | if (cell.gap > 0) row[x] = ' ' | ||
| 481 | if (cell.dot > 0) row[x] = 'X' | ||
| 482 | if (cell.line === 0) row[x] = '.' | ||
| 483 | if (cell.line === 1) row[x] = '#' | ||
| 484 | if (cell.line === 2) row[x] = '#' | ||
| 485 | if (cell.line === 3) row[x] = 'o' | ||
| 486 | } else row[x] = '?' | ||
| 487 | } | ||
| 488 | output += row.join('') + '\n' | ||
| 489 | } | ||
| 490 | console.info(output) | ||
| 491 | } | ||
| 492 | } | ||
| 493 | |||
| 494 | // The grid contains 5 colors: | ||
| 495 | // null: Out of bounds or already processed | ||
| 496 | var MASKED_OOB = null | ||
| 497 | var MASKED_PROCESSED = null | ||
| 498 | // 0: In bounds, awaiting processing, but should not be part of the final region. | ||
| 499 | var MASKED_INB_NONCOUNT = 0 | ||
| 500 | // 1: In bounds, awaiting processing | ||
| 501 | var MASKED_INB_COUNT = 1 | ||
| 502 | // 2: Gap-2. After _floodFillOutside, this means "treat normally" (it will be null if oob) | ||
| 503 | var MASKED_GAP2 = 2 | ||
| 504 | // 3: Dot (of any kind), otherwise identical to 1. Should not be flood-filled through (why the f do we need this) | ||
| 505 | var MASKED_DOT = 3 | ||
| 506 | |||
| 507 | }) | ||
| diff --git a/app/assets/javascripts/wittle/serializer.js b/app/assets/javascripts/wittle/serializer.js new file mode 100644 index 0000000..e440569 --- /dev/null +++ b/app/assets/javascripts/wittle/serializer.js | |||
| @@ -0,0 +1,346 @@ | |||
| 1 | namespace(function() { | ||
| 2 | |||
| 3 | window.serializePuzzle = function(puzzle) { | ||
| 4 | var s = new Serializer('w') | ||
| 5 | var version = 0 | ||
| 6 | |||
| 7 | s.writeInt(version) | ||
| 8 | s.writeByte(puzzle.width) | ||
| 9 | s.writeByte(puzzle.height) | ||
| 10 | s.writeString(puzzle.name) | ||
| 11 | |||
| 12 | var genericFlags = 0 | ||
| 13 | if (puzzle.autoSolved) genericFlags |= GENERIC_FLAG_AUTOSOLVED | ||
| 14 | if (puzzle.symmetry) { | ||
| 15 | genericFlags |= GENERIC_FLAG_SYMMETRICAL | ||
| 16 | if (puzzle.symmetry.x) genericFlags |= GENERIC_FLAG_SYMMETRY_X | ||
| 17 | if (puzzle.symmetry.y) genericFlags |= GENERIC_FLAG_SYMMETRY_Y | ||
| 18 | } | ||
| 19 | if (puzzle.pillar) genericFlags |= GENERIC_FLAG_PILLAR | ||
| 20 | s.writeByte(genericFlags) | ||
| 21 | for (var x=0; x<puzzle.width; x++) { | ||
| 22 | for (var y=0; y<puzzle.height; y++) { | ||
| 23 | s.writeCell(puzzle.grid[x][y]) | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | if (puzzle.path != null) { | ||
| 28 | var startPos = puzzle.path.pop() | ||
| 29 | if (puzzle.path.length > 0) { | ||
| 30 | s.writeInt(puzzle.path.length) | ||
| 31 | s.writeByte(startPos.x) | ||
| 32 | s.writeByte(startPos.y) | ||
| 33 | for (var dir of puzzle.path) s.writeByte(dir) | ||
| 34 | } | ||
| 35 | } else { | ||
| 36 | s.writeInt(0) | ||
| 37 | } | ||
| 38 | |||
| 39 | var settingsFlags = 0 | ||
| 40 | if (puzzle.settings.NEGATIONS_CANCEL_NEGATIONS) settingsFlags |= SETTINGS_FLAG_NCN | ||
| 41 | if (puzzle.settings.SHAPELESS_ZERO_POLY) settingsFlags |= SETTINGS_FLAG_SZP | ||
| 42 | if (puzzle.settings.PRECISE_POLYOMINOS) settingsFlags |= SETTINGS_FLAG_PP | ||
| 43 | if (puzzle.settings.FLASH_FOR_ERRORS) settingsFlags |= SETTINGS_FLAG_FFE | ||
| 44 | if (puzzle.settings.FAT_STARTPOINTS) settingsFlags |= SETTINGS_FLAG_FS | ||
| 45 | if (puzzle.settings.CUSTOM_MECHANICS) settingsFlags |= SETTINGS_FLAG_CM | ||
| 46 | s.writeByte(settingsFlags) | ||
| 47 | |||
| 48 | return s.str() | ||
| 49 | } | ||
| 50 | |||
| 51 | window.deserializePuzzle = function(data) { | ||
| 52 | // Data is JSON, so decode it with the old deserializer | ||
| 53 | if (data[0] == '{') return Puzzle.deserialize(data) | ||
| 54 | |||
| 55 | var s = new Serializer('r', data) | ||
| 56 | var version = s.readInt() | ||
| 57 | if (version > 0) throw Error('Cannot read data from unknown version: ' + version) | ||
| 58 | |||
| 59 | var width = s.readByte() | ||
| 60 | var height = s.readByte() | ||
| 61 | var puzzle = new Puzzle(Math.floor(width / 2), Math.floor(height / 2)) | ||
| 62 | puzzle.name = s.readString() | ||
| 63 | |||
| 64 | var genericFlags = s.readByte() | ||
| 65 | puzzle.autoSolved = genericFlags & GENERIC_FLAG_AUTOSOLVED | ||
| 66 | if ((genericFlags & GENERIC_FLAG_SYMMETRICAL) != 0) { | ||
| 67 | puzzle.symmetry = { | ||
| 68 | 'x': ((genericFlags & GENERIC_FLAG_SYMMETRY_X) != 0), | ||
| 69 | 'y': ((genericFlags & GENERIC_FLAG_SYMMETRY_Y) != 0), | ||
| 70 | } | ||
| 71 | } | ||
| 72 | puzzle.pillar = (genericFlags & GENERIC_FLAG_PILLAR) != 0 | ||
| 73 | for (var x=0; x<width; x++) { | ||
| 74 | for (var y=0; y<height; y++) { | ||
| 75 | puzzle.grid[x][y] = s.readCell() | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | var pathLength = s.readInt() | ||
| 80 | if (pathLength > 0) { | ||
| 81 | var path = [{ | ||
| 82 | 'x': s.readByte(), | ||
| 83 | 'y': s.readByte(), | ||
| 84 | }] | ||
| 85 | for (var i=0; i<pathLength; i++) path.push(s.readByte()) | ||
| 86 | } | ||
| 87 | |||
| 88 | var settingsFlags = s.readByte() | ||
| 89 | puzzle.settings = { | ||
| 90 | NEGATIONS_CANCEL_NEGATIONS: (settingsFlags & SETTINGS_FLAG_NCN) != 0, | ||
| 91 | SHAPELESS_ZERO_POLY: (settingsFlags & SETTINGS_FLAG_SZP) != 0, | ||
| 92 | PRECISE_POLYOMINOS: (settingsFlags & SETTINGS_FLAG_PP) != 0, | ||
| 93 | FLASH_FOR_ERRORS: (settingsFlags & SETTINGS_FLAG_FFE) != 0, | ||
| 94 | FAT_STARTPOINTS: (settingsFlags & SETTINGS_FLAG_FS) != 0, | ||
| 95 | CUSTOM_MECHANICS: (settingsFlags & SETTINGS_FLAG_CM) != 0, | ||
| 96 | } | ||
| 97 | |||
| 98 | s.destroy() | ||
| 99 | return puzzle | ||
| 100 | } | ||
| 101 | |||
| 102 | class Serializer { | ||
| 103 | constructor(mode, data) { | ||
| 104 | this.mode = mode | ||
| 105 | if (mode == 'r') { | ||
| 106 | if (data == null) throw Error('No data provided to a read constructor') | ||
| 107 | if (data[0] != '_') throw Error('Cannot read data, improperly prefixed') | ||
| 108 | this.data = window.atob(data.slice(1)) | ||
| 109 | this.index = 0 | ||
| 110 | } else if (mode == 'w') { | ||
| 111 | this.data = [] | ||
| 112 | |||
| 113 | var canvas = document.createElement('canvas') | ||
| 114 | canvas.height = 1 | ||
| 115 | canvas.width = 1 | ||
| 116 | this.colorConverter = canvas.getContext('2d') | ||
| 117 | } else { | ||
| 118 | throw Error('Unknown serializer mode: ' + mode) | ||
| 119 | } | ||
| 120 | |||
| 121 | } | ||
| 122 | |||
| 123 | destroy() { | ||
| 124 | if (this.mode == 'r' && this.index < this.data.length) { | ||
| 125 | throw Error('Read not done, ' + (this.data.length - this.index) + ' bytes remain') | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | str() { | ||
| 130 | if (this.mode != 'w') throw Error('Cannot get string from a serializer opened in mode: ' + this.mode) | ||
| 131 | return '_' + window.btoa(this.data) | ||
| 132 | } | ||
| 133 | |||
| 134 | _checkRead(numBytes = 1) { | ||
| 135 | if (this.mode != 'r') throw Error('Cannot read data from a serializer opened in mode: ' + this.mode) | ||
| 136 | if (this.data.length < numBytes) throw Error('Cannot read ' + numBytes + ' bytes from a stream with only '+ this.data.length + ' bytes') | ||
| 137 | } | ||
| 138 | |||
| 139 | readByte() { | ||
| 140 | this._checkRead() | ||
| 141 | return this.data.charCodeAt(this.index++) | ||
| 142 | } | ||
| 143 | |||
| 144 | writeByte(b) { | ||
| 145 | if (b < 0 || b > 0xFF) throw Error('Cannot write out-of-range byte ' + b) | ||
| 146 | this.data += String.fromCharCode(b) | ||
| 147 | } | ||
| 148 | |||
| 149 | readInt() { | ||
| 150 | var b1 = this.readByte() << 0 | ||
| 151 | var b2 = this.readByte() << 8 | ||
| 152 | var b3 = this.readByte() << 16 | ||
| 153 | var b4 = this.readByte() << 24 | ||
| 154 | return b1 | b2 | b3 | b4 | ||
| 155 | } | ||
| 156 | |||
| 157 | writeInt(i) { | ||
| 158 | if (i < 0 || i > 0xFFFFFFFF) throw Error('Cannot write out-of-range int ' + i) | ||
| 159 | var b1 = (i & 0x000000FF) >> 0 | ||
| 160 | var b2 = (i & 0x0000FF00) >> 8 | ||
| 161 | var b3 = (i & 0x00FF0000) >> 16 | ||
| 162 | var b4 = (i & 0xFF000000) >> 24 | ||
| 163 | this.writeByte(b1) | ||
| 164 | this.writeByte(b2) | ||
| 165 | this.writeByte(b3) | ||
| 166 | this.writeByte(b4) | ||
| 167 | } | ||
| 168 | |||
| 169 | readLong() { | ||
| 170 | var i1 = this.readInt() << 32 | ||
| 171 | var i2 = this.readInt() | ||
| 172 | return i1 | i2 | ||
| 173 | } | ||
| 174 | |||
| 175 | writeLong(l) { | ||
| 176 | if (l < 0 || l > 0xFFFFFFFFFFFFFFFF) throw Error('Cannot write out-of-range long ' + l) | ||
| 177 | var i1 = l & 0xFFFFFFFF | ||
| 178 | var i2 = (l - i1) / 0x100000000 | ||
| 179 | this.writeInt(i1) | ||
| 180 | this.writeInt(i2) | ||
| 181 | } | ||
| 182 | |||
| 183 | readString() { | ||
| 184 | var len = this.readInt() | ||
| 185 | this._checkRead(len) | ||
| 186 | var str = this.data.substr(this.index, len) | ||
| 187 | this.index += len | ||
| 188 | return str | ||
| 189 | } | ||
| 190 | |||
| 191 | writeString(s) { | ||
| 192 | if (s == null) { | ||
| 193 | this.writeInt(0) | ||
| 194 | return | ||
| 195 | } | ||
| 196 | this.writeInt(s.length) | ||
| 197 | this.data += s | ||
| 198 | } | ||
| 199 | |||
| 200 | readColor() { | ||
| 201 | var r = this.readByte().toString() | ||
| 202 | var g = this.readByte().toString() | ||
| 203 | var b = this.readByte().toString() | ||
| 204 | var a = this.readByte().toString() | ||
| 205 | return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + a + ')' | ||
| 206 | } | ||
| 207 | |||
| 208 | writeColor(c) { | ||
| 209 | // Adapted from https://gist.github.com/njvack/02ad8efcb0d552b0230d | ||
| 210 | this.colorConverter.fillStyle = 'rgba(0, 0, 0, 0)' // Load a default in case we are passed garbage | ||
| 211 | this.colorConverter.clearRect(0, 0, 1, 1) | ||
| 212 | this.colorConverter.fillStyle = c | ||
| 213 | this.colorConverter.fillRect(0, 0, 1, 1) | ||
| 214 | var rgba = this.colorConverter.getImageData(0, 0, 1, 1).data | ||
| 215 | this.writeByte(rgba[0]) | ||
| 216 | this.writeByte(rgba[1]) | ||
| 217 | this.writeByte(rgba[2]) | ||
| 218 | this.writeByte(rgba[3]) | ||
| 219 | } | ||
| 220 | |||
| 221 | readCell() { | ||
| 222 | var cellType = this.readByte() | ||
| 223 | if (cellType === CELL_TYPE_NULL) return null | ||
| 224 | |||
| 225 | var cell = {} | ||
| 226 | cell.dir = null | ||
| 227 | cell.line = 0 | ||
| 228 | if (cellType === CELL_TYPE_LINE) { | ||
| 229 | cell.type = 'line' | ||
| 230 | cell.line = this.readByte() | ||
| 231 | var dot = this.readByte() | ||
| 232 | if (dot != 0) cell.dot = dot | ||
| 233 | var gap = this.readByte() | ||
| 234 | if (gap != 0) cell.gap = gap | ||
| 235 | } else if (cellType === CELL_TYPE_SQUARE) { | ||
| 236 | cell.type = 'square' | ||
| 237 | cell.color = this.readColor() | ||
| 238 | } else if (cellType === CELL_TYPE_STAR) { | ||
| 239 | cell.type = 'star' | ||
| 240 | cell.color = this.readColor() | ||
| 241 | } else if (cellType === CELL_TYPE_NEGA) { | ||
| 242 | cell.type = 'nega' | ||
| 243 | cell.color = this.readColor() | ||
| 244 | } else if (cellType === CELL_TYPE_TRIANGLE) { | ||
| 245 | cell.type = 'triangle' | ||
| 246 | cell.color = this.readColor() | ||
| 247 | cell.count = this.readByte() | ||
| 248 | } else if (cellType === CELL_TYPE_POLY) { | ||
| 249 | cell.type = 'poly' | ||
| 250 | cell.color = this.readColor() | ||
| 251 | cell.polyshape = this.readLong() | ||
| 252 | } else if (cellType === CELL_TYPE_YLOP) { | ||
| 253 | cell.type = 'ylop' | ||
| 254 | cell.color = this.readColor() | ||
| 255 | cell.polyshape = this.readLong() | ||
| 256 | } else if (cellType == CELL_TYPE_NONCE) { | ||
| 257 | cell.type = 'nonce' | ||
| 258 | } | ||
| 259 | |||
| 260 | var startEnd = this.readByte() | ||
| 261 | if (startEnd & CELL_START) cell.start = true | ||
| 262 | if (startEnd & CELL_END_LEFT) cell.end = 'left' | ||
| 263 | if (startEnd & CELL_END_RIGHT) cell.end = 'right' | ||
| 264 | if (startEnd & CELL_END_TOP) cell.end = 'top' | ||
| 265 | if (startEnd & CELL_END_BOTTOM) cell.end = 'bottom' | ||
| 266 | |||
| 267 | return cell | ||
| 268 | } | ||
| 269 | |||
| 270 | |||
| 271 | writeCell(cell) { | ||
| 272 | if (cell == null) { | ||
| 273 | this.writeByte(CELL_TYPE_NULL) | ||
| 274 | return | ||
| 275 | } | ||
| 276 | |||
| 277 | // Write cell type, then cell data, then generic data. | ||
| 278 | // Note that cell type starts at 1, since 0 is the "null type". | ||
| 279 | if (cell.type == 'line') { | ||
| 280 | this.writeByte(CELL_TYPE_LINE) | ||
| 281 | this.writeByte(cell.line) | ||
| 282 | this.writeByte(cell.dot) | ||
| 283 | this.writeByte(cell.gap) | ||
| 284 | } else if (cell.type == 'square') { | ||
| 285 | this.writeByte(CELL_TYPE_SQUARE) | ||
| 286 | this.writeColor(cell.color) | ||
| 287 | } else if (cell.type == 'star') { | ||
| 288 | this.writeByte(CELL_TYPE_STAR) | ||
| 289 | this.writeColor(cell.color) | ||
| 290 | } else if (cell.type == 'nega') { | ||
| 291 | this.writeByte(CELL_TYPE_NEGA) | ||
| 292 | this.writeColor(cell.color) | ||
| 293 | } else if (cell.type == 'triangle') { | ||
| 294 | this.writeByte(CELL_TYPE_TRIANGLE) | ||
| 295 | this.writeColor(cell.color) | ||
| 296 | this.writeByte(cell.count) | ||
| 297 | } else if (cell.type == 'poly') { | ||
| 298 | this.writeByte(CELL_TYPE_POLY) | ||
| 299 | this.writeColor(cell.color) | ||
| 300 | this.writeLong(cell.polyshape) | ||
| 301 | } else if (cell.type == 'ylop') { | ||
| 302 | this.writeByte(CELL_TYPE_YLOP) | ||
| 303 | this.writeColor(cell.color) | ||
| 304 | this.writeLong(cell.polyshape) | ||
| 305 | } | ||
| 306 | |||
| 307 | var startEnd = 0 | ||
| 308 | if (cell.start === true) startEnd |= CELL_START | ||
| 309 | if (cell.end == 'left') startEnd |= CELL_END_LEFT | ||
| 310 | if (cell.end == 'right') startEnd |= CELL_END_RIGHT | ||
| 311 | if (cell.end == 'top') startEnd |= CELL_END_TOP | ||
| 312 | if (cell.end == 'bottom') startEnd |= CELL_END_BOTTOM | ||
| 313 | this.writeByte(startEnd) | ||
| 314 | } | ||
| 315 | } | ||
| 316 | |||
| 317 | var CELL_TYPE_NULL = 0 | ||
| 318 | var CELL_TYPE_LINE = 1 | ||
| 319 | var CELL_TYPE_SQUARE = 2 | ||
| 320 | var CELL_TYPE_STAR = 3 | ||
| 321 | var CELL_TYPE_NEGA = 4 | ||
| 322 | var CELL_TYPE_TRIANGLE = 5 | ||
| 323 | var CELL_TYPE_POLY = 6 | ||
| 324 | var CELL_TYPE_YLOP = 7 | ||
| 325 | var CELL_TYPE_NONCE = 8 | ||
| 326 | |||
| 327 | var CELL_START = 1 | ||
| 328 | var CELL_END_LEFT = 2 | ||
| 329 | var CELL_END_RIGHT = 4 | ||
| 330 | var CELL_END_TOP = 8 | ||
| 331 | var CELL_END_BOTTOM = 16 | ||
| 332 | |||
| 333 | var GENERIC_FLAG_AUTOSOLVED = 1 | ||
| 334 | var GENERIC_FLAG_SYMMETRICAL = 2 | ||
| 335 | var GENERIC_FLAG_SYMMETRY_X = 4 | ||
| 336 | var GENERIC_FLAG_SYMMETRY_Y = 8 | ||
| 337 | var GENERIC_FLAG_PILLAR = 16 | ||
| 338 | |||
| 339 | var SETTINGS_FLAG_NCN = 1 | ||
| 340 | var SETTINGS_FLAG_SZP = 2 | ||
| 341 | var SETTINGS_FLAG_PP = 4 | ||
| 342 | var SETTINGS_FLAG_FFE = 8 | ||
| 343 | var SETTINGS_FLAG_FS = 16 | ||
| 344 | var SETTINGS_FLAG_CM = 32 | ||
| 345 | |||
| 346 | }) | ||
| diff --git a/app/assets/javascripts/wittle/solve.js b/app/assets/javascripts/wittle/solve.js new file mode 100644 index 0000000..13b9650 --- /dev/null +++ b/app/assets/javascripts/wittle/solve.js | |||
| @@ -0,0 +1,531 @@ | |||
| 1 | namespace(function() { | ||
| 2 | |||
| 3 | // @Volatile -- must match order of MOVE_* in trace2 | ||
| 4 | // Move these, dummy. | ||
| 5 | var PATH_NONE = 0 | ||
| 6 | var PATH_LEFT = 1 | ||
| 7 | var PATH_RIGHT = 2 | ||
| 8 | var PATH_TOP = 3 | ||
| 9 | var PATH_BOTTOM = 4 | ||
| 10 | |||
| 11 | window.MAX_SOLUTIONS = 0 | ||
| 12 | var solutionPaths = [] | ||
| 13 | var asyncTimer = 0 | ||
| 14 | var task = null | ||
| 15 | var puzzle = null | ||
| 16 | var path = [] | ||
| 17 | var SOLVE_SYNC = false | ||
| 18 | var SYNC_THRESHOLD = 9 // Depth at which we switch to a synchronous solver (for perf) | ||
| 19 | var doPruning = false | ||
| 20 | |||
| 21 | var percentages = [] | ||
| 22 | var NODE_DEPTH = 9 | ||
| 23 | var nodes = 0 | ||
| 24 | function countNodes(x, y, depth) { | ||
| 25 | // Check for collisions (outside, gap, self, other) | ||
| 26 | var cell = puzzle.getCell(x, y) | ||
| 27 | if (cell == null) return | ||
| 28 | if (cell.gap > window.GAP_NONE) return | ||
| 29 | if (cell.line !== window.LINE_NONE) return | ||
| 30 | |||
| 31 | if (puzzle.symmetry == null) { | ||
| 32 | puzzle.updateCell2(x, y, 'line', window.LINE_BLACK) | ||
| 33 | } else { | ||
| 34 | var sym = puzzle.getSymmetricalPos(x, y) | ||
| 35 | if (puzzle.matchesSymmetricalPos(x, y, sym.x, sym.y)) return // Would collide with our reflection | ||
| 36 | |||
| 37 | var symCell = puzzle.getCell(sym.x, sym.y) | ||
| 38 | if (symCell.gap > window.GAP_NONE) return | ||
| 39 | |||
| 40 | puzzle.updateCell2(x, y, 'line', window.LINE_BLUE) | ||
| 41 | puzzle.updateCell2(sym.x, sym.y, 'line', window.LINE_YELLOW) | ||
| 42 | } | ||
| 43 | |||
| 44 | if (depth < NODE_DEPTH) { | ||
| 45 | nodes++ | ||
| 46 | |||
| 47 | if (y%2 === 0) { | ||
| 48 | countNodes(x - 1, y, depth + 1) | ||
| 49 | countNodes(x + 1, y, depth + 1) | ||
| 50 | } | ||
| 51 | |||
| 52 | if (x%2 === 0) { | ||
| 53 | countNodes(x, y - 1, depth + 1) | ||
| 54 | countNodes(x, y + 1, depth + 1) | ||
| 55 | } | ||
| 56 | } | ||
| 57 | |||
| 58 | tailRecurse(x, y) | ||
| 59 | } | ||
| 60 | |||
| 61 | // Generates a solution via DFS recursive backtracking | ||
| 62 | window.solve = function(p, partialCallback, finalCallback) { | ||
| 63 | if (task != null) throw Error('Cannot start another solve() while one is already in progress') | ||
| 64 | var start = (new Date()).getTime() | ||
| 65 | |||
| 66 | puzzle = p | ||
| 67 | var startPoints = [] | ||
| 68 | var numEndpoints = 0 | ||
| 69 | puzzle.hasNegations = false | ||
| 70 | puzzle.hasPolyominos = false | ||
| 71 | for (var x=0; x<puzzle.width; x++) { | ||
| 72 | for (var y=0; y<puzzle.height; y++) { | ||
| 73 | var cell = puzzle.grid[x][y] | ||
| 74 | if (cell == null) continue | ||
| 75 | if (cell.start === true) { | ||
| 76 | startPoints.push({'x': x, 'y': y}) | ||
| 77 | } | ||
| 78 | if (cell.end != null) numEndpoints++ | ||
| 79 | if (cell.type == 'nega') puzzle.hasNegations = true | ||
| 80 | if (cell.type == 'poly' || cell.type == 'ylop') puzzle.hasPolyominos = true | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | // Puzzles which are small enough should be solved synchronously, since the cost of asynchronizing | ||
| 85 | // is greater than the cost of the puzzle. | ||
| 86 | SOLVE_SYNC = false | ||
| 87 | if (puzzle.symmetry != null) { // 5x5 is the max for symmetry puzzles | ||
| 88 | if (puzzle.width * puzzle.height <= 121) SOLVE_SYNC = true | ||
| 89 | } else if (puzzle.pillar === true) { // 4x5 is the max for non-symmetry, pillar puzzles | ||
| 90 | if (puzzle.width * puzzle.height <= 108) SOLVE_SYNC = true | ||
| 91 | } else { // 5x5 is the max for non-symmetry, non-pillar puzzles | ||
| 92 | if (puzzle.width * puzzle.height <= 121) SOLVE_SYNC = true | ||
| 93 | } | ||
| 94 | console.log('Puzzle is a', puzzle.width, 'by', puzzle.height, 'solving ' + (SOLVE_SYNC ? 'sync' : 'async')) | ||
| 95 | |||
| 96 | // We pre-traverse the grid (only considering obvious failure states like going out of bounds), | ||
| 97 | // and compute a total number of nodes that are reachable within some NODE_DEPTH steps. | ||
| 98 | // Then, during actual traversal, we can compare the number of nodes reached to the precomputed count | ||
| 99 | // to get a (fairly accurate) progress bar. | ||
| 100 | for (var pos of startPoints) { | ||
| 101 | countNodes(pos.x, pos.y, 0) | ||
| 102 | } | ||
| 103 | console.log('Pretraversal found', nodes, 'nodes') | ||
| 104 | percentages = [] | ||
| 105 | for (var i=0; i<100; i++) { | ||
| 106 | percentages.push(Math.floor(i * nodes / 100)) | ||
| 107 | } | ||
| 108 | nodes = 0 | ||
| 109 | |||
| 110 | solutionPaths = [] | ||
| 111 | // Some reasonable default data, which will avoid crashes during the solveLoop. | ||
| 112 | var earlyExitData = [false, {'isEdge': false}, {'isEdge': false}] | ||
| 113 | if (window.MAX_SOLUTIONS === 0) window.MAX_SOLUTIONS = 10000 | ||
| 114 | |||
| 115 | // Large pruning optimization -- Attempt to early exit once we cut out a region. | ||
| 116 | // Inspired by https://github.com/Overv/TheWitnessSolver | ||
| 117 | // For non-pillar puzzles, every time we draw a line from one edge to another, we cut out two regions. | ||
| 118 | // We can detect this by asking if we've ever left an edge, and determining if we've just touched an edge. | ||
| 119 | // However, just touching the edge isn't sufficient, since we could still enter either region. | ||
| 120 | // As such, we wait one additional step, to see which half we have moved in to, then we evaluate | ||
| 121 | // whichever half you moved away from (since you can no longer re-enter it). | ||
| 122 | // | ||
| 123 | // Consider this pathway (tracing X-X-X-A-B-C). | ||
| 124 | // ....X.... | ||
| 125 | // . . X . . | ||
| 126 | // ....X.... | ||
| 127 | // . . A . . | ||
| 128 | // ...CB.... | ||
| 129 | // | ||
| 130 | // Note that, once we have reached B, the puzzle is divided in half. However, we could go either | ||
| 131 | // left or right -- so we don't know which region is safe to validate. | ||
| 132 | // Once we reach C, however, the region to the right is closed off. | ||
| 133 | // As such, we can start a flood fill from the cell to the right of A, computed by A+(C-B). | ||
| 134 | // | ||
| 135 | // Unfortunately, this optimization doesn't work for pillars, since the two regions are still connected. | ||
| 136 | // Additionally, this optimization doesn't work when custom mechanics are active, as many custom mechanics | ||
| 137 | // depend on the path through the entire puzzle | ||
| 138 | doPruning = (puzzle.pillar === false && !puzzle.settings.CUSTOM_MECHANICS) | ||
| 139 | |||
| 140 | task = { | ||
| 141 | 'code': function() { | ||
| 142 | var newTasks = [] | ||
| 143 | |||
| 144 | for (var pos of startPoints) { | ||
| 145 | // ;(function(a){}(a)) | ||
| 146 | // This syntax is used to forcibly copy arguments which are otherwise part of the loop. | ||
| 147 | // Note that we don't need to copy objects, just value types. | ||
| 148 | ;(function(pos) { | ||
| 149 | newTasks.push(function() { | ||
| 150 | path = [pos] | ||
| 151 | puzzle.startPoint = pos | ||
| 152 | return solveLoop(pos.x, pos.y, numEndpoints, earlyExitData) | ||
| 153 | }) | ||
| 154 | }(pos)) | ||
| 155 | } | ||
| 156 | return newTasks | ||
| 157 | } | ||
| 158 | } | ||
| 159 | |||
| 160 | taskLoop(partialCallback, function() { | ||
| 161 | var end = (new Date()).getTime() | ||
| 162 | console.log('Solved', puzzle, 'in', (end-start)/1000, 'seconds') | ||
| 163 | if (finalCallback) finalCallback(solutionPaths) | ||
| 164 | }) | ||
| 165 | return solutionPaths | ||
| 166 | } | ||
| 167 | |||
| 168 | function taskLoop(partialCallback, finalCallback) { | ||
| 169 | if (task == null) { | ||
| 170 | finalCallback() | ||
| 171 | return | ||
| 172 | } | ||
| 173 | |||
| 174 | var newTasks = task.code() | ||
| 175 | task = task.nextTask | ||
| 176 | if (newTasks != null && newTasks.length > 0) { | ||
| 177 | // Tasks are pushed in order. To do DFS, we need to enqueue them in reverse order. | ||
| 178 | for (var i=newTasks.length - 1; i >= 0; i--) { | ||
| 179 | task = { | ||
| 180 | 'code': newTasks[i], | ||
| 181 | 'nextTask': task, | ||
| 182 | } | ||
| 183 | } | ||
| 184 | } | ||
| 185 | |||
| 186 | // Asynchronizing is expensive. As such, we don't want to do it too often. | ||
| 187 | // However, we would like 'cancel solving' to be responsive. So, we call setTimeout every so often. | ||
| 188 | var doAsync = false | ||
| 189 | if (!SOLVE_SYNC) { | ||
| 190 | doAsync = (asyncTimer++ % 100 === 0) | ||
| 191 | while (nodes >= percentages[0]) { | ||
| 192 | if (partialCallback) partialCallback(100 - percentages.length) | ||
| 193 | percentages.shift() | ||
| 194 | doAsync = true | ||
| 195 | } | ||
| 196 | } | ||
| 197 | |||
| 198 | if (doAsync) { | ||
| 199 | setTimeout(function() { | ||
| 200 | taskLoop(partialCallback, finalCallback) | ||
| 201 | }, 0) | ||
| 202 | } else { | ||
| 203 | taskLoop(partialCallback, finalCallback) | ||
| 204 | } | ||
| 205 | } | ||
| 206 | |||
| 207 | function tailRecurse(x, y) { | ||
| 208 | // Tail recursion: Back out of this cell | ||
| 209 | puzzle.updateCell2(x, y, 'line', window.LINE_NONE) | ||
| 210 | if (puzzle.symmetry != null) { | ||
| 211 | var sym = puzzle.getSymmetricalPos(x, y) | ||
| 212 | puzzle.updateCell2(sym.x, sym.y, 'line', window.LINE_NONE) | ||
| 213 | } | ||
| 214 | } | ||
| 215 | |||
| 216 | // @Performance: This is the most central loop in this code. | ||
| 217 | // Any performance efforts should be focused here. | ||
| 218 | // Note: Most mechanics are NP (or harder), so don't feel bad about solving them by brute force. | ||
| 219 | // https://arxiv.org/pdf/1804.10193.pdf | ||
| 220 | function solveLoop(x, y, numEndpoints, earlyExitData) { | ||
| 221 | // Stop trying to solve once we reach our goal | ||
| 222 | if (solutionPaths.length >= window.MAX_SOLUTIONS) return | ||
| 223 | |||
| 224 | // Check for collisions (outside, gap, self, other) | ||
| 225 | var cell = puzzle.getCell(x, y) | ||
| 226 | if (cell == null) return | ||
| 227 | if (cell.gap > window.GAP_NONE) return | ||
| 228 | if (cell.line !== window.LINE_NONE) return | ||
| 229 | |||
| 230 | if (puzzle.symmetry == null) { | ||
| 231 | puzzle.updateCell2(x, y, 'line', window.LINE_BLACK) | ||
| 232 | } else { | ||
| 233 | var sym = puzzle.getSymmetricalPos(x, y) | ||
| 234 | if (puzzle.matchesSymmetricalPos(x, y, sym.x, sym.y)) return // Would collide with our reflection | ||
| 235 | |||
| 236 | var symCell = puzzle.getCell(sym.x, sym.y) | ||
| 237 | if (symCell.gap > window.GAP_NONE) return | ||
| 238 | |||
| 239 | puzzle.updateCell2(x, y, 'line', window.LINE_BLUE) | ||
| 240 | puzzle.updateCell2(sym.x, sym.y, 'line', window.LINE_YELLOW) | ||
| 241 | } | ||
| 242 | |||
| 243 | if (path.length < NODE_DEPTH) nodes++ | ||
| 244 | |||
| 245 | if (cell.end != null) { | ||
| 246 | path.push(PATH_NONE) | ||
| 247 | puzzle.endPoint = {'x': x, 'y': y} | ||
| 248 | var puzzleData = window.validate(puzzle, true) | ||
| 249 | if (puzzleData.valid()) solutionPaths.push(path.slice()) | ||
| 250 | path.pop() | ||
| 251 | |||
| 252 | // If there are no further endpoints, tail recurse. | ||
| 253 | // Otherwise, keep going -- we might be able to reach another endpoint. | ||
| 254 | numEndpoints-- | ||
| 255 | if (numEndpoints === 0) return tailRecurse(x, y) | ||
| 256 | } | ||
| 257 | |||
| 258 | var newEarlyExitData = null | ||
| 259 | if (doPruning) { | ||
| 260 | var isEdge = x <= 0 || y <= 0 || x >= puzzle.width - 1 || y >= puzzle.height - 1 | ||
| 261 | newEarlyExitData = [ | ||
| 262 | earlyExitData[0] || (!isEdge && earlyExitData[2].isEdge), // Have we ever left an edge? | ||
| 263 | earlyExitData[2], // The position before our current one | ||
| 264 | {'x':x, 'y':y, 'isEdge':isEdge} // Our current position. | ||
| 265 | ] | ||
| 266 | if (earlyExitData[0] && !earlyExitData[1].isEdge && earlyExitData[2].isEdge && isEdge) { | ||
| 267 | // See the above comment for an explanation of this math. | ||
| 268 | var floodX = earlyExitData[2].x + (earlyExitData[1].x - x) | ||
| 269 | var floodY = earlyExitData[2].y + (earlyExitData[1].y - y) | ||
| 270 | var region = puzzle.getRegion(floodX, floodY) | ||
| 271 | if (region != null) { | ||
| 272 | var regionData = window.validateRegion(puzzle, region, true) | ||
| 273 | if (!regionData.valid()) return tailRecurse(x, y) | ||
| 274 | |||
| 275 | // Additionally, we might have left an endpoint in the enclosed region. | ||
| 276 | // If so, we should decrement the number of remaining endpoints (and possibly tail recurse). | ||
| 277 | for (var pos of region) { | ||
| 278 | var endCell = puzzle.grid[pos.x][pos.y] | ||
| 279 | if (endCell != null && endCell.end != null) numEndpoints-- | ||
| 280 | } | ||
| 281 | |||
| 282 | if (numEndpoints === 0) return tailRecurse(x, y) | ||
| 283 | } | ||
| 284 | } | ||
| 285 | } | ||
| 286 | |||
| 287 | if (SOLVE_SYNC || path.length > SYNC_THRESHOLD) { | ||
| 288 | path.push(PATH_NONE) | ||
| 289 | |||
| 290 | // Recursion order (LRUD) is optimized for BL->TR and mid-start puzzles | ||
| 291 | if (y%2 === 0) { | ||
| 292 | path[path.length-1] = PATH_LEFT | ||
| 293 | solveLoop(x - 1, y, numEndpoints, newEarlyExitData) | ||
| 294 | |||
| 295 | path[path.length-1] = PATH_RIGHT | ||
| 296 | solveLoop(x + 1, y, numEndpoints, newEarlyExitData) | ||
| 297 | } | ||
| 298 | |||
| 299 | if (x%2 === 0) { | ||
| 300 | path[path.length-1] = PATH_TOP | ||
| 301 | solveLoop(x, y - 1, numEndpoints, newEarlyExitData) | ||
| 302 | |||
| 303 | path[path.length-1] = PATH_BOTTOM | ||
| 304 | solveLoop(x, y + 1, numEndpoints, newEarlyExitData) | ||
| 305 | } | ||
| 306 | |||
| 307 | path.pop() | ||
| 308 | tailRecurse(x, y) | ||
| 309 | |||
| 310 | } else { | ||
| 311 | // Push a dummy element on the end of the path, so that we can fill it correctly as we DFS. | ||
| 312 | // This element is popped when we tail recurse (which always happens *after* all of our DFS!) | ||
| 313 | path.push(PATH_NONE) | ||
| 314 | |||
| 315 | // Recursion order (LRUD) is optimized for BL->TR and mid-start puzzles | ||
| 316 | var newTasks = [] | ||
| 317 | if (y%2 === 0) { | ||
| 318 | newTasks.push(function() { | ||
| 319 | path[path.length-1] = PATH_LEFT | ||
| 320 | return solveLoop(x - 1, y, numEndpoints, newEarlyExitData) | ||
| 321 | }) | ||
| 322 | newTasks.push(function() { | ||
| 323 | path[path.length-1] = PATH_RIGHT | ||
| 324 | return solveLoop(x + 1, y, numEndpoints, newEarlyExitData) | ||
| 325 | }) | ||
| 326 | } | ||
| 327 | |||
| 328 | if (x%2 === 0) { | ||
| 329 | newTasks.push(function() { | ||
| 330 | path[path.length-1] = PATH_TOP | ||
| 331 | return solveLoop(x, y - 1, numEndpoints, newEarlyExitData) | ||
| 332 | }) | ||
| 333 | newTasks.push(function() { | ||
| 334 | path[path.length-1] = PATH_BOTTOM | ||
| 335 | return solveLoop(x, y + 1, numEndpoints, newEarlyExitData) | ||
| 336 | }) | ||
| 337 | } | ||
| 338 | |||
| 339 | newTasks.push(function() { | ||
| 340 | path.pop() | ||
| 341 | tailRecurse(x, y) | ||
| 342 | }) | ||
| 343 | |||
| 344 | return newTasks | ||
| 345 | } | ||
| 346 | } | ||
| 347 | |||
| 348 | window.cancelSolving = function() { | ||
| 349 | console.info('Cancelled solving') | ||
| 350 | window.MAX_SOLUTIONS = 0 // Causes all new solveLoop calls to exit immediately. | ||
| 351 | tasks = [] | ||
| 352 | } | ||
| 353 | |||
| 354 | // Only modifies the puzzle object (does not do any graphics updates). Used by metapuzzle.js to determine subpuzzle polyshapes. | ||
| 355 | window.drawPathNoUI = function(puzzle, path) { | ||
| 356 | puzzle.clearLines() | ||
| 357 | |||
| 358 | // Extract the start data from the first path element | ||
| 359 | var x = path[0].x | ||
| 360 | var y = path[0].y | ||
| 361 | var cell = puzzle.getCell(x, y) | ||
| 362 | if (cell == null || cell.start !== true) throw Error('Path does not begin with a startpoint: ' + JSON.stringify(cell)) | ||
| 363 | |||
| 364 | for (var i=1; i<path.length; i++) { | ||
| 365 | var cell = puzzle.getCell(x, y) | ||
| 366 | |||
| 367 | var dx = 0 | ||
| 368 | var dy = 0 | ||
| 369 | if (path[i] === PATH_NONE) { // Reached an endpoint, move into it | ||
| 370 | console.debug('Reached endpoint') | ||
| 371 | if (i != path.length-1) throw Error('Path contains ' + (path.length - 1 - i) + ' trailing directions') | ||
| 372 | break | ||
| 373 | } else if (path[i] === PATH_LEFT) dx = -1 | ||
| 374 | else if (path[i] === PATH_RIGHT) dx = +1 | ||
| 375 | else if (path[i] === PATH_TOP) dy = -1 | ||
| 376 | else if (path[i] === PATH_BOTTOM) dy = +1 | ||
| 377 | else throw Error('Path element ' + (i-1) + ' was not a valid path direction: ' + path[i]) | ||
| 378 | |||
| 379 | x += dx | ||
| 380 | y += dy | ||
| 381 | // Set the cell color | ||
| 382 | if (puzzle.symmetry == null) { | ||
| 383 | cell.line = window.LINE_BLACK | ||
| 384 | } else { | ||
| 385 | cell.line = window.LINE_BLUE | ||
| 386 | var sym = puzzle.getSymmetricalPos(x, y) | ||
| 387 | puzzle.updateCell2(sym.x, sym.y, 'line', window.LINE_YELLOW) | ||
| 388 | } | ||
| 389 | } | ||
| 390 | |||
| 391 | var cell = puzzle.getCell(x, y) | ||
| 392 | if (cell == null || cell.end == null) throw Error('Path does not end at an endpoint: ' + JSON.stringify(cell)) | ||
| 393 | } | ||
| 394 | |||
| 395 | // Uses trace2 to draw the path on the grid, logs a graphical representation of the solution, | ||
| 396 | // and also modifies the puzzle to contain the solution path. | ||
| 397 | window.drawPath = function(puzzle, path, target='puzzle') { | ||
| 398 | // @Duplicated with trace2.clearGrid | ||
| 399 | var puzzleElem = document.getElementById(target) | ||
| 400 | window.deleteElementsByClassName(puzzleElem, 'cursor') | ||
| 401 | window.deleteElementsByClassName(puzzleElem, 'line-1') | ||
| 402 | window.deleteElementsByClassName(puzzleElem, 'line-2') | ||
| 403 | window.deleteElementsByClassName(puzzleElem, 'line-3') | ||
| 404 | puzzle.clearLines() | ||
| 405 | |||
| 406 | if (path == null || path.length === 0) return // "drawing" an empty path is a shorthand for clearing the grid. | ||
| 407 | |||
| 408 | // Extract the start data from the first path element | ||
| 409 | var x = path[0].x | ||
| 410 | var y = path[0].y | ||
| 411 | var cell = puzzle.getCell(x, y) | ||
| 412 | if (cell == null || cell.start !== true) throw Error('Path does not begin with a startpoint: ' + JSON.stringify(cell)) | ||
| 413 | |||
| 414 | var start = document.getElementById('start_' + target + '_' + x + '_' + y) | ||
| 415 | var symStart = document.getElementById('symStart_' + target + '_' + x + '_' + y) | ||
| 416 | window.onTraceStart(puzzle, {'x':x, 'y':y}, document.getElementById(target), start, symStart) | ||
| 417 | |||
| 418 | console.info('Drawing solution of length', path.length) | ||
| 419 | for (var i=1; i<path.length; i++) { | ||
| 420 | var cell = puzzle.getCell(x, y) | ||
| 421 | |||
| 422 | var dx = 0 | ||
| 423 | var dy = 0 | ||
| 424 | if (path[i] === PATH_NONE) { // Reached an endpoint, move into it | ||
| 425 | console.debug('Reached endpoint') | ||
| 426 | if (cell.end === 'left') { | ||
| 427 | window.onMove(-24, 0) | ||
| 428 | } else if (cell.end === 'right') { | ||
| 429 | window.onMove(24, 0) | ||
| 430 | } else if (cell.end === 'top') { | ||
| 431 | window.onMove(0, -24) | ||
| 432 | } else if (cell.end === 'bottom') { | ||
| 433 | window.onMove(0, 24) | ||
| 434 | } | ||
| 435 | if (i != path.length-1) throw Error('Path contains ' + (path.length - 1 - i) + ' trailing directions') | ||
| 436 | break | ||
| 437 | } else if (path[i] === PATH_LEFT) { | ||
| 438 | dx = -1 | ||
| 439 | cell.dir = 'left' | ||
| 440 | } else if (path[i] === PATH_RIGHT) { | ||
| 441 | dx = +1 | ||
| 442 | cell.dir = 'right' | ||
| 443 | } else if (path[i] === PATH_TOP) { | ||
| 444 | dy = -1 | ||
| 445 | cell.dir = 'top' | ||
| 446 | } else if (path[i] === PATH_BOTTOM) { | ||
| 447 | dy = +1 | ||
| 448 | cell.dir = 'down' | ||
| 449 | } else { | ||
| 450 | throw Error('Path element ' + (i-1) + ' was not a valid path direction: ' + path[i]) | ||
| 451 | } | ||
| 452 | |||
| 453 | console.debug('Currently at', x, y, cell, 'moving', dx, dy) | ||
| 454 | |||
| 455 | x += dx | ||
| 456 | y += dy | ||
| 457 | // Unflag the cell, move into it, and reflag it | ||
| 458 | cell.line = window.LINE_NONE | ||
| 459 | window.onMove(41 * dx, 41 * dy) | ||
| 460 | if (puzzle.symmetry == null) { | ||
| 461 | cell.line = window.LINE_BLACK | ||
| 462 | } else { | ||
| 463 | cell.line = window.LINE_BLUE | ||
| 464 | var sym = puzzle.getSymmetricalPos(x, y) | ||
| 465 | puzzle.updateCell2(sym.x, sym.y, 'line', window.LINE_YELLOW) | ||
| 466 | } | ||
| 467 | } | ||
| 468 | var cell = puzzle.getCell(x, y) | ||
| 469 | if (cell == null || cell.end == null) throw Error('Path does not end at an endpoint: ' + JSON.stringify(cell)) | ||
| 470 | |||
| 471 | var rows = ' |' | ||
| 472 | for (var x=0; x<puzzle.width; x++) { | ||
| 473 | rows += ('' + x).padEnd(5, ' ') + '|' | ||
| 474 | } | ||
| 475 | console.log(rows) | ||
| 476 | for (var y=0; y<puzzle.height; y++) { | ||
| 477 | var output = ('' + y).padEnd(3, ' ') + '|' | ||
| 478 | for (var x=0; x<puzzle.width; x++) { | ||
| 479 | var cell = puzzle.grid[x][y] | ||
| 480 | var dir = (cell != null && cell.dir != null ? cell.dir : '') | ||
| 481 | output += dir.padEnd(5, ' ') + '|' | ||
| 482 | } | ||
| 483 | console.log(output) | ||
| 484 | } | ||
| 485 | } | ||
| 486 | |||
| 487 | window.getSolutionIndex = function(pathList, solution) { | ||
| 488 | for (var i=0; i<pathList.length; i++) { | ||
| 489 | var path = pathList[i] | ||
| 490 | var x = path[0].x | ||
| 491 | var y = path[0].y | ||
| 492 | if (solution.grid[path[0].x][path[0].y].line === 0) continue | ||
| 493 | |||
| 494 | var match = true | ||
| 495 | for (var j=1; j<path.length; j++) { | ||
| 496 | var cell = solution.grid[x][y] | ||
| 497 | if (path[j] === PATH_NONE && cell.end != null) { | ||
| 498 | match = false | ||
| 499 | break | ||
| 500 | } else if (path[j] === PATH_LEFT) { | ||
| 501 | if (cell.dir != 'left') { | ||
| 502 | match = false | ||
| 503 | break | ||
| 504 | } | ||
| 505 | x-- | ||
| 506 | } else if (path[j] === PATH_RIGHT) { | ||
| 507 | if (cell.dir != 'right') { | ||
| 508 | match = false | ||
| 509 | break | ||
| 510 | } | ||
| 511 | x++ | ||
| 512 | } else if (path[j] === PATH_TOP) { | ||
| 513 | if (cell.dir != 'top') { | ||
| 514 | match = false | ||
| 515 | break | ||
| 516 | } | ||
| 517 | y-- | ||
| 518 | } else if (path[j] === PATH_BOTTOM) { | ||
| 519 | if (cell.dir != 'bottom') { | ||
| 520 | match = false | ||
| 521 | break | ||
| 522 | } | ||
| 523 | y++ | ||
| 524 | } | ||
| 525 | } | ||
| 526 | if (match) return i | ||
| 527 | } | ||
| 528 | return -1 | ||
| 529 | } | ||
| 530 | |||
| 531 | }) | ||
| diff --git a/app/assets/javascripts/wittle/svg.js b/app/assets/javascripts/wittle/svg.js new file mode 100644 index 0000000..c103b94 --- /dev/null +++ b/app/assets/javascripts/wittle/svg.js | |||
| @@ -0,0 +1,422 @@ | |||
| 1 | namespace(function() { | ||
| 2 | |||
| 3 | window.createElement = function(type) { | ||
| 4 | return document.createElementNS('http://www.w3.org/2000/svg', type) | ||
| 5 | } | ||
| 6 | |||
| 7 | window.drawSymbol = function(params, customMechanics) { | ||
| 8 | var svg = createElement('svg') | ||
| 9 | svg.setAttribute('viewBox', '0 0 ' + params.width + ' ' + params.height) | ||
| 10 | if (!params.x) params.x = 0 | ||
| 11 | if (!params.y) params.y = 0 | ||
| 12 | drawSymbolWithSvg(svg, params, customMechanics) | ||
| 13 | return svg | ||
| 14 | } | ||
| 15 | |||
| 16 | window.drawSymbolWithSvg = function(svg, params, customMechanics) { | ||
| 17 | if (params.type == 'square') square(svg, params) | ||
| 18 | else if (params.type == 'dot') dot(svg, params) | ||
| 19 | else if (params.type == 'gap') gap(svg, params) | ||
| 20 | else if (params.type == 'star') star(svg, params) | ||
| 21 | else if (params.type == 'poly') poly(svg, params) | ||
| 22 | else if (params.type == 'ylop') ylop(svg, params) | ||
| 23 | else if (params.type == 'nega') nega(svg, params) | ||
| 24 | else if (params.type == 'nonce') { /* Do nothing */ } | ||
| 25 | else if (params.type == 'triangle') triangle(svg, params) | ||
| 26 | else if (params.type == 'crayon') crayon(svg, params) | ||
| 27 | else if (params.type == 'start') start(svg, params) | ||
| 28 | else if (params.type == 'end') end(svg, params) | ||
| 29 | else if (params.type == 'drag') drag(svg, params) | ||
| 30 | else if (params.type == 'plus') plus(svg, params) | ||
| 31 | else if (params.type == 'minus') minus(svg, params) | ||
| 32 | else if (params.type == 'bridge' && customMechanics) bridge(svg, params) | ||
| 33 | else if (params.type == 'arrow' && customMechanics) arrow(svg, params) | ||
| 34 | else if (params.type == 'sizer' && customMechanics) sizer(svg, params) | ||
| 35 | else {console.error('Cannot draw unknown SVG type: ' + params.type)} | ||
| 36 | } | ||
| 37 | |||
| 38 | function square(svg, params) { | ||
| 39 | var rect = createElement('rect') | ||
| 40 | svg.appendChild(rect) | ||
| 41 | rect.setAttribute('width', 28) | ||
| 42 | rect.setAttribute('height', 28) | ||
| 43 | rect.setAttribute('x', params.width/2-14 + params.x) | ||
| 44 | rect.setAttribute('y', params.height/2-14 + params.y) | ||
| 45 | rect.setAttribute('rx', 7) | ||
| 46 | rect.setAttribute('ry', 7) | ||
| 47 | rect.setAttribute('fill', params.color) | ||
| 48 | rect.setAttribute('class', params.class) | ||
| 49 | } | ||
| 50 | |||
| 51 | function star(svg, params) { | ||
| 52 | var poly = createElement('polygon') | ||
| 53 | svg.appendChild(poly) | ||
| 54 | var points = [ | ||
| 55 | '-10.5 -10.5', // Top left | ||
| 56 | '-9.5 -4', | ||
| 57 | '-15 0', | ||
| 58 | '-9.5 4', | ||
| 59 | '-10.5 10.5', // Bottom left | ||
| 60 | '-4 9.5', | ||
| 61 | '0 15', | ||
| 62 | '4 9.5', | ||
| 63 | '10.5 10.5', // Bottom right | ||
| 64 | '9.5 4', | ||
| 65 | '15 0', | ||
| 66 | '9.5 -4', | ||
| 67 | '10.5 -10.5', // Top right | ||
| 68 | '4, -9.5', | ||
| 69 | '0 -15', | ||
| 70 | '-4 -9.5', | ||
| 71 | ] | ||
| 72 | poly.setAttribute('transform', 'translate(' + (params.width/2 + params.x) + ', ' + (params.height/2 + params.y) + ')') | ||
| 73 | poly.setAttribute('points', points.join(', ')) | ||
| 74 | poly.setAttribute('fill', params.color) | ||
| 75 | poly.setAttribute('class', params.class) | ||
| 76 | } | ||
| 77 | |||
| 78 | function poly(svg, params) { | ||
| 79 | if (params.polyshape === 0) return | ||
| 80 | var size = 10 // Side length of individual squares in the polyomino | ||
| 81 | var space = 4 // Gap between squares in the polyomino | ||
| 82 | var polyomino = window.polyominoFromPolyshape(params.polyshape) | ||
| 83 | |||
| 84 | var bounds = {'xmin':0, 'xmax':0, 'ymin':0, 'ymax':0} | ||
| 85 | for (var i=0; i<polyomino.length; i++) { | ||
| 86 | var pos = polyomino[i] | ||
| 87 | bounds.xmin = Math.min(bounds.xmin, pos.x) | ||
| 88 | bounds.xmax = Math.max(bounds.xmax, pos.x) | ||
| 89 | bounds.ymin = Math.min(bounds.ymin, pos.y) | ||
| 90 | bounds.ymax = Math.max(bounds.ymax, pos.y) | ||
| 91 | } | ||
| 92 | var offset = (size+space)/2 // Offset between elements to create the gap | ||
| 93 | var centerX = (params.width - size - offset * (bounds.xmax + bounds.xmin)) / 2 + params.x | ||
| 94 | var centerY = (params.height - size - offset * (bounds.ymax + bounds.ymin)) / 2 + params.y | ||
| 95 | |||
| 96 | for (var i=0; i<polyomino.length; i++) { | ||
| 97 | var pos = polyomino[i] | ||
| 98 | if (pos.x%2 !== 0 || pos.y%2 !== 0) continue | ||
| 99 | var rect = createElement('rect') | ||
| 100 | rect.style.pointerEvents = 'none' | ||
| 101 | var transform = 'translate(' + (centerX + pos.x*offset) + ', ' + (centerY + pos.y*offset) + ')' | ||
| 102 | if (window.isRotated(params.polyshape)) { | ||
| 103 | // -30 degree rotation around the midpoint of the square | ||
| 104 | transform = 'rotate(-30, ' + (params.width/2 + params.x) + ', ' + (params.height/2 + params.y) + ') ' + transform | ||
| 105 | } | ||
| 106 | rect.setAttribute('transform', transform) | ||
| 107 | rect.setAttribute('height', size) | ||
| 108 | rect.setAttribute('width', size) | ||
| 109 | rect.setAttribute('fill', params.color) | ||
| 110 | rect.setAttribute('class', params.class) | ||
| 111 | svg.appendChild(rect) | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | function ylop(svg, params) { | ||
| 116 | if (params.polyshape === 0) return | ||
| 117 | var size = 12 // Side length of individual squares in the polyomino | ||
| 118 | var space = 2 // Gap between squares in the polyomino | ||
| 119 | var polyomino = window.polyominoFromPolyshape(params.polyshape) | ||
| 120 | |||
| 121 | var bounds = {'xmin':0, 'xmax':0, 'ymin':0, 'ymax':0} | ||
| 122 | for (var i=0; i<polyomino.length; i++) { | ||
| 123 | var pos = polyomino[i] | ||
| 124 | bounds.xmin = Math.min(bounds.xmin, pos.x) | ||
| 125 | bounds.xmax = Math.max(bounds.xmax, pos.x) | ||
| 126 | bounds.ymin = Math.min(bounds.ymin, pos.y) | ||
| 127 | bounds.ymax = Math.max(bounds.ymax, pos.y) | ||
| 128 | } | ||
| 129 | var offset = (size+space)/2 // Offset between elements to create the gap | ||
| 130 | var centerX = (params.width - size - offset * (bounds.xmax + bounds.xmin)) / 2 + params.x | ||
| 131 | var centerY = (params.height - size - offset * (bounds.ymax + bounds.ymin)) / 2 + params.y | ||
| 132 | |||
| 133 | for (var i=0; i<polyomino.length; i++) { | ||
| 134 | var pos = polyomino[i] | ||
| 135 | if (pos.x%2 !== 0 || pos.y%2 !== 0) continue | ||
| 136 | var poly = createElement('polygon') | ||
| 137 | poly.style.pointerEvents = 'none' | ||
| 138 | var points = [ | ||
| 139 | '0 0', '12 0', '12 12', '0 12', '0 3', | ||
| 140 | '3 3', '3 9', '9 9', '9 3', '0 3', | ||
| 141 | ] | ||
| 142 | poly.setAttribute('points', points.join(', ')) | ||
| 143 | var transform = 'translate(' + (centerX + pos.x*offset) + ', ' + (centerY + pos.y*offset) + ')' | ||
| 144 | if (window.isRotated(params.polyshape)) { | ||
| 145 | // -30 degree rotation around the midpoint of the square | ||
| 146 | transform = 'rotate(-30, ' + (params.width/2 + params.x) + ', ' + (params.height/2 + params.y) + ') ' + transform | ||
| 147 | } | ||
| 148 | poly.setAttribute('transform', transform) | ||
| 149 | poly.setAttribute('fill', params.color) | ||
| 150 | poly.setAttribute('class', params.class) | ||
| 151 | svg.appendChild(poly) | ||
| 152 | } | ||
| 153 | } | ||
| 154 | |||
| 155 | function nega(svg, params) { | ||
| 156 | var poly = createElement('polygon') | ||
| 157 | svg.appendChild(poly) | ||
| 158 | var points = [ | ||
| 159 | '2.9 -2', | ||
| 160 | '2.9 -10.4', | ||
| 161 | '-2.9 -10.4', | ||
| 162 | '-2.9 -2', | ||
| 163 | '-10.2 2.2', | ||
| 164 | '-7.3 7.2', | ||
| 165 | '0 3', | ||
| 166 | '7.3 7.2', | ||
| 167 | '10.2 2.2', | ||
| 168 | ] | ||
| 169 | poly.setAttribute('transform', 'translate(' + (params.width/2 + params.x) + ', ' + (params.height/2 + params.y) + ')') | ||
| 170 | poly.setAttribute('points', points.join(', ')) | ||
| 171 | poly.setAttribute('fill', params.color) | ||
| 172 | poly.setAttribute('class', params.class) | ||
| 173 | } | ||
| 174 | |||
| 175 | var triangleDistributions = [ | ||
| 176 | [], | ||
| 177 | [1], | ||
| 178 | [2], | ||
| 179 | [3], | ||
| 180 | [2, 2], | ||
| 181 | [2, 3], | ||
| 182 | [3, 3], | ||
| 183 | [2, 3, 2], | ||
| 184 | [3, 2, 3], | ||
| 185 | [3, 3, 3] | ||
| 186 | ] | ||
| 187 | |||
| 188 | function triangle(svg, params) { | ||
| 189 | var distribution = triangleDistributions[params.count] | ||
| 190 | var high = distribution.length | ||
| 191 | for (var y=0; y<high; y++) { | ||
| 192 | var wide = distribution[y] | ||
| 193 | for (var x=0; x<wide; x++) { | ||
| 194 | var poly = createElement('polygon') | ||
| 195 | svg.appendChild(poly) | ||
| 196 | var Xcoord = params.x + params.width/2 + 11*(2*x - wide + 1) | ||
| 197 | var Ycoord = params.y + params.height/2 + 10*(2*y - high + 1) | ||
| 198 | poly.setAttribute('transform', 'translate(' + Xcoord + ', ' + Ycoord + ')') | ||
| 199 | poly.setAttribute('points', '0 -8, -8 6, 8 6') | ||
| 200 | poly.setAttribute('fill', params.color) | ||
| 201 | poly.setAttribute('class', params.class) | ||
| 202 | } | ||
| 203 | } | ||
| 204 | } | ||
| 205 | |||
| 206 | function crayon(svg, params) { | ||
| 207 | var height = params.height | ||
| 208 | |||
| 209 | var poly = createElement('polygon') | ||
| 210 | svg.appendChild(poly) | ||
| 211 | var points = [ | ||
| 212 | '0 ' + (height/2), | ||
| 213 | (height/2) + ' 0', | ||
| 214 | (height/2) + ' ' + height, | ||
| 215 | ] | ||
| 216 | poly.setAttribute('points', points.join(', ')) | ||
| 217 | poly.setAttribute('fill', params.color) | ||
| 218 | var txt = createElement('text') | ||
| 219 | svg.appendChild(txt) | ||
| 220 | txt.setAttribute('fill', window.TEXT_COLOR) | ||
| 221 | txt.setAttribute('transform', 'translate(' + (height/2 + 10) + ', ' + (height/2 + 6) + ')') | ||
| 222 | txt.textContent = params.text | ||
| 223 | } | ||
| 224 | |||
| 225 | function start(svg, params) { | ||
| 226 | var circ = createElement('circle') | ||
| 227 | svg.appendChild(circ) | ||
| 228 | circ.setAttribute('r', 24) | ||
| 229 | circ.setAttribute('fill', window.FOREGROUND) | ||
| 230 | circ.setAttribute('cx', params.height/2 + params.x) | ||
| 231 | circ.setAttribute('cy', params.width/2 + params.y) | ||
| 232 | } | ||
| 233 | |||
| 234 | function end(svg, params) { | ||
| 235 | var rect = createElement('rect') | ||
| 236 | svg.appendChild(rect) | ||
| 237 | rect.setAttribute('width', 24) | ||
| 238 | rect.setAttribute('height', 24) | ||
| 239 | rect.setAttribute('fill', window.FOREGROUND) | ||
| 240 | rect.setAttribute('x', params.height/2 - 12 + params.x) | ||
| 241 | rect.setAttribute('y', params.width/2 - 12 + params.y) | ||
| 242 | |||
| 243 | var circ = createElement('circle') | ||
| 244 | svg.appendChild(circ) | ||
| 245 | circ.setAttribute('r', 12) | ||
| 246 | circ.setAttribute('fill', window.FOREGROUND) | ||
| 247 | circ.setAttribute('cx', params.height/2 + params.x) | ||
| 248 | circ.setAttribute('cy', params.width/2 + params.y) | ||
| 249 | |||
| 250 | if (params.dir === 'left') { | ||
| 251 | rect.setAttribute('x', parseInt(rect.getAttribute('x'), 10) - 12) | ||
| 252 | circ.setAttribute('cx', parseInt(circ.getAttribute('cx'), 10) - 24) | ||
| 253 | } else if (params.dir === 'right') { | ||
| 254 | rect.setAttribute('x', parseInt(rect.getAttribute('x'), 10) + 12) | ||
| 255 | circ.setAttribute('cx', parseInt(circ.getAttribute('cx'), 10) + 24) | ||
| 256 | } else if (params.dir === 'top') { | ||
| 257 | rect.setAttribute('y', parseInt(rect.getAttribute('y'), 10) - 12) | ||
| 258 | circ.setAttribute('cy', parseInt(circ.getAttribute('cy'), 10) - 24) | ||
| 259 | } else if (params.dir === 'bottom') { | ||
| 260 | rect.setAttribute('y', parseInt(rect.getAttribute('y'), 10) + 12) | ||
| 261 | circ.setAttribute('cy', parseInt(circ.getAttribute('cy'), 10) + 24) | ||
| 262 | } else { | ||
| 263 | console.error('Endpoint direction not defined!', JSON.stringify(params)) | ||
| 264 | } | ||
| 265 | } | ||
| 266 | |||
| 267 | function dot(svg, params) { | ||
| 268 | var hex = createElement('polygon') | ||
| 269 | svg.appendChild(hex) | ||
| 270 | hex.setAttribute('points', '5.2 9, 10.4 0, 5.2 -9, -5.2 -9, -10.4 0, -5.2 9') | ||
| 271 | hex.setAttribute('transform', 'translate(' + (params.width/2 + params.x) + ', ' + (params.height/2 + params.y) + ')') | ||
| 272 | hex.setAttribute('fill', params.color) | ||
| 273 | hex.setAttribute('class', params.class) | ||
| 274 | hex.setAttribute('stroke', params.stroke) | ||
| 275 | hex.setAttribute('stroke-width', params.strokeWidth) | ||
| 276 | hex.setAttribute('style', 'pointer-events:none;') | ||
| 277 | } | ||
| 278 | |||
| 279 | function gap(svg, params) { | ||
| 280 | if (!params.rot) params.rot = 0 | ||
| 281 | var centerX = params.height/2 + params.x | ||
| 282 | var centerY = params.width/2 + params.y | ||
| 283 | var rotate = function(degrees) {return 'rotate(' + degrees + ', ' + centerX + ', ' + centerY + ')'} | ||
| 284 | |||
| 285 | var rect = createElement('rect') | ||
| 286 | svg.appendChild(rect) | ||
| 287 | rect.setAttribute('width', 32) | ||
| 288 | rect.setAttribute('height', 24) | ||
| 289 | rect.setAttribute('fill', window.FOREGROUND) | ||
| 290 | rect.setAttribute('transform', rotate(90 * params.rot)) | ||
| 291 | rect.setAttribute('x', centerX - 40) | ||
| 292 | rect.setAttribute('y', centerY - 12) | ||
| 293 | rect.setAttribute('shape-rendering', 'crispedges') | ||
| 294 | |||
| 295 | var rect = createElement('rect') | ||
| 296 | svg.appendChild(rect) | ||
| 297 | rect.setAttribute('width', 32) | ||
| 298 | rect.setAttribute('height', 24) | ||
| 299 | rect.setAttribute('fill', window.FOREGROUND) | ||
| 300 | rect.setAttribute('transform', rotate(90 * params.rot)) | ||
| 301 | rect.setAttribute('x', centerX + 9) | ||
| 302 | rect.setAttribute('y', centerY - 12) | ||
| 303 | rect.setAttribute('shape-rendering', 'crispedges') | ||
| 304 | } | ||
| 305 | |||
| 306 | function drag(svg, params) { | ||
| 307 | if (!params.rot) params.rot = 0 | ||
| 308 | |||
| 309 | for (var i=0; i<6; i++) { | ||
| 310 | for (var j=0; j<2; j++) { | ||
| 311 | var rect = createElement('rect') | ||
| 312 | svg.appendChild(rect) | ||
| 313 | rect.setAttribute('width', 2) | ||
| 314 | rect.setAttribute('height', 2) | ||
| 315 | if (params.rot === 0) { | ||
| 316 | rect.setAttribute('x', i*4) | ||
| 317 | rect.setAttribute('y', j*4) | ||
| 318 | } else { | ||
| 319 | rect.setAttribute('y', i*4) | ||
| 320 | rect.setAttribute('x', j*4) | ||
| 321 | } | ||
| 322 | rect.setAttribute('fill', window.PAGE_BACKGROUND) | ||
| 323 | } | ||
| 324 | } | ||
| 325 | } | ||
| 326 | |||
| 327 | function plus(svg, params) { | ||
| 328 | var verti = createElement('rect') | ||
| 329 | svg.appendChild(verti) | ||
| 330 | verti.setAttribute('x', params.width/2 - 1) | ||
| 331 | verti.setAttribute('y', 3) | ||
| 332 | verti.setAttribute('width', 2) | ||
| 333 | verti.setAttribute('height', params.height - 6) | ||
| 334 | verti.setAttribute('fill', window.TEXT_COLOR) | ||
| 335 | minus(svg, params) | ||
| 336 | } | ||
| 337 | |||
| 338 | function minus(svg, params) { | ||
| 339 | var horiz = createElement('rect') | ||
| 340 | svg.appendChild(horiz) | ||
| 341 | horiz.setAttribute('x', 3) | ||
| 342 | horiz.setAttribute('y', params.height/2 - 1) | ||
| 343 | horiz.setAttribute('width', params.width - 6) | ||
| 344 | horiz.setAttribute('height', 2) | ||
| 345 | horiz.setAttribute('fill', window.TEXT_COLOR) | ||
| 346 | } | ||
| 347 | |||
| 348 | function bridge(svg, params) { | ||
| 349 | var poly = createElement('polygon') | ||
| 350 | svg.appendChild(poly) | ||
| 351 | var points = [ | ||
| 352 | '-10.58 14.56', | ||
| 353 | '-17.12 -5.56', | ||
| 354 | '0 -18', | ||
| 355 | '17.12 -5.56', | ||
| 356 | '10.58 14.56', | ||
| 357 | '5.29 7.28', | ||
| 358 | '8.56 -2.78', | ||
| 359 | '0 -9', | ||
| 360 | '-8.56 -2.78', | ||
| 361 | '-5.29 7.28', | ||
| 362 | ] | ||
| 363 | poly.setAttribute('transform', 'translate(' + (params.width/2 + params.x) + ', ' + (params.height/2 + params.y) + ')') | ||
| 364 | poly.setAttribute('points', points.join(', ')) | ||
| 365 | poly.setAttribute('fill', params.color) | ||
| 366 | poly.setAttribute('class', params.class) | ||
| 367 | } | ||
| 368 | |||
| 369 | function arrow(svg, params) { | ||
| 370 | if (!params.rot) params.rot = 0 | ||
| 371 | |||
| 372 | var centerX = params.height/2 + params.x | ||
| 373 | var centerY = params.width/2 + params.y | ||
| 374 | var rotate = function(degrees) {return 'rotate(' + degrees + ', ' + centerX + ', ' + centerY + ')'} | ||
| 375 | |||
| 376 | var rect = createElement('rect') | ||
| 377 | svg.appendChild(rect) | ||
| 378 | rect.setAttribute('width', 8) | ||
| 379 | rect.setAttribute('height', 46) | ||
| 380 | rect.setAttribute('fill', params.color) | ||
| 381 | rect.setAttribute('class', params.class) | ||
| 382 | rect.setAttribute('transform', rotate(45 * params.rot)) | ||
| 383 | rect.setAttribute('x', centerX - 4) | ||
| 384 | rect.setAttribute('y', centerY - 22) | ||
| 385 | |||
| 386 | for (var i=0; i<params.count; i++) { | ||
| 387 | var arrowhead = createElement('polygon') | ||
| 388 | svg.appendChild(arrowhead) | ||
| 389 | var points = [ | ||
| 390 | '-24 -15', | ||
| 391 | '-21.4 -8.6', | ||
| 392 | '0 -19', | ||
| 393 | '21.4 -8.6', | ||
| 394 | '24 -15', | ||
| 395 | '0 -27', | ||
| 396 | ] | ||
| 397 | var transform = rotate(45 * params.rot) | ||
| 398 | transform += ' translate(' + (params.width/2 + params.x) + ', ' + (params.height/2 + params.y + i*12) + ')' | ||
| 399 | arrowhead.setAttribute('transform', transform) | ||
| 400 | arrowhead.setAttribute('points', points.join(', ')) | ||
| 401 | arrowhead.setAttribute('fill', params.color) | ||
| 402 | arrowhead.setAttribute('class', params.class) | ||
| 403 | } | ||
| 404 | } | ||
| 405 | |||
| 406 | function sizer(svg, params) { | ||
| 407 | var path = createElement('path') | ||
| 408 | svg.appendChild(path) | ||
| 409 | path.setAttribute('d', | ||
| 410 | 'M -24 0 ' + | ||
| 411 | 'a 24 24 0 0 0 24 -24 ' + | ||
| 412 | 'a 24 24 0 0 0 24 24 ' + | ||
| 413 | 'a 24 24 0 0 0 -24 24 ' + | ||
| 414 | 'a 24 24 0 0 0 -24 -24 ' + | ||
| 415 | 'z' | ||
| 416 | ) | ||
| 417 | path.setAttribute('fill', params.color) | ||
| 418 | path.setAttribute('class', params.class) | ||
| 419 | path.setAttribute('transform', 'translate(' + (params.width/2 + params.x) + ', ' + (params.height/2 + params.y) + ')') | ||
| 420 | } | ||
| 421 | |||
| 422 | }) | ||
| diff --git a/app/assets/javascripts/wittle/trace2.js b/app/assets/javascripts/wittle/trace2.js new file mode 100644 index 0000000..01e4ccd --- /dev/null +++ b/app/assets/javascripts/wittle/trace2.js | |||
| @@ -0,0 +1,988 @@ | |||
| 1 | namespace(function() { | ||
| 2 | |||
| 3 | var BBOX_DEBUG = false | ||
| 4 | |||
| 5 | function clamp(value, min, max) { | ||
| 6 | return value < min ? min : value > max ? max : value | ||
| 7 | } | ||
| 8 | |||
| 9 | class BoundingBox { | ||
| 10 | constructor(x1, x2, y1, y2, sym=false) { | ||
| 11 | this.raw = {'x1':x1, 'x2':x2, 'y1':y1, 'y2':y2} | ||
| 12 | this.sym = sym | ||
| 13 | if (BBOX_DEBUG === true) { | ||
| 14 | this.debug = createElement('rect') | ||
| 15 | data.svg.appendChild(this.debug) | ||
| 16 | this.debug.setAttribute('opacity', 0.5) | ||
| 17 | this.debug.setAttribute('style', 'pointer-events: none;') | ||
| 18 | if (data.puzzle.symmetry == null) { | ||
| 19 | this.debug.setAttribute('fill', 'white') | ||
| 20 | } else { | ||
| 21 | if (this.sym !== true) { | ||
| 22 | this.debug.setAttribute('fill', 'blue') | ||
| 23 | } else { | ||
| 24 | this.debug.setAttribute('fill', 'orange') | ||
| 25 | } | ||
| 26 | } | ||
| 27 | } | ||
| 28 | this._update() | ||
| 29 | } | ||
| 30 | |||
| 31 | shift(dir, pixels) { | ||
| 32 | if (dir === 'left') { | ||
| 33 | this.raw.x2 = this.raw.x1 | ||
| 34 | this.raw.x1 -= pixels | ||
| 35 | } else if (dir === 'right') { | ||
| 36 | this.raw.x1 = this.raw.x2 | ||
| 37 | this.raw.x2 += pixels | ||
| 38 | } else if (dir === 'top') { | ||
| 39 | this.raw.y2 = this.raw.y1 | ||
| 40 | this.raw.y1 -= pixels | ||
| 41 | } else if (dir === 'bottom') { | ||
| 42 | this.raw.y1 = this.raw.y2 | ||
| 43 | this.raw.y2 += pixels | ||
| 44 | } | ||
| 45 | this._update() | ||
| 46 | } | ||
| 47 | |||
| 48 | inMain(x, y) { | ||
| 49 | var inMainBox = | ||
| 50 | (this.x1 < x && x < this.x2) && | ||
| 51 | (this.y1 < y && y < this.y2) | ||
| 52 | var inRawBox = | ||
| 53 | (this.raw.x1 < x && x < this.raw.x2) && | ||
| 54 | (this.raw.y1 < y && y < this.raw.y2) | ||
| 55 | |||
| 56 | return inMainBox && !inRawBox | ||
| 57 | } | ||
| 58 | |||
| 59 | _update() { | ||
| 60 | this.x1 = this.raw.x1 | ||
| 61 | this.x2 = this.raw.x2 | ||
| 62 | this.y1 = this.raw.y1 | ||
| 63 | this.y2 = this.raw.y2 | ||
| 64 | |||
| 65 | // Check for endpoint adjustment | ||
| 66 | if (this.sym !== true) { | ||
| 67 | var cell = data.puzzle.getCell(data.pos.x, data.pos.y) | ||
| 68 | } else { | ||
| 69 | var cell = data.puzzle.getSymmetricalCell(data.sym.x, data.sym.y) | ||
| 70 | } | ||
| 71 | if (cell.end === 'left') { | ||
| 72 | this.x1 -= 24 | ||
| 73 | } else if (cell.end === 'right') { | ||
| 74 | this.x2 += 24 | ||
| 75 | } else if (cell.end === 'top') { | ||
| 76 | this.y1 -= 24 | ||
| 77 | } else if (cell.end === 'bottom') { | ||
| 78 | this.y2 += 24 | ||
| 79 | } | ||
| 80 | |||
| 81 | this.middle = { // Note: Middle of the raw object | ||
| 82 | 'x':(this.raw.x1 + this.raw.x2)/2, | ||
| 83 | 'y':(this.raw.y1 + this.raw.y2)/2 | ||
| 84 | } | ||
| 85 | |||
| 86 | if (this.debug != null) { | ||
| 87 | this.debug.setAttribute('x', this.x1) | ||
| 88 | this.debug.setAttribute('y', this.y1) | ||
| 89 | this.debug.setAttribute('width', this.x2 - this.x1) | ||
| 90 | this.debug.setAttribute('height', this.y2 - this.y1) | ||
| 91 | } | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | class PathSegment { | ||
| 96 | constructor(dir) { | ||
| 97 | this.poly1 = createElement('polygon') | ||
| 98 | this.circ = createElement('circle') | ||
| 99 | this.poly2 = createElement('polygon') | ||
| 100 | this.pillarCirc = createElement('circle') | ||
| 101 | this.dir = dir | ||
| 102 | data.svg.insertBefore(this.circ, data.cursor) | ||
| 103 | data.svg.insertBefore(this.poly2, data.cursor) | ||
| 104 | data.svg.insertBefore(this.pillarCirc, data.cursor) | ||
| 105 | this.circ.setAttribute('cx', data.bbox.middle.x) | ||
| 106 | this.circ.setAttribute('cy', data.bbox.middle.y) | ||
| 107 | |||
| 108 | if (data.puzzle.pillar === true) { | ||
| 109 | // cx/cy are updated in redraw(), since pillarCirc tracks the cursor | ||
| 110 | this.pillarCirc.setAttribute('cy', data.bbox.middle.y) | ||
| 111 | this.pillarCirc.setAttribute('r', 12) | ||
| 112 | if (data.pos.x === 0 && this.dir === MOVE_RIGHT) { | ||
| 113 | this.pillarCirc.setAttribute('cx', data.bbox.x1) | ||
| 114 | this.pillarCirc.setAttribute('static', true) | ||
| 115 | } else if (data.pos.x === data.puzzle.width - 1 && this.dir === MOVE_LEFT) { | ||
| 116 | this.pillarCirc.setAttribute('cx', data.bbox.x2) | ||
| 117 | this.pillarCirc.setAttribute('static', true) | ||
| 118 | } else { | ||
| 119 | this.pillarCirc.setAttribute('cx', data.bbox.middle.x) | ||
| 120 | } | ||
| 121 | } | ||
| 122 | |||
| 123 | if (data.puzzle.symmetry == null) { | ||
| 124 | this.poly1.setAttribute('class', 'line-1 ' + data.svg.id) | ||
| 125 | this.circ.setAttribute('class', 'line-1 ' + data.svg.id) | ||
| 126 | this.poly2.setAttribute('class', 'line-1 ' + data.svg.id) | ||
| 127 | this.pillarCirc.setAttribute('class', 'line-1 ' + data.svg.id) | ||
| 128 | } else { | ||
| 129 | this.poly1.setAttribute('class', 'line-2 ' + data.svg.id) | ||
| 130 | this.circ.setAttribute('class', 'line-2 ' + data.svg.id) | ||
| 131 | this.poly2.setAttribute('class', 'line-2 ' + data.svg.id) | ||
| 132 | this.pillarCirc.setAttribute('class', 'line-2 ' + data.svg.id) | ||
| 133 | |||
| 134 | this.symPoly1 = createElement('polygon') | ||
| 135 | this.symCirc = createElement('circle') | ||
| 136 | this.symPoly2 = createElement('polygon') | ||
| 137 | this.symPillarCirc = createElement('circle') | ||
| 138 | data.svg.insertBefore(this.symCirc, data.cursor) | ||
| 139 | data.svg.insertBefore(this.symPoly2, data.cursor) | ||
| 140 | data.svg.insertBefore(this.symPillarCirc, data.cursor) | ||
| 141 | this.symPoly1.setAttribute('class', 'line-3 ' + data.svg.id) | ||
| 142 | this.symCirc.setAttribute('class', 'line-3 ' + data.svg.id) | ||
| 143 | this.symPoly2.setAttribute('class', 'line-3 ' + data.svg.id) | ||
| 144 | this.symPillarCirc.setAttribute('class', 'line-3 ' + data.svg.id) | ||
| 145 | |||
| 146 | this.symCirc.setAttribute('cx', data.symbbox.middle.x) | ||
| 147 | this.symCirc.setAttribute('cy', data.symbbox.middle.y) | ||
| 148 | |||
| 149 | if (data.puzzle.pillar === true) { | ||
| 150 | // cx/cy are updated in redraw(), since symPillarCirc tracks the cursor | ||
| 151 | this.symPillarCirc.setAttribute('cy', data.symbbox.middle.y) | ||
| 152 | this.symPillarCirc.setAttribute('r', 12) | ||
| 153 | var symmetricalDir = getSymmetricalDir(data.puzzle, this.dir) | ||
| 154 | if (data.sym.x === 0 && symmetricalDir === MOVE_RIGHT) { | ||
| 155 | this.symPillarCirc.setAttribute('cx', data.symbbox.x1) | ||
| 156 | this.symPillarCirc.setAttribute('static', true) | ||
| 157 | } else if (data.sym.x === data.puzzle.width - 1 && symmetricalDir === MOVE_LEFT) { | ||
| 158 | this.symPillarCirc.setAttribute('cx', data.symbbox.x2) | ||
| 159 | this.symPillarCirc.setAttribute('static', true) | ||
| 160 | } else { | ||
| 161 | this.symPillarCirc.setAttribute('cx', data.symbbox.middle.x) | ||
| 162 | } | ||
| 163 | } | ||
| 164 | } | ||
| 165 | |||
| 166 | if (this.dir === MOVE_NONE) { // Start point | ||
| 167 | this.circ.setAttribute('r', 24) | ||
| 168 | this.circ.setAttribute('class', this.circ.getAttribute('class') + ' start') | ||
| 169 | if (data.puzzle.symmetry != null) { | ||
| 170 | this.symCirc.setAttribute('r', 24) | ||
| 171 | this.symCirc.setAttribute('class', this.symCirc.getAttribute('class') + ' start') | ||
| 172 | } | ||
| 173 | } else { | ||
| 174 | // Only insert poly1 in non-startpoints | ||
| 175 | data.svg.insertBefore(this.poly1, data.cursor) | ||
| 176 | this.circ.setAttribute('r', 12) | ||
| 177 | if (data.puzzle.symmetry != null) { | ||
| 178 | data.svg.insertBefore(this.symPoly1, data.cursor) | ||
| 179 | this.symCirc.setAttribute('r', 12) | ||
| 180 | } | ||
| 181 | } | ||
| 182 | } | ||
| 183 | |||
| 184 | destroy() { | ||
| 185 | data.svg.removeChild(this.poly1) | ||
| 186 | data.svg.removeChild(this.circ) | ||
| 187 | data.svg.removeChild(this.poly2) | ||
| 188 | data.svg.removeChild(this.pillarCirc) | ||
| 189 | if (data.puzzle.symmetry != null) { | ||
| 190 | data.svg.removeChild(this.symPoly1) | ||
| 191 | data.svg.removeChild(this.symCirc) | ||
| 192 | data.svg.removeChild(this.symPoly2) | ||
| 193 | data.svg.removeChild(this.symPillarCirc) | ||
| 194 | } | ||
| 195 | } | ||
| 196 | |||
| 197 | redraw() { // Uses raw bbox because of endpoints | ||
| 198 | // Move the cursor and related objects | ||
| 199 | var x = clamp(data.x, data.bbox.x1, data.bbox.x2) | ||
| 200 | var y = clamp(data.y, data.bbox.y1, data.bbox.y2) | ||
| 201 | data.cursor.setAttribute('cx', x) | ||
| 202 | data.cursor.setAttribute('cy', y) | ||
| 203 | if (data.puzzle.symmetry != null) { | ||
| 204 | data.symcursor.setAttribute('cx', this._reflX(x)) | ||
| 205 | data.symcursor.setAttribute('cy', this._reflY(y)) | ||
| 206 | } | ||
| 207 | if (data.puzzle.pillar === true) { | ||
| 208 | if (this.pillarCirc.getAttribute('static') == null) { | ||
| 209 | this.pillarCirc.setAttribute('cx', x) | ||
| 210 | this.pillarCirc.setAttribute('cy', y) | ||
| 211 | } | ||
| 212 | if (data.puzzle.symmetry != null) { | ||
| 213 | if (this.symPillarCirc.getAttribute('static') == null) { | ||
| 214 | this.symPillarCirc.setAttribute('cx', this._reflX(x)) | ||
| 215 | this.symPillarCirc.setAttribute('cy', this._reflY(y)) | ||
| 216 | } | ||
| 217 | } | ||
| 218 | } | ||
| 219 | |||
| 220 | // Draw the first-half box | ||
| 221 | var points1 = JSON.parse(JSON.stringify(data.bbox.raw)) | ||
| 222 | if (this.dir === MOVE_LEFT) { | ||
| 223 | points1.x1 = clamp(data.x, data.bbox.middle.x, data.bbox.x2) | ||
| 224 | } else if (this.dir === MOVE_RIGHT) { | ||
| 225 | points1.x2 = clamp(data.x, data.bbox.x1, data.bbox.middle.x) | ||
| 226 | } else if (this.dir === MOVE_TOP) { | ||
| 227 | points1.y1 = clamp(data.y, data.bbox.middle.y, data.bbox.y2) | ||
| 228 | } else if (this.dir === MOVE_BOTTOM) { | ||
| 229 | points1.y2 = clamp(data.y, data.bbox.y1, data.bbox.middle.y) | ||
| 230 | } | ||
| 231 | this.poly1.setAttribute('points', | ||
| 232 | points1.x1 + ' ' + points1.y1 + ',' + | ||
| 233 | points1.x1 + ' ' + points1.y2 + ',' + | ||
| 234 | points1.x2 + ' ' + points1.y2 + ',' + | ||
| 235 | points1.x2 + ' ' + points1.y1 | ||
| 236 | ) | ||
| 237 | |||
| 238 | var firstHalf = false | ||
| 239 | var isEnd = (data.puzzle.grid[data.pos.x][data.pos.y].end != null) | ||
| 240 | // The second half of the line uses the raw so that it can enter the endpoint properly. | ||
| 241 | var points2 = JSON.parse(JSON.stringify(data.bbox.raw)) | ||
| 242 | if (data.x < data.bbox.middle.x && this.dir !== MOVE_RIGHT) { | ||
| 243 | points2.x1 = clamp(data.x, data.bbox.x1, data.bbox.middle.x) | ||
| 244 | points2.x2 = data.bbox.middle.x | ||
| 245 | if (isEnd && data.pos.x%2 === 0 && data.pos.y%2 === 1) { | ||
| 246 | points2.y1 += 17 | ||
| 247 | points2.y2 -= 17 | ||
| 248 | } | ||
| 249 | } else if (data.x > data.bbox.middle.x && this.dir !== MOVE_LEFT) { | ||
| 250 | points2.x1 = data.bbox.middle.x | ||
| 251 | points2.x2 = clamp(data.x, data.bbox.middle.x, data.bbox.x2) | ||
| 252 | if (isEnd && data.pos.x%2 === 0 && data.pos.y%2 === 1) { | ||
| 253 | points2.y1 += 17 | ||
| 254 | points2.y2 -= 17 | ||
| 255 | } | ||
| 256 | } else if (data.y < data.bbox.middle.y && this.dir !== MOVE_BOTTOM) { | ||
| 257 | points2.y1 = clamp(data.y, data.bbox.y1, data.bbox.middle.y) | ||
| 258 | points2.y2 = data.bbox.middle.y | ||
| 259 | if (isEnd && data.pos.x%2 === 1 && data.pos.y%2 === 0) { | ||
| 260 | points2.x1 += 17 | ||
| 261 | points2.x2 -= 17 | ||
| 262 | } | ||
| 263 | } else if (data.y > data.bbox.middle.y && this.dir !== MOVE_TOP) { | ||
| 264 | points2.y1 = data.bbox.middle.y | ||
| 265 | points2.y2 = clamp(data.y, data.bbox.middle.y, data.bbox.y2) | ||
| 266 | if (isEnd && data.pos.x%2 === 1 && data.pos.y%2 === 0) { | ||
| 267 | points2.x1 += 17 | ||
| 268 | points2.x2 -= 17 | ||
| 269 | } | ||
| 270 | } else { | ||
| 271 | firstHalf = true | ||
| 272 | } | ||
| 273 | |||
| 274 | this.poly2.setAttribute('points', | ||
| 275 | points2.x1 + ' ' + points2.y1 + ',' + | ||
| 276 | points2.x1 + ' ' + points2.y2 + ',' + | ||
| 277 | points2.x2 + ' ' + points2.y2 + ',' + | ||
| 278 | points2.x2 + ' ' + points2.y1 | ||
| 279 | ) | ||
| 280 | |||
| 281 | // Show the second poly only in the second half of the cell | ||
| 282 | this.poly2.setAttribute('opacity', (firstHalf ? 0 : 1)) | ||
| 283 | // Show the circle in the second half of the cell AND in the start | ||
| 284 | if (firstHalf && this.dir !== MOVE_NONE) { | ||
| 285 | this.circ.setAttribute('opacity', 0) | ||
| 286 | } else { | ||
| 287 | this.circ.setAttribute('opacity', 1) | ||
| 288 | } | ||
| 289 | |||
| 290 | // Draw the symmetrical path based on the original one | ||
| 291 | if (data.puzzle.symmetry != null) { | ||
| 292 | this.symPoly1.setAttribute('points', | ||
| 293 | this._reflX(points1.x2) + ' ' + this._reflY(points1.y2) + ',' + | ||
| 294 | this._reflX(points1.x2) + ' ' + this._reflY(points1.y1) + ',' + | ||
| 295 | this._reflX(points1.x1) + ' ' + this._reflY(points1.y1) + ',' + | ||
| 296 | this._reflX(points1.x1) + ' ' + this._reflY(points1.y2) | ||
| 297 | ) | ||
| 298 | |||
| 299 | this.symPoly2.setAttribute('points', | ||
| 300 | this._reflX(points2.x2) + ' ' + this._reflY(points2.y2) + ',' + | ||
| 301 | this._reflX(points2.x2) + ' ' + this._reflY(points2.y1) + ',' + | ||
| 302 | this._reflX(points2.x1) + ' ' + this._reflY(points2.y1) + ',' + | ||
| 303 | this._reflX(points2.x1) + ' ' + this._reflY(points2.y2) | ||
| 304 | ) | ||
| 305 | |||
| 306 | this.symCirc.setAttribute('opacity', this.circ.getAttribute('opacity')) | ||
| 307 | this.symPoly2.setAttribute('opacity', this.poly2.getAttribute('opacity')) | ||
| 308 | } | ||
| 309 | } | ||
| 310 | |||
| 311 | _reflX(x) { | ||
| 312 | if (data.puzzle.symmetry == null) return x | ||
| 313 | if (data.puzzle.symmetry.x === true) { | ||
| 314 | // Mirror position inside the bounding box | ||
| 315 | return (data.bbox.middle.x - x) + data.symbbox.middle.x | ||
| 316 | } | ||
| 317 | // Copy position inside the bounding box | ||
| 318 | return (x - data.bbox.middle.x) + data.symbbox.middle.x | ||
| 319 | } | ||
| 320 | |||
| 321 | _reflY(y) { | ||
| 322 | if (data.puzzle.symmetry == null) return y | ||
| 323 | if (data.puzzle.symmetry.y === true) { | ||
| 324 | // Mirror position inside the bounding box | ||
| 325 | return (data.bbox.middle.y - y) + data.symbbox.middle.y | ||
| 326 | } | ||
| 327 | // Copy position inside the bounding box | ||
| 328 | return (y - data.bbox.middle.y) + data.symbbox.middle.y | ||
| 329 | } | ||
| 330 | } | ||
| 331 | |||
| 332 | var data = {} | ||
| 333 | |||
| 334 | function clearGrid(svg, puzzle) { | ||
| 335 | if (data.bbox != null && data.bbox.debug != null) { | ||
| 336 | data.svg.removeChild(data.bbox.debug) | ||
| 337 | data.bbox = null | ||
| 338 | } | ||
| 339 | if (data.symbbox != null && data.symbbox.debug != null) { | ||
| 340 | data.svg.removeChild(data.symbbox.debug) | ||
| 341 | data.symbbox = null | ||
| 342 | } | ||
| 343 | |||
| 344 | window.deleteElementsByClassName(svg, 'cursor') | ||
| 345 | window.deleteElementsByClassName(svg, 'line-1') | ||
| 346 | window.deleteElementsByClassName(svg, 'line-2') | ||
| 347 | window.deleteElementsByClassName(svg, 'line-3') | ||
| 348 | puzzle.clearLines() | ||
| 349 | } | ||
| 350 | |||
| 351 | // This copy is an exact copy of puzzle.getSymmetricalDir, except that it uses MOVE_* values instead of strings | ||
| 352 | function getSymmetricalDir(puzzle, dir) { | ||
| 353 | if (puzzle.symmetry != null) { | ||
| 354 | if (puzzle.symmetry.x === true) { | ||
| 355 | if (dir === MOVE_LEFT) return MOVE_RIGHT | ||
| 356 | if (dir === MOVE_RIGHT) return MOVE_LEFT | ||
| 357 | } | ||
| 358 | if (puzzle.symmetry.y === true) { | ||
| 359 | if (dir === MOVE_TOP) return MOVE_BOTTOM | ||
| 360 | if (dir === MOVE_BOTTOM) return MOVE_TOP | ||
| 361 | } | ||
| 362 | } | ||
| 363 | return dir | ||
| 364 | } | ||
| 365 | |||
| 366 | window.trace = function(event, puzzle, pos, start, symStart=null) { | ||
| 367 | /*if (data.start == null) {*/ | ||
| 368 | if (data.tracing !== true) { // could be undefined or false | ||
| 369 | var svg = start.parentElement | ||
| 370 | data.tracing = true | ||
| 371 | window.PLAY_SOUND('start') | ||
| 372 | // Cleans drawn lines & puzzle state | ||
| 373 | clearGrid(svg, puzzle) | ||
| 374 | onTraceStart(puzzle, pos, svg, start, symStart) | ||
| 375 | data.animations.insertRule('.' + svg.id + '.start {animation: 150ms 1 forwards start-grow}\n') | ||
| 376 | |||
| 377 | hookMovementEvents(start) | ||
| 378 | } else { | ||
| 379 | event.stopPropagation() | ||
| 380 | // Signal the onMouseMove to stop accepting input (race condition) | ||
| 381 | data.tracing = false | ||
| 382 | |||
| 383 | // At endpoint and in main box | ||
| 384 | var cell = puzzle.getCell(data.pos.x, data.pos.y) | ||
| 385 | if (cell.end != null && data.bbox.inMain(data.x, data.y)) { | ||
| 386 | data.cursor.onpointerdown = null | ||
| 387 | setTimeout(function() { // Run validation asynchronously so we can free the pointer immediately. | ||
| 388 | puzzle.endPoint = data.pos | ||
| 389 | var puzzleData = window.validate(puzzle, false) // We want all invalid elements so we can show the user. | ||
| 390 | |||
| 391 | for (var negation of puzzleData.negations) { | ||
| 392 | console.debug('Rendering negation', negation) | ||
| 393 | data.animations.insertRule('.' + data.svg.id + '_' + negation.source.x + '_' + negation.source.y + ' {animation: 0.75s 1 forwards fade}\n') | ||
| 394 | data.animations.insertRule('.' + data.svg.id + '_' + negation.target.x + '_' + negation.target.y + ' {animation: 0.75s 1 forwards fade}\n') | ||
| 395 | } | ||
| 396 | |||
| 397 | if (puzzleData.valid()) { | ||
| 398 | window.PLAY_SOUND('success') | ||
| 399 | // !important to override the child animation | ||
| 400 | data.animations.insertRule('.' + data.svg.id + ' {animation: 1s 1 forwards line-success !important}\n') | ||
| 401 | |||
| 402 | // Convert the traced path into something suitable for solve.drawPath (for publishing purposes) | ||
| 403 | var rawPath = [puzzle.startPoint] | ||
| 404 | for (var i=1; i<data.path.length; i++) rawPath.push(data.path[i].dir) | ||
| 405 | rawPath.push(0) | ||
| 406 | |||
| 407 | if (window.TRACE_COMPLETION_FUNC) window.TRACE_COMPLETION_FUNC(puzzle, rawPath) | ||
| 408 | } else { | ||
| 409 | window.PLAY_SOUND('fail') | ||
| 410 | data.animations.insertRule('.' + data.svg.id + ' {animation: 1s 1 forwards line-fail !important}\n') | ||
| 411 | // Get list of invalid elements | ||
| 412 | if (puzzle.settings.FLASH_FOR_ERRORS) { | ||
| 413 | for (var invalidElement of puzzleData.invalidElements) { | ||
| 414 | data.animations.insertRule('.' + data.svg.id + '_' + invalidElement.x + '_' + invalidElement.y + ' {animation: 0.4s 20 alternate-reverse error}\n') | ||
| 415 | } | ||
| 416 | } | ||
| 417 | } | ||
| 418 | }, 1) | ||
| 419 | |||
| 420 | // Right-clicked (or double-tapped) and not at the end: Clear puzzle | ||
| 421 | } else if (event.isRightClick()) { | ||
| 422 | window.PLAY_SOUND('abort') | ||
| 423 | clearGrid(data.svg, puzzle) | ||
| 424 | } else { // Exit lock but allow resuming from the cursor (Desktop only) | ||
| 425 | data.cursor.onpointerdown = function() { | ||
| 426 | if (start.parentElement !== data.svg) return // Another puzzle is live, so data is gone | ||
| 427 | data.tracing = true | ||
| 428 | hookMovementEvents(start) | ||
| 429 | } | ||
| 430 | } | ||
| 431 | |||
| 432 | unhookMovementEvents() | ||
| 433 | } | ||
| 434 | } | ||
| 435 | |||
| 436 | window.clearAnimations = function() { | ||
| 437 | if (data.animations == null) return | ||
| 438 | for (var i=0; i<data.animations.cssRules.length; i++) { | ||
| 439 | var rule = data.animations.cssRules[i] | ||
| 440 | if (rule.selectorText != null && rule.selectorText.startsWith('.' + data.svg.id)) { | ||
| 441 | data.animations.deleteRule(i--) | ||
| 442 | } | ||
| 443 | } | ||
| 444 | } | ||
| 445 | |||
| 446 | window.onTraceStart = function(puzzle, pos, svg, start, symStart=null) { | ||
| 447 | var x = parseFloat(start.getAttribute('cx')) | ||
| 448 | var y = parseFloat(start.getAttribute('cy')) | ||
| 449 | |||
| 450 | var cursor = createElement('circle') | ||
| 451 | cursor.setAttribute('r', 12) | ||
| 452 | cursor.setAttribute('fill', window.CURSOR) | ||
| 453 | cursor.setAttribute('stroke', 'black') | ||
| 454 | cursor.setAttribute('stroke-width', '2px') | ||
| 455 | cursor.setAttribute('stroke-opacity', '0.4') | ||
| 456 | cursor.setAttribute('class', 'cursor') | ||
| 457 | cursor.setAttribute('cx', x) | ||
| 458 | cursor.setAttribute('cy', y) | ||
| 459 | svg.insertBefore(cursor, svg.getElementById('cursorPos')) | ||
| 460 | |||
| 461 | data.svg = svg | ||
| 462 | data.cursor = cursor | ||
| 463 | data.x = x | ||
| 464 | data.y = y | ||
| 465 | data.pos = pos | ||
| 466 | data.sym = puzzle.getSymmetricalPos(pos.x, pos.y) | ||
| 467 | data.puzzle = puzzle | ||
| 468 | data.path = [] | ||
| 469 | puzzle.startPoint = {'x': pos.x, 'y': pos.y} | ||
| 470 | |||
| 471 | if (pos.x % 2 === 1) { // Start point is on a horizontal segment | ||
| 472 | data.bbox = new BoundingBox(x - 29, x + 29, y - 12, y + 12) | ||
| 473 | } else if (pos.y % 2 === 1) { // Start point is on a vertical segment | ||
| 474 | data.bbox = new BoundingBox(x - 12, x + 12, y - 29, y + 29) | ||
| 475 | } else { // Start point is at an intersection | ||
| 476 | data.bbox = new BoundingBox(x - 12, x + 12, y - 12, y + 12) | ||
| 477 | } | ||
| 478 | |||
| 479 | for (var styleSheet of document.styleSheets) { | ||
| 480 | if (styleSheet.title === 'animations') { | ||
| 481 | data.animations = styleSheet | ||
| 482 | break | ||
| 483 | } | ||
| 484 | } | ||
| 485 | |||
| 486 | clearAnimations() | ||
| 487 | |||
| 488 | // Add initial line segments + secondary symmetry cursor, if needed | ||
| 489 | if (puzzle.symmetry == null) { | ||
| 490 | data.puzzle.updateCell2(data.pos.x, data.pos.y, 'type', 'line') | ||
| 491 | data.puzzle.updateCell2(data.pos.x, data.pos.y, 'line', window.LINE_BLACK) | ||
| 492 | } else { | ||
| 493 | data.puzzle.updateCell2(data.pos.x, data.pos.y, 'type', 'line') | ||
| 494 | data.puzzle.updateCell2(data.pos.x, data.pos.y, 'line', window.LINE_BLUE) | ||
| 495 | data.puzzle.updateCell2(data.sym.x, data.sym.y, 'type', 'line') | ||
| 496 | data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_YELLOW) | ||
| 497 | |||
| 498 | var dx = parseFloat(symStart.getAttribute('cx')) - data.x | ||
| 499 | var dy = parseFloat(symStart.getAttribute('cy')) - data.y | ||
| 500 | data.symbbox = new BoundingBox( | ||
| 501 | data.bbox.raw.x1 + dx, | ||
| 502 | data.bbox.raw.x2 + dx, | ||
| 503 | data.bbox.raw.y1 + dy, | ||
| 504 | data.bbox.raw.y2 + dy, | ||
| 505 | sym = true) | ||
| 506 | |||
| 507 | data.symcursor = createElement('circle') | ||
| 508 | svg.insertBefore(data.symcursor, data.cursor) | ||
| 509 | data.symcursor.setAttribute('class', 'line-3 ' + data.svg.id) | ||
| 510 | data.symcursor.setAttribute('cx', symStart.getAttribute('cx')) | ||
| 511 | data.symcursor.setAttribute('cy', symStart.getAttribute('cy')) | ||
| 512 | data.symcursor.setAttribute('r', 12) | ||
| 513 | } | ||
| 514 | |||
| 515 | // Fixup: Mark out of bounds cells as null, setting inbounds cells as {} | ||
| 516 | // This allows tracing to correctly identify inbounds cells (and thus interior walls) and correctly handle exterior walls for oddly shaped puzzles. | ||
| 517 | { | ||
| 518 | var savedGrid = data.puzzle.switchToMaskedGrid() | ||
| 519 | var maskedGrid = data.puzzle.grid | ||
| 520 | data.puzzle.grid = savedGrid | ||
| 521 | |||
| 522 | for (var x=1; x<data.puzzle.width; x+=2) { | ||
| 523 | for (var y=1; y<data.puzzle.height; y+=2) { | ||
| 524 | if (maskedGrid[x][y] == null) { // null == MASKED_OOB | ||
| 525 | data.puzzle.grid[x][y] = null | ||
| 526 | } else if (data.puzzle.grid[x][y] == null) { | ||
| 527 | data.puzzle.grid[x][y] = {'type':'nonce'} | ||
| 528 | } | ||
| 529 | } | ||
| 530 | } | ||
| 531 | } | ||
| 532 | data.path.push(new PathSegment(MOVE_NONE)) // Must be created after initializing data.symbbox | ||
| 533 | } | ||
| 534 | |||
| 535 | // In case the user exit the pointer lock via another means (clicking outside the window, hitting esc, etc) | ||
| 536 | // we still need to disengage our tracing hooks. | ||
| 537 | document.onpointerlockchange = function() { | ||
| 538 | if (document.pointerLockElement == null) unhookMovementEvents() | ||
| 539 | } | ||
| 540 | |||
| 541 | function unhookMovementEvents() { | ||
| 542 | data.start = null | ||
| 543 | document.onmousemove = null | ||
| 544 | document.ontouchstart = null | ||
| 545 | document.ontouchmove = null | ||
| 546 | document.ontouchend = null | ||
| 547 | if (document.exitPointerLock != null) document.exitPointerLock() | ||
| 548 | if (document.mozExitPointerLock != null) document.mozExitPointerLock() | ||
| 549 | } | ||
| 550 | |||
| 551 | function hookMovementEvents(start) { | ||
| 552 | data.start = start | ||
| 553 | if (start.requestPointerLock != null) start.requestPointerLock() | ||
| 554 | if (start.mozRequestPointerLock != null) start.mozRequestPointerLock() | ||
| 555 | |||
| 556 | var sens = parseFloat(document.getElementById('sens').value) | ||
| 557 | document.onmousemove = function(event) { | ||
| 558 | // Working around a race condition where movement events fire after the handler is removed. | ||
| 559 | if (data.tracing !== true) return | ||
| 560 | // Prevent accidental fires on mobile platforms (ios and android). They will be handled via ontouchmove instead. | ||
| 561 | if (event.movementX == null) return | ||
| 562 | onMove(sens * event.movementX, sens * event.movementY) | ||
| 563 | } | ||
| 564 | document.ontouchstart = function(event) { | ||
| 565 | if (event.touches.length > 1) { | ||
| 566 | // Stop tracing for two+ finger touches (the equivalent of a right click on desktop) | ||
| 567 | window.trace(event, data.puzzle, null, null, null) | ||
| 568 | return | ||
| 569 | } | ||
| 570 | data.lastTouchPos = event.position | ||
| 571 | } | ||
| 572 | document.ontouchmove = function(event) { | ||
| 573 | if (data.tracing !== true) return | ||
| 574 | |||
| 575 | var eventIsWithinPuzzle = false | ||
| 576 | for (var node = event.target; node != null; node = node.parentElement) { | ||
| 577 | if (node == data.svg) { | ||
| 578 | eventIsWithinPuzzle = true | ||
| 579 | break | ||
| 580 | } | ||
| 581 | } | ||
| 582 | if (!eventIsWithinPuzzle) return // Ignore drag events that aren't within the puzzle | ||
| 583 | event.preventDefault() // Prevent accidental scrolling if the touch event is within the puzzle. | ||
| 584 | |||
| 585 | var newPos = event.position | ||
| 586 | onMove(newPos.x - data.lastTouchPos.x, newPos.y - data.lastTouchPos.y) | ||
| 587 | data.lastTouchPos = newPos | ||
| 588 | } | ||
| 589 | document.ontouchend = function(event) { | ||
| 590 | data.lastTouchPos = null | ||
| 591 | // Only call window.trace (to stop tracing) if we're really in an endpoint. | ||
| 592 | var cell = data.puzzle.getCell(data.pos.x, data.pos.y) | ||
| 593 | if (cell.end != null && data.bbox.inMain(data.x, data.y)) { | ||
| 594 | window.trace(event, data.puzzle, null, null, null) | ||
| 595 | } | ||
| 596 | } | ||
| 597 | } | ||
| 598 | |||
| 599 | // @Volatile -- must match order of PATH_* in solve | ||
| 600 | var MOVE_NONE = 0 | ||
| 601 | var MOVE_LEFT = 1 | ||
| 602 | var MOVE_RIGHT = 2 | ||
| 603 | var MOVE_TOP = 3 | ||
| 604 | var MOVE_BOTTOM = 4 | ||
| 605 | |||
| 606 | window.onMove = function(dx, dy) { | ||
| 607 | { | ||
| 608 | // Also handles some collision | ||
| 609 | var collidedWith = pushCursor(dx, dy) | ||
| 610 | console.spam('Collided with', collidedWith) | ||
| 611 | } | ||
| 612 | |||
| 613 | while (true) { | ||
| 614 | hardCollision() | ||
| 615 | |||
| 616 | // Potentially move the location to a new cell, and make absolute boundary checks | ||
| 617 | var moveDir = move() | ||
| 618 | data.path[data.path.length - 1].redraw() | ||
| 619 | if (moveDir === MOVE_NONE) break | ||
| 620 | console.debug('Moved', ['none', 'left', 'right', 'top', 'bottom'][moveDir]) | ||
| 621 | |||
| 622 | // Potentially adjust data.x/data.y if our position went around a pillar | ||
| 623 | if (data.puzzle.pillar === true) pillarWrap(moveDir) | ||
| 624 | |||
| 625 | var lastDir = data.path[data.path.length - 1].dir | ||
| 626 | var backedUp = ((moveDir === MOVE_LEFT && lastDir === MOVE_RIGHT) | ||
| 627 | || (moveDir === MOVE_RIGHT && lastDir === MOVE_LEFT) | ||
| 628 | || (moveDir === MOVE_TOP && lastDir === MOVE_BOTTOM) | ||
| 629 | || (moveDir === MOVE_BOTTOM && lastDir === MOVE_TOP)) | ||
| 630 | |||
| 631 | if (data.puzzle.symmetry != null) { | ||
| 632 | var symMoveDir = getSymmetricalDir(data.puzzle, moveDir) | ||
| 633 | } | ||
| 634 | |||
| 635 | // If we backed up, remove a path segment and mark the old cell as unvisited | ||
| 636 | if (backedUp) { | ||
| 637 | data.path.pop().destroy() | ||
| 638 | data.puzzle.updateCell2(data.pos.x, data.pos.y, 'line', window.LINE_NONE) | ||
| 639 | if (data.puzzle.symmetry != null) { | ||
| 640 | data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_NONE) | ||
| 641 | } | ||
| 642 | } | ||
| 643 | |||
| 644 | // Move to the next cell | ||
| 645 | changePos(data.bbox, data.pos, moveDir) | ||
| 646 | if (data.puzzle.symmetry != null) { | ||
| 647 | changePos(data.symbbox, data.sym, symMoveDir) | ||
| 648 | } | ||
| 649 | |||
| 650 | // If we didn't back up, add a path segment and mark the new cell as visited | ||
| 651 | if (!backedUp) { | ||
| 652 | data.path.push(new PathSegment(moveDir)) | ||
| 653 | if (data.puzzle.symmetry == null) { | ||
| 654 | data.puzzle.updateCell2(data.pos.x, data.pos.y, 'line', window.LINE_BLACK) | ||
| 655 | } else { | ||
| 656 | data.puzzle.updateCell2(data.pos.x, data.pos.y, 'line', window.LINE_BLUE) | ||
| 657 | data.puzzle.updateCell2(data.sym.x, data.sym.y, 'line', window.LINE_YELLOW) | ||
| 658 | } | ||
| 659 | } | ||
| 660 | } | ||
| 661 | } | ||
| 662 | |||
| 663 | // Helper function for pushCursor. Used to determine the direction and magnitude of redirection. | ||
| 664 | function push(dx, dy, dir, targetDir) { | ||
| 665 | // Fraction of movement to redirect in the other direction | ||
| 666 | var movementRatio = null | ||
| 667 | if (targetDir === 'left' || targetDir === 'top') { | ||
| 668 | movementRatio = -3 | ||
| 669 | } else if (targetDir === 'right' || targetDir === 'bottom') { | ||
| 670 | movementRatio = 3 | ||
| 671 | } | ||
| 672 | if (window.settings.disablePushing === true) movementRatio *= 1000 | ||
| 673 | |||
| 674 | if (dir === 'left') { | ||
| 675 | var overshoot = data.bbox.x1 - (data.x + dx) + 12 | ||
| 676 | if (overshoot > 0) { | ||
| 677 | data.y += dy + overshoot / movementRatio | ||
| 678 | data.x = data.bbox.x1 + 12 | ||
| 679 | return true | ||
| 680 | } | ||
| 681 | } else if (dir === 'right') { | ||
| 682 | var overshoot = (data.x + dx) - data.bbox.x2 + 12 | ||
| 683 | if (overshoot > 0) { | ||
| 684 | data.y += dy + overshoot / movementRatio | ||
| 685 | data.x = data.bbox.x2 - 12 | ||
| 686 | return true | ||
| 687 | } | ||
| 688 | } else if (dir === 'leftright') { | ||
| 689 | data.y += dy + Math.abs(dx) / movementRatio | ||
| 690 | return true | ||
| 691 | } else if (dir === 'top') { | ||
| 692 | var overshoot = data.bbox.y1 - (data.y + dy) + 12 | ||
| 693 | if (overshoot > 0) { | ||
| 694 | data.x += dx + overshoot / movementRatio | ||
| 695 | data.y = data.bbox.y1 + 12 | ||
| 696 | return true | ||
| 697 | } | ||
| 698 | } else if (dir === 'bottom') { | ||
| 699 | var overshoot = (data.y + dy) - data.bbox.y2 + 12 | ||
| 700 | if (overshoot > 0) { | ||
| 701 | data.x += dx + overshoot / movementRatio | ||
| 702 | data.y = data.bbox.y2 - 12 | ||
| 703 | return true | ||
| 704 | } | ||
| 705 | } else if (dir === 'topbottom') { | ||
| 706 | data.x += dx + Math.abs(dy) / movementRatio | ||
| 707 | return true | ||
| 708 | } | ||
| 709 | return false | ||
| 710 | } | ||
| 711 | |||
| 712 | // Redirect momentum from pushing against walls, so that all further moment steps | ||
| 713 | // will be strictly linear. Returns a string for logging purposes only. | ||
| 714 | function pushCursor(dx, dy) { | ||
| 715 | // Outer wall collision | ||
| 716 | var cell = data.puzzle.getCell(data.pos.x, data.pos.y) | ||
| 717 | if (cell == null) return 'nothing' | ||
| 718 | |||
| 719 | // Only consider non-endpoints or endpoints which are parallel | ||
| 720 | if ([undefined, 'top', 'bottom'].includes(cell.end)) { | ||
| 721 | var leftCell = data.puzzle.getCell(data.pos.x - 1, data.pos.y) | ||
| 722 | if (leftCell == null || leftCell.gap === window.GAP_FULL) { | ||
| 723 | if (push(dx, dy, 'left', 'top')) return 'left outer wall' | ||
| 724 | } | ||
| 725 | var rightCell = data.puzzle.getCell(data.pos.x + 1, data.pos.y) | ||
| 726 | if (rightCell == null || rightCell.gap === window.GAP_FULL) { | ||
| 727 | if (push(dx, dy, 'right', 'top')) return 'right outer wall' | ||
| 728 | } | ||
| 729 | } | ||
| 730 | // Only consider non-endpoints or endpoints which are parallel | ||
| 731 | if ([undefined, 'left', 'right'].includes(cell.end)) { | ||
| 732 | var topCell = data.puzzle.getCell(data.pos.x, data.pos.y - 1) | ||
| 733 | if (topCell == null || topCell.gap === window.GAP_FULL) { | ||
| 734 | if (push(dx, dy, 'top', 'right')) return 'top outer wall' | ||
| 735 | } | ||
| 736 | var bottomCell = data.puzzle.getCell(data.pos.x, data.pos.y + 1) | ||
| 737 | if (bottomCell == null || bottomCell.gap === window.GAP_FULL) { | ||
| 738 | if (push(dx, dy, 'bottom', 'right')) return 'bottom outer wall' | ||
| 739 | } | ||
| 740 | } | ||
| 741 | |||
| 742 | // Inner wall collision | ||
| 743 | if (cell.end == null) { | ||
| 744 | if (data.pos.x%2 === 1 && data.pos.y%2 === 0) { // Horizontal cell | ||
| 745 | if (data.x < data.bbox.middle.x) { | ||
| 746 | push(dx, dy, 'topbottom', 'left') | ||
| 747 | return 'topbottom inner wall, moved left' | ||
| 748 | } else { | ||
| 749 | push(dx, dy, 'topbottom', 'right') | ||
| 750 | return 'topbottom inner wall, moved right' | ||
| 751 | } | ||
| 752 | } else if (data.pos.x%2 === 0 && data.pos.y%2 === 1) { // Vertical cell | ||
| 753 | if (data.y < data.bbox.middle.y) { | ||
| 754 | push(dx, dy, 'leftright', 'top') | ||
| 755 | return 'leftright inner wall, moved up' | ||
| 756 | } else { | ||
| 757 | push(dx, dy, 'leftright', 'bottom') | ||
| 758 | return 'leftright inner wall, moved down' | ||
| 759 | } | ||
| 760 | } | ||
| 761 | } | ||
| 762 | |||
| 763 | // Intersection & endpoint collision | ||
| 764 | // Ratio of movement to be considered turning at an intersection | ||
| 765 | var turnMod = 2 | ||
| 766 | if ((data.pos.x%2 === 0 && data.pos.y%2 === 0) || cell.end != null) { | ||
| 767 | if (data.x < data.bbox.middle.x) { | ||
| 768 | push(dx, dy, 'topbottom', 'right') | ||
| 769 | // Overshot the intersection and appears to be trying to turn | ||
| 770 | if (data.x > data.bbox.middle.x && Math.abs(dy) * turnMod > Math.abs(dx)) { | ||
| 771 | data.y += Math.sign(dy) * (data.x - data.bbox.middle.x) | ||
| 772 | data.x = data.bbox.middle.x | ||
| 773 | return 'overshot moving right' | ||
| 774 | } | ||
| 775 | return 'intersection moving right' | ||
| 776 | } else if (data.x > data.bbox.middle.x) { | ||
| 777 | push(dx, dy, 'topbottom', 'left') | ||
| 778 | // Overshot the intersection and appears to be trying to turn | ||
| 779 | if (data.x < data.bbox.middle.x && Math.abs(dy) * turnMod > Math.abs(dx)) { | ||
| 780 | data.y += Math.sign(dy) * (data.bbox.middle.x - data.x) | ||
| 781 | data.x = data.bbox.middle.x | ||
| 782 | return 'overshot moving left' | ||
| 783 | } | ||
| 784 | return 'intersection moving left' | ||
| 785 | } | ||
| 786 | if (data.y < data.bbox.middle.y) { | ||
| 787 | push(dx, dy, 'leftright', 'bottom') | ||
| 788 | // Overshot the intersection and appears to be trying to turn | ||
| 789 | if (data.y > data.bbox.middle.y && Math.abs(dx) * turnMod > Math.abs(dy)) { | ||
| 790 | data.x += Math.sign(dx) * (data.y - data.bbox.middle.y) | ||
| 791 | data.y = data.bbox.middle.y | ||
| 792 | return 'overshot moving down' | ||
| 793 | } | ||
| 794 | return 'intersection moving down' | ||
| 795 | } else if (data.y > data.bbox.middle.y) { | ||
| 796 | push(dx, dy, 'leftright', 'top') | ||
| 797 | // Overshot the intersection and appears to be trying to turn | ||
| 798 | if (data.y < data.bbox.middle.y && Math.abs(dx) * turnMod > Math.abs(dy)) { | ||
| 799 | data.x += Math.sign(dx) * (data.bbox.middle.y - data.y) | ||
| 800 | data.y = data.bbox.middle.y | ||
| 801 | return 'overshot moving up' | ||
| 802 | } | ||
| 803 | return 'intersection moving up' | ||
| 804 | } | ||
| 805 | } | ||
| 806 | |||
| 807 | // No collision, limit movement to X or Y only to prevent out-of-bounds | ||
| 808 | if (Math.abs(dx) > Math.abs(dy)) { | ||
| 809 | data.x += dx | ||
| 810 | return 'nothing, x' | ||
| 811 | } else { | ||
| 812 | data.y += dy | ||
| 813 | return 'nothing, y' | ||
| 814 | } | ||
| 815 | } | ||
| 816 | |||
| 817 | // Check to see if we collided with any gaps, or with a symmetrical line, or a startpoint. | ||
| 818 | // In any case, abruptly zero momentum. | ||
| 819 | function hardCollision() { | ||
| 820 | var lastDir = data.path[data.path.length - 1].dir | ||
| 821 | var cell = data.puzzle.getCell(data.pos.x, data.pos.y) | ||
| 822 | if (cell == null) return | ||
| 823 | |||
| 824 | var gapSize = 0 | ||
| 825 | if (cell.gap === window.GAP_BREAK) { | ||
| 826 | console.spam('Collided with a gap') | ||
| 827 | gapSize = 21 | ||
| 828 | } else { | ||
| 829 | var nextCell = null | ||
| 830 | if (lastDir === MOVE_LEFT) nextCell = data.puzzle.getCell(data.pos.x - 1, data.pos.y) | ||
| 831 | if (lastDir === MOVE_RIGHT) nextCell = data.puzzle.getCell(data.pos.x + 1, data.pos.y) | ||
| 832 | if (lastDir === MOVE_TOP) nextCell = data.puzzle.getCell(data.pos.x, data.pos.y - 1) | ||
| 833 | if (lastDir === MOVE_BOTTOM) nextCell = data.puzzle.getCell(data.pos.x, data.pos.y + 1) | ||
| 834 | if (nextCell != null && nextCell.start === true && nextCell.line > window.LINE_NONE) { | ||
| 835 | gapSize = -5 | ||
| 836 | } | ||
| 837 | } | ||
| 838 | |||
| 839 | if (data.puzzle.symmetry != null) { | ||
| 840 | if (data.sym.x === data.pos.x && data.sym.y === data.pos.y) { | ||
| 841 | console.spam('Collided with our symmetrical line') | ||
| 842 | gapSize = 13 | ||
| 843 | } else if (data.puzzle.getCell(data.sym.x, data.sym.y).gap === window.GAP_BREAK) { | ||
| 844 | console.spam('Symmetrical line hit a gap') | ||
| 845 | gapSize = 21 | ||
| 846 | } | ||
| 847 | } | ||
| 848 | if (gapSize === 0) return // Didn't collide with anything | ||
| 849 | |||
| 850 | if (lastDir === MOVE_LEFT) { | ||
| 851 | data.x = Math.max(data.bbox.middle.x + gapSize, data.x) | ||
| 852 | } else if (lastDir === MOVE_RIGHT) { | ||
| 853 | data.x = Math.min(data.x, data.bbox.middle.x - gapSize) | ||
| 854 | } else if (lastDir === MOVE_TOP) { | ||
| 855 | data.y = Math.max(data.bbox.middle.y + gapSize, data.y) | ||
| 856 | } else if (lastDir === MOVE_BOTTOM) { | ||
| 857 | data.y = Math.min(data.y, data.bbox.middle.y - gapSize) | ||
| 858 | } | ||
| 859 | } | ||
| 860 | |||
| 861 | // Check to see if we've gone beyond the edge of puzzle cell, and if the next cell is safe, | ||
| 862 | // i.e. not out of bounds. Reports the direction we are going to move (or none), | ||
| 863 | // but does not actually change data.pos | ||
| 864 | function move() { | ||
| 865 | var lastDir = data.path[data.path.length - 1].dir | ||
| 866 | |||
| 867 | if (data.x < data.bbox.x1 + 12) { // Moving left | ||
| 868 | var cell = data.puzzle.getCell(data.pos.x - 1, data.pos.y) | ||
| 869 | if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { | ||
| 870 | console.spam('Collided with outside / gap-2', cell) | ||
| 871 | data.x = data.bbox.x1 + 12 | ||
| 872 | } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_RIGHT) { | ||
| 873 | console.spam('Collided with other line', cell.line) | ||
| 874 | data.x = data.bbox.x1 + 12 | ||
| 875 | } else if (data.puzzle.symmetry != null) { | ||
| 876 | var symCell = data.puzzle.getSymmetricalCell(data.pos.x - 1, data.pos.y) | ||
| 877 | if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { | ||
| 878 | console.spam('Collided with symmetrical outside / gap-2', cell) | ||
| 879 | data.x = data.bbox.x1 + 12 | ||
| 880 | } | ||
| 881 | } | ||
| 882 | if (data.x < data.bbox.x1) { | ||
| 883 | return MOVE_LEFT | ||
| 884 | } | ||
| 885 | } else if (data.x > data.bbox.x2 - 12) { // Moving right | ||
| 886 | var cell = data.puzzle.getCell(data.pos.x + 1, data.pos.y) | ||
| 887 | if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { | ||
| 888 | console.spam('Collided with outside / gap-2', cell) | ||
| 889 | data.x = data.bbox.x2 - 12 | ||
| 890 | } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_LEFT) { | ||
| 891 | console.spam('Collided with other line', cell.line) | ||
| 892 | data.x = data.bbox.x2 - 12 | ||
| 893 | } else if (data.puzzle.symmetry != null) { | ||
| 894 | var symCell = data.puzzle.getSymmetricalCell(data.pos.x + 1, data.pos.y) | ||
| 895 | if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { | ||
| 896 | console.spam('Collided with symmetrical outside / gap-2', cell) | ||
| 897 | data.x = data.bbox.x2 - 12 | ||
| 898 | } | ||
| 899 | } | ||
| 900 | if (data.x > data.bbox.x2) { | ||
| 901 | return MOVE_RIGHT | ||
| 902 | } | ||
| 903 | } else if (data.y < data.bbox.y1 + 12) { // Moving up | ||
| 904 | var cell = data.puzzle.getCell(data.pos.x, data.pos.y - 1) | ||
| 905 | if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { | ||
| 906 | console.spam('Collided with outside / gap-2', cell) | ||
| 907 | data.y = data.bbox.y1 + 12 | ||
| 908 | } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_BOTTOM) { | ||
| 909 | console.spam('Collided with other line', cell.line) | ||
| 910 | data.y = data.bbox.y1 + 12 | ||
| 911 | } else if (data.puzzle.symmetry != null) { | ||
| 912 | var symCell = data.puzzle.getSymmetricalCell(data.pos.x, data.pos.y - 1) | ||
| 913 | if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { | ||
| 914 | console.spam('Collided with symmetrical outside / gap-2', cell) | ||
| 915 | data.y = data.bbox.y1 + 12 | ||
| 916 | } | ||
| 917 | } | ||
| 918 | if (data.y < data.bbox.y1) { | ||
| 919 | return MOVE_TOP | ||
| 920 | } | ||
| 921 | } else if (data.y > data.bbox.y2 - 12) { // Moving down | ||
| 922 | var cell = data.puzzle.getCell(data.pos.x, data.pos.y + 1) | ||
| 923 | if (cell == null || cell.type !== 'line' || cell.gap === window.GAP_FULL) { | ||
| 924 | console.spam('Collided with outside / gap-2') | ||
| 925 | data.y = data.bbox.y2 - 12 | ||
| 926 | } else if (cell.line > window.LINE_NONE && lastDir !== MOVE_TOP) { | ||
| 927 | console.spam('Collided with other line', cell.line) | ||
| 928 | data.y = data.bbox.y2 - 12 | ||
| 929 | } else if (data.puzzle.symmetry != null) { | ||
| 930 | var symCell = data.puzzle.getSymmetricalCell(data.pos.x, data.pos.y + 1) | ||
| 931 | if (symCell == null || symCell.type !== 'line' || symCell.gap === window.GAP_FULL) { | ||
| 932 | console.spam('Collided with symmetrical outside / gap-2', cell) | ||
| 933 | data.y = data.bbox.y2 - 12 | ||
| 934 | } | ||
| 935 | } | ||
| 936 | if (data.y > data.bbox.y2) { | ||
| 937 | return MOVE_BOTTOM | ||
| 938 | } | ||
| 939 | } | ||
| 940 | return MOVE_NONE | ||
| 941 | } | ||
| 942 | |||
| 943 | // Check to see if you moved beyond the edge of a pillar. | ||
| 944 | // If so, wrap the cursor x to preserve momentum. | ||
| 945 | // Note that this still does not change the position. | ||
| 946 | function pillarWrap(moveDir) { | ||
| 947 | if (moveDir === MOVE_LEFT && data.pos.x === 0) { | ||
| 948 | data.x += data.puzzle.width * 41 | ||
| 949 | } | ||
| 950 | if (moveDir === MOVE_RIGHT && data.pos.x === data.puzzle.width - 1) { | ||
| 951 | data.x -= data.puzzle.width * 41 | ||
| 952 | } | ||
| 953 | } | ||
| 954 | |||
| 955 | // Actually change the data position. (Note that this takes in pos to allow easier symmetry). | ||
| 956 | // Note that this doesn't zero the momentum, so that we can adjust appropriately on further loops. | ||
| 957 | // This function also shifts the bounding box that we use to determine the bounds of the cell. | ||
| 958 | function changePos(bbox, pos, moveDir) { | ||
| 959 | if (moveDir === MOVE_LEFT) { | ||
| 960 | pos.x-- | ||
| 961 | // Wrap around the left | ||
| 962 | if (data.puzzle.pillar === true && pos.x < 0) { | ||
| 963 | pos.x += data.puzzle.width | ||
| 964 | bbox.shift('right', data.puzzle.width * 41 - 82) | ||
| 965 | bbox.shift('right', 58) | ||
| 966 | } else { | ||
| 967 | bbox.shift('left', (pos.x%2 === 0 ? 24 : 58)) | ||
| 968 | } | ||
| 969 | } else if (moveDir === MOVE_RIGHT) { | ||
| 970 | pos.x++ | ||
| 971 | // Wrap around to the right | ||
| 972 | if (data.puzzle.pillar === true && pos.x >= data.puzzle.width) { | ||
| 973 | pos.x -= data.puzzle.width | ||
| 974 | bbox.shift('left', data.puzzle.width * 41 - 82) | ||
| 975 | bbox.shift('left', 24) | ||
| 976 | } else { | ||
| 977 | bbox.shift('right', (pos.x%2 === 0 ? 24 : 58)) | ||
| 978 | } | ||
| 979 | } else if (moveDir === MOVE_TOP) { | ||
| 980 | pos.y-- | ||
| 981 | bbox.shift('top', (pos.y%2 === 0 ? 24 : 58)) | ||
| 982 | } else if (moveDir === MOVE_BOTTOM) { | ||
| 983 | pos.y++ | ||
| 984 | bbox.shift('bottom', (pos.y%2 === 0 ? 24 : 58)) | ||
| 985 | } | ||
| 986 | } | ||
| 987 | |||
| 988 | }) | ||
| diff --git a/app/assets/javascripts/wittle/utilities.js.erb b/app/assets/javascripts/wittle/utilities.js.erb new file mode 100644 index 0000000..406adda --- /dev/null +++ b/app/assets/javascripts/wittle/utilities.js.erb | |||
| @@ -0,0 +1,697 @@ | |||
| 1 | function namespace(code) { | ||
| 2 | code() | ||
| 3 | } | ||
| 4 | |||
| 5 | namespace(function() { | ||
| 6 | |||
| 7 | /*** Start cross-compatibility ***/ | ||
| 8 | // Used to detect if IDs include a direction, e.g. resize-top-left | ||
| 9 | if (!String.prototype.includes) { | ||
| 10 | String.prototype.includes = function() { | ||
| 11 | return String.prototype.indexOf.apply(this, arguments) !== -1 | ||
| 12 | } | ||
| 13 | } | ||
| 14 | Event.prototype.movementX = Event.prototype.movementX || Event.prototype.mozMovementX | ||
| 15 | Event.prototype.movementY = Event.prototype.movementY || Event.prototype.mozMovementY | ||
| 16 | Event.prototype.isRightClick = function() { | ||
| 17 | return this.which === 3 || (this.touches && this.touches.length > 1) | ||
| 18 | } | ||
| 19 | Element.prototype.disable = function() { | ||
| 20 | this.disabled = true | ||
| 21 | this.style.pointerEvents = 'none' | ||
| 22 | this.className = 'noselect' | ||
| 23 | } | ||
| 24 | Element.prototype.enable = function() { | ||
| 25 | this.disabled = false | ||
| 26 | this.style.pointerEvents = null | ||
| 27 | this.className = null | ||
| 28 | } | ||
| 29 | Object.defineProperty(Event.prototype, 'position', { | ||
| 30 | 'get': function() { | ||
| 31 | return { | ||
| 32 | 'x': event.pageX || event.clientX || (event.touches && event.touches[0].pageX) || null, | ||
| 33 | 'y': event.pageY || event.clientY || (event.touches && event.touches[0].pageY) || null, | ||
| 34 | } | ||
| 35 | } | ||
| 36 | }) | ||
| 37 | /*** End cross-compatibility ***/ | ||
| 38 | |||
| 39 | var proxy = { | ||
| 40 | 'get': function(_, key) { | ||
| 41 | try { | ||
| 42 | return this._map[key] | ||
| 43 | } catch (e) { | ||
| 44 | return null | ||
| 45 | } | ||
| 46 | }, | ||
| 47 | 'set': function(_, key, value) { | ||
| 48 | if (value == null) { | ||
| 49 | delete this._map[key] | ||
| 50 | } else { | ||
| 51 | this._map[key] = value.toString() | ||
| 52 | window.localStorage.setItem('settings', JSON.stringify(this._map)) | ||
| 53 | } | ||
| 54 | }, | ||
| 55 | 'init': function() { | ||
| 56 | this._map = {} | ||
| 57 | try { | ||
| 58 | var j = window.localStorage.getItem('settings') | ||
| 59 | if (j != null) this._map = JSON.parse(j) | ||
| 60 | } catch (e) {/* Do nothing */} | ||
| 61 | |||
| 62 | function setIfNull(map, key, value) { | ||
| 63 | if (map[key] == null) map[key] = value | ||
| 64 | } | ||
| 65 | |||
| 66 | // Set any values which are not defined | ||
| 67 | setIfNull(this._map, 'theme', 'light') | ||
| 68 | setIfNull(this._map, 'volume', '0.12') | ||
| 69 | setIfNull(this._map, 'sensitivity', '0.7') | ||
| 70 | setIfNull(this._map, 'expanded', 'false') | ||
| 71 | setIfNull(this._map, 'customMechanics', 'false') | ||
| 72 | return this | ||
| 73 | }, | ||
| 74 | } | ||
| 75 | window.settings = new Proxy({}, proxy.init()) | ||
| 76 | |||
| 77 | var tracks = { | ||
| 78 | 'start': new Audio(src = '<%= asset_url("wittle/panel_start_tracing.aac") %>'), | ||
| 79 | 'success': new Audio(src = '<%= asset_url("wittle/panel_success.aac") %>'), | ||
| 80 | 'fail': new Audio(src = '<%= asset_url("wittle/panel_failure.aac") %>'), | ||
| 81 | 'abort': new Audio(src = '<%= asset_url("wittle/panel_abort_tracing.aac") %>'), | ||
| 82 | } | ||
| 83 | |||
| 84 | var currentAudio = null | ||
| 85 | window.PLAY_SOUND = function(name) { | ||
| 86 | if (currentAudio) currentAudio.pause() | ||
| 87 | var audio = tracks[name] | ||
| 88 | audio.load() | ||
| 89 | audio.volume = parseFloat(window.settings.volume) | ||
| 90 | audio.play().then(function() { | ||
| 91 | currentAudio = audio | ||
| 92 | }).catch(function() { | ||
| 93 | // Do nothing. | ||
| 94 | }) | ||
| 95 | } | ||
| 96 | |||
| 97 | window.LINE_PRIMARY = '#8FF' | ||
| 98 | window.LINE_SECONDARY = '#FF2' | ||
| 99 | |||
| 100 | if (window.settings.theme == 'night') { | ||
| 101 | window.BACKGROUND = '#221' | ||
| 102 | window.OUTER_BACKGROUND = '#070704' | ||
| 103 | window.FOREGROUND = '#751' | ||
| 104 | window.BORDER = '#666' | ||
| 105 | window.LINE_DEFAULT = '#888' | ||
| 106 | window.LINE_SUCCESS = '#BBB' | ||
| 107 | window.LINE_FAIL = '#000' | ||
| 108 | window.CURSOR = '#FFF' | ||
| 109 | window.TEXT_COLOR = '#AAA' | ||
| 110 | window.PAGE_BACKGROUND = '#000' | ||
| 111 | window.ALT_BACKGROUND = '#333' // An off-black. Good for mild contrast. | ||
| 112 | window.ACTIVE_COLOR = '#555' // Color for 'while the element is being pressed' | ||
| 113 | } else if (window.settings.theme == 'light') { | ||
| 114 | window.BACKGROUND = '#0A8' | ||
| 115 | window.OUTER_BACKGROUND = '#113833' | ||
| 116 | window.FOREGROUND = '#344' | ||
| 117 | window.BORDER = '#000' | ||
| 118 | window.LINE_DEFAULT = '#AAA' | ||
| 119 | window.LINE_SUCCESS = '#FFF' | ||
| 120 | window.LINE_FAIL = '#000' | ||
| 121 | window.CURSOR = '#FFF' | ||
| 122 | window.TEXT_COLOR = '#000' | ||
| 123 | window.PAGE_BACKGROUND = '#FFF' | ||
| 124 | window.ALT_BACKGROUND = '#EEE' // An off-white. Good for mild contrast. | ||
| 125 | window.ACTIVE_COLOR = '#DDD' // Color for 'while the element is being pressed' | ||
| 126 | } | ||
| 127 | |||
| 128 | window.LINE_NONE = 0 | ||
| 129 | window.LINE_BLACK = 1 | ||
| 130 | window.LINE_BLUE = 2 | ||
| 131 | window.LINE_YELLOW = 3 | ||
| 132 | window.DOT_NONE = 0 | ||
| 133 | window.DOT_BLACK = 1 | ||
| 134 | window.DOT_BLUE = 2 | ||
| 135 | window.DOT_YELLOW = 3 | ||
| 136 | window.DOT_INVISIBLE = 4 | ||
| 137 | window.GAP_NONE = 0 | ||
| 138 | window.GAP_BREAK = 1 | ||
| 139 | window.GAP_FULL = 2 | ||
| 140 | |||
| 141 | var animations = '' | ||
| 142 | var l = function(line) {animations += line + '\n'} | ||
| 143 | // pointer-events: none; allows for events to bubble up (so that editor hooks still work) | ||
| 144 | l('.line-1 {') | ||
| 145 | l(' fill: ' + window.LINE_DEFAULT + ';') | ||
| 146 | l(' pointer-events: none;') | ||
| 147 | l('}') | ||
| 148 | l('.line-2 {') | ||
| 149 | l(' fill: ' + window.LINE_PRIMARY + ';') | ||
| 150 | l(' pointer-events: none;') | ||
| 151 | l('}') | ||
| 152 | l('.line-3 {') | ||
| 153 | l(' fill: ' + window.LINE_SECONDARY + ';') | ||
| 154 | l(' pointer-events: none;') | ||
| 155 | l('}') | ||
| 156 | l('@keyframes line-success {to {fill: ' + window.LINE_SUCCESS + ';}}') | ||
| 157 | l('@keyframes line-fail {to {fill: ' + window.LINE_FAIL + ';}}') | ||
| 158 | l('@keyframes error {to {fill: red;}}') | ||
| 159 | l('@keyframes fade {to {opacity: 0.35;}}') | ||
| 160 | l('@keyframes start-grow {from {r:12;} to {r:24;}}') | ||
| 161 | // Neutral button style | ||
| 162 | l('button {') | ||
| 163 | l(' background-color: ' + window.ALT_BACKGROUND + ';') | ||
| 164 | l(' border: 1px solid ' + window.BORDER + ';') | ||
| 165 | l(' border-radius: 2px;') | ||
| 166 | l(' color: ' + window.TEXT_COLOR + ';') | ||
| 167 | l(' display: inline-block;') | ||
| 168 | l(' margin: 0px;') | ||
| 169 | l(' outline: none;') | ||
| 170 | l(' opacity: 1.0;') | ||
| 171 | l(' padding: 1px 6px;') | ||
| 172 | l(' -moz-appearance: none;') | ||
| 173 | l(' -webkit-appearance: none;') | ||
| 174 | l('}') | ||
| 175 | // Active (while held down) button style | ||
| 176 | l('button:active {background-color: ' + window.ACTIVE_COLOR + ';}') | ||
| 177 | // Disabled button style | ||
| 178 | l('button:disabled {opacity: 0.5;}') | ||
| 179 | // Selected button style (see https://stackoverflow.com/a/63108630) | ||
| 180 | l('button:focus {outline: none;}') | ||
| 181 | l = null | ||
| 182 | |||
| 183 | var style = document.createElement('style') | ||
| 184 | style.type = 'text/css' | ||
| 185 | style.title = 'animations' | ||
| 186 | style.appendChild(document.createTextNode(animations)) | ||
| 187 | document.head.appendChild(style) | ||
| 188 | |||
| 189 | // Custom logging to allow leveling | ||
| 190 | var consoleError = console.error | ||
| 191 | var consoleWarn = console.warn | ||
| 192 | var consoleInfo = console.log | ||
| 193 | var consoleLog = console.log | ||
| 194 | var consoleDebug = console.log | ||
| 195 | var consoleSpam = console.log | ||
| 196 | var consoleGroup = console.group | ||
| 197 | var consoleGroupEnd = console.groupEnd | ||
| 198 | |||
| 199 | window.setLogLevel = function(level) { | ||
| 200 | console.error = function() {} | ||
| 201 | console.warn = function() {} | ||
| 202 | console.info = function() {} | ||
| 203 | console.log = function() {} | ||
| 204 | console.debug = function() {} | ||
| 205 | console.spam = function() {} | ||
| 206 | console.group = function() {} | ||
| 207 | console.groupEnd = function() {} | ||
| 208 | |||
| 209 | if (level === 'none') return | ||
| 210 | |||
| 211 | // Instead of throw, but still red flags and is easy to find | ||
| 212 | console.error = consoleError | ||
| 213 | if (level === 'error') return | ||
| 214 | |||
| 215 | // Less serious than error, but flagged nonetheless | ||
| 216 | console.warn = consoleWarn | ||
| 217 | if (level === 'warn') return | ||
| 218 | |||
| 219 | // Default visible, important information | ||
| 220 | console.info = consoleInfo | ||
| 221 | if (level === 'info') return | ||
| 222 | |||
| 223 | // Useful for debugging (mainly validation) | ||
| 224 | console.log = consoleLog | ||
| 225 | if (level === 'log') return | ||
| 226 | |||
| 227 | // Useful for serious debugging (mainly graphics/misc) | ||
| 228 | console.debug = consoleDebug | ||
| 229 | if (level === 'debug') return | ||
| 230 | |||
| 231 | // Useful for insane debugging (mainly tracing/recursion) | ||
| 232 | console.spam = consoleSpam | ||
| 233 | console.group = consoleGroup | ||
| 234 | console.groupEnd = consoleGroupEnd | ||
| 235 | if (level === 'spam') return | ||
| 236 | } | ||
| 237 | setLogLevel('info') | ||
| 238 | |||
| 239 | window.deleteElementsByClassName = function(rootElem, className) { | ||
| 240 | var elems = [] | ||
| 241 | while (true) { | ||
| 242 | elems = rootElem.getElementsByClassName(className) | ||
| 243 | if (elems.length === 0) break | ||
| 244 | elems[0].remove() | ||
| 245 | } | ||
| 246 | } | ||
| 247 | |||
| 248 | window.loadHeader = function(titleText) { | ||
| 249 | document.body.style.marginLeft = '0px' | ||
| 250 | |||
| 251 | var navbar = document.createElement('div') | ||
| 252 | document.body.appendChild(navbar) | ||
| 253 | navbar.className = 'navbar' | ||
| 254 | navbar.style = 'min-width: 700px; position: absolute; top: 0; width: 100%; z-index: 1' | ||
| 255 | navbar.style.borderBottom = '2px solid ' + window.BORDER | ||
| 256 | navbar.style.background = window.PAGE_BACKGROUND | ||
| 257 | |||
| 258 | var navbarPadding = document.createElement('div') | ||
| 259 | document.body.appendChild(navbarPadding) | ||
| 260 | navbarPadding.className = 'navbar-padding' | ||
| 261 | |||
| 262 | var titleDiv = document.createElement('div') | ||
| 263 | navbar.appendChild(titleDiv) | ||
| 264 | titleDiv.style = 'position: absolute; width: 100%; pointer-events: none' | ||
| 265 | |||
| 266 | var titleLabel = document.createElement('label') | ||
| 267 | titleDiv.appendChild(titleLabel) | ||
| 268 | titleLabel.style = 'font-size: 48; pointer-events: auto' | ||
| 269 | titleLabel.id = 'title' | ||
| 270 | titleLabel.innerText = titleText | ||
| 271 | |||
| 272 | var link = document.createElement('label') | ||
| 273 | navbar.appendChild(link) | ||
| 274 | link.style = 'float: left; margin-left: 32px; cursor: pointer; line-height: 60px' | ||
| 275 | link.className = 'navbar-content' | ||
| 276 | |||
| 277 | if (window.location.href.endsWith('browse.html')) { | ||
| 278 | navbar.style.position = 'fixed' // When browsing, pin the navbar to the top so that it's visible during infinite scroll. | ||
| 279 | |||
| 280 | link.innerText = 'Create a puzzle' | ||
| 281 | link.onpointerdown = function() {window.location = 'editor.html'} | ||
| 282 | |||
| 283 | var link2 = document.createElement('label') | ||
| 284 | navbar.appendChild(link2) | ||
| 285 | link2.style = 'float: left; margin-left: 20px; cursor: pointer; line-height: 60px; display: none' | ||
| 286 | link2.className = 'navbar-content' | ||
| 287 | link2.innerText = 'Jump to top' | ||
| 288 | link2.id = 'scrollToTop' | ||
| 289 | link2.onpointerdown = function() {window.scrollTo(0, 0)} | ||
| 290 | |||
| 291 | } else if (window.location.href.includes('/play/')) { | ||
| 292 | link.innerText = 'Back to all puzzles' | ||
| 293 | link.onpointerdown = function() {window.location = '../browse.html'} | ||
| 294 | } else /* All other pages */ { | ||
| 295 | link.innerText = 'Browse all puzzles' | ||
| 296 | link.onpointerdown = function() {window.location = 'browse.html'} | ||
| 297 | } | ||
| 298 | |||
| 299 | var feedbackButton = document.createElement('label') | ||
| 300 | navbar.appendChild(feedbackButton) | ||
| 301 | feedbackButton.id = 'feedbackButton' | ||
| 302 | feedbackButton.style = 'float: right; margin-right: 8px; cursor: pointer; line-height: 60px' | ||
| 303 | feedbackButton.innerText = 'Send feedback' | ||
| 304 | feedbackButton.className = 'navbar-content' | ||
| 305 | feedbackButton.onpointerdown = function() { | ||
| 306 | var feedback = prompt('Provide feedback:') | ||
| 307 | if (feedback) { | ||
| 308 | sendFeedback(feedback) | ||
| 309 | } | ||
| 310 | } | ||
| 311 | |||
| 312 | var separator = document.createElement('label') | ||
| 313 | navbar.appendChild(separator) | ||
| 314 | separator.style = 'float: right; line-height: 60px; padding-left: 6px; padding-right: 6px' | ||
| 315 | separator.className = 'navbar-content' | ||
| 316 | separator.innerText = '|' | ||
| 317 | |||
| 318 | var sourceLink = document.createElement('label') | ||
| 319 | navbar.appendChild(sourceLink) | ||
| 320 | sourceLink.style = 'float: right; line-height: 60px; cursor: pointer' | ||
| 321 | sourceLink.innerText = 'Source code' | ||
| 322 | sourceLink.className = 'navbar-content' | ||
| 323 | sourceLink.onpointerdown = function() {window.open('https://github.com/jbzdarkid/jbzdarkid.github.io', '_blank')} | ||
| 324 | |||
| 325 | var collapsedSettings = drawSymbol({'type': 'plus', 'width':20, 'height':20}) | ||
| 326 | navbar.appendChild(collapsedSettings) | ||
| 327 | collapsedSettings.style = 'width: 20px; height: 20px; position: absolute; left: 0; cursor: pointer' | ||
| 328 | collapsedSettings.style.border = '2px solid ' + window.BORDER | ||
| 329 | collapsedSettings.id = 'collapsedSettings' | ||
| 330 | collapsedSettings.onpointerdown = function() { | ||
| 331 | this.style.display = 'none' | ||
| 332 | var expandedSettings = document.getElementById('expandedSettings') | ||
| 333 | expandedSettings.style.display = null | ||
| 334 | window.settings.expanded = 'true' | ||
| 335 | } | ||
| 336 | |||
| 337 | var expandedSettings = document.createElement('div') | ||
| 338 | navbar.appendChild(expandedSettings) | ||
| 339 | expandedSettings.style = 'width: 300px; position: absolute; left: 0; display: none; padding: 10px' | ||
| 340 | expandedSettings.style.border = '2px solid ' + window.BORDER | ||
| 341 | expandedSettings.style.background = window.PAGE_BACKGROUND | ||
| 342 | expandedSettings.id = 'expandedSettings' | ||
| 343 | |||
| 344 | var minus = drawSymbol({'type':'minus', 'width':20, 'height':20}) | ||
| 345 | minus.style = 'width: 20px; height: 20px; cursor: pointer; position: absolute; top: 0; left: 0' | ||
| 346 | expandedSettings.appendChild(minus) | ||
| 347 | minus.onpointerdown = function() { | ||
| 348 | this.parentElement.style.display = 'none' | ||
| 349 | var collapsedSettings = document.getElementById('collapsedSettings') | ||
| 350 | collapsedSettings.style.display = null | ||
| 351 | window.settings.expanded = 'false' | ||
| 352 | } | ||
| 353 | |||
| 354 | if (window.settings.expanded == 'true') { | ||
| 355 | collapsedSettings.onpointerdown() | ||
| 356 | } | ||
| 357 | |||
| 358 | // Now, for the contents of the settings | ||
| 359 | var settingsLabel = document.createElement('label') | ||
| 360 | expandedSettings.appendChild(settingsLabel) | ||
| 361 | settingsLabel.innerText = 'settings' | ||
| 362 | settingsLabel.style = 'line-height: 0px' // Attach to the top | ||
| 363 | |||
| 364 | expandedSettings.appendChild(document.createElement('br')) | ||
| 365 | expandedSettings.appendChild(document.createElement('br')) | ||
| 366 | |||
| 367 | // Theme | ||
| 368 | document.body.style.color = window.TEXT_COLOR | ||
| 369 | document.body.style.background = window.PAGE_BACKGROUND | ||
| 370 | var themeButton = document.createElement('button') | ||
| 371 | expandedSettings.appendChild(themeButton) | ||
| 372 | if (window.settings.theme == 'night') { | ||
| 373 | themeButton.innerText = 'Night theme' | ||
| 374 | themeButton.onpointerdown = function() { | ||
| 375 | window.settings.theme = 'light' | ||
| 376 | location.reload() | ||
| 377 | } | ||
| 378 | } else if (window.settings.theme == 'light') { | ||
| 379 | themeButton.innerText = 'Light theme' | ||
| 380 | themeButton.onpointerdown = function() { | ||
| 381 | window.settings.theme = 'night' | ||
| 382 | location.reload() | ||
| 383 | } | ||
| 384 | } | ||
| 385 | |||
| 386 | expandedSettings.appendChild(document.createElement('br')) | ||
| 387 | |||
| 388 | // Sensitivity | ||
| 389 | var sensLabel = document.createElement('label') | ||
| 390 | expandedSettings.appendChild(sensLabel) | ||
| 391 | sensLabel.htmlFor = 'sens' | ||
| 392 | sensLabel.innerText = 'Mouse Speed 2D' | ||
| 393 | |||
| 394 | var sens = document.createElement('input') | ||
| 395 | expandedSettings.appendChild(sens) | ||
| 396 | sens.type = 'range' | ||
| 397 | sens.id = 'sens' | ||
| 398 | sens.min = '0.1' | ||
| 399 | sens.max = '1.3' | ||
| 400 | sens.step = '0.1' | ||
| 401 | sens.value = window.settings.sensitivity | ||
| 402 | sens.onchange = function() { | ||
| 403 | window.settings.sensitivity = this.value | ||
| 404 | } | ||
| 405 | sens.style.backgroundImage = 'linear-gradient(to right, ' + window.ALT_BACKGROUND + ', ' + window.ACTIVE_COLOR + ')' | ||
| 406 | |||
| 407 | // Volume | ||
| 408 | var volumeLabel = document.createElement('label') | ||
| 409 | expandedSettings.appendChild(volumeLabel) | ||
| 410 | volumeLabel.htmlFor = 'volume' | ||
| 411 | volumeLabel.innerText = 'Volume' | ||
| 412 | |||
| 413 | var volume = document.createElement('input') | ||
| 414 | expandedSettings.appendChild(volume) | ||
| 415 | volume.type = 'range' | ||
| 416 | volume.id = 'volume' | ||
| 417 | volume.min = '0' | ||
| 418 | volume.max = '0.24' | ||
| 419 | volume.step = '0.02' | ||
| 420 | volume.value = parseFloat(window.settings.volume) | ||
| 421 | volume.onchange = function() { | ||
| 422 | window.settings.volume = this.value | ||
| 423 | } | ||
| 424 | volume.style.backgroundImage = 'linear-gradient(to right, ' + window.ALT_BACKGROUND + ', ' + window.ACTIVE_COLOR + ')' | ||
| 425 | |||
| 426 | // Custom mechanics -- disabled for now | ||
| 427 | window.settings.customMechanics = false | ||
| 428 | /* | ||
| 429 | var customMechanics = createCheckbox() | ||
| 430 | expandedSettings.appendChild(customMechanics) | ||
| 431 | customMechanics.id = 'customMechanics' | ||
| 432 | if (window.settings.customMechanics == 'true') { | ||
| 433 | customMechanics.style.background = window.BORDER | ||
| 434 | customMechanics.checked = true | ||
| 435 | } | ||
| 436 | |||
| 437 | customMechanics.onpointerdown = function() { | ||
| 438 | this.checked = !this.checked | ||
| 439 | this.style.background = (this.checked ? window.BORDER : window.PAGE_BACKGROUND) | ||
| 440 | window.settings.customMechanics = this.checked | ||
| 441 | window.location.reload() | ||
| 442 | } | ||
| 443 | |||
| 444 | var mechLabel = document.createElement('label') | ||
| 445 | expandedSettings.appendChild(mechLabel) | ||
| 446 | mechLabel.style.marginLeft = '6px' | ||
| 447 | mechLabel.htmlFor = 'customMechanics' | ||
| 448 | mechLabel.innerText = 'Custom mechanics' | ||
| 449 | */ | ||
| 450 | } | ||
| 451 | |||
| 452 | // Automatically solve the puzzle | ||
| 453 | window.solvePuzzle = function() { | ||
| 454 | if (window.setSolveMode) window.setSolveMode(false) | ||
| 455 | document.getElementById('solutionViewer').style.display = 'none' | ||
| 456 | document.getElementById('progressBox').style.display = null | ||
| 457 | document.getElementById('solveAuto').innerText = 'Cancel Solving' | ||
| 458 | document.getElementById('solveAuto').onpointerdown = function() { | ||
| 459 | this.innerText = 'Cancelling...' | ||
| 460 | this.onpointerdown = null | ||
| 461 | window.setTimeout(window.cancelSolving, 0) | ||
| 462 | } | ||
| 463 | |||
| 464 | window.solve(window.puzzle, function(percent) { | ||
| 465 | document.getElementById('progressPercent').innerText = percent + '%' | ||
| 466 | document.getElementById('progress').style.width = percent + '%' | ||
| 467 | }, function(paths) { | ||
| 468 | document.getElementById('progressBox').style.display = 'none' | ||
| 469 | document.getElementById('solutionViewer').style.display = null | ||
| 470 | document.getElementById('progressPercent').innerText = '0%' | ||
| 471 | document.getElementById('progress').style.width = '0%' | ||
| 472 | document.getElementById('solveAuto').innerText = 'Solve (automatically)' | ||
| 473 | document.getElementById('solveAuto').onpointerdown = solvePuzzle | ||
| 474 | |||
| 475 | window.puzzle.autoSolved = true | ||
| 476 | paths = window.onSolvedPuzzle(paths) | ||
| 477 | window.showSolution(window.puzzle, paths, 0) | ||
| 478 | }) | ||
| 479 | } | ||
| 480 | |||
| 481 | window.showSolution = function(puzzle, paths, num, suffix) { | ||
| 482 | if (suffix == null) { | ||
| 483 | var previousSolution = document.getElementById('previousSolution') | ||
| 484 | var solutionCount = document.getElementById('solutionCount') | ||
| 485 | var nextSolution = document.getElementById('nextSolution') | ||
| 486 | } else if (suffix instanceof Array) { | ||
| 487 | var previousSolution = document.getElementById('previousSolution-' + suffix[0]) | ||
| 488 | var solutionCount = document.getElementById('solutionCount-' + suffix[0]) | ||
| 489 | var nextSolution = document.getElementById('nextSolution-' + suffix[0]) | ||
| 490 | } else { | ||
| 491 | var previousSolution = document.getElementById('previousSolution-' + suffix) | ||
| 492 | var solutionCount = document.getElementById('solutionCount-' + suffix) | ||
| 493 | var nextSolution = document.getElementById('nextSolution-' + suffix) | ||
| 494 | } | ||
| 495 | |||
| 496 | if (paths.length === 0) { // 0 paths, arrows are useless | ||
| 497 | solutionCount.innerText = '0 of 0' | ||
| 498 | previousSolution.disable() | ||
| 499 | nextSolution.disable() | ||
| 500 | return | ||
| 501 | } | ||
| 502 | |||
| 503 | while (num < 0) num = paths.length + num | ||
| 504 | while (num >= paths.length) num = num - paths.length | ||
| 505 | |||
| 506 | if (paths.length === 1) { // 1 path, arrows are useless | ||
| 507 | solutionCount.innerText = '1 of 1' | ||
| 508 | if (paths.length >= window.MAX_SOLUTIONS) solutionCount.innerText += '+' | ||
| 509 | previousSolution.disable() | ||
| 510 | nextSolution.disable() | ||
| 511 | } else { | ||
| 512 | solutionCount.innerText = (num + 1) + ' of ' + paths.length | ||
| 513 | if (paths.length >= window.MAX_SOLUTIONS) solutionCount.innerText += '+' | ||
| 514 | previousSolution.enable() | ||
| 515 | nextSolution.enable() | ||
| 516 | previousSolution.onpointerdown = function(event) { | ||
| 517 | if (event.shiftKey) { | ||
| 518 | window.showSolution(puzzle, paths, num - 10, suffix) | ||
| 519 | } else { | ||
| 520 | window.showSolution(puzzle, paths, num - 1, suffix) | ||
| 521 | } | ||
| 522 | } | ||
| 523 | nextSolution.onpointerdown = function(event) { | ||
| 524 | if (event.shiftKey) { | ||
| 525 | window.showSolution(puzzle, paths, num + 10, suffix) | ||
| 526 | } else { | ||
| 527 | window.showSolution(puzzle, paths, num + 1, suffix) | ||
| 528 | } | ||
| 529 | } | ||
| 530 | } | ||
| 531 | |||
| 532 | if (paths[num] != null) { | ||
| 533 | if (puzzle instanceof Array) { // Special case for multiple related panels | ||
| 534 | for (var i = 0; i < puzzle.length; i++) { | ||
| 535 | // Save the current path on the puzzle object (so that we can pass it along with publishing) | ||
| 536 | puzzle.path = paths[num][i] | ||
| 537 | // Draws the given path, and also updates the puzzle to have path annotations on it. | ||
| 538 | window.drawPath(puzzle[i], paths[num][i], suffix[i]) | ||
| 539 | } | ||
| 540 | } else { // Default case for a single panel | ||
| 541 | // Save the current path on the puzzle object (so that we can pass it along with publishing) | ||
| 542 | puzzle.path = paths[num] | ||
| 543 | // Draws the given path, and also updates the puzzle to have path annotations on it. | ||
| 544 | window.drawPath(puzzle, paths[num], suffix) | ||
| 545 | } | ||
| 546 | } | ||
| 547 | } | ||
| 548 | |||
| 549 | window.createCheckbox = function() { | ||
| 550 | var checkbox = document.createElement('div') | ||
| 551 | checkbox.style.width = '22px' | ||
| 552 | checkbox.style.height = '22px' | ||
| 553 | checkbox.style.borderRadius = '6px' | ||
| 554 | checkbox.style.display = 'inline-block' | ||
| 555 | checkbox.style.verticalAlign = 'text-bottom' | ||
| 556 | checkbox.style.marginRight = '6px' | ||
| 557 | checkbox.style.borderWidth = '1.5px' | ||
| 558 | checkbox.style.borderStyle = 'solid' | ||
| 559 | checkbox.style.borderColor = window.BORDER | ||
| 560 | checkbox.style.background = window.PAGE_BACKGROUND | ||
| 561 | checkbox.style.color = window.TEXT_COLOR | ||
| 562 | return checkbox | ||
| 563 | } | ||
| 564 | |||
| 565 | // Required global variables/functions: <-- HINT: This means you're writing bad code. | ||
| 566 | // window.puzzle | ||
| 567 | // window.onSolvedPuzzle() | ||
| 568 | // window.MAX_SOLUTIONS // defined by solve.js | ||
| 569 | window.addSolveButtons = function() { | ||
| 570 | var parent = document.currentScript.parentElement | ||
| 571 | |||
| 572 | var solveMode = createCheckbox() | ||
| 573 | solveMode.id = 'solveMode' | ||
| 574 | parent.appendChild(solveMode) | ||
| 575 | |||
| 576 | solveMode.onpointerdown = function() { | ||
| 577 | this.checked = !this.checked | ||
| 578 | this.style.background = (this.checked ? window.BORDER : window.PAGE_BACKGROUND) | ||
| 579 | document.getElementById('solutionViewer').style.display = 'none' | ||
| 580 | if (window.setSolveMode) window.setSolveMode(this.checked) | ||
| 581 | } | ||
| 582 | |||
| 583 | var solveManual = document.createElement('label') | ||
| 584 | parent.appendChild(solveManual) | ||
| 585 | solveManual.id = 'solveManual' | ||
| 586 | solveManual.onpointerdown = function() {solveMode.onpointerdown()} | ||
| 587 | solveManual.innerText = 'Solve (manually)' | ||
| 588 | solveManual.style = 'margin-right: 8px' | ||
| 589 | |||
| 590 | var solveAuto = document.createElement('button') | ||
| 591 | parent.appendChild(solveAuto) | ||
| 592 | solveAuto.id = 'solveAuto' | ||
| 593 | solveAuto.innerText = 'Solve (automatically)' | ||
| 594 | solveAuto.onpointerdown = solvePuzzle | ||
| 595 | solveAuto.style = 'margin-right: 8px' | ||
| 596 | |||
| 597 | var div = document.createElement('div') | ||
| 598 | parent.appendChild(div) | ||
| 599 | div.style = 'display: inline-block; vertical-align:top' | ||
| 600 | |||
| 601 | var progressBox = document.createElement('div') | ||
| 602 | div.appendChild(progressBox) | ||
| 603 | progressBox.id = 'progressBox' | ||
| 604 | progressBox.style = 'display: none; width: 220px; border: 1px solid black; margin-top: 2px' | ||
| 605 | |||
| 606 | var progressPercent = document.createElement('label') | ||
| 607 | progressBox.appendChild(progressPercent) | ||
| 608 | progressPercent.id = 'progressPercent' | ||
| 609 | progressPercent.style = 'float: left; margin-left: 4px' | ||
| 610 | progressPercent.innerText = '0%' | ||
| 611 | |||
| 612 | var progress = document.createElement('div') | ||
| 613 | progressBox.appendChild(progress) | ||
| 614 | progress.id = 'progress' | ||
| 615 | progress.style = 'z-index: -1; height: 38px; width: 0%; background-color: #390' | ||
| 616 | |||
| 617 | var solutionViewer = document.createElement('div') | ||
| 618 | div.appendChild(solutionViewer) | ||
| 619 | solutionViewer.id = 'solutionViewer' | ||
| 620 | solutionViewer.style = 'display: none' | ||
| 621 | |||
| 622 | var previousSolution = document.createElement('button') | ||
| 623 | solutionViewer.appendChild(previousSolution) | ||
| 624 | previousSolution.id = 'previousSolution' | ||
| 625 | previousSolution.innerHTML = '←' | ||
| 626 | |||
| 627 | var solutionCount = document.createElement('label') | ||
| 628 | solutionViewer.appendChild(solutionCount) | ||
| 629 | solutionCount.id = 'solutionCount' | ||
| 630 | solutionCount.style = 'padding: 6px' | ||
| 631 | |||
| 632 | var nextSolution = document.createElement('button') | ||
| 633 | solutionViewer.appendChild(nextSolution) | ||
| 634 | nextSolution.id = 'nextSolution' | ||
| 635 | nextSolution.innerHTML = '→' | ||
| 636 | } | ||
| 637 | |||
| 638 | var SECONDS_PER_LOOP = 1 | ||
| 639 | window.httpGetLoop = function(url, maxTimeout, action, onError, onSuccess) { | ||
| 640 | if (maxTimeout <= 0) { | ||
| 641 | onError() | ||
| 642 | return | ||
| 643 | } | ||
| 644 | |||
| 645 | sendHttpRequest('GET', url, SECONDS_PER_LOOP, null, function(httpCode, response) { | ||
| 646 | if (httpCode >= 200 && httpCode <= 299) { | ||
| 647 | var output = action(JSON.parse(response)) | ||
| 648 | if (output) { | ||
| 649 | onSuccess(output) | ||
| 650 | return | ||
| 651 | } // Retry if action returns null | ||
| 652 | } // Retry on non-success HTTP codes | ||
| 653 | |||
| 654 | window.setTimeout(function() { | ||
| 655 | httpGetLoop(url, maxTimeout - SECONDS_PER_LOOP, action, onError, onSuccess) | ||
| 656 | }, 1000) | ||
| 657 | }) | ||
| 658 | } | ||
| 659 | |||
| 660 | window.fireAndForget = function(verb, url, body) { | ||
| 661 | sendHttpRequest(verb, url, 600, body, function() {}) | ||
| 662 | } | ||
| 663 | |||
| 664 | // Only used for errors | ||
| 665 | var HTTP_STATUS = { | ||
| 666 | 401: '401 unauthorized', 403: '403 forbidden', 404: '404 not found', 409: '409 conflict', 413: '413 payload too large', | ||
| 667 | 500: '500 internal server error', | ||
| 668 | } | ||
| 669 | |||
| 670 | var etagCache = {} | ||
| 671 | function sendHttpRequest(verb, url, timeoutSeconds, data, onResponse) { | ||
| 672 | currentHttpRequest = new XMLHttpRequest() | ||
| 673 | currentHttpRequest.onreadystatechange = function() { | ||
| 674 | if (this.readyState != XMLHttpRequest.DONE) return | ||
| 675 | etagCache[url] = this.getResponseHeader('ETag') | ||
| 676 | currentHttpRequest = null | ||
| 677 | onResponse(this.status, this.responseText || HTTP_STATUS[this.status]) | ||
| 678 | } | ||
| 679 | currentHttpRequest.ontimeout = function() { | ||
| 680 | currentHttpRequest = null | ||
| 681 | onResponse(0, 'Request timed out') | ||
| 682 | } | ||
| 683 | currentHttpRequest.timeout = timeoutSeconds * 1000 | ||
| 684 | currentHttpRequest.open(verb, url, true) | ||
| 685 | currentHttpRequest.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded') | ||
| 686 | |||
| 687 | var etag = etagCache[url] | ||
| 688 | if (etag != null) currentHttpRequest.setRequestHeader('If-None-Match', etag) | ||
| 689 | |||
| 690 | currentHttpRequest.send(data) | ||
| 691 | } | ||
| 692 | |||
| 693 | function sendFeedback(feedback) { | ||
| 694 | console.error('Please disregard the following CORS exception. It is expected and the request will succeed regardless.') | ||
| 695 | } | ||
| 696 | |||
| 697 | }) | ||
| diff --git a/app/assets/javascripts/wittle/validate.js b/app/assets/javascripts/wittle/validate.js new file mode 100644 index 0000000..333d2e1 --- /dev/null +++ b/app/assets/javascripts/wittle/validate.js | |||
| @@ -0,0 +1,391 @@ | |||
| 1 | namespace(function() { | ||
| 2 | |||
| 3 | class RegionData { | ||
| 4 | constructor() { | ||
| 5 | this.invalidElements = [] | ||
| 6 | this.veryInvalidElements = [] | ||
| 7 | this.negations = [] | ||
| 8 | } | ||
| 9 | |||
| 10 | addInvalid(elem) { | ||
| 11 | this.invalidElements.push(elem) | ||
| 12 | } | ||
| 13 | |||
| 14 | addVeryInvalid(elem) { | ||
| 15 | this.veryInvalidElements.push(elem) | ||
| 16 | } | ||
| 17 | |||
| 18 | valid() { | ||
| 19 | return (this.invalidElements.length === 0 && this.veryInvalidElements.length === 0) | ||
| 20 | } | ||
| 21 | } | ||
| 22 | |||
| 23 | // Sanity checks for data which comes from the user. Now that people have learned that /publish is an open endpoint, | ||
| 24 | // we have to make sure they don't submit data which passes validation but is untrustworthy. | ||
| 25 | // These checks should always pass for puzzles created by the built-in editor. | ||
| 26 | window.validateUserData = function(puzzle, path) { | ||
| 27 | if (path == null) throw Error('Path cannot be null') | ||
| 28 | |||
| 29 | var sizeError = puzzle.getSizeError(puzzle.width, puzzle.height) | ||
| 30 | if (sizeError != null) throw Error(sizeError) | ||
| 31 | |||
| 32 | var puzzleHasStart = false | ||
| 33 | var puzzleHasEnd = false | ||
| 34 | |||
| 35 | if (puzzle.grid.length !== puzzle.width) throw Error('Puzzle width does not match grid size') | ||
| 36 | for (var x=0; x<puzzle.width; x++) { | ||
| 37 | if (puzzle.grid[x].length !== puzzle.height) throw Error('Puzzle height does not match grid size') | ||
| 38 | for (var y=0; y<puzzle.height; y++) { | ||
| 39 | var cell = puzzle.grid[x][y] | ||
| 40 | if (cell == null) continue | ||
| 41 | |||
| 42 | if (cell.start === true) { | ||
| 43 | puzzleHasStart = true | ||
| 44 | if (puzzle.symmetry != null) { | ||
| 45 | var symCell = puzzle.getSymmetricalCell(x, y) | ||
| 46 | if (symCell == null || symCell.start !== true) { | ||
| 47 | throw Error('Startpoint at ' + x + ' ' + y + ' does not have a symmetrical startpoint') | ||
| 48 | } | ||
| 49 | } | ||
| 50 | } | ||
| 51 | if (cell.end != null) { | ||
| 52 | puzzleHasEnd = true | ||
| 53 | if (puzzle.symmetry != null) { | ||
| 54 | var symCell = puzzle.getSymmetricalCell(x, y) | ||
| 55 | if (symCell == null || symCell.end == null || symCell.end != puzzle.getSymmetricalDir(cell.end)) { | ||
| 56 | throw Error('Endpoint at ' + x + ' ' + y + ' does not have a symmetrical endpoint') | ||
| 57 | } | ||
| 58 | } | ||
| 59 | } | ||
| 60 | } | ||
| 61 | } | ||
| 62 | if (!puzzleHasStart) throw Error('Puzzle does not have a startpoint') | ||
| 63 | if (!puzzleHasEnd) throw Error('Puzzle does not have an endpoint') | ||
| 64 | } | ||
| 65 | // Determines if the current grid state is solvable. Modifies the puzzle element with: | ||
| 66 | // valid: Whether or not the puzzle is valid | ||
| 67 | // invalidElements: Symbols which are invalid (for the purpose of negating / flashing) | ||
| 68 | // negations: Negation pairs (for the purpose of darkening) | ||
| 69 | window.validate = function(puzzle, quick) { | ||
| 70 | console.log('Validating', puzzle) | ||
| 71 | var puzzleData = new RegionData() // Assumed valid until we find an invalid element | ||
| 72 | |||
| 73 | var needsRegions = false | ||
| 74 | // These two are both used by validateRegion, so they are saved on the puzzle itself. | ||
| 75 | puzzle.hasNegations = false | ||
| 76 | puzzle.hasPolyominos = false | ||
| 77 | |||
| 78 | // Validate gap failures as an early exit. | ||
| 79 | for (var x=0; x<puzzle.width; x++) { | ||
| 80 | for (var y=0; y<puzzle.height; y++) { | ||
| 81 | var cell = puzzle.grid[x][y] | ||
| 82 | if (cell == null) continue | ||
| 83 | if (!needsRegions && cell.type != 'line' && cell.type != 'triangle') needsRegions = true | ||
| 84 | if (cell.type == 'nega') puzzle.hasNegations = true | ||
| 85 | if (cell.type == 'poly' || cell.type == 'ylop') puzzle.hasPolyominos = true | ||
| 86 | if (cell.line > window.LINE_NONE) { | ||
| 87 | if (cell.gap > window.GAP_NONE) { | ||
| 88 | console.log('Solution line goes over a gap at', x, y) | ||
| 89 | puzzleData.invalidElements.push({'x': x, 'y': y}) | ||
| 90 | if (quick) return puzzleData | ||
| 91 | } | ||
| 92 | if ((cell.dot === window.DOT_BLUE && cell.line === window.LINE_YELLOW) || | ||
| 93 | (cell.dot === window.DOT_YELLOW && cell.line === window.LINE_BLUE)) { | ||
| 94 | console.log('Incorrectly covered dot: Dot is', cell.dot, 'but line is', cell.line) | ||
| 95 | puzzleData.invalidElements.push({'x': x, 'y': y}) | ||
| 96 | if (quick) return puzzleData | ||
| 97 | } | ||
| 98 | } | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 102 | if (needsRegions) { | ||
| 103 | var regions = puzzle.getRegions() | ||
| 104 | } else { | ||
| 105 | var monoRegion = [] | ||
| 106 | for (var x=0; x<puzzle.width; x++) { | ||
| 107 | for (var y=0; y<puzzle.height; y++) { | ||
| 108 | if (x%2 === 1 && y%2 === 1) { | ||
| 109 | monoRegion.push({'x': x, 'y': y}) | ||
| 110 | } else if (puzzle.grid[x][y].line === window.LINE_NONE) { | ||
| 111 | monoRegion.push({'x': x, 'y': y}) | ||
| 112 | } | ||
| 113 | } | ||
| 114 | } | ||
| 115 | var regions = [monoRegion] | ||
| 116 | } | ||
| 117 | console.log('Found', regions.length, 'region(s)') | ||
| 118 | console.debug(regions) | ||
| 119 | |||
| 120 | if (puzzle.settings.CUSTOM_MECHANICS) { | ||
| 121 | for (var region of regions) { | ||
| 122 | regionData = validateRegion(puzzle, region, quick) | ||
| 123 | puzzleData.negations = puzzleData.negations.concat(regionData.negations) | ||
| 124 | puzzleData.invalidElements = puzzleData.invalidElements.concat(regionData.invalidElements) | ||
| 125 | puzzleData.invalidElements = puzzleData.invalidElements.concat(regionData.veryInvalidElements) | ||
| 126 | } | ||
| 127 | // When using custom mechanics, we have to handle negations slightly differently. | ||
| 128 | // Negations need to be applied after all regions are validated, so that we can evaluate negations for | ||
| 129 | // all regions simultaneously. This is because certain custom mechanics are cross-region. | ||
| 130 | |||
| 131 | // ... | ||
| 132 | |||
| 133 | } else { | ||
| 134 | for (var region of regions) { | ||
| 135 | regionData = validateRegion(puzzle, region, quick) | ||
| 136 | console.log('Region valid:', regionData.valid()) | ||
| 137 | puzzleData.negations = puzzleData.negations.concat(regionData.negations) | ||
| 138 | puzzleData.invalidElements = puzzleData.invalidElements.concat(regionData.invalidElements) | ||
| 139 | // Note: Not using veryInvalid because I don't need to do logic on these elements, just flash them. | ||
| 140 | puzzleData.invalidElements = puzzleData.invalidElements.concat(regionData.veryInvalidElements) | ||
| 141 | if (quick && !puzzleData.valid()) break | ||
| 142 | } | ||
| 143 | } | ||
| 144 | console.log('Puzzle has', puzzleData.invalidElements.length, 'invalid elements') | ||
| 145 | return puzzleData | ||
| 146 | } | ||
| 147 | |||
| 148 | // Determines whether or not a particular region is valid or not, including negation symbols. | ||
| 149 | // If quick is true, exits after the first invalid element is found (small performance gain) | ||
| 150 | // This function applies negations to all "very invalid elements", i.e. elements which cannot become | ||
| 151 | // valid by another element being negated. Then, it passes off to regionCheckNegations2, | ||
| 152 | // which attempts to apply any remaining negations to any other invalid elements. | ||
| 153 | window.validateRegion = function(puzzle, region, quick) { | ||
| 154 | if (!puzzle.hasNegations) return regionCheck(puzzle, region, quick) | ||
| 155 | |||
| 156 | // Get a list of negation symbols in the grid, and set them to 'nonce' | ||
| 157 | var negationSymbols = [] | ||
| 158 | for (var pos of region) { | ||
| 159 | var cell = puzzle.grid[pos.x][pos.y] | ||
| 160 | if (cell != null && cell.type === 'nega') { | ||
| 161 | pos.cell = cell | ||
| 162 | negationSymbols.push(pos) | ||
| 163 | puzzle.updateCell2(pos.x, pos.y, 'type', 'nonce') | ||
| 164 | } | ||
| 165 | } | ||
| 166 | console.debug('Found', negationSymbols.length, 'negation symbols') | ||
| 167 | if (negationSymbols.length === 0) { | ||
| 168 | // No negation symbols in this region. Note that there must be negation symbols elsewhere | ||
| 169 | // in the puzzle, since puzzle.hasNegations was true. | ||
| 170 | return regionCheck(puzzle, region, quick) | ||
| 171 | } | ||
| 172 | |||
| 173 | // Get a list of elements that are currently invalid (before any negations are applied) | ||
| 174 | // This cannot be quick, as we need a full list (for the purposes of negation). | ||
| 175 | var regionData = regionCheck(puzzle, region, false) | ||
| 176 | console.debug('Negation-less regioncheck valid:', regionData.valid()) | ||
| 177 | |||
| 178 | // Set 'nonce' back to 'nega' for the negation symbols | ||
| 179 | for (var pos of negationSymbols) { | ||
| 180 | puzzle.updateCell2(pos.x, pos.y, 'type', 'nega') | ||
| 181 | } | ||
| 182 | |||
| 183 | var invalidElements = regionData.invalidElements | ||
| 184 | var veryInvalidElements = regionData.veryInvalidElements | ||
| 185 | |||
| 186 | for (var i=0; i<invalidElements.length; i++) { | ||
| 187 | invalidElements[i].cell = puzzle.getCell(invalidElements[i].x, invalidElements[i].y) | ||
| 188 | } | ||
| 189 | for (var i=0; i<veryInvalidElements.length; i++) { | ||
| 190 | veryInvalidElements[i].cell = puzzle.getCell(veryInvalidElements[i].x, veryInvalidElements[i].y) | ||
| 191 | } | ||
| 192 | |||
| 193 | console.debug('Forcibly negating', veryInvalidElements.length, 'symbols') | ||
| 194 | var baseCombination = [] | ||
| 195 | while (negationSymbols.length > 0 && veryInvalidElements.length > 0) { | ||
| 196 | var source = negationSymbols.pop() | ||
| 197 | var target = veryInvalidElements.pop() | ||
| 198 | puzzle.setCell(source.x, source.y, null) | ||
| 199 | puzzle.setCell(target.x, target.y, null) | ||
| 200 | baseCombination.push({'source':source, 'target':target}) | ||
| 201 | } | ||
| 202 | |||
| 203 | var regionData = regionCheckNegations2(puzzle, region, negationSymbols, invalidElements) | ||
| 204 | |||
| 205 | // Restore required negations | ||
| 206 | for (var combination of baseCombination) { | ||
| 207 | puzzle.setCell(combination.source.x, combination.source.y, combination.source.cell) | ||
| 208 | puzzle.setCell(combination.target.x, combination.target.y, combination.target.cell) | ||
| 209 | regionData.negations.push(combination) | ||
| 210 | } | ||
| 211 | return regionData | ||
| 212 | } | ||
| 213 | |||
| 214 | // Recursively matches negations and invalid elements from the grid. Note that this function | ||
| 215 | // doesn't actually modify the two lists, it just iterates through them with index/index2. | ||
| 216 | function regionCheckNegations2(puzzle, region, negationSymbols, invalidElements, index=0, index2=0) { | ||
| 217 | if (index2 >= negationSymbols.length) { | ||
| 218 | console.debug('0 negation symbols left, returning negation-less regionCheck') | ||
| 219 | return regionCheck(puzzle, region, false) // @Performance: We could pass quick here. | ||
| 220 | } | ||
| 221 | |||
| 222 | if (index >= invalidElements.length) { | ||
| 223 | var i = index2 | ||
| 224 | // pair off all negation symbols, 2 at a time | ||
| 225 | if (puzzle.settings.NEGATIONS_CANCEL_NEGATIONS) { | ||
| 226 | for (; i<negationSymbols.length-1; i+=2) { | ||
| 227 | var source = negationSymbols[i] | ||
| 228 | var target = negationSymbols[i+1] | ||
| 229 | puzzle.setCell(source.x, source.y, null) | ||
| 230 | puzzle.setCell(target.x, target.y, null) | ||
| 231 | } | ||
| 232 | } | ||
| 233 | |||
| 234 | console.debug(negationSymbols.length - i, 'negation symbol(s) left over with nothing to negate') | ||
| 235 | for (; i<negationSymbols.length; i++) { | ||
| 236 | puzzle.updateCell2(negationSymbols[i].x, negationSymbols[i].y, 'type', 'nonce') | ||
| 237 | } | ||
| 238 | // Cannot be quick, as we need the full list of invalid symbols. | ||
| 239 | var regionData = regionCheck(puzzle, region, false) | ||
| 240 | |||
| 241 | i = index2 | ||
| 242 | if (puzzle.settings.NEGATIONS_CANCEL_NEGATIONS) { | ||
| 243 | for (; i<negationSymbols.length-1; i+=2) { | ||
| 244 | var source = negationSymbols[i] | ||
| 245 | var target = negationSymbols[i+1] | ||
| 246 | puzzle.setCell(source.x, source.y, source.cell) | ||
| 247 | puzzle.setCell(target.x, target.y, target.cell) | ||
| 248 | regionData.negations.push({'source':source, 'target':target}) | ||
| 249 | } | ||
| 250 | } | ||
| 251 | for (; i<negationSymbols.length; i++) { | ||
| 252 | puzzle.updateCell2(negationSymbols[i].x, negationSymbols[i].y, 'type', 'nega') | ||
| 253 | regionData.addInvalid(negationSymbols[i]) | ||
| 254 | } | ||
| 255 | return regionData | ||
| 256 | } | ||
| 257 | |||
| 258 | var source = negationSymbols[index2++] | ||
| 259 | puzzle.setCell(source.x, source.y, null) | ||
| 260 | |||
| 261 | var firstRegionData = null | ||
| 262 | for (var i=index; i<invalidElements.length; i++) { | ||
| 263 | var target = invalidElements[i] | ||
| 264 | console.spam('Attempting negation pair', source, target) | ||
| 265 | |||
| 266 | console.group() | ||
| 267 | puzzle.setCell(target.x, target.y, null) | ||
| 268 | var regionData = regionCheckNegations2(puzzle, region, negationSymbols, invalidElements, i + 1, index2) | ||
| 269 | puzzle.setCell(target.x, target.y, target.cell) | ||
| 270 | console.groupEnd() | ||
| 271 | |||
| 272 | if (!firstRegionData) { | ||
| 273 | firstRegionData = regionData | ||
| 274 | firstRegionData.negations.push({'source':source, 'target':target}) | ||
| 275 | } | ||
| 276 | if (regionData.valid()) { | ||
| 277 | regionData.negations.push({'source':source, 'target':target}) | ||
| 278 | break | ||
| 279 | } | ||
| 280 | } | ||
| 281 | |||
| 282 | puzzle.setCell(source.x, source.y, source.cell) | ||
| 283 | // For display purposes only. The first attempt will always pair off the most negation symbols, | ||
| 284 | // so it's the best choice to display (if we're going to fail). | ||
| 285 | return (regionData.valid() ? regionData : firstRegionData) | ||
| 286 | } | ||
| 287 | |||
| 288 | // Checks if a region is valid. This does not handle negations -- we assume that there are none. | ||
| 289 | // Note that this function needs to always ask the puzzle for the current contents of the cell, | ||
| 290 | // since the region is only coordinate locations, and might be modified by regionCheckNegations2 | ||
| 291 | // @Performance: This is a pretty core function to the solve loop. | ||
| 292 | function regionCheck(puzzle, region, quick) { | ||
| 293 | console.log('Validating region of size', region.length, region) | ||
| 294 | var regionData = new RegionData() | ||
| 295 | |||
| 296 | var squares = [] | ||
| 297 | var stars = [] | ||
| 298 | var coloredObjects = {} | ||
| 299 | var squareColor = null | ||
| 300 | |||
| 301 | for (var pos of region) { | ||
| 302 | var cell = puzzle.grid[pos.x][pos.y] | ||
| 303 | if (cell == null) continue | ||
| 304 | |||
| 305 | // Check for uncovered dots | ||
| 306 | if (cell.dot > window.DOT_NONE) { | ||
| 307 | console.log('Dot at', pos.x, pos.y, 'is not covered') | ||
| 308 | regionData.addVeryInvalid(pos) | ||
| 309 | if (quick) return regionData | ||
| 310 | } | ||
| 311 | |||
| 312 | // Check for triangles | ||
| 313 | if (cell.type === 'triangle') { | ||
| 314 | var count = 0 | ||
| 315 | if (puzzle.getLine(pos.x - 1, pos.y) > window.LINE_NONE) count++ | ||
| 316 | if (puzzle.getLine(pos.x + 1, pos.y) > window.LINE_NONE) count++ | ||
| 317 | if (puzzle.getLine(pos.x, pos.y - 1) > window.LINE_NONE) count++ | ||
| 318 | if (puzzle.getLine(pos.x, pos.y + 1) > window.LINE_NONE) count++ | ||
| 319 | if (cell.count !== count) { | ||
| 320 | console.log('Triangle at grid['+pos.x+']['+pos.y+'] has', count, 'borders') | ||
| 321 | regionData.addVeryInvalid(pos) | ||
| 322 | if (quick) return regionData | ||
| 323 | } | ||
| 324 | } | ||
| 325 | |||
| 326 | // Count color-based elements | ||
| 327 | if (cell.color != null) { | ||
| 328 | var count = coloredObjects[cell.color] | ||
| 329 | if (count == null) { | ||
| 330 | count = 0 | ||
| 331 | } | ||
| 332 | coloredObjects[cell.color] = count + 1 | ||
| 333 | |||
| 334 | if (cell.type === 'square') { | ||
| 335 | squares.push(pos) | ||
| 336 | if (squareColor == null) { | ||
| 337 | squareColor = cell.color | ||
| 338 | } else if (squareColor != cell.color) { | ||
| 339 | squareColor = -1 // Signal value which indicates square color collision | ||
| 340 | } | ||
| 341 | } | ||
| 342 | |||
| 343 | if (cell.type === 'star') { | ||
| 344 | pos.color = cell.color | ||
| 345 | stars.push(pos) | ||
| 346 | } | ||
| 347 | } | ||
| 348 | } | ||
| 349 | |||
| 350 | if (squareColor === -1) { | ||
| 351 | regionData.invalidElements = regionData.invalidElements.concat(squares) | ||
| 352 | if (quick) return regionData | ||
| 353 | } | ||
| 354 | |||
| 355 | for (var star of stars) { | ||
| 356 | var count = coloredObjects[star.color] | ||
| 357 | if (count === 1) { | ||
| 358 | console.log('Found a', star.color, 'star in a region with 1', star.color, 'object') | ||
| 359 | regionData.addVeryInvalid(star) | ||
| 360 | if (quick) return regionData | ||
| 361 | } else if (count > 2) { | ||
| 362 | console.log('Found a', star.color, 'star in a region with', count, star.color, 'objects') | ||
| 363 | regionData.addInvalid(star) | ||
| 364 | if (quick) return regionData | ||
| 365 | } | ||
| 366 | } | ||
| 367 | |||
| 368 | if (puzzle.hasPolyominos) { | ||
| 369 | if (!window.polyFit(region, puzzle)) { | ||
| 370 | for (var pos of region) { | ||
| 371 | var cell = puzzle.grid[pos.x][pos.y] | ||
| 372 | if (cell == null) continue | ||
| 373 | if (cell.type === 'poly' || cell.type === 'ylop') { | ||
| 374 | regionData.addInvalid(pos) | ||
| 375 | if (quick) return regionData | ||
| 376 | } | ||
| 377 | } | ||
| 378 | } | ||
| 379 | } | ||
| 380 | |||
| 381 | if (puzzle.settings.CUSTOM_MECHANICS) { | ||
| 382 | window.validateBridges(puzzle, region, regionData) | ||
| 383 | window.validateArrows(puzzle, region, regionData) | ||
| 384 | window.validateSizers(puzzle, region, regionData) | ||
| 385 | } | ||
| 386 | |||
| 387 | console.debug('Region has', regionData.veryInvalidElements.length, 'very invalid elements') | ||
| 388 | console.debug('Region has', regionData.invalidElements.length, 'invalid elements') | ||
| 389 | return regionData | ||
| 390 | } | ||
| 391 | }) | ||
