├── .gitignore ├── README.md ├── build.sh ├── client ├── .yalc │ └── webpack-rollup-ts-compiler │ │ ├── README.md │ │ ├── package.json │ │ └── yalc.sig ├── PostBuild.js ├── config-overrides.js ├── globals.d.ts ├── package.json ├── public │ ├── favicon.svg │ ├── index.html │ └── robots.txt ├── src │ ├── Application.tsx │ ├── components │ │ ├── Banner │ │ │ ├── Banner.module.scss │ │ │ ├── Banner.tsx │ │ │ ├── sad.svg │ │ │ ├── sailor.svg │ │ │ └── turtle.svg │ │ ├── Border │ │ │ ├── Border.module.scss │ │ │ └── Border.tsx │ │ ├── Button │ │ │ ├── Button.module.scss │ │ │ ├── Button.tsx │ │ │ └── button-46.mp3 │ │ ├── Counter │ │ │ └── Counter.tsx │ │ ├── DemoField │ │ │ └── DemoField.tsx │ │ ├── Field │ │ │ ├── Field.module.scss │ │ │ ├── Field.tsx │ │ │ ├── c1.png │ │ │ ├── c2.png │ │ │ ├── c3.png │ │ │ ├── h1.png │ │ │ └── h2.png │ │ ├── Layout │ │ │ ├── Layout.module.scss │ │ │ └── Layout.tsx │ │ ├── Modal │ │ │ ├── Modal.module.scss │ │ │ └── Modal.tsx │ │ └── ThreeDots │ │ │ ├── ThreeDots.module.scss │ │ │ └── ThreeDots.tsx │ ├── fonts │ │ ├── FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8w88PXVh (1).woff2 │ │ ├── FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8w88PXVh.woff2 │ │ ├── FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8wU8PXVh (1).woff2 │ │ ├── FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8wU8PXVh.woff2 │ │ ├── FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8wY8PXVh (1).woff2 │ │ ├── FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8wY8PXVh.woff2 │ │ ├── FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8ws8PQ (1).woff2 │ │ └── FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8ws8PQ.woff2 │ ├── index.module.scss │ ├── index.tsx │ ├── manifest.ts │ ├── react-app-env.d.ts │ ├── serviceWorker.ts │ ├── utils │ │ ├── Socket.ts │ │ ├── TelegramApi.ts │ │ ├── beep.mp3 │ │ ├── formatTime.ts │ │ ├── generateGrid.ts │ │ ├── i18n.ts │ │ ├── inviteId.ts │ │ ├── persistentUserId.ts │ │ ├── playSound.ts │ │ ├── plural.ts │ │ ├── selfUpdate.ts │ │ ├── tempUserId.ts │ │ ├── useGameState.ts │ │ └── userLocale.ts │ └── views │ │ ├── Battle │ │ ├── Battle.module.scss │ │ ├── Battle.tsx │ │ ├── hit1.mp3 │ │ ├── hit2.mp3 │ │ ├── hit3.mp3 │ │ ├── hit4.mp3 │ │ └── miss.mp3 │ │ ├── GameOver │ │ ├── GameOver.module.scss │ │ └── GameOver.tsx │ │ ├── PlaceShips │ │ ├── PlaceShips.module.scss │ │ └── PlaceShips.tsx │ │ ├── Replay │ │ ├── Replay.module.scss │ │ └── Replay.tsx │ │ └── WaitForContact │ │ ├── WaitForContact.module.scss │ │ └── WaitForContact.tsx └── tsconfig.json ├── server ├── makeBundle.js ├── nodemon.json ├── package.json ├── pm2.json ├── src │ ├── Telegram.ts │ ├── formatText.ts │ └── index.ts └── tsconfig.json └── shared ├── CellType.ts ├── ClientToServerEvents.ts ├── GameState.ts ├── Map.ts ├── ServerToClientEvents.ts ├── Settings.ts ├── ShotResult.ts ├── generateUniqueId.ts ├── getRandomInt.ts └── mapUtils.ts /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .yalc 3 | yalc.lock 4 | node_modules 5 | package-lock.json 6 | yarn.lock 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 💜If you like my projects, you can support me. 2 | 3 | | Coin/Symbol | Network | Adress | 4 | |------|---------|--------| 5 | | Bitcoin (BTC) | BTC | 1LU7DtLiKkWe3aAXbhRhNAMUFdrapWuAHW | 6 | | Tether (USDT) | TRC20 | TK7f7TXozWVbkHxdArAJd2rELu725q1Ac5 | 7 | | Tether (USDT) | TON | UQDI4e7xm_B7O_REaYd5CoyEz1Ki08t0EPlUim022_K9B2xa | 8 | 9 | # Naval Clash Game - Telegram Mini App 10 | 11 | 🎉🎉🎉 [**1st PLACE – $1,500**](https://t.me/contest/342) 🎉🎉🎉 12 | 13 | ![Новый проект (7)](https://github.com/angrycoding/naval_clash_bot/assets/895042/ae7f987a-13fd-4fd2-be2c-2cc2dedb27cf) 14 | 15 | 16 | Naval Clash a beloved childhood game for everyone. 17 | 18 | Play now here: https://t.me/naval_clash_bot/play 19 | 20 | Author: https://www.linkedin.com/in/ruslanmatveev/ 21 | 22 | ![output](https://github.com/angrycoding/naval_clash_bot/assets/895042/09038a85-48cc-4c53-b3e8-a64f45f0191c) 23 | 24 | 25 | Just in case if you have no idea what it is, then [here is some description](https://www.thesprucecrafts.com/the-basic-rules-of-battleship-411069) 26 | 27 | 28 | ## Project structure, running, building 29 | 30 | Project is separated in two folders: **client** where all client stuff lives obviously and **server** for the server side. Client and server both written in TypeScript. Server side code is reusing 31 | some parts of the client side code (i.e. shared code). Before starting developing, make sure that you check out the repo first: 32 | 33 | ``` 34 | git clone git@github.com:angrycoding/naval_clash_bot.git 35 | ``` 36 | 37 | ### Setting up bot token and webhook url 38 | 39 | Before running backend side, make sure that you set **telegramBotToken** and **telegramWebhookUrl** in [Settings.ts](https://github.com/angrycoding/naval_clash_bot/blob/main/client/src/Settings.ts), 40 | otherwise bot won't start. 41 | 42 | ### Starting the client 43 | 44 | ``` 45 | cd client 46 | yarn 47 | yarn start 48 | ``` 49 | 50 | Client side will start on port 3000, so now you should be able to open it in your web-browser (http://localhost:3000/) 51 | 52 | 53 | ### Building the client 54 | 55 | In order to produce client's production build just run: 56 | 57 | ``` 58 | cd client 59 | yarn 60 | yarn build 61 | ``` 62 | 63 | Now just go to **client/dist** folder and see all build artefacts. 64 | 65 | 66 | ### Starting the server 67 | 68 | Starting the server is also pretty simple: 69 | 70 | ``` 71 | cd server 72 | yarn 73 | yarn start 74 | ``` 75 | 76 | This will start watching **server/src** folder and will recompile / restart backend whenever some change is made. By default server will start on port 3495, but you can adjust it 77 | if you change **socketIoPort** [here](https://github.com/angrycoding/naval_clash_bot/blob/main/client/src/Settings.ts). 78 | 79 | 80 | ### Building the server 81 | 82 | Just go to **server** directory and run: 83 | 84 | ``` 85 | cd server 86 | yarn 87 | yarn build 88 | ``` 89 | 90 | This will compile all TypeScript files located in **server/src** and will produce one single bundle in **server/dist** (index.js) that you can run on your own dedicated server. This will also create 91 | **server/dist/pm2.json** file that you can use in combination with [PM2 process manager](https://pm2.keymetrics.io/), but of course feel free to run it manually if you wish so. 92 | 93 | 94 | ### Building client and server altogether 95 | 96 | There is pretty useful script that will let you to build client and server altogether at once, [check it out](https://github.com/angrycoding/naval_clash_bot/blob/main/build.sh): 97 | 98 | ``` 99 | ./build.sh 100 | ``` 101 | 102 | (Make sure that you install all the dependencies first before running it) 103 | This will run client build + server build and put everything into **dist** folder in the project root. 104 | 105 | 106 | ### Note about setting it up on real server 107 | 108 | Usually when it comes to the point when you need to deploy such application on server then you have two friends: [NGINX](https://www.nginx.com/) and [PM2 process manager](https://pm2.keymetrics.io/) 109 | First will handle all content serving, second will make sure that your server is running well and restart it in case if something goes wrong. That's why this project doesn't contain any [Express](https://expressjs.com/) server 110 | or anything like that, also you won't find any SSL stuff here, cause usually in real life you would handle it separately and externally. Also that's exactly the reason why server side is not starting on some port like 80 or 443, 111 | but instead uses some random port numbers. For my setup I just use combination of **NGINX** and **PM2** drop all stuff produced by **build.sh** into one folder on my server. But just in case if you're curious then my **nginx.conf** looks like this: 112 | 113 | ``` 114 | worker_processes auto; 115 | user root; 116 | 117 | events { 118 | worker_connections 8000; 119 | multi_accept on; 120 | } 121 | 122 | http { 123 | 124 | server_names_hash_bucket_size 64; 125 | include /etc/nginx/mime.types; 126 | 127 | server { 128 | ssl_certificate /etc/letsencrypt/fullchain.pem; 129 | ssl_certificate_key /etc/letsencrypt/privkey.pem; 130 | ssl_trusted_certificate /etc/letsencrypt/fullchain.pem; 131 | listen 443 ssl; 132 | 133 | # make sure that you use correct host name 134 | server_name naval_clash_bot.com; 135 | 136 | # directory where I drop all build 137 | root /root/naval_clash_bot; 138 | 139 | index index.html; 140 | error_page 404 https://naval_clash_bot.com/; 141 | 142 | # connect with api 143 | location /api/ { 144 | proxy_set_header X-Real-IP $remote_addr; 145 | # use port number that you run your backend part on 146 | proxy_pass http://127.0.0.1:3495; 147 | } 148 | 149 | } 150 | 151 | } 152 | ``` 153 | 154 | But of course, if you find all this too complicated, then you can stil do it your own way. 155 | 156 | 157 | ## Setup from Telegram side 158 | 159 | Here is what you have to do in order to recreate something similar: 160 | 161 | 1. Contact [BotFather bot](https://t.me/BotFather) and ask him to create new bot. 162 | 2. After that, open bot's menu and choose **/newapp** 163 | 3. You'll be asked to choose the bot that you wan't to bind this new app with. Choose the one that you've just created on step 1. 164 | 4. After few more questions, you'll be asked to give app url, that's most important point. Give it a url where you host your app. 165 | 5. At the end you'll get a link that looks like [https://t.me/naval_clash_bot/play](https://t.me/naval_clash_bot/play) where **naval_clash_bot** is the name of your bot, and **app** is the name of your app. 166 | 167 | 168 | ## Client overview 169 | 170 | From the client side perspective of view it's just [TypeScript](https://www.typescriptlang.org/) + [React](https://react.dev/) + [Socket.IO](https://socket.io/). 171 | Project is based on standard [Create React App template](https://create-react-app.dev/docs/adding-typescript/) in it's TypeScript variation. In order to customize 172 | standard Create React App setup without ["ejecting" it](https://create-react-app.dev/docs/available-scripts/) I use [react-app-rewired](https://github.com/timarney/react-app-rewired) and 173 | [customize cra](https://github.com/arackaf/customize-cra). This allows me to hack into webpack configuration without loosing ability to update create react app template in the future (see 174 | custom configuration [here](https://github.com/angrycoding/naval_clash_bot/blob/main/client/config-overrides.js)). 175 | From the CSS perspective of view, project uses [Sass modules](https://sass-lang.com/documentation/modules/). So no rocket science here, just couple of well-known libraries along with React. 176 | 177 | In order to make use **Telegram Mini App** platform features ([see full documentation here](https://core.telegram.org/bots/webapps)) there are few wrappers made (you can find them [here](https://github.com/angrycoding/naval_clash_bot/blob/main/client/src/utils/TelegramApi.ts)). 178 | 179 | 180 | ### Responsive design 181 | 182 | One interesting thing that I'd like to mention here is **page responsiveness**. So we've got **Telegram Mini Apps platform** and by using it you can expect that your app will run on any device with basically any resolution. Usual solution here is to use so called "breakpoints". Here I decided to take a bit different approach: [CSS Container queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_container_queries). In simple words it introduces new CSS units: 183 | 184 | - cqw: 1% of a query container's width 185 | - cqh: 1% of a query container's height 186 | - cqi: 1% of a query container's inline size 187 | - cqb: 1% of a query container's block size 188 | - cqmin: The smaller value of either cqi or cqb 189 | - cqmax: The larger value of either cqi or cqb 190 | 191 | And you can apply this units to anything, like element size, font size, border size and so on. So instead of changing page look discretely using traditional breakpoints, I use this new CSS units so interfaces kind of scales and adjusts to any resolution. Check this out: 192 | 193 | https://github.com/angrycoding/naval_clash_bot/assets/895042/3408e8d6-e35c-4f43-9181-2a67808e57c0 194 | 195 | [And here is the YouTube link](https://youtu.be/jMPWXFHTvCI) in case if above's doesn't open for you. 196 | 197 | 198 | ## Server overview 199 | 200 | Server side code is written using [TypeScript](https://www.typescriptlang.org/), in order to run it locally (**only locally**, never do it on production, because it's very inefficient performance wise), I use [nodemon](https://nodemon.io/) in combination with the TypeScript compliler itself it gives us very simple way to compile all typescript stuff automatically when you change something in your sources. Besides that most notable part for backend is [Socket.IO](https://socket.io/), but that's obvious since it's already mentioned that it's used on the front-end side. So again, no rocket science here, just get yourself familiar with it by running it locally. Also check [makeBundle.js](https://github.com/angrycoding/naval_clash_bot/blob/main/server/makeBundle.js) that is responsible for producing "production" bundle using [Browserify](https://browserify.org/). 201 | 202 | 203 | ## Some suggestions on how Telegram Mini App platform could be improved 204 | 205 | Here is the list of suggestions that I'd like to share with **Telegram team** in order to improve the platform (IMHO of course): 206 | 207 | - Missing [Navigation.share API](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/share). This thing could possibly let front-end developers to share some content from within the Mini App without having to close the window. 208 | - Using [manifest.json](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json), there is already a thing called PWA (stands for Progressive Web Apps) and it uses manifest.json in order to adjust it's [settings](https://developer.mozilla.org/en-US/docs/Web/Manifest). This could potentially reduce the gap between building Telegram platform apps and PWA. 209 | - Besides that in manifest.json you can set preferred screen orientation (which is most of the time should be set to portrait IMHO), there is also one more [missing api](https://developer.mozilla.org/en-US/docs/Web/API/ScreenOrientation/lock) 210 | - Not sure why (maybe it was made on purpose) but [requestFullscreen](https://developer.mozilla.org/en-US/docs/Web/API/Element/requestFullscreen) 211 | that allows developer to lock screen orientation in either portrait or landscape mode. It's important because for some of the applications it doesn't really make any sense to run in landscape for instance (cause it's just becomes too small). 212 | - There are no methods in Telegram Mini App API that could give developers more freedom on adjusting the applications's window. For example: hide title bar, ajust title bar (what if I'd like to provide localized title?). 213 | - Very strange? support on linux. Window has fixed size no matter what I do, but maybe it's just a problem with my OS. 214 | - Ability to create app without binding it to bot. I believe that for some of the apps this link can be useful but on the other hand, for some it's just useless. It's like you can create WebApp using BotFather, but why do you have to connect it to bot in case if your app doesn't have any functionality that could potentially be dedicated to the bot. 215 | - Ability to somehow disable this "minifying" / drawer thing, that allows your app to be shown on this small window. For some of the apps this can be useful while for the others it looks like this: 216 | 217 | ![Новый проект (3)](https://github.com/angrycoding/naval_clash_bot/assets/895042/65f4aa49-b88b-49d8-b11e-e4d3064f2961) 218 | 219 | But if you think that's already too small, then you're wrong, because it can get even smaller in case if you open your Mini App from bot's interface menu (cause then input bar will be added at bottom which is also takes additional space). 220 | 221 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd ./server 4 | yarn build 5 | 6 | cd ../client 7 | yarn build 8 | 9 | cd .. 10 | 11 | rm -rf ./dist 12 | mkdir dist 13 | mkdir dist/static 14 | 15 | cp -r ./server/dist/* ./dist 16 | cp -r ./client/dist/* ./dist/static -------------------------------------------------------------------------------- /client/.yalc/webpack-rollup-ts-compiler/README.md: -------------------------------------------------------------------------------- 1 | # webpack-rollup-ts-compiler 2 | webpack-rollup-ts-compiler 3 | -------------------------------------------------------------------------------- /client/.yalc/webpack-rollup-ts-compiler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack-rollup-ts-compiler", 3 | "version": "0.0.1+b3203bfb", 4 | "description": "webpack-rollup-ts-compiler", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "license": "Apache-2.0", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/angrycoding/webpack-rollup-ts-compiler" 11 | }, 12 | "author": "Ruslan Matveev aka Angrycoding ", 13 | "files": [ 14 | "dist" 15 | ], 16 | "engines": { 17 | "node": ">=18.0.0" 18 | }, 19 | "scripts": { 20 | "build": "rm -rf dist && mkdir -p dist && yarn tsc", 21 | "prepublishOnly": "yarn build" 22 | }, 23 | "dependencies": { 24 | "rollup": "^4.1.4", 25 | "typescript": "^5.2.2" 26 | }, 27 | "yalcSig": "b3203bfb528d3fc7a1e3c1a68d690108" 28 | } 29 | -------------------------------------------------------------------------------- /client/.yalc/webpack-rollup-ts-compiler/yalc.sig: -------------------------------------------------------------------------------- 1 | b3203bfb528d3fc7a1e3c1a68d690108 -------------------------------------------------------------------------------- /client/PostBuild.js: -------------------------------------------------------------------------------- 1 | const 2 | Path = require('path'), 3 | CSSO = require('csso'), 4 | SVGO = require('svgo'), 5 | FS = require('fs/promises'), 6 | Cheerio = require('cheerio'), 7 | Crypto = require('crypto'), 8 | Terser = require('terser').minify, 9 | RecursiveReadDir = require('recursive-readdir') 10 | 11 | var svgo = new SVGO({ 12 | plugins: [ 13 | { 14 | cleanupIDs: false 15 | } 16 | ] 17 | }); 18 | 19 | 20 | const endsWith = (file, endings) => { 21 | return endings.some(ending => file.endsWith(ending)); 22 | } 23 | 24 | const getFiles = (dirPath, extensions) => new Promise(resolve => { 25 | RecursiveReadDir(dirPath, [(file, stats) => { 26 | return stats.isFile() && (extensions && !extensions.some(ext => file.endsWith(ext))); 27 | }], (error, files) => resolve(files)); 28 | }); 29 | 30 | const createPreload = async(distDir) => { 31 | 32 | const indexPath = Path.resolve(distDir, 'index.html'); 33 | const indexBundle = await FS.readFile(indexPath, 'utf-8'); 34 | 35 | let files = await getFiles(distDir, [".svg", ".woff2"]); 36 | files = files.map((file) => Path.relative(distDir, file)); 37 | var $ = Cheerio.load(indexBundle, { decodeEntities: false }); 38 | for (const file of files) { 39 | console.info("[ createPreload ]", file); 40 | 41 | if (file.endsWith('.woff2')) { 42 | $("head").prepend( 43 | `` 44 | ); 45 | } 46 | 47 | else if (file.endsWith('.svg')) { 48 | $("head").prepend( 49 | `` 50 | ); 51 | } 52 | 53 | 54 | 55 | } 56 | 57 | await FS.writeFile(indexPath, $.html()); 58 | 59 | }; 60 | 61 | const minifyCSS = (path) => new Promise(async(resolve) => { 62 | const data = await FS.readFile(path, 'utf-8'); 63 | if (typeof data !== 'string' || !data.length) return resolve(); 64 | let result = CSSO.minify(data, { comments: false }).css; 65 | if (typeof result !== 'string') result = ''; 66 | if (!result.length || result.length >= data.length) return resolve(); 67 | console.info('[ COMPRESS ]', path, `${data.length}b -> ${result.length}b`); 68 | await FS.writeFile(path, result); 69 | resolve(); 70 | }); 71 | 72 | const minifySVG = (path) => new Promise(async(resolve) => { 73 | const data = await FS.readFile(path, 'utf-8'); 74 | if (typeof data !== 'string' || !data.length) return resolve(); 75 | let result = (await svgo.optimize(data, {path: path})).data; 76 | if (typeof result !== 'string') result = ''; 77 | if (!result.length || result.length >= data.length) return resolve(); 78 | console.info('[ COMPRESS ]', path, `${data.length}b -> ${result.length}b`); 79 | await FS.writeFile(path, result); 80 | resolve(); 81 | }); 82 | 83 | const minifyJS = (path) => new Promise(async(resolve) => { 84 | const data = await FS.readFile(path, 'utf-8'); 85 | if (typeof data !== 'string' || !data.length) return resolve(); 86 | let result = ''; 87 | try { 88 | 89 | result = (await Terser(data, { 90 | toplevel: true, 91 | compress: { 92 | pure_getters: true, 93 | unused: true, 94 | dead_code: true, 95 | drop_console: true, 96 | passes: 4, 97 | toplevel: true, 98 | }, 99 | mangle: { 100 | toplevel: true, 101 | } 102 | }))?.code; 103 | } catch (e) { 104 | console.info('ERROR?'); 105 | console.info(e); 106 | return; 107 | } 108 | if (typeof result !== 'string') result = ''; 109 | if (result.length >= data.length) return resolve(); 110 | console.info('[ COMPRESS ]', path, `${data.length}b -> ${result.length}b`); 111 | await FS.writeFile(path, result); 112 | resolve(); 113 | }); 114 | 115 | const getFileHash = (path) => new Promise(resolve => { 116 | const hash = Crypto.createHash('sha1'); 117 | const stream = require('fs').createReadStream(path); 118 | stream.on('error', () => resolve('')); 119 | stream.on('data', chunk => hash.update(chunk)); 120 | stream.on('end', () => resolve(hash.digest('hex'))); 121 | }); 122 | 123 | class PostBuild { 124 | 125 | constructor(distDir) { 126 | this.distDir = distDir; 127 | } 128 | 129 | apply(compiler) { 130 | 131 | compiler.hooks.afterEmit.tapAsync("PostBuild", async (compilation, callback) => { 132 | 133 | const { distDir } = this; 134 | 135 | await createPreload(distDir); 136 | 137 | for (const file of await getFiles(distDir)) { 138 | 139 | if (0); 140 | 141 | if (endsWith(file, ['.json'])) { 142 | console.info('[ COMPRESS ]', file); 143 | let data = await FS.readFile(file, 'utf-8'); 144 | try { data = JSON.parse(data); } catch (e) {} 145 | await FS.writeFile(file, JSON.stringify(data)); 146 | } 147 | 148 | else if (endsWith(file, [".map", ".LICENSE.txt"])) { 149 | console.info('[ REMOVE ]', file); 150 | await FS.unlink(file); 151 | } 152 | 153 | else if (endsWith(file, ['.css'])) { 154 | await minifyCSS(file); 155 | } 156 | 157 | else if (endsWith(file, ['.svg'])) { 158 | await minifySVG(file); 159 | } 160 | 161 | else if (endsWith(file, ['.js'])) { 162 | await minifyJS(file, this.globalDefs); 163 | } 164 | 165 | } 166 | 167 | const allFilesHash = {}; 168 | for (const file of await getFiles(distDir)) { 169 | let relPath = Path.relative(distDir, file); 170 | if (['sw.js', 'robots.txt', 'naval_clash_bot.json'].includes(relPath)) continue; 171 | if (relPath === 'index.html') relPath = ''; 172 | allFilesHash[`/${relPath}`] = await getFileHash(file); 173 | } 174 | 175 | await FS.writeFile(Path.resolve(distDir, "naval_clash_bot.json"), JSON.stringify(allFilesHash)); 176 | 177 | 178 | 179 | 180 | callback(); 181 | }); 182 | } 183 | } 184 | 185 | module.exports = PostBuild; -------------------------------------------------------------------------------- /client/config-overrides.js: -------------------------------------------------------------------------------- 1 | const 2 | Path = require('path'), 3 | PostBuild = require('./PostBuild'), 4 | TSCompilerPlugin = require('webpack-rollup-ts-compiler'), 5 | FontPreloadPlugin = require('webpack-font-preload-plugin'), 6 | MinimalClassnameGenerator = require('webpack-minimal-classnames'), 7 | { override, removeModuleScopePlugin, babelInclude } = require('customize-cra'); 8 | 9 | let DIST_DIR = ''; 10 | 11 | 12 | const isProduction = process.env.NODE_ENV === "production"; 13 | 14 | const generateMinimalClassname = MinimalClassnameGenerator({ 15 | length: 1, 16 | excludePatterns: [/ad/i] 17 | }) 18 | 19 | let settings = {}; 20 | 21 | module.exports = { 22 | 23 | paths: (paths) => { 24 | DIST_DIR = paths.appBuild; 25 | }, 26 | 27 | webpack: override((config) => { 28 | 29 | 30 | 31 | 32 | if (isProduction) { 33 | JSON.stringify(config, (_key, value) => { 34 | if (typeof value === 'object' && value && typeof value.loader === 'string' && 35 | value.loader.includes('css-loader') && value.options && value.options.modules) { 36 | value.options.modules.getLocalIdent = generateMinimalClassname; 37 | } 38 | return value; 39 | }) 40 | } 41 | 42 | 43 | 44 | 45 | config = removeModuleScopePlugin()(config); 46 | 47 | config = babelInclude()(config); 48 | 49 | if (isProduction) { 50 | delete config.devtool; 51 | } 52 | 53 | config.plugins = config.plugins.map((plugin) => { 54 | 55 | if (isProduction && plugin.constructor.name === 'WebpackManifestPlugin') { 56 | return undefined; 57 | } 58 | 59 | if (0); 60 | 61 | else if (plugin.constructor.name === "HtmlWebpackPlugin") { 62 | plugin = new plugin.constructor({ 63 | ...plugin.userOptions, 64 | cache: false, 65 | templateParameters: () => ({ settings }), 66 | isProduction, 67 | }); 68 | } 69 | 70 | else if (plugin.constructor.name === "DefinePlugin") { 71 | plugin = new plugin.constructor({ 72 | ...plugin.definitions, 73 | isProduction 74 | }); 75 | } 76 | 77 | return plugin; 78 | }) 79 | 80 | config.plugins.push(new TSCompilerPlugin( 81 | Path.resolve(__dirname, '../shared/Settings.ts'), { 82 | postprocess: (result) => { 83 | settings = new Function(`return ${result}`)() 84 | return {} 85 | } 86 | } 87 | )) 88 | 89 | 90 | config.plugins.push(new TSCompilerPlugin(Path.resolve(__dirname, 'src/serviceWorker.ts'), { 91 | to: 'sw.js', 92 | })) 93 | 94 | config.plugins.push(new TSCompilerPlugin(Path.resolve(__dirname, 'src/manifest.ts'), { 95 | to: 'manifest.json', 96 | postprocess: (manifest) => JSON.stringify(new Function(`return ${manifest}`)(), null, '\t') }, 97 | )); 98 | 99 | if (isProduction) { 100 | config.plugins.push(new PostBuild(DIST_DIR)); 101 | } else { 102 | config.plugins.push(new FontPreloadPlugin()); 103 | } 104 | 105 | 106 | return config; 107 | }) 108 | 109 | }; -------------------------------------------------------------------------------- /client/globals.d.ts: -------------------------------------------------------------------------------- 1 | // Fix TS error - import images as modules 2 | declare module '*.png'; 3 | declare module '*.svg'; 4 | declare module '*.jpg'; 5 | declare module '*.json'; 6 | declare module '*.mp3'; 7 | declare module '*.wav'; -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "naval_clash_bot", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "clsx": "^2.0.0", 7 | "react": "^18.2.0", 8 | "react-dom": "^18.2.0", 9 | "socket.io-client": "^4.7.2" 10 | }, 11 | "scripts": { 12 | "start": "BROWSER=none DISABLE_ESLINT_PLUGIN=true react-app-rewired start", 13 | "build": "BUILD_PATH=./dist react-app-rewired build", 14 | "serveDist": "npx http-server ./dist" 15 | }, 16 | "browserslist": { 17 | "production": [ 18 | ">0.2%", 19 | "not dead", 20 | "not op_mini all" 21 | ], 22 | "development": [ 23 | "last 1 chrome version", 24 | "last 1 firefox version", 25 | "last 1 safari version" 26 | ] 27 | }, 28 | "engines": { 29 | "node": ">=18.0.0" 30 | }, 31 | "devDependencies": { 32 | "@types/node": "^16.18.53", 33 | "@types/react": "^18.2.22", 34 | "@types/react-dom": "^18.2.7", 35 | "cheerio": "^1.0.0-rc.12", 36 | "csso": "^5.0.5", 37 | "customize-cra": "^1.0.0", 38 | "react-app-rewired": "^2.2.1", 39 | "react-scripts": "5.0.1", 40 | "recursive-readdir": "^2.2.3", 41 | "sass": "^1.68.0", 42 | "svgo": "1.3.2", 43 | "terser": "^5.21.0", 44 | "typescript": "^4.9.5", 45 | "webpack-font-preload-plugin": "^1.5.0", 46 | "webpack-minimal-classnames": "^2.1.1", 47 | "webpack-rollup-ts-compiler": "^0.0.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /client/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 37 | 39 | 40 | 42 | image/svg+xml 43 | 45 | 46 | 47 | 48 | 49 | 51 | 54 | 56 | 64 | 72 | 75 | 80 | 85 | 90 | 95 | 96 | 104 | 112 | 120 | 128 | 136 | 141 | 146 | 151 | 156 | 157 | 161 | 165 | 170 | 175 | 180 | 185 | 190 | 195 | 200 | 205 | 210 | 215 | 220 | 225 | 230 | 235 | 240 | 245 | 250 | 255 | 256 | 261 | 265 | 270 | 275 | 280 | 285 | 290 | 295 | 300 | 301 | 302 | 303 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= settings.siteTitle %> 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/src/Application.tsx: -------------------------------------------------------------------------------- 1 | import { GameStatus } from "../../shared/GameState"; 2 | import PlaceShips from "./views/PlaceShips/PlaceShips"; 3 | import Battle from "./views/Battle/Battle"; 4 | import Replay from "./views/Replay/Replay"; 5 | import GameOver from "./views/GameOver/GameOver"; 6 | import { useGameState } from "./utils/useGameState"; 7 | import WaitForContact from "./views/WaitForContact/WaitForContact"; 8 | 9 | const Application = () => { 10 | 11 | const gameState = useGameState(); 12 | 13 | if (!gameState.watchDog) { 14 | return 15 | } 16 | 17 | if (gameState.status === GameStatus.WAIT_FOR_CONTACT) { 18 | return 19 | } 20 | 21 | if (gameState.status === GameStatus.PLACESHIPS) { 22 | return ; 23 | } 24 | 25 | if (gameState.status === GameStatus.ACTIVE) { 26 | return 27 | } 28 | 29 | if (gameState.status === GameStatus.WAITING_FOR_REPLAY) { 30 | return 31 | } 32 | 33 | 34 | return ; 35 | 36 | } 37 | 38 | export default Application; -------------------------------------------------------------------------------- /client/src/components/Banner/Banner.module.scss: -------------------------------------------------------------------------------- 1 | .banner { 2 | position: absolute; 3 | top: 0px; 4 | left: 0px; 5 | right: 0px; 6 | bottom: 0px; 7 | 8 | 9 | 10 | // &::before { 11 | // position: absolute; 12 | // top: 0px; 13 | // left: 0px; 14 | // right: 0px; 15 | // bottom: 0px; 16 | // content: ''; 17 | // // background-color: rgba(white, 0.8); 18 | // // backdrop-filter: blur(2px); 19 | // } 20 | 21 | &::after { 22 | content: ''; 23 | position: absolute; 24 | top: 0px; 25 | left: 0px; 26 | right: 0px; 27 | bottom: 0px; 28 | mask-size: 80%; 29 | mask-repeat: no-repeat; 30 | mask-position: bottom; 31 | background-color: currentColor; 32 | opacity: 0.9; 33 | mask-position: center; 34 | } 35 | 36 | } 37 | 38 | .sad::after { 39 | mask-image: url('./sad.svg'); 40 | } 41 | 42 | .sailor::after { 43 | mask-image: url('./sailor.svg'); 44 | } 45 | 46 | .slow::after { 47 | mask-image: url('./turtle.svg'); 48 | mask-size: 70%; 49 | transform: scaleX(-1); 50 | } 51 | 52 | .gameOver { 53 | font-size: 7cqmin; 54 | padding: 10%; 55 | display: flex; 56 | flex-direction: column; 57 | gap: 6cqmin; 58 | pointer-events: all; 59 | } -------------------------------------------------------------------------------- /client/src/components/Banner/Banner.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import styles from './Banner.module.scss'; 3 | import DemoField from "../DemoField/DemoField"; 4 | 5 | enum Kind { 6 | SADFACE, 7 | SAILOR, 8 | SLOW 9 | } 10 | 11 | const BannerBase = (props: { kind: Kind }) => { 12 | const { kind } = props; 13 | return ( 14 | 15 |
21 | 22 | ) 23 | } 24 | 25 | const Banner = Object.assign(BannerBase, { 26 | Kind: Kind 27 | }) 28 | 29 | export default Banner; -------------------------------------------------------------------------------- /client/src/components/Banner/sad.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/components/Banner/sailor.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 45 | 46 | 47 | 50 | 55 | 59 | 60 | -------------------------------------------------------------------------------- /client/src/components/Banner/turtle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 17 | -------------------------------------------------------------------------------- /client/src/components/Border/Border.module.scss: -------------------------------------------------------------------------------- 1 | 2 | .box{ 3 | position: relative; 4 | } 5 | 6 | .box::before{ 7 | background-color: var(--background, white); 8 | box-shadow: 0px 0px 4px rgba(black, 0.2); 9 | position: absolute; 10 | top: 0px; 11 | left: 0px; 12 | right: 0px; 13 | bottom: 0px; 14 | content: ''; 15 | border-style: solid; 16 | // backdrop-filter: blur(1px); 17 | border-width: var(--border-size1) var(--border-size2) var(--border-size3) var(--border-size4); 18 | border-color: currentColor; 19 | border-radius: var(--border-round5) var(--border-round1) var(--border-round6) var(--border-round2)/var(--border-round3) var(--border-round7) var(--border-round4) var(--border-round8); 20 | transform-origin: 50% 50%; 21 | transform: var(--transform); 22 | } 23 | 24 | -------------------------------------------------------------------------------- /client/src/components/Border/Border.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties } from 'react'; 2 | import styles from './Border.module.scss'; 3 | import getRandomInt from '../../../../shared/getRandomInt'; 4 | 5 | interface Props { 6 | children?: any; 7 | } 8 | 9 | function getRandomFloat(min: number, max: number, decimals: number) { 10 | const str = (Math.random() * (max - min) + min).toFixed( 11 | decimals, 12 | ); 13 | 14 | return parseFloat(str); 15 | } 16 | 17 | class Border extends React.Component { 18 | 19 | sizes = [ 20 | getRandomFloat(3, 5, 2), 21 | getRandomFloat(3, 5, 2), 22 | getRandomFloat(3, 5, 2), 23 | getRandomFloat(3, 5, 2), 24 | getRandomInt(1, 20), 25 | getRandomInt(1, 20), 26 | getRandomInt(1, 20), 27 | getRandomInt(1, 20), 28 | getRandomInt(50, 100), 29 | getRandomInt(50, 100), 30 | getRandomInt(50, 100), 31 | getRandomInt(50, 100), 32 | getRandomInt(0, 1), 33 | getRandomInt(0, 1), 34 | getRandomFloat(-0.5, 0.5, 2), 35 | getRandomFloat(-0.25, 0.25, 2), 36 | getRandomFloat(-1, 1, 2) 37 | ] 38 | 39 | 40 | render() { 41 | return
65 |
68 | {this.props.children} 69 |
70 |
71 | } 72 | } 73 | 74 | export default Border; -------------------------------------------------------------------------------- /client/src/components/Button/Button.module.scss: -------------------------------------------------------------------------------- 1 | .outer { 2 | cursor: pointer; 3 | text-transform: uppercase; 4 | } 5 | 6 | .outer:active { 7 | --background: rgba(49, 49, 167, 0.1); 8 | } 9 | 10 | .disabled { 11 | opacity: 0.6; 12 | pointer-events: none; 13 | } 14 | 15 | .inner { 16 | padding: 6cqmin; 17 | text-align: center; 18 | // background-color: blue; 19 | } 20 | 21 | .timer { 22 | font-size: 80%; 23 | } -------------------------------------------------------------------------------- /client/src/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import Border from '../Border/Border'; 3 | import styles from './Button.module.scss'; 4 | import {playSound} from '../../utils/playSound'; 5 | import foo from './button-46.mp3'; 6 | import { CSSProperties } from 'react'; 7 | import Counter from '../Counter/Counter'; 8 | import formatTime from '../../utils/formatTime'; 9 | import i18n from '../../utils/i18n'; 10 | 11 | 12 | interface Props { 13 | children?: any; 14 | disabled?: boolean; 15 | style?: CSSProperties; 16 | onClick?: () => void; 17 | showTime?: boolean; 18 | } 19 | 20 | const Button = (props: Props) => ( 21 |
22 | 23 |
{ 24 | playSound(foo); 25 | props?.onClick?.() 26 | }}> 27 | {props.children} 28 | 29 | {props.showTime && ( 30 | ( 31 |
32 | ({i18n('WAITING_FOR')}{' '}{formatTime(secondsLeft)}) 33 |
34 | )} /> 35 | )} 36 | 37 |
38 |
39 |
40 | ); 41 | 42 | export default Button; -------------------------------------------------------------------------------- /client/src/components/Button/button-46.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/components/Button/button-46.mp3 -------------------------------------------------------------------------------- /client/src/components/Counter/Counter.tsx: -------------------------------------------------------------------------------- 1 | import { useSecondsLeft } from "../../utils/useGameState"; 2 | 3 | const Counter = (props: { onRender: (seconds: number) => any }) => { 4 | const secondsLeft = useSecondsLeft() 5 | if (secondsLeft === Infinity) return ''; 6 | if (secondsLeft <= 0) return ''; 7 | return props.onRender(secondsLeft); 8 | } 9 | 10 | export default Counter; -------------------------------------------------------------------------------- /client/src/components/DemoField/DemoField.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import Modal from "../Modal/Modal"; 3 | import Field from "../Field/Field"; 4 | import { generateMap, makeRandomShot } from "../../../../shared/mapUtils"; 5 | import ShotResult from "../../../../shared/ShotResult"; 6 | 7 | 8 | const DemoField = (props: { reverseLegend?: boolean, children?: any }) => { 9 | 10 | let [ map, setMap ] = useState(generateMap()); 11 | 12 | const makeNextShot = () => { 13 | const r = makeRandomShot(map); 14 | if (r === ShotResult.GAME_OVER) { 15 | setMap(map = generateMap()); 16 | makeRandomShot(map); 17 | setMap({...map}) 18 | } else { 19 | setMap({...map}); 20 | } 21 | setTimeout(makeNextShot, 1000); 22 | } 23 | 24 | useEffect(() => { 25 | 26 | makeNextShot(); 27 | 28 | return () => { 29 | // clearInterval(x); 30 | } 31 | }, []) 32 | 33 | return 34 | 35 | {props.children} 36 | 37 | 38 | 39 | } 40 | 41 | export default DemoField; -------------------------------------------------------------------------------- /client/src/components/Field/Field.module.scss: -------------------------------------------------------------------------------- 1 | 2 | $padding: 3px; 3 | $letterSize: 1.5ch; 4 | 5 | $borderSize: 3px; 6 | 7 | $gridBorderRadius: 6px; 8 | $shipBorderRadius: 3px; 9 | 10 | 11 | 12 | 13 | 14 | .outerWrapper { 15 | max-width: 100cqmin; 16 | min-width: 100cqmin; 17 | min-height: 100cqmin; 18 | max-height: 100cqmin; 19 | font-size: 4cqmin; 20 | display: flex; 21 | flex-direction: column; 22 | gap: $padding; 23 | } 24 | 25 | .innerWrapper { 26 | display: flex; 27 | } 28 | 29 | 30 | .letters { 31 | display: flex; 32 | margin-left: calc($letterSize + $padding); 33 | & > * { 34 | flex: 1; 35 | max-height: $letterSize; 36 | min-height: $letterSize; 37 | text-align: center; 38 | } 39 | } 40 | 41 | 42 | .digits { 43 | display: flex; 44 | flex-direction: column; 45 | margin-right: $padding; 46 | & > * { 47 | flex: 1; 48 | max-width: $letterSize; 49 | min-width: $letterSize; 50 | display: flex; 51 | align-items: center; 52 | justify-content: flex-end; 53 | } 54 | } 55 | 56 | 57 | .grid { 58 | aspect-ratio: 1/1; 59 | flex: 1; 60 | display: flex; 61 | flex-wrap: wrap; 62 | background-color: white; 63 | position: relative; 64 | overflow: hidden; 65 | border-radius: $gridBorderRadius; 66 | 67 | 68 | &::after { 69 | content: ''; 70 | position: absolute; 71 | top: 0px; 72 | left: 0px; 73 | right: 0px; 74 | bottom: 0px; 75 | pointer-events: all; 76 | background-color: var(--background); 77 | pointer-events: none; 78 | } 79 | 80 | 81 | &::before { 82 | content: ''; 83 | position: absolute; 84 | top: 0px; 85 | left: 0px; 86 | right: 0px; 87 | bottom: 0px; 88 | z-index: 1; 89 | pointer-events: none; 90 | border-radius: inherit; 91 | border: $borderSize solid currentColor; 92 | } 93 | 94 | & > * { 95 | min-width: 10%; 96 | max-width: 10%; 97 | min-height: 10%; 98 | max-height: 10%; 99 | position: relative; 100 | } 101 | } 102 | 103 | .status:has(*:not(:empty)) { 104 | z-index: 1; 105 | pointer-events: none; 106 | position: absolute; 107 | min-width: 100%; 108 | min-height: 100%; 109 | display: flex; 110 | align-items: center; 111 | justify-content: center; 112 | align-content: center; 113 | text-align: center; 114 | overflow: hidden; 115 | border-radius: $gridBorderRadius; 116 | border: $borderSize solid currentColor; 117 | } 118 | 119 | 120 | @media screen and (orientation:landscape) { 121 | .outerWrapper.reverseLegend { 122 | transform: scaleX(-1); 123 | .letters, .grid, .digits { 124 | transform: scaleX(-1); 125 | } 126 | .digits > * { 127 | justify-content: flex-start; 128 | } 129 | } 130 | } 131 | 132 | 133 | @media screen and (orientation:portrait) { 134 | .outerWrapper.reverseLegend { 135 | transform: scaleY(-1); 136 | .letters, .grid, .digits { 137 | transform: scaleY(-1); 138 | } 139 | } 140 | } 141 | 142 | 143 | .outerWrapper.hideAliveShips .ship:not(.hit) { 144 | opacity: 0; 145 | } 146 | 147 | 148 | @mixin absolute($offset: 0) { 149 | top: $offset; 150 | left: $offset; 151 | right: $offset; 152 | bottom: $offset; 153 | position: absolute; 154 | } 155 | 156 | @mixin ship($top, $left, $right, $bottom, $tlr, $trr, $blr, $brr) { 157 | &::after { 158 | content: ''; 159 | @include absolute; 160 | border-top-left-radius: ($tlr * $shipBorderRadius); 161 | border-top-right-radius: ($trr * $shipBorderRadius); 162 | border-bottom-left-radius: ($blr * $shipBorderRadius); 163 | border-bottom-right-radius: ($brr * $shipBorderRadius); 164 | border-top: ($top * $borderSize) solid currentColor; 165 | border-left: ($left * $borderSize) solid currentColor; 166 | border-right: ($right * $borderSize) solid currentColor; 167 | border-bottom: ($bottom * $borderSize) solid currentColor; 168 | } 169 | } 170 | 171 | 172 | 173 | .ship.one { 174 | @include ship(1, 1, 1, 1, 1, 1, 1, 1); 175 | } 176 | 177 | .ship.verStart { 178 | @include ship(1, 1, 1, 0, 1, 1, 0, 0); 179 | } 180 | 181 | .ship.verEnd { 182 | @include ship(0, 1, 1, 1, 0, 0, 1, 1); 183 | } 184 | 185 | .ship.horStart { 186 | @include ship(1, 1, 0, 1, 1, 0, 1, 0); 187 | } 188 | 189 | .ship.horEnd { 190 | @include ship(1, 0, 1, 1, 0, 1, 0, 1); 191 | } 192 | 193 | .ship.verCenter { 194 | @include ship(0, 1, 1, 0, 0, 0, 0, 0); 195 | } 196 | 197 | .ship.horCenter { 198 | @include ship(1, 0, 0, 1, 0, 0, 0, 0); 199 | } 200 | 201 | 202 | .ship.hit > * { 203 | @include absolute; 204 | mask-image: var(--maskImage); 205 | mask-repeat: no-repeat; 206 | mask-position: center; 207 | mask-size: var(--maskSize); 208 | background-color: currentColor; 209 | transform-origin: 50% 50%; 210 | } 211 | 212 | .sea > * { 213 | @include absolute; 214 | mask-image: var(--maskImage); 215 | mask-repeat: no-repeat; 216 | mask-position: center; 217 | mask-size: var(--maskSize); 218 | background-color: currentColor; 219 | transform-origin: 50% 50%; 220 | } 221 | 222 | .dot { 223 | container-type: size; 224 | &::before { 225 | content: '•'; 226 | @include absolute; 227 | display: flex; 228 | align-items: center; 229 | align-content: center; 230 | justify-content: center; 231 | font-size: 100cqmax; 232 | line-height: 100cqmax; 233 | } 234 | } -------------------------------------------------------------------------------- /client/src/components/Field/Field.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties } from 'react'; 2 | import clsx from "clsx"; 3 | import styles from './Field.module.scss'; 4 | import theme from '../../index.module.scss' 5 | import getRandomInt from '../../../../shared/getRandomInt'; 6 | 7 | import h1 from './h1.png'; 8 | import h2 from './h2.png'; 9 | import c1 from './c1.png'; 10 | import c2 from './c2.png'; 11 | import c3 from './c3.png'; 12 | 13 | import generateGrid from '../../utils/generateGrid'; 14 | import { indexOffset, isHitShip, isSea, isShip } from '../../../../shared/mapUtils'; 15 | import Map from '../../../../shared/Map'; 16 | import userLocale from '../../utils/userLocale'; 17 | 18 | const FIELD_GRID_BG = generateGrid(1.2, '10%', theme.gridColor); 19 | 20 | const DIGITS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 21 | 22 | 23 | const LETTERS = ( 24 | userLocale === 'ru' ? ['А', 'Б', 'В', 'Г', 'Д', 'Е', 'Ж', 'З', 'И', 'К'] : 25 | userLocale === 'uk' ? ['А', 'Б', 'В', 'Г', 'Д', 'Е', 'Є', 'Ж', 'З', 'И'] : 26 | ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'] 27 | ); 28 | 29 | 30 | const CROSSES = [c1, c2, c3]; 31 | const MISSES = [h1, h2]; 32 | 33 | 34 | 35 | const SeaCell = React.memo(() => ( 36 |
41 | )); 42 | 43 | 44 | const CellHit = React.memo(() => ( 45 |
50 | )); 51 | 52 | 53 | const Label = React.memo((props: { children: any }) => ( 54 |
{props.children}
57 | )); 58 | 59 | 60 | interface Props { 61 | map: Map; 62 | style?: CSSProperties; 63 | className?: string; 64 | background?: string; 65 | children?: any; 66 | reverseLegend?: boolean; 67 | hideAliveShips?: boolean; 68 | onHit?: (index: number) => void; 69 | } 70 | 71 | class Field extends React.Component { 72 | 73 | onHit = (index: number) => { 74 | this.props?.onHit?.(index); 75 | } 76 | 77 | render() { 78 | 79 | const { map, reverseLegend, hideAliveShips, background, className, children, style } = this.props; 80 | 81 | 82 | 83 | return
92 | 93 | 94 |
95 | {LETTERS.map(letter => )} 96 |
97 | 98 | 99 |
100 |
101 | {DIGITS.map(digit => )} 102 |
103 | 104 | 105 |
106 | 107 | 108 | {new Array(100).fill(0).map((_, index) => { 109 | 110 | const entity = map[index]; 111 | 112 | if (isSea(entity)) { 113 | return ( 114 |
115 | 116 |
117 | ) 118 | } 119 | 120 | 121 | if (isShip(entity)) { 122 | 123 | 124 | const isHit = isHitShip(entity); 125 | const leftObject = (hideAliveShips ? isHitShip : isShip)(map[indexOffset(index, -1, 0)]); 126 | const rightObject = (hideAliveShips ? isHitShip : isShip)(map[indexOffset(index, 1, 0)]); 127 | const topObject = (hideAliveShips ? isHitShip : isShip)(map[indexOffset(index, 0, -1)]); 128 | const bottomObject = (hideAliveShips ? isHitShip : isShip)(map[indexOffset(index, 0, 1)]); 129 | 130 | const isSquare = Boolean(!leftObject && !rightObject && !topObject && !bottomObject); 131 | const isVerStart = Boolean(!leftObject && !rightObject && !topObject && bottomObject); 132 | const isVerEnd = Boolean(!leftObject && !rightObject && topObject && !bottomObject); 133 | const isVerCenter = Boolean(!leftObject && !rightObject && topObject && bottomObject); 134 | const isHorStart = Boolean(!leftObject && rightObject && !topObject && !bottomObject); 135 | const isHorEnd = Boolean(leftObject && !rightObject && !topObject && !bottomObject); 136 | const isHorCenter = Boolean(leftObject && rightObject && !topObject && !bottomObject); 137 | 138 | 139 | return ( 140 |
this.onHit(index)} 142 | className={clsx( 143 | isSquare && [styles.ship, styles.one, isHit && styles.hit], 144 | isVerStart && [styles.ship, styles.verStart, isHit && styles.hit], 145 | isVerEnd && [styles.ship, styles.verEnd, isHit && styles.hit], 146 | isHorStart && [styles.ship, styles.horStart, isHit && styles.hit], 147 | isHorEnd && [styles.ship, styles.horEnd, isHit && styles.hit], 148 | isVerCenter && [styles.ship, styles.verCenter, isHit && styles.hit], 149 | isHorCenter && [styles.ship, styles.horCenter, isHit && styles.hit] 150 | )} 151 | > 152 | 153 |
154 | ) 155 | } 156 | 157 | 158 | return
this.onHit(index)} /> 159 | 160 | })} 161 | 162 |
163 |
{children}
164 |
165 | 166 | 167 |
168 | 169 |
170 | 171 |
172 | } 173 | } 174 | 175 | export default Field; -------------------------------------------------------------------------------- /client/src/components/Field/c1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/components/Field/c1.png -------------------------------------------------------------------------------- /client/src/components/Field/c2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/components/Field/c2.png -------------------------------------------------------------------------------- /client/src/components/Field/c3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/components/Field/c3.png -------------------------------------------------------------------------------- /client/src/components/Field/h1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/components/Field/h1.png -------------------------------------------------------------------------------- /client/src/components/Field/h2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/components/Field/h2.png -------------------------------------------------------------------------------- /client/src/components/Layout/Layout.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | top: 15px; 3 | left: 15px; 4 | right: 15px; 5 | bottom: 15px; 6 | position: absolute; 7 | display: flex; 8 | gap: 5cqmin; 9 | } 10 | 11 | 12 | 13 | 14 | .firstColumn, .lastColumn { 15 | container-type: size; 16 | display: flex; 17 | flex: 1; 18 | } 19 | 20 | @media screen and (orientation:landscape) { 21 | .wrapper { 22 | flex-direction: row; 23 | } 24 | .firstColumn { 25 | justify-content: flex-end; 26 | } 27 | .firstColumn, .lastColumn { 28 | align-items: center; 29 | } 30 | } 31 | 32 | @media screen and (orientation:portrait) { 33 | .wrapper { 34 | flex-direction: column; 35 | } 36 | .firstColumn { 37 | align-items: flex-end; 38 | } 39 | .firstColumn, .lastColumn { 40 | justify-content: center; 41 | } 42 | } -------------------------------------------------------------------------------- /client/src/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import styles from './Layout.module.scss' 3 | 4 | const Layout = (props: { className?: string, field1: any, field2: any }) => ( 5 |
6 |
7 | {props.field1} 8 |
9 |
10 | {props.field2} 11 |
12 |
13 | ) 14 | 15 | export default Layout; -------------------------------------------------------------------------------- /client/src/components/Modal/Modal.module.scss: -------------------------------------------------------------------------------- 1 | .modal { 2 | pointer-events: all; 3 | position: absolute; 4 | top: 0px; 5 | left: 0px; 6 | right: 0px; 7 | bottom: 0px; 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | align-content: center; 12 | background-color: rgba(white, 0.7); 13 | backdrop-filter: blur(2px); 14 | } 15 | 16 | .modal > *:nth-child(1) { 17 | border: 5cqmin solid transparent; 18 | } 19 | 20 | .modal > *:nth-child(2) { 21 | border-left: 4px solid currentColor; 22 | border-bottom: 4px solid currentColor; 23 | position: absolute; 24 | padding: 6px; 25 | top: 0px; 26 | right: 0px; 27 | border-bottom-left-radius: 10px; 28 | padding-bottom: 4px; 29 | padding-top: 4px; 30 | } -------------------------------------------------------------------------------- /client/src/components/Modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import styles from './Modal.module.scss' 2 | 3 | const Modal = (props: { 4 | children?: any, 5 | }) => { 6 | 7 | return
8 |
{props.children}
9 |
10 | } 11 | 12 | export default Modal; -------------------------------------------------------------------------------- /client/src/components/ThreeDots/ThreeDots.module.scss: -------------------------------------------------------------------------------- 1 | // @keyframes dots-1 { 0% { opacity: 0; } 50% { opacity: 1; } 65% { opacity: 1; } } 2 | // @keyframes dots-2 { 10% { opacity: 0; } 50% { opacity: 1; } 65% { opacity: 1; } } 3 | // @keyframes dots-3 { 20% { opacity: 0; } 50% { opacity: 1; } 65% { opacity: 1; } } 4 | 5 | 6 | @keyframes dots-1 { 35% { opacity: 0} 60% { opacity: 1; } } 7 | @keyframes dots-2 { 35% { opacity: 0} 85% { opacity: 1; } } 8 | @keyframes dots-3 { 35% { opacity: 0} 100% { opacity: 1; } } 9 | 10 | .wrapper { 11 | 12 | & > *:nth-child(n+2) { 13 | // opacity: 0; 14 | animation-duration: 2s; 15 | animation-iteration-count: infinite; 16 | animation-delay: calc(var(--animation-delay) * 1ms); 17 | } 18 | 19 | & > *:nth-child(2) { 20 | animation-name: dots-1; 21 | } 22 | 23 | & > *:nth-child(3) { 24 | animation-name: dots-2; 25 | } 26 | 27 | & > *:nth-child(4) { 28 | animation-name: dots-3; 29 | } 30 | } -------------------------------------------------------------------------------- /client/src/components/ThreeDots/ThreeDots.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, useState } from 'react'; 2 | import getRandomInt from '../../../../shared/getRandomInt'; 3 | import styles from './ThreeDots.module.scss'; 4 | import clsx from 'clsx'; 5 | 6 | 7 | interface Props { 8 | children?: any; 9 | className?: string; 10 | style?: CSSProperties; 11 | } 12 | 13 | const ThreeDots = (props: Props) => { 14 | const [animationDelay] = useState(getRandomInt(0, 1000)); 15 | return 19 | {props.children} 20 | . 21 | . 22 | . 23 | 24 | }; 25 | 26 | export default ThreeDots; -------------------------------------------------------------------------------- /client/src/fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8w88PXVh (1).woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8w88PXVh (1).woff2 -------------------------------------------------------------------------------- /client/src/fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8w88PXVh.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8w88PXVh.woff2 -------------------------------------------------------------------------------- /client/src/fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8wU8PXVh (1).woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8wU8PXVh (1).woff2 -------------------------------------------------------------------------------- /client/src/fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8wU8PXVh.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8wU8PXVh.woff2 -------------------------------------------------------------------------------- /client/src/fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8wY8PXVh (1).woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8wY8PXVh (1).woff2 -------------------------------------------------------------------------------- /client/src/fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8wY8PXVh.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8wY8PXVh.woff2 -------------------------------------------------------------------------------- /client/src/fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8ws8PQ (1).woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8ws8PQ (1).woff2 -------------------------------------------------------------------------------- /client/src/fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8ws8PQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8ws8PQ.woff2 -------------------------------------------------------------------------------- /client/src/index.module.scss: -------------------------------------------------------------------------------- 1 | /* cyrillic-ext */ 2 | @font-face { 3 | font-family: 'Shantell Sans'; 4 | font-style: normal; 5 | font-weight: 400; 6 | font-display: swap; 7 | src: url(./fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8wY8PXVh.woff2) format('woff2'); 8 | unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; 9 | } 10 | /* cyrillic */ 11 | @font-face { 12 | font-family: 'Shantell Sans'; 13 | font-style: normal; 14 | font-weight: 400; 15 | font-display: swap; 16 | src: url(./fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8w88PXVh.woff2) format('woff2'); 17 | unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 18 | } 19 | /* latin-ext */ 20 | @font-face { 21 | font-family: 'Shantell Sans'; 22 | font-style: normal; 23 | font-weight: 400; 24 | font-display: swap; 25 | src: url(./fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8wU8PXVh.woff2) format('woff2'); 26 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 27 | } 28 | /* latin */ 29 | @font-face { 30 | font-family: 'Shantell Sans'; 31 | font-style: normal; 32 | font-weight: 400; 33 | font-display: swap; 34 | src: url(./fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8ws8PQ.woff2) format('woff2'); 35 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 36 | } 37 | /* cyrillic-ext */ 38 | @font-face { 39 | font-family: 'Shantell Sans'; 40 | font-style: normal; 41 | font-weight: 600; 42 | font-display: swap; 43 | src: url(./fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8wY8PXVh.woff2) format('woff2'); 44 | unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; 45 | } 46 | /* cyrillic */ 47 | @font-face { 48 | font-family: 'Shantell Sans'; 49 | font-style: normal; 50 | font-weight: 600; 51 | font-display: swap; 52 | src: url(./fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8w88PXVh.woff2) format('woff2'); 53 | unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 54 | } 55 | /* latin-ext */ 56 | @font-face { 57 | font-family: 'Shantell Sans'; 58 | font-style: normal; 59 | font-weight: 600; 60 | font-display: swap; 61 | src: url(./fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8wU8PXVh.woff2) format('woff2'); 62 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 63 | } 64 | /* latin */ 65 | @font-face { 66 | font-family: 'Shantell Sans'; 67 | font-style: normal; 68 | font-weight: 600; 69 | font-display: swap; 70 | src: url(./fonts/FeVvS0pCoLIo-lcdY7kjvNoQqWVWB0qWpl29ajppTuUTu_kJKmHesPOL-maYi4xZeHCNQ09eBlmv8ws8PQ.woff2) format('woff2'); 71 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 72 | } 73 | 74 | * { 75 | color: #2204BF; 76 | box-sizing: border-box; 77 | font-weight: 600; 78 | -webkit-tap-highlight-color: transparent; 79 | -webkit-touch-callout: none; 80 | -webkit-user-select: none; 81 | -khtml-user-select: none; 82 | -moz-user-select: none; 83 | -ms-user-select: none; 84 | user-select: none; 85 | } 86 | 87 | html { 88 | font-family: 'Shantell Sans', cursive; 89 | background-color: white; 90 | } 91 | 92 | @media (prefers-color-scheme: dark) { 93 | html { 94 | filter: grayscale(1) invert(1); 95 | } 96 | } 97 | 98 | html, body { 99 | position: absolute; 100 | top: 0px; 101 | left: 0px; 102 | right: 0px; 103 | padding: 0px; 104 | margin: 0px; 105 | bottom: 0px; 106 | height: var(--tg-viewport-height, 100%); 107 | } 108 | 109 | .wrapper { 110 | position: absolute; 111 | top: 0px; 112 | left: 0px; 113 | right: 0px; 114 | bottom: 0px; 115 | } 116 | 117 | :export { 118 | smallGridColor: rgba(gray, 0.2); 119 | gridColor: rgba(#2204BF, 0.5); 120 | redBg: rgba(red, 0.15); 121 | } -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client'; 2 | import Application from './Application'; 3 | import theme from './index.module.scss'; 4 | import generateGrid from './utils/generateGrid'; 5 | import TelegramApi from './utils/TelegramApi'; 6 | import selfUpdate from './utils/selfUpdate'; 7 | import Settings from '../../shared/Settings'; 8 | 9 | const LAYER_GRID_BG = generateGrid(1.2, 20, theme.smallGridColor); 10 | 11 | TelegramApi.expand(); 12 | TelegramApi.setHeaderColor(Settings.theme_color); 13 | TelegramApi.enableClosingConfirmation(); 14 | setTimeout(selfUpdate, 1000 * 5); 15 | 16 | ReactDOM.createRoot( 17 | document.getElementById('root') as HTMLElement 18 | ).render( 19 |
20 | 21 |
22 | ); -------------------------------------------------------------------------------- /client/src/manifest.ts: -------------------------------------------------------------------------------- 1 | import Settings from '../../shared/Settings'; 2 | import favicon from '../public/favicon.svg'; 3 | 4 | 5 | 6 | 7 | export default { 8 | name: Settings.siteTitle, 9 | short_name: Settings.siteTitle, 10 | description: "description", 11 | background_color: Settings.theme_color, 12 | theme_color: Settings.theme_color, 13 | display: "standalone", 14 | orientation: "portrait", 15 | start_url: "/?{{getUniqueId}}sss", 16 | icons: [{ 17 | src: favicon, 18 | type: "image/png", 19 | sizes: "512x512" 20 | }] 21 | } -------------------------------------------------------------------------------- /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | import Settings from "../../shared/Settings"; 2 | 3 | declare const self: ServiceWorkerGlobalScope; 4 | 5 | console.info("HELLO", Settings.CACHE_NAME); 6 | 7 | 8 | 9 | 10 | 11 | const OPEN_CACHE = Settings.CACHE_NAME ? caches.open(Settings.CACHE_NAME) : null; 12 | 13 | 14 | const getFromCache = async(requestUrl: string) => { 15 | if (!OPEN_CACHE) return; 16 | const cache = await OPEN_CACHE; 17 | return cache.match(requestUrl); 18 | }; 19 | 20 | self.addEventListener('fetch', (event) => { 21 | const request = event.request; 22 | let requestUrl = request.url; 23 | if (request.method !== 'GET') return; 24 | if (requestUrl.includes('nocache')) return; 25 | if (request?.headers?.get('pragma') === 'no-cache') return; 26 | if (request?.headers?.get('cache-control') === 'no-cache') return; 27 | if (request.destination === 'document') requestUrl = '/'; 28 | // event.respondWith(new Promise(async (resolve) => { 29 | // let response = await getFromCache(requestUrl); 30 | // if (!response) response = await fetch(event.request); 31 | // resolve(response); 32 | // })); 33 | }); 34 | 35 | export default 0; -------------------------------------------------------------------------------- /client/src/utils/Socket.ts: -------------------------------------------------------------------------------- 1 | import { Socket, io } from 'socket.io-client'; 2 | import ServerToClientEvents from '../../../shared/ServerToClientEvents'; 3 | import ClientToServerEvents from '../../../shared/ClientToServerEvents'; 4 | import Settings from '../../../shared/Settings'; 5 | import userLocale from './userLocale'; 6 | import TelegramApi from './TelegramApi'; 7 | import persistentUserId from './persistentUserId'; 8 | 9 | declare const isProduction: boolean; 10 | 11 | const socketIO: Socket = io(( 12 | isProduction ? Settings.socketIoHost : `:${Settings.socketIoPort}` 13 | ), { 14 | auth: { 15 | locale: userLocale, 16 | persistentUserId, 17 | userName: TelegramApi.getFirstName() || new URLSearchParams(window.location.search).get('name'), 18 | }, 19 | path: Settings.socketIoPath, 20 | autoConnect: true, 21 | }); 22 | 23 | export default socketIO; -------------------------------------------------------------------------------- /client/src/utils/TelegramApi.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "events"; 2 | 3 | declare const Telegram: any; 4 | 5 | declare interface TelegramApiClass { 6 | on(event: 'onBackButtonClicked', listener: () => void): this; 7 | } 8 | 9 | class TelegramApiClass extends EventEmitter { 10 | 11 | isTelegram = Boolean(typeof Telegram?.WebApp?.initDataUnsafe?.user === 'object'); 12 | 13 | private onBackButtonClicked = () => { 14 | this.emit('onBackButtonClicked'); 15 | } 16 | 17 | constructor() { 18 | super(); 19 | Telegram?.WebApp?.onEvent?.('backButtonClicked', this.onBackButtonClicked) 20 | } 21 | 22 | expand = () => { 23 | try { Telegram?.WebApp?.expand() } 24 | catch (e) {} 25 | } 26 | 27 | setHeaderColor = (color: string) => { 28 | try { Telegram?.WebApp?.setHeaderColor(color) } 29 | catch (e) {} 30 | } 31 | 32 | requestWriteAccess = () => { 33 | try { Telegram?.WebApp?.requestWriteAccess?.() } 34 | catch (e) {} 35 | } 36 | 37 | enableClosingConfirmation = () => { 38 | try { Telegram?.WebApp?.enableClosingConfirmation() } 39 | catch (e) {} 40 | } 41 | 42 | showConfirm = (message: string) => new Promise(resolve => { 43 | try { return Telegram?.WebApp?.showConfirm(message, resolve) } 44 | catch (e) {} 45 | resolve(confirm(message)); 46 | }) 47 | 48 | getStartParam = (): string => { 49 | const result = Telegram?.WebApp?.initDataUnsafe?.start_param; 50 | return (typeof result === 'string' ? result : ''); 51 | } 52 | 53 | getFirstName = (): string => { 54 | const result = Telegram?.WebApp?.initDataUnsafe?.user?.first_name; 55 | return (typeof result === 'string' ? result : ''); 56 | } 57 | 58 | getUserId = (): number => { 59 | const result = parseInt(Telegram?.WebApp?.initDataUnsafe?.user?.id, 10); 60 | return (Number.isInteger(result) && result > 0 ? result : 0); 61 | } 62 | 63 | getUserLocale = (): string => { 64 | const result = Telegram?.WebApp?.initDataUnsafe?.user?.language_code; 65 | return (typeof result === 'string' && result.trim() ? result : '').trim().toLowerCase(); 66 | } 67 | 68 | showHideBackButton = (show: boolean) => { 69 | if (show) { 70 | Telegram?.WebApp?.BackButton?.show(); 71 | } else { 72 | Telegram?.WebApp?.BackButton?.hide(); 73 | } 74 | } 75 | 76 | } 77 | 78 | const TelegramApi = new TelegramApiClass(); 79 | 80 | export default TelegramApi; -------------------------------------------------------------------------------- /client/src/utils/beep.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/utils/beep.mp3 -------------------------------------------------------------------------------- /client/src/utils/formatTime.ts: -------------------------------------------------------------------------------- 1 | import i18n from "./i18n"; 2 | import plural from "./plural"; 3 | 4 | const HOURS = [i18n('ONE_HOUR'), i18n('TWO_HOURS'), i18n('FIVE_HOURS')]; 5 | const MINUTES = [i18n('ONE_MINUTE'), i18n('TWO_MINUTES'), i18n('FIVE_MINUTES')]; 6 | const SECONDS = [i18n('ONE_SECOND'), i18n('TWO_SECONDS'), i18n('FIVE_SECONDS')]; 7 | 8 | 9 | const formatTime = (seconds: number): string => { 10 | var showSeconds = false; 11 | const hours = Math.floor(seconds / 3600); 12 | seconds %= 3600; 13 | const minutes = Math.floor(seconds / 60); 14 | seconds %= 60; 15 | const result = []; 16 | if (hours) result.push(plural(hours, HOURS)); 17 | if (showSeconds ? (hours || minutes) : minutes) result.push(plural(minutes, MINUTES)); 18 | if (showSeconds ? true : seconds) result.push(plural(seconds, SECONDS)); 19 | return result.join(' '); 20 | }; 21 | 22 | export default formatTime; -------------------------------------------------------------------------------- /client/src/utils/generateGrid.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react"; 2 | 3 | const generateGrid = (lineSize: number, cellSize: number | string, color: string): CSSProperties => { 4 | 5 | const svg = ` 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | `; 14 | 15 | return { 16 | backgroundPosition: `-${lineSize / 2}px -${lineSize / 2}px`, 17 | backgroundImage: `url("data:image/svg+xml;,${encodeURIComponent(svg.replace(/\s+/g, ' '))}")` 18 | }; 19 | 20 | } 21 | 22 | export default generateGrid; -------------------------------------------------------------------------------- /client/src/utils/i18n.ts: -------------------------------------------------------------------------------- 1 | import userLocale from "./userLocale"; 2 | 3 | 4 | export const Messages = { 5 | 6 | ONE_HOUR: ['hour', 'час', 'година'], 7 | TWO_HOURS: ['hours', 'часа', 'години'], 8 | FIVE_HOURS: ['hours', 'часов', 'годин'], 9 | ONE_MINUTE: ['minute', 'минуту', 'хвилина'], 10 | TWO_MINUTES: ['minutes', 'минуты', 'хвилини'], 11 | FIVE_MINUTES: ['minutes', 'минут', 'хвилин'], 12 | ONE_SECOND: ['second', 'секунду', 'секунда'], 13 | TWO_SECONDS: ['seconds', 'секунды', 'секунди'], 14 | FIVE_SECONDS: ['seconds', 'секунд', 'секунд'], 15 | 16 | CHANGE_LAYOUT: ['Change layout', 'поменять расположение', 'змінити розташування'], 17 | READY_TO_PLAY: ['I am Ready to play', 'Я готов к игре', 'Я готов до гри'], 18 | WITH_RANDOM_ENEMY: ['Play with whoever wants to join', 'Игра со случайным противником', 'Гра з випадковим супротивником'], 19 | GAME_WITH: ['Enemy', 'Противник', 'Супротивник'], 20 | WAITING_FOR: ['waiting', 'ждём', 'очікуємо'], 21 | WAITING_ENEMY: ['Waiting enemy', 'Ждем противника', 'Очікуємо на супротивника'], 22 | 23 | YOU_WIN: ['You win!', 'Вы выиграли!', 'Ви перемогли!'], 24 | YOU_LOSE: ['You lose!', 'Вы проиграли!', 'Ви програли!'], 25 | PLAY_MORE_WITH: ['play again with', 'хотите поиграть еще с', 'бажаєте пограти ще з'], 26 | PLAY_ONE_MORE_TIME: ['play one more time', 'давай ещё раз', 'давай зіграємо ще'], 27 | 28 | 29 | SLOW_ACTION_YOU: [ 30 | 'because you were too slow making your next shot', 31 | 'потому что слишком долго решали куда выстрелить', 32 | 'тому що ти занадто повільно робив наступний постріл' 33 | ], 34 | 35 | SLOW_ACTION_ENEMY: [ 36 | 'because the enemy disappeared somewhere, or was too slow making his next shot', 37 | 'потому что противник куда то подевался, либо слишком долго решал куда выстрелить', 38 | 'тому що супротивник кудись зник чи занадто повільно робив наступний постріл' 39 | ], 40 | 41 | GAME_OVER_TITLE: ['Game over', 'Игра окончена', 'Гра скінчена'], 42 | 43 | ENEMY_GONE: [ 44 | 'because the enemy disappeared somewhere, or didn\'t want to play again', 45 | 'потому что противник куда то подевался, либо не пожелал играть ещё раз', 46 | 'тому що супротивник кудись зник чи не забажав грати ще раз' 47 | ], 48 | 49 | SLOW_PLACING_SHIPS: [ 50 | 'because you were too slow placing your ships', 51 | 'потому что вы слишком долго расставляли корабли', 52 | 'тому що ви занадто повільно розташовували кораблі' 53 | ], 54 | 55 | PLAY_AGAIN: ['Play again', 'Играть ещё', 'Грати ще'], 56 | 57 | LOADING: ['Waiting for user to join the game', 'Ждем подключения пользователя', 'Очікуємо підключення користувача'], 58 | 59 | 60 | 61 | } 62 | 63 | const i18n = (id: keyof typeof Messages): string => { 64 | return Messages[id][ 65 | userLocale === 'ru' ? 1 : 66 | userLocale === 'uk' ? 2 : 67 | 0 68 | ].trim(); 69 | } 70 | 71 | export default i18n; -------------------------------------------------------------------------------- /client/src/utils/inviteId.ts: -------------------------------------------------------------------------------- 1 | import TelegramApi from "./TelegramApi"; 2 | 3 | const inviteId = (() => { 4 | 5 | let inviteId: any; 6 | 7 | try { 8 | inviteId = window.atob( 9 | new URLSearchParams(window.location.search).get('inviteid') || 10 | TelegramApi.getStartParam() 11 | ); 12 | } catch (e) {} 13 | 14 | return (typeof inviteId === 'string' && inviteId.trim() ? inviteId : ''); 15 | })(); 16 | 17 | export default inviteId; -------------------------------------------------------------------------------- /client/src/utils/persistentUserId.ts: -------------------------------------------------------------------------------- 1 | import generateUniqueId from "../../../shared/generateUniqueId"; 2 | 3 | const persistentUserId = (() => { 4 | 5 | let result = (localStorage.getItem('persistentUserId') || ''); 6 | if (typeof result !== 'string' || !result.trim()) { 7 | result = generateUniqueId(128); 8 | localStorage.setItem('persistentUserId', result); 9 | } 10 | 11 | return result; 12 | 13 | })(); 14 | 15 | 16 | export default persistentUserId; -------------------------------------------------------------------------------- /client/src/utils/playSound.ts: -------------------------------------------------------------------------------- 1 | const audioContext = new AudioContext(); 2 | 3 | const CACHE: {[path: string]: AudioBuffer} = {}; 4 | 5 | const getSound = async(path: string): Promise => { 6 | const cached = CACHE[path]; 7 | if (cached) return cached; 8 | const response = await fetch(path); 9 | if (!response) return; 10 | const buffer = await response.arrayBuffer(); 11 | if (!buffer) return; 12 | return (CACHE[path] = await audioContext.decodeAudioData(buffer)); 13 | } 14 | 15 | 16 | const startPlaying = async(path: string) => { 17 | const audioBuffer = await getSound(path); 18 | if (!audioBuffer) return; 19 | const source = audioContext.createBufferSource(); 20 | source.buffer = audioBuffer; 21 | source.loop = true; 22 | source.connect(audioContext.destination); 23 | source.start(0); 24 | } 25 | 26 | const playSound = async(path: string) => { 27 | const audioBuffer = await getSound(path); 28 | if (!audioBuffer) return; 29 | const source = audioContext.createBufferSource(); 30 | source.buffer = audioBuffer; 31 | source.connect(audioContext.destination); 32 | source.start(0); 33 | } 34 | 35 | export {playSound, startPlaying}; -------------------------------------------------------------------------------- /client/src/utils/plural.ts: -------------------------------------------------------------------------------- 1 | export default (count: number, arr: string[]) => { 2 | const newCount = count % 100; 3 | if (newCount >= 11 && newCount <= 19) return [count, arr[2]].join(' '); 4 | const i = newCount % 10; 5 | if (i === 1) return [count, arr[0]].join(' '); 6 | if (i === 2 || i === 3 || i === 4) return [count, arr[1]].join(' '); 7 | return [count, arr[2]].join(' '); 8 | } -------------------------------------------------------------------------------- /client/src/utils/selfUpdate.ts: -------------------------------------------------------------------------------- 1 | import Settings from "../../../shared/Settings"; 2 | 3 | declare const isProduction: boolean; 4 | 5 | if (isProduction && navigator.serviceWorker && typeof navigator.serviceWorker === "object") { 6 | navigator.serviceWorker.register("/sw.js"); 7 | } 8 | 9 | const fetchUrlNoCache = async(url: string): Promise => { 10 | const headers = new Headers(); 11 | headers.append("pragma", "no-cache"); 12 | headers.append("cache-control", "no-cache"); 13 | try { 14 | const response = await fetch(`${url}?nocache&${Math.random()}`, { method: "GET", headers }); 15 | return (response instanceof Response ? response : undefined); 16 | } catch (e) {} 17 | } 18 | 19 | const getFileListFromServer = async(): Promise<{[key: string]: string}> => { 20 | do try { 21 | let result: any = await fetchUrlNoCache('/naval_clash_bot.json'); 22 | if (!result) break; 23 | result = await result?.json(); 24 | if (!result) break; 25 | if (typeof result !== 'object') break; 26 | if (!Object.keys(result).length) break; 27 | if (!Object.values(result).every(x => typeof x === 'string' && x.trim())) break; 28 | return result; 29 | } catch (e) {} while (0); 30 | return {} 31 | } 32 | 33 | const selfUpdate = async() => { 34 | 35 | if (!Settings.CACHE_NAME) return; 36 | if (typeof caches !== 'object') return; 37 | 38 | const serverFiles = await getFileListFromServer(); 39 | const cache = await caches.open(Settings.CACHE_NAME); 40 | 41 | for (const path in serverFiles) { 42 | const hash = serverFiles[path]; 43 | let response = await cache.match(path); 44 | if (!response || hash !== response.headers.get('hash')) { 45 | response = await fetchUrlNoCache(path); 46 | if (!response) continue; 47 | const newHeaders = new Headers(response.headers); 48 | newHeaders.set('hash', hash); 49 | newHeaders.set('path', path); 50 | console.info('ADD', path) 51 | await cache.put(path, new Response(response.body, { 52 | status: response.status, 53 | statusText: response.statusText, 54 | headers: newHeaders 55 | })); 56 | } 57 | } 58 | 59 | for (const response of await cache.matchAll()) { 60 | const path = response.headers.get('path'); 61 | if (path && !serverFiles[path]) { 62 | await cache.delete(path); 63 | console.info('REMOVE', path) 64 | } 65 | } 66 | 67 | 68 | } 69 | 70 | export default selfUpdate; -------------------------------------------------------------------------------- /client/src/utils/tempUserId.ts: -------------------------------------------------------------------------------- 1 | import generateUniqueId from "../../../shared/generateUniqueId"; 2 | 3 | let result = generateUniqueId(64); 4 | 5 | export const getTempUserId = (): string => { 6 | return result; 7 | } 8 | 9 | export const refreshTempUserId = () => { 10 | result = generateUniqueId(64); 11 | } -------------------------------------------------------------------------------- /client/src/utils/useGameState.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import GameState, { GameStatus } from "../../../shared/GameState"; 3 | import beep from './beep.mp3'; 4 | import { playSound } from "./playSound"; 5 | import inviteId from "./inviteId"; 6 | 7 | 8 | const DEFAULT_GAME_STATE: GameState = { 9 | replayId: '', 10 | watchDog: Infinity, 11 | status: GameStatus.PLACESHIPS, 12 | whosTurn: '', 13 | users: {} 14 | }; 15 | 16 | const WAIT_FOR_CONTACT_STATE: GameState = { 17 | replayId: '', 18 | watchDog: Infinity, 19 | status: GameStatus.WAIT_FOR_CONTACT, 20 | whosTurn: '', 21 | users: {} 22 | } 23 | 24 | let globalGameState = ( 25 | inviteId ? WAIT_FOR_CONTACT_STATE : 26 | DEFAULT_GAME_STATE 27 | ); 28 | 29 | 30 | export const useSecondsLeft = () => { 31 | 32 | const forceUpdate = (() => { 33 | const setForceUpdate = useState(String(Math.random()))[1]; 34 | return () => setForceUpdate(String(Math.random())); 35 | })(); 36 | 37 | useEffect(() => { 38 | document.addEventListener('counter', forceUpdate); 39 | return () => document.removeEventListener('counter', forceUpdate); 40 | }, []); 41 | 42 | return Math.max(Math.ceil((globalGameState.watchDog - Date.now()) / 1000), 0); 43 | 44 | } 45 | 46 | (() => { 47 | 48 | let prevValue = -1; 49 | 50 | setInterval(() => { 51 | const { watchDog } = globalGameState; 52 | if (watchDog === Infinity || watchDog <= 0) return; 53 | const secondsLeft = Math.ceil((watchDog - Date.now()) / 1000); 54 | if (secondsLeft !== prevValue) { 55 | prevValue = secondsLeft; 56 | document.dispatchEvent(new Event('counter')); 57 | if (secondsLeft && secondsLeft <= 5) playSound(beep); 58 | } 59 | if (secondsLeft <= 0) { 60 | globalGameState.watchDog = 0; 61 | setGameState(globalGameState); 62 | } 63 | }, 250); 64 | 65 | })(); 66 | 67 | 68 | 69 | export const setGameState = (gameState?: Partial) => { 70 | globalGameState = gameState ? { ...globalGameState, ...gameState } : DEFAULT_GAME_STATE; 71 | document.dispatchEvent(new Event('updateGameState')); 72 | } 73 | 74 | export const useGameState = (): GameState => { 75 | 76 | const forceUpdate = (() => { 77 | const setForceUpdate = useState(String(Math.random()))[1]; 78 | return () => setForceUpdate(String(Math.random())); 79 | })(); 80 | 81 | useEffect(() => { 82 | document.addEventListener('updateGameState', forceUpdate); 83 | return () => { 84 | document.removeEventListener('updateGameState', forceUpdate); 85 | }; 86 | }); 87 | 88 | return globalGameState 89 | } -------------------------------------------------------------------------------- /client/src/utils/userLocale.ts: -------------------------------------------------------------------------------- 1 | import TelegramApi from "./TelegramApi"; 2 | 3 | const result: any = ( 4 | TelegramApi.getUserLocale() || 5 | navigator?.languages?.[0] || 6 | navigator?.language 7 | ); 8 | 9 | const userLocale = ( 10 | result?.trim()?.split('-')?.[0] || 'en' 11 | ).toLowerCase(); 12 | 13 | export default userLocale; -------------------------------------------------------------------------------- /client/src/views/Battle/Battle.module.scss: -------------------------------------------------------------------------------- 1 | .enemyName { 2 | font-size: 10cqmin; 3 | opacity: 0.8; 4 | max-width: 90%; 5 | margin-left: auto; 6 | margin-right: auto; 7 | } 8 | 9 | .timer { 10 | font-size: 50cqmin; 11 | color: rgba(red, 0.3); 12 | } -------------------------------------------------------------------------------- /client/src/views/Battle/Battle.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import styles from './Battle.module.scss'; 3 | import Field from '../../components/Field/Field'; 4 | import socketIO from '../../utils/Socket'; 5 | import { getTempUserId } from '../../utils/tempUserId'; 6 | import GameState, { GameStatus } from '../../../../shared/GameState'; 7 | import { isFresh, makeShot } from '../../../../shared/mapUtils'; 8 | import Layout from '../../components/Layout/Layout'; 9 | import theme from '../../index.module.scss'; 10 | import Counter from '../../components/Counter/Counter'; 11 | import { setGameState, useGameState } from '../../utils/useGameState'; 12 | import Settings from '../../../../shared/Settings'; 13 | import ShotResult from '../../../../shared/ShotResult'; 14 | import { playSound } from '../../utils/playSound'; 15 | import getRandomInt from '../../../../shared/getRandomInt'; 16 | 17 | import miss from './miss.mp3'; 18 | import hit1 from './hit1.mp3'; 19 | import hit2 from './hit2.mp3'; 20 | import hit3 from './hit3.mp3'; 21 | import hit4 from './hit4.mp3'; 22 | 23 | 24 | const setWaitingForReplay = (gameState: GameState) => { 25 | gameState.watchDog = Date.now() + (Settings.waitForReplayS * 1000); 26 | gameState.status = GameStatus.WAITING_FOR_REPLAY; 27 | setGameState(gameState); 28 | } 29 | 30 | const setWaitingForNextTurn = (gameState: GameState) => { 31 | gameState.watchDog = Date.now() + (Settings.waitForShotS * 1000); 32 | setGameState(gameState); 33 | } 34 | 35 | const Battle = () => { 36 | 37 | const myUserId = getTempUserId(); 38 | const gameState = useGameState(); 39 | const { users } = gameState; 40 | const enemyUserId = Object.keys(users).find(k => k !== myUserId) || ''; 41 | const enemyName = users[enemyUserId].userName || ''; 42 | const freshMap = isFresh(users[enemyUserId].map); 43 | const isMyTurn = (gameState.whosTurn === myUserId); 44 | const myMap = users[myUserId].map; 45 | const enemyMap = users[enemyUserId].map; 46 | 47 | const onEnemyShot = (fromUserId: string, index: number) => { 48 | if (isMyTurn || fromUserId !== enemyUserId) return; 49 | const shotResult = makeShot(myMap, index); 50 | if (shotResult === ShotResult.HIT_SEA) { 51 | playSound(miss); 52 | setGameState({ whosTurn: myUserId }) 53 | } else if (shotResult === ShotResult.GAME_OVER) { 54 | playSound(hit1); 55 | setWaitingForReplay(gameState); 56 | } else if ([ShotResult.HIT_SHIP, ShotResult.KILL_SHIP].includes(shotResult)) { 57 | playSound([hit2, hit3, hit4][getRandomInt(0, 2)]); 58 | setWaitingForNextTurn(gameState); 59 | } 60 | 61 | } 62 | 63 | const onMyShot = (index: number) => { 64 | const shotResult = makeShot(enemyMap, index); 65 | socketIO.emit('shot', myUserId, enemyUserId, index); 66 | if (shotResult === ShotResult.HIT_SEA) { 67 | playSound(miss); 68 | setGameState({ whosTurn: enemyUserId }) 69 | } else if (shotResult === ShotResult.GAME_OVER) { 70 | playSound(hit1); 71 | setWaitingForReplay(gameState); 72 | } else if ([ShotResult.HIT_SHIP, ShotResult.KILL_SHIP].includes(shotResult)) { 73 | playSound([hit2, hit3, hit4][getRandomInt(0, 2)]); 74 | setWaitingForNextTurn(gameState); 75 | } 76 | } 77 | 78 | useEffect(() => { 79 | socketIO.on('shot', onEnemyShot); 80 | return () => { socketIO.off('shot', onEnemyShot); } 81 | }); 82 | 83 | return 85 | 86 | {!isMyTurn && ( 87 | 88 | { 89 | 90 | if (secondsLeft <= Settings.waitForShotShowS) { 91 | return
92 | {secondsLeft} 93 |
94 | } 95 | 96 | 97 | }} /> 98 | 99 | )} 100 | 101 | 102 | 103 | } field2={ 104 | 110 | { 111 | 112 | 113 | 114 | if (isMyTurn && secondsLeft <= Settings.waitForShotShowS) { 115 | return
116 | {secondsLeft} 117 |
118 | } 119 | 120 | if (freshMap) { 121 | return
122 | {enemyName} 123 |
124 | } 125 | 126 | }} /> 127 |
128 | } /> 129 | 130 | } 131 | 132 | 133 | export default Battle; -------------------------------------------------------------------------------- /client/src/views/Battle/hit1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/views/Battle/hit1.mp3 -------------------------------------------------------------------------------- /client/src/views/Battle/hit2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/views/Battle/hit2.mp3 -------------------------------------------------------------------------------- /client/src/views/Battle/hit3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/views/Battle/hit3.mp3 -------------------------------------------------------------------------------- /client/src/views/Battle/hit4.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/views/Battle/hit4.mp3 -------------------------------------------------------------------------------- /client/src/views/Battle/miss.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angrycoding/naval_clash_bot/01c2ca00ef66da3eaf11edc82ec06c7ef13962f3/client/src/views/Battle/miss.mp3 -------------------------------------------------------------------------------- /client/src/views/GameOver/GameOver.module.scss: -------------------------------------------------------------------------------- 1 | .textWithButtons { 2 | display: flex; 3 | pointer-events: all; 4 | flex-direction: column; 5 | position: absolute; 6 | top: 0px; 7 | left: 0px; 8 | right: 0px; 9 | bottom: 0px; 10 | border: 8cqmin solid transparent; 11 | 12 | 13 | & > *:first-child { 14 | flex: 1; 15 | display: flex; 16 | flex-direction: column; 17 | align-items: center; 18 | justify-content: center; 19 | gap: 2cqmin; 20 | & > *:nth-child(1) { 21 | font-size: 10cqmin; 22 | } 23 | & > *:nth-child(2) { 24 | font-size: 5cqmin; 25 | } 26 | } 27 | 28 | & > *:last-child { 29 | gap: 4cqh; 30 | display: flex; 31 | flex-direction: column; 32 | } 33 | } -------------------------------------------------------------------------------- /client/src/views/GameOver/GameOver.tsx: -------------------------------------------------------------------------------- 1 | import { GameStatus } from "../../../../shared/GameState"; 2 | import { getTempUserId, refreshTempUserId } from "../../utils/tempUserId"; 3 | import i18n from "../../utils/i18n"; 4 | import { setGameState, useGameState } from "../../utils/useGameState"; 5 | import Banner from "../../components/Banner/Banner"; 6 | import Button from "../../components/Button/Button"; 7 | import Field from "../../components/Field/Field" 8 | import Layout from "../../components/Layout/Layout" 9 | import styles from './GameOver.module.scss' 10 | import DemoField from "../../components/DemoField/DemoField"; 11 | 12 | 13 | const PlayAgainButton = () => ( 14 | 18 | 19 | ) 20 | 21 | const GameOver = () => { 22 | 23 | const myUserId = getTempUserId(); 24 | const gameState = useGameState(); 25 | const users = (gameState.users || {}); 26 | const isMyTurn = (gameState.whosTurn === myUserId); 27 | const iConfirm = Boolean(users[myUserId]?.confirm); 28 | 29 | if (gameState.status === GameStatus.ACTIVE) { 30 | return ( 31 | 33 | } field2={ 34 | 35 |
36 |
37 |
{isMyTurn ? i18n('YOU_LOSE') : i18n('YOU_WIN')}
38 |
{isMyTurn ? i18n('SLOW_ACTION_YOU') : i18n('SLOW_ACTION_ENEMY')}
39 |
40 | 41 |
42 |
43 | } /> 44 | ) 45 | } 46 | 47 | if (gameState.status === GameStatus.WAITING_FOR_REPLAY) { 48 | return ( 49 | 51 | } field2={ 52 | 53 |
54 |
55 |
{i18n('GAME_OVER_TITLE')}
56 | {iConfirm ? i18n('ENEMY_GONE') : ''} 57 |
58 | 59 |
60 |
61 | } /> 62 | ) 63 | } 64 | 65 | if (gameState.status === GameStatus.PLACESHIPS) { 66 | return ( 67 | 69 | } field2={ 70 | 71 |
72 |
73 |
{i18n('GAME_OVER_TITLE')}
74 | {iConfirm ? i18n('ENEMY_GONE') : i18n('SLOW_PLACING_SHIPS')} 75 |
76 | 77 |
78 |
79 | } /> 80 | ); 81 | } 82 | 83 | return ( 84 | 86 | } field2={ 87 | 88 |
89 |
90 |
{i18n('GAME_OVER_TITLE')}
91 |
92 | 93 |
94 |
95 | } /> 96 | ); 97 | } 98 | 99 | export default GameOver; -------------------------------------------------------------------------------- /client/src/views/PlaceShips/PlaceShips.module.scss: -------------------------------------------------------------------------------- 1 | .text:has(:last-child:nth-child(1)) { 2 | & > *:nth-child(1) { 3 | font-size: 150%; 4 | } 5 | } 6 | 7 | .text:has(:last-child:nth-child(2)) { 8 | & > *:nth-child(1) { 9 | font-size: 200%; 10 | } 11 | } 12 | 13 | 14 | 15 | .text:has(:last-child:nth-child(3)) { 16 | & > *:nth-child(2) { 17 | font-size: 200%; 18 | word-break: break-all; 19 | } 20 | } 21 | 22 | 23 | .textWithButtons { 24 | display: flex; 25 | flex-direction: column; 26 | position: absolute; 27 | top: 0px; 28 | left: 0px; 29 | right: 0px; 30 | bottom: 0px; 31 | border: 8cqmin solid transparent; 32 | 33 | 34 | & > *:first-child { 35 | flex: 1; 36 | display: flex; 37 | flex-direction: column; 38 | align-items: center; 39 | justify-content: center; 40 | gap: 2cqmin; 41 | font-size: 6cqmin; 42 | span { 43 | word-break: break-all; 44 | } 45 | } 46 | 47 | & > *:last-child { 48 | gap: 4cqh; 49 | display: flex; 50 | flex-direction: column; 51 | } 52 | } -------------------------------------------------------------------------------- /client/src/views/PlaceShips/PlaceShips.tsx: -------------------------------------------------------------------------------- 1 | import Button from '../../components/Button/Button'; 2 | import Field from '../../components/Field/Field'; 3 | import ThreeDots from '../../components/ThreeDots/ThreeDots'; 4 | import { useEffect, useState } from 'react'; 5 | import { convertNewMapToOld, convertOldMapToNew, generateMap } from '../../../../shared/mapUtils'; 6 | import socketIO from '../../utils/Socket'; 7 | import Layout from '../../components/Layout/Layout'; 8 | import Modal from '../../components/Modal/Modal'; 9 | import GameState from '../../../../shared/GameState'; 10 | import Counter from '../../components/Counter/Counter'; 11 | import { getTempUserId } from '../../utils/tempUserId'; 12 | import i18n from '../../utils/i18n'; 13 | import { setGameState, useGameState } from '../../utils/useGameState'; 14 | import Settings from '../../../../shared/Settings'; 15 | import formatTime from '../../utils/formatTime'; 16 | import styles from './PlaceShips.module.scss' 17 | import Map from '../../../../shared/Map'; 18 | import DemoField from '../../components/DemoField/DemoField'; 19 | 20 | 21 | 22 | const PlaceShips = () => { 23 | 24 | const myUserId = getTempUserId(); 25 | const gameState = useGameState(); 26 | const [ myRandomMap, setMyRandomMap ] = useState(generateMap); 27 | 28 | const users = (gameState.users || {}); 29 | const enemyUserId = Object.keys(users).find(k => k !== myUserId) || ''; 30 | const enemyName = users[enemyUserId]?.userName || ''; 31 | const iConfirm = Boolean(users[myUserId]?.confirm); 32 | const iWin = (gameState.whosTurn === myUserId); 33 | 34 | const onGenerate = () => { 35 | setMyRandomMap(generateMap()); 36 | } 37 | 38 | const startGameRequest = () => { 39 | 40 | 41 | setGameState({ 42 | ...gameState, 43 | watchDog: (enemyUserId ? gameState.watchDog : Infinity), 44 | users: { 45 | ...gameState.users, 46 | [myUserId]: { 47 | ...gameState.users[myUserId], 48 | confirm: true 49 | } 50 | } 51 | }); 52 | 53 | socketIO.emit( 54 | 'startGameRequest', 55 | convertNewMapToOld( myRandomMap ), 56 | myUserId, 57 | gameState.replayId || '', 58 | enemyUserId || '', 59 | (iWin ? myUserId : enemyUserId) || '' 60 | ); 61 | 62 | } 63 | 64 | const startGameResponse = (gameState: GameState) => { 65 | 66 | for (const x in gameState.users) { 67 | const user = gameState.users[x]; 68 | user.map = convertOldMapToNew(user.map); 69 | } 70 | 71 | setGameState({ 72 | ...gameState, 73 | watchDog: Date.now() + (Settings.waitForShotS * 1000) 74 | }); 75 | } 76 | 77 | useEffect(() => { 78 | socketIO.on('startGameResponse', startGameResponse); 79 | return () => { socketIO.off('startGameResponse', startGameResponse) } 80 | }, []); 81 | 82 | // waiting for random user to join 83 | if (iConfirm && !enemyUserId) { 84 | return 86 | } field2={ 87 | 88 | 89 |
90 | {i18n('WAITING_ENEMY')} 91 |
92 |
93 | 94 | } /> 95 | } 96 | 97 | // waiting for specific user to join 98 | if (iConfirm && enemyUserId) { 99 | return 101 | } field2={ 102 | 103 |
104 |
{i18n('WAITING_ENEMY')}
105 | {enemyName &&
{enemyName}
} 106 | {formatTime(s)}} /> 107 |
108 |
109 | } /> 110 | } 111 | 112 | return 114 | } field2={ 115 | 116 |
117 |
118 | {enemyUserId ? <> 119 |
{i18n('GAME_WITH')}:
120 |
{enemyName}
121 | : i18n('WITH_RANDOM_ENEMY')} 122 |
123 | 124 |
125 |
126 | 129 |
130 |
131 | 134 |
135 |
136 |
137 | 138 | 139 |
140 | } /> 141 | 142 | } 143 | 144 | export default PlaceShips; -------------------------------------------------------------------------------- /client/src/views/Replay/Replay.module.scss: -------------------------------------------------------------------------------- 1 | .text { 2 | display: flex; 3 | flex-direction: column; 4 | position: absolute; 5 | top: 0px; 6 | left: 0px; 7 | right: 0px; 8 | bottom: 0px; 9 | border: 8cqmin solid transparent; 10 | & > *:first-child { 11 | flex: 1; 12 | display: flex; 13 | flex-direction: column; 14 | align-items: center; 15 | justify-content: center; 16 | gap: 2cqmin; 17 | & > *:first-child { 18 | font-size: 8cqmin; 19 | } 20 | & > *:nth-child(3) { 21 | font-size: 150%; 22 | word-break: break-all; 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /client/src/views/Replay/Replay.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { GameStatus } from "../../../../shared/GameState"; 3 | import { getTempUserId } from "../../utils/tempUserId"; 4 | import Field from "../../components/Field/Field"; 5 | import Layout from "../../components/Layout/Layout"; 6 | import socketIO from "../../utils/Socket"; 7 | import Modal from "../../components/Modal/Modal"; 8 | import Button from "../../components/Button/Button"; 9 | import i18n from "../../utils/i18n"; 10 | import Settings from "../../../../shared/Settings"; 11 | import { setGameState, useGameState } from "../../utils/useGameState"; 12 | import styles from './Replay.module.scss'; 13 | 14 | 15 | const Replay = () => { 16 | 17 | const myUserId = getTempUserId(); 18 | const gameState = useGameState(); 19 | const { users, whosTurn, replayId } = gameState; 20 | const winner = (whosTurn === myUserId); 21 | const enemyUserId = Object.keys(users).find(k => k !== myUserId) || ''; 22 | const enemyName = users[enemyUserId].userName || ''; 23 | const iConfirm = Boolean(users[myUserId]?.confirm); 24 | 25 | const readyToReplayRequest = () => { 26 | setGameState({ 27 | ...gameState, 28 | users: { 29 | ...gameState.users, 30 | [myUserId]: { 31 | ...gameState.users[myUserId], 32 | confirm: true 33 | } 34 | } 35 | }); 36 | socketIO.emit('readyToReplayRequest', replayId, myUserId, enemyUserId); 37 | } 38 | 39 | const onReadyToReplayResponse = (replayId: string, withUserId: string) => { 40 | if (replayId !== gameState.replayId || withUserId !== enemyUserId) return; 41 | gameState.watchDog = Date.now() + (Settings.waitForPlayS * 1000); 42 | gameState.users[myUserId].map = {}; 43 | gameState.users[enemyUserId].map = {}; 44 | gameState.status = GameStatus.PLACESHIPS; 45 | setGameState(gameState); 46 | } 47 | 48 | useEffect(() => { 49 | socketIO.on('readyToReplayResponse', onReadyToReplayResponse); 50 | return () => { socketIO.off('readyToReplayResponse', onReadyToReplayResponse); } 51 | }, []) 52 | 53 | 54 | return 56 | 57 |
58 |
59 |
{winner ? i18n('YOU_WIN') : i18n('YOU_LOSE')}
60 |
{i18n('PLAY_MORE_WITH')}
61 |
{enemyName}?
62 |
63 | 66 |
67 |
68 | 69 | } field2={ 70 | 75 | } /> 76 | } 77 | 78 | export default Replay; -------------------------------------------------------------------------------- /client/src/views/WaitForContact/WaitForContact.module.scss: -------------------------------------------------------------------------------- 1 | .text { 2 | font-size: 8cqmin; 3 | } -------------------------------------------------------------------------------- /client/src/views/WaitForContact/WaitForContact.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import socketIO from "../../utils/Socket"; 3 | import { setGameState } from "../../utils/useGameState"; 4 | import GameState from "../../../../shared/GameState"; 5 | import Settings from "../../../../shared/Settings"; 6 | import inviteId from "../../utils/inviteId"; 7 | import Layout from "../../components/Layout/Layout"; 8 | import Banner from "../../components/Banner/Banner"; 9 | import styles from './WaitForContact.module.scss'; 10 | import i18n from "../../utils/i18n"; 11 | import ThreeDots from "../../components/ThreeDots/ThreeDots"; 12 | import { getTempUserId } from "../../utils/tempUserId"; 13 | import DemoField from "../../components/DemoField/DemoField"; 14 | 15 | const WaitForContact = () => { 16 | 17 | const myUserId = getTempUserId(); 18 | 19 | const onInviteResponse = (gameState: GameState) => { 20 | 21 | do { 22 | if (gameState.replayId !== inviteId) break; 23 | const userIds = Object.keys(gameState.users); 24 | const myId = userIds.find(u => u === myUserId); 25 | const enemyId = userIds.find(u => u !== myUserId); 26 | if (!myId || !enemyId || myId === enemyId) break; 27 | return setGameState({ 28 | ...gameState, 29 | watchDog: Date.now() + (Settings.waitForPlayP2PS * 1000) 30 | }); 31 | } while (0); 32 | 33 | socketIO.emit('inviteRequest', myUserId, inviteId); 34 | 35 | } 36 | 37 | useEffect(() => { 38 | socketIO.emit('inviteRequest', myUserId, inviteId); 39 | socketIO.on('inviteResponse', onInviteResponse); 40 | return () => { socketIO.off('inviteResponse', onInviteResponse); } 41 | }, []) 42 | 43 | return 45 | } field2={ 46 | 47 | 48 | {i18n('LOADING')} 49 | 50 | 51 | } /> 52 | 53 | } 54 | 55 | export default WaitForContact; -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext", 8 | "WebWorker" 9 | ], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "module": "esnext", 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "noEmit": true, 22 | "jsx": "react-jsx" 23 | }, 24 | "include": ["./src", "./globals.d.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /server/makeBundle.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const 4 | Path = require('path'), 5 | FS = require('fs-extra'), 6 | Browserify = require('browserify'), 7 | RecursiveReadDir = require('recursive-readdir'); 8 | 9 | const DIST_DIR = Path.resolve(__dirname, 'dist'); 10 | 11 | const produceBundle = () => new Promise(async(resolve) => { 12 | 13 | const browserify = Browserify({ 14 | bundleExternal: false, 15 | insertGlobalVars: { 16 | navigator: function() { return "undefined" }, 17 | Telegram: function() { return "undefined" }, 18 | __dirname: function () { return "__dirname"; } 19 | } 20 | }) 21 | 22 | browserify.plugin( 23 | require('esmify') 24 | ) 25 | 26 | for (const file of await RecursiveReadDir(DIST_DIR)) { 27 | if (!file.endsWith('.js')) continue; 28 | browserify.add(file); 29 | } 30 | 31 | browserify.bundle((error, result) => { 32 | result = (!error && result?.toString()) || ''; 33 | if (!result) return resolve(''); 34 | resolve(result); 35 | }); 36 | 37 | }); 38 | 39 | 40 | (async () => { 41 | const minified = await produceBundle(); 42 | await FS.emptyDir(DIST_DIR); 43 | await FS.writeFile(Path.resolve(DIST_DIR, './index.js'), minified); 44 | await FS.copyFile(Path.resolve(__dirname, './pm2.json'), Path.resolve(DIST_DIR, './pm2.json')); 45 | await FS.copyFile(Path.resolve(__dirname, './package.json'), Path.resolve(DIST_DIR, './package.json')); 46 | })(); -------------------------------------------------------------------------------- /server/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["*.*", "../shared/*"], 3 | "exec": "npx ts-node ./src/index.ts", 4 | "verbose": true, 5 | "ext": "ts" 6 | } 7 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "nodemon", 4 | "build": "rm -rf dist && mkdir -p dist && yarn tsc && ./makeBundle.js" 5 | }, 6 | "dependencies": { 7 | "uuid": "^9.0.1", 8 | "cheerio": "^1.0.0-rc.12", 9 | "request": "^2.88.2", 10 | "socket.io": "^4.7.2", 11 | "uuid": "^9.0.1" 12 | }, 13 | "devDependencies": { 14 | "browserify": "^17.0.0", 15 | "esmify": "^2.1.1", 16 | "fs-extra": "^11.1.1", 17 | "nodemon": "^3.0.1", 18 | "recursive-readdir": "^2.2.3", 19 | "typescript": "^5.2.2" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/pm2.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps" : [{ 3 | "time": true, 4 | "name": "naval_clash_server", 5 | "script": "./index.js", 6 | "watch": false, 7 | "kill_timeout": 30000, 8 | "node_args": "--optimize_for_size --gc_interval=100" 9 | }] 10 | } 11 | -------------------------------------------------------------------------------- /server/src/Telegram.ts: -------------------------------------------------------------------------------- 1 | // import FS from 'fs'; 2 | // import Path from 'path'; 3 | import Request from 'request'; 4 | import Settings from "../../shared/Settings"; 5 | import formatText from './formatText'; 6 | 7 | type ObjectLiteral = {[key: string]: any}; 8 | 9 | const performRequest = (method: string, payload?: ObjectLiteral) => new Promise(resolve => { 10 | Request.post({ 11 | uri: `${Settings.telegramBotApiUrl}/bot${Settings.telegramBotToken}/${method}`, 12 | timeout: Settings.telegramBotRequestTimeoutMs, 13 | json: payload 14 | }, (_error, _response, body) => { 15 | if (typeof body === 'string') { 16 | try { body = JSON.parse(body); } 17 | catch (e) {} 18 | } 19 | resolve(body instanceof Object ? body : {}); 20 | }); 21 | }); 22 | 23 | const getWebhookUrl = async(): Promise => { 24 | const body = await performRequest('getWebhookInfo'); 25 | const result = (body.ok === true ? body?.result?.url : ''); 26 | return (typeof result === 'string' ? result : ''); 27 | }; 28 | 29 | const setWebhookUrl = async(url: string): Promise => { 30 | const body = await performRequest('setWebhook', { url }); 31 | return (body.ok === true); 32 | }; 33 | 34 | export const deleteWebhookUrl = async(): Promise => { 35 | const body = await performRequest('deleteWebhook'); 36 | return (body.ok === true); 37 | }; 38 | 39 | export const updateWebhookUrl = async(url: string): Promise => { 40 | console.info('delete', await deleteWebhookUrl()); 41 | console.info('set', await setWebhookUrl(url)); 42 | return (await getWebhookUrl() === url); 43 | } 44 | 45 | export const sendMessage = async(text: string, chat_id: number, extraFields?: ObjectLiteral): Promise => { 46 | const body = await performRequest('sendMessage', { 47 | text: formatText(text), 48 | parse_mode: 'HTML', 49 | chat_id, 50 | ...(extraFields || {}) 51 | }); 52 | return (body?.ok === true); 53 | } 54 | 55 | export const answerCallbackQuery = (callback_query_id: string) => { 56 | performRequest('answerCallbackQuery', { callback_query_id }); 57 | } -------------------------------------------------------------------------------- /server/src/formatText.ts: -------------------------------------------------------------------------------- 1 | import { load } from "cheerio"; 2 | 3 | const formatText = (text: string) => { 4 | text = text.trim(); 5 | text = text.replace(/[\n\t]+/g, ' '); 6 | text = text.replace(/[\n\t\s]*(|
)[\n\t\s]*/g, '\n'); 7 | 8 | const $ = load(text, { 9 | decodeEntities: false 10 | }); 11 | 12 | $('*').each(function() { 13 | var element = $(this); 14 | // @ts-ignore 15 | var tagName = this.tagName; 16 | if (![ 17 | 'html', 'body', 18 | 'b', 'strong', 19 | 'i', 'em', 20 | 'u', 'ins', 21 | 's', 'strike', 'del', 22 | 'a', 23 | 'code', 'pre', 24 | ].includes(tagName)) { 25 | element.replaceWith(element.text()); 26 | } 27 | }); 28 | 29 | return $('body').html(); 30 | } 31 | 32 | export default formatText; -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import HTTP from 'http'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | import { Server } from 'socket.io'; 4 | import { answerCallbackQuery, sendMessage, updateWebhookUrl } from './Telegram'; 5 | import Settings from '../../shared/Settings'; 6 | import ServerToClientEvents from '../../shared/ServerToClientEvents'; 7 | import ClientToServerEvents from '../../shared/ClientToServerEvents'; 8 | import getRandomInt from '../../shared/getRandomInt'; 9 | import { GameStatus } from '../../shared/GameState'; 10 | import Map from '../../shared/Map'; 11 | 12 | const MISSED_SHOTS: {[userId: string]: Array<[number, string, number]>} = {}; 13 | 14 | interface SocketData { 15 | map: Map; 16 | userId: string; 17 | userName: string; 18 | replayId: string; 19 | inviteId: string; 20 | persistentUserId: string; 21 | } 22 | 23 | 24 | const socketIO = new Server({ 25 | path: Settings.socketIoPath, 26 | cors: { 27 | origin: "*", 28 | methods: ["GET", "POST"] 29 | } 30 | }); 31 | 32 | const getRandomUserName = (locale: string) => { 33 | const names = (locale === 'en' ? Settings.randomNamesEn : locale === 'uk' ? Settings.randomNamesUa : Settings.randomNamesRu); 34 | const ranks = (locale === 'en' ? Settings.randomRanksEn : locale === 'uk' ? Settings.randomRanksUa : Settings.randomRanksRu); 35 | return [names[getRandomInt(0, names.length - 1)], ranks[getRandomInt(0, ranks.length - 1)]].join(' ') 36 | } 37 | 38 | const cleanupMissedShots = () => { 39 | for (const userId in MISSED_SHOTS) { 40 | 41 | MISSED_SHOTS[userId] = MISSED_SHOTS[userId].filter(missedAction => { 42 | return Date.now() - missedAction[0] <= 1000 * 60; 43 | }); 44 | 45 | if (!MISSED_SHOTS[userId].length) { 46 | delete MISSED_SHOTS[userId]; 47 | } 48 | 49 | } 50 | setTimeout(cleanupMissedShots, 1000 * 30); 51 | } 52 | 53 | const getPositiveInteger = (value: any): number => { 54 | if (Number.isInteger(value) && value > 0) { 55 | return value; 56 | } 57 | return 0; 58 | } 59 | 60 | const getNonEmptyString = (value: any): string => { 61 | if (typeof value === 'string' && value.trim()) { 62 | return value.trim(); 63 | } 64 | return ''; 65 | } 66 | 67 | const closeSocket = (socket) => { 68 | delete socket.data.map; 69 | delete socket.data.userName; 70 | delete socket.data.replayId; 71 | delete socket.data.inviteId; 72 | delete socket.data.persistentUserId; 73 | socket.removeAllListeners(); 74 | socket.disconnect(); 75 | } 76 | 77 | const startBattle = (socket1, socket2, whosTurn?: string) => { 78 | 79 | const userIds = [socket1.data.userId, socket2.data.userId]; 80 | 81 | whosTurn = getNonEmptyString(whosTurn); 82 | 83 | 84 | const data = { 85 | replayId: uuidv4(), 86 | watchDog: 0, 87 | status: GameStatus.ACTIVE, 88 | whosTurn: ( 89 | userIds.includes(whosTurn) ? 90 | whosTurn : userIds[getRandomInt(0, 1)] 91 | ), 92 | users: { 93 | [userIds[0]]: { 94 | map: socket1.data.map, 95 | userName: socket1.data.userName, 96 | }, 97 | [userIds[1]]: { 98 | map: socket2.data.map, 99 | userName: socket2.data.userName, 100 | } 101 | } 102 | }; 103 | 104 | socket1.emit('startGameResponse', data); 105 | socket2.emit('startGameResponse', data); 106 | 107 | delete socket1.data.map; 108 | delete socket1.data.replayId; 109 | 110 | delete socket2.data.map; 111 | delete socket2.data.replayId; 112 | } 113 | 114 | socketIO.on('connection', async(socket) => { 115 | 116 | const persistentUserId = getNonEmptyString(socket?.handshake?.auth?.persistentUserId); 117 | if (!persistentUserId) return closeSocket(socket); 118 | socket.data.persistentUserId = persistentUserId; 119 | 120 | socket.data.userName = (() => { 121 | let locale = getNonEmptyString(socket?.handshake?.auth?.locale).toLowerCase(); 122 | locale = ['en', 'ru', 'uk'].includes(locale) ? locale : 'en'; 123 | const result = getNonEmptyString(socket?.handshake?.auth?.userName); 124 | return (result ? result : getRandomUserName(locale)); 125 | })(); 126 | 127 | while (MISSED_SHOTS[socket.data.userId]?.length) { 128 | const action = MISSED_SHOTS[socket.data.userId].shift(); 129 | socket.emit('shot', action[1], action[2]); 130 | } 131 | 132 | // sent by client when he is waiting for friend's connection 133 | socket.on('inviteRequest', async(fromUserId: string, inviteId: string) => { 134 | 135 | fromUserId = getNonEmptyString(fromUserId); 136 | inviteId = getNonEmptyString(inviteId); 137 | if (!fromUserId || !inviteId) return; 138 | 139 | socket.data.userId = fromUserId; 140 | socket.data.inviteId = inviteId; 141 | 142 | const enemySocket = (await socketIO.fetchSockets()).find(socket => ( 143 | socket.data.persistentUserId !== persistentUserId && 144 | socket.data.userId !== fromUserId && 145 | socket.data.inviteId === inviteId 146 | )); 147 | 148 | if (!enemySocket) return; 149 | const enemyUserId = enemySocket.data.userId; 150 | 151 | delete socket.data.userId; 152 | delete socket.data.inviteId; 153 | delete enemySocket.data.userId; 154 | delete enemySocket.data.inviteId; 155 | 156 | 157 | const data = { 158 | replayId: inviteId, 159 | watchDog: 0, 160 | status: GameStatus.PLACESHIPS, 161 | whosTurn: [fromUserId, enemyUserId][getRandomInt(0, 1)], 162 | users: { 163 | [fromUserId]: { 164 | map: {}, 165 | userName: socket.data.userName, 166 | }, 167 | [enemyUserId]: { 168 | map: {}, 169 | userName: enemySocket.data.userName, 170 | } 171 | } 172 | }; 173 | 174 | socket.emit('inviteResponse', data); 175 | enemySocket.emit('inviteResponse', data); 176 | 177 | }); 178 | 179 | socket.on('startGameRequest', async(map: Map, fromUserId: string, replayId?: string, withUserId?: string, whosTurn?: string) => { 180 | 181 | fromUserId = getNonEmptyString(fromUserId); 182 | if (!fromUserId) return; 183 | socket.data.userId = fromUserId; 184 | 185 | replayId = getNonEmptyString(replayId); 186 | withUserId = getNonEmptyString(withUserId); 187 | whosTurn = getNonEmptyString(whosTurn); 188 | 189 | if (typeof map !== 'object' || !map) return; 190 | socket.data.map = map; 191 | 192 | const allSockets = await socketIO.fetchSockets(); 193 | 194 | 195 | if (replayId && withUserId && [fromUserId, withUserId].includes(whosTurn)) { 196 | socket.data.replayId = replayId; 197 | const withSocket = allSockets.find(s => ( 198 | s.data.persistentUserId !== persistentUserId && 199 | s.data.replayId === replayId && 200 | s.data.map && 201 | s.data.userId === withUserId 202 | )); 203 | if (!withSocket) return; 204 | startBattle(socket, withSocket, whosTurn); 205 | } else { 206 | const readyToBattle: typeof allSockets = []; 207 | for (const socket of allSockets) { 208 | if (!socket.data.map) continue; 209 | if (socket.data.inviteId) continue; 210 | if (socket.data.replayId) continue; 211 | readyToBattle.push(socket); 212 | while (readyToBattle.length >= 2) { 213 | const first = readyToBattle.shift(); 214 | const second = readyToBattle.shift(); 215 | startBattle(first, second); 216 | } 217 | } 218 | } 219 | 220 | }); 221 | 222 | socket.on('shot', async(fromUserId: string, toUserId: string, index: number) => { 223 | fromUserId = getNonEmptyString(fromUserId); 224 | toUserId = getNonEmptyString(toUserId); 225 | if (!fromUserId || !toUserId || fromUserId === toUserId) return; 226 | if (socket.data.userId !== fromUserId) return; 227 | if (!Number.isInteger(index) || index < 0 || index > 99) return; 228 | const toSocket = (await socketIO.fetchSockets()).find(s => ( 229 | s.data.persistentUserId !== persistentUserId && 230 | s.data.userId === toUserId 231 | )); 232 | if (!toSocket) { 233 | if (!MISSED_SHOTS[toUserId]) MISSED_SHOTS[toUserId] = []; 234 | MISSED_SHOTS[toUserId].push([ Date.now(), fromUserId, index ]); 235 | } else { 236 | toSocket.emit('shot', fromUserId, index); 237 | } 238 | }); 239 | 240 | // sent by client when he wants to play with same player again 241 | socket.on('readyToReplayRequest', async(replayId: string, fromUserId: string, withUserId: string) => { 242 | fromUserId = getNonEmptyString(fromUserId); 243 | withUserId = getNonEmptyString(withUserId); 244 | replayId = getNonEmptyString(replayId); 245 | if (!replayId || !fromUserId || !withUserId || fromUserId === withUserId) return; 246 | if (socket.data.userId !== fromUserId) return; 247 | socket.data.replayId = replayId; 248 | const withSocket = (await socketIO.fetchSockets()).find(s => ( 249 | s.data.persistentUserId !== persistentUserId && 250 | s.data.replayId === replayId && 251 | s.data.userId === withUserId 252 | )); 253 | if (!withSocket) return; 254 | delete socket.data.replayId; 255 | delete withSocket.data.replayId; 256 | socket.emit('readyToReplayResponse', replayId, withUserId); 257 | withSocket.emit('readyToReplayResponse', replayId, fromUserId); 258 | }); 259 | 260 | socket.once('disconnect', () => { 261 | closeSocket(socket); 262 | }); 263 | 264 | }); 265 | 266 | socketIO.listen(Settings.socketIoPort); 267 | 268 | cleanupMissedShots(); 269 | 270 | 271 | 272 | 273 | /* TELGRAM BOT */ 274 | 275 | console.info('bot settings:', { 276 | telegramBotPort: Settings.telegramBotPort, 277 | telegramBotToken: Settings.telegramBotToken, 278 | telegramWebhookUrl: Settings.telegramWebhookUrl 279 | }) 280 | 281 | if (Number.isInteger(Settings.telegramBotPort) && 282 | typeof Settings.telegramBotToken === 'string' && 283 | typeof Settings.telegramWebhookUrl === 'string') { 284 | (async() => { 285 | 286 | await updateWebhookUrl(Settings.telegramWebhookUrl); 287 | 288 | HTTP.createServer((request, response) => { 289 | 290 | let body: any = ''; 291 | 292 | request.on('data', (chunk) => body += chunk.toString('utf8')); 293 | request.on('end', () => { 294 | 295 | try { body = JSON.parse(body) } 296 | catch (e) {} 297 | if (typeof body !== 'object' || !body) return; 298 | 299 | 300 | // console.info(body); 301 | 302 | const entity = (body?.callback_query || body?.message); 303 | const myUserId = getPositiveInteger(entity?.from?.id); 304 | const replyInRussian = (getNonEmptyString(entity?.from?.language_code) === 'ru'); 305 | const replyInUkrainian = (getNonEmptyString(entity?.from?.language_code) === 'uk'); 306 | 307 | console.info({ replyInUkrainian }) 308 | 309 | if (!myUserId) return; 310 | 311 | const callbackQueryId = getNonEmptyString(body?.callback_query?.id); 312 | if (callbackQueryId) answerCallbackQuery(callbackQueryId); 313 | 314 | const callbackQueryData = getNonEmptyString(body?.callback_query?.data); 315 | 316 | if (callbackQueryData === 'create_game') { 317 | const inviteId = uuidv4(); 318 | if (replyInRussian) { 319 | sendMessage(` 320 | Хорошо, вот ссылка для игры с другом, отправь ее тому с кем ты хочешь сыграть. 321 | Затем нажми на нее и жди пока друг присоединится к твоей игре 322 | https://t.me/naval_clash_bot/play?startapp=${Buffer.from(inviteId).toString('base64')} 323 | `, myUserId, { disable_notification: true }) 324 | } 325 | 326 | else if (replyInUkrainian) { 327 | sendMessage(` 328 | Добре, ось посилання для гри з другом, відправь її тому, з ким бажаєш зіграти. 329 | Потім натисни на неї та очікуй доки друг доєднається до твоєї гри 330 | https://t.me/naval_clash_bot/play?startapp=${Buffer.from(inviteId).toString('base64')} 331 | `, myUserId, { disable_notification: true }) 332 | } 333 | 334 | else { 335 | sendMessage(` 336 | Okay, here is the link to play with the friend, send it to one of your friends. 337 | Then click on it and wait until your friend will join the game 338 | https://t.me/naval_clash_bot/play?startapp=${Buffer.from(inviteId).toString('base64')} 339 | `, myUserId, { disable_notification: true }) 340 | } 341 | } 342 | 343 | else if (callbackQueryData === 'send_link') { 344 | if (replyInRussian) { 345 | sendMessage(` 346 | Хорошо, вот ссылка для игры со случайным противником, просто нажми и жди 347 | пока кто-нибудь присоединится к твоей игре 348 | https://t.me/naval_clash_bot/play 349 | `, myUserId, { disable_notification: true }) 350 | } 351 | 352 | else if (replyInUkrainian) { 353 | sendMessage(` 354 | Добре, ось посилання для гри з випадковим супротивником, просто натисни та очікуй 355 | доки хто-небудь доєднається до твоєї гри 356 | https://t.me/naval_clash_bot/play 357 | `, myUserId, { disable_notification: true }) 358 | } 359 | 360 | else { 361 | sendMessage(` 362 | Okay, here is the link to play with whoever wants to join, just click on it 363 | and wait until somebody will join your game 364 | https://t.me/naval_clash_bot/play 365 | `, myUserId, { disable_notification: true }) 366 | } 367 | } 368 | 369 | else if (replyInRussian) { 370 | 371 | sendMessage(`Привет, с кем ты хочешь поиграть?`, myUserId, { 372 | disable_notification: true, 373 | reply_markup: { 374 | inline_keyboard: [ 375 | [{ 376 | text: 'С другом', 377 | callback_data: 'create_game' 378 | }], 379 | [{ 380 | text: 'Со случайным противником', 381 | callback_data: 'send_link' 382 | }] 383 | ] 384 | } 385 | }); 386 | 387 | } 388 | 389 | else if (replyInUkrainian) { 390 | 391 | sendMessage(`Привіт, з ким ти хочеш зіграти?`, myUserId, { 392 | disable_notification: true, 393 | reply_markup: { 394 | inline_keyboard: [ 395 | [{ 396 | text: 'З другом', 397 | callback_data: 'create_game' 398 | }], 399 | [{ 400 | text: 'З випадковим супротивником', 401 | callback_data: 'send_link' 402 | }] 403 | ] 404 | } 405 | }); 406 | 407 | } 408 | 409 | else { 410 | 411 | sendMessage(`Hi, who do you want to play with this time?`, myUserId, { 412 | disable_notification: true, 413 | reply_markup: { 414 | inline_keyboard: [ 415 | [{ 416 | text: 'With on of my friends', 417 | callback_data: 'create_game' 418 | }], 419 | [{ 420 | text: 'With anyone whoever wants to join', 421 | callback_data: 'send_link' 422 | }] 423 | ] 424 | } 425 | }); 426 | 427 | 428 | } 429 | 430 | 431 | 432 | }); 433 | 434 | response.end(); 435 | 436 | }).listen(Settings.telegramBotPort, '127.0.0.1'); 437 | 438 | })(); 439 | } 440 | 441 | else { 442 | console.info('Incorrect settings, bot won\'t start'); 443 | } -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "strictNullChecks": true, 4 | "compilerOptions": { 5 | "noUnusedLocals": false, 6 | "noUnusedParameters": false, 7 | "declaration": true, 8 | "removeComments": true, 9 | "module": "commonjs", 10 | "target": "es6", 11 | "noImplicitAny": false, 12 | "types": [ 13 | "node", 14 | ], 15 | "outDir": "dist", 16 | "esModuleInterop": true 17 | }, 18 | "include": ["./src", "../shared"] 19 | } 20 | -------------------------------------------------------------------------------- /shared/CellType.ts: -------------------------------------------------------------------------------- 1 | enum CellType { 2 | SEA = 1, 3 | SHIP = 2, 4 | HIT = 3 5 | } 6 | 7 | export default CellType; -------------------------------------------------------------------------------- /shared/ClientToServerEvents.ts: -------------------------------------------------------------------------------- 1 | import Map from "./Map"; 2 | 3 | export default interface ClientToServerEvents { 4 | inviteRequest: (fromUserId: string, inviteId: string) => void; 5 | startGameRequest: (map: Map, fromUserId: string, replayId?: string, withUserId?: string, whosTurn?: string) => void; 6 | shot: (fromUserId: string, toUserId: string, index: number) => void; 7 | readyToReplayRequest: (replayId: string, fromUserId: string, withUserId: string) => void; 8 | } -------------------------------------------------------------------------------- /shared/GameState.ts: -------------------------------------------------------------------------------- 1 | export enum GameStatus { 2 | ACTIVE, 3 | WAITING_FOR_REPLAY, 4 | PLACESHIPS, 5 | WAIT_FOR_CONTACT 6 | } 7 | 8 | interface GameState { 9 | 10 | replayId: string; 11 | status: GameStatus; 12 | watchDog: number; 13 | whosTurn: string; 14 | 15 | users: { 16 | [userId: string]: { 17 | confirm?: boolean; 18 | userName: string; 19 | map: {[index: number]: number} 20 | } 21 | } 22 | } 23 | 24 | export default GameState; -------------------------------------------------------------------------------- /shared/Map.ts: -------------------------------------------------------------------------------- 1 | import CellType from './CellType'; 2 | 3 | type Map = {[index: string]: CellType }; 4 | 5 | export default Map; -------------------------------------------------------------------------------- /shared/ServerToClientEvents.ts: -------------------------------------------------------------------------------- 1 | import GameState from "./GameState"; 2 | 3 | export default interface ServerToClientEvents { 4 | inviteResponse: (gameState: GameState) => void; 5 | shot: (fromUserId: string, index: number) => void; 6 | readyToReplayResponse: (replayId: string, withUserId: string) => void; 7 | startGameResponse: (gameState: GameState) => void; 8 | } -------------------------------------------------------------------------------- /shared/Settings.ts: -------------------------------------------------------------------------------- 1 | const process = ( 2 | typeof global?.process === 'object' && 3 | typeof global?.process?.env === 'object' && 4 | global?.process 5 | ); 6 | 7 | const Settings = { 8 | 9 | // various frontend settings 10 | siteTitle: "Naval Clash", 11 | theme_color: "#517DA2", 12 | CACHE_NAME: "naval_clash_bot", 13 | 14 | // socket.io settings 15 | socketIoPort: 3495, 16 | socketIoPath: "/api/", 17 | socketIoHost: 'https://new.videotam.ru', 18 | 19 | // how long to wait for the next shot 20 | waitForShotS: 30, 21 | 22 | // when to start counting about shot timeout 23 | waitForShotShowS: 5, 24 | 25 | // how long to wait for replay 26 | waitForReplayS: 20, 27 | 28 | // how long to wait on place ship screen when playing with the stranger 29 | waitForPlayS: 45, 30 | 31 | // how long to wait on place ship screen when playing with the friend 32 | waitForPlayP2PS: 60, 33 | 34 | /* TELEGRAM BOT SETTINGS */ 35 | 36 | // telegram api url (just in case if you need to change it for self hosted bots) 37 | // see https://core.telegram.org/bots/api#using-a-local-bot-api-server 38 | telegramBotApiUrl: "https://api.telegram.org", 39 | 40 | telegramBotRequestTimeoutMs: 1000, 41 | telegramBotToken: process?.env?.telegramBotToken, 42 | telegramWebhookUrl: process?.env?.telegramWebhookUrl, 43 | telegramBotPort: parseInt(process?.env?.telegramBotPort, 10), 44 | 45 | // various backend settings 46 | 47 | randomNamesUa: [ 48 | "Володимир", 49 | "Михайло", 50 | "Дмитро", 51 | "Павло", 52 | "Олексій", 53 | "Руслан", 54 | "Іван" 55 | ], 56 | 57 | randomRanksUa: [ 58 | "Моряк", 59 | "Юнга", 60 | "Шкіпер", 61 | "Капітан", 62 | "Боцман", 63 | "Штурман" 64 | ], 65 | 66 | randomNamesEn: [ 67 | "Vladimir", 68 | "Mikhail", 69 | "Dmitriy", 70 | "Pavel", 71 | "Alexey", 72 | "Ruslan", 73 | "Ivan" 74 | ], 75 | 76 | randomNamesRu: [ 77 | "Владимир", 78 | "Михаил", 79 | "Дмитрий", 80 | "Павел", 81 | "Алексей", 82 | "Руслан", 83 | "Иван" 84 | ], 85 | 86 | randomRanksEn: [ 87 | "Sailor", 88 | "Shipboy", 89 | "Skipper", 90 | "Captain", 91 | "Boatswain", 92 | "Navigator" 93 | ], 94 | 95 | randomRanksRu: [ 96 | "Моряк", 97 | "Юнга", 98 | "Шкипер", 99 | "Капитан", 100 | "Боцман", 101 | "Штурман" 102 | ], 103 | 104 | } 105 | 106 | export default Settings; -------------------------------------------------------------------------------- /shared/ShotResult.ts: -------------------------------------------------------------------------------- 1 | enum ShotResult { 2 | NOTHING, 3 | HIT_SEA, 4 | HIT_SHIP, 5 | KILL_SHIP, 6 | GAME_OVER 7 | } 8 | 9 | export default ShotResult; -------------------------------------------------------------------------------- /shared/generateUniqueId.ts: -------------------------------------------------------------------------------- 1 | import getRandomInt from './getRandomInt'; 2 | 3 | const DEFAULT_UNIQUE_ID_LENGTH = 32; 4 | 5 | const getRandomBoolean = (): boolean => { 6 | return Boolean(Math.round(Math.random())); 7 | }; 8 | 9 | const getRandomLetter = (): string => { 10 | let letter = String.fromCharCode(getRandomInt(97, 122)); 11 | if (getRandomBoolean()) letter = letter.toUpperCase(); 12 | return letter; 13 | }; 14 | 15 | const getRandomLetterOrDigit = (): string => { 16 | if (getRandomBoolean()) return String(getRandomInt(0, 9)); 17 | return getRandomLetter(); 18 | }; 19 | 20 | const generateUniqueId = (length?: number, prefix?: string): string => { 21 | let result = ''; 22 | let flag = false; 23 | if (!length) length = DEFAULT_UNIQUE_ID_LENGTH; 24 | const time = new Date().getTime().toString(16).split(''); 25 | while (result.length < length) { 26 | let tc = ((flag = !flag) && time.shift()); 27 | if (!tc) tc = getRandomLetterOrDigit(); 28 | else if (getRandomBoolean()) tc = tc.toUpperCase(); 29 | result += tc; 30 | } 31 | return (prefix || '') + result; 32 | }; 33 | 34 | export default generateUniqueId; -------------------------------------------------------------------------------- /shared/getRandomInt.ts: -------------------------------------------------------------------------------- 1 | const getRandomInt = (min: number, max: number): number => { 2 | return Math.floor(Math.random() * (max - min + 1)) + min 3 | } 4 | 5 | export default getRandomInt; -------------------------------------------------------------------------------- /shared/mapUtils.ts: -------------------------------------------------------------------------------- 1 | import Map from "./Map"; 2 | import ShotResult from './ShotResult'; 3 | import CellType from "./CellType"; 4 | import getRandomInt from "./getRandomInt"; 5 | 6 | const SHIPS = [4, 3, 3, 2, 2, 2, 1, 1, 1, 1]; 7 | 8 | const createMatrix = () => [ 9 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 10 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 11 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 12 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 13 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 14 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 15 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 16 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 17 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 18 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 19 | ]; 20 | 21 | const placeShip = (matrix: ReturnType, size: number): boolean => { 22 | 23 | const fieldSize = 10; 24 | const aMax = fieldSize - 1; 25 | const bMax = fieldSize - size; 26 | 27 | if (getRandomInt(0, 1)) { 28 | 29 | const x = getRandomInt(0, bMax); 30 | const y = getRandomInt(0, aMax); 31 | 32 | if (x > 0) { 33 | if (matrix[y][x - 1]) return false; 34 | if (y > 0 && matrix[y - 1][x - 1]) return false; 35 | if (y < aMax && matrix[y + 1][x - 1]) return false; 36 | } 37 | 38 | if ((x + size) < fieldSize) { 39 | if (matrix[y][x + size]) return false; 40 | if (y > 0 && matrix[y - 1][x + size]) return false; 41 | if (y < aMax && matrix[y + 1][x + size]) return false; 42 | } 43 | 44 | for (let c = x; c < x + size; c++) { 45 | if (y > 0 && matrix[y - 1][c]) return false; 46 | if (y < aMax && matrix[y + 1][c]) return false; 47 | if (matrix[y][c]) return false; 48 | matrix[y][c] = 1; 49 | } 50 | } 51 | 52 | else { 53 | 54 | 55 | const x = getRandomInt(0, aMax); 56 | const y = getRandomInt(0, bMax); 57 | 58 | if (y > 0) { 59 | if (matrix[y - 1][x]) return false; 60 | if (x > 0 && matrix[y - 1][x - 1]) return false; 61 | if (x < aMax && matrix[y - 1][x + 1]) return false; 62 | } 63 | 64 | if ((y + size) < fieldSize) { 65 | if (matrix[y + size][x]) return false; 66 | if (x > 0 && matrix[y + size][x - 1]) return false; 67 | if (x < aMax && matrix[y + size][x + 1]) return false; 68 | } 69 | 70 | for (let c = y; c < y + size; c++) { 71 | if (x > 0 && matrix[c][x - 1]) return false; 72 | if (x < aMax && matrix[c][x + 1]) return false; 73 | if (matrix[c][x]) return false; 74 | matrix[c][x] = 1; 75 | } 76 | } 77 | 78 | return true; 79 | 80 | } 81 | 82 | const xy2index = (x: number, y: number): number => { 83 | if (x < 0) return Infinity; 84 | if (y < 0) return Infinity; 85 | if (x > 9) return Infinity; 86 | if (y > 9) return Infinity; 87 | return y * 10 + x 88 | } 89 | 90 | const index2xy = (index: number): DOMPoint => { 91 | let x = index % 10; 92 | let y = Math.floor(index / 10); 93 | return new DOMPoint(x, y); 94 | } 95 | 96 | const indexOffset = (index: number, xOff: number, yOff: number): number => { 97 | 98 | let {x, y} = index2xy(index) 99 | 100 | x += xOff; 101 | y += yOff; 102 | 103 | if (x < 0) x = Infinity; 104 | if (x > 9) x = Infinity; 105 | if (y < 0) y = Infinity; 106 | if (y > 9) y = Infinity; 107 | 108 | return xy2index(x, y); 109 | } 110 | 111 | const isGameOver = (map: Map) => ( 112 | Object.values(map).every(i => [CellType.SEA, CellType.HIT].includes(i)) 113 | ) 114 | 115 | const isSea = (cell: any): boolean => { 116 | return cell === CellType.SEA; 117 | } 118 | 119 | const isShip = (cell: any): boolean => { 120 | return [CellType.SHIP, CellType.HIT].includes(cell); 121 | } 122 | 123 | const isHitShip = (cell: any): boolean => { 124 | return cell === CellType.HIT; 125 | } 126 | 127 | const isFresh = (map: Map) => !( 128 | Object.values(map).some(entity => isSea(entity) || isHitShip(entity)) 129 | ) 130 | 131 | 132 | const fillBordersAndGetDeadCells = (map: Map): number[] => { 133 | 134 | 135 | const ships: number[][] = [ 136 | 137 | ] 138 | 139 | for (let index = 0; index < 100; index++) { 140 | 141 | if (!isShip(map[index])) continue; 142 | 143 | const xminus1 = isShip(map[indexOffset(index, -1, 0)]); 144 | const yminus1 = isShip(map[indexOffset(index, 0, -1)]); 145 | 146 | const xplus1 = isShip(map[indexOffset(index, 1, 0)]); 147 | const xplus2 = isShip(map[indexOffset(index, 2, 0)]); 148 | const xplus3 = isShip(map[indexOffset(index, 3, 0)]); 149 | 150 | const yplus1 = isShip(map[indexOffset(index, 0, 1)]); 151 | const yplus2 = isShip(map[indexOffset(index, 0, 2)]); 152 | const yplus3 = isShip(map[indexOffset(index, 0, 3)]); 153 | 154 | if (!xminus1 && xplus1 && xplus2 && xplus3) { 155 | ships.push([index, index + 1, index + 2, index + 3]); 156 | } 157 | 158 | else if (!xminus1 && xplus1 && xplus2) { 159 | ships.push([index, index + 1, index + 2]); 160 | } 161 | 162 | else if (!xminus1 && xplus1) { 163 | ships.push([index, index + 1]); 164 | } 165 | 166 | else if (!yminus1 && yplus1 && yplus2 && yplus3) { 167 | ships.push([index, index + (10 * 1), index + (10 * 2), index + (10 * 3)]); 168 | } 169 | 170 | else if (!yminus1 && yplus1 && yplus2) { 171 | ships.push([index, index + (10 * 1), index + (10 * 2)]); 172 | } 173 | 174 | else if (!yminus1 && yplus1) { 175 | ships.push([index, index + (10 * 1)]); 176 | } 177 | 178 | else if (!yminus1 && !yplus1 && !xminus1 && !xplus1) { 179 | ships.push([index]); 180 | } 181 | 182 | 183 | } 184 | 185 | 186 | const deadCells: number[] = []; 187 | 188 | for (const cells of ships) { 189 | const isDead = cells.every(index => map[index] === 3); 190 | if (!isDead) continue; 191 | 192 | 193 | for (const index of cells) { 194 | deadCells.push(index); 195 | const { x, y } = index2xy(index); 196 | for (let xx = x - 1; xx <= x + 1; xx++) { 197 | for (let yy = y - 1; yy <= y + 1; yy++) { 198 | const index = xy2index(xx, yy); 199 | if (map[index]) continue; 200 | map[index] = CellType.SEA; 201 | } 202 | } 203 | } 204 | 205 | } 206 | 207 | return deadCells; 208 | 209 | 210 | } 211 | 212 | const makeShot = (map: Map, index: number): ShotResult => { 213 | const cellType = map[index]; 214 | 215 | if (!cellType) { 216 | map[index] = CellType.SEA; 217 | return ShotResult.HIT_SEA; 218 | } 219 | 220 | if (cellType !== CellType.SHIP) { 221 | return ShotResult.NOTHING; 222 | } 223 | 224 | map[index] = CellType.HIT; 225 | const deadCells = fillBordersAndGetDeadCells(map); 226 | if (isGameOver(map)) return ShotResult.GAME_OVER; 227 | if (deadCells.includes(index)) return ShotResult.KILL_SHIP; 228 | return ShotResult.HIT_SHIP; 229 | 230 | } 231 | 232 | const convertOldMapToNew = (map: Map): Map => { 233 | const result: Map = {}; 234 | for (const index in map) result[index] = CellType.SHIP; 235 | return result; 236 | } 237 | 238 | const convertNewMapToOld = (map: Map): Map => { 239 | 240 | 241 | const ships: any = []; 242 | 243 | for (const indexStr in map) { 244 | const index = parseInt(indexStr, 10); 245 | 246 | if (ships.flat(Infinity).includes(index)) continue; 247 | 248 | const cells = [index]; 249 | let left = true, right = true, bottom = true, top = true; 250 | for (let c = 0; c < 4; c++) { 251 | let ind = 0; 252 | if (left && (left = !!map[ind = indexOffset(index, -(c + 1), 0)])) cells.push(ind); 253 | if (right && (right = !!map[ind = indexOffset(index, (c + 1), 0)])) cells.push(ind); 254 | if (top && (top = !!map[ind = indexOffset(index, 0, -(c + 1))])) cells.push(ind); 255 | if (bottom && (bottom = !!map[ind = indexOffset(index, 0, (c + 1))])) cells.push(ind); 256 | } 257 | 258 | 259 | ships.push(cells); 260 | 261 | } 262 | 263 | 264 | const oldMap: Map = {}; 265 | for (let index = 0; index < 10; index++) { 266 | const indexes = ships[index]; 267 | 268 | 269 | for (let x of indexes) { 270 | oldMap[x] = index + 1 << 2 | 0 271 | } 272 | 273 | 274 | } 275 | 276 | return oldMap; 277 | 278 | 279 | 280 | 281 | } 282 | 283 | const generateMap = (): Map => { 284 | 285 | const result: Map = {}; 286 | let matrix = createMatrix(); 287 | 288 | iteration: for (;;) { 289 | for (let index = 0; index < SHIPS.length; index++) { 290 | if (!placeShip(matrix, SHIPS[index])) { 291 | matrix = createMatrix(); 292 | continue iteration; 293 | } 294 | } 295 | break; 296 | } 297 | 298 | for (let index = 0; index < 100; index++) { 299 | const { x, y } = index2xy(index); 300 | if (!matrix?.[x]?.[y]) continue; 301 | result[index] = CellType.SHIP; 302 | } 303 | 304 | 305 | return result; 306 | } 307 | 308 | 309 | const getAllNonHitIndexes = (map: Map): number[] => { 310 | const result: number[] = []; 311 | for (let c = 0; c < 100; c++) { 312 | if (!map[c] || map[c] === CellType.SHIP) result.push(c); 313 | } 314 | return result; 315 | 316 | } 317 | 318 | const getFirstHitButNotDeadShipCells = (map: Map): number => { 319 | 320 | for (let index = 0; index < 100; index++) { 321 | if (!isShip(map[index])) continue; 322 | const cells = [index] 323 | let left = true, right = true, bottom = true, top = true; 324 | for (let c = 0; c < 4; c++) { 325 | let ind = 0; 326 | if (left && (left = isShip(map[ind = indexOffset(index, -(c + 1), 0)]))) cells.push(ind); 327 | if (right && (right = isShip(map[ind = indexOffset(index, (c + 1), 0)]))) cells.push(ind); 328 | if (top && (top = isShip(map[ind = indexOffset(index, 0, -(c + 1))]))) cells.push(ind); 329 | if (bottom && (bottom = isShip(map[ind = indexOffset(index, 0, (c + 1))]))) cells.push(ind); 330 | } 331 | 332 | 333 | const hit = cells.filter(cell => map[cell] === CellType.HIT); 334 | const ship = cells.filter(cell => map[cell] === CellType.SHIP); 335 | 336 | if (hit.length && ship.length) { 337 | 338 | 339 | let ar = hit.map(hitIndex => [ 340 | indexOffset(hitIndex, -1, 0), 341 | indexOffset(hitIndex, 1, 0), 342 | indexOffset(hitIndex, 0, -1), 343 | indexOffset(hitIndex, 0, 1) 344 | ]).flat().filter(a => Number.isFinite(a) && ( 345 | (hit.length === 1 && map[a] === undefined) || 346 | map[a] === CellType.SHIP 347 | )); 348 | return ar[getRandomInt(0, ar.length - 1)]; 349 | } 350 | 351 | } 352 | 353 | return -1; 354 | 355 | } 356 | 357 | const makeRandomShot = (map: Map): ShotResult => { 358 | const moo = getFirstHitButNotDeadShipCells(map); 359 | if (moo !== -1) return makeShot(map, moo) 360 | let ind = getAllNonHitIndexes(map); 361 | let ixx = ind[getRandomInt(0, ind.length - 1)]; 362 | return makeShot(map, ixx); 363 | } 364 | 365 | export { 366 | isSea, 367 | isShip, 368 | isHitShip, 369 | indexOffset, 370 | isFresh, 371 | generateMap, 372 | makeShot, 373 | makeRandomShot, 374 | convertOldMapToNew, 375 | convertNewMapToOld 376 | } --------------------------------------------------------------------------------