├── .editorconfig ├── .gitattributes ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── docs ├── index.html └── nonogram.min.js ├── package.json ├── rollup.config.js ├── src ├── Editor.ts ├── Game.ts ├── Nonogram.ts ├── Solver.ts ├── colors.ts ├── global.d.ts ├── index.ts ├── status.ts └── worker.ts ├── test ├── benchmark.html └── index.html └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | ; http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [*.md] 14 | indent_size = 4 15 | 16 | [node_modules/**.js] 17 | codepaint = false 18 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | docs/*.js binary 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .DS_Store 4 | .vscode 5 | docs/nonogram.js 6 | yarn.lock 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ### 0.1.0 4 | 5 | - start using babel and webpack 6 | 7 | ### 0.2.0 8 | 9 | - constructors are using namespace `nonogram` 10 | - use truthy and falsy values to setting `Editor`s grid 11 | 12 | ### 0.3.0 13 | 14 | - removed custom event, using callbacks instead 15 | 16 | ### 0.4.0 17 | 18 | - canvas in constructors goes optional 19 | - some configurable items wrapped in key `theme` 20 | 21 | ### 0.5.0 22 | 23 | - now using TypeScript 24 | 25 | ### 0.6.0 26 | 27 | - now using `WebWorker` 28 | 29 | ### 0.7.0 30 | 31 | - enhance `worker.ts` 32 | - remove `demoMode`, `!!delay` now has the same meaning 33 | 34 | ### 0.8.0 35 | 36 | - use rollup instead of webpack 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Zhou Qi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nonogram 2 | 3 | [![demo](http://i.imgur.com/XRs3jk7.gif)](https://handsomeone.github.io/Nonogram) 4 | 5 | ## Usage 6 | 7 | [**Check the live demo and find out how to build your own nonogram application.**](https://handsomeone.github.io/Nonogram) 8 | 9 | You just need to attach 10 | 11 | ```html 12 | 13 | ``` 14 | 15 | to ``. A `` element is required for each nonogram instance. 16 | 17 | ## API 18 | 19 | ### `class nonogram.Solver` 20 | 21 | #### `#constructor(row, column, canvas[, config])` 22 | 23 | Creates a nonogram solver. 24 | 25 | - `row`: a two-dimensional array, consisting of the hints of each row as an array. 26 | - `column`: a two-dimensional array, consisting of the hints of each column as an array. 27 | - *optional* `canvas`: a canvas element, or `id` of the canvas to print the nonogram on. If not given, a new canvas element will be created and assigned to `this.canvas` so you can put it to the document later. 28 | - *optional* `config`: an object, see [§ Configuration Items](#configuration-items). 29 | 30 | #### `#solve()` 31 | 32 | Solves and prints the nonogram by given hints. 33 | 34 | For example, if there is ``, then you can use 35 | ```javascript 36 | var s = new nonogram.Solver( 37 | [ 38 | [1, 1], 39 | [1, 1], 40 | [1, 1], 41 | [4] 42 | ], 43 | [ 44 | [4], 45 | [1], 46 | [1], 47 | [4] 48 | ], 49 | 'canvas1', 50 | { width: 500, delay: 100 } 51 | ) 52 | s.solve() 53 | ``` 54 | then the output will be like this: 55 | ``` 56 | ██ ██ 1 1 57 | ██ ██ 1 1 58 | ██ ██ 1 1 59 | ████████ 4 60 | 4 1 1 4 61 | ``` 62 | 63 | ### `class nonogram.Editor` 64 | 65 | #### `#constructor(m, n, canvas[, config])` 66 | 67 | Creates a nonogram editor. 68 | 69 | - `m`: number of rows, or the length of each column. 70 | - `n`: number of columns, or the length of each row. 71 | - *optional* `canvas`: same as that of `nonogram.Solver`. 72 | - *optional* `config`: an object, see [§ Configuration Items](#configuration-items). 73 | 74 | #### `#refresh()` 75 | 76 | Randomly generates the grid. 77 | 78 | For example, if you run 79 | ```javascript 80 | new nonogram.Editor(4, 6, 'canvas2', {threshold: 0.9}) 81 | ``` 82 | then the output is likely to be 83 | ``` 84 | ████████████ 6 85 | ████ ██████ 2 3 86 | ██████████ 5 87 | ████████████ 6 88 | 2 4 1 4 4 4 89 | 1 2 90 | ``` 91 | 92 | ### `class nonogram.Game` 93 | 94 | #### `#constructor(row, column, canvas[, config])` 95 | 96 | Creates a nonogram game. The parameters have the same definitions as those of `nonogram.Solver`'s. 97 | 98 | ## Configuration Items 99 | 100 | - `theme`: an plain object, controls the appearance. 101 | - `width` (px): a number to set the canvas' width. If not given, the canvas' current `clientWidth` (**not** the value of its `width` property) will be used. 102 | - `filledColor`: filled cells' color. 103 | - `unsetColor`: unset cells' color. 104 | - `correctColor`: numbers' color of correct rows or columns. 105 | - `wrongColor`: numbers' color of wrong rows or columns. 106 | - `meshColor`: meshes' color. 107 | - `isMeshed`: `true` or `false`, coltrols whether to print the meshes or not. 108 | - `isBoldMeshOnly`: default is `false`. 109 | - `isMeshOnTop`: default is `false`. 110 | - `boldMeshGap`: default is `5`. Controls how many cells are there between two adjacent bold meshes. If you don't want any bold meshes, simply set it to `0`. 111 | 112 | ### `nonogram.Solver` 113 | 114 | - `delay` (ms): default is `50`. Controls the delay between steps of the solving process. 115 | 116 | - `onSuccess(time)`: fired when the nonogram has been solved, `time` is how many milliseconds cost. 117 | 118 | - `onError(err)`: when some contradiction has been found, `err` tells the bad hints' location (index starts at 1). 119 | 120 | ### `nonogram.Editor` 121 | 122 | - `grid`: a two-dimensional array, consisting of `1`s and `0`s, will be assigned to the nonogram's grid. For example, you can use 123 | ```javascript 124 | [[1, 0, 0, 1], 125 | [1, 0, 0, 1], 126 | [1, 0, 0, 1], 127 | [1, 1, 1, 1]] 128 | ``` 129 | to create 130 | ``` 131 | ██ ██ 1 1 132 | ██ ██ 1 1 133 | ██ ██ 1 1 134 | ████████ 4 135 | 4 1 1 4 136 | ``` 137 | 138 | - `threshold`: if `grid` is not given, then the nonogram's grid will be randomly generated. Each cell of the grid has a chance of threshold*100% to be filled. Default is `0.5`. 139 | 140 | - `onHintChange(row, column)`: fired when the nonogram's hints have any change. To automatically create a new solver on hint change, you can use 141 | ```javascript 142 | new nonogram.Editor(4, 4, 'canvas1', { 143 | onHintChange: function (row, column) { 144 | new nonogram.Solver(row, column, 'canvas2').solve() 145 | }) 146 | }) 147 | ``` 148 | 149 | ### `nonogram.Game` 150 | 151 | - `onSuccess()`: fired when the player has successfully solved the nonogram. 152 | 153 | - `onAnimationEnd()`: fired when when the success animation has been finished. 154 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Nonogram 8 | 9 | 10 | 11 | 12 | 13 | 14 | 30 | 43 | 44 | 45 | 46 | 66 | 67 |

nonogram.Editor

68 |
69 |
70 |
 71 | new nonogram.Editor(
 72 |   4,
 73 |   8,
 74 |   'edit1',
 75 |   { theme: {
 76 |     threshold: 0.2,
 77 |     isMeshed: true,
 78 |     boldMeshGap: 2 }})
79 |
80 |
81 | 82 |
83 |
84 |
85 |
86 |
 87 | new nonogram.Editor(
 88 |   2,
 89 |   4,
 90 |   'edit2',
 91 |   { grid: [[1, 0, 1, 0], [0, 1, 0, 1]] })
92 |
93 |
94 | 95 |
96 |
97 | 98 |

nonogram.Game

99 |
100 |
101 |
102 | new nonogram.Game(
103 |   [[1,1], [1,1]],
104 |   [[1], [1], [1], [1]],
105 |   'play1',
106 |   { theme: {
107 |     filledColor: '#0c6',
108 |     correctColor: '#0c6' }})
109 |
110 |
111 | 112 |
113 |
114 |
115 |
116 |
117 | new nonogram.Game(
118 |   [[1,1], [1,1]],
119 |   [[2], [], [2], []],
120 |   'play2')
121 |
122 |
123 | 124 |
125 |
126 | 127 |

nonogram.Solver

128 |
129 |
130 |
131 | new nonogram.Solver(
132 |   [[1,1], [1,1]],
133 |   [[2], [], [2], []],
134 |   'solve1',
135 |   { delay: 200 }).solve()
136 |
137 |
138 | 139 |
140 |
141 |
142 |
143 |
144 | window._row = [
145 |   [7, 2, 2, 7],
146 |   [1, 1, 1, 2, 1, 1],
147 |   [1, 3, 1, 3, 1, 1, 3, 1],
148 |   [1, 3, 1, 2, 1, 1, 3, 1],
149 |   [1, 3, 1, 2, 1, 3, 1],
150 |   [1, 1, 2, 2, 1, 1],
151 |   [7, 1, 1, 1, 7],
152 |   [2],
153 |   [2, 3, 2, 1, 4],
154 |   [1, 1, 3, 3, 2, 1],
155 |   [3, 1, 3, 2, 2],
156 |   [1, 1, 1, 3, 1, 1],
157 |   [1, 5, 1, 1, 1, 1],
158 |   [1, 1, 1, 1, 3, 1],
159 |   [7, 1, 1],
160 |   [1, 1, 1, 1, 1, 1, 1, 1],
161 |   [1, 3, 1, 1, 1, 2, 2],
162 |   [1, 3, 1, 2, 1, 2, 1, 1],
163 |   [1, 3, 1, 1, 1, 2],
164 |   [1, 1, 2, 1, 1],
165 |   [7, 1, 3, 1],
166 | ]
167 |
168 |
169 |
170 | window._column = [
171 |   [7, 1, 2, 7],
172 |   [1, 1, 1, 1, 1, 1],
173 |   [1, 3, 1, 1, 1, 3, 1],
174 |   [1, 3, 1, 1, 1, 1, 3, 1],
175 |   [1, 3, 1, 1, 1, 1, 3, 1],
176 |   [1, 1, 2, 1, 1],
177 |   [7, 1, 1, 1, 7],
178 |   [4],
179 |   [4, 2, 2, 2, 2, 2],
180 |   [1, 2, 1, 1, 1, 2, 3],
181 |   [1, 2, 2, 2],
182 |   [2, 3, 1, 1, 1, 1, 1],
183 |   [3, 3, 2, 3, 1, 1],
184 |   [1, 1, 3, 2],
185 |   [7, 1, 1],
186 |   [1, 1, 1, 1, 1, 1, 1],
187 |   [1, 3, 1, 3, 2, 3],
188 |   [1, 3, 1, 2, 2, 1, 1],
189 |   [1, 3, 1, 1, 1, 1, 1],
190 |   [1, 1, 5, 3],
191 |   [7, 1, 1, 2, 1],
192 | ]
193 |
194 |
195 |
196 | new
197 |   nonogram.Solver(
198 |     window._row,
199 |     window._column,
200 |     'solve2')
201 |   .solve()
202 |
203 |
204 | 205 |
206 |
207 | 208 |

Advanced Usages

209 | 210 |

Create Your Own Nonogram

211 |

212 | Here is an example showing how to use listeners to create nonogram.Game and nonogram.Solver instances automatically. 213 |

214 |
215 |
216 |
217 | 218 | 219 |
220 |
221 | 222 | 223 |
224 |
225 | 226 | 227 |
228 |
229 |
230 | 231 |
232 |
233 | 234 |
235 |
236 | 237 |
238 |
239 |
240 | function f() {
241 |   window.advEditor1 = new nonogram.Editor(
242 |     +document.getElementById('m').value,
243 |     +document.getElementById('n').value,
244 |     'adv-edit1',
245 |     { threshold: +document.getElementById('threshold').value,
246 |       grid: window.advEditor1 ? window.advEditor1.grid : undefined,
247 |       onHintChange(row, column) {
248 |         new nonogram.Game(row, column, 'adv-play1', { theme: { boldMeshGap: 0 } })
249 |         new nonogram.Solver(row, column, 'adv-solve1').solve()
250 |       } })
251 | }
252 | document.getElementById('m').addEventListener('change', f)
253 | document.getElementById('n').addEventListener('change', f)
254 | document.getElementById('threshold').addEventListener('change', f)
255 | f()
256 | 257 |

Interactive Nonogram Solver

258 |

This example shows how to solve the nonogram by given hints, which are separated by newlines and commas.

259 |

Put your rows in the first textarea, columns in the second textarea. 260 | One row (or column) a line, numbers separated by any non-numerical characters. 261 | To represent an empty row (or column), you can use an empty line, or a line including the number 0 as well. 262 | However, empty lines at two ends will be dropped, since they are really unnecessary.

263 |

If the process end with an error, please check your input carefully.

264 |
265 |
266 | 286 | 287 |

288 | Delay: 0~500ms. Setting delay=0 disables step-by-step solution, with a much smaller time cost. 289 |

290 |
291 |
292 | 312 |
313 |
314 |
315 | 316 |
317 |

318 | Parse JSON-like strings or anything. 319 |

320 |
321 |
322 |
323 | 324 |
325 |

326 |

327 |
328 |
329 |
330 |
331 | 332 |
333 |
334 |
335 | function parseArray(text) {
336 |   return text
337 |     .replace(/[^\d\n]+/g, ' ')
338 |     .trim()
339 |     .split('\n')
340 |     .map(row => (row.match(/\d+/g) || [])
341 |       .map(parseFloat)
342 |       .filter(Math.sign))
343 | }
344 | 
345 | document.getElementById('btn-solve2').addEventListener('click', () => {
346 |   new nonogram.Solver(
347 |     parseArray(document.getElementById('txt-row-hints2').value),
348 |     parseArray(document.getElementById('txt-col-hints2').value),
349 |     'adv-solve2',
350 |     { theme: {
351 |         isMeshed: true,
352 |         isBoldMeshOnly: true,
353 |         isMeshOnTop: true },
354 |       delay: +document.getElementById('delay').value,
355 |       onSuccess(time) {
356 |         document.getElementById('timecost').innerHTML =
357 |         'Solved in ' + time + 'ms.'
358 |       } }).solve()
359 | })
360 | 361 | 362 | 363 | -------------------------------------------------------------------------------- /docs/nonogram.min.js: -------------------------------------------------------------------------------- 1 | var nonogram=function(a){'use strict';function b(a,b){return a.length===b.length&&a.every((a,c)=>a===b[c])}function c(a){try{return n.createObjectURL(new Blob([a],{type:l}))}catch(c){var b=new m;return b.append(a),n.createObjectURL(b.getBlob(type))}}var d=Math.SQRT1_2,e=Math.PI,g=Math.min,f=Math.floor,h={greyVeryLight:'#ccc',grey:'#555',greyVeryDark:'#111',blue:'#0ebeff',green:'#47cf73',violet:'#ae63e4',yellow:'#fcd000',red:'#ff3c41'};const k={EMPTY:0,FILLED:1,UNSET:2,TEMP_FILLED:3,TEMP_EMPTY:4,INCONSTANT:5};class i{constructor(){this.theme={filledColor:h.grey,unsetColor:h.greyVeryLight,correctColor:h.green,wrongColor:h.red,meshColor:h.yellow,isMeshed:!1,isBoldMeshOnly:!1,isMeshOnTop:!1,boldMeshGap:5}}initCanvas(a){let b=a instanceof HTMLCanvasElement?a:document.getElementById(a);b instanceof HTMLCanvasElement||(b=document.createElement('canvas')),this.canvas=b,this.canvas.nonogram&&this.canvas.nonogram.listeners.forEach(([a,b])=>{this.canvas.removeEventListener(a,b)}),this.canvas.nonogram=this,this.canvas.width=this.theme.width||this.canvas.clientWidth,this.canvas.height=this.canvas.width*(this.m+1)/(this.n+1),this.ctx=this.canvas.getContext('2d')||new CanvasRenderingContext2D,this.initListeners(),this.listeners.forEach(([a,b])=>{this.canvas.addEventListener(a,b)}),this.canvas.oncontextmenu=(a)=>{a.preventDefault()}}initListeners(){this.listeners=[]}removeNonPositiveHints(){function a(a,b,c){c[b]=a.filter((a)=>0{if(b===k.FILLED)c.push(a?c.pop()+1:1);else if(b!==k.EMPTY)throw new Error;return b===k.FILLED},!1),c}isLineCorrect(a,c){try{return b(this.calculateHints(a,c),this.hints[a][c])}catch(a){return!1}}getLocation(a,b){const c=this.canvas.getBoundingClientRect(),e=c.width,f=c.height,g=2*e/3,h=2*f/3,i=g/(this.n+1);return 0>a||a>=e||0>b||b>=f?'outside':0<=a&&a<=g&&0<=b&&b{e.onmessage&&setTimeout(()=>e.onmessage({data:a,target:h}))}};b.call(h),this.postMessage=(a)=>{setTimeout(()=>h.onmessage({data:a,target:e}))},this.isThisThread=!0}}('./worker.ts',function(){function a(a,b){return a.length===b.length&&a.every((a,c)=>a===b[c])}const b={EMPTY:0,FILLED:1,UNSET:2,TEMP_FILLED:3,TEMP_EMPTY:4,INCONSTANT:5},c=(a)=>a.reduce((c,a)=>c+a,0),d=new Map;d.set(b.TEMP_FILLED,b.FILLED),d.set(b.TEMP_EMPTY,b.EMPTY),d.set(b.INCONSTANT,b.UNSET);class e{constructor(a){this.scan=()=>{if(this.updateScanner()){this.delay&&(this.message={type:'update',grid:this.grid,scanner:this.scanner,hints:this.hints},postMessage(this.message)),this.isError=!0;const{direction:a,i:c}=this.scanner;this.currentHints=this.hints[a][c],this.currentHints.unchanged=!0,this.currentLine=this.getSingleLine(a,c);const d=this.currentLine.every((a)=>a!==b.UNSET);return d||(this.solveSingleLine(),this.setBackToGrid(this.currentLine)),this.isLineCorrect(a,c)&&(this.hints[a][c].isCorrect=!0,this.isError=!1),this.isError?(this.message={type:'error',grid:this.grid,scanner:this.scanner,hints:this.hints},void postMessage(this.message)):void(this.delay?setTimeout(this.scan,this.delay):this.scan())}},this.hints=a.hints,this.delay=a.delay,this.grid=a.grid,this.scanner={direction:'row',i:-1},this.possibleBlanks={row:[],column:[]},this.scan()}getSingleLine(a,b){const c=[],d=this.grid.length,e=this.grid.length&&this.grid[0].length;if('row'===a)for(let a=0;a{if(c===b.FILLED)d.push(a?d.pop()+1:1);else if(c!==b.EMPTY)throw new Error;return c===b.FILLED},!1),d}isLineCorrect(b,c){try{return a(this.calculateHints(b,c),this.hints[b][c])}catch(a){return!1}}updateScanner(){let a;do if(this.isError=!1,this.scanner.i+=1,void 0===this.hints[this.scanner.direction][this.scanner.i]&&(this.scanner.direction='row'===this.scanner.direction?'column':'row',this.scanner.i=0),a=this.hints[this.scanner.direction][this.scanner.i],this.hints.row.every((a)=>!!a.unchanged)&&this.hints.column.every((a)=>!!a.unchanged))return this.message={type:'finish',grid:this.grid,hints:this.hints},postMessage(this.message),!1;while(a.isCorrect||a.unchanged);return!0}setBackToGrid(a){const{direction:b,i:c}=this.scanner;'row'===b?a.forEach((a,b)=>{d.has(a)&&this.grid[c][b]!==d.get(a)&&(this.grid[c][b]=d.get(a),this.hints.column[b].unchanged=!1)}):'column'===b&&a.forEach((a,b)=>{d.has(a)&&this.grid[b][c]!==d.get(a)&&(this.grid[b][c]=d.get(a),this.hints.row[b].unchanged=!1)})}solveSingleLine(){this.isError=!0;const{direction:a,i:b}=this.scanner;void 0===this.possibleBlanks[a][b]&&(this.possibleBlanks[a][b]=[],this.findAll(this.currentLine.length-c(this.currentHints)+1)),this.merge()}findAll(a,b=[],c=0){if(c===this.currentHints.length){const a=b.slice(0,this.currentHints.length);a[0]-=1;const{direction:c,i:d}=this.scanner;this.possibleBlanks[c][d]&&this.possibleBlanks[c][d].push(a)}for(let d=1;d<=a;d+=1)b[c]=d,this.findAll(a-b[c],b,c+1)}merge(){const{direction:a,i:c}=this.scanner,d=this.possibleBlanks[a][c];d.forEach((a,c)=>{const e=[];for(let d=0;da===b.TEMP_EMPTY&&this.currentLine[c]===b.FILLED||a===b.TEMP_FILLED&&this.currentLine[c]===b.EMPTY);return f?void delete d[c]:void(this.isError=!1,e.forEach((a,c)=>{a===b.TEMP_FILLED?this.currentLine[c]===b.TEMP_EMPTY?this.currentLine[c]=b.INCONSTANT:this.currentLine[c]===b.UNSET&&(this.currentLine[c]=b.TEMP_FILLED):a===b.TEMP_EMPTY&&(this.currentLine[c]===b.TEMP_FILLED?this.currentLine[c]=b.INCONSTANT:this.currentLine[c]===b.UNSET&&(this.currentLine[c]=b.TEMP_EMPTY))}))}),this.possibleBlanks[a][c]=d.filter((a)=>a)}}onmessage=({data:a})=>{new e(a)}});return a.Solver=class extends i{constructor(a,b,c,{theme:d={},delay:e=50,onSuccess:g=()=>{},onError:i=()=>{}}={}){super(),this.worker=new s,this.click=(a)=>{if(!this.isBusy){const b=this.canvas.getBoundingClientRect(),c=a.clientX-b.left,e=a.clientY-b.top,g=2*b.width/3/(this.n+1),d=this.getLocation(c,e);if('grid'===d){if(this.isError)return;const a=f(e/g-0.5),b=f(c/g-0.5);this.grid[a][b]===k.UNSET&&(this.grid[a][b]=k.FILLED,this.hints.row[a].unchanged=!1,this.hints.column[b].unchanged=!1,this.solve())}else'controller'===d&&this.refresh()}},this.theme.filledColor=h.green,this.theme.correctColor=h.green,this.theme.wrongColor=h.yellow,Object.assign(this.theme,d),this.delay=e,this.handleSuccess=g,this.handleError=i,this.hints={row:a.slice(),column:b.slice()},this.removeNonPositiveHints(),this.m=this.hints.row.length,this.n=this.hints.column.length,this.grid=Array(this.m);for(let f=0;f{a.isCorrect=!1,a.unchanged=!1}),this.hints.column.forEach((a)=>{a.isCorrect=!1,a.unchanged=!1}),this.solve()}solve(){this.isBusy||(this.print(),this.isBusy=!0,this.startTime=Date.now(),this.worker.onmessage=({data:a})=>{if(this.canvas.nonogram!==this)return void this.worker.terminate();if(this.scanner=a.scanner,this.grid=a.grid,this.hints=a.hints,'update'!==a.type)if(this.isBusy=!1,'error'===a.type){this.isError=!0;const{direction:a,i:b}=this.scanner;this.handleError(new Error(`Bad hints at ${a} ${b+1}`))}else'finish'===a.type&&(this.isError=!1,this.handleSuccess(Date.now()-this.startTime));this.print()},this.worker.postMessage({delay:this.delay,grid:this.grid,hints:this.hints}))}print(){this.printGrid(),this.printHints(),this.printScanner(),this.printController()}printController(){const{ctx:a}=this,{width:b,height:c}=this.canvas,f=g(b,c)/4,h=this.theme.filledColor;a.clearRect(2*b/3-1,2*c/3-1,b/3+1,c/3+1),this.isBusy||(a.save(),a.translate(0.7*b,0.7*c),a.drawImage(function(){const a=document.createElement('canvas'),b=f/10;a.width=f,a.height=f;const g=a.getContext('2d')||new CanvasRenderingContext2D;return g.translate(f/2,f/2),g.rotate(Math.PI),g.arc(0,0,f/2-b/2,e/2,e/3.9),g.lineWidth=b,g.strokeStyle=h,g.stroke(),g.beginPath(),g.moveTo((f/2+b)*d,(f/2+b)*d),g.lineTo((f/2-2*b)*d,(f/2-2*b)*d),g.lineTo((f/2-2*b)*d,(f/2+b)*d),g.closePath(),g.fillStyle=h,g.fill(),a}(),0,0),a.restore())}printScanner(){if(this.scanner){const{ctx:a}=this,{width:b,height:c}=this.canvas,e=2*b/3/(this.n+1);a.save(),a.translate(e/2,e/2),a.fillStyle=this.isError?this.theme.wrongColor:this.theme.correctColor,a.globalAlpha=0.5,'row'===this.scanner.direction?a.fillRect(0,e*this.scanner.i,b,e):'column'===this.scanner.direction&&a.fillRect(e*this.scanner.i,0,e,c),a.restore()}}},a.Editor=class extends i{constructor(a,b,c,{theme:d={},grid:e=[],threshold:g=0.5,onHintChange:i=()=>{}}={}){super(),this.mousedown=(a)=>{const b=this.canvas.getBoundingClientRect(),c=a.clientX-b.left,e=a.clientY-b.top,g=2*b.width/3/(this.n+1),d=this.getLocation(c,e);if('controller'===d)this.refresh();else if('grid'===d){this.draw.firstI=f(e/g-0.5),this.draw.firstJ=f(c/g-0.5);const a=this.grid[this.draw.firstI][this.draw.firstJ];this.draw.brush=a===k.FILLED?k.EMPTY:k.FILLED,this.isPressed=!0,this.paintCell(this.draw.firstI,this.draw.firstJ),this.draw.lastI=this.draw.firstI,this.draw.lastJ=this.draw.firstJ}},this.mousemove=(a)=>{if(this.isPressed){const b=this.canvas.getBoundingClientRect(),c=a.clientX-b.left,e=a.clientY-b.top,g=2*b.width/3/(this.n+1);if('grid'===this.getLocation(c,e)){const a=f(e/g-0.5),b=f(c/g-0.5);(a!==this.draw.lastI||b!==this.draw.lastJ)&&(void 0===this.draw.direction&&(a===this.draw.firstI?this.draw.direction='row':b===this.draw.firstJ&&(this.draw.direction='column')),('row'===this.draw.direction&&a===this.draw.firstI||'column'===this.draw.direction&&b===this.draw.firstJ)&&(this.paintCell(a,b),this.draw.lastI=a,this.draw.lastJ=b))}}},this.brushUp=()=>{delete this.isPressed,this.draw={}},this.theme.filledColor=h.violet,this.theme.correctColor=h.violet,Object.assign(this.theme,d),this.threshold=g,this.handleHintChange=i,this.m=a,this.n=b,this.grid=Array(this.m);for(let f=0;f{},onAnimationEnd:g=()=>{}}={}){super(),this.mousedown=(a)=>{const b=this.canvas.getBoundingClientRect(),c=a.clientX-b.left,e=a.clientY-b.top,g=2*b.width/3/(this.n+1),d=this.getLocation(c,e);if('controller'===d)this.switchBrush();else if('grid'===d){this.draw.firstI=f(e/g-0.5),this.draw.firstJ=f(c/g-0.5),this.draw.inverted=2===a.button;const b=this.grid[this.draw.firstI][this.draw.firstJ];let d=this.brush;this.draw.inverted&&(d=this.brush===k.FILLED?k.EMPTY:k.FILLED),(b===k.UNSET||d===b)&&(this.draw.mode=d===b?'empty':'filling',this.isPressed=!0,this.switchCell(this.draw.firstI,this.draw.firstJ)),this.draw.lastI=this.draw.firstI,this.draw.lastJ=this.draw.firstJ}},this.mousemove=(a)=>{if(this.isPressed){const b=this.canvas.getBoundingClientRect(),c=a.clientX-b.left,e=a.clientY-b.top,g=2*b.width/3/(this.n+1);if('grid'===this.getLocation(c,e)){const a=f(e/g-0.5),b=f(c/g-0.5);(a!==this.draw.lastI||b!==this.draw.lastJ)&&(void 0===this.draw.direction&&(a===this.draw.firstI?this.draw.direction='row':b===this.draw.firstJ&&(this.draw.direction='column')),('row'===this.draw.direction&&a===this.draw.firstI||'column'===this.draw.direction&&b===this.draw.firstJ)&&(this.switchCell(a,b),this.draw.lastI=a,this.draw.lastJ=b))}}},this.brushUp=()=>{delete this.isPressed,this.draw={}},this.theme.filledColor=h.blue,this.theme.wrongColor=h.grey,this.theme.isMeshed=!0,Object.assign(this.theme,d),this.handleSuccess=e,this.handleAnimationEnd=g,this.hints={row:a.slice(),column:b.slice()},this.removeNonPositiveHints(),this.m=this.hints.row.length,this.n=this.hints.column.length,this.grid=Array(this.m);for(let f=0;f{a.isCorrect=this.isLineCorrect('row',b)}),this.hints.column.forEach((a,b)=>{a.isCorrect=this.isLineCorrect('column',b)}),this.initCanvas(c),this.brush=k.FILLED,this.draw={},this.print()}calculateHints(a,b){const c=[],d=this.getSingleLine(a,b);return d.reduce((a,b)=>(b===k.FILLED&&c.push(a?c.pop()+1:1),b===k.FILLED),!1),c}initListeners(){this.listeners=[['mousedown',this.mousedown],['mousemove',this.mousemove],['mouseup',this.brushUp],['mouseleave',this.brushUp]]}switchBrush(){this.brush=this.brush===k.EMPTY?k.FILLED:k.EMPTY,this.printController()}switchCell(a,b){let c=this.brush;if(this.draw.inverted&&(c=this.brush===k.FILLED?k.EMPTY:k.FILLED),c===k.FILLED&&this.grid[a][b]!==k.EMPTY){this.grid[a][b]='filling'===this.draw.mode?k.FILLED:k.UNSET,this.hints.row[a].isCorrect=this.isLineCorrect('row',a),this.hints.column[b].isCorrect=this.isLineCorrect('column',b),this.print();const c=this.hints.row.every((a)=>!!a.isCorrect)&&this.hints.column.every((a)=>!!a.isCorrect);c&&this.succeed()}else c===k.EMPTY&&this.grid[a][b]!==k.FILLED&&(this.grid[a][b]='filling'===this.draw.mode?k.EMPTY:k.UNSET,this.print())}printCell(a){const{ctx:b}=this,c=2*this.canvas.width/3/(this.n+1);a===k.FILLED?(b.fillStyle=this.theme.filledColor,b.fillRect(0.05*-c,0.05*-c,1.1*c,1.1*c)):a===k.EMPTY?(b.strokeStyle=h.red,b.lineWidth=c/15,b.beginPath(),b.moveTo(0.3*c,0.3*c),b.lineTo(0.7*c,0.7*c),b.moveTo(0.3*c,0.7*c),b.lineTo(0.7*c,0.3*c),b.stroke()):void 0}printController(){function a(){c.save(),c.translate(j,0),c.fillStyle=this.theme.meshColor,c.fillRect(0,0,i,i),c.fillStyle=this.theme.filledColor,c.fillRect(l,l,m,m),c.restore()}function b(){c.save(),c.translate(0,j),c.fillStyle=this.theme.meshColor,c.fillRect(0,0,i,i),c.clearRect(l,l,m,m),c.strokeStyle=h.red,c.lineWidth=l,c.beginPath(),c.moveTo(0.3*i,0.3*i),c.lineTo(0.7*i,0.7*i),c.moveTo(0.3*i,0.7*i),c.lineTo(0.7*i,0.3*i),c.stroke(),c.restore()}const{ctx:c}=this,{width:d,height:e}=this.canvas,f=g(d,e)/4,i=3*f/4,j=f/4,l=f/20,m=i-2*l;c.clearRect(2*d/3-1,2*e/3-1,d/3+1,e/3+1),c.save(),c.translate(0.7*d,0.7*e),this.brush===k.FILLED?(b.call(this),a.call(this)):this.brush===k.EMPTY&&(a.call(this),b.call(this)),c.restore()}succeed(){function a(a){return 1+Math.pow(a-1,3)}this.handleSuccess(),this.listeners.forEach(([a,b])=>{this.canvas.removeEventListener(a,b)});const{ctx:b}=this,{width:c,height:d}=this.canvas,f=g(c,d)/4,i=b.getImageData(0,0,c,d),j=function(){var a=Math.SQRT2;const b=2*f,d=b/10,g=document.createElement('canvas');g.width=b,g.height=b;const i=g.getContext('2d')||new CanvasRenderingContext2D;return i.translate(b/3,5*b/6),i.rotate(-e/4),i.fillStyle=h.green,i.fillRect(0,0,d,-b*a/3),i.fillRect(0,0,2*(b*a)/3,-d),g}();let k=0;const l=()=>{b.putImageData(i,0,0),k+=0.03,b.globalAlpha=a(k),b.clearRect(2*c/3,2*d/3,c/3,d/3),b.drawImage(j,0.7*c-(1-a(k))*f/2,0.7*d-(1-a(k))*f/2,(2-a(k))*f,(2-a(k))*f),1>=k?requestAnimationFrame(l):this.handleAnimationEnd()};l()}},a}({}); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nonogram", 3 | "version": "0.8.0", 4 | "description": "Edit, play and solve nonograms.", 5 | "main": "docs/nonogram.min.js", 6 | "scripts": { 7 | "start": "rollup -cw", 8 | "build": "rollup -c && minify docs/nonogram.js --outFile docs/nonogram.min.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/HandsomeOne/Nonogram.git" 13 | }, 14 | "keywords": [ 15 | "nonogram", 16 | "puzzle" 17 | ], 18 | "author": "Zhou Qi ", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/HandsomeOne/Nonogram/issues" 22 | }, 23 | "homepage": "https://github.com/HandsomeOne/Nonogram", 24 | "devDependencies": { 25 | "babel-minify": "^0.2.0", 26 | "rollup": "^0.50.0", 27 | "rollup-plugin-bundle-worker": "^0.1.0", 28 | "rollup-plugin-typescript": "^0.8.1", 29 | "typescript": "^2.6.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript' 2 | import bundleWorker from 'rollup-plugin-bundle-worker' 3 | 4 | export default { 5 | input: 'src/index.ts', 6 | output: { 7 | file: 'docs/nonogram.js', 8 | format: 'iife', 9 | }, 10 | name: 'nonogram', 11 | plugins: [ 12 | typescript({ typescript: require('typescript') }), 13 | bundleWorker(), 14 | ], 15 | } 16 | -------------------------------------------------------------------------------- /src/Editor.ts: -------------------------------------------------------------------------------- 1 | import Nonogram from './Nonogram' 2 | import $ from './colors' 3 | import Status from './status' 4 | 5 | export default class Editor extends Nonogram { 6 | threshold: number 7 | handleHintChange: (row: LineOfHints[], column: LineOfHints[]) => void 8 | draw: { 9 | firstI?: number 10 | firstJ?: number 11 | lastI?: number 12 | lastJ?: number 13 | inverted?: boolean 14 | mode?: 'empty' | 'filling' 15 | direction?: Direction 16 | brush?: Status 17 | } 18 | isPressed: boolean 19 | 20 | constructor( 21 | m: number, 22 | n: number, 23 | canvas: string | HTMLCanvasElement, 24 | { 25 | theme = {}, 26 | grid = [], 27 | threshold = 0.5, 28 | onHintChange = () => { }, 29 | } = {}, 30 | ) { 31 | super() 32 | this.theme.filledColor = $.violet 33 | this.theme.correctColor = $.violet 34 | Object.assign(this.theme, theme) 35 | 36 | this.threshold = threshold 37 | this.handleHintChange = onHintChange 38 | 39 | this.m = m 40 | this.n = n 41 | this.grid = new Array(this.m) 42 | for (let i = 0; i < this.m; i += 1) { 43 | this.grid[i] = new Array(this.n) 44 | for (let j = 0; j < this.n; j += 1) { 45 | if (grid.length) { 46 | this.grid[i][j] = (grid[i] && (grid)[i][j]) ? Status.FILLED : Status.EMPTY 47 | } else { 48 | this.grid[i][j] = (Math.random() < this.threshold) ? Status.FILLED : Status.EMPTY 49 | } 50 | } 51 | } 52 | this.hints = { 53 | row: new Array(m), 54 | column: new Array(n), 55 | } 56 | for (let i = 0; i < this.m; i += 1) { 57 | this.hints.row[i] = this.calculateHints('row', i) 58 | this.hints.row[i].isCorrect = true 59 | } 60 | for (let j = 0; j < this.n; j += 1) { 61 | this.hints.column[j] = this.calculateHints('column', j) 62 | this.hints.column[j].isCorrect = true 63 | } 64 | 65 | this.initCanvas(canvas) 66 | 67 | this.draw = {} 68 | this.print() 69 | this.handleHintChange(this.hints.row, this.hints.column) 70 | } 71 | 72 | initListeners() { 73 | this.listeners = [ 74 | ['mousedown', this.mousedown], 75 | ['mousemove', this.mousemove], 76 | ['mouseup', this.brushUp], 77 | ['mouseleave', this.brushUp], 78 | ] 79 | } 80 | mousedown = (e: MouseEvent) => { 81 | const rect = this.canvas.getBoundingClientRect() 82 | const x = e.clientX - rect.left 83 | const y = e.clientY - rect.top 84 | const d = rect.width * 2 / 3 / (this.n + 1) 85 | const location = this.getLocation(x, y) 86 | if (location === 'controller') { 87 | this.refresh() 88 | } else if (location === 'grid') { 89 | this.draw.firstI = Math.floor(y / d - 0.5) 90 | this.draw.firstJ = Math.floor(x / d - 0.5) 91 | const cell = this.grid[this.draw.firstI][this.draw.firstJ] 92 | this.draw.brush = (cell === Status.FILLED) ? Status.EMPTY : Status.FILLED 93 | this.isPressed = true 94 | this.paintCell(this.draw.firstI, this.draw.firstJ) 95 | this.draw.lastI = this.draw.firstI 96 | this.draw.lastJ = this.draw.firstJ 97 | } 98 | } 99 | mousemove = (e: MouseEvent) => { 100 | if (this.isPressed) { 101 | const rect = this.canvas.getBoundingClientRect() 102 | const x = e.clientX - rect.left 103 | const y = e.clientY - rect.top 104 | const d = rect.width * 2 / 3 / (this.n + 1) 105 | if (this.getLocation(x, y) === 'grid') { 106 | const i = Math.floor(y / d - 0.5) 107 | const j = Math.floor(x / d - 0.5) 108 | if (i !== this.draw.lastI || j !== this.draw.lastJ) { 109 | if (this.draw.direction === undefined) { 110 | if (i === this.draw.firstI) { 111 | this.draw.direction = 'row' 112 | } else if (j === this.draw.firstJ) { 113 | this.draw.direction = 'column' 114 | } 115 | } 116 | if ((this.draw.direction === 'row' && i === this.draw.firstI) || 117 | (this.draw.direction === 'column' && j === this.draw.firstJ)) { 118 | this.paintCell(i, j) 119 | this.draw.lastI = i 120 | this.draw.lastJ = j 121 | } 122 | } 123 | } 124 | } 125 | } 126 | brushUp = () => { 127 | delete this.isPressed 128 | this.draw = {} 129 | } 130 | paintCell(i: number, j: number) { 131 | this.grid[i][j] = this.draw.brush 132 | this.hints.row[i] = this.calculateHints('row', i) 133 | this.hints.row[i].isCorrect = true 134 | this.hints.column[j] = this.calculateHints('column', j) 135 | this.hints.column[j].isCorrect = true 136 | this.print() 137 | this.handleHintChange(this.hints.row, this.hints.column) 138 | } 139 | refresh() { 140 | for (let i = 0; i < this.m; i += 1) { 141 | for (let j = 0; j < this.n; j += 1) { 142 | this.grid[i][j] = (Math.random() < this.threshold) ? Status.FILLED : Status.EMPTY 143 | } 144 | } 145 | for (let i = 0; i < this.m; i += 1) { 146 | this.hints.row[i] = this.calculateHints('row', i) 147 | this.hints.row[i].isCorrect = true 148 | } 149 | for (let j = 0; j < this.n; j += 1) { 150 | this.hints.column[j] = this.calculateHints('column', j) 151 | this.hints.column[j].isCorrect = true 152 | } 153 | this.print() 154 | this.handleHintChange(this.hints.row, this.hints.column) 155 | } 156 | printController() { 157 | const { ctx } = this 158 | const { width: w, height: h } = this.canvas 159 | const controllerSize = Math.min(w, h) / 4 160 | const filledColor = this.theme.filledColor 161 | 162 | function getCycle() { 163 | const cycle = document.createElement('canvas') 164 | const borderWidth = controllerSize / 10 165 | cycle.width = controllerSize 166 | cycle.height = controllerSize 167 | 168 | const c = cycle.getContext('2d') || new CanvasRenderingContext2D() 169 | c.translate(controllerSize / 2, controllerSize / 2) 170 | c.arc(0, 0, controllerSize / 2 - borderWidth / 2, Math.PI / 2, Math.PI / 3.9) 171 | c.lineWidth = borderWidth 172 | c.strokeStyle = filledColor 173 | c.stroke() 174 | c.beginPath() 175 | c.moveTo((controllerSize / 2 + borderWidth) * Math.SQRT1_2, 176 | (controllerSize / 2 + borderWidth) * Math.SQRT1_2) 177 | c.lineTo((controllerSize / 2 - borderWidth * 2) * Math.SQRT1_2, 178 | (controllerSize / 2 - borderWidth * 2) * Math.SQRT1_2) 179 | c.lineTo((controllerSize / 2 - borderWidth * 2) * Math.SQRT1_2, 180 | (controllerSize / 2 + borderWidth) * Math.SQRT1_2) 181 | c.closePath() 182 | c.fillStyle = filledColor 183 | c.fill() 184 | 185 | return cycle 186 | } 187 | 188 | ctx.clearRect(w * 2 / 3 - 1, h * 2 / 3 - 1, w / 3 + 1, h / 3 + 1) 189 | ctx.save() 190 | ctx.translate(w * 0.7, h * 0.7) 191 | ctx.drawImage(getCycle(), 0, 0) 192 | ctx.restore() 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/Game.ts: -------------------------------------------------------------------------------- 1 | import Nonogram from './Nonogram' 2 | import $ from './colors' 3 | import Status from './status' 4 | 5 | export default class Game extends Nonogram { 6 | handleSuccess: () => void 7 | handleAnimationEnd: () => void 8 | 9 | brush: Status 10 | draw: { 11 | firstI?: number 12 | firstJ?: number 13 | lastI?: number 14 | lastJ?: number 15 | inverted?: boolean 16 | mode?: 'empty' | 'filling' 17 | direction?: Direction 18 | } 19 | isPressed: boolean 20 | 21 | constructor( 22 | row: number[][], 23 | column: number[][], 24 | canvas: string | HTMLCanvasElement, 25 | { 26 | theme = {}, 27 | onSuccess = () => { }, 28 | onAnimationEnd = () => { }, 29 | } = {}, 30 | ) { 31 | super() 32 | this.theme.filledColor = $.blue 33 | this.theme.wrongColor = $.grey 34 | this.theme.isMeshed = true 35 | Object.assign(this.theme, theme) 36 | 37 | this.handleSuccess = onSuccess 38 | this.handleAnimationEnd = onAnimationEnd 39 | 40 | this.hints = { 41 | row: row.slice(), 42 | column: column.slice(), 43 | } 44 | this.removeNonPositiveHints() 45 | this.m = this.hints.row.length 46 | this.n = this.hints.column.length 47 | this.grid = new Array(this.m) 48 | for (let i = 0; i < this.m; i += 1) { 49 | this.grid[i] = new Array(this.n).fill(Status.UNSET) 50 | } 51 | this.hints.row.forEach((r, i) => { r.isCorrect = this.isLineCorrect('row', i) }) 52 | this.hints.column.forEach((c, j) => { c.isCorrect = this.isLineCorrect('column', j) }) 53 | 54 | this.initCanvas(canvas) 55 | 56 | this.brush = Status.FILLED 57 | this.draw = {} 58 | this.print() 59 | } 60 | 61 | calculateHints(direction: Direction, i: number) { 62 | const hints: number[] = [] 63 | const line = this.getSingleLine(direction, i) 64 | line.reduce((lastIsFilled, cell) => { 65 | if (cell === Status.FILLED) { 66 | hints.push(lastIsFilled ? hints.pop() + 1 : 1) 67 | } 68 | return cell === Status.FILLED 69 | }, false) 70 | return hints 71 | } 72 | initListeners() { 73 | this.listeners = [ 74 | ['mousedown', this.mousedown], 75 | ['mousemove', this.mousemove], 76 | ['mouseup', this.brushUp], 77 | ['mouseleave', this.brushUp], 78 | ] 79 | } 80 | mousedown = (e: MouseEvent) => { 81 | const rect = this.canvas.getBoundingClientRect() 82 | const x = e.clientX - rect.left 83 | const y = e.clientY - rect.top 84 | const d = rect.width * 2 / 3 / (this.n + 1) 85 | const location = this.getLocation(x, y) 86 | if (location === 'controller') { 87 | this.switchBrush() 88 | } else if (location === 'grid') { 89 | this.draw.firstI = Math.floor(y / d - 0.5) 90 | this.draw.firstJ = Math.floor(x / d - 0.5) 91 | this.draw.inverted = e.button === 2 92 | const cell = this.grid[this.draw.firstI][this.draw.firstJ] 93 | let brush = this.brush 94 | if (this.draw.inverted) { 95 | brush = this.brush === Status.FILLED ? Status.EMPTY : Status.FILLED 96 | } 97 | if (cell === Status.UNSET || brush === cell) { 98 | this.draw.mode = (brush === cell) ? 'empty' : 'filling' 99 | this.isPressed = true 100 | this.switchCell(this.draw.firstI, this.draw.firstJ) 101 | } 102 | this.draw.lastI = this.draw.firstI 103 | this.draw.lastJ = this.draw.firstJ 104 | } 105 | } 106 | mousemove = (e: MouseEvent) => { 107 | if (this.isPressed) { 108 | const rect = this.canvas.getBoundingClientRect() 109 | const x = e.clientX - rect.left 110 | const y = e.clientY - rect.top 111 | const d = rect.width * 2 / 3 / (this.n + 1) 112 | if (this.getLocation(x, y) === 'grid') { 113 | const i = Math.floor(y / d - 0.5) 114 | const j = Math.floor(x / d - 0.5) 115 | if (i !== this.draw.lastI || j !== this.draw.lastJ) { 116 | if (this.draw.direction === undefined) { 117 | if (i === this.draw.firstI) { 118 | this.draw.direction = 'row' 119 | } else if (j === this.draw.firstJ) { 120 | this.draw.direction = 'column' 121 | } 122 | } 123 | if ((this.draw.direction === 'row' && i === this.draw.firstI) || 124 | (this.draw.direction === 'column' && j === this.draw.firstJ)) { 125 | this.switchCell(i, j) 126 | this.draw.lastI = i 127 | this.draw.lastJ = j 128 | } 129 | } 130 | } 131 | } 132 | } 133 | switchBrush() { 134 | this.brush = (this.brush === Status.EMPTY) ? Status.FILLED : Status.EMPTY 135 | this.printController() 136 | } 137 | brushUp = () => { 138 | delete this.isPressed 139 | this.draw = {} 140 | } 141 | switchCell(i: number, j: number) { 142 | let brush = this.brush 143 | if (this.draw.inverted) { 144 | brush = this.brush === Status.FILLED ? Status.EMPTY : Status.FILLED 145 | } 146 | if (brush === Status.FILLED && this.grid[i][j] !== Status.EMPTY) { 147 | this.grid[i][j] = (this.draw.mode === 'filling') ? Status.FILLED : Status.UNSET 148 | this.hints.row[i].isCorrect = this.isLineCorrect('row', i) 149 | this.hints.column[j].isCorrect = this.isLineCorrect('column', j) 150 | this.print() 151 | const correct = this.hints.row.every(singleRow => !!singleRow.isCorrect) && 152 | this.hints.column.every(singleCol => !!singleCol.isCorrect) 153 | if (correct) { 154 | this.succeed() 155 | } 156 | } else if (brush === Status.EMPTY && this.grid[i][j] !== Status.FILLED) { 157 | this.grid[i][j] = (this.draw.mode === 'filling') ? Status.EMPTY : Status.UNSET 158 | this.print() 159 | } 160 | } 161 | 162 | printCell(status: Status) { 163 | const { ctx } = this 164 | const d = this.canvas.width * 2 / 3 / (this.n + 1) 165 | switch (status) { 166 | case Status.FILLED: 167 | ctx.fillStyle = this.theme.filledColor 168 | ctx.fillRect(-d * 0.05, -d * 0.05, d * 1.1, d * 1.1) 169 | break 170 | case Status.EMPTY: 171 | ctx.strokeStyle = $.red 172 | ctx.lineWidth = d / 15 173 | ctx.beginPath() 174 | ctx.moveTo(d * 0.3, d * 0.3) 175 | ctx.lineTo(d * 0.7, d * 0.7) 176 | ctx.moveTo(d * 0.3, d * 0.7) 177 | ctx.lineTo(d * 0.7, d * 0.3) 178 | ctx.stroke() 179 | break 180 | } 181 | } 182 | printController() { 183 | const { ctx } = this 184 | const { width: w, height: h } = this.canvas 185 | const controllerSize = Math.min(w, h) / 4 186 | const outerSize = controllerSize * 3 / 4 187 | const offset = controllerSize / 4 188 | const borderWidth = controllerSize / 20 189 | const innerSize = outerSize - 2 * borderWidth 190 | 191 | function printFillingBrush() { 192 | ctx.save() 193 | ctx.translate(offset, 0) 194 | ctx.fillStyle = this.theme.meshColor 195 | ctx.fillRect(0, 0, outerSize, outerSize) 196 | ctx.fillStyle = this.theme.filledColor 197 | ctx.fillRect(borderWidth, borderWidth, innerSize, innerSize) 198 | ctx.restore() 199 | } 200 | 201 | function printEmptyBrush() { 202 | ctx.save() 203 | ctx.translate(0, offset) 204 | ctx.fillStyle = this.theme.meshColor 205 | ctx.fillRect(0, 0, outerSize, outerSize) 206 | ctx.clearRect(borderWidth, borderWidth, innerSize, innerSize) 207 | ctx.strokeStyle = $.red 208 | ctx.lineWidth = borderWidth 209 | ctx.beginPath() 210 | ctx.moveTo(outerSize * 0.3, outerSize * 0.3) 211 | ctx.lineTo(outerSize * 0.7, outerSize * 0.7) 212 | ctx.moveTo(outerSize * 0.3, outerSize * 0.7) 213 | ctx.lineTo(outerSize * 0.7, outerSize * 0.3) 214 | ctx.stroke() 215 | ctx.restore() 216 | } 217 | 218 | ctx.clearRect(w * 2 / 3 - 1, h * 2 / 3 - 1, w / 3 + 1, h / 3 + 1) 219 | ctx.save() 220 | ctx.translate(w * 0.7, h * 0.7) 221 | if (this.brush === Status.FILLED) { 222 | printEmptyBrush.call(this) 223 | printFillingBrush.call(this) 224 | } else if (this.brush === Status.EMPTY) { 225 | printFillingBrush.call(this) 226 | printEmptyBrush.call(this) 227 | } 228 | ctx.restore() 229 | } 230 | 231 | succeed() { 232 | this.handleSuccess() 233 | this.listeners.forEach(([type, listener]) => { 234 | this.canvas.removeEventListener(type, listener) 235 | }) 236 | const { ctx } = this 237 | const { width: w, height: h } = this.canvas 238 | const controllerSize = Math.min(w, h) / 4 239 | const background = ctx.getImageData(0, 0, w, h) 240 | 241 | function getTick() { 242 | const size = controllerSize * 2 243 | const borderWidth = size / 10 244 | const tick = document.createElement('canvas') 245 | tick.width = size 246 | tick.height = size 247 | 248 | const c = tick.getContext('2d') || new CanvasRenderingContext2D() 249 | c.translate(size / 3, size * 5 / 6) 250 | c.rotate(-Math.PI / 4) 251 | c.fillStyle = $.green 252 | c.fillRect(0, 0, borderWidth, -size * Math.SQRT2 / 3) 253 | c.fillRect(0, 0, size * Math.SQRT2 * 2 / 3, -borderWidth) 254 | 255 | return tick 256 | } 257 | 258 | const tick = getTick() 259 | let t = 0 260 | 261 | function f(_: number) { 262 | return 1 + Math.pow(_ - 1, 3) 263 | } 264 | 265 | const fadeTickIn = () => { 266 | ctx.putImageData(background, 0, 0) 267 | t += 0.03 268 | ctx.globalAlpha = f(t) 269 | ctx.clearRect(w * 2 / 3, h * 2 / 3, w / 3, h / 3) 270 | ctx.drawImage(tick, 271 | w * 0.7 - (1 - f(t)) * controllerSize / 2, 272 | h * 0.7 - (1 - f(t)) * controllerSize / 2, 273 | (2 - f(t)) * controllerSize, 274 | (2 - f(t)) * controllerSize) 275 | if (t <= 1) { 276 | requestAnimationFrame(fadeTickIn) 277 | } else { 278 | this.handleAnimationEnd() 279 | } 280 | } 281 | 282 | fadeTickIn() 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /src/Nonogram.ts: -------------------------------------------------------------------------------- 1 | import $ from './colors' 2 | import Status from './status' 3 | 4 | interface NonogramCanvas extends HTMLCanvasElement { 5 | nonogram: Nonogram 6 | } 7 | 8 | function eekwall(arr1: any[], arr2: any[]) { 9 | return arr1.length === arr2.length && 10 | arr1.every((e, i) => e === arr2[i]) 11 | } 12 | 13 | abstract class Nonogram { 14 | theme: Theme 15 | canvas: NonogramCanvas 16 | ctx: CanvasRenderingContext2D 17 | listeners: [string, EventListener][] 18 | m: number 19 | n: number 20 | grid: Status[][] 21 | hints: { 22 | row: LineOfHints[] 23 | column: LineOfHints[] 24 | } 25 | 26 | constructor() { 27 | this.theme = { 28 | filledColor: $.grey, 29 | unsetColor: $.greyVeryLight, 30 | correctColor: $.green, 31 | wrongColor: $.red, 32 | meshColor: $.yellow, 33 | isMeshed: false, 34 | isBoldMeshOnly: false, 35 | isMeshOnTop: false, 36 | boldMeshGap: 5, 37 | } 38 | } 39 | 40 | initCanvas(canvas: string | HTMLCanvasElement) { 41 | let _canvas = canvas instanceof HTMLCanvasElement ? canvas : document.getElementById(canvas) 42 | if (!(_canvas instanceof HTMLCanvasElement)) { 43 | _canvas = document.createElement('canvas') 44 | } 45 | this.canvas = _canvas 46 | if (this.canvas.nonogram) { 47 | this.canvas.nonogram.listeners.forEach(([type, listener]) => { 48 | this.canvas.removeEventListener(type, listener) 49 | }) 50 | } 51 | this.canvas.nonogram = this 52 | this.canvas.width = this.theme.width || this.canvas.clientWidth 53 | this.canvas.height = this.canvas.width * (this.m + 1) / (this.n + 1) 54 | 55 | this.ctx = this.canvas.getContext('2d') || new CanvasRenderingContext2D() 56 | 57 | this.initListeners() 58 | this.listeners.forEach(([type, listener]) => { 59 | this.canvas.addEventListener(type, listener) 60 | }) 61 | this.canvas.oncontextmenu = (e) => { e.preventDefault() } 62 | } 63 | initListeners() { 64 | this.listeners = [] 65 | } 66 | removeNonPositiveHints() { 67 | function removeNonPositiveElement(array: number[], j: number, self: number[][]) { 68 | self[j] = array.filter(v => v > 0) 69 | } 70 | this.hints.row.forEach(removeNonPositiveElement) 71 | this.hints.column.forEach(removeNonPositiveElement) 72 | } 73 | getSingleLine(direction: Direction, i: number): Status[] { 74 | const g: number[] = [] 75 | if (direction === 'row') { 76 | for (let j = 0; j < this.n; j += 1) { 77 | g[j] = this.grid[i][j] 78 | } 79 | } else if (direction === 'column') { 80 | for (let j = 0; j < this.m; j += 1) { 81 | g[j] = this.grid[j][i] 82 | } 83 | } 84 | return g 85 | } 86 | calculateHints(direction: Direction, i: number) { 87 | const hints: number[] = [] 88 | const line = this.getSingleLine(direction, i) 89 | line.reduce((lastIsFilled, cell) => { 90 | if (cell === Status.FILLED) { 91 | hints.push(lastIsFilled ? hints.pop() + 1 : 1) 92 | } else if (cell !== Status.EMPTY) { 93 | throw new Error 94 | } 95 | return cell === Status.FILLED 96 | }, false) 97 | return hints 98 | } 99 | isLineCorrect(direction: Direction, i: number) { 100 | try { 101 | return eekwall(this.calculateHints(direction, i), this.hints[direction][i]) 102 | } catch (e) { 103 | return false 104 | } 105 | } 106 | 107 | getLocation(x: number, y: number) { 108 | const rect = this.canvas.getBoundingClientRect() 109 | const w = rect.width 110 | const h = rect.height 111 | const w23 = w * 2 / 3 112 | const h23 = h * 2 / 3 113 | const d = w23 / (this.n + 1) 114 | 115 | if (x < 0 || x >= w || y < 0 || y >= h) { 116 | return 'outside' 117 | } 118 | if (x >= 0 && x <= w23 && y >= 0 && y < h23) { 119 | if (d / 2 <= x && x < w23 - d / 2 && d / 2 <= y && y < h23 - d / 2) { 120 | return 'grid' 121 | } 122 | return 'limbo' 123 | } 124 | if (w23 <= x && x < w && h23 <= y && y < h) { 125 | return 'controller' 126 | } 127 | return 'hints' 128 | } 129 | 130 | print() { 131 | this.printGrid() 132 | this.printHints() 133 | this.printController() 134 | } 135 | printGrid() { 136 | const { ctx } = this 137 | const { width: w, height: h } = this.canvas 138 | const d = w * 2 / 3 / (this.n + 1) 139 | 140 | ctx.clearRect(-1, -1, w * 2 / 3 + 1, h * 2 / 3 + 1) 141 | if (this.theme.isMeshed && !this.theme.isMeshOnTop) { 142 | this.printMesh() 143 | } 144 | ctx.save() 145 | ctx.translate(d / 2, d / 2) 146 | for (let i = 0; i < this.m; i += 1) { 147 | for (let j = 0; j < this.n; j += 1) { 148 | ctx.save() 149 | ctx.translate(d * j, d * i) 150 | this.printCell(this.grid[i][j]) 151 | ctx.restore() 152 | } 153 | } 154 | ctx.restore() 155 | if (this.theme.isMeshed && this.theme.isMeshOnTop) { 156 | this.printMesh() 157 | } 158 | } 159 | printCell(status: Status) { 160 | const { ctx } = this 161 | const d = this.canvas.width * 2 / 3 / (this.n + 1) 162 | switch (status) { 163 | case Status.UNSET: 164 | ctx.fillStyle = this.theme.unsetColor 165 | ctx.fillRect(d * 0.05, d * 0.05, d * 0.9, d * 0.9) 166 | break 167 | case Status.FILLED: 168 | ctx.fillStyle = this.theme.filledColor 169 | ctx.fillRect(-d * 0.05, -d * 0.05, d * 1.1, d * 1.1) 170 | break 171 | } 172 | } 173 | printMesh() { 174 | const { ctx } = this 175 | const d = this.canvas.width * 2 / 3 / (this.n + 1) 176 | 177 | ctx.save() 178 | ctx.translate(d / 2, d / 2) 179 | ctx.beginPath() 180 | for (let i = 1; i < this.m; i += 1) { 181 | if (!this.theme.isBoldMeshOnly) { 182 | ctx.moveTo(0, i * d) 183 | ctx.lineTo(this.n * d, i * d) 184 | } 185 | if (i % this.theme.boldMeshGap === 0) { 186 | ctx.moveTo(0, i * d) 187 | ctx.lineTo(this.n * d, i * d) 188 | if (!this.theme.isBoldMeshOnly) { 189 | ctx.moveTo(0, i * d - 1) 190 | ctx.lineTo(this.n * d, i * d - 1) 191 | ctx.moveTo(0, i * d + 1) 192 | ctx.lineTo(this.n * d, i * d + 1) 193 | } 194 | } 195 | } 196 | for (let j = 1; j < this.n; j += 1) { 197 | if (!this.theme.isBoldMeshOnly) { 198 | ctx.moveTo(j * d, 0) 199 | ctx.lineTo(j * d, this.m * d) 200 | } 201 | if (j % this.theme.boldMeshGap === 0) { 202 | ctx.moveTo(j * d, 0) 203 | ctx.lineTo(j * d, this.m * d) 204 | if (!this.theme.isBoldMeshOnly) { 205 | ctx.moveTo(j * d - 1, 0) 206 | ctx.lineTo(j * d - 1, this.m * d) 207 | ctx.moveTo(j * d + 1, 0) 208 | ctx.lineTo(j * d + 1, this.m * d) 209 | } 210 | } 211 | } 212 | ctx.lineWidth = 1 213 | ctx.strokeStyle = this.theme.meshColor 214 | ctx.stroke() 215 | ctx.restore() 216 | } 217 | printHints() { 218 | const { ctx } = this 219 | const { width: w, height: h } = this.canvas 220 | const d = w * 2 / 3 / (this.n + 1) 221 | 222 | ctx.clearRect(w * 2 / 3 - 1, -1, w * 3 + 1, h * 2 / 3 + 1) 223 | ctx.clearRect(-1, h * 2 / 3 - 1, w * 2 / 3 + 1, h / 3 + 1) 224 | ctx.save() 225 | ctx.translate(d / 2, d / 2) 226 | for (let i = 0; i < this.m; i += 1) { 227 | for (let j = 0; j < this.hints.row[i].length; j += 1) { 228 | this.printSingleHint('row', i, j) 229 | } 230 | if (this.hints.row[i].length === 0) { 231 | this.printSingleHint('row', i, 0) 232 | } 233 | } 234 | for (let j = 0; j < this.n; j += 1) { 235 | for (let i = 0; i < this.hints.column[j].length; i += 1) { 236 | this.printSingleHint('column', j, i) 237 | } 238 | if (this.hints.column[j].length === 0) { 239 | this.printSingleHint('column', j, 0) 240 | } 241 | } 242 | ctx.restore() 243 | } 244 | printSingleHint(direction: Direction, i: number, j: number) { 245 | const { ctx } = this 246 | const { width: w, height: h } = this.canvas 247 | const d = w * 2 / 3 / (this.n + 1) 248 | 249 | ctx.textAlign = 'center' 250 | ctx.textBaseline = 'middle' 251 | ctx.font = `${d}pt "Courier New", Inconsolata, Consolas, monospace` 252 | const line = this.hints[direction][i] 253 | ctx.fillStyle = line.isCorrect ? this.theme.correctColor : this.theme.wrongColor 254 | ctx.globalAlpha = (!line.isCorrect && line.unchanged) ? 0.5 : 1 255 | if (direction === 'row') { 256 | ctx.fillText(`${this.hints.row[i][j] || 0}`, 257 | w * 2 / 3 + d * j, d * (i + 0.5), d * 0.8) 258 | } else if (direction === 'column') { 259 | ctx.fillText(`${this.hints.column[i][j] || 0}`, 260 | d * (i + 0.5), h * 2 / 3 + d * j, d * 0.8) 261 | } 262 | } 263 | abstract printController(): void 264 | } 265 | 266 | export default Nonogram 267 | -------------------------------------------------------------------------------- /src/Solver.ts: -------------------------------------------------------------------------------- 1 | import Nonogram from './Nonogram' 2 | import $ from './colors' 3 | import Status from './status' 4 | 5 | import SolverWorker from 'worker!./worker.ts' 6 | 7 | export default class Solver extends Nonogram { 8 | worker: Worker = new SolverWorker() 9 | 10 | delay: number 11 | handleSuccess: (time: number) => void 12 | handleError: (e: Error) => void 13 | isBusy: boolean 14 | isError: boolean 15 | scanner?: { 16 | direction: Direction 17 | i: number 18 | } 19 | startTime: number 20 | 21 | constructor( 22 | row: number[][], 23 | column: number[][], 24 | canvas: string | HTMLCanvasElement, 25 | { 26 | theme = {}, 27 | delay = 50, 28 | onSuccess = () => { }, 29 | onError = () => { }, 30 | } = {}, 31 | ) { 32 | super() 33 | this.theme.filledColor = $.green 34 | this.theme.correctColor = $.green 35 | this.theme.wrongColor = $.yellow 36 | Object.assign(this.theme, theme) 37 | 38 | this.delay = delay 39 | this.handleSuccess = onSuccess 40 | this.handleError = onError 41 | 42 | this.hints = { 43 | row: row.slice(), 44 | column: column.slice(), 45 | } 46 | this.removeNonPositiveHints() 47 | this.m = this.hints.row.length 48 | this.n = this.hints.column.length 49 | this.grid = new Array(this.m) 50 | for (let i = 0; i < this.m; i += 1) { 51 | this.grid[i] = new Array(this.n).fill(Status.UNSET) 52 | } 53 | 54 | this.initCanvas(canvas) 55 | this.print() 56 | } 57 | 58 | initListeners() { 59 | this.listeners = [ 60 | ['click', this.click], 61 | ] 62 | } 63 | click = (e: MouseEvent) => { 64 | if (this.isBusy) return 65 | 66 | const rect = this.canvas.getBoundingClientRect() 67 | const x = e.clientX - rect.left 68 | const y = e.clientY - rect.top 69 | const d = rect.width * 2 / 3 / (this.n + 1) 70 | const location = this.getLocation(x, y) 71 | if (location === 'grid') { 72 | if (this.isError) return 73 | 74 | const i = Math.floor(y / d - 0.5) 75 | const j = Math.floor(x / d - 0.5) 76 | if (this.grid[i][j] === Status.UNSET) { 77 | this.grid[i][j] = Status.FILLED 78 | this.hints.row[i].unchanged = false 79 | this.hints.column[j].unchanged = false 80 | this.solve() 81 | } 82 | } else if (location === 'controller') { 83 | this.refresh() 84 | } 85 | } 86 | private refresh() { 87 | this.grid = new Array(this.m) 88 | for (let i = 0; i < this.m; i += 1) { 89 | this.grid[i] = new Array(this.n).fill(Status.UNSET) 90 | } 91 | this.hints.row.forEach((r) => { 92 | r.isCorrect = false 93 | r.unchanged = false 94 | }) 95 | this.hints.column.forEach((c) => { 96 | c.isCorrect = false 97 | c.unchanged = false 98 | }) 99 | this.solve() 100 | } 101 | solve() { 102 | if (this.isBusy) return 103 | 104 | this.print() 105 | this.isBusy = true 106 | this.startTime = Date.now() 107 | this.worker.onmessage = ({ data }: {data: SolverMessage}) => { 108 | if (this.canvas.nonogram !== this) { 109 | this.worker.terminate() 110 | return 111 | } 112 | 113 | this.scanner = data.scanner 114 | this.grid = data.grid 115 | this.hints = data.hints 116 | if (data.type !== 'update') { 117 | this.isBusy = false 118 | if (data.type === 'error') { 119 | this.isError = true 120 | const { direction, i } = this.scanner 121 | this.handleError(new Error( 122 | `Bad hints at ${direction} ${i + 1}` 123 | )) 124 | } else if (data.type === 'finish') { 125 | this.isError = false 126 | this.handleSuccess(Date.now() - this.startTime) 127 | } 128 | } 129 | this.print() 130 | } 131 | this.worker.postMessage({ 132 | delay: this.delay, 133 | grid: this.grid, 134 | hints: this.hints, 135 | }) 136 | } 137 | 138 | print() { 139 | this.printGrid() 140 | this.printHints() 141 | this.printScanner() 142 | this.printController() 143 | } 144 | printController() { 145 | const { ctx } = this 146 | const { width: w, height: h } = this.canvas 147 | const controllerSize = Math.min(w, h) / 4 148 | const filledColor = this.theme.filledColor 149 | 150 | function getCycle() { 151 | const cycle = document.createElement('canvas') 152 | const borderWidth = controllerSize / 10 153 | cycle.width = controllerSize 154 | cycle.height = controllerSize 155 | 156 | const c = cycle.getContext('2d') || new CanvasRenderingContext2D() 157 | c.translate(controllerSize / 2, controllerSize / 2) 158 | c.rotate(Math.PI) 159 | c.arc(0, 0, controllerSize / 2 - borderWidth / 2, Math.PI / 2, Math.PI / 3.9) 160 | c.lineWidth = borderWidth 161 | c.strokeStyle = filledColor 162 | c.stroke() 163 | c.beginPath() 164 | c.moveTo((controllerSize / 2 + borderWidth) * Math.SQRT1_2, 165 | (controllerSize / 2 + borderWidth) * Math.SQRT1_2) 166 | c.lineTo((controllerSize / 2 - borderWidth * 2) * Math.SQRT1_2, 167 | (controllerSize / 2 - borderWidth * 2) * Math.SQRT1_2) 168 | c.lineTo((controllerSize / 2 - borderWidth * 2) * Math.SQRT1_2, 169 | (controllerSize / 2 + borderWidth) * Math.SQRT1_2) 170 | c.closePath() 171 | c.fillStyle = filledColor 172 | c.fill() 173 | 174 | return cycle 175 | } 176 | 177 | ctx.clearRect(w * 2 / 3 - 1, h * 2 / 3 - 1, w / 3 + 1, h / 3 + 1) 178 | if (this.isBusy) return 179 | 180 | ctx.save() 181 | ctx.translate(w * 0.7, h * 0.7) 182 | ctx.drawImage(getCycle(), 0, 0) 183 | ctx.restore() 184 | } 185 | printScanner() { 186 | if (!this.scanner) return 187 | 188 | const { ctx } = this 189 | const { width: w, height: h } = this.canvas 190 | const d = w * 2 / 3 / (this.n + 1) 191 | 192 | ctx.save() 193 | ctx.translate(d / 2, d / 2) 194 | ctx.fillStyle = this.isError ? this.theme.wrongColor : this.theme.correctColor 195 | ctx.globalAlpha = 0.5 196 | if (this.scanner.direction === 'row') { 197 | ctx.fillRect(0, d * this.scanner.i, w, d) 198 | } else if (this.scanner.direction === 'column') { 199 | ctx.fillRect(d * this.scanner.i, 0, d, h) 200 | } 201 | ctx.restore() 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/colors.ts: -------------------------------------------------------------------------------- 1 | // codepen.io/guide 2 | 3 | export default { 4 | greyVeryLight: '#ccc', 5 | grey: '#555', 6 | greyVeryDark: '#111', 7 | blue: '#0ebeff', 8 | green: '#47cf73', 9 | violet: '#ae63e4', 10 | yellow: '#fcd000', 11 | red: '#ff3c41', 12 | } 13 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | type Direction = 'row' | 'column' 2 | 3 | interface LineOfHints extends Array { 4 | isCorrect?: boolean 5 | unchanged?: boolean 6 | } 7 | 8 | interface Theme { 9 | filledColor: string 10 | unsetColor: string 11 | correctColor: string 12 | wrongColor: string 13 | meshColor: string 14 | isMeshed: boolean 15 | isBoldMeshOnly: boolean 16 | isMeshOnTop: boolean 17 | boldMeshGap: number 18 | width?: number 19 | } 20 | 21 | interface Scanner { 22 | direction: Direction 23 | i: number 24 | } 25 | 26 | interface SolverMessage { 27 | type: 'error' | 'finish' | 'update' 28 | grid: number[][] 29 | scanner?: Scanner 30 | hints: { 31 | row: LineOfHints[] 32 | column: LineOfHints[] 33 | } 34 | } 35 | 36 | declare module "worker!*" { 37 | const worker: { 38 | prototype: Worker 39 | new(): Worker 40 | } 41 | export default worker 42 | } 43 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Solver from './Solver' 2 | import Editor from './Editor' 3 | import Game from './Game' 4 | 5 | export { Solver, Editor, Game } 6 | -------------------------------------------------------------------------------- /src/status.ts: -------------------------------------------------------------------------------- 1 | type Status = number 2 | const Status = { 3 | EMPTY: 0, 4 | FILLED: 1, 5 | UNSET: 2, 6 | TEMP_FILLED: 3, 7 | TEMP_EMPTY: 4, 8 | INCONSTANT: 5, 9 | } 10 | 11 | export default Status 12 | -------------------------------------------------------------------------------- /src/worker.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | const WorkerStatus = { 5 | EMPTY: 0, 6 | FILLED: 1, 7 | UNSET: 2, 8 | TEMP_FILLED: 3, 9 | TEMP_EMPTY: 4, 10 | INCONSTANT: 5, 11 | } 12 | type status = number 13 | 14 | const sum = (array: number[]) => array.reduce((a, b) => a + b, 0) 15 | 16 | const cellValueMap = new Map() 17 | cellValueMap.set(WorkerStatus.TEMP_FILLED, WorkerStatus.FILLED) 18 | cellValueMap.set(WorkerStatus.TEMP_EMPTY, WorkerStatus.EMPTY) 19 | cellValueMap.set(WorkerStatus.INCONSTANT, WorkerStatus.UNSET) 20 | 21 | 22 | function eekwall(arr1: any[], arr2: any[]) { 23 | return arr1.length === arr2.length && 24 | arr1.every((e, i) => e === arr2[i]) 25 | } 26 | 27 | class Solver { 28 | grid: status[][] 29 | hints: { 30 | row: LineOfHints[] 31 | column: LineOfHints[] 32 | } 33 | isError: Boolean 34 | scanner: Scanner 35 | currentHints: LineOfHints 36 | currentLine: status[] 37 | delay: number 38 | message: SolverMessage 39 | possibleBlanks: { 40 | row: number[][][] 41 | column: number[][][] 42 | } 43 | 44 | constructor(data: any) { 45 | this.hints = data.hints 46 | this.delay = data.delay 47 | this.grid = data.grid 48 | 49 | this.scanner = { 50 | direction: 'row', 51 | i: -1, 52 | } 53 | this.possibleBlanks = { 54 | row: [], 55 | column: [], 56 | } 57 | this.scan() 58 | } 59 | 60 | getSingleLine(direction: Direction, i: number): status[] { 61 | const g: number[] = [] 62 | const m = this.grid.length 63 | const n = this.grid.length && this.grid[0].length 64 | if (direction === 'row') { 65 | for (let j = 0; j < n; j += 1) { 66 | g[j] = this.grid[i][j] 67 | } 68 | } else if (direction === 'column') { 69 | for (let j = 0; j < m; j += 1) { 70 | g[j] = this.grid[j][i] 71 | } 72 | } 73 | return g 74 | } 75 | calculateHints(direction: Direction, i: number) { 76 | const hints: number[] = [] 77 | const line = this.getSingleLine(direction, i) 78 | line.reduce((lastIsFilled, cell) => { 79 | if (cell === WorkerStatus.FILLED) { 80 | hints.push(lastIsFilled ? hints.pop() + 1 : 1) 81 | } else if (cell !== WorkerStatus.EMPTY) { 82 | throw new Error 83 | } 84 | return cell === WorkerStatus.FILLED 85 | }, false) 86 | return hints 87 | } 88 | isLineCorrect(direction: Direction, i: number) { 89 | try { 90 | return eekwall(this.calculateHints(direction, i), this.hints[direction][i]) 91 | } catch (e) { 92 | return false 93 | } 94 | } 95 | 96 | scan = () => { 97 | if (!this.updateScanner()) return 98 | 99 | if (this.delay) { 100 | this.message = { 101 | type: 'update', 102 | grid: this.grid, 103 | scanner: this.scanner, 104 | hints: this.hints, 105 | } 106 | postMessage(this.message) 107 | } 108 | this.isError = true 109 | 110 | const { direction, i } = this.scanner 111 | this.currentHints = this.hints[direction][i] 112 | this.currentHints.unchanged = true 113 | 114 | this.currentLine = this.getSingleLine(direction, i) 115 | const finished = this.currentLine.every(cell => cell !== WorkerStatus.UNSET) 116 | if (!finished) { 117 | this.solveSingleLine() 118 | this.setBackToGrid(this.currentLine) 119 | } 120 | 121 | if (this.isLineCorrect(direction, i)) { 122 | this.hints[direction][i].isCorrect = true 123 | this.isError = false 124 | } 125 | 126 | if (this.isError) { 127 | this.message = { 128 | type: 'error', 129 | grid: this.grid, 130 | scanner: this.scanner, 131 | hints: this.hints, 132 | } 133 | postMessage(this.message) 134 | return 135 | } 136 | if (this.delay) { 137 | setTimeout(this.scan, this.delay) 138 | } else { 139 | this.scan() 140 | } 141 | } 142 | updateScanner() { 143 | let line 144 | do { 145 | this.isError = false 146 | this.scanner.i += 1 147 | if (this.hints[this.scanner.direction][this.scanner.i] === undefined) { 148 | this.scanner.direction = (this.scanner.direction === 'row') ? 'column' : 'row' 149 | this.scanner.i = 0 150 | } 151 | line = this.hints[this.scanner.direction][this.scanner.i] 152 | 153 | if (this.hints.row.every(row => !!row.unchanged) && 154 | this.hints.column.every(col => !!col.unchanged)) { 155 | this.message = { 156 | type: 'finish', 157 | grid: this.grid, 158 | hints: this.hints, 159 | } 160 | postMessage(this.message) 161 | return false 162 | } 163 | } 164 | while (line.isCorrect || line.unchanged) 165 | 166 | return true 167 | } 168 | setBackToGrid(line: status[]) { 169 | const { direction, i } = this.scanner 170 | if (direction === 'row') { 171 | line.forEach((cell, j) => { 172 | if (cellValueMap.has(cell)) { 173 | if (this.grid[i][j] !== cellValueMap.get(cell)) { 174 | this.grid[i][j] = cellValueMap.get(cell) 175 | this.hints.column[j].unchanged = false 176 | } 177 | } 178 | }) 179 | } else if (direction === 'column') { 180 | line.forEach((cell, j) => { 181 | if (cellValueMap.has(cell)) { 182 | if (this.grid[j][i] !== cellValueMap.get(cell)) { 183 | this.grid[j][i] = cellValueMap.get(cell) 184 | this.hints.row[j].unchanged = false 185 | } 186 | } 187 | }) 188 | } 189 | } 190 | 191 | solveSingleLine() { 192 | this.isError = true 193 | const { direction, i } = this.scanner 194 | if (this.possibleBlanks[direction][i] === undefined) { 195 | this.possibleBlanks[direction][i] = [] 196 | this.findAll(this.currentLine.length - sum(this.currentHints) + 1) 197 | } 198 | this.merge() 199 | } 200 | findAll(max: number, array: number[] = [], index = 0) { 201 | if (index === this.currentHints.length) { 202 | const blanks = array.slice(0, this.currentHints.length) 203 | blanks[0] -= 1 204 | const { direction, i } = this.scanner 205 | if (this.possibleBlanks[direction][i]) { 206 | this.possibleBlanks[direction][i].push(blanks) 207 | } 208 | } 209 | 210 | for (let i = 1; i <= max; i += 1) { 211 | array[index] = i 212 | this.findAll(max - array[index], array, index + 1) 213 | } 214 | } 215 | merge() { 216 | const { direction, i } = this.scanner 217 | const possibleBlanks = this.possibleBlanks[direction][i] 218 | possibleBlanks.forEach((blanks, p) => { 219 | const line: status[] = [] 220 | for (let i = 0; i < this.currentHints.length; i += 1) { 221 | line.push(...new Array(blanks[i]).fill(WorkerStatus.TEMP_EMPTY)) 222 | line.push(...new Array(this.currentHints[i]).fill(WorkerStatus.TEMP_FILLED)) 223 | } 224 | line.push(...new Array(this.currentLine.length - line.length).fill(WorkerStatus.TEMP_EMPTY)) 225 | 226 | const improper = line.some((cell, i) => 227 | (cell === WorkerStatus.TEMP_EMPTY && this.currentLine[i] === WorkerStatus.FILLED) || 228 | (cell === WorkerStatus.TEMP_FILLED && this.currentLine[i] === WorkerStatus.EMPTY) 229 | ) 230 | if (improper) { 231 | delete possibleBlanks[p] 232 | return 233 | } 234 | 235 | this.isError = false 236 | line.forEach((cell, i) => { 237 | if (cell === WorkerStatus.TEMP_FILLED) { 238 | if (this.currentLine[i] === WorkerStatus.TEMP_EMPTY) { 239 | this.currentLine[i] = WorkerStatus.INCONSTANT 240 | } else if (this.currentLine[i] === WorkerStatus.UNSET) { 241 | this.currentLine[i] = WorkerStatus.TEMP_FILLED 242 | } 243 | } else if (cell === WorkerStatus.TEMP_EMPTY) { 244 | if (this.currentLine[i] === WorkerStatus.TEMP_FILLED) { 245 | this.currentLine[i] = WorkerStatus.INCONSTANT 246 | } else if (this.currentLine[i] === WorkerStatus.UNSET) { 247 | this.currentLine[i] = WorkerStatus.TEMP_EMPTY 248 | } 249 | } 250 | }) 251 | }) 252 | this.possibleBlanks[direction][i] = possibleBlanks.filter(e => e) 253 | } 254 | } 255 | 256 | onmessage = ({ data }) => { 257 | new Solver(data) 258 | } 259 | -------------------------------------------------------------------------------- /test/benchmark.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 70 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 6 |
7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 63 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "strictNullChecks": true, 5 | "target": "es6" 6 | } 7 | } 8 | --------------------------------------------------------------------------------