├── .babelrc ├── .eslintrc.js ├── .firebaserc ├── .gitignore ├── README.md ├── colors.js ├── components └── tool-tip.js ├── firebase.json ├── input-range-style.js ├── next.config.js ├── out └── service-worker.js ├── package.json ├── pages └── index.js ├── server.js ├── static └── og-image.png ├── sudoku.js ├── svg ├── help.svg ├── loupe.svg ├── reload.svg ├── remove.svg └── return.svg └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "next/babel" 4 | ], 5 | "plugins": [ 6 | [ 7 | "inline-react-svg", 8 | { 9 | "svgo": { 10 | "plugins": [ 11 | { 12 | "removeAttrs": { "attrs": "(data-name)" } 13 | }, 14 | { 15 | "cleanupIDs": true 16 | } 17 | ] 18 | 19 | } 20 | } 21 | ] 22 | ] 23 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "parser": "babel-eslint", 3 | "extends": "airbnb", 4 | "rules": { 5 | "react/jsx-filename-extension": "off", 6 | "jsx-a11y/click-events-have-key-events": "off", 7 | "jsx-a11y/no-static-element-interactions": "off" 8 | } 9 | }; -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "sudoku-15e8e" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | *.ipr 3 | *.iws 4 | node_modules 5 | build/ 6 | out/ 7 | package-lock.json 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## React Sudoku Game 2 | 3 | A sudoku web game packed with user-friendly features. It is built with React + Next.js 4 | 5 | #### [Deployed Game](https://sudoku.sitianliu.com/) 6 | #### [Blog Post: Building a Sudoku Game in React ](https://medium.com/@sitianliu_57680/building-a-sudoku-game-in-react-ca663915712) 7 | 8 | #### Develop 9 | `yarn install && yarn run dev` 10 | 11 | Go to localhost:3000 12 | 13 | #### Build 14 | `yarn install && yarn run build` -------------------------------------------------------------------------------- /colors.js: -------------------------------------------------------------------------------- 1 | export const backGroundOrange = '#F4511E'; 2 | export const backGroundGrey = '#546E7A'; 3 | export const backGroundBlue = '#1B6B9B'; 4 | export const backGroundGreen = '#7CDC1B'; 5 | -------------------------------------------------------------------------------- /components/tool-tip.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Popover from 'react-popover'; 3 | import HelpIcon from '../svg/help.svg'; 4 | 5 | const TipCopy = ( 6 |
7 |
Select: Click a cell
8 |
Assign Number: Single click on desired number control
9 |
Tag Number as Note: Double click on the desired number control
10 | { /* language=CSS */ } 11 | 20 |
21 | ) 22 | 23 | 24 | export default class Tip extends Component { 25 | state = {} 26 | toggleOpen = (event) => { 27 | // This prevents ghost click. 28 | event.preventDefault(); 29 | this.setState({ open: !this.state.open }); 30 | } 31 | 32 | close = () => { 33 | this.setState({ open: false }); 34 | } 35 | 36 | open = () => { 37 | this.setState({ open: true }); 38 | } 39 | render() { 40 | return ( 41 | 47 |
52 | 53 | { /* language=CSS */ } 54 | 61 |
62 |
63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "out" 4 | } 5 | } -------------------------------------------------------------------------------- /input-range-style.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import css from 'styled-jsx/css'; 3 | import {backGroundBlue} from "./colors"; 4 | 5 | // eslint-disable-next-line no-lone-blocks 6 | { /* language=CSS */ } 7 | const Style = css` 8 | .input-range__slider { 9 | appearance: none; 10 | background: #3f51b5; 11 | border: 1px solid #3f51b5; 12 | border-radius: 100%; 13 | cursor: pointer; 14 | display: block; 15 | height: 1rem; 16 | margin-left: -0.5rem; 17 | margin-top: -0.65rem; 18 | outline: none; 19 | position: absolute; 20 | top: 50%; 21 | transition: transform 0.3s ease-out, box-shadow 0.3s ease-out; 22 | width: 1rem; } 23 | .input-range__slider:active { 24 | transform: scale(1.3); } 25 | .input-range__slider:focus { 26 | box-shadow: 0 0 0 5px rgba(63, 81, 181, 0.2); } 27 | .input-range--disabled .input-range__slider { 28 | background: #cccccc; 29 | border: 1px solid #cccccc; 30 | box-shadow: none; 31 | transform: none; } 32 | 33 | .input-range__slider-container { 34 | transition: left 0.3s ease-out; } 35 | 36 | .input-range__label { 37 | color: #aaaaaa; 38 | font-size: 0.8rem; 39 | transform: translateZ(0); 40 | white-space: nowrap; } 41 | 42 | .input-range__label--min, 43 | .input-range__label--max { 44 | bottom: -1.4rem; 45 | position: absolute; } 46 | 47 | .input-range__label--min { 48 | left: 0; } 49 | 50 | .input-range__label--max { 51 | right: 0; } 52 | 53 | .input-range__label--value { 54 | position: absolute; 55 | top: -1.8rem; } 56 | 57 | .input-range__label-container { 58 | left: -50%; 59 | position: relative; } 60 | .input-range__label--max .input-range__label-container { 61 | left: 50%; } 62 | 63 | .input-range__track { 64 | background: #eeeeee; 65 | border-radius: 0.3rem; 66 | cursor: pointer; 67 | display: block; 68 | height: 0.3rem; 69 | position: relative; 70 | transition: left 0.3s ease-out, width 0.3s ease-out; } 71 | .input-range--disabled .input-range__track { 72 | background: #eeeeee; } 73 | 74 | .input-range__track--background { 75 | left: 0; 76 | margin-top: -0.15rem; 77 | position: absolute; 78 | right: 0; 79 | top: 50%; } 80 | 81 | .input-range__track--active { 82 | background: #3f51b5; } 83 | 84 | .input-range { 85 | height: 1rem; 86 | position: relative; 87 | margin-top: 1.2em; 88 | margin-bottom: 1.4em; 89 | } 90 | 91 | .Popover { 92 | z-index: 2000; 93 | } 94 | .Popover-body { 95 | display: inline-flex; 96 | flex-direction: column; 97 | padding: .5rem 1rem; 98 | background: white; 99 | border-radius: 0.3rem; 100 | opacity: .95; 101 | box-shadow: rgba(0, 0, 0, 0.12) 0 1px 6px, rgba(0, 0, 0, 0.12) 0 1px 4px; 102 | font-size: 14px; 103 | } 104 | 105 | .Popover-tipShape { 106 | fill: ${backGroundBlue}; 107 | } 108 | 109 | .Popover-white .Popover-tipShape { 110 | fill: #00bcd4; 111 | } 112 | 113 | .Popover-white .Popover-body { 114 | background: white; 115 | } 116 | `; 117 | 118 | export default Style; 119 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin'); 2 | 3 | module.exports = { 4 | webpack: (config, { 5 | // eslint-disable-next-line no-unused-vars 6 | buildId, dev, isServer, defaultLoaders, 7 | }) => { 8 | // Perform customizations to webpack config 9 | if (dev) { 10 | config.module.rules.unshift({ 11 | test: /\.js$/, 12 | enforce: 'pre', 13 | exclude: /node_modules/, 14 | loader: 'eslint-loader', 15 | options: { 16 | // Emit errors as warnings for dev to not break webpack build. 17 | // Eslint errors are shown in console for dev, yay :-) 18 | emitWarning: dev, 19 | }, 20 | }); 21 | } 22 | config.plugins.push(new SWPrecacheWebpackPlugin({ 23 | verbose: true, 24 | staticFileGlobsIgnorePatterns: [/\.next\//], 25 | runtimeCaching: [ 26 | { 27 | handler: 'networkFirst', 28 | urlPattern: /^https?.*!/, 29 | }, 30 | ], 31 | })); 32 | // Important: return the modified config 33 | return config; 34 | }, 35 | webpackDevMiddleware: config => 36 | // Perform customizations to webpack dev middleware config 37 | 38 | // Important: return the modified config 39 | config, 40 | 41 | exportPathMap() { 42 | return { 43 | '/': { page: '/' }, 44 | }; 45 | }, 46 | 47 | }; 48 | -------------------------------------------------------------------------------- /out/service-worker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2016 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // DO NOT EDIT THIS GENERATED OUTPUT DIRECTLY! 18 | // This file should be overwritten as part of your build process. 19 | // If you need to extend the behavior of the generated service worker, the best approach is to write 20 | // additional code and include it using the importScripts option: 21 | // https://github.com/GoogleChrome/sw-precache#importscripts-arraystring 22 | // 23 | // Alternatively, it's possible to make changes to the underlying template file and then use that as the 24 | // new base for generating output, via the templateFilePath option: 25 | // https://github.com/GoogleChrome/sw-precache#templatefilepath-string 26 | // 27 | // If you go that route, make sure that whenever you update your sw-precache dependency, you reconcile any 28 | // changes made to this original template file with your modified copy. 29 | 30 | // This generated service worker JavaScript will precache your site's resources. 31 | // The code needs to be saved in a .js file at the top-level of your site, and registered 32 | // from your pages in order to be used. See 33 | // https://github.com/googlechrome/sw-precache/blob/master/demo/app/js/service-worker-registration.js 34 | // for an example of how you can register this script and handle various service worker events. 35 | 36 | /* eslint-env worker, serviceworker */ 37 | /* eslint-disable indent, no-unused-vars, no-multiple-empty-lines, max-nested-callbacks, space-before-function-paren, quotes, comma-spacing */ 38 | 'use strict'; 39 | 40 | var precacheConfig = []; 41 | var cacheName = 'sw-precache-v3-sw-precache-webpack-plugin-' + (self.registration ? self.registration.scope : ''); 42 | 43 | 44 | var ignoreUrlParametersMatching = [/^utm_/]; 45 | 46 | 47 | 48 | var addDirectoryIndex = function (originalUrl, index) { 49 | var url = new URL(originalUrl); 50 | if (url.pathname.slice(-1) === '/') { 51 | url.pathname += index; 52 | } 53 | return url.toString(); 54 | }; 55 | 56 | var cleanResponse = function (originalResponse) { 57 | // If this is not a redirected response, then we don't have to do anything. 58 | if (!originalResponse.redirected) { 59 | return Promise.resolve(originalResponse); 60 | } 61 | 62 | // Firefox 50 and below doesn't support the Response.body stream, so we may 63 | // need to read the entire body to memory as a Blob. 64 | var bodyPromise = 'body' in originalResponse ? 65 | Promise.resolve(originalResponse.body) : 66 | originalResponse.blob(); 67 | 68 | return bodyPromise.then(function(body) { 69 | // new Response() is happy when passed either a stream or a Blob. 70 | return new Response(body, { 71 | headers: originalResponse.headers, 72 | status: originalResponse.status, 73 | statusText: originalResponse.statusText 74 | }); 75 | }); 76 | }; 77 | 78 | var createCacheKey = function (originalUrl, paramName, paramValue, 79 | dontCacheBustUrlsMatching) { 80 | // Create a new URL object to avoid modifying originalUrl. 81 | var url = new URL(originalUrl); 82 | 83 | // If dontCacheBustUrlsMatching is not set, or if we don't have a match, 84 | // then add in the extra cache-busting URL parameter. 85 | if (!dontCacheBustUrlsMatching || 86 | !(url.pathname.match(dontCacheBustUrlsMatching))) { 87 | url.search += (url.search ? '&' : '') + 88 | encodeURIComponent(paramName) + '=' + encodeURIComponent(paramValue); 89 | } 90 | 91 | return url.toString(); 92 | }; 93 | 94 | var isPathWhitelisted = function (whitelist, absoluteUrlString) { 95 | // If the whitelist is empty, then consider all URLs to be whitelisted. 96 | if (whitelist.length === 0) { 97 | return true; 98 | } 99 | 100 | // Otherwise compare each path regex to the path of the URL passed in. 101 | var path = (new URL(absoluteUrlString)).pathname; 102 | return whitelist.some(function(whitelistedPathRegex) { 103 | return path.match(whitelistedPathRegex); 104 | }); 105 | }; 106 | 107 | var stripIgnoredUrlParameters = function (originalUrl, 108 | ignoreUrlParametersMatching) { 109 | var url = new URL(originalUrl); 110 | // Remove the hash; see https://github.com/GoogleChrome/sw-precache/issues/290 111 | url.hash = ''; 112 | 113 | url.search = url.search.slice(1) // Exclude initial '?' 114 | .split('&') // Split into an array of 'key=value' strings 115 | .map(function(kv) { 116 | return kv.split('='); // Split each 'key=value' string into a [key, value] array 117 | }) 118 | .filter(function(kv) { 119 | return ignoreUrlParametersMatching.every(function(ignoredRegex) { 120 | return !ignoredRegex.test(kv[0]); // Return true iff the key doesn't match any of the regexes. 121 | }); 122 | }) 123 | .map(function(kv) { 124 | return kv.join('='); // Join each [key, value] array into a 'key=value' string 125 | }) 126 | .join('&'); // Join the array of 'key=value' strings into a string with '&' in between each 127 | 128 | return url.toString(); 129 | }; 130 | 131 | 132 | var hashParamName = '_sw-precache'; 133 | var urlsToCacheKeys = new Map( 134 | precacheConfig.map(function(item) { 135 | var relativeUrl = item[0]; 136 | var hash = item[1]; 137 | var absoluteUrl = new URL(relativeUrl, self.location); 138 | var cacheKey = createCacheKey(absoluteUrl, hashParamName, hash, false); 139 | return [absoluteUrl.toString(), cacheKey]; 140 | }) 141 | ); 142 | 143 | function setOfCachedUrls(cache) { 144 | return cache.keys().then(function(requests) { 145 | return requests.map(function(request) { 146 | return request.url; 147 | }); 148 | }).then(function(urls) { 149 | return new Set(urls); 150 | }); 151 | } 152 | 153 | self.addEventListener('install', function(event) { 154 | event.waitUntil( 155 | caches.open(cacheName).then(function(cache) { 156 | return setOfCachedUrls(cache).then(function(cachedUrls) { 157 | return Promise.all( 158 | Array.from(urlsToCacheKeys.values()).map(function(cacheKey) { 159 | // If we don't have a key matching url in the cache already, add it. 160 | if (!cachedUrls.has(cacheKey)) { 161 | var request = new Request(cacheKey, {credentials: 'same-origin'}); 162 | return fetch(request).then(function(response) { 163 | // Bail out of installation unless we get back a 200 OK for 164 | // every request. 165 | if (!response.ok) { 166 | throw new Error('Request for ' + cacheKey + ' returned a ' + 167 | 'response with status ' + response.status); 168 | } 169 | 170 | return cleanResponse(response).then(function(responseToCache) { 171 | return cache.put(cacheKey, responseToCache); 172 | }); 173 | }); 174 | } 175 | }) 176 | ); 177 | }); 178 | }).then(function() { 179 | 180 | // Force the SW to transition from installing -> active state 181 | return self.skipWaiting(); 182 | 183 | }) 184 | ); 185 | }); 186 | 187 | self.addEventListener('activate', function(event) { 188 | var setOfExpectedUrls = new Set(urlsToCacheKeys.values()); 189 | 190 | event.waitUntil( 191 | caches.open(cacheName).then(function(cache) { 192 | return cache.keys().then(function(existingRequests) { 193 | return Promise.all( 194 | existingRequests.map(function(existingRequest) { 195 | if (!setOfExpectedUrls.has(existingRequest.url)) { 196 | return cache.delete(existingRequest); 197 | } 198 | }) 199 | ); 200 | }); 201 | }).then(function() { 202 | 203 | return self.clients.claim(); 204 | 205 | }) 206 | ); 207 | }); 208 | 209 | 210 | self.addEventListener('fetch', function(event) { 211 | if (event.request.method === 'GET') { 212 | // Should we call event.respondWith() inside this fetch event handler? 213 | // This needs to be determined synchronously, which will give other fetch 214 | // handlers a chance to handle the request if need be. 215 | var shouldRespond; 216 | 217 | // First, remove all the ignored parameters and hash fragment, and see if we 218 | // have that URL in our cache. If so, great! shouldRespond will be true. 219 | var url = stripIgnoredUrlParameters(event.request.url, ignoreUrlParametersMatching); 220 | shouldRespond = urlsToCacheKeys.has(url); 221 | 222 | // If shouldRespond is false, check again, this time with 'index.html' 223 | // (or whatever the directoryIndex option is set to) at the end. 224 | var directoryIndex = 'index.html'; 225 | if (!shouldRespond && directoryIndex) { 226 | url = addDirectoryIndex(url, directoryIndex); 227 | shouldRespond = urlsToCacheKeys.has(url); 228 | } 229 | 230 | // If shouldRespond is still false, check to see if this is a navigation 231 | // request, and if so, whether the URL matches navigateFallbackWhitelist. 232 | var navigateFallback = ''; 233 | if (!shouldRespond && 234 | navigateFallback && 235 | (event.request.mode === 'navigate') && 236 | isPathWhitelisted([], event.request.url)) { 237 | url = new URL(navigateFallback, self.location).toString(); 238 | shouldRespond = urlsToCacheKeys.has(url); 239 | } 240 | 241 | // If shouldRespond was set to true at any point, then call 242 | // event.respondWith(), using the appropriate cache key. 243 | if (shouldRespond) { 244 | event.respondWith( 245 | caches.open(cacheName).then(function(cache) { 246 | return cache.match(urlsToCacheKeys.get(url)).then(function(response) { 247 | if (response) { 248 | return response; 249 | } 250 | throw Error('The cached response that was expected is missing.'); 251 | }); 252 | }).catch(function(e) { 253 | // Fall back to just fetch()ing the request if some unexpected error 254 | // prevented the cached response from being valid. 255 | console.warn('Couldn\'t serve response for "%s" from cache: %O', event.request.url, e); 256 | return fetch(event.request); 257 | }) 258 | ); 259 | } 260 | } 261 | }); 262 | 263 | 264 | // *** Start of auto-included sw-toolbox code. *** 265 | /* 266 | Copyright 2016 Google Inc. All Rights Reserved. 267 | 268 | Licensed under the Apache License, Version 2.0 (the "License"); 269 | you may not use this file except in compliance with the License. 270 | You may obtain a copy of the License at 271 | 272 | http://www.apache.org/licenses/LICENSE-2.0 273 | 274 | Unless required by applicable law or agreed to in writing, software 275 | distributed under the License is distributed on an "AS IS" BASIS, 276 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 277 | See the License for the specific language governing permissions and 278 | limitations under the License. 279 | */!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var t;t="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this,t.toolbox=e()}}(function(){return function e(t,n,r){function o(c,s){if(!n[c]){if(!t[c]){var a="function"==typeof require&&require;if(!s&&a)return a(c,!0);if(i)return i(c,!0);var u=new Error("Cannot find module '"+c+"'");throw u.code="MODULE_NOT_FOUND",u}var f=n[c]={exports:{}};t[c][0].call(f.exports,function(e){var n=t[c][1][e];return o(n?n:e)},f,f.exports,e,t,n,r)}return n[c].exports}for(var i="function"==typeof require&&require,c=0;ct.value[l]){var r=t.value[p];c.push(r),a.delete(r),t.continue()}},s.oncomplete=function(){r(c)},s.onabort=o}):Promise.resolve([])}function s(e,t){return t?new Promise(function(n,r){var o=[],i=e.transaction(h,"readwrite"),c=i.objectStore(h),s=c.index(l),a=s.count();s.count().onsuccess=function(){var e=a.result;e>t&&(s.openCursor().onsuccess=function(n){var r=n.target.result;if(r){var i=r.value[p];o.push(i),c.delete(i),e-o.length>t&&r.continue()}})},i.oncomplete=function(){n(o)},i.onabort=r}):Promise.resolve([])}function a(e,t,n,r){return c(e,n,r).then(function(n){return s(e,t).then(function(e){return n.concat(e)})})}var u="sw-toolbox-",f=1,h="store",p="url",l="timestamp",d={};t.exports={getDb:o,setTimestampForUrl:i,expireEntries:a}},{}],3:[function(e,t,n){"use strict";function r(e){var t=a.match(e.request);t?e.respondWith(t(e.request)):a.default&&"GET"===e.request.method&&0===e.request.url.indexOf("http")&&e.respondWith(a.default(e.request))}function o(e){s.debug("activate event fired");var t=u.cache.name+"$$$inactive$$$";e.waitUntil(s.renameCache(t,u.cache.name))}function i(e){return e.reduce(function(e,t){return e.concat(t)},[])}function c(e){var t=u.cache.name+"$$$inactive$$$";s.debug("install event fired"),s.debug("creating cache ["+t+"]"),e.waitUntil(s.openCache({cache:{name:t}}).then(function(e){return Promise.all(u.preCacheItems).then(i).then(s.validatePrecacheInput).then(function(t){return s.debug("preCache list: "+(t.join(", ")||"(none)")),e.addAll(t)})}))}e("serviceworker-cache-polyfill");var s=e("./helpers"),a=e("./router"),u=e("./options");t.exports={fetchListener:r,activateListener:o,installListener:c}},{"./helpers":1,"./options":4,"./router":6,"serviceworker-cache-polyfill":16}],4:[function(e,t,n){"use strict";var r;r=self.registration?self.registration.scope:self.scope||new URL("./",self.location).href,t.exports={cache:{name:"$$$toolbox-cache$$$"+r+"$$$",maxAgeSeconds:null,maxEntries:null},debug:!1,networkTimeoutSeconds:null,preCacheItems:[],successResponses:/^0|([123]\d\d)|(40[14567])|410$/}},{}],5:[function(e,t,n){"use strict";var r=new URL("./",self.location),o=r.pathname,i=e("path-to-regexp"),c=function(e,t,n,r){t instanceof RegExp?this.fullUrlRegExp=t:(0!==t.indexOf("/")&&(t=o+t),this.keys=[],this.regexp=i(t,this.keys)),this.method=e,this.options=r,this.handler=n};c.prototype.makeHandler=function(e){var t;if(this.regexp){var n=this.regexp.exec(e);t={},this.keys.forEach(function(e,r){t[e.name]=n[r+1]})}return function(e){return this.handler(e,t,this.options)}.bind(this)},t.exports=c},{"path-to-regexp":15}],6:[function(e,t,n){"use strict";function r(e){return e.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&")}var o=e("./route"),i=e("./helpers"),c=function(e,t){for(var n=e.entries(),r=n.next(),o=[];!r.done;){var i=new RegExp(r.value[0]);i.test(t)&&o.push(r.value[1]),r=n.next()}return o},s=function(){this.routes=new Map,this.routes.set(RegExp,new Map),this.default=null};["get","post","put","delete","head","any"].forEach(function(e){s.prototype[e]=function(t,n,r){return this.add(e,t,n,r)}}),s.prototype.add=function(e,t,n,c){c=c||{};var s;t instanceof RegExp?s=RegExp:(s=c.origin||self.location.origin,s=s instanceof RegExp?s.source:r(s)),e=e.toLowerCase();var a=new o(e,t,n,c);this.routes.has(s)||this.routes.set(s,new Map);var u=this.routes.get(s);u.has(e)||u.set(e,new Map);var f=u.get(e),h=a.regexp||a.fullUrlRegExp;f.has(h.source)&&i.debug('"'+t+'" resolves to same regex as existing route.'),f.set(h.source,a)},s.prototype.matchMethod=function(e,t){var n=new URL(t),r=n.origin,o=n.pathname;return this._match(e,c(this.routes,r),o)||this._match(e,[this.routes.get(RegExp)],t)},s.prototype._match=function(e,t,n){if(0===t.length)return null;for(var r=0;r0)return s[0].makeHandler(n)}}return null},s.prototype.match=function(e){return this.matchMethod(e.method,e.url)||this.matchMethod("any",e.url)},t.exports=new s},{"./helpers":1,"./route":5}],7:[function(e,t,n){"use strict";function r(e,t,n){return n=n||{},i.debug("Strategy: cache first ["+e.url+"]",n),i.openCache(n).then(function(t){return t.match(e).then(function(t){var r=n.cache||o.cache,c=Date.now();return i.isResponseFresh(t,r.maxAgeSeconds,c)?t:i.fetchAndCache(e,n)})})}var o=e("../options"),i=e("../helpers");t.exports=r},{"../helpers":1,"../options":4}],8:[function(e,t,n){"use strict";function r(e,t,n){return n=n||{},i.debug("Strategy: cache only ["+e.url+"]",n),i.openCache(n).then(function(t){return t.match(e).then(function(e){var t=n.cache||o.cache,r=Date.now();if(i.isResponseFresh(e,t.maxAgeSeconds,r))return e})})}var o=e("../options"),i=e("../helpers");t.exports=r},{"../helpers":1,"../options":4}],9:[function(e,t,n){"use strict";function r(e,t,n){return o.debug("Strategy: fastest ["+e.url+"]",n),new Promise(function(r,c){var s=!1,a=[],u=function(e){a.push(e.toString()),s?c(new Error('Both cache and network failed: "'+a.join('", "')+'"')):s=!0},f=function(e){e instanceof Response?r(e):u("No result returned")};o.fetchAndCache(e.clone(),n).then(f,u),i(e,t,n).then(f,u)})}var o=e("../helpers"),i=e("./cacheOnly");t.exports=r},{"../helpers":1,"./cacheOnly":8}],10:[function(e,t,n){t.exports={networkOnly:e("./networkOnly"),networkFirst:e("./networkFirst"),cacheOnly:e("./cacheOnly"),cacheFirst:e("./cacheFirst"),fastest:e("./fastest")}},{"./cacheFirst":7,"./cacheOnly":8,"./fastest":9,"./networkFirst":11,"./networkOnly":12}],11:[function(e,t,n){"use strict";function r(e,t,n){n=n||{};var r=n.successResponses||o.successResponses,c=n.networkTimeoutSeconds||o.networkTimeoutSeconds;return i.debug("Strategy: network first ["+e.url+"]",n),i.openCache(n).then(function(t){var s,a,u=[];if(c){var f=new Promise(function(r){s=setTimeout(function(){t.match(e).then(function(e){var t=n.cache||o.cache,c=Date.now(),s=t.maxAgeSeconds;i.isResponseFresh(e,s,c)&&r(e)})},1e3*c)});u.push(f)}var h=i.fetchAndCache(e,n).then(function(e){if(s&&clearTimeout(s),r.test(e.status))return e;throw i.debug("Response was an HTTP error: "+e.statusText,n),a=e,new Error("Bad response")}).catch(function(r){return i.debug("Network or response error, fallback to cache ["+e.url+"]",n),t.match(e).then(function(e){if(e)return e;if(a)return a;throw r})});return u.push(h),Promise.race(u)})}var o=e("../options"),i=e("../helpers");t.exports=r},{"../helpers":1,"../options":4}],12:[function(e,t,n){"use strict";function r(e,t,n){return o.debug("Strategy: network only ["+e.url+"]",n),fetch(e)}var o=e("../helpers");t.exports=r},{"../helpers":1}],13:[function(e,t,n){"use strict";var r=e("./options"),o=e("./router"),i=e("./helpers"),c=e("./strategies"),s=e("./listeners");i.debug("Service Worker Toolbox is loading"),self.addEventListener("install",s.installListener),self.addEventListener("activate",s.activateListener),self.addEventListener("fetch",s.fetchListener),t.exports={networkOnly:c.networkOnly,networkFirst:c.networkFirst,cacheOnly:c.cacheOnly,cacheFirst:c.cacheFirst,fastest:c.fastest,router:o,options:r,cache:i.cache,uncache:i.uncache,precache:i.precache}},{"./helpers":1,"./listeners":3,"./options":4,"./router":6,"./strategies":10}],14:[function(e,t,n){t.exports=Array.isArray||function(e){return"[object Array]"==Object.prototype.toString.call(e)}},{}],15:[function(e,t,n){function r(e,t){for(var n,r=[],o=0,i=0,c="",s=t&&t.delimiter||"/";null!=(n=x.exec(e));){var f=n[0],h=n[1],p=n.index;if(c+=e.slice(i,p),i=p+f.length,h)c+=h[1];else{var l=e[i],d=n[2],m=n[3],g=n[4],v=n[5],w=n[6],y=n[7];c&&(r.push(c),c="");var b=null!=d&&null!=l&&l!==d,E="+"===w||"*"===w,R="?"===w||"*"===w,k=n[2]||s,$=g||v;r.push({name:m||o++,prefix:d||"",delimiter:k,optional:R,repeat:E,partial:b,asterisk:!!y,pattern:$?u($):y?".*":"[^"+a(k)+"]+?"})}}return i=46||"Chrome"===n&&r>=50)||(Cache.prototype.addAll=function(e){function t(e){this.name="NetworkError",this.code=19,this.message=e}var n=this;return t.prototype=Object.create(Error.prototype),Promise.resolve().then(function(){if(arguments.length<1)throw new TypeError;return e=e.map(function(e){return e instanceof Request?e:String(e)}),Promise.all(e.map(function(e){"string"==typeof e&&(e=new Request(e));var n=new URL(e.url).protocol;if("http:"!==n&&"https:"!==n)throw new t("Invalid scheme");return fetch(e.clone())}))}).then(function(r){if(r.some(function(e){return!e.ok}))throw new t("Incorrect response status");return Promise.all(r.map(function(t,r){return n.put(e[r],t)}))}).then(function(){})},Cache.prototype.add=function(e){return this.addAll([e])})}()},{}]},{},[13])(13)}); 280 | 281 | 282 | // *** End of auto-included sw-toolbox code. *** 283 | 284 | 285 | 286 | // Runtime cache configuration, using the sw-toolbox library. 287 | 288 | toolbox.router.get(/^https?.*!/, toolbox.networkFirst, {}); 289 | 290 | toolbox.router.default = toolbox.cacheFirst; 291 | 292 | 293 | 294 | 295 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-sudoku-game", 3 | "version": "1.0.0", 4 | "description": "A sudoku web game packed with user-friendly features. It is built with React + Next.js", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "next", 8 | "build": "next build", 9 | "start": "next start", 10 | "export": "next export" 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "color": "^3.0.0", 16 | "immutable": "4.0.0-rc.9", 17 | "next": "^5.0.0", 18 | "prop-types": "^15.6.0", 19 | "react": "^16.2.0", 20 | "react-dom": "^16.2.0", 21 | "react-input-range": "^1.3.0", 22 | "react-popover": "^0.5.4", 23 | "sw-precache-webpack-plugin": "^0.11.4" 24 | }, 25 | "devDependencies": { 26 | "babel-eslint": "^8.2.2", 27 | "babel-plugin-inline-react-svg": "^0.5.2", 28 | "eslint": "^4.18.1", 29 | "eslint-config-airbnb": "^16.1.0", 30 | "eslint-loader": "^1.9.0", 31 | "eslint-plugin-import": "^2.9.0", 32 | "eslint-plugin-jsx-a11y": "^6.0.3", 33 | "eslint-plugin-react": "^7.7.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/accessible-emoji */ 2 | import React, { Component } from 'react'; 3 | import { Set, List, fromJS } from 'immutable'; 4 | import PropTypes from 'prop-types'; 5 | import NextHead from 'next/head'; 6 | import Color from 'color'; 7 | import InputRange from 'react-input-range'; 8 | 9 | // eslint-disable-next-line import/no-extraneous-dependencies 10 | import css from 'styled-jsx/css'; 11 | 12 | import RangeStyle from '../input-range-style'; 13 | import LoupeIcon from '../svg/loupe.svg'; 14 | import RemoveIcon from '../svg/remove.svg'; 15 | import ReloadIcon from '../svg/reload.svg'; 16 | import ReturnIcon from '../svg/return.svg'; 17 | 18 | import { makePuzzle, pluck, isPeer as areCoordinatePeers, range } from '../sudoku'; 19 | import { backGroundBlue } from '../colors'; 20 | import Tip from '../components/tool-tip'; 21 | 22 | 23 | const Description = 'Discover the next evolution of Sudoku with amazing graphics, animations, and user-friendly features. Enjoy a Sudoku experience like you never have before with customizable game generation, cell highlighting, intuitive controls and more!'; 24 | const cellWidth = 2.5; 25 | 26 | const LightBlue100 = '#B3E5FC'; 27 | const LightBlue200 = '#81D4FA'; 28 | const LightBlue300 = '#4FC3F7'; 29 | const Indigo700 = '#303F9F'; 30 | const DeepOrange200 = '#FFAB91'; 31 | const DeepOrange600 = '#F4511E'; 32 | const ControlNumberColor = Indigo700; 33 | 34 | // eslint-disable-next-line no-lone-blocks 35 | { /* language=CSS */ } 36 | const CellStyle = css` 37 | .cell { 38 | height: ${cellWidth}em; 39 | width: ${cellWidth}em; 40 | display: flex; 41 | flex-wrap: wrap; 42 | align-items: center; 43 | justify-content: center; 44 | font-size: 1.1em; 45 | font-weight: bold; 46 | transition: background-color .3s ease-in-out; 47 | } 48 | .cell:nth-child(3n+3):not(:last-child) { 49 | border-right: 2px solid black; 50 | } 51 | .cell:not(:last-child) { 52 | border-right: 1px solid black; 53 | } 54 | .note-number { 55 | font-size: .6em; 56 | width: 33%; 57 | height: 33%; 58 | box-sizing: border-box; 59 | display: flex; 60 | align-items: center; 61 | justify-content: center; 62 | } 63 | `; 64 | 65 | // eslint-disable-next-line no-lone-blocks 66 | { /* language=CSS */ } 67 | const ActionsStyle = css` 68 | .actions { 69 | display: flex; 70 | align-items: center; 71 | justify-content: space-between; 72 | width: 100%; 73 | max-width: 400px; 74 | margin-top: .5em; 75 | padding: 0 .6em; 76 | } 77 | .action { 78 | display: flex; 79 | align-items: center; 80 | flex-direction: column; 81 | } 82 | .action :global(svg) { 83 | width: 2.5em; 84 | margin-bottom: .2em; 85 | } 86 | .redo :global(svg) { 87 | transform: scaleX(-1); 88 | } 89 | `; 90 | 91 | // eslint-disable-next-line no-lone-blocks 92 | { /* language=CSS */ } 93 | const ControlStyle = css` 94 | .control { 95 | padding: 0 2em; 96 | cursor: pointer; 97 | display: inline-flex; 98 | align-items: center; 99 | flex-wrap: wrap; 100 | justify-content: center; 101 | font-family: 'Special Elite', cursive; 102 | transition: filter .5s ease-in-out; 103 | width: 100%; 104 | } 105 | `; 106 | 107 | // eslint-disable-next-line no-lone-blocks 108 | { /* language=CSS */ } 109 | const NumberControlStyle = css` 110 | .number { 111 | display: flex; 112 | position: relative; 113 | justify-content: center; 114 | align-items: center; 115 | font-size: 2em; 116 | margin: .1em; 117 | width: 1.5em; 118 | height: 1.5em; 119 | color: ${ControlNumberColor}; 120 | box-shadow: 0 1px 2px rgba(0,0,0,0.16), 0 1px 2px rgba(0,0,0,0.23); 121 | border-radius: 50%; 122 | } 123 | .number > div { 124 | margin-top: .3em; 125 | } 126 | `; 127 | 128 | // eslint-disable-next-line no-lone-blocks 129 | { /* language=CSS */ } 130 | const PuzzleStyle = css` 131 | .puzzle { 132 | margin-top: .5em; 133 | width: ${cellWidth * 9}em; 134 | cursor: pointer; 135 | box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); 136 | } 137 | .row { 138 | display: flex; 139 | align-items: center; 140 | flex: 0; 141 | width: ${cellWidth * 9}em; 142 | } 143 | .row:not(:last-child) { 144 | border-bottom: 1px solid black; 145 | } 146 | .row:nth-child(3n+3):not(:last-child) { 147 | border-bottom: 2px solid black !important; 148 | } 149 | `; 150 | 151 | // eslint-disable-next-line no-lone-blocks 152 | { /* language=CSS */ } 153 | const CirculuarProgressStyle = css` 154 | .circular-progress { 155 | display: block; 156 | width: 100%; 157 | position: absolute; 158 | top: 0; 159 | left: 0; 160 | transition: filter .4s ease-in-out; 161 | } 162 | 163 | .circle-bg { 164 | fill: none; 165 | stroke: #eee; 166 | stroke-width: 3.8; 167 | } 168 | 169 | .circle { 170 | stroke: ${ControlNumberColor}; 171 | transition: stroke-dasharray .4s ease-in-out; 172 | fill: none; 173 | stroke-width: 2.8; 174 | stroke-linecap: round; 175 | } 176 | `; 177 | 178 | const CircularPathD = 'M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831'; 179 | 180 | function getBackGroundColor({ 181 | conflict, isPeer, sameValue, isSelected, 182 | }) { 183 | if (conflict && isPeer && sameValue) { 184 | return DeepOrange200; 185 | } else if (sameValue) { 186 | return LightBlue300; 187 | } else if (isSelected) { 188 | return LightBlue200; 189 | } else if (isPeer) { 190 | return LightBlue100; 191 | } 192 | return false; 193 | } 194 | 195 | function getFontColor({ value, conflict, prefilled }) { 196 | if (conflict && !prefilled) { 197 | return DeepOrange600; 198 | } else if (!prefilled && value) { 199 | return ControlNumberColor; 200 | } 201 | return false; 202 | } 203 | 204 | class GenerationUI extends Component { 205 | constructor(props) { 206 | super(props); 207 | 208 | this.state = { value: 30 }; 209 | } 210 | 211 | generateGame = () => { 212 | this.props.generateGame(this.state.value); 213 | } 214 | 215 | render() { 216 | return ( 217 |
218 |
Start with {this.state.value} cells prefilled
219 | this.setState({ value })} 224 | /> 225 |
Play Sudoku
226 | { /* language=CSS */ } 227 | 274 |
275 | ); 276 | } 277 | } 278 | 279 | GenerationUI.propTypes = { 280 | generateGame: PropTypes.func.isRequired, 281 | }; 282 | 283 | const NumberControl = ({ number, onClick, completionPercentage }) => ( 284 |
289 |
{number}
290 | 291 | 292 |
293 | ); 294 | 295 | NumberControl.propTypes = { 296 | number: PropTypes.number.isRequired, 297 | onClick: PropTypes.func, 298 | completionPercentage: PropTypes.number.isRequired, 299 | }; 300 | 301 | NumberControl.defaultProps = { 302 | onClick: null, 303 | }; 304 | 305 | const Cell = (props) => { 306 | const { 307 | value, onClick, isPeer, isSelected, sameValue, prefilled, notes, conflict, 308 | } = props; 309 | const backgroundColor = getBackGroundColor({ 310 | conflict, isPeer, sameValue, isSelected, 311 | }); 312 | const fontColor = getFontColor({ conflict, prefilled, value }); 313 | return ( 314 |
315 | { 316 | notes ? 317 | range(9).map(i => 318 | ( 319 |
320 | {notes.has(i + 1) && (i + 1)} 321 |
322 | )) : 323 | value && value 324 | } 325 | {/* language=CSS */} 326 | 327 | 334 |
335 | ); 336 | }; 337 | 338 | Cell.propTypes = { 339 | // current number value 340 | value: PropTypes.number, 341 | // cell click handler 342 | onClick: PropTypes.func.isRequired, 343 | // if the cell is a peer of the selected cell 344 | isPeer: PropTypes.bool.isRequired, 345 | // if the cell is selected by the user 346 | isSelected: PropTypes.bool.isRequired, 347 | // current cell has the same value if the user selected cell 348 | sameValue: PropTypes.bool.isRequired, 349 | // if this was prefilled as a part of the puzzle 350 | prefilled: PropTypes.bool.isRequired, 351 | // current notes taken on the cell 352 | notes: PropTypes.instanceOf(Set), 353 | // if the current cell does not satisfy the game constraint 354 | conflict: PropTypes.bool.isRequired, 355 | }; 356 | 357 | Cell.defaultProps = { 358 | notes: null, 359 | value: null, 360 | }; 361 | 362 | const CirclularProgress = ({ percent }) => ( 363 | 364 | 368 | 373 | { /* language=CSS */ } 374 | 375 | 376 | ); 377 | 378 | CirclularProgress.propTypes = { 379 | percent: PropTypes.number.isRequired, 380 | }; 381 | 382 | function getClickHandler(onClick, onDoubleClick, delay = 250) { 383 | let timeoutID = null; 384 | return (event) => { 385 | if (!timeoutID) { 386 | timeoutID = setTimeout(() => { 387 | onClick(event); 388 | timeoutID = null; 389 | }, delay); 390 | } else { 391 | timeoutID = clearTimeout(timeoutID); 392 | onDoubleClick(event); 393 | } 394 | }; 395 | } 396 | 397 | /** 398 | * make size 9 array of 0s 399 | * @returns {Array} 400 | */ 401 | function makeCountObject() { 402 | const countObj = []; 403 | for (let i = 0; i < 10; i += 1) countObj.push(0); 404 | return countObj; 405 | } 406 | 407 | /** 408 | * given a 2D array of numbers as the initial puzzle, generate the initial game state 409 | * @param puzzle 410 | * @returns {any} 411 | */ 412 | function makeBoard({ puzzle }) { 413 | // create initial count object to keep track of conflicts per number value 414 | const rows = Array.from(Array(9).keys()).map(() => makeCountObject()); 415 | const columns = Array.from(Array(9).keys()).map(() => makeCountObject()); 416 | const squares = Array.from(Array(9).keys()).map(() => makeCountObject()); 417 | const result = puzzle.map((row, i) => ( 418 | row.map((cell, j) => { 419 | if (cell) { 420 | rows[i][cell] += 1; 421 | columns[j][cell] += 1; 422 | squares[((Math.floor(i / 3)) * 3) + Math.floor(j / 3)][cell] += 1; 423 | } 424 | return { 425 | value: puzzle[i][j] > 0 ? puzzle[i][j] : null, 426 | prefilled: !!puzzle[i][j], 427 | }; 428 | }) 429 | )); 430 | return fromJS({ puzzle: result, selected: false, choices: { rows, columns, squares } }); 431 | } 432 | 433 | /** 434 | * give the coordinate update the current board with a number choice 435 | * @param x 436 | * @param y 437 | * @param number 438 | * @param fill whether to set or unset 439 | * @param board the immutable board given to change 440 | */ 441 | function updateBoardWithNumber({ 442 | x, y, number, fill = true, board, 443 | }) { 444 | let cell = board.get('puzzle').getIn([x, y]); 445 | // delete its notes 446 | cell = cell.delete('notes'); 447 | // set or unset its value depending on `fill` 448 | cell = fill ? cell.set('value', number) : cell.delete('value'); 449 | const increment = fill ? 1 : -1; 450 | // update the current group choices 451 | const rowPath = ['choices', 'rows', x, number]; 452 | const columnPath = ['choices', 'columns', y, number]; 453 | const squarePath = ['choices', 'squares', 454 | ((Math.floor(x / 3)) * 3) + Math.floor(y / 3), number]; 455 | return board.setIn(rowPath, board.getIn(rowPath) + increment) 456 | .setIn(columnPath, board.getIn(columnPath) + increment) 457 | .setIn(squarePath, board.getIn(squarePath) + increment) 458 | .setIn(['puzzle', x, y], cell); 459 | } 460 | 461 | function getNumberOfGroupsAssignedForNumber(number, groups) { 462 | return groups.reduce((accumulator, row) => 463 | accumulator + (row.get(number) > 0 ? 1 : 0), 0); 464 | } 465 | // eslint-disable-next-line react/no-multi-comp 466 | export default class Index extends Component { 467 | state = {}; 468 | 469 | componentDidMount() { 470 | // eslint-disable-next-line no-undef 471 | if ('serviceWorker' in navigator) { 472 | // eslint-disable-next-line no-undef 473 | navigator.serviceWorker 474 | .register('/service-worker.js') 475 | .then((reg) => { 476 | console.log('ServiceWorker scope: ', reg.scope); 477 | console.log('service worker registration successful'); 478 | }) 479 | .catch((err) => { 480 | console.warn('service worker registration failed', err.message); 481 | }); 482 | } 483 | } 484 | getSelectedCell() { 485 | const { board } = this.state; 486 | const selected = board.get('selected'); 487 | return selected && board.get('puzzle').getIn([selected.x, selected.y]); 488 | } 489 | 490 | // get the min between its completion in rows, columns and squares. 491 | getNumberValueCount(number) { 492 | const rows = this.state.board.getIn(['choices', 'rows']); 493 | const columns = this.state.board.getIn(['choices', 'columns']); 494 | const squares = this.state.board.getIn(['choices', 'squares']); 495 | return Math.min( 496 | getNumberOfGroupsAssignedForNumber(number, squares), 497 | Math.min( 498 | getNumberOfGroupsAssignedForNumber(number, rows), 499 | getNumberOfGroupsAssignedForNumber(number, columns), 500 | ), 501 | ); 502 | } 503 | 504 | generateGame = (finalCount = 20) => { 505 | // get a filled puzzle generated 506 | const solution = makePuzzle(); 507 | // pluck values from cells to create the game 508 | const { puzzle } = pluck(solution, finalCount); 509 | // initialize the board with choice counts 510 | const board = makeBoard({ puzzle }); 511 | this.setState({ 512 | board, history: List.of(board), historyOffSet: 0, solution, 513 | }); 514 | } 515 | 516 | addNumberAsNote = (number) => { 517 | let { board } = this.state; 518 | let selectedCell = this.getSelectedCell(); 519 | if (!selectedCell) return; 520 | const prefilled = selectedCell.get('prefilled'); 521 | if (prefilled) return; 522 | const { x, y } = board.get('selected'); 523 | const currentValue = selectedCell.get('value'); 524 | if (currentValue) { 525 | board = updateBoardWithNumber({ 526 | x, y, number: currentValue, fill: false, board: this.state.board, 527 | }); 528 | } 529 | let notes = selectedCell.get('notes') || Set(); 530 | if (notes.has(number)) { 531 | notes = notes.delete(number); 532 | } else { 533 | notes = notes.add(number); 534 | } 535 | selectedCell = selectedCell.set('notes', notes); 536 | selectedCell = selectedCell.delete('value'); 537 | board = board.setIn(['puzzle', x, y], selectedCell); 538 | this.updateBoard(board); 539 | }; 540 | 541 | updateBoard = (newBoard) => { 542 | let { history } = this.state; 543 | const { historyOffSet } = this.state; 544 | // anything before current step is still in history 545 | history = history.slice(0, historyOffSet + 1); 546 | // add itself onto the history 547 | history = history.push(newBoard); 548 | // update the game 549 | this.setState({ board: newBoard, history, historyOffSet: history.size - 1 }); 550 | }; 551 | 552 | canUndo = () => this.state.historyOffSet > 0 553 | 554 | redo = () => { 555 | const { history } = this.state; 556 | let { historyOffSet } = this.state; 557 | if (history.size) { 558 | historyOffSet = Math.min(history.size - 1, historyOffSet + 1); 559 | const board = history.get(historyOffSet); 560 | this.setState({ board, historyOffSet }); 561 | } 562 | }; 563 | 564 | undo = () => { 565 | const { history } = this.state; 566 | let { historyOffSet, board } = this.state; 567 | if (history.size) { 568 | historyOffSet = Math.max(0, historyOffSet - 1); 569 | board = history.get(historyOffSet); 570 | this.setState({ board, historyOffSet, history }); 571 | } 572 | }; 573 | 574 | eraseSelected = () => { 575 | const selectedCell = this.getSelectedCell(); 576 | if (!selectedCell) return; 577 | this.fillNumber(false); 578 | } 579 | 580 | fillSelectedWithSolution = () => { 581 | const { board, solution } = this.state; 582 | const selectedCell = this.getSelectedCell(); 583 | if (!selectedCell) return; 584 | const { x, y } = board.get('selected'); 585 | this.fillNumber(solution[x][y]); 586 | } 587 | 588 | 589 | // fill currently selected cell with number 590 | fillNumber = (number) => { 591 | let { board } = this.state; 592 | const selectedCell = this.getSelectedCell(); 593 | // no-op if nothing is selected 594 | if (!selectedCell) return; 595 | const prefilled = selectedCell.get('prefilled'); 596 | // no-op if it is refilled 597 | if (prefilled) return; 598 | const { x, y } = board.get('selected'); 599 | const currentValue = selectedCell.get('value'); 600 | // remove the current value and update the game state 601 | if (currentValue) { 602 | board = updateBoardWithNumber({ 603 | x, y, number: currentValue, fill: false, board: this.state.board, 604 | }); 605 | } 606 | // update to new number if any 607 | const setNumber = currentValue !== number && number; 608 | if (setNumber) { 609 | board = updateBoardWithNumber({ 610 | x, y, number, fill: true, board, 611 | }); 612 | } 613 | this.updateBoard(board); 614 | }; 615 | 616 | selectCell = (x, y) => { 617 | let { board } = this.state; 618 | board = board.set('selected', { x, y }); 619 | this.setState({ board }); 620 | }; 621 | 622 | isConflict(i, j) { 623 | const { value } = this.state.board.getIn(['puzzle', i, j]).toJSON(); 624 | if (!value) return false; 625 | const rowConflict = 626 | this.state.board.getIn(['choices', 'rows', i, value]) > 1; 627 | const columnConflict = 628 | this.state.board.getIn(['choices', 'columns', j, value]) > 1; 629 | const squareConflict = 630 | this.state.board.getIn(['choices', 'squares', 631 | ((Math.floor(i / 3)) * 3) + Math.floor(j / 3), value]) > 1; 632 | return rowConflict || columnConflict || squareConflict; 633 | } 634 | 635 | renderCell(cell, x, y) { 636 | const { board } = this.state; 637 | const selected = this.getSelectedCell(); 638 | const { value, prefilled, notes } = cell.toJSON(); 639 | const conflict = this.isConflict(x, y); 640 | const peer = areCoordinatePeers({ x, y }, board.get('selected')); 641 | const sameValue = !!(selected && selected.get('value') 642 | && value === selected.get('value')); 643 | 644 | const isSelected = cell === selected; 645 | return ( { this.selectCell(x, y); }} 653 | key={y} 654 | x={x} 655 | y={y} 656 | conflict={conflict} 657 | />); 658 | } 659 | 660 | renderNumberControl() { 661 | const selectedCell = this.getSelectedCell(); 662 | const prefilled = selectedCell && selectedCell.get('prefilled'); 663 | return ( 664 |
665 | {range(9).map((i) => { 666 | const number = i + 1; 667 | // handles binding single click and double click callbacks 668 | const clickHandle = getClickHandler( 669 | () => { this.fillNumber(number); }, 670 | () => { this.addNumberAsNote(number); }, 671 | ); 672 | return ( 673 | ); 679 | })} 680 | 681 |
682 | ); 683 | } 684 | 685 | renderActions() { 686 | const { history } = this.state; 687 | const selectedCell = this.getSelectedCell(); 688 | const prefilled = selectedCell && selectedCell.get('prefilled'); 689 | return ( 690 |
691 |
692 | Undo 693 |
694 |
695 | Redo 696 |
697 |
698 | Erase 699 |
700 |
705 | Hint 706 |
707 | 708 |
709 | ); 710 | } 711 | 712 | renderPuzzle() { 713 | const { board } = this.state; 714 | return ( 715 |
716 | {board.get('puzzle').map((row, i) => ( 717 | // eslint-disable-next-line react/no-array-index-key 718 |
719 | { 720 | row.map((cell, j) => this.renderCell(cell, i, j)).toArray() 721 | } 722 |
723 | )).toArray()} 724 | 725 |
726 | ); 727 | } 728 | 729 | renderControls() { 730 | return ( 731 |
732 | {this.renderNumberControl()} 733 | {this.renderActions()} 734 | { /* language=CSS */ } 735 | 746 |
747 | ); 748 | } 749 | 750 | renderGenerationUI() { 751 | return ( 752 | 753 | ); 754 | } 755 | 756 | renderHeader() { 757 | return ( 758 |
759 |
this.setState({ board: false })}> 760 | 761 |
New Game
762 |
763 | 764 | { /* language=CSS */ } 765 | 788 |
789 | ); 790 | } 791 | 792 | render() { 793 | const { board } = this.state; 794 | return ( 795 |
796 | 797 | Sudoku Evolved 798 | 799 | 800 | 801 | 802 | 803 | 804 | 805 | 806 | 807 | {!board && this.renderGenerationUI()} 808 | {board && this.renderHeader()} 809 | {board && this.renderPuzzle()} 810 | {board && this.renderControls()} 811 |
812 | Made with ❤️️ By Sitian Liu | Blog Post 813 |
814 | { /* language=CSS */ } 815 | 870 | 871 |
872 | ); 873 | } 874 | } 875 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const next = require('next'); -------------------------------------------------------------------------------- /static/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goldensunliu/react-sudoku-game/08eb59e336c6ba731473c79700cf0ece159bba9c/static/og-image.png -------------------------------------------------------------------------------- /sudoku.js: -------------------------------------------------------------------------------- 1 | function randomChoice(choices) { 2 | return choices[Math.floor(Math.random() * choices.length)]; 3 | } 4 | 5 | export function range(n) { 6 | return Array.from(Array(n).keys()); 7 | } 8 | 9 | // TODO use immutable when this is all working 10 | export function makePuzzle() { 11 | while (true) { 12 | try { 13 | const puzzle = Array.from(Array(9).keys()).map(() => Array.from(Array(9).keys())); 14 | const rows = Array.from(Array(9).keys()).map(() => new Set([1, 2, 3, 4, 5, 6, 7, 8, 9])); 15 | const columns = Array.from(Array(9).keys()).map(() => new Set([1, 2, 3, 4, 5, 6, 7, 8, 9])); 16 | const squares = Array.from(Array(9).keys()).map(() => new Set([1, 2, 3, 4, 5, 6, 7, 8, 9])); 17 | Array.from(Array(9).keys()).forEach((i) => { 18 | Array.from(Array(9).keys()).forEach((j) => { 19 | const row = rows[i]; 20 | const column = columns[j]; 21 | const square = squares[((Math.floor(i / 3)) * 3) + Math.floor(j / 3)]; 22 | const choices = [...row].filter(x => column.has(x)).filter(x => square.has(x)); 23 | const choice = randomChoice(choices); 24 | if (!choice) { 25 | // eslint-disable-next-line no-throw-literal 26 | throw 'dead end'; 27 | } 28 | puzzle[i][j] = choice; 29 | column.delete(choice); 30 | row.delete(choice); 31 | square.delete(choice); 32 | }); 33 | }); 34 | return puzzle; 35 | } catch (e) { 36 | // eslint-disable-next-line no-continue 37 | continue; 38 | } 39 | } 40 | } 41 | 42 | /** 43 | * Answers the question: can the cell (i,j) in the puzzle contain the number 44 | in cell "c" 45 | * @param puzzle 46 | * @param i 47 | * @param j 48 | * @param c 49 | */ 50 | function canBeA(puzzle, i, j, c) { 51 | const x = Math.floor(c / 9); 52 | const y = c % 9; 53 | const value = puzzle[x][y]; 54 | if (puzzle[i][j] === value) return true; 55 | if (puzzle[i][j] > 0) return false; 56 | // if not the cell itself, and the mth cell of the group contains the value v, then "no" 57 | // eslint-disable-next-line guard-for-in,no-restricted-syntax 58 | for (const m in Array.from(Array(9).keys())) { 59 | const rowPeer = { x: m, y: j }; 60 | const columnPeer = { x: i, y: m }; 61 | const SquarePeer = { 62 | x: (Math.floor(i / 3) * 3) + Math.floor(m / 3), 63 | y: (Math.floor(j / 3) * 3) + (m % 3), 64 | }; 65 | if (!(rowPeer.x === x && rowPeer.y === y) && puzzle[rowPeer.x, rowPeer.y] === value) return false; 66 | if (!(columnPeer.x === x && columnPeer.y === y) && puzzle[columnPeer.x, columnPeer.y] === value) return false; 67 | if (!(SquarePeer.x === x && SquarePeer.y === y) && puzzle[SquarePeer.x, SquarePeer.y] === value) return false; 68 | } 69 | return true; 70 | } 71 | 72 | /** 73 | * 74 | * @param a 75 | * @param b 76 | * @returns {boolean} 77 | */ 78 | export function isPeer(a, b) { 79 | if (!a || !b) return false; 80 | const squareA = ((Math.floor(a.x / 3)) * 3) + Math.floor(a.y / 3); 81 | const squareB = ((Math.floor(b.x / 3)) * 3) + Math.floor(b.y / 3); 82 | return a.x === b.x || a.y === b.y || squareA === squareB; 83 | } 84 | 85 | export function pluck(allCells, n = 0) { 86 | const puzzle = JSON.parse(JSON.stringify(allCells)); 87 | /** 88 | * starts with a set of all 81 cells, and tries to remove one (randomly) at a time, 89 | * but not before checking that the cell can still be deduced from the remaining cells. 90 | * @type {Set} 91 | */ 92 | const cells = new Set(Array.from(Array(81).keys())); 93 | const cellsLeft = new Set(cells); 94 | while (cellsLeft.size && cells.size > n) { 95 | const cell = randomChoice([...cells]); 96 | const x = Math.floor(cell / 9); 97 | const y = cell % 9; 98 | cellsLeft.delete(cell); 99 | /** 100 | * row, column and square record whether another cell in those groups could also take 101 | * on the value we are trying to pluck. (If another cell can, then we can't use the 102 | * group to deduce this value.) If all three groups are True, then we cannot pluck 103 | * this cell and must try another one. 104 | */ 105 | let row = false; 106 | let column = false; 107 | let square = false; 108 | range(9).forEach((i) => { 109 | const rowPeer = { x: i, y }; 110 | const columnPeer = { x, y: i }; 111 | const squarePeer = { 112 | x: (Math.floor(Math.floor(cell / 9) / 3) * 3) + Math.floor(i / 3), 113 | y: ((Math.floor(cell / 9) % 3) * 3) + (i % 3), 114 | }; 115 | if (rowPeer.x !== x) { 116 | row = canBeA(puzzle, rowPeer.x, rowPeer.y, cell); 117 | } 118 | if (columnPeer.y !== y) { 119 | column = canBeA(puzzle, columnPeer.x, columnPeer.y, cell); 120 | } 121 | if (squarePeer.x !== x && squarePeer.y !== y) { 122 | square = canBeA(puzzle, squarePeer.x, squarePeer.y, cell); 123 | } 124 | }); 125 | if (row && column && square) { 126 | // eslint-disable-next-line no-continue 127 | continue; 128 | } else { 129 | // this is a pluckable cell! 130 | // eslint-disable-next-line no-param-reassign 131 | puzzle[x][y] = 0; // 0 denotes a blank cell 132 | /** 133 | * remove from the set of visible cells (pluck it) 134 | * we don't need to reset "cellsleft" because if a cell was not pluckable 135 | * earlier, then it will still not be pluckable now (with less information 136 | * on the board). 137 | */ 138 | cells.delete(cell); 139 | } 140 | } 141 | return { puzzle, size: cells.size }; 142 | } 143 | -------------------------------------------------------------------------------- /svg/help.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 9 | 12 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /svg/loupe.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 18 | 19 | 20 | 21 | 22 | 27 | 28 | 29 | 30 | 31 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /svg/reload.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /svg/remove.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 19 | 20 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /svg/return.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | --------------------------------------------------------------------------------