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