├── .gitignore ├── Procfile ├── README.md ├── assets ├── tic-tac-toe-js-data-flow.png └── tic-tac-toe.png ├── css └── scss │ ├── app │ └── _app.scss │ ├── globals │ ├── _defaults.scss │ ├── _helpers.scss │ ├── _mixins.scss │ ├── _print.scss │ ├── _reset.scss │ └── _variables.scss │ └── style.scss ├── gulpfile.js ├── index.js ├── package.json ├── public ├── css │ └── style.css ├── favicon-o.ico ├── favicon.ico ├── icons │ ├── tic-tac-toe-js-icon-114.png │ ├── tic-tac-toe-js-icon-120.png │ ├── tic-tac-toe-js-icon-144.png │ ├── tic-tac-toe-js-icon-152.png │ ├── tic-tac-toe-js-icon-180.png │ ├── tic-tac-toe-js-icon-72.png │ ├── tic-tac-toe-js-icon-72@2x.png │ ├── tic-tac-toe-js-icon-76.png │ ├── tic-tac-toe-js-icon-76@2x.png │ ├── tic-tac-toe-js-icon.png │ └── tic-tac-toe-js-icon@2x.png ├── img │ ├── startup-1242x2148.png │ ├── startup-640x1096.png │ └── startup-750x1294.png ├── index.html └── js │ └── app.js └── src ├── actions.js ├── fiveicon-view.js ├── game.js ├── grid-view.js ├── initializer.js ├── middlewares ├── define-winner.js └── logger.js ├── score-view.js ├── store.js ├── utils.js └── winner-service.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled source # 2 | ################### 3 | *.sass-cache 4 | *.com 5 | *.class 6 | *.dll 7 | *.exe 8 | *.o 9 | *.so 10 | 11 | # Packages # 12 | ############ 13 | # it's better to unpack these files and commit the raw source 14 | # git has its own built in compression methods 15 | *.7z 16 | *.dmg 17 | *.gz 18 | *.iso 19 | *.jar 20 | *.rar 21 | *.tar 22 | *.zip 23 | 24 | # Logs and databases # 25 | ###################### 26 | *.log 27 | *.sql 28 | *.sqlite 29 | 30 | # OS generated files # 31 | ###################### 32 | .DS_Store 33 | npm-debug.log 34 | .DS_Store? 35 | ._* 36 | .Spotlight-V100 37 | .Trashes 38 | # Icon? 39 | ehthumbs.db 40 | Thumbs.db 41 | node_modules 42 | /*.env -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node index.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tic-Tac-Toe.js 2 | 3 | Tic-Tac-Toe game written in vanilla javascript using redux-like approach. 4 | 5 | #### [Medium article](https://medium.com/@ramonvictor/tic-tac-toe-js-redux-pattern-in-plain-javascript-fffe37f7c47a) / [Github.io page](http://ramonvictor.github.io/tic-tac-toe-js/) / [Play the game](https://rocky-ocean-52527.herokuapp.com/) 6 | 7 | Mobile and desktop Tic-Tac-Toe.js screenshots 8 | 9 | ## How the game applies Redux pattern? 10 | 11 | It uses the unidirectional data flow: 12 | 13 | Mobile and desktop Tic-Tac-Toe.js screenshots 14 | 15 | ### The key principles 16 | 17 | **1. Single source of truth** 18 | 19 | One single [store.js](https://github.com/ramonvictor/tic-tac-toe-js/blob/master/src/store.js): 20 | 21 | ```javascript 22 | function Store() { 23 | this.state = {}; 24 | this.state = this.update(this.state, {}); 25 | // `this.update()` will return the initial state: 26 | // ---------------------------------------------- 27 | // { 28 | // grid: ['', '', '', '', '', '', '', '', ''], 29 | // turn: 'x', 30 | // score: { x: 0, o: 0 }, 31 | // winnerSequence: [], 32 | // turnCounter: 0, 33 | // player: '' 34 | // } 35 | } 36 | ``` 37 | 38 | **2. State is read-only** 39 | 40 | [Game.js](https://github.com/ramonvictor/tic-tac-toe-js/blob/master/src/game.js) dispatches actions whenever needed: 41 | 42 | ```javascript 43 | this.$table.addEventListener('click', function(event) { 44 | var state = store.getState(); 45 | // [Prevent dispatch under certain conditions] 46 | // Otherwise, trigger `SET_X` or `SET_O` 47 | store.dispatch({ 48 | type: state.turn === 'x' ? 'SET_X' : 'SET_O', 49 | index: parseInt(index, 10) 50 | }); 51 | }); 52 | ``` 53 | 54 | **3. Changes are made with pure functions** 55 | 56 | [Store.js](https://github.com/ramonvictor/tic-tac-toe-js/blob/master/src/store.js): reducers receive actions and return new state. 57 | 58 | ```javascript 59 | // Reducer (pure function) 60 | function updatePlayer(player, action) { 61 | switch (action.type) { 62 | case 'PICK_SIDE': 63 | return action.side; 64 | default: 65 | return player || ''; 66 | } 67 | } 68 | 69 | // Call reducer on Store.update() 70 | Store.prototype.update = function(state, action) { 71 | return { 72 | player: updatePlayer(state.player, action) 73 | // [...other cool stuff here] 74 | }; 75 | }; 76 | ``` 77 | 78 | **4. After update, UI can render latest state** 79 | 80 | [Game.js](https://github.com/ramonvictor/tic-tac-toe-js/blob/master/src/game.js) handles UI changes: 81 | 82 | ```javascript 83 | var store = require('./store'); 84 | var gridView = require('./grid-view'); 85 | 86 | TicTacToe.prototype.eventListeners = function() { 87 | store.subscribe(this.render.bind(this)); 88 | }; 89 | 90 | TicTacToe.prototype.render = function(prevState, state) { 91 | // You can even check whether new state is different 92 | if (prevState.grid !== state.grid) { 93 | this.gridView.render('grid', state.grid); 94 | } 95 | }; 96 | ``` 97 | 98 | Further details about implementation you can [find on this page](http://ramonvictor.github.io/tic-tac-toe-js/). 99 | 100 | ## Browser support 101 | 102 | The game has been tested in the following platforms: 103 | 104 | Latest | Latest | 10+ | Latest | 105 | --- | --- | --- | --- | 106 | ![Chrome](https://raw.github.com/alrra/browser-logos/master/chrome/chrome_48x48.png) | ![Firefox](https://raw.github.com/alrra/browser-logos/master/firefox/firefox_48x48.png) | ![IE](https://raw.github.com/alrra/browser-logos/master/internet-explorer/internet-explorer_48x48.png) | ![Safari](https://raw.github.com/alrra/browser-logos/master/safari/safari_48x48.png) 107 | 108 | 109 | ## Development stack 110 | - Server: NodeJS / Express / Socket.io 111 | - Client: VanillaJS / Redux 112 | - Tools: Gulp / Webpack / Sass / Heroku 113 | 114 | ## Did you find a bug? 115 | 116 | Please report on the [issues tab](https://github.com/ramonvictor/tic-tac-toe-js/issues). 117 | -------------------------------------------------------------------------------- /assets/tic-tac-toe-js-data-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/assets/tic-tac-toe-js-data-flow.png -------------------------------------------------------------------------------- /assets/tic-tac-toe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/assets/tic-tac-toe.png -------------------------------------------------------------------------------- /css/scss/app/_app.scss: -------------------------------------------------------------------------------- 1 | body{ 2 | font-size: 14px; 3 | font-family: Helvetica, Arial, 'Sans serif'; 4 | color: #555; 5 | background: #38485f; 6 | } 7 | 8 | .container{ 9 | width: 500px; 10 | margin: 0 auto; 11 | } 12 | 13 | .header{ 14 | font-size: 18px; 15 | } 16 | 17 | .footer{ 18 | padding: 30px 0; 19 | color: #96a8c1; 20 | text-align: center; 21 | position: relative; 22 | 23 | a { 24 | color: #96a8c1; 25 | text-decoration: none; 26 | transition: all .2s linear; 27 | -webkit-tap-highlight-color: rgba(0,0,0,0); 28 | 29 | &:hover{ 30 | color: #fff; 31 | } 32 | } 33 | } 34 | 35 | .group { 36 | zoom: 1; 37 | &:before, &:after { 38 | content: ""; 39 | display: table; 40 | } 41 | &:after { 42 | clear: both; 43 | } 44 | } 45 | 46 | .room-id-label{ 47 | border: 1px solid #324156; 48 | border-right: 0; 49 | padding: 12px 13px 11px; 50 | border-radius: 3px 0 0 3px; 51 | text-transform: uppercase; 52 | position: relative; 53 | top: -1px; 54 | -webkit-tap-highlight-color: rgba(0,0,0,0); 55 | } 56 | 57 | .room-id{ 58 | max-width: 100px; 59 | font-size: 16px; 60 | background: #324156; 61 | border: none; 62 | border-radius: 0 3px 3px 0; 63 | margin-bottom: 25px; 64 | padding: 12px 18px 11px; 65 | color: #96A8C1; 66 | outline: none; 67 | -webkit-tap-highlight-color: rgba(0,0,0,0); 68 | transition: all .2s linear; 69 | 70 | &:focus{ 71 | background: darken(#324156, 3%); 72 | } 73 | } 74 | 75 | .refresh-icon{ 76 | font-size: 16px; 77 | color: #96A8C1; 78 | padding: 9px 14px; 79 | text-align: center; 80 | border: 1px solid #324156; 81 | border-radius: 3px; 82 | cursor: pointer; 83 | background: none; 84 | outline: none; 85 | transition: all .2s linear; 86 | -webkit-tap-highlight-color: rgba(0,0,0,0); 87 | 88 | &:hover{ 89 | color: #aebdd3; 90 | border-color: #aebdd3; 91 | } 92 | } 93 | 94 | .tic-tac-toe-table{ 95 | width: 500px; 96 | height: 500px; 97 | } 98 | 99 | .tic-tac-toe-table-cell{ 100 | border: 3px solid #283344; 101 | width: 33.3%; 102 | height: 33.3%; 103 | position: relative; 104 | } 105 | 106 | .turn-display{ 107 | overflow: hidden; 108 | white-space: nowrap; 109 | 110 | & > li { 111 | padding: 40px 15px; 112 | width: 50%; 113 | float: left; 114 | position: relative; 115 | box-sizing: border-box; 116 | } 117 | 118 | .score{ 119 | color: #95a7c1; 120 | display: inline-block; 121 | background: #273342; 122 | border-radius: 14px; 123 | min-width: 40px; 124 | padding: 0 10px; 125 | text-align: center; 126 | position: absolute; 127 | top: 50%; 128 | border-left: 1px solid darken(#273342, 10%); 129 | border-top: 1px solid darken(#273342, 10%); 130 | margin-top: -13px; 131 | height: 28px; 132 | line-height: 26px; 133 | } 134 | } 135 | 136 | .is-x .score { 137 | right: 90px; 138 | } 139 | 140 | .is-o .score { 141 | left: 90px; 142 | } 143 | 144 | 145 | .turn-player { 146 | width: 60px; 147 | height: 60px; 148 | border-radius: 50%; 149 | background: #54667f; 150 | position: relative; 151 | box-shadow: 3px 3px 0 0 #344359; 152 | 153 | .is-o &{ 154 | float: left; 155 | } 156 | 157 | .is-x &{ 158 | float: right; 159 | } 160 | 161 | & > .o{ 162 | border-width: 5px; 163 | width: 30px; 164 | height: 30px; 165 | margin: -15px 0 0 -15px; 166 | position: absolute; 167 | left: 50%; 168 | top: 50%; 169 | z-index: 2; 170 | } 171 | 172 | & > .x{ 173 | margin: 0; 174 | z-index: 2; 175 | 176 | &:before, 177 | &:after{ 178 | width: 5px; 179 | height: 30px; 180 | top: 15px; 181 | left: 34px; 182 | } 183 | } 184 | 185 | .is-selected &:before { 186 | content: ''; 187 | position: absolute; 188 | top: 0; 189 | left: 0; 190 | width: 60px; 191 | height: 60px; 192 | content: ''; 193 | border-radius: 50%; 194 | border: 3px solid transparent; 195 | border-top-color: rgba(255,255,255,.3); 196 | border-bottom-color: rgba(255,255,255,.3); 197 | animation: spinner 1s ease infinite; 198 | -webkit-animation: spinner 1s ease infinite; 199 | } 200 | 201 | } 202 | 203 | 204 | .x{ 205 | &:before, &:after{ 206 | content: ''; 207 | display: block; 208 | width: 15px; 209 | height: 110px; 210 | background: #fff; 211 | border-radius: 2px; 212 | position: absolute; 213 | left: 50%; 214 | top: 24px; 215 | margin-left: -7px; 216 | } 217 | 218 | &:before { 219 | -webkit-transform: rotate(45deg); 220 | -moz-transform: rotate(45deg); 221 | transform: rotate(45deg); 222 | } 223 | 224 | &:after{ 225 | -webkit-transform: rotate(-45deg); 226 | -moz-transform: rotate(-45deg); 227 | transform: rotate(-45deg); 228 | } 229 | 230 | &.is-winner-cell:after, 231 | &.is-winner-cell:before{ 232 | background: #5bd1ab; 233 | } 234 | } 235 | 236 | .is-winner-cell { 237 | animation-name: shakeme; 238 | animation-duration: 0.8s; 239 | transform-origin: 50% 50%; 240 | animation-iteration-count: infinite; 241 | animation-timing-function: linear; 242 | -webkit-animation-name: shakeme; 243 | -webkit-animation-duration: 0.8s; 244 | -webkit-transform-origin: 50% 50%; 245 | -webkit-animation-iteration-count: infinite; 246 | -webkit-animation-timing-function: linear; 247 | } 248 | 249 | .o{ 250 | width: 95px; 251 | height: 95px; 252 | border-radius: 50%; 253 | border: 15px solid #fff; 254 | position: absolute; 255 | left: 50%; 256 | top: 50%; 257 | margin: -47px 0 0 -47px; 258 | 259 | &.is-winner-cell{ 260 | border-color: #5bd1ab; 261 | } 262 | } 263 | 264 | 265 | .pop-over{ 266 | width: 70%; 267 | background: #fff; 268 | border-radius: 3px; 269 | position: absolute; 270 | left: 0; 271 | bottom: 125px; 272 | margin-left: 15%; 273 | box-sizing: border-box; 274 | padding: 20px 25px; 275 | color: #666d79; 276 | z-index: 5; 277 | opacity: 1; 278 | transition: all .2s linear; 279 | box-shadow: 3px 3px 0 0 rgba(0,0,0,.1); 280 | display: none; 281 | 282 | p { 283 | line-height: 150%; 284 | } 285 | 286 | &:before{ 287 | content: ''; 288 | display: block; 289 | width: 0; 290 | height: 0; 291 | border: 8px solid transparent; 292 | border-top-color: #fff; 293 | position: absolute; 294 | bottom: -16px; 295 | left: 50%; 296 | margin-left: -4px; 297 | } 298 | 299 | &.hide { 300 | opacity: 0; 301 | } 302 | } 303 | 304 | .pop-over-close{ 305 | position: absolute; 306 | top: 0; 307 | right: 0; 308 | width: 30px; 309 | display: block; 310 | height: 30px; 311 | line-height: 30px; 312 | text-align: center; 313 | cursor: pointer; 314 | font-size: 18px; 315 | opacity: 1; 316 | } 317 | 318 | 319 | // Mobile 320 | // -------- 321 | 322 | @media only screen and (max-device-width: 499px) { 323 | .container { 324 | width: 100%; 325 | margin: 0; 326 | padding: 0 25px; 327 | box-sizing: border-box; 328 | } 329 | 330 | .tic-tac-toe-table{ 331 | width: 100%; 332 | height: auto; 333 | -webkit-tap-highlight-color: rgba(0,0,0,0); 334 | } 335 | 336 | .tic-tac-toe-table-cell{ 337 | position: relative; 338 | 339 | &:after{ 340 | content: ''; 341 | display: block; 342 | position: absolute; 343 | top: 0; 344 | right: 0; 345 | bottom: 0; 346 | left: 0; 347 | } 348 | 349 | &:before{ 350 | content: ''; 351 | display: block; 352 | padding-top: 100%; 353 | width: 1px; 354 | float: left; 355 | } 356 | 357 | .x{ 358 | width: 60px; 359 | height: 60px; 360 | position: absolute; 361 | left: 50%; 362 | top: 50%; 363 | margin: -30px 0 0 -30px; 364 | 365 | &:before, &:after{ 366 | width: 10px; 367 | height: 65px; 368 | top: -3px; 369 | left: 25px; 370 | margin: 0; 371 | } 372 | } 373 | 374 | .o{ 375 | width: 60px; 376 | height: 60px; 377 | border-width: 10px; 378 | position: absolute; 379 | left: 50%; 380 | top: 50%; 381 | margin: -30px 0 0 -30px; 382 | } 383 | } 384 | 385 | .pop-over{ 386 | width: 90%; 387 | margin-left: 5%; 388 | } 389 | } 390 | 391 | 392 | // Animations 393 | // ----------- 394 | 395 | @-webkit-keyframes shakeme { 396 | 0% { -webkit-transform: translate(2px, 1px) rotate(0deg); } 397 | 10% { -webkit-transform: translate(-1px, -2px) rotate(-1deg); } 398 | 20% { -webkit-transform: translate(-3px, 0px) rotate(1deg); } 399 | 30% { -webkit-transform: translate(0px, 2px) rotate(0deg); } 400 | 40% { -webkit-transform: translate(1px, -1px) rotate(1deg); } 401 | 50% { -webkit-transform: translate(-1px, 2px) rotate(-1deg); } 402 | 60% { -webkit-transform: translate(-3px, 1px) rotate(0deg); } 403 | 70% { -webkit-transform: translate(2px, 1px) rotate(-1deg); } 404 | 80% { -webkit-transform: translate(-1px, -1px) rotate(1deg); } 405 | 90% { -webkit-transform: translate(2px, 2px) rotate(0deg); } 406 | 100% { -webkit-transform: translate(1px, -2px) rotate(-1deg); } 407 | } 408 | 409 | @keyframes shakeme { 410 | 0% { -webkit-transform: translate(2px, 1px) rotate(0deg); } 411 | 10% { -webkit-transform: translate(-1px, -2px) rotate(-1deg); } 412 | 20% { -webkit-transform: translate(-3px, 0px) rotate(1deg); } 413 | 30% { -webkit-transform: translate(0px, 2px) rotate(0deg); } 414 | 40% { -webkit-transform: translate(1px, -1px) rotate(1deg); } 415 | 50% { -webkit-transform: translate(-1px, 2px) rotate(-1deg); } 416 | 60% { -webkit-transform: translate(-3px, 1px) rotate(0deg); } 417 | 70% { -webkit-transform: translate(2px, 1px) rotate(-1deg); } 418 | 80% { -webkit-transform: translate(-1px, -1px) rotate(1deg); } 419 | 90% { -webkit-transform: translate(2px, 2px) rotate(0deg); } 420 | 100% { -webkit-transform: translate(1px, -2px) rotate(-1deg); } 421 | } 422 | 423 | @keyframes fade-and-hide { 424 | 0% { 425 | display: block; 426 | opacity: 1; 427 | } 428 | 99% { 429 | display: block; 430 | } 431 | 100% { 432 | display: none; 433 | opacity: 0; 434 | } 435 | } 436 | 437 | @-webkit-keyframes fade-and-hide { 438 | 0% { 439 | display: block; 440 | opacity: 1; 441 | } 442 | 99% { 443 | display: block; 444 | } 445 | 100% { 446 | display: none; 447 | opacity: 0; 448 | } 449 | } 450 | 451 | 452 | @keyframes spinner { 453 | to {transform: rotate(360deg);} 454 | } 455 | 456 | @-webkit-keyframes spinner { 457 | to {-webkit-transform: rotate(360deg);} 458 | } 459 | 460 | // IE10+ CSS styles 461 | @media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { 462 | .tic-tac-toe-table-cell{ 463 | vertical-align: middle; 464 | text-align: center; 465 | } 466 | 467 | .tic-tac-toe-table .o{ 468 | margin: 0; 469 | position: static; 470 | left: auto; 471 | top: auto; 472 | display: inline-block; 473 | } 474 | } -------------------------------------------------------------------------------- /css/scss/globals/_defaults.scss: -------------------------------------------------------------------------------- 1 | @import "compass/css3/transition"; 2 | 3 | html { 4 | font-size: 100%; 5 | overflow-y: scroll; 6 | } 7 | body { 8 | font-size: 13px; 9 | line-height: 1 10 | } 11 | body, button, input, select, textarea { 12 | font-family: Arial,sans-serif; 13 | color: #929292; 14 | font-size: $font-size-default; 15 | } 16 | ::-moz-selection { 17 | background: #45b2ad; 18 | color: #fff; 19 | text-shadow:none 20 | } 21 | ::selection { 22 | background: #45b2ad; 23 | color: #fff; 24 | text-shadow: none 25 | } 26 | a { 27 | color: blue; 28 | text-decoration: none; 29 | @include single-transition(color, .2s, linear); 30 | } 31 | a:focus { 32 | outline: thin dotted 33 | } 34 | a:hover,a:active { 35 | outline: 0; 36 | text-decoration: none 37 | } 38 | img{ 39 | vertical-align: middle; 40 | } 41 | 42 | h1, h2, h3, h4, h5 { 43 | line-height: 140%; 44 | margin-bottom: .5em; 45 | } 46 | 47 | p { 48 | line-height: 150%; 49 | margin-bottom: .7em; 50 | } -------------------------------------------------------------------------------- /css/scss/globals/_helpers.scss: -------------------------------------------------------------------------------- 1 | // rv classes 2 | 3 | .hide { display: none } 4 | .show { display: block } 5 | .clr { clear: both } 6 | .left { float: left } 7 | .right { float: right } 8 | .skip { 9 | text-indent: 100%; 10 | white-space: nowrap; 11 | overflow: hidden; 12 | display: block; 13 | } 14 | 15 | .group { 16 | zoom: 1; 17 | &:before, &:after { 18 | content: ""; 19 | display: table; 20 | } 21 | &:after { 22 | clear: both; 23 | } 24 | } -------------------------------------------------------------------------------- /css/scss/globals/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin rv-v-align( $h ){ 2 | height: $h; 3 | line-height: $h; 4 | } 5 | 6 | @mixin rv-square( $dimensions ){ 7 | width: $dimensions; 8 | height: $dimensions; 9 | } 10 | 11 | @mixin rv-sticky( $top: false, $right: false, $bottom: false, $left: false ){ 12 | position: absolute; 13 | @if $top != false { 14 | top : $top; 15 | } 16 | @if $right != false { 17 | right : $right; 18 | } 19 | @if $bottom != false { 20 | bottom : $bottom; 21 | } 22 | @if $left != false { 23 | left : $left; 24 | } 25 | } 26 | 27 | @mixin rv-dimentions($w, $h){ 28 | width: $w; 29 | height: $h; 30 | } 31 | 32 | // --------------------------------------------------------- 33 | // USAGE: 34 | // 1. @import "icons/*.png"; 35 | // 2. @each $icon-name in icon-file-name, icon-file-name-2 { 36 | // @include rv-icon-set( "icons", $icon-name ); 37 | // } 38 | @mixin rv-icon-set( $icons-slug, $icon-name ){ 39 | .#{$icon-name} { 40 | display: inline-block; 41 | width: icons-sprite-width( $icon-name ); 42 | height: icons-sprite-height( $icon-name ); 43 | @include icons-sprite( $icon-name ); 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /css/scss/globals/_print.scss: -------------------------------------------------------------------------------- 1 | @media print { 2 | * { 3 | background:transparent!important; 4 | color:black!important; 5 | box-shadow:none !important; 6 | text-shadow:none!important; 7 | filter:none!important; 8 | -ms-filter:none !important 9 | } 10 | a,a:visited { 11 | text-decoration:underline 12 | } 13 | a[href]:after { 14 | content:" (" attr(href) ")" 15 | } 16 | abbr[title]:after { 17 | content:" (" attr(title) ")" 18 | } 19 | .ir a:after,a[href^="javascript:"]:after,a[href^="#"]:after { 20 | content:"" 21 | } 22 | pre,blockquote { 23 | border:1px solid #999; 24 | page-break-inside:avoid 25 | } 26 | thead { 27 | display:table-header-group 28 | } 29 | tr,img { 30 | page-break-inside:avoid 31 | } 32 | img { 33 | max-width:100%!important 34 | } 35 | @page { 36 | margin:0.5cm 37 | } 38 | p,h2,h3 { 39 | orphans:3; 40 | widows:3 41 | } 42 | h2,h3 { 43 | page-break-after: avoid 44 | } 45 | } -------------------------------------------------------------------------------- /css/scss/globals/_reset.scss: -------------------------------------------------------------------------------- 1 | html,body,div,span,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,address,big,cite,code,em,img,small,strong,sub,sup,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,header,nav,article,section,dialog,figure,aside,footer { 2 | margin:0; 3 | padding:0; 4 | border:0; 5 | font-size:100%; 6 | vertical-align:baseline; 7 | background:transparent 8 | } 9 | ol,ul { 10 | list-style:none 11 | } 12 | blockquote,q { 13 | quotes:none 14 | } 15 | blockquote:before,blockquote:after,q:before,q:after { 16 | content:''; 17 | content:none 18 | } 19 | ins { 20 | text-decoration:none 21 | } 22 | del { 23 | text-decoration:line-through 24 | } 25 | table { 26 | border-collapse:collapse; 27 | border-spacing:0 28 | } 29 | hr { 30 | display:none 31 | } 32 | a { 33 | overflow:hidden 34 | } 35 | abbr[title] { 36 | border-bottom:1px dotted 37 | } 38 | b,strong { 39 | font-weight:bold 40 | } 41 | blockquote { 42 | margin:1em 40px 43 | } 44 | pre { 45 | white-space:pre; 46 | white-space:pre-wrap; 47 | word-wrap:break-word 48 | } 49 | small { 50 | font-size:85% 51 | } 52 | img { 53 | -ms-interpolation-mode:bicubic 54 | } 55 | label { 56 | cursor:pointer 57 | } 58 | button,input,select,textarea { 59 | font-size:100%; 60 | margin:0; 61 | vertical-align:baseline 62 | } 63 | textarea { 64 | overflow:auto; 65 | vertical-align:top 66 | } 67 | button,input { 68 | line-height:normal 69 | } 70 | input[type="search"] { 71 | box-sizing: content-box; 72 | } 73 | input[type="checkbox"],input[type="radio"] { 74 | box-sizing: border-box; 75 | } 76 | button { 77 | width:auto; 78 | overflow:visible 79 | } 80 | button::-moz-focus-inner,input[type="reset"]::-moz-focus-inner,input[type="button"]::-moz-focus-inner,input[type="submit"]::-moz-focus-inner,input[type="file"]>input[type="button"]::-moz-focus-inner { 81 | border:0; 82 | padding:0; 83 | margin:0; 84 | } 85 | .lt-ie8 button,.lt-ie8 input { 86 | overflow:visible 87 | } 88 | .lt-ie8 button,.lt-ie8 input,.lt-ie8 select,.lt-ie8 textarea { 89 | vertical-align: middle 90 | } 91 | 92 | // border-box 93 | *, *:before, *:after { 94 | -webkit-box-sizing: border-box; 95 | -moz-box-sizing: border-box; 96 | box-sizing: border-box; 97 | } -------------------------------------------------------------------------------- /css/scss/globals/_variables.scss: -------------------------------------------------------------------------------- 1 | $font-size-default: 12px; -------------------------------------------------------------------------------- /css/scss/style.scss: -------------------------------------------------------------------------------- 1 | /* =================================== 2 | * Project: Canvas Fireworks 3 | * Author: Ramon Victor | @ramonvictor 4 | * Date: March 2016 5 | * =================================== 6 | */ 7 | 8 | @import "globals/variables"; 9 | @import "globals/reset"; 10 | 11 | @import "app/app"; -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 1. DEPENDENCIES 3 | *******************************************************************************/ 4 | 5 | var gulp = require('gulp'); // gulp core 6 | var compass = require('gulp-compass'); // compass compiler 7 | var uglify = require('gulp-uglify'); // uglifies the js 8 | var jshint = require('gulp-jshint'); // check if js is ok 9 | var rename = require("gulp-rename"); // rename files 10 | var concat = require('gulp-concat'); // concatinate js 11 | var notify = require('gulp-notify'); // send notifications to osx 12 | var plumber = require('gulp-plumber'); // disable interuption 13 | var stylish = require('jshint-stylish'); // make errors look good in shell 14 | var minifycss = require('gulp-minify-css'); // minify the css files 15 | var browserSync = require('browser-sync').create(); // inject code to all devices 16 | var autoprefixer = require('gulp-autoprefixer'); // sets missing browserprefixes 17 | var nodemon = require('gulp-nodemon'); 18 | var webpack = require('webpack'); 19 | var webpackStream = require('webpack-stream'); 20 | var port = process.env.PORT || 3000; 21 | 22 | /******************************************************************************* 23 | 2. FILE DESTINATIONS (RELATIVE TO ASSSETS FOLDER) 24 | *******************************************************************************/ 25 | 26 | var target = { 27 | sass_src : 'css/scss/**/*.scss', // all sass files 28 | css_dest : 'public/css', // where to put minified css 29 | css_output : 'public/css/*.css', // where to put minified css 30 | sass_folder : 'css/scss', // where to put minified css 31 | js_concat_src : [ // all js files that should be concatinated 32 | 'src/utils.js', 33 | 'src/store.js', 34 | 'src/winner-service.js', 35 | 'src/score-view.js', 36 | 'src/grid-view.js', 37 | 'src/fiveicon-view.js', 38 | 'src/game.js', 39 | 'src/initializer.js' 40 | ], 41 | js_dest : 'public/js', // where to put minified js 42 | css_img : 'public/css/i' 43 | }; 44 | 45 | 46 | /******************************************************************************* 47 | 3. COMPASS TASK 48 | *******************************************************************************/ 49 | 50 | gulp.task('compass', function() { 51 | gulp.src(target.sass_src) 52 | .pipe(plumber()) 53 | .pipe(compass({ 54 | css: target.css_dest, 55 | sass: target.sass_folder, 56 | image: target.css_img 57 | })) 58 | .pipe(autoprefixer( 59 | 'last 2 version', 60 | '> 1%', 61 | 'ios 6', 62 | 'android 4' 63 | )) 64 | .pipe(minifycss()) 65 | .pipe(gulp.dest(target.css_dest)); 66 | }); 67 | 68 | 69 | /******************************************************************************* 70 | 4. JS TASKS 71 | *******************************************************************************/ 72 | 73 | // lint my custom js 74 | gulp.task('js-lint', function() { 75 | gulp.src(target.js_concat_src) // get the files 76 | .pipe(jshint()) // lint the files 77 | .pipe(jshint.reporter(stylish)) // present the results in a beautiful way 78 | }); 79 | 80 | /******************************************************************************* 81 | 5. BROWSER SYNC 82 | *******************************************************************************/ 83 | gulp.task('browser-sync', function() { 84 | browserSync.init({ 85 | proxy: 'http://localhost:' + port, 86 | files: ['public/**/*.*'], 87 | port: 5000 88 | }); 89 | }); 90 | 91 | // Reference: https://gist.github.com/sogko/b53d33d4f3b40d3b4b2e 92 | gulp.task('nodemon', function(cb) { 93 | return nodemon({ 94 | script: 'index.js' 95 | }).once('start', cb); 96 | }); 97 | 98 | gulp.task('webpack', function() { 99 | return gulp.src(target.js_concat_src) 100 | .pipe(webpackStream({ 101 | output: { 102 | filename: 'app.js' 103 | } 104 | })) 105 | .pipe(gulp.dest(target.js_dest)); 106 | }); 107 | 108 | /******************************************************************************* 109 | 1. GULP TASKS 110 | *******************************************************************************/ 111 | // gulp.task('watch', function() { 112 | // gulp.watch(target.sass_src, ['compass']).on('change', browserSync.reload); 113 | // gulp.watch(target.css_output).on('change', browserSync.reload); 114 | // gulp.watch(target.js_concat_src, ['js-lint']).on('change', browserSync.reload); 115 | // gulp.watch(target.js_concat_src, ['webpack']).on('change', browserSync.reload); 116 | // }); 117 | 118 | gulp.task('default', ['compass', 'js-lint', 'webpack', 'nodemon']); 119 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var app = express(); 3 | var http = require('http').Server(app); 4 | var io = require('socket.io')(http); 5 | var port = process.env.PORT || 3000; 6 | 7 | app.use(express.static(__dirname + '/public')); 8 | 9 | app.get('/', function(req, res) { 10 | res.sendfile('index.html'); 11 | }); 12 | 13 | io.on('connection', function(socket) { 14 | socket.on('room', function(room) { 15 | socket.join(room); 16 | }); 17 | 18 | socket.on('dispatch', function(data) { 19 | socket.broadcast.to(data.room) 20 | .emit('dispatch', data); 21 | }); 22 | }); 23 | 24 | http.listen(port); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tic-tac-toe-js", 3 | "author": "Ramon Victor", 4 | "description": "Tic Tac Toe game using redux", 5 | "license": "MIT", 6 | "version": "1.0.0", 7 | "main": "web.js", 8 | "repository": "ramonvictor/tic-tac-toe-js", 9 | "dependencies": { 10 | "express": "^4.10.2", 11 | "socket.io": "^1.4.5" 12 | }, 13 | "engines": { 14 | "node": "5.0.0" 15 | }, 16 | "devDependencies": { 17 | "browser-sync": "^2.12.8", 18 | "gulp": "^3.9.1", 19 | "gulp-autoprefixer": "^3.1.0", 20 | "gulp-compass": "^2.1.0", 21 | "gulp-concat": "^2.6.0", 22 | "gulp-jshint": "^2.0.1", 23 | "gulp-minify-css": "^1.2.4", 24 | "gulp-nodemon": "^2.0.7", 25 | "gulp-notify": "^2.2.0", 26 | "gulp-plumber": "^1.1.0", 27 | "gulp-rename": "^1.2.2", 28 | "gulp-uglify": "^1.5.3", 29 | "jshint": "^2.9.2", 30 | "jshint-stylish": "^2.2.0", 31 | "webpack-stream": "^3.2.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | a,abbr,address,article,aside,big,blockquote,body,caption,cite,code,dd,dialog,div,dl,dt,em,fieldset,figure,footer,form,h1,h2,h3,h4,h5,h6,header,html,iframe,img,label,legend,li,nav,object,ol,p,pre,section,small,span,strong,sub,sup,table,tbody,td,tfoot,th,thead,tr,ul{margin:0;padding:0;border:0;font-size:100%;vertical-align:baseline;background:0 0}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:after,blockquote:before,q:after,q:before{content:'';content:none}ins{text-decoration:none}del{text-decoration:line-through}table{border-collapse:collapse;border-spacing:0}hr{display:none}a{overflow:hidden}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}blockquote{margin:1em 40px}pre{white-space:pre;white-space:pre-wrap;word-wrap:break-word}small{font-size:85%}img{-ms-interpolation-mode:bicubic}label{cursor:pointer}button,input,select,textarea{font-size:100%;margin:0;vertical-align:baseline}textarea{overflow:auto;vertical-align:top}button,input{line-height:normal}input[type=search]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}input[type=checkbox],input[type=radio]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}button{width:auto;overflow:visible}button::-moz-focus-inner,input[type=button]::-moz-focus-inner,input[type=file]>input[type=button]::-moz-focus-inner,input[type=reset]::-moz-focus-inner,input[type=submit]::-moz-focus-inner{border:0;padding:0;margin:0}.lt-ie8 button,.lt-ie8 input{overflow:visible}.lt-ie8 button,.lt-ie8 input,.lt-ie8 select,.lt-ie8 textarea{vertical-align:middle}*,:after,:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}body{font-size:14px;font-family:Helvetica,Arial,'Sans serif';color:#555;background:#38485f}.container{width:500px;margin:0 auto}.header{font-size:18px}.footer{padding:30px 0;color:#96a8c1;text-align:center;position:relative}.footer a{color:#96a8c1;text-decoration:none;-webkit-transition:all .2s linear;transition:all .2s linear;-webkit-tap-highlight-color:transparent}.footer a:hover{color:#fff}.group{zoom:1}.group:after,.group:before{content:"";display:table}.group:after{clear:both}.room-id-label{border:1px solid #324156;border-right:0;padding:12px 13px 11px;border-radius:3px 0 0 3px;text-transform:uppercase;position:relative;top:-1px;-webkit-tap-highlight-color:transparent}.room-id{max-width:100px;font-size:16px;background:#324156;border:0;border-radius:0 3px 3px 0;margin-bottom:25px;padding:12px 18px 11px;color:#96A8C1;outline:0;-webkit-tap-highlight-color:transparent;-webkit-transition:all .2s linear;transition:all .2s linear}.room-id:focus{background:#2c3a4c}.refresh-icon{font-size:16px;color:#96A8C1;padding:9px 14px;text-align:center;border:1px solid #324156;border-radius:3px;cursor:pointer;background:0 0;outline:0;-webkit-transition:all .2s linear;transition:all .2s linear;-webkit-tap-highlight-color:transparent}.refresh-icon:hover{color:#aebdd3;border-color:#aebdd3}.tic-tac-toe-table{width:500px;height:500px}.tic-tac-toe-table-cell{border:3px solid #283344;width:33.3%;height:33.3%;position:relative}.turn-display{overflow:hidden;white-space:nowrap}.turn-display>li{padding:40px 15px;width:50%;float:left;position:relative;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.turn-display .score{color:#95a7c1;display:inline-block;background:#273342;border-radius:14px;min-width:40px;padding:0 10px;text-align:center;position:absolute;top:50%;border-left:1px solid #141a22;border-top:1px solid #141a22;margin-top:-13px;height:28px;line-height:26px}.is-x .score{right:90px}.is-o .score{left:90px}.turn-player{width:60px;height:60px;border-radius:50%;background:#54667f;position:relative;-webkit-box-shadow:3px 3px 0 0 #344359;box-shadow:3px 3px 0 0 #344359}.is-o .turn-player{float:left}.is-x .turn-player{float:right}.turn-player>.o{border-width:5px;width:30px;height:30px;margin:-15px 0 0 -15px;position:absolute;left:50%;top:50%;z-index:2}.turn-player>.x{margin:0;z-index:2}.turn-player>.x:after,.turn-player>.x:before{width:5px;height:30px;top:15px;left:34px}.is-selected .turn-player:before{position:absolute;top:0;left:0;width:60px;height:60px;content:'';border-radius:50%;border:3px solid transparent;border-top-color:rgba(255,255,255,.3);border-bottom-color:rgba(255,255,255,.3);animation:spinner 1s ease infinite;-webkit-animation:spinner 1s ease infinite}.x:after,.x:before{content:'';display:block;width:15px;height:110px;background:#fff;border-radius:2px;position:absolute;left:50%;top:24px;margin-left:-7px}.x:before{-webkit-transform:rotate(45deg);-ms-transform:rotate(45deg);transform:rotate(45deg)}.x:after{-webkit-transform:rotate(-45deg);-ms-transform:rotate(-45deg);transform:rotate(-45deg)}.x.is-winner-cell:after,.x.is-winner-cell:before{background:#5bd1ab}.is-winner-cell{animation-name:shakeme;animation-duration:.8s;-ms-transform-origin:50% 50%;transform-origin:50% 50%;animation-iteration-count:infinite;animation-timing-function:linear;-webkit-animation-name:shakeme;-webkit-animation-duration:.8s;-webkit-transform-origin:50% 50%;-webkit-animation-iteration-count:infinite;-webkit-animation-timing-function:linear}.o{width:95px;height:95px;border-radius:50%;border:15px solid #fff;position:absolute;left:50%;top:50%;margin:-47px 0 0 -47px}.o.is-winner-cell{border-color:#5bd1ab}.pop-over{width:70%;background:#fff;border-radius:3px;position:absolute;left:0;bottom:125px;margin-left:15%;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:20px 25px;color:#666d79;z-index:5;opacity:1;-webkit-transition:all .2s linear;transition:all .2s linear;-webkit-box-shadow:3px 3px 0 0 rgba(0,0,0,.1);box-shadow:3px 3px 0 0 rgba(0,0,0,.1);display:none}.pop-over p{line-height:150%}.pop-over:before{content:'';display:block;width:0;height:0;border:8px solid transparent;border-top-color:#fff;position:absolute;bottom:-16px;left:50%;margin-left:-4px}.pop-over.hide{opacity:0}.pop-over-close{position:absolute;top:0;right:0;width:30px;display:block;height:30px;line-height:30px;text-align:center;cursor:pointer;font-size:18px;opacity:1}@media only screen and (max-device-width:499px){.container{width:100%;margin:0;padding:0 25px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.tic-tac-toe-table{width:100%;height:auto;-webkit-tap-highlight-color:transparent}.tic-tac-toe-table-cell{position:relative}.tic-tac-toe-table-cell:after{content:'';display:block;position:absolute;top:0;right:0;bottom:0;left:0}.tic-tac-toe-table-cell:before{content:'';display:block;padding-top:100%;width:1px;float:left}.tic-tac-toe-table-cell .x{width:60px;height:60px;position:absolute;left:50%;top:50%;margin:-30px 0 0 -30px}.tic-tac-toe-table-cell .x:after,.tic-tac-toe-table-cell .x:before{width:10px;height:65px;top:-3px;left:25px;margin:0}.tic-tac-toe-table-cell .o{width:60px;height:60px;border-width:10px;position:absolute;left:50%;top:50%;margin:-30px 0 0 -30px}.pop-over{width:90%;margin-left:5%}}@-webkit-keyframes shakeme{0%{-webkit-transform:translate(2px,1px) rotate(0deg)}10%{-webkit-transform:translate(-1px,-2px) rotate(-1deg)}20%{-webkit-transform:translate(-3px,0) rotate(1deg)}30%{-webkit-transform:translate(0px,2px) rotate(0deg)}40%{-webkit-transform:translate(1px,-1px) rotate(1deg)}50%{-webkit-transform:translate(-1px,2px) rotate(-1deg)}60%{-webkit-transform:translate(-3px,1px) rotate(0deg)}70%{-webkit-transform:translate(2px,1px) rotate(-1deg)}80%{-webkit-transform:translate(-1px,-1px) rotate(1deg)}90%{-webkit-transform:translate(2px,2px) rotate(0deg)}100%{-webkit-transform:translate(1px,-2px) rotate(-1deg)}}@keyframes shakeme{0%{-webkit-transform:translate(2px,1px) rotate(0deg)}10%{-webkit-transform:translate(-1px,-2px) rotate(-1deg)}20%{-webkit-transform:translate(-3px,0) rotate(1deg)}30%{-webkit-transform:translate(0px,2px) rotate(0deg)}40%{-webkit-transform:translate(1px,-1px) rotate(1deg)}50%{-webkit-transform:translate(-1px,2px) rotate(-1deg)}60%{-webkit-transform:translate(-3px,1px) rotate(0deg)}70%{-webkit-transform:translate(2px,1px) rotate(-1deg)}80%{-webkit-transform:translate(-1px,-1px) rotate(1deg)}90%{-webkit-transform:translate(2px,2px) rotate(0deg)}100%{-webkit-transform:translate(1px,-2px) rotate(-1deg)}}@keyframes fade-and-hide{0%{display:block;opacity:1}99%{display:block}100%{display:none;opacity:0}}@-webkit-keyframes fade-and-hide{0%{display:block;opacity:1}99%{display:block}100%{display:none;opacity:0}}@keyframes spinner{to{-webkit-transform:rotate(360deg);-ms-transform:rotate(360deg);transform:rotate(360deg)}}@-webkit-keyframes spinner{to{-webkit-transform:rotate(360deg)}}@media all and (-ms-high-contrast:none),(-ms-high-contrast:active){.tic-tac-toe-table-cell{vertical-align:middle;text-align:center}.tic-tac-toe-table .o{margin:0;position:static;left:auto;top:auto;display:inline-block}} -------------------------------------------------------------------------------- /public/favicon-o.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/public/favicon-o.ico -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/public/favicon.ico -------------------------------------------------------------------------------- /public/icons/tic-tac-toe-js-icon-114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/public/icons/tic-tac-toe-js-icon-114.png -------------------------------------------------------------------------------- /public/icons/tic-tac-toe-js-icon-120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/public/icons/tic-tac-toe-js-icon-120.png -------------------------------------------------------------------------------- /public/icons/tic-tac-toe-js-icon-144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/public/icons/tic-tac-toe-js-icon-144.png -------------------------------------------------------------------------------- /public/icons/tic-tac-toe-js-icon-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/public/icons/tic-tac-toe-js-icon-152.png -------------------------------------------------------------------------------- /public/icons/tic-tac-toe-js-icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/public/icons/tic-tac-toe-js-icon-180.png -------------------------------------------------------------------------------- /public/icons/tic-tac-toe-js-icon-72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/public/icons/tic-tac-toe-js-icon-72.png -------------------------------------------------------------------------------- /public/icons/tic-tac-toe-js-icon-72@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/public/icons/tic-tac-toe-js-icon-72@2x.png -------------------------------------------------------------------------------- /public/icons/tic-tac-toe-js-icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/public/icons/tic-tac-toe-js-icon-76.png -------------------------------------------------------------------------------- /public/icons/tic-tac-toe-js-icon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/public/icons/tic-tac-toe-js-icon-76@2x.png -------------------------------------------------------------------------------- /public/icons/tic-tac-toe-js-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/public/icons/tic-tac-toe-js-icon.png -------------------------------------------------------------------------------- /public/icons/tic-tac-toe-js-icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/public/icons/tic-tac-toe-js-icon@2x.png -------------------------------------------------------------------------------- /public/img/startup-1242x2148.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/public/img/startup-1242x2148.png -------------------------------------------------------------------------------- /public/img/startup-640x1096.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/public/img/startup-640x1096.png -------------------------------------------------------------------------------- /public/img/startup-750x1294.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramonvictor/tic-tac-toe-js/aab37a8cadfaff7bc5fb12c5949f1607885eaff7/public/img/startup-750x1294.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tic-tac-toe.js: redux pattern implemented in vanilla javascript 8 | 9 | 10 | 11 | 12 | 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 | 48 |
49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 |
68 | 78 |
79 | 80 | 81 | 90 | 91 | -------------------------------------------------------------------------------- /public/js/app.js: -------------------------------------------------------------------------------- 1 | /******/ (function(modules) { // webpackBootstrap 2 | /******/ // The module cache 3 | /******/ var installedModules = {}; 4 | 5 | /******/ // The require function 6 | /******/ function __webpack_require__(moduleId) { 7 | 8 | /******/ // Check if module is in cache 9 | /******/ if(installedModules[moduleId]) 10 | /******/ return installedModules[moduleId].exports; 11 | 12 | /******/ // Create a new module (and put it into the cache) 13 | /******/ var module = installedModules[moduleId] = { 14 | /******/ exports: {}, 15 | /******/ id: moduleId, 16 | /******/ loaded: false 17 | /******/ }; 18 | 19 | /******/ // Execute the module function 20 | /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 21 | 22 | /******/ // Flag the module as loaded 23 | /******/ module.loaded = true; 24 | 25 | /******/ // Return the exports of the module 26 | /******/ return module.exports; 27 | /******/ } 28 | 29 | 30 | /******/ // expose the modules object (__webpack_modules__) 31 | /******/ __webpack_require__.m = modules; 32 | 33 | /******/ // expose the module cache 34 | /******/ __webpack_require__.c = installedModules; 35 | 36 | /******/ // __webpack_public_path__ 37 | /******/ __webpack_require__.p = ""; 38 | 39 | /******/ // Load entry module and return exports 40 | /******/ return __webpack_require__(0); 41 | /******/ }) 42 | /************************************************************************/ 43 | /******/ ([ 44 | /* 0 */ 45 | /***/ function(module, exports, __webpack_require__) { 46 | 47 | __webpack_require__(1); 48 | __webpack_require__(2); 49 | __webpack_require__(3); 50 | __webpack_require__(4); 51 | __webpack_require__(5); 52 | __webpack_require__(6); 53 | __webpack_require__(7); 54 | module.exports = __webpack_require__(11); 55 | 56 | 57 | /***/ }, 58 | /* 1 */ 59 | /***/ function(module, exports) { 60 | 61 | var utils = {}; 62 | 63 | utils.qs = function(selector, context) { 64 | context = context || document; 65 | return context.querySelector(selector); 66 | }; 67 | 68 | utils.qsa = function(selector, context) { 69 | context = context || document; 70 | return context.querySelectorAll(selector); 71 | }; 72 | 73 | utils.wait = function(ms, cb) { 74 | return window.setTimeout(cb, (ms || 500)); 75 | }; 76 | 77 | utils.isDevMode = function() { 78 | return window && window.location.hostname === 'localhost'; 79 | }; 80 | 81 | if (typeof Object.assign != 'function') { 82 | (function () { 83 | Object.assign = function (target) { 84 | 'use strict'; 85 | if (target === undefined || target === null) { 86 | throw new TypeError('Cannot convert undefined or null to object'); 87 | } 88 | 89 | var output = Object(target); 90 | for (var index = 1; index < arguments.length; index++) { 91 | var source = arguments[index]; 92 | if (source !== undefined && source !== null) { 93 | for (var nextKey in source) { 94 | if (source.hasOwnProperty(nextKey)) { 95 | output[nextKey] = source[nextKey]; 96 | } 97 | } 98 | } 99 | } 100 | return output; 101 | }; 102 | })(); 103 | } 104 | 105 | module.exports = utils; 106 | 107 | /***/ }, 108 | /* 2 */ 109 | /***/ function(module, exports) { 110 | 111 | var subscribers = []; 112 | var middlewares; 113 | 114 | function Store(mid) { 115 | middlewares = mid || []; 116 | 117 | this.prevState = {}; 118 | this.state = {}; 119 | 120 | this.state = this.reduce(this.state, {}); 121 | 122 | if (middlewares.length > 0) { 123 | this.dispatch = this._combineMiddlewares(); 124 | } 125 | } 126 | 127 | Store.prototype.getState = function() { 128 | return this.state; 129 | }; 130 | 131 | Store.prototype.getPrevState = function() { 132 | return this.prevState; 133 | }; 134 | 135 | Store.prototype.dispatch = function(action) { 136 | this.prevState = this.state; 137 | this.state = this.reduce(this.state, action); 138 | 139 | this.notifySubscribers(); 140 | 141 | return action; 142 | }; 143 | 144 | Store.prototype._combineMiddlewares = function() { 145 | var self = this; 146 | var dispatch = this.dispatch; 147 | 148 | var middlewareAPI = { 149 | getState: this.getState.bind(this), 150 | dispatch: function(action) { 151 | return dispatch.call(self, action); 152 | } 153 | }; 154 | 155 | // Inject store "proxy" into all middleware 156 | var chain = middlewares.map(function(middleware) { 157 | return middleware(middlewareAPI); 158 | }); 159 | 160 | // Init reduceRight with middlewareAPI.dispatch as initial value 161 | dispatch = chain.reduceRight(function(composed, fn) { 162 | return fn(composed); 163 | }, dispatch.bind(this)); 164 | 165 | return dispatch; 166 | }; 167 | 168 | Store.prototype.reduce = function(state, action) { 169 | return { 170 | grid: updateGrid(state.grid, action), 171 | turn: updateTurn(state.turn, action), 172 | score: updateScore(state.score, action), 173 | winnerSequence: updateWinnerSequence(state.winnerSequence, action), 174 | turnCounter: updateCounter(state.turnCounter, action), 175 | player: updatePlayer(state.player, action) 176 | }; 177 | }; 178 | 179 | Store.prototype.subscribe = function(fn) { 180 | subscribers.push(fn); 181 | }; 182 | 183 | Store.prototype.notifySubscribers = function() { 184 | subscribers.forEach(function(subscriber) { 185 | subscriber(this.prevState, this.state); 186 | }.bind(this)); 187 | }; 188 | 189 | function updateGrid(grid, action) { 190 | grid = grid || ['', '', '', '', '', '', '', '', '']; 191 | 192 | switch (action.type) { 193 | case 'SET_X': 194 | case 'SET_O': 195 | case 'RESTART_GAME': 196 | return grid.map(function(c, i) { 197 | var output = c; 198 | 199 | if (action.index === i || action.type === 'RESTART_GAME') { 200 | output = updateCell(c, action); 201 | } 202 | 203 | return output; 204 | }); 205 | default: 206 | return grid; 207 | } 208 | } 209 | 210 | function updateCell(cell, action) { 211 | switch (action.type) { 212 | case 'SET_X': 213 | return 'x'; 214 | case 'SET_O': 215 | return 'o'; 216 | case 'RESTART_GAME': 217 | return ''; 218 | default: 219 | return cell; 220 | } 221 | } 222 | 223 | function updateTurn(turn, action) { 224 | switch (action.type) { 225 | case 'SET_X': 226 | return 'o'; 227 | case 'SET_O': 228 | return 'x'; 229 | case 'SHOW_WINNER': 230 | return action.winner; 231 | case 'RESTART_GAME': 232 | return 'x'; 233 | default: 234 | return turn || 'x'; 235 | } 236 | } 237 | 238 | function updateScore(score, action) { 239 | switch (action.type) { 240 | case 'SHOW_WINNER': 241 | var newScore = {}; 242 | newScore[action.winner] = score[action.winner] + 1; 243 | return Object.assign({}, score, newScore); 244 | default: 245 | return score || { x: 0, o: 0 }; 246 | } 247 | } 248 | 249 | function updateWinnerSequence(winnerSequence, action) { 250 | switch (action.type) { 251 | case 'SHOW_WINNER': 252 | return action.sequence.slice(); 253 | case 'RESTART_GAME': 254 | return []; 255 | default: 256 | return winnerSequence || []; 257 | } 258 | } 259 | 260 | function updateCounter(turnCounter, action) { 261 | switch (action.type) { 262 | case 'SET_X': 263 | return turnCounter + 1; 264 | case 'SET_O': 265 | return turnCounter + 1; 266 | case 'RESTART_GAME': 267 | return 0; 268 | default: 269 | return turnCounter || 0; 270 | } 271 | } 272 | 273 | function updatePlayer(player, action) { 274 | switch (action.type) { 275 | case 'PICK_SIDE': 276 | return action.side; 277 | default: 278 | return player || ''; 279 | } 280 | } 281 | 282 | module.exports = Store; 283 | 284 | 285 | /***/ }, 286 | /* 3 */ 287 | /***/ function(module, exports) { 288 | 289 | function Winner(grid, lastTurn) { 290 | this.dimensions = [this.getRows(), this.getColumns(), this.getDiagonals()]; 291 | } 292 | 293 | Winner.prototype.check = function(grid, lastTurn) { 294 | return this.hasWinner(grid, lastTurn); 295 | }; 296 | 297 | Winner.prototype.getRows = function() { 298 | return [0, 1, 2, 3, 4, 5, 6, 7, 8]; 299 | }; 300 | 301 | Winner.prototype.getColumns = function() { 302 | return [0, 3, 6, 1, 4, 7, 2, 5, 8]; 303 | }; 304 | 305 | Winner.prototype.getDiagonals = function() { 306 | return [0, 4, 8, 2, 4, 6]; 307 | }; 308 | 309 | Winner.prototype.getSequence = function(index) { 310 | var sequences = [ 311 | [0, 1, 2], 312 | [3, 4, 5], 313 | [6, 7, 8], 314 | [0, 3, 6], 315 | [1, 4, 7], 316 | [2, 5, 8], 317 | [0, 4, 8], 318 | [2, 4, 6] 319 | ]; 320 | 321 | return sequences[index]; 322 | }; 323 | 324 | Winner.prototype.hasWinner = function(grid, lastTurn) { 325 | var dIndex = 0; 326 | var sequence = 0; 327 | var counter; 328 | var index; 329 | var i; 330 | 331 | while (this.dimensions[dIndex]) { 332 | counter = { x: 0, o: 0 }; 333 | 334 | for (i = 0; i < this.dimensions[dIndex].length; i++) { 335 | index = this.dimensions[dIndex][i]; 336 | 337 | // Increment counter 338 | counter[grid[index]]++; 339 | 340 | // Break loop if there's a winner 341 | if (counter[lastTurn] === 3) { 342 | return this.getSequence(sequence); 343 | } 344 | 345 | // Reset counter each three indexes 346 | if ((i + 1) % 3 === 0) { 347 | counter = { x: 0, o: 0 }; 348 | sequence++; 349 | } 350 | } 351 | 352 | dIndex++; 353 | } 354 | 355 | return []; 356 | }; 357 | 358 | 359 | module.exports = new Winner(); 360 | 361 | 362 | /***/ }, 363 | /* 4 */ 364 | /***/ function(module, exports, __webpack_require__) { 365 | 366 | var utils = __webpack_require__(1); 367 | 368 | function ScoreView(players) { 369 | this.$playerTurn = utils.qsa('.js-player-turn', players); 370 | this.$playerScore = utils.qsa('.js-player-score', players); 371 | } 372 | 373 | ScoreView.prototype.render = function(what, data) { 374 | this[what](data); 375 | }; 376 | 377 | 378 | ScoreView.prototype.score = function(score) { 379 | this.$playerScore[0].innerHTML = score.x; 380 | this.$playerScore[1].innerHTML = score.o; 381 | }; 382 | 383 | ScoreView.prototype.turn = function(turn) { 384 | if (turn === 'o') { 385 | this.$playerTurn[0].classList.remove('is-selected'); 386 | this.$playerTurn[1].classList.add('is-selected'); 387 | } else { 388 | this.$playerTurn[1].classList.remove('is-selected'); 389 | this.$playerTurn[0].classList.add('is-selected'); 390 | } 391 | }; 392 | 393 | module.exports = function(players) { 394 | return new ScoreView(players); 395 | }; 396 | 397 | /***/ }, 398 | /* 5 */ 399 | /***/ function(module, exports, __webpack_require__) { 400 | 401 | var utils = __webpack_require__(1); 402 | 403 | function GridView(table) { 404 | this.$tableCell = utils.qsa('.js-cell', table); 405 | } 406 | 407 | GridView.prototype.render = function(what, data) { 408 | this[what](data); 409 | }; 410 | 411 | GridView.prototype.grid = function(grid) { 412 | var self = this; 413 | var selected = 'is-filled'; 414 | 415 | grid.forEach(function(cell, index) { 416 | var output = ''; 417 | var $cell = self.$tableCell[index]; 418 | 419 | $cell.classList.remove(selected); 420 | 421 | if (cell.length > 0) { 422 | output = '
'; 423 | $cell.classList.add(selected); 424 | } 425 | 426 | $cell.innerHTML = output; 427 | }); 428 | }; 429 | 430 | GridView.prototype.winner = function(seq) { 431 | var self = this; 432 | var div; 433 | 434 | seq.forEach(function(ind) { 435 | div = utils.qs('div', self.$tableCell[ind]); 436 | div.classList.add('is-winner-cell'); 437 | }); 438 | }; 439 | 440 | module.exports = function(table) { 441 | return new GridView(table); 442 | }; 443 | 444 | 445 | /***/ }, 446 | /* 6 */ 447 | /***/ function(module, exports) { 448 | 449 | function FaviconView(head) { 450 | this.$head = head; 451 | } 452 | 453 | FaviconView.prototype.render = function(turn) { 454 | var link = document.createElement('link'); 455 | var oldLink = document.getElementById('favicon'); 456 | var src = (turn === 'x') ? 'favicon.ico' : 'favicon-o.ico'; 457 | 458 | link.id = 'favicon'; 459 | link.rel = 'shortcut icon'; 460 | link.href = src; 461 | 462 | if (oldLink) { 463 | this.$head.removeChild(oldLink); 464 | } 465 | 466 | this.$head.appendChild(link); 467 | }; 468 | 469 | module.exports = function(head) { 470 | return new FaviconView(head); 471 | }; 472 | 473 | 474 | /***/ }, 475 | /* 7 */ 476 | /***/ function(module, exports, __webpack_require__) { 477 | 478 | // Application 479 | // -------------- 480 | var utils = __webpack_require__(1); 481 | var actions = __webpack_require__(8); 482 | var scoreView = __webpack_require__(4); 483 | var gridView = __webpack_require__(5); 484 | var fiveiconView = __webpack_require__(6); 485 | var defineWinner = __webpack_require__(9); 486 | var logger = __webpack_require__(10); 487 | var Store = __webpack_require__(2); 488 | var store = new Store([defineWinner, logger]); 489 | var socket = io(); 490 | 491 | // Game 492 | // ---------------- 493 | function TicTacToe(config) { 494 | this.$head = document.head || utils.qs('head'); 495 | this.$table = utils.qs(config.gridElement); 496 | this.$players = utils.qs(config.playersElement); 497 | 498 | this.room = config.room; 499 | 500 | this.scoreView = scoreView(this.$players); 501 | this.gridView = gridView(this.$table); 502 | this.fiveiconView = fiveiconView(this.$head); 503 | 504 | this.eventListeners(); 505 | } 506 | 507 | TicTacToe.prototype.eventListeners = function() { 508 | this.$table.addEventListener('click', this.onCellClick.bind(this)); 509 | 510 | store.subscribe(this.render.bind(this)); 511 | 512 | socket.on('connect', this.onSocketConnect.bind(this)); 513 | socket.on('dispatch', this.onSocketDispatch.bind(this)); 514 | }; 515 | 516 | TicTacToe.prototype.onSocketConnect = function(data) { 517 | socket.emit('room', this.room); 518 | }; 519 | 520 | TicTacToe.prototype.onSocketDispatch = function(data) { 521 | store.dispatch(data); 522 | }; 523 | 524 | TicTacToe.prototype.onCellClick = function(event) { 525 | var target = event.target; 526 | var classes = target.classList; 527 | var index = target.getAttribute('data-index'); 528 | var state = store.getState(); 529 | 530 | if (!classes.contains('js-cell') || classes.contains('is-filled') || 531 | this.shouldPreventClick(state)) { 532 | return; 533 | } 534 | 535 | this.updateCell(state, index); 536 | }; 537 | 538 | TicTacToe.prototype.shouldPreventClick = function(state) { 539 | var isNotMyTurn = (state.player.length && state.turn !== state.player); 540 | var isGameFinished = (state.winnerSequence.length > 0); 541 | 542 | return isNotMyTurn || isGameFinished; 543 | }; 544 | 545 | TicTacToe.prototype.updateCell = function(state, index) { 546 | var action = actions.setCell(state.turn, index, this.room); 547 | 548 | if (!state.player.length) { 549 | store.dispatch(actions.pickSide(state.turn)); 550 | } 551 | 552 | store.dispatch(action); 553 | socket.emit('dispatch', action); 554 | }; 555 | 556 | TicTacToe.prototype.render = function(prevState, state) { 557 | if (prevState.grid !== state.grid) { 558 | this.gridView.render('grid', state.grid); 559 | } 560 | 561 | if (prevState.turn !== state.turn) { 562 | this.scoreView.render('turn', state.turn); 563 | this.fiveiconView.render(state.turn); 564 | } 565 | 566 | if (prevState.score !== state.score) { 567 | this.scoreView.render('score', state.score); 568 | } 569 | 570 | if (prevState.winnerSequence !== state.winnerSequence) { 571 | this.gridView.render('winner', state.winnerSequence); 572 | } 573 | 574 | if (!prevState.winnerSequence.length && state.turnCounter === 9) { 575 | this.restartGame(); 576 | } 577 | }; 578 | 579 | TicTacToe.prototype.restartGame = function() { 580 | utils.wait(1500, function() { 581 | store.dispatch(actions.restart()); 582 | }); 583 | }; 584 | 585 | module.exports = function(config) { 586 | return new TicTacToe(config); 587 | }; 588 | 589 | 590 | /***/ }, 591 | /* 8 */ 592 | /***/ function(module, exports) { 593 | 594 | exports.pickSide = function(turn) { 595 | return { 596 | type: 'PICK_SIDE', 597 | side: turn 598 | }; 599 | }; 600 | 601 | exports.setCell = function(turn, index, room) { 602 | return { 603 | type: turn === 'x' ? 'SET_X' : 'SET_O', 604 | index: parseInt(index, 10), 605 | room: room 606 | }; 607 | }; 608 | 609 | exports.showWinner = function(lastTurn, winnerSeq) { 610 | return { 611 | type: 'SHOW_WINNER', 612 | winner: lastTurn, 613 | sequence: winnerSeq 614 | }; 615 | }; 616 | 617 | exports.restart = function() { 618 | return { 619 | type: 'RESTART_GAME' 620 | }; 621 | }; 622 | 623 | /***/ }, 624 | /* 9 */ 625 | /***/ function(module, exports, __webpack_require__) { 626 | 627 | var utils = __webpack_require__(1); 628 | var winnerService = __webpack_require__(3); 629 | var actions = __webpack_require__(8); 630 | 631 | module.exports = function defineWinner(store) { 632 | return function(next) { 633 | return function(action) { 634 | var winnerSeq; 635 | var prevState = store.getState(); 636 | var lastTurn = prevState.turn; 637 | 638 | // Dispatch action 639 | var result = next(action); 640 | 641 | // Get new state 642 | var state = store.getState(); 643 | 644 | // Check winner 645 | if (action.type !== 'SHOW_WINNER' && action.type !== 'RESTART_GAME') { 646 | winnerSeq = winnerService.check(state.grid, lastTurn); 647 | 648 | if (winnerSeq.length > 0) { 649 | store.dispatch(actions.showWinner(lastTurn, winnerSeq)); 650 | 651 | utils.wait(1500, function() { 652 | store.dispatch(actions.restart()); 653 | }); 654 | } 655 | } 656 | 657 | return result; 658 | }; 659 | }; 660 | }; 661 | 662 | /***/ }, 663 | /* 10 */ 664 | /***/ function(module, exports, __webpack_require__) { 665 | 666 | var utils = __webpack_require__(1); 667 | 668 | module.exports = function logger(store) { 669 | return function(next) { 670 | return function(action) { 671 | if (!utils.isDevMode()) { 672 | return next(action); 673 | } 674 | 675 | console.groupCollapsed(action.type); 676 | console.group('action:'); 677 | console.log(JSON.stringify(action, '', '\t')); 678 | console.groupEnd(); 679 | console.groupCollapsed('previous state:'); 680 | console.log(JSON.stringify(store.getState(), '', '\t')); 681 | console.groupEnd(); 682 | var result = next(action); 683 | console.groupCollapsed('state:'); 684 | console.log(JSON.stringify(store.getState(), '', '\t')); 685 | console.groupEnd(); 686 | console.groupEnd(); 687 | return result; 688 | }; 689 | }; 690 | }; 691 | 692 | /***/ }, 693 | /* 11 */ 694 | /***/ function(module, exports, __webpack_require__) { 695 | 696 | document.addEventListener('DOMContentLoaded', function() { 697 | var hash = window.location.hash; 698 | 699 | // Generate room id 700 | // --------------- 701 | if (!hash || hash.length < 2) { 702 | window.location.href = window.location.href + '#' + 703 | (((1+Math.random())*0x10000)|0).toString(16).substring(1); 704 | } 705 | 706 | // Define variables 707 | // --------------- 708 | var game = __webpack_require__(7); 709 | var utils = __webpack_require__(1); 710 | var room = window.location.hash; 711 | var refreshForm = utils.qs('#refresh-game-form'); 712 | var roomField = utils.qs('#room-id'); 713 | var popOver = utils.qs('#pop-over'); 714 | var storage = window.localStorage; 715 | 716 | // Init game 717 | // --------------- 718 | game({ 719 | gridElement: '.js-table', 720 | playersElement: '.js-players-display', 721 | room: room.replace('#', '') 722 | }); 723 | 724 | // Display room id 725 | // ---------------- 726 | roomField.value = room.replace('#', ''); 727 | 728 | // Force refresh 729 | // --------------- 730 | refreshForm.addEventListener('submit', function(event) { 731 | event.preventDefault(); 732 | window.location.hash = roomField.value; 733 | document.location.reload(false); 734 | }, false); 735 | 736 | // Pop-over logic 737 | // --------------- 738 | if (!storage.getItem('ttt-pop-over-shown')) { 739 | popOver.style.display = 'block'; 740 | 741 | popOver.addEventListener('click', function() { 742 | popOver.classList.add('hide'); 743 | utils.wait(300, function() { 744 | popOver.style.display = 'none'; 745 | storage.setItem('ttt-pop-over-shown', 1); 746 | }); 747 | }); 748 | } 749 | 750 | }, false); 751 | 752 | 753 | /***/ } 754 | /******/ ]); -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | exports.pickSide = function(turn) { 2 | return { 3 | type: 'PICK_SIDE', 4 | side: turn 5 | }; 6 | }; 7 | 8 | exports.setCell = function(turn, index, room) { 9 | return { 10 | type: turn === 'x' ? 'SET_X' : 'SET_O', 11 | index: parseInt(index, 10), 12 | room: room 13 | }; 14 | }; 15 | 16 | exports.showWinner = function(lastTurn, winnerSeq) { 17 | return { 18 | type: 'SHOW_WINNER', 19 | winner: lastTurn, 20 | sequence: winnerSeq 21 | }; 22 | }; 23 | 24 | exports.restart = function() { 25 | return { 26 | type: 'RESTART_GAME' 27 | }; 28 | }; -------------------------------------------------------------------------------- /src/fiveicon-view.js: -------------------------------------------------------------------------------- 1 | function FaviconView(head) { 2 | this.$head = head; 3 | } 4 | 5 | FaviconView.prototype.render = function(turn) { 6 | var link = document.createElement('link'); 7 | var oldLink = document.getElementById('favicon'); 8 | var src = (turn === 'x') ? 'favicon.ico' : 'favicon-o.ico'; 9 | 10 | link.id = 'favicon'; 11 | link.rel = 'shortcut icon'; 12 | link.href = src; 13 | 14 | if (oldLink) { 15 | this.$head.removeChild(oldLink); 16 | } 17 | 18 | this.$head.appendChild(link); 19 | }; 20 | 21 | module.exports = function(head) { 22 | return new FaviconView(head); 23 | }; 24 | -------------------------------------------------------------------------------- /src/game.js: -------------------------------------------------------------------------------- 1 | // Application 2 | // -------------- 3 | var utils = require('./utils'); 4 | var actions = require('./actions'); 5 | var scoreView = require('./score-view'); 6 | var gridView = require('./grid-view'); 7 | var fiveiconView = require('./fiveicon-view'); 8 | var defineWinner = require('./middlewares/define-winner'); 9 | var logger = require('./middlewares/logger'); 10 | var Store = require('./store'); 11 | var store = new Store([defineWinner, logger]); 12 | var socket = io(); 13 | 14 | // Game 15 | // ---------------- 16 | function TicTacToe(config) { 17 | this.$head = document.head || utils.qs('head'); 18 | this.$table = utils.qs(config.gridElement); 19 | this.$players = utils.qs(config.playersElement); 20 | 21 | this.room = config.room; 22 | 23 | this.scoreView = scoreView(this.$players); 24 | this.gridView = gridView(this.$table); 25 | this.fiveiconView = fiveiconView(this.$head); 26 | 27 | this.eventListeners(); 28 | } 29 | 30 | TicTacToe.prototype.eventListeners = function() { 31 | this.$table.addEventListener('click', this.onCellClick.bind(this)); 32 | 33 | store.subscribe(this.render.bind(this)); 34 | 35 | socket.on('connect', this.onSocketConnect.bind(this)); 36 | socket.on('dispatch', this.onSocketDispatch.bind(this)); 37 | }; 38 | 39 | TicTacToe.prototype.onSocketConnect = function(data) { 40 | socket.emit('room', this.room); 41 | }; 42 | 43 | TicTacToe.prototype.onSocketDispatch = function(data) { 44 | store.dispatch(data); 45 | }; 46 | 47 | TicTacToe.prototype.onCellClick = function(event) { 48 | var target = event.target; 49 | var classes = target.classList; 50 | var index = target.getAttribute('data-index'); 51 | var state = store.getState(); 52 | 53 | if (!classes.contains('js-cell') || classes.contains('is-filled') || 54 | this.shouldPreventClick(state)) { 55 | return; 56 | } 57 | 58 | this.updateCell(state, index); 59 | }; 60 | 61 | TicTacToe.prototype.shouldPreventClick = function(state) { 62 | var isNotMyTurn = (state.player.length && state.turn !== state.player); 63 | var isGameFinished = (state.winnerSequence.length > 0); 64 | 65 | return isNotMyTurn || isGameFinished; 66 | }; 67 | 68 | TicTacToe.prototype.updateCell = function(state, index) { 69 | var action = actions.setCell(state.turn, index, this.room); 70 | 71 | if (!state.player.length) { 72 | store.dispatch(actions.pickSide(state.turn)); 73 | } 74 | 75 | store.dispatch(action); 76 | socket.emit('dispatch', action); 77 | }; 78 | 79 | TicTacToe.prototype.render = function(prevState, state) { 80 | if (prevState.grid !== state.grid) { 81 | this.gridView.render('grid', state.grid); 82 | } 83 | 84 | if (prevState.turn !== state.turn) { 85 | this.scoreView.render('turn', state.turn); 86 | this.fiveiconView.render(state.turn); 87 | } 88 | 89 | if (prevState.score !== state.score) { 90 | this.scoreView.render('score', state.score); 91 | } 92 | 93 | if (prevState.winnerSequence !== state.winnerSequence) { 94 | this.gridView.render('winner', state.winnerSequence); 95 | } 96 | 97 | if (!prevState.winnerSequence.length && state.turnCounter === 9) { 98 | this.restartGame(); 99 | } 100 | }; 101 | 102 | TicTacToe.prototype.restartGame = function() { 103 | utils.wait(1500, function() { 104 | store.dispatch(actions.restart()); 105 | }); 106 | }; 107 | 108 | module.exports = function(config) { 109 | return new TicTacToe(config); 110 | }; 111 | -------------------------------------------------------------------------------- /src/grid-view.js: -------------------------------------------------------------------------------- 1 | var utils = require('./utils'); 2 | 3 | function GridView(table) { 4 | this.$tableCell = utils.qsa('.js-cell', table); 5 | } 6 | 7 | GridView.prototype.render = function(what, data) { 8 | this[what](data); 9 | }; 10 | 11 | GridView.prototype.grid = function(grid) { 12 | var self = this; 13 | var selected = 'is-filled'; 14 | 15 | grid.forEach(function(cell, index) { 16 | var output = ''; 17 | var $cell = self.$tableCell[index]; 18 | 19 | $cell.classList.remove(selected); 20 | 21 | if (cell.length > 0) { 22 | output = '
'; 23 | $cell.classList.add(selected); 24 | } 25 | 26 | $cell.innerHTML = output; 27 | }); 28 | }; 29 | 30 | GridView.prototype.winner = function(seq) { 31 | var self = this; 32 | var div; 33 | 34 | seq.forEach(function(ind) { 35 | div = utils.qs('div', self.$tableCell[ind]); 36 | div.classList.add('is-winner-cell'); 37 | }); 38 | }; 39 | 40 | module.exports = function(table) { 41 | return new GridView(table); 42 | }; 43 | -------------------------------------------------------------------------------- /src/initializer.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', function() { 2 | var hash = window.location.hash; 3 | 4 | // Generate room id 5 | // --------------- 6 | if (!hash || hash.length < 2) { 7 | window.location.href = window.location.href + '#' + 8 | (((1+Math.random())*0x10000)|0).toString(16).substring(1); 9 | } 10 | 11 | // Define variables 12 | // --------------- 13 | var game = require('./game'); 14 | var utils = require('./utils'); 15 | var room = window.location.hash; 16 | var refreshForm = utils.qs('#refresh-game-form'); 17 | var roomField = utils.qs('#room-id'); 18 | var popOver = utils.qs('#pop-over'); 19 | var storage = window.localStorage; 20 | 21 | // Init game 22 | // --------------- 23 | game({ 24 | gridElement: '.js-table', 25 | playersElement: '.js-players-display', 26 | room: room.replace('#', '') 27 | }); 28 | 29 | // Display room id 30 | // ---------------- 31 | roomField.value = room.replace('#', ''); 32 | 33 | // Force refresh 34 | // --------------- 35 | refreshForm.addEventListener('submit', function(event) { 36 | event.preventDefault(); 37 | window.location.hash = roomField.value; 38 | document.location.reload(false); 39 | }, false); 40 | 41 | // Pop-over logic 42 | // --------------- 43 | if (!storage.getItem('ttt-pop-over-shown')) { 44 | popOver.style.display = 'block'; 45 | 46 | popOver.addEventListener('click', function() { 47 | popOver.classList.add('hide'); 48 | utils.wait(300, function() { 49 | popOver.style.display = 'none'; 50 | storage.setItem('ttt-pop-over-shown', 1); 51 | }); 52 | }); 53 | } 54 | 55 | }, false); 56 | -------------------------------------------------------------------------------- /src/middlewares/define-winner.js: -------------------------------------------------------------------------------- 1 | var utils = require('../utils'); 2 | var winnerService = require('../winner-service'); 3 | var actions = require('../actions'); 4 | 5 | module.exports = function defineWinner(store) { 6 | return function(next) { 7 | return function(action) { 8 | var winnerSeq; 9 | var prevState = store.getState(); 10 | var lastTurn = prevState.turn; 11 | 12 | // Dispatch action 13 | var result = next(action); 14 | 15 | // Get new state 16 | var state = store.getState(); 17 | 18 | // Check winner 19 | if (action.type !== 'SHOW_WINNER' && action.type !== 'RESTART_GAME') { 20 | winnerSeq = winnerService.check(state.grid, lastTurn); 21 | 22 | if (winnerSeq.length > 0) { 23 | store.dispatch(actions.showWinner(lastTurn, winnerSeq)); 24 | 25 | utils.wait(1500, function() { 26 | store.dispatch(actions.restart()); 27 | }); 28 | } 29 | } 30 | 31 | return result; 32 | }; 33 | }; 34 | }; -------------------------------------------------------------------------------- /src/middlewares/logger.js: -------------------------------------------------------------------------------- 1 | var utils = require('../utils'); 2 | 3 | module.exports = function logger(store) { 4 | return function(next) { 5 | return function(action) { 6 | if (!utils.isDevMode()) { 7 | return next(action); 8 | } 9 | 10 | console.groupCollapsed(action.type); 11 | console.group('action:'); 12 | console.log(JSON.stringify(action, '', '\t')); 13 | console.groupEnd(); 14 | console.groupCollapsed('previous state:'); 15 | console.log(JSON.stringify(store.getState(), '', '\t')); 16 | console.groupEnd(); 17 | var result = next(action); 18 | console.groupCollapsed('state:'); 19 | console.log(JSON.stringify(store.getState(), '', '\t')); 20 | console.groupEnd(); 21 | console.groupEnd(); 22 | return result; 23 | }; 24 | }; 25 | }; -------------------------------------------------------------------------------- /src/score-view.js: -------------------------------------------------------------------------------- 1 | var utils = require('./utils'); 2 | 3 | function ScoreView(players) { 4 | this.$playerTurn = utils.qsa('.js-player-turn', players); 5 | this.$playerScore = utils.qsa('.js-player-score', players); 6 | } 7 | 8 | ScoreView.prototype.render = function(what, data) { 9 | this[what](data); 10 | }; 11 | 12 | 13 | ScoreView.prototype.score = function(score) { 14 | this.$playerScore[0].innerHTML = score.x; 15 | this.$playerScore[1].innerHTML = score.o; 16 | }; 17 | 18 | ScoreView.prototype.turn = function(turn) { 19 | if (turn === 'o') { 20 | this.$playerTurn[0].classList.remove('is-selected'); 21 | this.$playerTurn[1].classList.add('is-selected'); 22 | } else { 23 | this.$playerTurn[1].classList.remove('is-selected'); 24 | this.$playerTurn[0].classList.add('is-selected'); 25 | } 26 | }; 27 | 28 | module.exports = function(players) { 29 | return new ScoreView(players); 30 | }; -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | var subscribers = []; 2 | var middlewares; 3 | 4 | function Store(mid) { 5 | middlewares = mid || []; 6 | 7 | this.prevState = {}; 8 | this.state = {}; 9 | 10 | this.state = this.reduce(this.state, {}); 11 | 12 | if (middlewares.length > 0) { 13 | this.dispatch = this._combineMiddlewares(); 14 | } 15 | } 16 | 17 | Store.prototype.getState = function() { 18 | return this.state; 19 | }; 20 | 21 | Store.prototype.getPrevState = function() { 22 | return this.prevState; 23 | }; 24 | 25 | Store.prototype.dispatch = function(action) { 26 | this.prevState = this.state; 27 | this.state = this.reduce(this.state, action); 28 | 29 | this.notifySubscribers(); 30 | 31 | return action; 32 | }; 33 | 34 | Store.prototype._combineMiddlewares = function() { 35 | var self = this; 36 | var dispatch = this.dispatch; 37 | 38 | var middlewareAPI = { 39 | getState: this.getState.bind(this), 40 | dispatch: function(action) { 41 | return dispatch.call(self, action); 42 | } 43 | }; 44 | 45 | // Inject store "proxy" into all middleware 46 | var chain = middlewares.map(function(middleware) { 47 | return middleware(middlewareAPI); 48 | }); 49 | 50 | // Init reduceRight with middlewareAPI.dispatch as initial value 51 | dispatch = chain.reduceRight(function(composed, fn) { 52 | return fn(composed); 53 | }, dispatch.bind(this)); 54 | 55 | return dispatch; 56 | }; 57 | 58 | Store.prototype.reduce = function(state, action) { 59 | return { 60 | grid: updateGrid(state.grid, action), 61 | turn: updateTurn(state.turn, action), 62 | score: updateScore(state.score, action), 63 | winnerSequence: updateWinnerSequence(state.winnerSequence, action), 64 | turnCounter: updateCounter(state.turnCounter, action), 65 | player: updatePlayer(state.player, action) 66 | }; 67 | }; 68 | 69 | Store.prototype.subscribe = function(fn) { 70 | subscribers.push(fn); 71 | }; 72 | 73 | Store.prototype.notifySubscribers = function() { 74 | subscribers.forEach(function(subscriber) { 75 | subscriber(this.prevState, this.state); 76 | }.bind(this)); 77 | }; 78 | 79 | function updateGrid(grid, action) { 80 | grid = grid || ['', '', '', '', '', '', '', '', '']; 81 | 82 | switch (action.type) { 83 | case 'SET_X': 84 | case 'SET_O': 85 | case 'RESTART_GAME': 86 | return grid.map(function(c, i) { 87 | var output = c; 88 | 89 | if (action.index === i || action.type === 'RESTART_GAME') { 90 | output = updateCell(c, action); 91 | } 92 | 93 | return output; 94 | }); 95 | default: 96 | return grid; 97 | } 98 | } 99 | 100 | function updateCell(cell, action) { 101 | switch (action.type) { 102 | case 'SET_X': 103 | return 'x'; 104 | case 'SET_O': 105 | return 'o'; 106 | case 'RESTART_GAME': 107 | return ''; 108 | default: 109 | return cell; 110 | } 111 | } 112 | 113 | function updateTurn(turn, action) { 114 | switch (action.type) { 115 | case 'SET_X': 116 | return 'o'; 117 | case 'SET_O': 118 | return 'x'; 119 | case 'SHOW_WINNER': 120 | return action.winner; 121 | case 'RESTART_GAME': 122 | return 'x'; 123 | default: 124 | return turn || 'x'; 125 | } 126 | } 127 | 128 | function updateScore(score, action) { 129 | switch (action.type) { 130 | case 'SHOW_WINNER': 131 | var newScore = {}; 132 | newScore[action.winner] = score[action.winner] + 1; 133 | return Object.assign({}, score, newScore); 134 | default: 135 | return score || { x: 0, o: 0 }; 136 | } 137 | } 138 | 139 | function updateWinnerSequence(winnerSequence, action) { 140 | switch (action.type) { 141 | case 'SHOW_WINNER': 142 | return action.sequence.slice(); 143 | case 'RESTART_GAME': 144 | return []; 145 | default: 146 | return winnerSequence || []; 147 | } 148 | } 149 | 150 | function updateCounter(turnCounter, action) { 151 | switch (action.type) { 152 | case 'SET_X': 153 | return turnCounter + 1; 154 | case 'SET_O': 155 | return turnCounter + 1; 156 | case 'RESTART_GAME': 157 | return 0; 158 | default: 159 | return turnCounter || 0; 160 | } 161 | } 162 | 163 | function updatePlayer(player, action) { 164 | switch (action.type) { 165 | case 'PICK_SIDE': 166 | return action.side; 167 | default: 168 | return player || ''; 169 | } 170 | } 171 | 172 | module.exports = Store; 173 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | var utils = {}; 2 | 3 | utils.qs = function(selector, context) { 4 | context = context || document; 5 | return context.querySelector(selector); 6 | }; 7 | 8 | utils.qsa = function(selector, context) { 9 | context = context || document; 10 | return context.querySelectorAll(selector); 11 | }; 12 | 13 | utils.wait = function(ms, cb) { 14 | return window.setTimeout(cb, (ms || 500)); 15 | }; 16 | 17 | utils.isDevMode = function() { 18 | return window && window.location.hostname === 'localhost'; 19 | }; 20 | 21 | if (typeof Object.assign != 'function') { 22 | (function () { 23 | Object.assign = function (target) { 24 | 'use strict'; 25 | if (target === undefined || target === null) { 26 | throw new TypeError('Cannot convert undefined or null to object'); 27 | } 28 | 29 | var output = Object(target); 30 | for (var index = 1; index < arguments.length; index++) { 31 | var source = arguments[index]; 32 | if (source !== undefined && source !== null) { 33 | for (var nextKey in source) { 34 | if (source.hasOwnProperty(nextKey)) { 35 | output[nextKey] = source[nextKey]; 36 | } 37 | } 38 | } 39 | } 40 | return output; 41 | }; 42 | })(); 43 | } 44 | 45 | module.exports = utils; -------------------------------------------------------------------------------- /src/winner-service.js: -------------------------------------------------------------------------------- 1 | function Winner(grid, lastTurn) { 2 | this.dimensions = [this.getRows(), this.getColumns(), this.getDiagonals()]; 3 | } 4 | 5 | Winner.prototype.check = function(grid, lastTurn) { 6 | return this.hasWinner(grid, lastTurn); 7 | }; 8 | 9 | Winner.prototype.getRows = function() { 10 | return [0, 1, 2, 3, 4, 5, 6, 7, 8]; 11 | }; 12 | 13 | Winner.prototype.getColumns = function() { 14 | return [0, 3, 6, 1, 4, 7, 2, 5, 8]; 15 | }; 16 | 17 | Winner.prototype.getDiagonals = function() { 18 | return [0, 4, 8, 2, 4, 6]; 19 | }; 20 | 21 | Winner.prototype.getSequence = function(index) { 22 | var sequences = [ 23 | [0, 1, 2], 24 | [3, 4, 5], 25 | [6, 7, 8], 26 | [0, 3, 6], 27 | [1, 4, 7], 28 | [2, 5, 8], 29 | [0, 4, 8], 30 | [2, 4, 6] 31 | ]; 32 | 33 | return sequences[index]; 34 | }; 35 | 36 | Winner.prototype.hasWinner = function(grid, lastTurn) { 37 | var dIndex = 0; 38 | var sequence = 0; 39 | var counter; 40 | var index; 41 | var i; 42 | 43 | while (this.dimensions[dIndex]) { 44 | counter = { x: 0, o: 0 }; 45 | 46 | for (i = 0; i < this.dimensions[dIndex].length; i++) { 47 | index = this.dimensions[dIndex][i]; 48 | 49 | // Increment counter 50 | counter[grid[index]]++; 51 | 52 | // Break loop if there's a winner 53 | if (counter[lastTurn] === 3) { 54 | return this.getSequence(sequence); 55 | } 56 | 57 | // Reset counter each three indexes 58 | if ((i + 1) % 3 === 0) { 59 | counter = { x: 0, o: 0 }; 60 | sequence++; 61 | } 62 | } 63 | 64 | dIndex++; 65 | } 66 | 67 | return []; 68 | }; 69 | 70 | 71 | module.exports = new Winner(); 72 | --------------------------------------------------------------------------------