dao-game / commit
Merge branch 'multiplayer-scratch' into 'main'
.gitignore | 2 +- .gitlab-ci.yml | 6 +- .prettierrc | 8 + .vscode/launch.json | 20 +- .vscode/settings.json | 3 + package-lock.json | 109 ++++++++- package.json | 3 +- public/cursor.png | Bin 0 -> 504 bytes public/index.html | 142 ++++-------- public/local.html | 70 ++++++ public/remote.html | 72 ++++++ public/share.png | Bin 0 -> 322 bytes public/styles.css | 490 +++++++++++++++++++++------------------ readme.md | 15 +- src/game-board.ts | 67 ++++++ src/game.ts | 475 +++++++++++++------------------------ src/listeners.ts | 24 ++ src/local-game.ts | 264 +++++++++++++++++++++ src/local.ts | 3 + src/modal.ts | 59 +++++ src/online.ts | 3 + src/remote-game.ts | 212 +++++++++++++++++ src/storage.ts | 27 +++ src/types.ts | 141 ++++------- src/utils.ts | 32 +++ tools/esbuild-build-and-serve.ts | 68 +++--- tools/esbuild-settings.ts | 13 ++ tools/esbuild-watch.ts | 8 +- tools/esbuild.ts | 7 +- 29 files changed, 1561 insertions(+), 782 deletions(-) diff --git a/.gitignore b/.gitignore index 9b60cca..6760a00 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ /.git/* # artifacts -/public/bundle.js \ No newline at end of file +/public/js diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 590999b..4e85ef4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -4,8 +4,10 @@ pages: script: - npm install - npm run build + - mv public .public + - mkdir -p public + - mkdir -p public/$CI_COMMIT_BRANCH + - cp .public/* public/$CI_COMMIT_BRANCH/ -R artifacts: paths: - public - only: - - main diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8350497 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 140 +} diff --git a/.vscode/launch.json b/.vscode/launch.json index b9fd332..98ef16e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,10 +7,26 @@ { "type": "pwa-chrome", "request": "launch", - "name": "Launch Chrome against localhost", + "name": "Debug Landing Page", "url": "http://localhost:3000/", "webRoot": "${workspaceFolder}/src", - "preLaunchTask": "task: npm: start" + // "preLaunchTask": "task: npm: start" + }, + { + "type": "pwa-chrome", + "request": "launch", + "name": "Debug Local Game", + "url": "http://localhost:3000/local.html", + "webRoot": "${workspaceFolder}/src", + // "preLaunchTask": "task: npm: start" + }, + { + "type": "pwa-chrome", + "request": "launch", + "name": "Debug Remote Game", + "url": "http://localhost:3000/remote.html", + "webRoot": "${workspaceFolder}/src", + // "preLaunchTask": "task: npm: start" } ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 1e2d4fa..48c5f7c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,10 @@ { "cSpell.words": [ "esbuild", + "Newp", + "outdir", "outfile", + "peerjs", "servedir", "Todos" ] diff --git a/package-lock.json b/package-lock.json index bb7d6c6..59eac04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "esbuild": "^0.14.25" + "esbuild": "^0.14.25", + "peerjs": "^1.3.2" }, "devDependencies": { "@types/node": "^17.0.21", @@ -467,6 +468,11 @@ "node": ">=12" } }, + "node_modules/eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -545,6 +551,27 @@ "node": ">=0.10.0" } }, + "node_modules/peerjs": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/peerjs/-/peerjs-1.3.2.tgz", + "integrity": "sha512-+PHfmsC7QGUU8Ye3OLi6tKQZGPCNy7QatUVNw4JtE8alkguF3+DdO5W0bzepqP2OtE9FqH/ltXt37qyvHw2CqA==", + "dependencies": { + "@types/node": "^10.14.33", + "eventemitter3": "^3.1.2", + "peerjs-js-binarypack": "1.0.1", + "webrtc-adapter": "^7.7.1" + } + }, + "node_modules/peerjs-js-binarypack": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/peerjs-js-binarypack/-/peerjs-js-binarypack-1.0.1.tgz", + "integrity": "sha512-N6aeia3NhdpV7kiGxJV5xQiZZCVEEVjRz2T2C6UZQiBkHWHzUv/oWA4myQLcwBwO8LUoR1KWW5oStvwVesmfCg==" + }, + "node_modules/peerjs/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -560,6 +587,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rtcpeerconnection-shim": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rtcpeerconnection-shim/-/rtcpeerconnection-shim-1.2.15.tgz", + "integrity": "sha512-C6DxhXt7bssQ1nHb154lqeL0SXz5Dx4RczXZu2Aa/L1NJFnEVDxFwCBo3fqtuljhHIGceg5JKBV4XJ0gW5JKyw==", + "dependencies": { + "sdp": "^2.6.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, + "node_modules/sdp": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz", + "integrity": "sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw==" + }, "node_modules/ts-node": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.6.0.tgz", @@ -621,6 +665,19 @@ "integrity": "sha512-mpSYqfsFvASnSn5qMiwrr4VKfumbPyONLCOPmsR3A6pTY/r0+tSaVbgPWSAIuzbk3lCTa+FForeTiO+wBQGkjA==", "dev": true }, + "node_modules/webrtc-adapter": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-7.7.1.tgz", + "integrity": "sha512-TbrbBmiQBL9n0/5bvDdORc6ZfRY/Z7JnEj+EYOD1ghseZdpJ+nF2yx14k3LgQKc7JZnG7HAcL+zHnY25So9d7A==", + "dependencies": { + "rtcpeerconnection-shim": "^1.2.15", + "sdp": "^2.12.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -882,6 +939,11 @@ "integrity": "sha512-4ype9ERiI45rSh+R8qUoBtaj6kJvUOI7oVLhKqPEpcF4Pa5PpT3hm/mXAyotJHREkHpM87PAJcA442mLnbtlNA==", "optional": true }, + "eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -948,6 +1010,29 @@ "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true }, + "peerjs": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/peerjs/-/peerjs-1.3.2.tgz", + "integrity": "sha512-+PHfmsC7QGUU8Ye3OLi6tKQZGPCNy7QatUVNw4JtE8alkguF3+DdO5W0bzepqP2OtE9FqH/ltXt37qyvHw2CqA==", + "requires": { + "@types/node": "^10.14.33", + "eventemitter3": "^3.1.2", + "peerjs-js-binarypack": "1.0.1", + "webrtc-adapter": "^7.7.1" + }, + "dependencies": { + "@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==" + } + } + }, + "peerjs-js-binarypack": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/peerjs-js-binarypack/-/peerjs-js-binarypack-1.0.1.tgz", + "integrity": "sha512-N6aeia3NhdpV7kiGxJV5xQiZZCVEEVjRz2T2C6UZQiBkHWHzUv/oWA4myQLcwBwO8LUoR1KWW5oStvwVesmfCg==" + }, "rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -957,6 +1042,19 @@ "glob": "^7.1.3" } }, + "rtcpeerconnection-shim": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rtcpeerconnection-shim/-/rtcpeerconnection-shim-1.2.15.tgz", + "integrity": "sha512-C6DxhXt7bssQ1nHb154lqeL0SXz5Dx4RczXZu2Aa/L1NJFnEVDxFwCBo3fqtuljhHIGceg5JKBV4XJ0gW5JKyw==", + "requires": { + "sdp": "^2.6.0" + } + }, + "sdp": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz", + "integrity": "sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw==" + }, "ts-node": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.6.0.tgz", @@ -990,6 +1088,15 @@ "integrity": "sha512-mpSYqfsFvASnSn5qMiwrr4VKfumbPyONLCOPmsR3A6pTY/r0+tSaVbgPWSAIuzbk3lCTa+FForeTiO+wBQGkjA==", "dev": true }, + "webrtc-adapter": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-7.7.1.tgz", + "integrity": "sha512-TbrbBmiQBL9n0/5bvDdORc6ZfRY/Z7JnEj+EYOD1ghseZdpJ+nF2yx14k3LgQKc7JZnG7HAcL+zHnY25So9d7A==", + "requires": { + "rtcpeerconnection-shim": "^1.2.15", + "sdp": "^2.12.0" + } + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 7569d29..e9cc180 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ }, "homepage": "https://gitlab.com/fuzzy-rp/dao-game#readme", "dependencies": { - "esbuild": "^0.14.25" + "esbuild": "^0.14.25", + "peerjs": "^1.3.2" }, "devDependencies": { "@types/node": "^17.0.21", diff --git a/public/cursor.png b/public/cursor.png new file mode 100644 index 0000000..b34724b Binary files /dev/null and b/public/cursor.png differ diff --git a/public/index.html b/public/index.html index 033ea5a..defd512 100644 --- a/public/index.html +++ b/public/index.html @@ -1,95 +1,53 @@ <!DOCTYPE html> <html lang="en"> - <head> - <meta charset="utf-8"> - <title>Dao Game</title> - <meta name="viewport" - content="width=device-width, initial-scale=1"> - <link rel="icon" - href="yin-yang.svg" - type="image/svg+xml"> - <link rel="stylesheet" - href="styles.css" /> - <script defer - src="bundle.js"></script> - </head> - - <body> - - <div class="invisible"> - <p class='piece red-piece' - id='default-red-piece'></p> - <p class='piece blue-piece' - id='default-blue-piece'></p> - </div> - <main> - - <div class="controls"> - - <span class="red-turn-text"> - Red's turn! - </span> - - <span class="divider">|</span> - - <span class="blue-turn-text"> - Blue's turn - </span> - </div> - - <table id="table"> - </table> - - <div class="controls"> - <button type="button" - class="button" - id="undo-button"> - Undo - </button> - - <span class="divider">|</span> - <button type="button" - class="button" - id="reset-button"> - Reset - </button> - </div> - </main> - <hr /> - - <div class="title"> - <p> - Board game <a href='https://en.wikipedia.org/wiki/Dao_(game)' - target='_blank' - rel="noopener">Dao</a>. - </p> - </div> - - <div class="info"> - The game consists of only one simple rule and three ways - to - win. Pieces move in a straight line, including - diagonally, - as far as possible with no jumps. The three ways to win - are - arranging your pieces in a straight line on the board - (not - diagonally), occupying the four corners, or forming a - 2x2 - square anywhere on the board. - </div> - <hr /> - - <div class="info"> - <p> - Here is the - <a href='https://gitlab.com/fuzzy-rp/dao-game' - target="_blank" - rel="noopener">source - code</a> - </p> - </div> - </body> - -</html> +<head> + <meta charset="utf-8" /> + <title>Dao Game</title> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <link rel="icon" href="yin-yang.svg" type="image/svg+xml" /> + <link rel="stylesheet" href="styles.css" /> +</head> + +<body> + <div class="title"> + <p>Board game <a href="https://en.wikipedia.org/wiki/Dao_(game)" target="_blank" rel="noopener">Dao</a>.</p> + </div> + + <div class="info"> + The game consists of only one simple rule and three ways to win. + + Rules: + <ul> + <li> + Pieces move in a straight line, including diagonally, as far as possible with no jumps. + </li> + </ul> + Ways to win: + <ol> + <li>Arrange your pieces in a straight line on the board (not diagonally)</li> + <li>Occupy four corners of the board</li> + <li>Form a 2x2 square anywhere on the board</li> + </ol> + </div> + <hr /> + <div class="info"> + <div> + Play local (pass-and-play) game of Dao <a href='local.html'>here</a>. + </div> + <div> + Play online game of Dao <a href='remote.html'>here</a>. + </div> + </div> + + <div class="info"> + <p> + Here is the + <a href="https://gitlab.com/fuzzy-rp/dao-game" target="_blank" rel="noopener">source code</a> + </p> + </div> + + <div id="snackbar" class="info"></div> +</body> + +</html> \ No newline at end of file diff --git a/public/local.html b/public/local.html new file mode 100644 index 0000000..c3fc3cf --- /dev/null +++ b/public/local.html @@ -0,0 +1,70 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="utf-8" /> + <title>Dao Game</title> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <link rel="icon" href="yin-yang.svg" type="image/svg+xml" /> + <link rel="stylesheet" href="styles.css" /> + <script defer src="js/local.js"></script> +</head> + +<body> + <div id="placeholders" class="invisible"> + <p class="piece p1-piece" id="default-p1-piece"></p> + <p class="piece p2-piece" id="default-p2-piece"></p> + </div> + <main> + <div class="controls"> + <span class="p1-turn-text">Red</span> + <span class="p2-turn-text">Blue</span> + </div> + + <table id="table"></table> + + <div class="controls"> + <button type="button" class="button" id="undo-button"> + Undo + </button> + <button type="button" class="button" id="reset-button"> + Reset + </button> + </div> + </main> + + <div class="title"> + <p>Board game <a href="https://en.wikipedia.org/wiki/Dao_(game)" target="_blank" rel="noopener">Dao</a>.</p> + </div> + + <div class="info"> + The game consists of only one simple rule and three ways to win. + + Rules: + <ul> + <li> + Pieces move in a straight line, including diagonally, as far as possible with no jumps. + </li> + </ul> + Ways to win: + <ol> + <li>Arrange your pieces in a straight line on the board (not diagonally)</li> + <li>Occupy four corners of the board</li> + <li>Form a 2x2 square anywhere on the board</li> + </ol> + </div> + + <hr /> + + <div class="info"> + <p> + Here is the + <a href="https://gitlab.com/fuzzy-rp/dao-game" target="_blank" rel="noopener">source code</a> + </p> + </div> + + <div id="snackbar"></div> + +</body> + +</html> \ No newline at end of file diff --git a/public/remote.html b/public/remote.html new file mode 100644 index 0000000..ff94ed7 --- /dev/null +++ b/public/remote.html @@ -0,0 +1,72 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="utf-8" /> + <title>Dao Game</title> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <link rel="icon" href="yin-yang.svg" type="image/svg+xml" /> + <link rel="stylesheet" href="styles.css" /> + <script defer src="js/online.js"></script> +</head> + +<body> + <div id="placeholders" class="invisible"> + <p class="piece p1-piece" id="default-p1-piece"></p> + <p class="piece p2-piece" id="default-p2-piece"></p> + </div> + <main class="invisible"> + <div class="controls"> + <span class="p1-turn-text">Red</span> + <span class="p2-turn-text">Blue</span> + </div> + + <table id="table"></table> + </main> + + <hr /> + <div id="share-container" class="invisible info"> + Press this button to share a link with your opponent. + <button id="share-link">Share <img src="share.png" /></button> + </div> + <hr /> + + <div class="title"> + <p>Board game <a href="https://en.wikipedia.org/wiki/Dao_(game)" target="_blank" rel="noopener">Dao</a>.</p> + </div> + + <div class="info"> + The game consists of only one simple rule and three ways to win. + + Rules: + <ul> + <li> + Pieces move in a straight line, including diagonally, as far as possible with no jumps. + </li> + </ul> + Ways to win: + <ol> + <li>Arrange your pieces in a straight line on the board (not diagonally)</li> + <li>Occupy four corners of the board</li> + <li>Form a 2x2 square anywhere on the board</li> + </ol> + </div> + + <hr /> + + <div class="info"> + <p> + Here is the + <a href="https://gitlab.com/fuzzy-rp/dao-game" target="_blank" rel="noopener">source code</a> + </p> + </div> + + <div id="snackbar" class="info"></div> + + <div id="other-mouse"> + <img src="./cursor.png" alt="cursor" /> + </div> + +</body> + +</html> \ No newline at end of file diff --git a/public/share.png b/public/share.png new file mode 100644 index 0000000..921f933 Binary files /dev/null and b/public/share.png differ diff --git a/public/styles.css b/public/styles.css index cb59d42..79f8c94 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1,294 +1,336 @@ :root { - --default-size: 5px; - --active-size: 40px; - --cell-size: 80px; - /* --p1-color: crimson; - --p2-color: mediumblue; */ + --default-size: 5px; + --active-size: 40px; + --active-pulse-size: 30px; + --cell-size: 80px; + --max-width: 500px; + --font-family: Arial, sans-serif; } -* { - margin: auto; +#other-mouse { + position: absolute; + z-index: 1000; + pointer-events: none; + display: none; } -main { - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: center; - /* height: 75vh; */ +#table { + margin: auto; } -body { - /* overflow: hidden; */ - margin: 0px; - background-color: white; +#undo-button { + float: left; +} +#reset-button { + float: right; } -div { - display: flex; - justify-content: space-between; - /* display: inline; - flex: 1; */ +#share-container { + align-content: center; + align-items: center; + justify-content: center; + text-align: center; +} + +#share-link { + background-color: rgb(106, 170, 100); + border: black 1px solid; + max-width: var(--max-width); + margin: auto; + border-radius: 5px; + cursor: pointer; + display: flex; + + align-content: center; + align-items: center; + justify-content: center; + text-align: center; + + text-transform: uppercase; } .invisible { - display: none; + display: none; } .title { - text-align: center; - font-family: "open sans"; - font-size: 40px; + text-align: center; + font-family: var(--font-family); + font-size: 40px; + max-width: var(--max-width); + margin: auto; } -/* tile */ -td { - width: var(--cell-size); - height: var(--cell-size); - text-align: center; - border: 2px transparent solid; +.tile { + width: var(--cell-size); + height: var(--cell-size); + text-align: center; + border: 2px transparent solid; } .controls { - font-size: 90px; - margin-left: 15px; - margin-right: 15px; - font-family: "open sans"; - font-size: 55px; - border-radius: 10px; - padding: 10px; -} - -.divider { - font-size: 90px; - margin-left: 15px; - margin-right: 15px; + font-size: 90px; + font-family: var(--font-family); + font-size: 55px; + border-radius: 10px; + padding: 10px; + display: flex; + max-width: var(--max-width); + margin: auto; } -.red-turn-text, .blue-turn-text { - text-align: center; - color: lightgray; - background-color: white; - /* border: 1px solid black; */ - border-radius: 10px; - padding: 10px; - -ms-user-select: none; - -webkit-user-select: none; - user-select: none; +.p1-turn-text, +.p2-turn-text { + text-align: center; + color: lightgray; + background-color: white; + border-radius: 10px; + padding: 10px; + -ms-user-select: none; + -webkit-user-select: none; + user-select: none; + margin: auto; } -.red-turn-text.selected-player-text { - background-color: red; +.p1-turn-text.selected-player-text { + background-color: red; } -.blue-turn-text.selected-player-text { - background-color: blue; +.p2-turn-text.selected-player-text { + background-color: blue; } .selected-player-text { - color: white; + color: white; } .button { - border-radius: 10px; - padding: 10px; - background-color: orange; - font-size: 60px; - border-color: transparent; - transition: .2s all; + border-radius: 10px; + padding: 10px; + background-color: orange; + font-size: 60px; + border-color: transparent; + transition: 0.2s all; + margin: auto; } .button:active { - transform: scale(0.98); - /* Scaling button to 0.98 to its original size */ - box-shadow: 3px 2px 22px 1px rgba(0, 0, 0, 0.24); - /* Lowering the shadow */ + transform: scale(0.98); + box-shadow: 3px 2px 22px 1px rgba(0, 0, 0, 0.24); } .white { - background-color: orange; + background-color: orange; } .black { - background-color: wheat; + background-color: wheat; } .piece { - width: var(--default-size); - height: var(--default-size); - padding: 10px; - border-radius: 50px; - animation: activeToDisabled 1s 1; - /* background-image: url(yin-yang.svg); */ + width: var(--default-size); + height: var(--default-size); + padding: 10px; + border-radius: 50px; + animation: activeToDisabled .5s 1; + margin: auto; } -.red-piece { - background-color: crimson; - /* background-image: url(yin-yang.svg); */ +.p1-piece { + background-color: crimson; } -.blue-piece { - background-color: mediumblue; - /* background-image: url(yin-yang.svg); */ +.p2-piece { + background-color: mediumblue; } .active-player-piece { - width: var(--active-size); - height: var(--active-size); - animation: disabledToActive 1s 1; + width: var(--active-size); + height: var(--active-size); + animation: disabledToActive .5s 1; } .selected-piece { - animation: activePulsing 1s infinite; + animation: activePulsing .5s infinite; } .info { - text-align: center; - font-family: "open sans"; - font-size: 20px; + /* text-align: center; */ + font-family: var(--font-family); + font-size: 20px; + max-width: var(--max-width); + margin: auto; } .possible-move { - animation: blinkingText 1s infinite; - border-color: black; - filter: drop-shadow(0px 0px 5px black); + animation: blinkingText 1s infinite; + border-color: black; + filter: drop-shadow(0px 0px 5px black); } @keyframes blinkingText { - 0%{opacity: 1;} - 50%{opacity: .6;} - 100%{opacity: 1;} + 0% { + opacity: 1; + } + 50% { + opacity: 0.6; + } + 100% { + opacity: 1; + } } @keyframes fade-in { - 0%{opacity: 0;} - 50%{opacity: .5;} - 100%{opacity: 1;} + 0% { + opacity: 0; + } + 50% { + opacity: 0.5; + } + 100% { + opacity: 1; + } } @keyframes disabledToActive { - from { - width: var(--default-size); - height: var(--default-size); - } - to { - width: var(--active-size); - height: var(--active-size); - } + from { + width: var(--default-size); + height: var(--default-size); + } + to { + width: var(--active-size); + height: var(--active-size); + } } @keyframes activeToDisabled { - from { - width: var(--active-size); - height: var(--active-size); - } - to { - width: var(--default-size); - height: var(--default-size); - } + from { + width: var(--active-size); + height: var(--active-size); + } + to { + width: var(--default-size); + height: var(--default-size); + } } - -/* .pulsing { - animation: pulsingSize 1.5s infinite; -} */ @keyframes activePulsing { - from { - width: var(--active-size); - height: var(--active-size); - } - 50% { - width: 25px; - height: 25px; - } - to { - width: var(--active-size); - height: var(--active-size); - } -} - -/* breaks */ - -/* @media screen and (max-width: 560px) { - .red-piece { - height: 4.5vw; - width: 4.5vw; - } - - .black-piece { - height: 4.5vw; - width: 4.5vw; - } - - td { - width: 12vw; - height: 12vw; - } - - .red-turn-text { - font-size: 40px; - } - - .black-turn-text { - font-size: 40px; - } - - table { - margin: 0; - } - - .mobile { - display: block; - } - - .desktop { - display: none; - } - - .red-turn-text { - -webkit-transform:rotate(-180deg); - -moz-transform:rotate(-180deg); - -o-transform:rotate(-180deg); - transform:rotate(-180deg); - filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2); - } -} - -@media screen and (max-width: 380px) { - .red-piece { - height: 3vw; - width: 3vw; - } - - .black-piece { - height: 3vw; - width: 3vw; - } -} - -@media screen and (max-width: 345px) { - .red-piece { - height: 2.5vw; - width: 2.5vw; - } - - .black-piece { - height: 2.5vw; - width: 2.5vw; - } -} - -@media screen and (max-width: 300px) { - .red-piece { - height: 1.5vw; - width: 1.5vw; - } - - .black-piece { - height: 1.5vw; - width: 1.5vw; - } - - .red-turn-text { - font-size: 35px; - } - - .black-turn-text { - font-size: 35px; - } -} */ \ No newline at end of file + from { + opacity: 1; + } + 50% { + opacity: .5; + } + to { + opacity: 1; + } +} + +/* Snackbar, from https://www.w3schools.com/howto/howto_js_snackbar.asp */ + +/* The snackbar - position it at the bottom and in the middle of the screen */ +#snackbar { + visibility: hidden; /* Hidden by default. Visible on click */ + min-width: 250px; /* Set a default minimum width */ + margin-left: -125px; /* Divide value of min-width by 2 */ + background-color: #333; /* Black background color */ + color: #fff; /* White text color */ + text-align: center; /* Centered text */ + border-radius: 2px; /* Rounded borders */ + padding: 16px; /* Padding */ + position: fixed; /* Sit on top of the screen */ + z-index: 1; /* Add a z-index if needed */ + left: 50%; /* Center the snackbar */ + bottom: 30px; /* 30px from the bottom */ +} + +/* Show the snackbar when clicking on a button (class added with JavaScript) */ +#snackbar.show { + visibility: visible; /* Show the snackbar */ + /* Add animation: Take 0.5 seconds to fade in and out the snackbar. + However, delay the fade out process for 2.5 seconds */ + -webkit-animation: fadein 0.5s, fadeout 0.5s 2.5s; + animation: fadein 0.5s, fadeout 0.5s 2.5s; +} + +/* Animations to fade the snackbar in and out */ +@-webkit-keyframes fadein { + from { + bottom: 0; + opacity: 0; + } + to { + bottom: 30px; + opacity: 1; + } +} + +@keyframes fadein { + from { + bottom: 0; + opacity: 0; + } + to { + bottom: 30px; + opacity: 1; + } +} + +@-webkit-keyframes fadeout { + from { + bottom: 30px; + opacity: 1; + } + to { + bottom: 0; + opacity: 0; + } +} + +@keyframes fadeout { + from { + bottom: 30px; + opacity: 1; + } + to { + bottom: 0; + opacity: 0; + } +} + +/* Modal from https://www.w3schools.com/howto/howto_css_modals.asp */ + /* The Modal (background) */ +.modal { + display: none; /* Hidden by default */ + position: fixed; /* Stay in place */ + z-index: 1; /* Sit on top */ + left: 0; + top: 0; + width: 100%; /* Full width */ + height: 100%; /* Full height */ + overflow: auto; /* Enable scroll if needed */ + background-color: rgb(0,0,0); /* Fallback color */ + background-color: rgba(0,0,0,0.4); /* Black w/ opacity */ + font-family: var(--font-family); + font-size: 30px; +} + +/* Modal Content/Box */ +.modal-content { + background-color: #fefefe; + margin: 100px auto; /* 15% from the top and centered */ + padding: 20px; + border: 1px solid #888; + /* width: 80%; /* Could be more or less, depending on screen size */ + max-width: var(--max-width); +} + +/* The Close Button */ +.close { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; +} + +.close:hover, +.close:focus { + color: black; + text-decoration: none; + cursor: pointer; +} \ No newline at end of file diff --git a/readme.md b/readme.md index a7939e5..2e86fd3 100644 --- a/readme.md +++ b/readme.md @@ -3,10 +3,16 @@ ## Running 1. `npm run start` +2. Navigate to [`http://localhost:3000/`](http://localhost:3000/) + +## Debugging + +1. `npm run start` +2. Run `Launch Chrome against localhost` debug task in VSCode ## Gitlab Pages -[Location](https://fuzzy-rp.gitlab.io/dao-game/) +[https://fuzzy-rp.gitlab.io/dao-game/multiplayer-scratch/](https://fuzzy-rp.gitlab.io/dao-game/multiplayer-scratch/) ## Esbuild @@ -14,6 +20,7 @@ This project is using [esbuild](https://esbuild.github.io/getting-started/#build ## Todos -1. Use esbuild and rewrite app in TypeScript -2. Online multiplayer - - Follow the deployment logic [here](https://gitlab.com/fuzzy-rp/flight3d/-/blob/master/.gitlab-ci.yml) to deploy a signaling service to dreamhost and use that for this game. +1. Online multiplayer +2. Figure out way to have different pages access different JS +3. There's a bug where opening a new tab results in the same session data, look into this and figure out why and how to prevent this +4. Publish versions of all branches diff --git a/src/game-board.ts b/src/game-board.ts new file mode 100644 index 0000000..f9f54e9 --- /dev/null +++ b/src/game-board.ts @@ -0,0 +1,67 @@ +import { Coord } from "./types"; + + +export class GameBoard { + private board: (string | null)[]; + public readonly size: Coord; + public readonly player1Id: string; + public readonly player2Id: string; + + public constructor(player1Id: string, player2Id: string) { + // default board + const defaultBoard = [] + .concat([player1Id, null, null, player2Id]) + .concat([null, player1Id, player2Id, null]) + .concat([null, player2Id, player1Id, null]) + .concat([player2Id, null, null, player1Id]); + const testBoard = [] + .concat([player1Id, null, player2Id, player2Id]) + .concat([player1Id, null, player2Id, null]) + .concat([player1Id, null, null, null]) + .concat([null, player1Id, null, player2Id]); + this.board = defaultBoard; + this.size = new Coord(4, 4); + this.player1Id = player1Id; + this.player2Id = player2Id; + } + public move(source: Coord, dest: Coord): void { + const isP1 = this.get(source); + if (isP1 === null) + throw `Source ${source} points to an empty space`; + this.set(source, null); + this.set(dest, isP1); + } + public get(coord: Coord): boolean | null { + const index = this.coordToBoardIndex(coord); + if (!Number.isSafeInteger(index) || index < 0 || index >= this.board.length) + throw `Index ${index} is out of bounds`; + const id = this.board[index]; + const isP1 = id === null ? null : id === this.player1Id; + return isP1; + } + public getCoords(id: string): Coord[] { + const coords = this.board + .map((pieceId, index) => { + const isTarget = pieceId === id; + return isTarget ? this.boardIndexToCoord(index) : null; + }) + .filter((coord) => coord !== null); + return coords; + } + private set(coord: Coord, isP1: boolean | null): void { + const index = this.coordToBoardIndex(coord); + if (!Number.isSafeInteger(index) || index < 0 || index >= this.board.length) + throw `Index ${index} is out of bounds`; + this.board[index] = isP1 === null ? null : isP1 ? this.player1Id : this.player2Id; + } + public coordToBoardIndex(coord: Coord): number { + const { row, col } = coord; + const index = row * this.size.col + col; + return index; + } + private boardIndexToCoord(index: number): Coord { + const col = index % this.size.col; + const row = Math.floor(index / this.size.col); + return new Coord(row, col); + } +} diff --git a/src/game.ts b/src/game.ts index 60fa73c..2cfd4ee 100644 --- a/src/game.ts +++ b/src/game.ts @@ -1,333 +1,178 @@ -import { Coord, GameBoard, ListenerManager, Move } from './types' - -const constants = { - selectedPieceClassName: 'selected-piece', - selectedPlayerTextClassName: 'selected-player-text', - activePlayerPieceClassName: 'active-player-piece', - possibleMoveClassName: 'possible-move', +import { Coord, Move, MoveResult } from './types'; +import { GameBoard } from "./game-board"; + +export interface IGame { + size: Coord; + moveHistory: Move[]; + player1Id: string; + player1Name: string; + player2Id: string; + player2Name: string; + currentPlayerId: string; + canUndoMove: boolean; + getPlayerPositions: (playerId: string) => Coord[]; + coordToBoardIndex: (coord: Coord) => number; + getValidMoves: (start: Coord) => Coord[]; + makeMove: (source: Coord, dest: Coord) => Promise<MoveResult>; + undoMove: () => Move; + getPiece(pos: Coord): string | undefined; } -const gameBoard = new GameBoard(); -const listenerManager: ListenerManager = new ListenerManager(); - -const player: { isRedTurn: boolean, selectedPieceId: number | undefined, moveHistory: Move[] } = { - isRedTurn: false, - selectedPieceId: undefined, - moveHistory: [], -} - -// DOM references -const dom = { - defaultRedPiece: document.getElementById('default-red-piece'), - defaultBluePiece: document.getElementById('default-blue-piece'), - cells: () => Array.from(document.querySelectorAll("td")), - allPieces: () => Array.from(document.querySelectorAll('.piece')), - playerPieces: (isRedTurn: boolean) => isRedTurn ? - Array.from(document.querySelectorAll('.red-piece:not(#default-red-piece')) : - Array.from(document.querySelectorAll('.blue-piece:not(#default-blue-piece')), - redTurnText: () => Array.from(document.querySelectorAll(".red-turn-text")), - blueTurnText: () => Array.from(document.querySelectorAll(".blue-turn-text")), - resetButton: document.getElementById('reset-button') as HTMLInputElement, - undoButton: document.getElementById('undo-button') as HTMLInputElement, -} - -const resetBoard = () => { - // resetCells - dom.cells().forEach(cell => { - cell.classList.remove(constants.possibleMoveClassName); - }); - - // reset pieces - dom.allPieces().forEach(piece => { - if (piece.classList.contains(constants.activePlayerPieceClassName)) { - piece.classList.toggle(constants.activePlayerPieceClassName); - } - if (piece.classList.contains(constants.selectedPieceClassName)) { - piece.classList.toggle(constants.selectedPieceClassName); - } - }); - - // remove all listeners - listenerManager.removeListeners(); -}; - -const movePiece = async (sourceIndex: number, destIndex: number) => { - const pieceId = gameBoard.board[sourceIndex]; - // find current piece - const piece = document.getElementById(pieceId.toString()); - // unselect piece - piece.classList.remove(constants.selectedPieceClassName); - - // create clone for new position - const destPiece = piece.cloneNode(true); - // add to destination cell - dom.cells()[destIndex].appendChild(destPiece); - - // start animations - const destAnimation = dom.cells()[destIndex].animate([{ opacity: 0 }, { opacity: 1 }], { duration: 1000, iterations: 1 }) - const sourceAnimation = piece.animate([{ - opacity: 1, - width: '45px', - height: '45px' - }, { - opacity: 0, - width: '5px', - height: '5px' - },], { duration: 1000, iterations: 1 }); - - // await animations - await Promise.all([sourceAnimation.finished, destAnimation.finished]); - - // remove old piece - piece.parentNode.removeChild(piece); - gameBoard.board[sourceIndex] = null; - // set new piece - gameBoard.board[destIndex] = pieceId; -}; - -const nextMove = () => { - const setActivePlayerPieces = () => { - const selectPiece = (event: Event) => { - const getAllowedMoveIndicesForIndex = (boardIndex: number): number[] => { - const getFarthestCoord = (startCoord: Coord, direction: Coord): Coord => { - let farthestPos = undefined; - let testPos = startCoord; - while (true) { - const pos = new Coord(testPos.row + direction.row, testPos.col + direction.col); - const index = gameBoard.coordToBoardIndex(pos); - const withinBounds = - pos.row >= 0 && pos.row < gameBoard.rows && - pos.col >= 0 && pos.col < gameBoard.cols; - const isOccupied = Number.isInteger(gameBoard.board[index]); - if (isOccupied || !withinBounds) { - break; - } else { - farthestPos = pos; - testPos = pos; - } - } - return farthestPos; - }; - - const coord = gameBoard.boardIndexToCoord(boardIndex); - - const farthestCoords = Coord.allDirections - .map(direction => getFarthestCoord(coord, direction)) - .filter(farCoord => farCoord !== undefined); - - const farthestIndices = farthestCoords.map(c => gameBoard.coordToBoardIndex(c)); - return farthestIndices; - }; - - const previousSelectedPieceId = player.selectedPieceId; - if (previousSelectedPieceId !== undefined) { - const previousPiece = document.getElementById(previousSelectedPieceId.toString()); - previousPiece.classList.remove(constants.selectedPieceClassName); - dom.cells().forEach(cell => { - cell.classList.remove(constants.possibleMoveClassName); - }); - } - - // read event data - const piece = event.target as Element; - const pieceId = parseInt(piece.id); - - // set as selected - player.selectedPieceId = pieceId; - piece.classList.add(constants.selectedPieceClassName); - - // hook up allowed moves for: - // 1. blink - // 2. complete the move - const pieceIndex = gameBoard.board.indexOf(pieceId); - const allowedMoveIndices = getAllowedMoveIndicesForIndex(pieceIndex); - allowedMoveIndices.forEach(i => { - const cell = dom.cells()[i]; - listenerManager.addListener(cell, 'click', async () => await makeMove(i)); - cell.classList.add(constants.possibleMoveClassName); - }); - }; - - const makeMove = async (destIndex) => { - listenerManager.removeListeners(); - - const sourceIndex = gameBoard.board.indexOf(player.selectedPieceId); - player.moveHistory.push(new Move(sourceIndex, destIndex)); - - await movePiece(sourceIndex, destIndex); - - checkForWin(); - nextMove(); - } - - // mark active pieces - const playerPieces = dom.playerPieces(player.isRedTurn); - playerPieces.forEach(pp => { - pp.classList.add(constants.activePlayerPieceClassName); - listenerManager.addListener(pp, 'click', selectPiece); - }); - }; - - // reset text - dom.redTurnText().forEach(t => t.classList.remove(constants.selectedPlayerTextClassName)); - dom.blueTurnText().forEach(t => t.classList.remove(constants.selectedPlayerTextClassName)); - - // changePlayer - // 1. flip flag - // 2. set text - // 3. set click callbacks - if (player.isRedTurn) { - player.isRedTurn = false; - dom.blueTurnText().forEach(t => t.classList.add(constants.selectedPlayerTextClassName)); - } - else { - player.isRedTurn = true; - dom.redTurnText().forEach(t => t.classList.add(constants.selectedPlayerTextClassName)); - } - - if (player.moveHistory.length === 0) { - dom.undoButton.disabled = true; - dom.resetButton.disabled = true; - } else { - dom.undoButton.disabled = false; - dom.resetButton.disabled = false; - } - - player.selectedPieceId = undefined; - resetBoard(); - setActivePlayerPieces(); -}; - -const checkForWin = () => { - const elementsToCoords = (elements: Element[]): Coord[] => { - const elementsToBoardIndexes = (elements: Element[]): number[] => { - const indices = elements.map(pp => { - const pieceId = Number.parseInt(pp.id); - const pieceIndex = gameBoard.board.indexOf(pieceId); - return pieceIndex; - }); - return indices; - }; - - const indices = elementsToBoardIndexes(elements); - const coords = indices.map(pieceIndex => gameBoard.boardIndexToCoord(pieceIndex)); - return coords; - }; - const doCoordsFormSquare = (coords: Coord[]): boolean => { - const allRows = coords.map(c => c.row); - const allCols = coords.map(c => c.col); +export class Game implements IGame { + private isGameOver: boolean; + private readonly gameBoard: GameBoard; + private readonly _moveHistory: Move[]; + public readonly player1Id: string; + public readonly player2Id: string; + public readonly player1Name: string; + public readonly player2Name: string; + + public constructor(player1Id: string, player1Name: string, player2Id: string, player2Name: string) { + this.gameBoard = new GameBoard(player1Id, player2Id); + this.player1Id = player1Id; + this.player2Id = player2Id; + this.player1Name = player1Name; + this.player2Name = player2Name; + this.isGameOver = false; + this._moveHistory = []; + this.currentPlayerId = player1Id; + } + getPiece(pos: Coord): string { + const value = this.gameBoard.get(pos); + if (value === null) return null; + const b = value.valueOf(); + const id = b ? this.player1Id : this.player2Id; + return id; + } + public currentPlayerId: string; + public get size(): Coord { + return this.gameBoard.size; + } + public get moveHistory(): Move[] { + return this._moveHistory; + } + public getPlayerPositions(playerId: string): Coord[] { + return this.gameBoard.getCoords(playerId); + } + public coordToBoardIndex(coord: Coord): number { + return this.gameBoard.coordToBoardIndex(coord); + } + public async makeMove(source: Coord, dest: Coord): Promise<MoveResult> { + const checkForWin = (isP1Turn: boolean): MoveResult => { + const doCoordsFormSquare = (coords: Coord[]): boolean => { + const allRows = coords.map((c) => c.row); + const allCols = coords.map((c) => c.col); const min = new Coord(Math.min(...allRows), Math.min(...allCols)); const max = new Coord(Math.max(...allRows), Math.max(...allCols)); const diff = max.sub(min); - const isSquare = (diff.row === 1 && diff.col === 1); + const isSquare = diff.row === 1 && diff.col === 1; return isSquare; - }; - - const playerCoords = elementsToCoords(dom.playerPieces(player.isRedTurn)) - const otherCoords = elementsToCoords(dom.playerPieces(!player.isRedTurn)); - const squareCoords: Coord[] = [ - new Coord(0, 0), - new Coord(3, 0), - new Coord(3, 3), - new Coord(0, 3), - ]; - - // winning conditions: - // 1. all in one row - // 2. all in one column - // 3. all in corners - // 4. all in square - // losing conditions: - // 1. on three sides of an opponent - - const firstPiece = playerCoords[0]; - const allInRow = playerCoords.every(pc => pc.row === firstPiece.row) - const allInCol = playerCoords.every(pc => pc.col === firstPiece.col) - const allInCorners = playerCoords.every(pc => squareCoords.findIndex(sc => pc.equals(sc)) >= 0); - const allInSquare = doCoordsFormSquare(playerCoords); - - const winnerName = player.isRedTurn ? 'Red' : 'Blue'; - let winningMessage = undefined; - if (allInRow) { + }; + const { gameBoard, player1Id, player2Id, player1Name, player2Name } = this; + const playerId = isP1Turn ? player1Id : player2Id; + + const playerCoords = gameBoard.getCoords(playerId); + const squareCoords: Coord[] = [new Coord(0, 0), new Coord(3, 0), new Coord(3, 3), new Coord(0, 3)]; + + // winning conditions: + // 1. all in one row + // 2. all in one column + // 3. all in corners + // 4. all in square + // losing conditions: + // 1. on three sides of an opponent + + const firstPiece = playerCoords[0]; + const allInRow = playerCoords.every((pc) => pc.row === firstPiece.row); + const allInCol = playerCoords.every((pc) => pc.col === firstPiece.col); + const allInCorners = playerCoords.every((pc) => squareCoords.findIndex((sc) => pc.equals(sc)) >= 0); + const allInSquare = doCoordsFormSquare(playerCoords); + + const winnerName = isP1Turn ? player1Name : player2Name; + let winningMessage = undefined; + if (allInRow) { winningMessage = `${winnerName} wins by forming a row`; - } - if (allInCol) { + } + if (allInCol) { winningMessage = `${winnerName} wins by forming a column`; - } - if (allInCorners) { + } + if (allInCorners) { winningMessage = `${winnerName} wins by putting pieces in corners`; - } - if (allInSquare) { + } + if (allInSquare) { winningMessage = `${winnerName} wins by forming a square`; - } - - if (winningMessage !== undefined) { - alert(winningMessage); - document.location.reload(); - } -}; + } + if (winningMessage) { + return { + isTerminal: true, + message: winningMessage, + }; + } else { + return { + isTerminal: false, + }; + } + }; -const init = () => { - // create board and pieces - let isWhite = true; - const table = document.getElementById('table'); - for (let r = 0; r < gameBoard.rows; r++) { - const tr = document.createElement('tr'); - table.appendChild(tr); - for (let c = 0; c < gameBoard.cols; c++) { - const td = document.createElement('td'); - tr.appendChild(td); - td.className = isWhite ? 'white' : 'black'; - isWhite = !isWhite; - } - isWhite = !isWhite; - } - gameBoard.board.forEach((value, index) => { - if (value !== null) { - const isRed = value < 4; - const defaultPiece = isRed ? dom.defaultRedPiece : dom.defaultBluePiece; - const newPiece = defaultPiece.cloneNode(true) as HTMLParagraphElement; - newPiece.id = value.toString(); - dom.cells()[index].appendChild(newPiece); - } - }); + const { gameBoard, player1Id, player2Id } = this; + const isP1Turn = this.currentPlayerId === player1Id; + const playerId = isP1Turn ? player1Id : player2Id; - // hook up buttons - let blockActions = false; - dom.resetButton.addEventListener('click', () => { - if (blockActions) { - return; - } - if (player.moveHistory.length === 0) { - return; - } + this._moveHistory.push(new Move(playerId, source, dest)); + gameBoard.move(source, dest); - blockActions = true; - if (confirm('Reset?')) { - window.location.reload(); - } - blockActions = false; - }); - dom.undoButton.addEventListener('click', async () => { - if (blockActions) { - return; + const result = checkForWin(isP1Turn); + if (result.isTerminal) { + this.isGameOver = true; + } else { + this.currentPlayerId = isP1Turn ? this.player2Id : this.player1Id; + } + return result; + } + public getValidMoves(start: Coord): Coord[] { + const getFarthestCoord = (startCoord: Coord, direction: Coord): Coord => { + const { gameBoard } = this; + let farthestPos = undefined; + let testPos = startCoord; + while (true) { + const pos = new Coord(testPos.row + direction.row, testPos.col + direction.col); + + const withinBounds = gameBoard.size.contains(pos); + if (!withinBounds) { + break; } - if (player.moveHistory.length === 0) { - return; + const isOccupied = gameBoard.get(pos) !== null; + if (isOccupied) { + break; } - blockActions = true; - - player.selectedPieceId = undefined; - resetBoard(); + farthestPos = pos; + testPos = pos; + } + return farthestPos; + }; - const { destIndex, sourceIndex } = player.moveHistory.pop(); - await movePiece(destIndex, sourceIndex); - nextMove(); - blockActions = false; - }); -}; + const farthestCoords = Coord.allDirections + .map((direction) => getFarthestCoord(start, direction)) + .filter((farCoord) => farCoord !== undefined); -// start! -init(); -nextMove(); \ No newline at end of file + return farthestCoords; + } + public undoMove(): Move { + if (!this.canUndoMove) { + throw "Newp! Can't undo!"; + } + const { gameBoard } = this; + const move = this._moveHistory.pop(); + const { source, dest, playerId } = move; + this.currentPlayerId = playerId; + gameBoard.move(dest, source); + return move; + } + public get canUndoMove(): boolean { + if (this._moveHistory.length === 0 || this.isGameOver) { + return false; + } + return true; + } +} diff --git a/src/listeners.ts b/src/listeners.ts new file mode 100644 index 0000000..2ffe56e --- /dev/null +++ b/src/listeners.ts @@ -0,0 +1,24 @@ +export interface Listener { + element: Element; + type: string; + listener: (event: Event) => void; +} + +export class ListenerManager { + private listeners: Listener[] = []; + + public addListener(element: Element, type: string, listener: (event: Event) => void): void { + const { listeners } = this; + element.addEventListener(type, listener); + listeners.push({ element, type, listener }); + } + + public removeListeners() { + const { listeners } = this; + listeners.forEach(value => { + const { element, type, listener } = value; + element.removeEventListener(type, listener); + }); + listeners.length = 0; + } +} diff --git a/src/local-game.ts b/src/local-game.ts new file mode 100644 index 0000000..ec5a19b --- /dev/null +++ b/src/local-game.ts @@ -0,0 +1,264 @@ +import { Game, IGame } from './game'; +import { Coord, MoveResult } from './types'; +import { ListenerManager } from './listeners'; +import { showModal } from './modal'; + +const classNames = { + selectedPiece: 'selected-piece', + selectedPlayerText: 'selected-player-text', + activePlayerPiece: 'active-player-piece', + possibleMove: 'possible-move', +}; + +export class LocalGame extends Game { + selectedPiece: Element | null = null; + listenerManager: ListenerManager = new ListenerManager(); + + // DOM references + readonly dom = { + defaultP1Piece: document.getElementById('default-p1-piece'), + defaultP2Piece: document.getElementById('default-p2-piece'), + resetButton: document.getElementById('reset-button') as HTMLInputElement, + undoButton: document.getElementById('undo-button') as HTMLInputElement, + cells: () => Array.from(document.querySelectorAll('td')), + allPieces: () => Array.from(document.querySelectorAll('.piece')), + p1TurnText: () => Array.from(document.querySelectorAll('.p1-turn-text'))[0], + p2TurnText: () => Array.from(document.querySelectorAll('.p2-turn-text'))[0], + }; + + private getPiecesAndCoords(playerId: string) { + const allCells = this.dom.cells(); + const playerPositions = this.getPlayerPositions(playerId); + const piecesAndPositions = playerPositions.map((coord) => { + const index = this.coordToBoardIndex(coord); + const cell = allCells[index]; + const piece = cell.children[0]; + return { coord, piece }; + }); + return piecesAndPositions; + } + + /** + * Animates the motion of piece moving from source to dest + * @param source Starting Coord + * @param dest Ending Coord + * @param playerId Player id + */ + private async movePiece(source: Coord, dest: Coord) { + const { dom } = this; + const sourceIndex = this.coordToBoardIndex(source); + const destIndex = this.coordToBoardIndex(dest); + + // find current piece + const piece = dom.cells()[sourceIndex].children[0]; + // unselect piece + piece.classList.remove(classNames.selectedPiece); + + // create clone for new position + const destPiece = piece.cloneNode(true); + // add to destination cell + dom.cells()[destIndex].appendChild(destPiece); + + // start animations + const animDurationMs = 500; + const destAnimation = dom.cells()[destIndex].animate([{ opacity: 0 }, { opacity: 1 }], { duration: animDurationMs, iterations: 1 }); + const sourceAnimation = piece.animate( + [ + { + opacity: 1, + width: '45px', + height: '45px', + }, + { + opacity: 0, + width: '5px', + height: '5px', + }, + ], + { duration: animDurationMs, iterations: 1 } + ); + + // await animations + await Promise.all([sourceAnimation.finished, destAnimation.finished]); + + // remove old piece + piece.parentNode.removeChild(piece); + } + + public constructor(player1Id: string, player1Name: string, player2Id: string, player2Name: string) { + super(player1Id, player1Name, player2Id, player2Name); + this.init(); + } + + public override async makeMove(source: Coord, dest: Coord): Promise<MoveResult> { + this.listenerManager.removeListeners(); + await this.movePiece(source, dest); + const result = await super.makeMove(source, dest); + this.handleMoveResult(result); + return result; + } + + public async handleMoveResult(result: MoveResult) { + const { isTerminal, message } = result; + if (isTerminal) { + await showModal(message); + document.location.reload(); + } else { + this.resetUi(); + this.setActiveUi(); + } + } + + public resetUi() { + const { dom, currentPlayerId, player1Id, canUndoMove, moveHistory, listenerManager, selectedPiece } = this; + + // reset text + dom.p1TurnText().classList.remove(classNames.selectedPlayerText); + dom.p2TurnText().classList.remove(classNames.selectedPlayerText); + + const isP1Turn = currentPlayerId === player1Id; + if (isP1Turn) { + dom.p1TurnText().classList.add(classNames.selectedPlayerText); + } else { + dom.p2TurnText().classList.add(classNames.selectedPlayerText); + } + + if (dom.undoButton || dom.resetButton) { + dom.undoButton.disabled = canUndoMove; + if (moveHistory.length === 0) { + dom.undoButton.disabled = true; + dom.resetButton.disabled = true; + } else { + dom.undoButton.disabled = false; + dom.resetButton.disabled = false; + } + } + + this.selectedPiece = null; + + // reset Cells + dom.cells().forEach((cell) => cell.classList.remove(classNames.possibleMove)); + // reset pieces + dom.allPieces().forEach((piece) => { + piece.classList.remove(classNames.activePlayerPiece); + piece.classList.remove(classNames.selectedPiece); + }); + // remove all listeners + listenerManager.removeListeners(); + } + public setActiveUi() { + const { dom, currentPlayerId, listenerManager } = this; + + const clearPossibleMovesBlink = () => + dom.cells().forEach((cell) => cell.classList.remove(classNames.possibleMove)); + const activePieceListener = (piece: Element, source: Coord) => { + // clear current selection + if (this.selectedPiece !== null) { + this.selectedPiece.classList.remove(classNames.selectedPiece); + clearPossibleMovesBlink(); + listenerManager.removeListeners(); + hookupActivePieces(); + } + + // set as selected + this.selectedPiece = piece; + piece.classList.add(classNames.selectedPiece); + + // hook up allowed moves for: + // 1. blink + // 2. complete the move + const allowedMoves = this.getValidMoves(source); + allowedMoves.forEach((dest: Coord) => { + const cell = dom.cells()[this.coordToBoardIndex(dest)]; + cell.classList.add(classNames.possibleMove); + listenerManager.addListener(cell, 'click', async () => { + clearPossibleMovesBlink(); + await this.makeMove(source, dest); + }); + }); + }; + + const hookupActivePieces = () => { + // mark active pieces + const piecesAndPositions = this.getPiecesAndCoords(currentPlayerId); + piecesAndPositions.forEach(({ coord: source, piece }) => { + piece.classList.add(classNames.activePlayerPiece); + listenerManager.addListener(piece, 'click', () => activePieceListener(piece, source)); + }); + } + + hookupActivePieces(); + } + + private init() { + const { dom, player1Id, player1Name, player2Id, player2Name, size } = this; + + // create board and pieces + let isWhite = true; + const table = document.getElementById('table'); + for (let r = 0; r < size.row; r++) { + const tr = document.createElement('tr'); + table.appendChild(tr); + for (let c = 0; c < size.col; c++) { + const td = document.createElement('td'); + tr.appendChild(td); + td.classList.add('tile'); + td.classList.add(isWhite ? 'white' : 'black'); + isWhite = !isWhite; + } + isWhite = !isWhite; + } + const createPiece = (isP1Turn: boolean, pieceId: string, coord: Coord) => { + const defaultPiece = isP1Turn ? dom.defaultP1Piece : dom.defaultP2Piece; + const newPiece = defaultPiece.cloneNode(true) as HTMLParagraphElement; + newPiece.id = pieceId; + const index = this.coordToBoardIndex(coord); + dom.cells()[index].appendChild(newPiece); + }; + this.getPlayerPositions(player1Id).forEach((coord) => createPiece(true, player1Id, coord)); + this.getPlayerPositions(player2Id).forEach((coord) => createPiece(false, player2Id, coord)); + + dom.p1TurnText().textContent = `${player1Name}` + dom.p2TurnText().textContent = `${player2Name}` + + // hook up buttons + let blockActions = false; + if (dom.resetButton) { + dom.resetButton.addEventListener('click', () => { + if (blockActions) { + return; + } + + blockActions = true; + if (confirm('Reset?')) { + window.location.reload(); + } + blockActions = false; + }); + } + + if (dom.undoButton) { + dom.undoButton.addEventListener('click', async () => { + if (blockActions) { + return; + } + + blockActions = true; + + const move = this.undoMove(); + await this.movePiece(move.dest, move.source); + this.resetUi(); + this.setActiveUi(); + + blockActions = false; + }); + } + } +} + +// start! +export const playGame = () => { + const lg = new LocalGame('red-player', 'Red', 'blue-player', 'Blue'); + lg.resetUi(); + lg.setActiveUi(); +}; diff --git a/src/local.ts b/src/local.ts new file mode 100644 index 0000000..22236bd --- /dev/null +++ b/src/local.ts @@ -0,0 +1,3 @@ +import { playGame } from './local-game'; + +playGame(); \ No newline at end of file diff --git a/src/modal.ts b/src/modal.ts new file mode 100644 index 0000000..92770d6 --- /dev/null +++ b/src/modal.ts @@ -0,0 +1,59 @@ +/* + <div class="modal"> + <div class="modal-content"> + <span class="close">Close</span> + <p></p> + </div> + </div> +*/ +export const showModal = (text): Promise<void> => { + const modal = document.createElement('div'); + modal.classList.add('modal'); + + const modalContentDiv = document.createElement('div'); + modalContentDiv.classList.add('modal-content'); + modal.appendChild(modalContentDiv); + + const closeSpan = document.createElement('span'); + closeSpan.classList.add('close'); + closeSpan.innerText = 'x'; + modalContentDiv.appendChild(closeSpan); + + const modalContentPara = document.createElement('p'); + modalContentPara.id = 'modal-content'; + modalContentPara.innerText = text; + modalContentDiv.appendChild(modalContentPara); + + document.body.appendChild(modal); + + // Get the <span> element that closes the modal + var span = document.getElementsByClassName("close")[0] as HTMLSpanElement; + + var content = document.getElementById('modal-content') as HTMLParagraphElement; + content.innerText = text; + + const promise = new Promise<void>((resolve, reject) => { + let isModalOpen = true; + const closeModal = () => { + modal.style.display = "none"; + isModalOpen = false; + modal.parentNode.removeChild(modal); + resolve(); + } + + // When the user clicks on <span> (x), close the modal + span.onclick = () => closeModal(); + + // When the user clicks anywhere outside of the modal, close it + window.onclick = (event: MouseEvent) => { + if (event.target == modal) { + closeModal(); + } + }; + }); + + // show modal + modal.style.display = "block"; + + return promise; +} \ No newline at end of file diff --git a/src/online.ts b/src/online.ts new file mode 100644 index 0000000..b314e49 --- /dev/null +++ b/src/online.ts @@ -0,0 +1,3 @@ +import { playGame } from './remote-game'; + +playGame(); \ No newline at end of file diff --git a/src/remote-game.ts b/src/remote-game.ts new file mode 100644 index 0000000..8d642b3 --- /dev/null +++ b/src/remote-game.ts @@ -0,0 +1,212 @@ +import Peer, { PeerJSOption } from 'peerjs'; +import { LocalGame } from './local-game'; +import { showModal } from './modal'; +import { useSessionStorage } from './storage'; +import { Coord, Move, MoveResult } from './types'; +import { delay, generateHash, showSnackbar } from './utils'; + +interface LocalData { + id: string; + peerId: string; +} +interface Pos { + x: number; + y: number; +} +interface PeerData { + mousePos?: Pos; + move?: Move; +} +const peerOptions: PeerJSOption = { debug: 0 }; +const { getter: getData, setter: setData } = useSessionStorage<LocalData>('dao-game', { id: undefined, peerId: undefined }); +const otherMouseElement = document.getElementById('other-mouse'); +const shareContainer = document.getElementById('share-container'); +const shareLinkButton = document.getElementById('share-link'); +const openInNewTab = (url: string) => { + const openInSystemDefault = false; + if (openInSystemDefault) { + window.open(`microsoft-edge:${url}`, '_blank'); + } else { + // opening in the same browser causes a copy of the session storage, + // so we clear the session storage, open the url, and restore the session storage + const data = getData(); + sessionStorage.clear(); + try { + window.open(url, '_blank'); + } finally { + setData(data); + } + } +}; +const minString = (a: string, b: string): string => (a < b ? a : b); +const maxString = (a: string, b: string): string => (a > b ? a : b); +const getFirstPlayerId = (playerId1: string, playerId2: string) => { + const hash1 = generateHash(playerId1); + const hash2 = generateHash(playerId2); + const prod = hash1 * hash2; + const isEven = prod % 2 == 0; + const id = isEven ? minString(playerId1, playerId2) : maxString(playerId1, playerId2); + return id; +}; +const dropFileExtension = (path: string) => { + const lastSlashIndex = path.lastIndexOf('/'); + const dirName = path.substring(0, lastSlashIndex); + return dirName; +} + +export class PeerGame extends LocalGame { + public conn: Peer.DataConnection; + public selfId: string; + public constructor(player1Id: string, player1Name: string, player2Id: string, player2Name: string, selfId: string, conn: Peer.DataConnection) { + super(player1Id, player1Name, player2Id, player2Name); + this.selfId = selfId; + this.conn = conn; + } + public override setActiveUi(): void { + const { currentPlayerId, selfId } = this; + + const isLocalTurn = currentPlayerId === selfId; + if (isLocalTurn) { + super.setActiveUi(); + } + } + public override makeMove(source: Coord, dest: Coord): Promise<MoveResult> { + const result = super.makeMove(source, dest); + const peerData: PeerData = { move: { source, dest, playerId: this.currentPlayerId } }; + this.conn.send(JSON.stringify(peerData)); + return result; + } + public override async handleMoveResult(result: MoveResult): Promise<void> { + const { isTerminal, message } = result; + if (result.isTerminal) { + await showModal(message); + sessionStorage.clear(); + const { protocol, host, pathname } = location; + const dir = dropFileExtension(pathname); + const newUrl = `${protocol}//${host}${dir}` + document.location = newUrl; + } else { + this.resetUi(); + this.setActiveUi(); + } + } + + public static async createPeerGame(): Promise<PeerGame> { + let { id, peerId } = getData(); + const main = document.getElementsByTagName('main')[0]; + let game: PeerGame = undefined; + + const handleConn = (conn: Peer.DataConnection) => { + if (game !== undefined) { + conn.close(); + return; + } + + shareContainer.classList.add('invisible'); + + id = peer.id; + peerId = conn.peer; + setData({ id, peerId }); + + const isLocalP1 = getFirstPlayerId(id, peerId) === id; + game = isLocalP1 ? + new PeerGame(id, "Local", peerId, "Remote", id, conn) : + new PeerGame(peerId, "Remote", id, "Local", id, conn); + game.resetUi(); + game.setActiveUi(); + main.classList.remove('invisible'); + + const table = document.getElementById('table'); + table.addEventListener('mousemove', (ev: MouseEvent) => { + const xTable = ev.clientX - table.offsetLeft; + const yTable = ev.clientY - table.offsetTop; + const xRatio = xTable / table.clientWidth; + const yRatio = yTable / table.clientHeight; + const peerData: PeerData = { mousePos: { x: xRatio, y: yRatio } }; + conn.send(JSON.stringify(peerData)); + }); + table.addEventListener('mouseleave', () => { + const peerData: PeerData = { mousePos: { x: -1, y: -1 } }; + conn.send(JSON.stringify(peerData)); + }); + + let lastTimerId = undefined; + conn.on('data', (data: string) => { + const peerData = JSON.parse(data) as PeerData; + const { mousePos, move } = peerData; + + if (mousePos) { + const isValidPos = mousePos.x !== -1 && mousePos.y !== -1; + if (isValidPos) { + const xRatio = mousePos.x; + const yRatio = mousePos.y; + const xTable = xRatio * table.clientWidth; + const yTable = yRatio * table.clientHeight; + const x = table.offsetLeft + xTable; + const y = table.offsetTop + yTable; + const style = `left:${x - otherMouseElement.clientWidth / 2}px;top:${y}px;display:block;`; + otherMouseElement.setAttribute('style', style); + if (lastTimerId !== undefined) { + clearTimeout(lastTimerId); + } + lastTimerId = setTimeout(() => { + otherMouseElement.setAttribute('style', ''); + lastTimerId = undefined; + }, 3000); + } else if (lastTimerId !== undefined) { + otherMouseElement.setAttribute('style', ''); + } + } + + const isWaitingForRemoteMove = peerId === game.currentPlayerId; + if (isWaitingForRemoteMove && move) { + const isMoveLegal = game.getPiece(move.source) === game.currentPlayerId && game.getPiece(move.dest) === null; + if (isMoveLegal) { + game.makeMove(move.source, move.dest); + } else { + console.log({ isMoveLegal, move }); + } + } + }); + }; + + const { host, protocol, pathname, search } = location; + const queryParams = new URLSearchParams(search); + + const peer = id === undefined ? new Peer(peerOptions) : new Peer(id, peerOptions); + window.onunload = () => peer.destroy(); + peer.on('connection', (conn) => handleConn(conn)); + peer.on('error', (err) => console.error({ err })); + peer.on('open', (trueId) => { + id = trueId; + peerId = queryParams.get('peerId') ?? peerId; + setData({ id, peerId }); + + if (peerId) { + console.log(`${id} connecting to peer "${peerId}"`); + const conn = peer.connect(peerId); + handleConn(conn); + } else { + queryParams.set('peerId', id); + const newSearch = queryParams.toString(); + const newUrl = `${protocol}//${host}${pathname}?${newSearch}`; + shareLinkButton.onclick = () => { + navigator.clipboard.writeText(newUrl); + showSnackbar('Url copied to clipboard'); + }; + shareContainer.classList.remove('invisible'); + // openInNewTab(newUrl); + } + }); + + while (game === undefined) { + console.log('Waiting to connect to peer'); + await delay(500); + } + return game; + } +} + +export const playGame = async () => { + const game = await PeerGame.createPeerGame(); +}; diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..d05cd5c --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,27 @@ +export interface StorageInterface<T> { + getter: () => T; + setter: (data: T) => void; +} +export const useSessionStorage = <T>( + key: string, + defaultData?: T +): StorageInterface<T> => { + return { + getter: () => { + let item = sessionStorage.getItem(key); + if (item === null && defaultData !== undefined) { + item = JSON.stringify(defaultData); + sessionStorage.setItem(key, item); + } + const data = JSON.parse(item) as T; + return data; + }, + setter: (data: T) => { + const text = JSON.stringify(data as T); + console.log(`Saving data to local storage`); + console.log(`Key=${key}`); + console.log(`Text=${text}`); + sessionStorage.setItem(key, text); + }, + }; +}; diff --git a/src/types.ts b/src/types.ts index 9ba103b..9dfa8e0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,103 +1,64 @@ export class Coord { - public row: number; - public col: number; + public row: number; + public col: number; - constructor(row: number, col: number) { - this.row = row; - this.col = col; - } + constructor(row: number, col: number) { + this.row = row; + this.col = col; + } - public add(coord: Coord): Coord { - return new Coord(this.row + coord.row, this.col + coord.col); - } + public add(coord: Coord): Coord { + return new Coord(this.row + coord.row, this.col + coord.col); + } - sub(coord: Coord): Coord { - return new Coord(this.row - coord.row, this.col - coord.col); - } + sub(coord: Coord): Coord { + return new Coord(this.row - coord.row, this.col - coord.col); + } - equals(otherCoord: Coord): boolean { - const areEqual = (this.col === otherCoord.col && this.row === otherCoord.row); - return areEqual; - } + /** + * Finds if otherCoord is contained within a rectangle that sits between origin and this Coord + * @param otherCoord + */ + contains(otherCoord: Coord): boolean { + const xRatio = otherCoord.col / this.col; + const yRatio = otherCoord.row / this.row; + const xyInsideSquare = xRatio >= 0 && xRatio < 1 && yRatio >= 0 && yRatio < 1; + return xyInsideSquare; + } - // clock face directions - static get allDirections(): Coord[] { - const allDirections = [ - new Coord(1, 0), // up - new Coord(1, 1), // up-right - new Coord(0, 1), // right - new Coord(-1, 1), // right-down - new Coord(-1, 0), // down - new Coord(-1, -1), // down-left - new Coord(0, -1), // left - new Coord(1, -1), // left-up - ] - return allDirections; - } + equals(otherCoord: Coord): boolean { + const areEqual = this.col === otherCoord.col && this.row === otherCoord.row; + return areEqual; + } + // clock face directions + static get allDirections(): Coord[] { + const allDirections = [ + new Coord(1, 0), // up + new Coord(1, 1), // up-right + new Coord(0, 1), // right + new Coord(-1, 1), // right-down + new Coord(-1, 0), // down + new Coord(-1, -1), // down-left + new Coord(0, -1), // left + new Coord(1, -1), // left-up + ]; + return allDirections; + } } export class Move { - public sourceIndex: number; - public destIndex: number; - constructor(sourceIndex: number, destIndex: number) { - this.sourceIndex = sourceIndex; - this.destIndex = destIndex; - } + public source: Coord; + public dest: Coord; + public playerId: string; + constructor(playerId: string, source: Coord, dest: Coord) { + this.source = source; + this.dest = dest; + this.playerId = playerId; + } } -interface Listener { - element: Element; - type: string; - listener: (event: Event) => void; +export interface MoveResult { + isTerminal: boolean; + message?: string; } - -export class ListenerManager { - private listeners: Listener[] = []; - - public addListener(element: Element, type: string, listener: (event: Event) => void): void { - const { listeners } = this; - element.addEventListener(type, listener); - listeners.push({ element, type, listener }); - } - - public removeListeners() { - const { listeners } = this; - listeners.forEach(value => { - const { element, type, listener } = value; - element.removeEventListener(type, listener); - }); - listeners.length = 0; - } -} - -export class GameBoard { - public board: number[]; - public rows: number; - public cols: number; - // testBoard: [ - // 4, null, null, null, - // null, 0, 1, 5, - // null, 2, 3, null, - // 6, null, null, 7 - // ], - public constructor() { - this.board = [ - 0, null, null, 4, - null, 1, 5, null, - null, 6, 2, null, - 7, null, null, 3 - ]; - this.rows = 4; - this.cols = 4; - } - public coordToBoardIndex({ row, col }: Coord): number { - const index = row * 4 + col; - return index; - } - public boardIndexToCoord(index: number): Coord { - const col = index % 4; - const row = Math.floor(index / 4); - return new Coord(row, col); - } -} \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..b97d40e --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,32 @@ +export function generateHash(text: string) { + var hash = 0; + if (text.length == 0) { + return hash; + } + for (let i = 0; i < text.length; i++) { + var charCode = text.charCodeAt(i); + hash = (hash << 7) - hash + charCode; + hash = hash & hash; + } + return hash; +} + +export async function delay(delayInMs: number) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(2); + }, delayInMs); + }); +} + +export function showSnackbar(text) { + // Get the snackbar DIV + var x = document.getElementById("snackbar"); + + // Add the "show" class to DIV + x.className = "show"; + x.innerText = text; + + // After 3 seconds, remove the show class from DIV + setTimeout(function () { x.className = x.className.replace("show", ""); }, 3000); +} \ No newline at end of file diff --git a/tools/esbuild-build-and-serve.ts b/tools/esbuild-build-and-serve.ts index 3be8fb1..144ba95 100644 --- a/tools/esbuild-build-and-serve.ts +++ b/tools/esbuild-build-and-serve.ts @@ -1,44 +1,34 @@ -import childProcess from 'child_process'; import esbuild from 'esbuild'; import http from 'http'; +import { serveOptions, buildOptions, hostPort } from './esbuild-settings' -const hostPort = 3000; +esbuild.serve(serveOptions, buildOptions).then((result) => { + const { host, port } = result; + http + .createServer((req, res) => { + const options: http.RequestOptions = { + hostname: host, + port: port, + path: req.url, + method: req.method, + headers: req.headers, + }; + const proxyReq = http.request(options, (proxyRes) => { + if (proxyRes.statusCode === 404) { + res.writeHead(404, { 'Content-Type': 'text/html' }); + res.end('<h1>A custom 404 page</h1>'); + return; + } -const serveOptions: esbuild.ServeOptions = { - servedir: 'public' -}; -const buildOptions: esbuild.BuildOptions = { - entryPoints: ['src/game.ts'], - bundle: true, - outfile: 'public/bundle.js', - sourcemap: true -}; -esbuild.serve(serveOptions, buildOptions) - .then(result => { - const { host, port } = result; - http - .createServer((req, res) => { - const options: http.RequestOptions = { - hostname: host, - port: port, - path: req.url, - method: req.method, - headers: req.headers - }; - const proxyReq = http.request(options, proxyRes => { - if (proxyRes.statusCode === 404) { - res.writeHead(404, { 'Content-Type': 'text/html' }); - res.end('<h1>A custom 404 page</h1>'); - return; - } + res.writeHead(proxyRes.statusCode!, proxyRes.headers); + proxyRes.pipe(res, { end: true }); + }); - res.writeHead(proxyRes.statusCode!, proxyRes.headers); - proxyRes.pipe(res, { end: true }); - }); - - req.pipe(proxyReq, { end: true }); - }) - .listen(hostPort, () => { - childProcess.exec(`start http://localhost:${hostPort}/`); - }); - }); \ No newline at end of file + req.pipe(proxyReq, { end: true }); + }) + .listen(hostPort, () => { + const url = `http://localhost:${hostPort}/`; + // childProcess.exec(`start ${url}`); + console.log(`Server serving at ${url}`); + }); +}); diff --git a/tools/esbuild-settings.ts b/tools/esbuild-settings.ts new file mode 100644 index 0000000..d6c5afd --- /dev/null +++ b/tools/esbuild-settings.ts @@ -0,0 +1,13 @@ +import esbuild from 'esbuild'; + +export const hostPort = 3000; + +export const serveOptions: esbuild.ServeOptions = { + servedir: 'public', +}; +export const buildOptions: esbuild.BuildOptions = { + entryPoints: ['src/local.ts', 'src/online.ts'], + bundle: true, + outdir: 'public/js', + sourcemap: true, +}; diff --git a/tools/esbuild-watch.ts b/tools/esbuild-watch.ts index 602f1c3..e37f236 100644 --- a/tools/esbuild-watch.ts +++ b/tools/esbuild-watch.ts @@ -1,8 +1,4 @@ import esbuild from 'esbuild'; +import { buildOptions } from './esbuild-settings' -esbuild.build({ - entryPoints: ['src/game.ts'], - bundle: true, - outfile: 'public/bundle.js', - watch: true, -}).catch(() => process.exit(1)) \ No newline at end of file +esbuild.build(buildOptions).catch(() => process.exit(1)) \ No newline at end of file diff --git a/tools/esbuild.ts b/tools/esbuild.ts index 24ef6db..e9dce4e 100644 --- a/tools/esbuild.ts +++ b/tools/esbuild.ts @@ -1,7 +1,4 @@ import esbuild from 'esbuild'; +import { serveOptions, buildOptions, hostPort } from './esbuild-settings' -esbuild.build({ - entryPoints: ['src/game.ts'], - bundle: true, - outfile: 'public/bundle.js', -}).catch(() => process.exit(1)) \ No newline at end of file +esbuild.build(buildOptions).catch(() => process.exit(1)) \ No newline at end of file