diff options
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 | }) | ||