about summary refs log tree commit diff stats
path: root/app/assets/javascripts
diff options
context:
space:
mode:
authorStar Rauchenberger <fefferburbia@gmail.com>2023-10-28 00:16:07 -0400
committerStar Rauchenberger <fefferburbia@gmail.com>2023-10-28 00:16:07 -0400
commitb496a29a7de381765f3c45ced9fa18f026b2e314 (patch)
tree34f6558ffed34b0ec922c90e6407d165e9256b59 /app/assets/javascripts
parentd09db2d6d0727faba8e5078900f2fbd1e18ea49f (diff)
downloadwittle-b496a29a7de381765f3c45ced9fa18f026b2e314.tar.gz
wittle-b496a29a7de381765f3c45ced9fa18f026b2e314.tar.bz2
wittle-b496a29a7de381765f3c45ced9fa18f026b2e314.zip
serve
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/wittle/application.js13
-rw-r--r--app/assets/javascripts/wittle/custom_mechanics.js201
-rw-r--r--app/assets/javascripts/wittle/display2.js299
-rw-r--r--app/assets/javascripts/wittle/polyominos.js331
-rw-r--r--app/assets/javascripts/wittle/puzzle.js507
-rw-r--r--app/assets/javascripts/wittle/serializer.js346
-rw-r--r--app/assets/javascripts/wittle/solve.js531
-rw-r--r--app/assets/javascripts/wittle/svg.js422
-rw-r--r--app/assets/javascripts/wittle/trace2.js988
-rw-r--r--app/assets/javascripts/wittle/utilities.js.erb697
-rw-r--r--app/assets/javascripts/wittle/validate.js391
11 files changed, 4726 insertions, 0 deletions
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 @@
1namespace(function() {
2
3function 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
9function 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
27function 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
57function 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
63function 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
84window.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
133var 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
144window.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
171window.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 @@
1namespace(function() {
2
3window.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
79function 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
114function 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
208function 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
245function 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 @@
1namespace(function() {
2
3function 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
13function mask(x, y) {
14 return 1 << ((3-y)*4 + x)
15}
16
17function 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)
24window.ROTATION_BIT = (1 << 20)
25
26window.isRotated = function(polyshape) {
27 return (polyshape & ROTATION_BIT) !== 0
28}
29
30function 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
49window.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.
59window.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
106window.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.
133var 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.
146window.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)
215function 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.
233function 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.
259function 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 @@
1namespace(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
12window.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
496var MASKED_OOB = null
497var MASKED_PROCESSED = null
498// 0: In bounds, awaiting processing, but should not be part of the final region.
499var MASKED_INB_NONCOUNT = 0
500// 1: In bounds, awaiting processing
501var MASKED_INB_COUNT = 1
502// 2: Gap-2. After _floodFillOutside, this means "treat normally" (it will be null if oob)
503var 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)
505var 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 @@
1namespace(function() {
2
3window.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
51window.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
102class 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
317var CELL_TYPE_NULL = 0
318var CELL_TYPE_LINE = 1
319var CELL_TYPE_SQUARE = 2
320var CELL_TYPE_STAR = 3
321var CELL_TYPE_NEGA = 4
322var CELL_TYPE_TRIANGLE = 5
323var CELL_TYPE_POLY = 6
324var CELL_TYPE_YLOP = 7
325var CELL_TYPE_NONCE = 8
326
327var CELL_START = 1
328var CELL_END_LEFT = 2
329var CELL_END_RIGHT = 4
330var CELL_END_TOP = 8
331var CELL_END_BOTTOM = 16
332
333var GENERIC_FLAG_AUTOSOLVED = 1
334var GENERIC_FLAG_SYMMETRICAL = 2
335var GENERIC_FLAG_SYMMETRY_X = 4
336var GENERIC_FLAG_SYMMETRY_Y = 8
337var GENERIC_FLAG_PILLAR = 16
338
339var SETTINGS_FLAG_NCN = 1
340var SETTINGS_FLAG_SZP = 2
341var SETTINGS_FLAG_PP = 4
342var SETTINGS_FLAG_FFE = 8
343var SETTINGS_FLAG_FS = 16
344var 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 @@
1namespace(function() {
2
3// @Volatile -- must match order of MOVE_* in trace2
4// Move these, dummy.
5var PATH_NONE = 0
6var PATH_LEFT = 1
7var PATH_RIGHT = 2
8var PATH_TOP = 3
9var PATH_BOTTOM = 4
10
11window.MAX_SOLUTIONS = 0
12var solutionPaths = []
13var asyncTimer = 0
14var task = null
15var puzzle = null
16var path = []
17var SOLVE_SYNC = false
18var SYNC_THRESHOLD = 9 // Depth at which we switch to a synchronous solver (for perf)
19var doPruning = false
20
21var percentages = []
22var NODE_DEPTH = 9
23var nodes = 0
24function 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
62window.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
168function 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
207function 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
220function 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
348window.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.
355window.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.
397window.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
487window.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 @@
1namespace(function() {
2
3window.createElement = function(type) {
4 return document.createElementNS('http://www.w3.org/2000/svg', type)
5}
6
7window.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
16window.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
38function 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
51function 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
78function 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
115function 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
155function 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
175var 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
188function 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
206function 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
225function 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
234function 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
267function 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
279function 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
306function 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
327function 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
338function 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
348function 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
369function 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
406function 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 @@
1namespace(function() {
2
3var BBOX_DEBUG = false
4
5function clamp(value, min, max) {
6 return value < min ? min : value > max ? max : value
7}
8
9class 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
95class 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
332var data = {}
333
334function 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
352function 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
366window.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
436window.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
446window.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.
537document.onpointerlockchange = function() {
538 if (document.pointerLockElement == null) unhookMovementEvents()
539}
540
541function 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
551function 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
600var MOVE_NONE = 0
601var MOVE_LEFT = 1
602var MOVE_RIGHT = 2
603var MOVE_TOP = 3
604var MOVE_BOTTOM = 4
605
606window.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.
664function 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.
714function 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.
819function 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
864function 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.
946function 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.
958function 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 @@
1function namespace(code) {
2 code()
3}
4
5namespace(function() {
6
7/*** Start cross-compatibility ***/
8// Used to detect if IDs include a direction, e.g. resize-top-left
9if (!String.prototype.includes) {
10 String.prototype.includes = function() {
11 return String.prototype.indexOf.apply(this, arguments) !== -1
12 }
13}
14Event.prototype.movementX = Event.prototype.movementX || Event.prototype.mozMovementX
15Event.prototype.movementY = Event.prototype.movementY || Event.prototype.mozMovementY
16Event.prototype.isRightClick = function() {
17 return this.which === 3 || (this.touches && this.touches.length > 1)
18}
19Element.prototype.disable = function() {
20 this.disabled = true
21 this.style.pointerEvents = 'none'
22 this.className = 'noselect'
23}
24Element.prototype.enable = function() {
25 this.disabled = false
26 this.style.pointerEvents = null
27 this.className = null
28}
29Object.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
39var 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}
75window.settings = new Proxy({}, proxy.init())
76
77var 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
84var currentAudio = null
85window.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
97window.LINE_PRIMARY = '#8FF'
98window.LINE_SECONDARY = '#FF2'
99
100if (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
128window.LINE_NONE = 0
129window.LINE_BLACK = 1
130window.LINE_BLUE = 2
131window.LINE_YELLOW = 3
132window.DOT_NONE = 0
133window.DOT_BLACK = 1
134window.DOT_BLUE = 2
135window.DOT_YELLOW = 3
136window.DOT_INVISIBLE = 4
137window.GAP_NONE = 0
138window.GAP_BREAK = 1
139window.GAP_FULL = 2
140
141var animations = ''
142var l = function(line) {animations += line + '\n'}
143// pointer-events: none; allows for events to bubble up (so that editor hooks still work)
144l('.line-1 {')
145l(' fill: ' + window.LINE_DEFAULT + ';')
146l(' pointer-events: none;')
147l('}')
148l('.line-2 {')
149l(' fill: ' + window.LINE_PRIMARY + ';')
150l(' pointer-events: none;')
151l('}')
152l('.line-3 {')
153l(' fill: ' + window.LINE_SECONDARY + ';')
154l(' pointer-events: none;')
155l('}')
156l('@keyframes line-success {to {fill: ' + window.LINE_SUCCESS + ';}}')
157l('@keyframes line-fail {to {fill: ' + window.LINE_FAIL + ';}}')
158l('@keyframes error {to {fill: red;}}')
159l('@keyframes fade {to {opacity: 0.35;}}')
160l('@keyframes start-grow {from {r:12;} to {r:24;}}')
161// Neutral button style
162l('button {')
163l(' background-color: ' + window.ALT_BACKGROUND + ';')
164l(' border: 1px solid ' + window.BORDER + ';')
165l(' border-radius: 2px;')
166l(' color: ' + window.TEXT_COLOR + ';')
167l(' display: inline-block;')
168l(' margin: 0px;')
169l(' outline: none;')
170l(' opacity: 1.0;')
171l(' padding: 1px 6px;')
172l(' -moz-appearance: none;')
173l(' -webkit-appearance: none;')
174l('}')
175// Active (while held down) button style
176l('button:active {background-color: ' + window.ACTIVE_COLOR + ';}')
177// Disabled button style
178l('button:disabled {opacity: 0.5;}')
179// Selected button style (see https://stackoverflow.com/a/63108630)
180l('button:focus {outline: none;}')
181l = null
182
183var style = document.createElement('style')
184style.type = 'text/css'
185style.title = 'animations'
186style.appendChild(document.createTextNode(animations))
187document.head.appendChild(style)
188
189// Custom logging to allow leveling
190var consoleError = console.error
191var consoleWarn = console.warn
192var consoleInfo = console.log
193var consoleLog = console.log
194var consoleDebug = console.log
195var consoleSpam = console.log
196var consoleGroup = console.group
197var consoleGroupEnd = console.groupEnd
198
199window.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}
237setLogLevel('info')
238
239window.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
248window.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
453window.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
481window.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
549window.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
569window.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 = '&larr;'
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 = '&rarr;'
636}
637
638var SECONDS_PER_LOOP = 1
639window.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
660window.fireAndForget = function(verb, url, body) {
661 sendHttpRequest(verb, url, 600, body, function() {})
662}
663
664// Only used for errors
665var 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
670var etagCache = {}
671function 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
693function 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 @@
1namespace(function() {
2
3class 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.
26window.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)
69window.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.
153window.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.
216function 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.
292function 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})