about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--Gemfile.lock7
-rw-r--r--app/assets/audio/wittle/.keep (renamed from app/assets/images/wittle/.keep)0
-rw-r--r--app/assets/audio/wittle/panel_abort_tracing.aacbin0 -> 5606 bytes
-rw-r--r--app/assets/audio/wittle/panel_failure.aacbin0 -> 8543 bytes
-rw-r--r--app/assets/audio/wittle/panel_start_tracing.aacbin0 -> 12082 bytes
-rw-r--r--app/assets/audio/wittle/panel_success.aacbin0 -> 46061 bytes
-rw-r--r--app/assets/config/wittle_manifest.js2
-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
-rw-r--r--app/controllers/wittle/puzzles_controller.rb6
-rw-r--r--app/helpers/wittle/puzzles_helper.rb4
-rw-r--r--app/views/layouts/wittle/application.html.erb15
-rw-r--r--app/views/layouts/wittle/application.html.haml9
-rw-r--r--app/views/wittle/puzzles/index.html.haml12
-rw-r--r--config/routes.rb1
-rw-r--r--lib/wittle/engine.rb2
-rw-r--r--test/controllers/wittle/puzzles_controller_test.rb12
-rw-r--r--wittle.gemspec (renamed from wittle_generator.gemspec)1
27 files changed, 4782 insertions, 15 deletions
diff --git a/Gemfile.lock b/Gemfile.lock index 88b074d..2fd1efc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock
@@ -2,6 +2,7 @@ PATH
2 remote: . 2 remote: .
3 specs: 3 specs:
4 wittle (0.1.0) 4 wittle (0.1.0)
5 haml
5 rails (>= 7.1.1) 6 rails (>= 7.1.1)
6 rice 7 rice
7 8
@@ -93,6 +94,10 @@ GEM
93 erubi (1.12.0) 94 erubi (1.12.0)
94 globalid (1.2.1) 95 globalid (1.2.1)
95 activesupport (>= 6.1) 96 activesupport (>= 6.1)
97 haml (6.2.3)
98 temple (>= 0.8.2)
99 thor
100 tilt
96 i18n (1.14.1) 101 i18n (1.14.1)
97 concurrent-ruby (~> 1.0) 102 concurrent-ruby (~> 1.0)
98 io-console (0.6.0) 103 io-console (0.6.0)
@@ -183,7 +188,9 @@ GEM
183 sprockets (>= 3.0.0) 188 sprockets (>= 3.0.0)
184 sqlite3 (1.6.7-x86_64-darwin) 189 sqlite3 (1.6.7-x86_64-darwin)
185 stringio (3.0.8) 190 stringio (3.0.8)
191 temple (0.10.3)
186 thor (1.3.0) 192 thor (1.3.0)
193 tilt (2.3.0)
187 timeout (0.4.0) 194 timeout (0.4.0)
188 tzinfo (2.0.6) 195 tzinfo (2.0.6)
189 concurrent-ruby (~> 1.0) 196 concurrent-ruby (~> 1.0)
diff --git a/app/assets/images/wittle/.keep b/app/assets/audio/wittle/.keep index e69de29..e69de29 100644 --- a/app/assets/images/wittle/.keep +++ b/app/assets/audio/wittle/.keep
diff --git a/app/assets/audio/wittle/panel_abort_tracing.aac b/app/assets/audio/wittle/panel_abort_tracing.aac new file mode 100644 index 0000000..1871586 --- /dev/null +++ b/app/assets/audio/wittle/panel_abort_tracing.aac
Binary files differ
diff --git a/app/assets/audio/wittle/panel_failure.aac b/app/assets/audio/wittle/panel_failure.aac new file mode 100644 index 0000000..c61fe94 --- /dev/null +++ b/app/assets/audio/wittle/panel_failure.aac
Binary files differ
diff --git a/app/assets/audio/wittle/panel_start_tracing.aac b/app/assets/audio/wittle/panel_start_tracing.aac new file mode 100644 index 0000000..9b828f1 --- /dev/null +++ b/app/assets/audio/wittle/panel_start_tracing.aac
Binary files differ
diff --git a/app/assets/audio/wittle/panel_success.aac b/app/assets/audio/wittle/panel_success.aac new file mode 100644 index 0000000..d6e75fe --- /dev/null +++ b/app/assets/audio/wittle/panel_success.aac
Binary files differ
diff --git a/app/assets/config/wittle_manifest.js b/app/assets/config/wittle_manifest.js index af556d6..079ddad 100644 --- a/app/assets/config/wittle_manifest.js +++ b/app/assets/config/wittle_manifest.js
@@ -1 +1,3 @@
1//= link_directory ../stylesheets/wittle .css 1//= link_directory ../stylesheets/wittle .css
2//= link_directory ../javascripts/wittle .js
3//= link_directory ../audio/wittle .aac
diff --git a/app/assets/javascripts/wittle/application.js b/app/assets/javascripts/wittle/application.js new file mode 100644 index 0000000..e54c646 --- /dev/null +++ b/app/assets/javascripts/wittle/application.js
@@ -0,0 +1,13 @@
1// This is a manifest file that'll be compiled into application.js, which will include all the files
2// listed below.
3//
4// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
5// or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path.
6//
7// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
8// compiled file. JavaScript code in this file should be added after the last require_* statement.
9//
10// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
11// about supported directives.
12//
13//= require_tree .
diff --git a/app/assets/javascripts/wittle/custom_mechanics.js b/app/assets/javascripts/wittle/custom_mechanics.js new file mode 100644 index 0000000..d4733db --- /dev/null +++ b/app/assets/javascripts/wittle/custom_mechanics.js
@@ -0,0 +1,201 @@
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})
diff --git a/app/controllers/wittle/puzzles_controller.rb b/app/controllers/wittle/puzzles_controller.rb new file mode 100644 index 0000000..e23b435 --- /dev/null +++ b/app/controllers/wittle/puzzles_controller.rb
@@ -0,0 +1,6 @@
1module Wittle
2 class PuzzlesController < ApplicationController
3 def index
4 end
5 end
6end
diff --git a/app/helpers/wittle/puzzles_helper.rb b/app/helpers/wittle/puzzles_helper.rb new file mode 100644 index 0000000..6230940 --- /dev/null +++ b/app/helpers/wittle/puzzles_helper.rb
@@ -0,0 +1,4 @@
1module Wittle
2 module PuzzlesHelper
3 end
4end
diff --git a/app/views/layouts/wittle/application.html.erb b/app/views/layouts/wittle/application.html.erb deleted file mode 100644 index ba450d1..0000000 --- a/app/views/layouts/wittle/application.html.erb +++ /dev/null
@@ -1,15 +0,0 @@
1<!DOCTYPE html>
2<html>
3<head>
4 <title>Wittle</title>
5 <%= csrf_meta_tags %>
6 <%= csp_meta_tag %>
7
8 <%= stylesheet_link_tag "wittle/application", media: "all" %>
9</head>
10<body>
11
12<%= yield %>
13
14</body>
15</html>
diff --git a/app/views/layouts/wittle/application.html.haml b/app/views/layouts/wittle/application.html.haml new file mode 100644 index 0000000..945ffc0 --- /dev/null +++ b/app/views/layouts/wittle/application.html.haml
@@ -0,0 +1,9 @@
1!!! 5
2%html
3 %head
4 %title Wittle
5 = csrf_meta_tags
6 = csp_meta_tag
7 = stylesheet_link_tag "wittle/application", media: "all"
8 = javascript_include_tag "wittle/application"
9 %body= yield
diff --git a/app/views/wittle/puzzles/index.html.haml b/app/views/wittle/puzzles/index.html.haml new file mode 100644 index 0000000..dfeac3c --- /dev/null +++ b/app/views/wittle/puzzles/index.html.haml
@@ -0,0 +1,12 @@
1%h1 Puzzles#index
2%p Find me in app/views/wittle/puzzles/index.html.haml
3:javascript
4 loadHeader("Unnamed Puzzle")
5%div{ style: "display: flex; justify-content: center; align-items: center"}
6 %svg#puzzle{ style: "pointer-events: auto"}
7:javascript
8 window.onload = function() {
9 var puzzle = window.deserializePuzzle("_AAAAAA8PCQAAAEdlbmVyYXRlZAABAAAACAEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAgAAQAAAAACAAAA/wABAAAAAAgAAQAAAAAIAAEAAAAACAABAAAAAAgAAQAAAAAIAAEAAQAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAQABAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAAIAAEAAAAACAABAAAAAAgAAQAAAAAIAAEAAAAAAv////8AAQAAAAAIAAEAAAAAAv////8AAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAQEAAAAAAQAAAAABAAAAAAEAAQAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAIAAAD/AAEAAAAACAABAAAAAAgAAQAAAAAIAAEAAAAACAABAAAAAAgAAQAAAAAIAAEAAAAAAQAAAAEBAAAAAAEAAAAAAQAAAAABAAAAAQEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAAIAAEAAQAAAv////8AAQAAAAAIAAEAAAAAAv////8AAQAAAAACAAAA/wABAAAAAAIAAAD/AAEAAAAAAgAAAP8AAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAL/////AAEAAAAAAv////8AAQAAAAAIAAEAAAAACAABAAAAAAgAAQAAAAACAAAA/wABAAAAAAgAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAQAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAABAQAAAAABAAEAAAEAAAAAAQAAAAABAAAAAAL/////AAEAAAAAAgAAAP8AAQAAAAAIAAEAAAAAAv////8AAQAAAAACAAAA/wABAAAAAAIAAAD/AAEAAAAACAABAAEAAAEAAAAAAQABAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAgAAAP8AAQAAAAAIAAEAAAAAAv////8AAQAAAAAIAAEAAAAAAv////8AAQAAAAAIAAEAAAAACAABAAAAAAEAAAAIAQAAAAABAAAAAAEAAAAAAQABAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAAAAAQAAAAABAAAAAAEAAQAAAQAAAAABAAAAEAAAAAAN")
10 draw(puzzle)
11 }
12
diff --git a/config/routes.rb b/config/routes.rb index bc08755..6cf2832 100644 --- a/config/routes.rb +++ b/config/routes.rb
@@ -1,2 +1,3 @@
1Wittle::Engine.routes.draw do 1Wittle::Engine.routes.draw do
2 get 'puzzles/index'
2end 3end
diff --git a/lib/wittle/engine.rb b/lib/wittle/engine.rb index 5c73a8e..fd1ec4c 100644 --- a/lib/wittle/engine.rb +++ b/lib/wittle/engine.rb
@@ -1,3 +1,5 @@
1require "haml"
2
1module Wittle 3module Wittle
2 class Engine < ::Rails::Engine 4 class Engine < ::Rails::Engine
3 isolate_namespace Wittle 5 isolate_namespace Wittle
diff --git a/test/controllers/wittle/puzzles_controller_test.rb b/test/controllers/wittle/puzzles_controller_test.rb new file mode 100644 index 0000000..d1a3ca7 --- /dev/null +++ b/test/controllers/wittle/puzzles_controller_test.rb
@@ -0,0 +1,12 @@
1require "test_helper"
2
3module Wittle
4 class PuzzlesControllerTest < ActionDispatch::IntegrationTest
5 include Engine.routes.url_helpers
6
7 test "should get index" do
8 get puzzles_index_url
9 assert_response :success
10 end
11 end
12end
diff --git a/wittle_generator.gemspec b/wittle.gemspec index 11c2107..e182456 100644 --- a/wittle_generator.gemspec +++ b/wittle.gemspec
@@ -17,4 +17,5 @@ Gem::Specification.new do |s|
17 17
18 s.add_dependency "rails", ">= 7.1.1" 18 s.add_dependency "rails", ">= 7.1.1"
19 s.add_dependency "rice" 19 s.add_dependency "rice"
20 s.add_dependency "haml"
20end 21end