├── .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 |
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 |
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 |
51 |
--------------------------------------------------------------------------------
/svg/loupe.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
67 |
--------------------------------------------------------------------------------
/svg/reload.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
46 |
--------------------------------------------------------------------------------
/svg/remove.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
60 |
--------------------------------------------------------------------------------
/svg/return.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
46 |
--------------------------------------------------------------------------------
|