├── .babelrc ├── .dockerignore ├── .editorconfig ├── .eslintrc ├── .github └── workflows │ └── docker-publish.yml ├── .gitignore ├── .jshintignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── TODO.md ├── app.json ├── config.js ├── docker-compose.yml ├── gulpfile.js ├── manifest.yml ├── package-lock.json ├── package.json ├── screenshot.png ├── src ├── client │ ├── audio │ │ ├── spawn.mp3 │ │ └── split.mp3 │ ├── css │ │ └── main.css │ ├── img │ │ ├── feed.png │ │ └── split.png │ ├── index.html │ └── js │ │ ├── app.js │ │ ├── canvas.js │ │ ├── chat-client.js │ │ ├── global.js │ │ └── render.js └── server │ ├── game-logic.js │ ├── lib │ ├── entityUtils.js │ └── util.js │ ├── map │ ├── food.js │ ├── map.js │ ├── massFood.js │ ├── player.js │ └── virus.js │ ├── repositories │ ├── chat-repository.js │ └── logging-repository.js │ ├── server.js │ └── sql.js ├── test ├── server.js └── util.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/env", { 4 | "targets": { 5 | "node": "current" 6 | } 7 | }] 8 | ] 9 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .idea 4 | client/bower_components 5 | bin 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,py}] 8 | charset = utf-8 9 | 10 | [*] 11 | indent_style = space 12 | indent_size = 4 13 | 14 | [{*.json}] 15 | indent_style = space 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "browser": true, 5 | "node": true, 6 | "jquery": true 7 | }, 8 | "rules" : { }, 9 | "parser": "@babel/eslint-parser" 10 | } -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker Container 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | workflow_dispatch: 11 | 12 | env: 13 | REGISTRY: ghcr.io 14 | IMAGE_NAME: ${{ github.repository }} 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | permissions: 20 | contents: read 21 | packages: write 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v2 25 | 26 | - name: Log into registry ${{ env.REGISTRY }} 27 | if: github.event_name != 'pull_request' 28 | uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c 29 | with: 30 | registry: ${{ env.REGISTRY }} 31 | username: ${{ github.actor }} 32 | password: ${{ secrets.GITHUB_TOKEN }} 33 | 34 | - name: Extract Docker metadata 35 | id: meta 36 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 37 | with: 38 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 39 | 40 | - name: Build and push Docker image 41 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 42 | with: 43 | context: . 44 | push: ${{ github.event_name != 'pull_request' }} 45 | tags: ${{ steps.meta.outputs.tags }} 46 | labels: ${{ steps.meta.outputs.labels }} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .idea 4 | client/bower_components 5 | bin 6 | -------------------------------------------------------------------------------- /.jshintignore: -------------------------------------------------------------------------------- 1 | client/bower_components 2 | node_modules 3 | bin 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "iojs" 5 | - "6" 6 | - "4" 7 | - "0.12" 8 | - "0.10" 9 | 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine 2 | 3 | RUN mkdir -p /usr/src/app 4 | WORKDIR /usr/src/app 5 | 6 | COPY package.json /usr/src/app/ 7 | RUN npm install && npm cache clean --force 8 | COPY . /usr/src/app 9 | 10 | CMD [ "npm", "start" ] 11 | 12 | HEALTHCHECK --interval=5m --timeout=3s \ 13 | CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1 14 | 15 | EXPOSE 3000 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015, Huy Tran 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Agar.io Clone 2 | ============= 3 | 4 | This project was originally created by @huytd. I have since taken ownership of the repository to revive the project. 5 | 6 | [![GitHub Stars](https://img.shields.io/github/stars/huytd/agar.io-clone.svg)](https://github.com/huytd/agar.io-clone/stargazers) 7 | [![GitHub Issues](https://img.shields.io/github/issues/huytd/agar.io-clone.svg)](https://github.com/huytd/agar.io-clone/issues) 8 | [![GitHub Wiki](https://img.shields.io/badge/project-wiki-ff69b4.svg)](https://github.com/huytd/agar.io-clone/wiki/Home) 9 | [![Live Demo](https://img.shields.io/badge/demo-online-green.svg)](#live-demos) 10 | [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/huytd/agar.io-clone?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 11 | 12 | A simple but powerful Agar.IO clone built with socket.IO and HTML5 canvas on top of NodeJS. 13 | 14 | ![Image](screenshot.png) 15 | 16 | ## Live Demos 17 | An updated live list of demos can be found on the [Live Demos wiki page](https://github.com/owenashurst/agar.io-clone/wiki/Live-Demos). 18 | 19 | This is the most up to date version from master. Any merged pull requests will deploy to this URL automatically. 20 | 21 | --- 22 | 23 | ## How to Play 24 | You can check out how to play on our [wiki](https://github.com/owenashurst/agar.io-clone/wiki/How-to-Play). 25 | 26 | #### Game Basics 27 | - Move your mouse around the screen to move your cell. 28 | - Eat food and other players in order to grow your character (food respawns every time a player eats it). 29 | - A player's **mass** is the number of food particles eaten. 30 | - **Objective**: Try to get as big as possible and eat other players. 31 | 32 | #### Gameplay Rules 33 | - Players who haven't eaten yet cannot be eaten as a sort of "grace" period. This invincibility fades once they gain mass. 34 | - Everytime a player joins the game, **3** food particles will spawn. 35 | - Everytime a food particle is eaten by a player, **1** new food particle will respawn. 36 | - The more food you eat, the slower you move to make the game fairer for all. 37 | 38 | --- 39 | 40 | ## Latest Changes 41 | - Game logic is handled by the server 42 | - The client side is for rendering of the canvas and its items only. 43 | - Mobile optimisation. 44 | - Implementation of working viruses. 45 | - Display player name. 46 | - Now supporting chat. 47 | - Type`-ping` in the chatbox to check your ping, as well as other commands! 48 | 49 | --- 50 | 51 | ## Installation 52 | You can simply click one of the buttons below to easily deploy this repo to Bluemix or Heroku: 53 | 54 | [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy) 55 | 56 | Or... 57 | 58 | >You can check out a more detailed setup tutorial on our [wiki](https://github.com/owenashurst/agar.io-clone/wiki/Setup). 59 | 60 | #### Requirements 61 | To run / install this game, you'll need: 62 | - NodeJS with NPM installed. 63 | - socket.IO. 64 | - Express. 65 | 66 | 67 | #### Downloading the dependencies 68 | After cloning the source code from Github, you need to run the following command to download all the dependencies (socket.IO, express, etc.): 69 | 70 | ``` 71 | npm install 72 | ``` 73 | 74 | #### Running the Server 75 | After downloading all the dependencies, you can run the server with the following command: 76 | 77 | ``` 78 | npm start 79 | ``` 80 | 81 | The game will then be accessible at `http://localhost:3000`. The default port is `3000`, however this can be changed in config. Further elaboration is available on our [wiki](https://github.com/owenashurst/agar.io-clone/wiki/Setup). 82 | 83 | 84 | ### Running the Server with Docker 85 | If you have [Docker](https://www.docker.com/) installed, after cloning the repository you can run the following commands to start the server and make it acessible at `http://localhost:3000`: 86 | 87 | ``` 88 | docker build -t agarioclone_agar . 89 | docker run -it -p 3000:3000 agarioclone_agar 90 | ``` 91 | 92 | --- 93 | 94 | ## FAQ 95 | 1. **What is this game?** 96 | 97 | This is a clone of the game [Agar.IO](http://agar.io/). Someone said that Agar.IO is a clone of an iPad game called Osmos, but we haven't tried it yet. (Cloneception? :P) 98 | 99 | 2. **Why would you make a clone of this game?** 100 | 101 | Well, while the original game is still online, it is closed-source, and sometimes, it suffers from massive lag. That's why we want to make an open source version of it: for educational purposes, and to let the community add the features that they want, self-host it on their own servers, have fun with friends and more. 102 | 103 | 3. **Any plans on adding an online server to compete with Agar.IO or making money out of it?** 104 | 105 | No. This game belongs to the open-source community, and we have no plans on making money out of it nor competing with anything. But you can of course create your own public server, let us know if you do so and we can add it to our Live Demos list! 106 | 107 | 4. **Can I deploy this game to my own server?** 108 | 109 | Sure you can! That's what it's made for! ;) 110 | 111 | 5. **I don't like HTML5 canvas. Can I write my own game client with this server?** 112 | 113 | Of course! As long as your client supports WebSockets, you can write your game client in any language/technology, even with Unity3D if you want (there is an open source library for Unity to communicate with WebSockets)! 114 | 115 | 6. **Can I use some code of this project on my own?** 116 | 117 | Yes you can. 118 | 119 | ## For Developers 120 | - [Game Architecture](https://github.com/owenashurst/agar.io-clone/wiki/Game-Architecture) to understand how the backend works. 121 | - If you want to start your own project, I recommend you use [this template](https://github.com/huytd/node-online-game-template). Happy developing! 122 | - 123 | 124 | ## TODOs 125 | We have an explicit [TODO](https://github.com/owenashurst/agar.io-clone/wiki/Coming-Features) list for the all the features we aim to develop in the future. Feel free to contribute, we'll be more than grateful. 126 | 127 | ## License 128 | >You can check out the full license [here](https://github.com/owenashurst/agar.io-clone/blob/master/LICENSE). 129 | 130 | This project is licensed under the terms of the **MIT** license. 131 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ### TODOs 2 | | Filename | line # | TODO 3 | |:------|:------:|:------ 4 | | client/js/app.js | 96 | Break out into GameControls. 5 | | client/js/chat-client.js | 24 | Break out many of these GameControls into separate classes. -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "agar-clone", 3 | "description": "A simple Agar.io clone", 4 | "scripts": { 5 | "build": "gulp build", 6 | "start": "gulp run", 7 | "watch": "gulp watch", 8 | "test": "gulp test" 9 | }, 10 | "repository": "https://github.com/huytd/agar.io-clone" 11 | } 12 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | host: "0.0.0.0", 3 | port: 3000, 4 | logpath: "logger.php", 5 | foodMass: 1, 6 | fireFood: 20, 7 | limitSplit: 16, 8 | defaultPlayerMass: 10, 9 | virus: { 10 | fill: "#33ff33", 11 | stroke: "#19D119", 12 | strokeWidth: 20, 13 | defaultMass: { 14 | from: 100, 15 | to: 150 16 | }, 17 | splitMass: 180, 18 | uniformDisposition: false, 19 | }, 20 | gameWidth: 5000, 21 | gameHeight: 5000, 22 | adminPass: "DEFAULT", 23 | gameMass: 20000, 24 | maxFood: 1000, 25 | maxVirus: 50, 26 | slowBase: 4.5, 27 | logChat: 0, 28 | networkUpdateFactor: 40, 29 | maxHeartbeatInterval: 5000, 30 | foodUniformDisposition: true, 31 | newPlayerInitialPosition: "farthest", 32 | massLossRate: 1, 33 | minMassLoss: 50, 34 | sqlinfo: { 35 | fileName: "db.sqlite3", 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | agar: 4 | build: . 5 | ports: 6 | - 3000:3000 7 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | /* jshint esversion: 8 */ 2 | 3 | const gulp = require('gulp'); 4 | const babel = require('gulp-babel'); 5 | const eslint = require('gulp-eslint'); 6 | const nodemon = require('gulp-nodemon'); 7 | const todo = require('gulp-todo'); 8 | const webpack = require('webpack-stream'); 9 | const Mocha = require('mocha'); 10 | const fs = require("fs"); 11 | const path = require("path"); 12 | const PluginError = require('plugin-error'); 13 | 14 | function getWebpackConfig() { 15 | return require('./webpack.config.js')(!process.env.IS_DEV) 16 | } 17 | 18 | function runServer(done) { 19 | nodemon({ 20 | delay: 10, 21 | script: './bin/server/server.js', 22 | ignore: ['bin/'], 23 | ext: 'js html css', 24 | done, 25 | tasks: [process.env.IS_DEV ? 'dev' : 'build'] 26 | }) 27 | } 28 | 29 | function buildServer() { 30 | let task = gulp.src(['src/server/**/*.*', 'src/server/**/*.js']); 31 | if (!process.env.IS_DEV) { 32 | task = task.pipe(babel()) 33 | } 34 | return task.pipe(gulp.dest('bin/server/')); 35 | } 36 | 37 | function copyClientResources() { 38 | return gulp.src(['src/client/**/*.*', '!src/client/**/*.js']) 39 | .pipe(gulp.dest('./bin/client/')); 40 | } 41 | 42 | function buildClientJS() { 43 | return gulp.src(['src/client/js/app.js']) 44 | .pipe(webpack(getWebpackConfig())) 45 | .pipe(gulp.dest('bin/client/js/')); 46 | } 47 | 48 | function setDev(done) { 49 | process.env.IS_DEV = 'true'; 50 | done(); 51 | } 52 | 53 | function mocha(done) { 54 | const mochaInstance = new Mocha() 55 | const files = fs 56 | .readdirSync('test/', {recursive: true}) 57 | .filter(x => x.endsWith('.js')).map(x => path.resolve('test/' + x)); 58 | for (const file of files) { 59 | mochaInstance.addFile(file); 60 | } 61 | mochaInstance.run(failures => failures ? done(new PluginError('mocha', `${failures} test(s) failed`)) : done()); 62 | } 63 | 64 | gulp.task('lint', () => { 65 | return gulp.src(['**/*.js', '!node_modules/**/*.js', '!bin/**/*.js']) 66 | .pipe(eslint()) 67 | .pipe(eslint.format()) 68 | .pipe(eslint.failAfterError()) 69 | }); 70 | 71 | gulp.task('test', gulp.series('lint', mocha)); 72 | 73 | gulp.task('todo', gulp.series('lint', () => { 74 | return gulp.src('src/**/*.js') 75 | .pipe(todo()) 76 | .pipe(gulp.dest('./')); 77 | })); 78 | 79 | gulp.task('build', gulp.series('lint', gulp.parallel(copyClientResources, buildClientJS, buildServer, mocha))); 80 | 81 | gulp.task('dev', gulp.parallel(copyClientResources, buildClientJS, buildServer)); 82 | 83 | gulp.task('run', gulp.series('build', runServer)); 84 | 85 | gulp.task('watch', gulp.series(setDev, 'dev', runServer)); 86 | 87 | gulp.task('default', gulp.series('run')); 88 | -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | applications: 3 | - name: agar-clone 4 | memory: 512M 5 | disk_quota: 1G 6 | buildpack: nodejs_buildpack 7 | host: agar-clone 8 | domain: mybluemix.net 9 | timeout: 180 10 | env: 11 | NPM_CONFIG_PRODUCTION: false -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "agar.io-clone", 3 | "version": "1.0.0", 4 | "description": "A simple Agar.io clone", 5 | "main": "server/server.js", 6 | "scripts": { 7 | "build": "gulp build", 8 | "start": "gulp run", 9 | "watch": "gulp watch", 10 | "test": "gulp test" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/owenashurst/agar.io-clone.git" 15 | }, 16 | "author": "Huy Tran", 17 | "maintainers": [ 18 | { 19 | "name": "Owen Ashurst" 20 | } 21 | ], 22 | "license": "MIT", 23 | "contributors": [ 24 | "abalabahaha (https://github.com/abalabahaha)", 25 | "Ariamiro (https://github.com/Ariamiro)", 26 | "Bjarne Oeverli (https://github.com/bjarneo)", 27 | "Chris Morgan (https://github.com/drpotato)", 28 | "Damian Dlugosz (https://github.com/bigfoot90)", 29 | "Dan Prince (https://github.com/danprince)", 30 | "Igor Antun (https://github.com/IgorAntun)", 31 | "Juha Tauriainen (https://github.com/JuhQ)", 32 | "Keith Groves (https://github.com/buskcoin)", 33 | "Kostas Bariotis (https://github.com/kbariotis)", 34 | "Madara Uchiha (https://github.com/MadaraUchiha)", 35 | "Nguyen Huu Thanh (https://github.com/giongto35)", 36 | "PET Computação UFPR (http://pet.inf.ufpr.br)", 37 | "Saren Currie (https://github.com/SarenCurrie)", 38 | "VILLERS Mickaël (https://github.com/villers)", 39 | "wb9688 (https://github.com/wb9688)" 40 | ], 41 | "dependencies": { 42 | "@babel/core": "^7.21.8", 43 | "@babel/eslint-parser": "^7.21.8", 44 | "@babel/preset-env": "^7.21.5", 45 | "babel-loader": "^9.1.2", 46 | "chai": "^4.3.7", 47 | "eslint": "^8.40.0", 48 | "eslint-plugin-import": "^2.27.5", 49 | "express": "^4.18.2", 50 | "gulp": "^4.0.2", 51 | "gulp-babel": "^8.0.0", 52 | "gulp-eslint": "^6.0.0", 53 | "gulp-nodemon": "^2.5.0", 54 | "gulp-todo": "^7.1.1", 55 | "jshint": "^2.13.6", 56 | "mocha": "^10.2.0", 57 | "nodemon": "^3.0.1", 58 | "sat": "^0.9.0", 59 | "socket.io": "^4.6.1", 60 | "socket.io-client": "^4.6.1", 61 | "sqlite3": "^5.1.6", 62 | "uuid": "^9.0.0", 63 | "webpack": "^5.82.1", 64 | "webpack-stream": "^7.0.0" 65 | }, 66 | "devDependencies": { 67 | "@types/sat": "^0.0.32", 68 | "plugin-error": "^2.0.1" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owenashurst/agar.io-clone/cb8c12173cd6ae07abbc300a8838de0ab1d358ad/screenshot.png -------------------------------------------------------------------------------- /src/client/audio/spawn.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owenashurst/agar.io-clone/cb8c12173cd6ae07abbc300a8838de0ab1d358ad/src/client/audio/spawn.mp3 -------------------------------------------------------------------------------- /src/client/audio/split.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owenashurst/agar.io-clone/cb8c12173cd6ae07abbc300a8838de0ab1d358ad/src/client/audio/split.mp3 -------------------------------------------------------------------------------- /src/client/css/main.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: sans-serif; 3 | font-size: 14px; 4 | } 5 | 6 | html, body { 7 | background-color: #222; 8 | overflow: hidden; 9 | } 10 | 11 | html, body, canvas { 12 | width: 100%; 13 | height: 100%; 14 | margin: 0; 15 | padding: 0; 16 | background: rgba(0, 0, 0, 0.4); 17 | 18 | } 19 | 20 | div { 21 | -webkit-user-select: none; /* webkit (safari, chrome) browsers */ 22 | -moz-user-select: none; /* mozilla browsers */ 23 | -khtml-user-select: none; /* webkit (konqueror) browsers */ 24 | -ms-user-select: none; /* IE10+ */ 25 | } 26 | 27 | #split { 28 | position: absolute; 29 | bottom: 10px; 30 | left: 10px; 31 | width: 100px; 32 | height: 100px; 33 | padding: 5px; 34 | border: none; 35 | } 36 | 37 | #feed { 38 | position: absolute; 39 | bottom: 10px; 40 | right: 10px; 41 | width: 100px; 42 | height: 100px; 43 | padding: 5px; 44 | border: none; 45 | } 46 | 47 | #status { 48 | position: absolute; 49 | padding: 10px; 50 | background: rgba(0, 0, 0, 0.4); 51 | color: #FFF; 52 | font-size: 16.1px; 53 | top: 10px; 54 | right: 10px; 55 | font-weight: bold; 56 | text-align: center; 57 | } 58 | 59 | #status .title { 60 | font-size: 25px; 61 | } 62 | 63 | #status .me { 64 | color: #FF8888; 65 | font-size: 16.1px; 66 | } 67 | 68 | .chatbox { 69 | position: absolute; 70 | width: 300px; 71 | height: 320px; 72 | background: rgba(22, 22, 22, 0.7); 73 | bottom: 5px; 74 | left: 5px; 75 | border-radius: 5px; 76 | pointer-events: none; 77 | } 78 | 79 | .chatbox .chat-list { 80 | padding: 5px; 81 | margin: 0; 82 | list-style: none; 83 | box-sizing: border-box; 84 | height: 285px; 85 | overflow: hidden; 86 | } 87 | 88 | .chatbox .chat-list li { 89 | padding: 2px; 90 | margin: 3px; 91 | } 92 | 93 | .chatbox .chat-list li.me b { 94 | color: #ea6153; 95 | } 96 | 97 | .chatbox .chat-list li.friend b { 98 | color: #2ecc71; 99 | } 100 | 101 | .chatbox .chat-list li.system { 102 | color: #9b59b6; 103 | font-style: italic; 104 | } 105 | 106 | .chatbox .chat-list li.system:before { 107 | content: "» "; 108 | } 109 | 110 | .chatbox .chat-input { 111 | pointer-events: all; 112 | box-sizing: border-box; 113 | width: 100%; 114 | padding: 8px; 115 | background: transparent; 116 | border: none; 117 | border-top: 1px solid #DDD; 118 | outline: none; 119 | } 120 | 121 | #startMenu { 122 | position: relative; 123 | margin: auto; 124 | margin-top: 100px; 125 | width: 350px; 126 | padding: 20px; 127 | border-radius: 5px; 128 | -moz-border-radius: 5px; 129 | -webkit-border-radius: 5px; 130 | background-color: white; 131 | box-sizing: border-box; 132 | } 133 | 134 | #startMenu p { 135 | padding: 0; 136 | text-align: center; 137 | font-size: x-large; 138 | font-weight: bold; 139 | } 140 | 141 | #playerNameInput { 142 | width: 100%; 143 | text-align: center; 144 | padding: 10px; 145 | border: solid 1px #dcdcdc; 146 | transition: box-shadow 0.3s, border 0.3s; 147 | box-sizing: border-box; 148 | border-radius: 5px; 149 | -moz-border-radius: 5px; 150 | -webkit-border-radius: 5px; 151 | margin-bottom: 10px; 152 | outline: none; 153 | } 154 | 155 | #playerNameInput:focus, #playerNameInput.focus { 156 | border: solid 1px #CCCCCC; 157 | box-shadow: 0 0 3px 1px #DDDDDD; 158 | } 159 | 160 | #startButton, #spectateButton { 161 | position: relative; 162 | margin: auto; 163 | margin-top: 10px; 164 | width: 100%; 165 | height: 40px; 166 | box-sizing: border-box; 167 | font-size: large; 168 | color: white; 169 | text-align: center; 170 | text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); 171 | background: #2ecc71; 172 | border: 0; 173 | border-bottom: 2px solid #28be68; 174 | cursor: pointer; 175 | -webkit-box-shadow: inset 0 -2px #28be68; 176 | box-shadow: inset 0 -2px #28be68; 177 | border-radius: 5px; 178 | -moz-border-radius: 5px; 179 | -webkit-border-radius: 5px; 180 | margin-bottom: 10px; 181 | } 182 | 183 | #spectateButton:active, #spectateButton:hover, 184 | #startButton:active, #startButton:hover { 185 | top: 1px; 186 | background: #55D88B; 187 | outline: none; 188 | -webkit-box-shadow: none; 189 | box-shadow: none; 190 | } 191 | 192 | #settingsButton { 193 | position: relative; 194 | margin: auto; 195 | margin-top: 10px; 196 | width: 100%; 197 | height: 40px; 198 | box-sizing: border-box; 199 | font-size: large; 200 | color: white; 201 | text-align: center; 202 | text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); 203 | background: #2ecc71; 204 | border: 0; 205 | border-bottom: 2px solid #28be68; 206 | cursor: pointer; 207 | -webkit-box-shadow: inset 0 -2px #28be68; 208 | box-shadow: inset 0 -2px #28be68; 209 | border-radius: 5px; 210 | -moz-border-radius: 5px; 211 | -webkit-border-radius: 5px; 212 | margin-bottom: 10px; 213 | } 214 | 215 | #settingsButton:active, #settingsButton:hover { 216 | top: 1px; 217 | background: #55D88B; 218 | outline: none; 219 | -webkit-box-shadow: none; 220 | box-shadow: none; 221 | } 222 | 223 | #settings, #startMenuWrapper { 224 | -webkit-transition: max-height 1s; 225 | -moz-transition: max-height 1s; 226 | -ms-transition: max-height 1s; 227 | -o-transition: max-height 1s; 228 | transition: max-height 1s; 229 | overflow: hidden; 230 | } 231 | 232 | #settings { 233 | max-height: 0; 234 | } 235 | 236 | #startMenu h3 { 237 | padding-bottom: 0; 238 | margin-bottom: 0; 239 | } 240 | 241 | #startMenu ul { 242 | margin: 10px; 243 | padding: 10px; 244 | margin-top: 0; 245 | } 246 | 247 | #startMenu .input-error { 248 | color: red; 249 | opacity: 0; 250 | font-size : 12px; 251 | } 252 | 253 | #startMenuWrapper { 254 | z-index: 2; 255 | } 256 | 257 | #gameAreaWrapper { 258 | position: absolute !important; 259 | top: 0; 260 | left: 0; 261 | opacity: 0; 262 | } 263 | 264 | @media only screen and (min-width : 1224px) { 265 | #mobile { 266 | display: none; 267 | } 268 | } 269 | 270 | @media only screen and (max-width : 1224px) { 271 | #chatbox { 272 | display: none; 273 | } 274 | } 275 | 276 | input [type="image"]:focus{ 277 | border:none; 278 | outline: 1px solid transparent; 279 | border-style: none; 280 | } 281 | 282 | *:focus { 283 | outline: 1px solid transparent; 284 | border-style: none; 285 | } 286 | -------------------------------------------------------------------------------- /src/client/img/feed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owenashurst/agar.io-clone/cb8c12173cd6ae07abbc300a8838de0ab1d358ad/src/client/img/feed.png -------------------------------------------------------------------------------- /src/client/img/split.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owenashurst/agar.io-clone/cb8c12173cd6ae07abbc300a8838de0ab1d358ad/src/client/img/split.png -------------------------------------------------------------------------------- /src/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Open Agar 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
Leaderboard
17 |
18 |
    19 | 20 |
    21 |
    22 | 23 | 24 |
    25 | 26 |
    27 |
    28 |
    29 |

    Open Agar

    30 | 31 | Nick must be alphanumeric characters only! 32 |
    33 | 34 | 35 | 36 |
    37 |
    38 |

    Settings

    39 |
      40 | 41 | 42 |
      43 | 44 |
      45 | 46 | 47 |
    48 |
    49 |
    50 |

    Gameplay

    51 |
      52 |
    • Move your mouse on the screen to move your character.
    • 53 |
    • Eat food and other players in order to grow your character (food respawns every time a player eats it).
    • 54 |
    • A player's mass is the number of food particles eaten.
    • 55 |
    • Objective: Try to get fat and eat other players.
    • 56 |
    57 |
    58 |
    59 |
    60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/client/js/app.js: -------------------------------------------------------------------------------- 1 | var io = require('socket.io-client'); 2 | var render = require('./render'); 3 | var ChatClient = require('./chat-client'); 4 | var Canvas = require('./canvas'); 5 | var global = require('./global'); 6 | 7 | var playerNameInput = document.getElementById('playerNameInput'); 8 | var socket; 9 | 10 | var debug = function (args) { 11 | if (console && console.log) { 12 | console.log(args); 13 | } 14 | }; 15 | 16 | if (/Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent)) { 17 | global.mobile = true; 18 | } 19 | 20 | function startGame(type) { 21 | global.playerName = playerNameInput.value.replace(/(<([^>]+)>)/ig, '').substring(0, 25); 22 | global.playerType = type; 23 | 24 | global.screen.width = window.innerWidth; 25 | global.screen.height = window.innerHeight; 26 | 27 | document.getElementById('startMenuWrapper').style.maxHeight = '0px'; 28 | document.getElementById('gameAreaWrapper').style.opacity = 1; 29 | if (!socket) { 30 | socket = io({ query: "type=" + type }); 31 | setupSocket(socket); 32 | } 33 | if (!global.animLoopHandle) 34 | animloop(); 35 | socket.emit('respawn'); 36 | window.chat.socket = socket; 37 | window.chat.registerFunctions(); 38 | window.canvas.socket = socket; 39 | global.socket = socket; 40 | } 41 | 42 | // Checks if the nick chosen contains valid alphanumeric characters (and underscores). 43 | function validNick() { 44 | var regex = /^\w*$/; 45 | debug('Regex Test', regex.exec(playerNameInput.value)); 46 | return regex.exec(playerNameInput.value) !== null; 47 | } 48 | 49 | window.onload = function () { 50 | 51 | var btn = document.getElementById('startButton'), 52 | btnS = document.getElementById('spectateButton'), 53 | nickErrorText = document.querySelector('#startMenu .input-error'); 54 | 55 | btnS.onclick = function () { 56 | startGame('spectator'); 57 | }; 58 | 59 | btn.onclick = function () { 60 | 61 | // Checks if the nick is valid. 62 | if (validNick()) { 63 | nickErrorText.style.opacity = 0; 64 | startGame('player'); 65 | } else { 66 | nickErrorText.style.opacity = 1; 67 | } 68 | }; 69 | 70 | var settingsMenu = document.getElementById('settingsButton'); 71 | var settings = document.getElementById('settings'); 72 | 73 | settingsMenu.onclick = function () { 74 | if (settings.style.maxHeight == '300px') { 75 | settings.style.maxHeight = '0px'; 76 | } else { 77 | settings.style.maxHeight = '300px'; 78 | } 79 | }; 80 | 81 | playerNameInput.addEventListener('keypress', function (e) { 82 | var key = e.which || e.keyCode; 83 | 84 | if (key === global.KEY_ENTER) { 85 | if (validNick()) { 86 | nickErrorText.style.opacity = 0; 87 | startGame('player'); 88 | } else { 89 | nickErrorText.style.opacity = 1; 90 | } 91 | } 92 | }); 93 | }; 94 | 95 | // TODO: Break out into GameControls. 96 | 97 | var playerConfig = { 98 | border: 6, 99 | textColor: '#FFFFFF', 100 | textBorder: '#000000', 101 | textBorderSize: 3, 102 | defaultSize: 30 103 | }; 104 | 105 | var player = { 106 | id: -1, 107 | x: global.screen.width / 2, 108 | y: global.screen.height / 2, 109 | screenWidth: global.screen.width, 110 | screenHeight: global.screen.height, 111 | target: { x: global.screen.width / 2, y: global.screen.height / 2 } 112 | }; 113 | global.player = player; 114 | 115 | var foods = []; 116 | var viruses = []; 117 | var fireFood = []; 118 | var users = []; 119 | var leaderboard = []; 120 | var target = { x: player.x, y: player.y }; 121 | global.target = target; 122 | 123 | window.canvas = new Canvas(); 124 | window.chat = new ChatClient(); 125 | 126 | var visibleBorderSetting = document.getElementById('visBord'); 127 | visibleBorderSetting.onchange = settings.toggleBorder; 128 | 129 | var showMassSetting = document.getElementById('showMass'); 130 | showMassSetting.onchange = settings.toggleMass; 131 | 132 | var continuitySetting = document.getElementById('continuity'); 133 | continuitySetting.onchange = settings.toggleContinuity; 134 | 135 | var roundFoodSetting = document.getElementById('roundFood'); 136 | roundFoodSetting.onchange = settings.toggleRoundFood; 137 | 138 | var c = window.canvas.cv; 139 | var graph = c.getContext('2d'); 140 | 141 | $("#feed").click(function () { 142 | socket.emit('1'); 143 | window.canvas.reenviar = false; 144 | }); 145 | 146 | $("#split").click(function () { 147 | socket.emit('2'); 148 | window.canvas.reenviar = false; 149 | }); 150 | 151 | function handleDisconnect() { 152 | socket.close(); 153 | if (!global.kicked) { // We have a more specific error message 154 | render.drawErrorMessage('Disconnected!', graph, global.screen); 155 | } 156 | } 157 | 158 | // socket stuff. 159 | function setupSocket(socket) { 160 | // Handle ping. 161 | socket.on('pongcheck', function () { 162 | var latency = Date.now() - global.startPingTime; 163 | debug('Latency: ' + latency + 'ms'); 164 | window.chat.addSystemLine('Ping: ' + latency + 'ms'); 165 | }); 166 | 167 | // Handle error. 168 | socket.on('connect_error', handleDisconnect); 169 | socket.on('disconnect', handleDisconnect); 170 | 171 | // Handle connection. 172 | socket.on('welcome', function (playerSettings, gameSizes) { 173 | player = playerSettings; 174 | player.name = global.playerName; 175 | player.screenWidth = global.screen.width; 176 | player.screenHeight = global.screen.height; 177 | player.target = window.canvas.target; 178 | global.player = player; 179 | window.chat.player = player; 180 | socket.emit('gotit', player); 181 | global.gameStart = true; 182 | window.chat.addSystemLine('Connected to the game!'); 183 | window.chat.addSystemLine('Type -help for a list of commands.'); 184 | if (global.mobile) { 185 | document.getElementById('gameAreaWrapper').removeChild(document.getElementById('chatbox')); 186 | } 187 | c.focus(); 188 | global.game.width = gameSizes.width; 189 | global.game.height = gameSizes.height; 190 | resize(); 191 | }); 192 | 193 | socket.on('playerDied', (data) => { 194 | const player = isUnnamedCell(data.playerEatenName) ? 'An unnamed cell' : data.playerEatenName; 195 | //const killer = isUnnamedCell(data.playerWhoAtePlayerName) ? 'An unnamed cell' : data.playerWhoAtePlayerName; 196 | 197 | //window.chat.addSystemLine('{GAME} - ' + (player) + ' was eaten by ' + (killer) + ''); 198 | window.chat.addSystemLine('{GAME} - ' + (player) + ' was eaten'); 199 | }); 200 | 201 | socket.on('playerDisconnect', (data) => { 202 | window.chat.addSystemLine('{GAME} - ' + (isUnnamedCell(data.name) ? 'An unnamed cell' : data.name) + ' disconnected.'); 203 | }); 204 | 205 | socket.on('playerJoin', (data) => { 206 | window.chat.addSystemLine('{GAME} - ' + (isUnnamedCell(data.name) ? 'An unnamed cell' : data.name) + ' joined.'); 207 | }); 208 | 209 | socket.on('leaderboard', (data) => { 210 | leaderboard = data.leaderboard; 211 | var status = 'Leaderboard'; 212 | for (var i = 0; i < leaderboard.length; i++) { 213 | status += '
    '; 214 | if (leaderboard[i].id == player.id) { 215 | if (leaderboard[i].name.length !== 0) 216 | status += '' + (i + 1) + '. ' + leaderboard[i].name + ""; 217 | else 218 | status += '' + (i + 1) + ". An unnamed cell"; 219 | } else { 220 | if (leaderboard[i].name.length !== 0) 221 | status += (i + 1) + '. ' + leaderboard[i].name; 222 | else 223 | status += (i + 1) + '. An unnamed cell'; 224 | } 225 | } 226 | //status += '
    Players: ' + data.players; 227 | document.getElementById('status').innerHTML = status; 228 | }); 229 | 230 | socket.on('serverMSG', function (data) { 231 | window.chat.addSystemLine(data); 232 | }); 233 | 234 | // Chat. 235 | socket.on('serverSendPlayerChat', function (data) { 236 | window.chat.addChatLine(data.sender, data.message, false); 237 | }); 238 | 239 | // Handle movement. 240 | socket.on('serverTellPlayerMove', function (playerData, userData, foodsList, massList, virusList) { 241 | if (global.playerType == 'player') { 242 | player.x = playerData.x; 243 | player.y = playerData.y; 244 | player.hue = playerData.hue; 245 | player.massTotal = playerData.massTotal; 246 | player.cells = playerData.cells; 247 | } 248 | users = userData; 249 | foods = foodsList; 250 | viruses = virusList; 251 | fireFood = massList; 252 | }); 253 | 254 | // Death. 255 | socket.on('RIP', function () { 256 | global.gameStart = false; 257 | render.drawErrorMessage('You died!', graph, global.screen); 258 | window.setTimeout(() => { 259 | document.getElementById('gameAreaWrapper').style.opacity = 0; 260 | document.getElementById('startMenuWrapper').style.maxHeight = '1000px'; 261 | if (global.animLoopHandle) { 262 | window.cancelAnimationFrame(global.animLoopHandle); 263 | global.animLoopHandle = undefined; 264 | } 265 | }, 2500); 266 | }); 267 | 268 | socket.on('kick', function (reason) { 269 | global.gameStart = false; 270 | global.kicked = true; 271 | if (reason !== '') { 272 | render.drawErrorMessage('You were kicked for: ' + reason, graph, global.screen); 273 | } 274 | else { 275 | render.drawErrorMessage('You were kicked!', graph, global.screen); 276 | } 277 | socket.close(); 278 | }); 279 | } 280 | 281 | const isUnnamedCell = (name) => name.length < 1; 282 | 283 | const getPosition = (entity, player, screen) => { 284 | return { 285 | x: entity.x - player.x + screen.width / 2, 286 | y: entity.y - player.y + screen.height / 2 287 | } 288 | } 289 | 290 | window.requestAnimFrame = (function () { 291 | return window.requestAnimationFrame || 292 | window.webkitRequestAnimationFrame || 293 | window.mozRequestAnimationFrame || 294 | window.msRequestAnimationFrame || 295 | function (callback) { 296 | window.setTimeout(callback, 1000 / 60); 297 | }; 298 | })(); 299 | 300 | window.cancelAnimFrame = (function (handle) { 301 | return window.cancelAnimationFrame || 302 | window.mozCancelAnimationFrame; 303 | })(); 304 | 305 | function animloop() { 306 | global.animLoopHandle = window.requestAnimFrame(animloop); 307 | gameLoop(); 308 | } 309 | 310 | function gameLoop() { 311 | if (global.gameStart) { 312 | graph.fillStyle = global.backgroundColor; 313 | graph.fillRect(0, 0, global.screen.width, global.screen.height); 314 | 315 | render.drawGrid(global, player, global.screen, graph); 316 | foods.forEach(food => { 317 | let position = getPosition(food, player, global.screen); 318 | render.drawFood(position, food, graph); 319 | }); 320 | fireFood.forEach(fireFood => { 321 | let position = getPosition(fireFood, player, global.screen); 322 | render.drawFireFood(position, fireFood, playerConfig, graph); 323 | }); 324 | viruses.forEach(virus => { 325 | let position = getPosition(virus, player, global.screen); 326 | render.drawVirus(position, virus, graph); 327 | }); 328 | 329 | 330 | let borders = { // Position of the borders on the screen 331 | left: global.screen.width / 2 - player.x, 332 | right: global.screen.width / 2 + global.game.width - player.x, 333 | top: global.screen.height / 2 - player.y, 334 | bottom: global.screen.height / 2 + global.game.height - player.y 335 | } 336 | if (global.borderDraw) { 337 | render.drawBorder(borders, graph); 338 | } 339 | 340 | var cellsToDraw = []; 341 | for (var i = 0; i < users.length; i++) { 342 | let color = 'hsl(' + users[i].hue + ', 100%, 50%)'; 343 | let borderColor = 'hsl(' + users[i].hue + ', 100%, 45%)'; 344 | for (var j = 0; j < users[i].cells.length; j++) { 345 | cellsToDraw.push({ 346 | color: color, 347 | borderColor: borderColor, 348 | mass: users[i].cells[j].mass, 349 | name: users[i].name, 350 | radius: users[i].cells[j].radius, 351 | x: users[i].cells[j].x - player.x + global.screen.width / 2, 352 | y: users[i].cells[j].y - player.y + global.screen.height / 2 353 | }); 354 | } 355 | } 356 | cellsToDraw.sort(function (obj1, obj2) { 357 | return obj1.mass - obj2.mass; 358 | }); 359 | render.drawCells(cellsToDraw, playerConfig, global.toggleMassState, borders, graph); 360 | 361 | socket.emit('0', window.canvas.target); // playerSendTarget "Heartbeat". 362 | } 363 | } 364 | 365 | window.addEventListener('resize', resize); 366 | 367 | function resize() { 368 | if (!socket) return; 369 | 370 | player.screenWidth = c.width = global.screen.width = global.playerType == 'player' ? window.innerWidth : global.game.width; 371 | player.screenHeight = c.height = global.screen.height = global.playerType == 'player' ? window.innerHeight : global.game.height; 372 | 373 | if (global.playerType == 'spectator') { 374 | player.x = global.game.width / 2; 375 | player.y = global.game.height / 2; 376 | } 377 | 378 | socket.emit('windowResized', { screenWidth: global.screen.width, screenHeight: global.screen.height }); 379 | } 380 | -------------------------------------------------------------------------------- /src/client/js/canvas.js: -------------------------------------------------------------------------------- 1 | var global = require('./global'); 2 | 3 | class Canvas { 4 | constructor(params) { 5 | this.directionLock = false; 6 | this.target = global.target; 7 | this.reenviar = true; 8 | this.socket = global.socket; 9 | this.directions = []; 10 | var self = this; 11 | 12 | this.cv = document.getElementById('cvs'); 13 | this.cv.width = global.screen.width; 14 | this.cv.height = global.screen.height; 15 | this.cv.addEventListener('mousemove', this.gameInput, false); 16 | this.cv.addEventListener('mouseout', this.outOfBounds, false); 17 | this.cv.addEventListener('keypress', this.keyInput, false); 18 | this.cv.addEventListener('keyup', function(event) { 19 | self.reenviar = true; 20 | self.directionUp(event); 21 | }, false); 22 | this.cv.addEventListener('keydown', this.directionDown, false); 23 | this.cv.addEventListener('touchstart', this.touchInput, false); 24 | this.cv.addEventListener('touchmove', this.touchInput, false); 25 | this.cv.parent = self; 26 | global.canvas = this; 27 | } 28 | 29 | // Function called when a key is pressed, will change direction if arrow key. 30 | directionDown(event) { 31 | var key = event.which || event.keyCode; 32 | var self = this.parent; // have to do this so we are not using the cv object 33 | if (self.directional(key)) { 34 | self.directionLock = true; 35 | if (self.newDirection(key, self.directions, true)) { 36 | self.updateTarget(self.directions); 37 | self.socket.emit('0', self.target); 38 | } 39 | } 40 | } 41 | 42 | // Function called when a key is lifted, will change direction if arrow key. 43 | directionUp(event) { 44 | var key = event.which || event.keyCode; 45 | if (this.directional(key)) { // this == the actual class 46 | if (this.newDirection(key, this.directions, false)) { 47 | this.updateTarget(this.directions); 48 | if (this.directions.length === 0) this.directionLock = false; 49 | this.socket.emit('0', this.target); 50 | } 51 | } 52 | } 53 | 54 | // Updates the direction array including information about the new direction. 55 | newDirection(direction, list, isAddition) { 56 | var result = false; 57 | var found = false; 58 | for (var i = 0, len = list.length; i < len; i++) { 59 | if (list[i] == direction) { 60 | found = true; 61 | if (!isAddition) { 62 | result = true; 63 | // Removes the direction. 64 | list.splice(i, 1); 65 | } 66 | break; 67 | } 68 | } 69 | // Adds the direction. 70 | if (isAddition && found === false) { 71 | result = true; 72 | list.push(direction); 73 | } 74 | 75 | return result; 76 | } 77 | 78 | // Updates the target according to the directions in the directions array. 79 | updateTarget(list) { 80 | this.target = { x : 0, y: 0 }; 81 | var directionHorizontal = 0; 82 | var directionVertical = 0; 83 | for (var i = 0, len = list.length; i < len; i++) { 84 | if (directionHorizontal === 0) { 85 | if (list[i] == global.KEY_LEFT) directionHorizontal -= Number.MAX_VALUE; 86 | else if (list[i] == global.KEY_RIGHT) directionHorizontal += Number.MAX_VALUE; 87 | } 88 | if (directionVertical === 0) { 89 | if (list[i] == global.KEY_UP) directionVertical -= Number.MAX_VALUE; 90 | else if (list[i] == global.KEY_DOWN) directionVertical += Number.MAX_VALUE; 91 | } 92 | } 93 | this.target.x += directionHorizontal; 94 | this.target.y += directionVertical; 95 | global.target = this.target; 96 | } 97 | 98 | directional(key) { 99 | return this.horizontal(key) || this.vertical(key); 100 | } 101 | 102 | horizontal(key) { 103 | return key == global.KEY_LEFT || key == global.KEY_RIGHT; 104 | } 105 | 106 | vertical(key) { 107 | return key == global.KEY_DOWN || key == global.KEY_UP; 108 | } 109 | 110 | // Register when the mouse goes off the canvas. 111 | outOfBounds() { 112 | if (!global.continuity) { 113 | this.parent.target = { x : 0, y: 0 }; 114 | global.target = this.parent.target; 115 | } 116 | } 117 | 118 | gameInput(mouse) { 119 | if (!this.directionLock) { 120 | this.parent.target.x = mouse.clientX - this.width / 2; 121 | this.parent.target.y = mouse.clientY - this.height / 2; 122 | global.target = this.parent.target; 123 | } 124 | } 125 | 126 | touchInput(touch) { 127 | touch.preventDefault(); 128 | touch.stopPropagation(); 129 | if (!this.directionLock) { 130 | this.parent.target.x = touch.touches[0].clientX - this.width / 2; 131 | this.parent.target.y = touch.touches[0].clientY - this.height / 2; 132 | global.target = this.parent.target; 133 | } 134 | } 135 | 136 | // Chat command callback functions. 137 | keyInput(event) { 138 | var key = event.which || event.keyCode; 139 | if (key === global.KEY_FIREFOOD && this.parent.reenviar) { 140 | this.parent.socket.emit('1'); 141 | this.parent.reenviar = false; 142 | } 143 | else if (key === global.KEY_SPLIT && this.parent.reenviar) { 144 | document.getElementById('split_cell').play(); 145 | this.parent.socket.emit('2'); 146 | this.parent.reenviar = false; 147 | } 148 | else if (key === global.KEY_CHAT) { 149 | document.getElementById('chatInput').focus(); 150 | } 151 | } 152 | } 153 | 154 | module.exports = Canvas; 155 | -------------------------------------------------------------------------------- /src/client/js/chat-client.js: -------------------------------------------------------------------------------- 1 | var global = require('./global'); 2 | 3 | class ChatClient { 4 | constructor(params) { 5 | this.canvas = global.canvas; 6 | this.socket = global.socket; 7 | this.mobile = global.mobile; 8 | this.player = global.player; 9 | var self = this; 10 | this.commands = {}; 11 | var input = document.getElementById('chatInput'); 12 | input.addEventListener('keypress', this.sendChat.bind(this)); 13 | input.addEventListener('keyup', function(key) { 14 | input = document.getElementById('chatInput'); 15 | key = key.which || key.keyCode; 16 | if (key === global.KEY_ESC) { 17 | input.value = ''; 18 | self.canvas.cv.focus(); 19 | } 20 | }); 21 | global.chatClient = this; 22 | } 23 | 24 | // TODO: Break out many of these GameControls into separate classes. 25 | 26 | registerFunctions() { 27 | var self = this; 28 | this.registerCommand('ping', 'Check your latency.', function () { 29 | self.checkLatency(); 30 | }); 31 | 32 | this.registerCommand('dark', 'Toggle dark mode.', function () { 33 | self.toggleDarkMode(); 34 | }); 35 | 36 | this.registerCommand('border', 'Toggle visibility of border.', function () { 37 | self.toggleBorder(); 38 | }); 39 | 40 | this.registerCommand('mass', 'Toggle visibility of mass.', function () { 41 | self.toggleMass(); 42 | }); 43 | 44 | this.registerCommand('continuity', 'Toggle continuity.', function () { 45 | self.toggleContinuity(); 46 | }); 47 | 48 | this.registerCommand('roundfood', 'Toggle food drawing.', function (args) { 49 | self.toggleRoundFood(args); 50 | }); 51 | 52 | this.registerCommand('help', 'Information about the chat commands.', function () { 53 | self.printHelp(); 54 | }); 55 | 56 | this.registerCommand('login', 'Login as an admin.', function (args) { 57 | self.socket.emit('pass', args); 58 | }); 59 | 60 | this.registerCommand('kick', 'Kick a player, for admins only.', function (args) { 61 | self.socket.emit('kick', args); 62 | }); 63 | global.chatClient = this; 64 | } 65 | 66 | // Chat box implementation for the users. 67 | addChatLine(name, message, me) { 68 | if (this.mobile) { 69 | return; 70 | } 71 | var newline = document.createElement('li'); 72 | 73 | // Colours the chat input correctly. 74 | newline.className = (me) ? 'me' : 'friend'; 75 | newline.innerHTML = '' + ((name.length < 1) ? 'An unnamed cell' : name) + ': ' + message; 76 | 77 | this.appendMessage(newline); 78 | } 79 | 80 | // Chat box implementation for the system. 81 | addSystemLine(message) { 82 | if (this.mobile) { 83 | return; 84 | } 85 | var newline = document.createElement('li'); 86 | 87 | // Colours the chat input correctly. 88 | newline.className = 'system'; 89 | newline.innerHTML = message; 90 | 91 | // Append messages to the logs. 92 | this.appendMessage(newline); 93 | } 94 | 95 | // Places the message DOM node into the chat box. 96 | appendMessage(node) { 97 | if (this.mobile) { 98 | return; 99 | } 100 | var chatList = document.getElementById('chatList'); 101 | if (chatList.childNodes.length > 10) { 102 | chatList.removeChild(chatList.childNodes[0]); 103 | } 104 | chatList.appendChild(node); 105 | } 106 | 107 | // Sends a message or executes a command on the click of enter. 108 | sendChat(key) { 109 | var commands = this.commands, 110 | input = document.getElementById('chatInput'); 111 | 112 | key = key.which || key.keyCode; 113 | 114 | if (key === global.KEY_ENTER) { 115 | var text = input.value.replace(/(<([^>]+)>)/ig,''); 116 | if (text !== '') { 117 | 118 | // Chat command. 119 | if (text.indexOf('-') === 0) { 120 | var args = text.substring(1).split(' '); 121 | if (commands[args[0]]) { 122 | commands[args[0]].callback(args.slice(1)); 123 | } else { 124 | this.addSystemLine('Unrecognized Command: ' + text + ', type -help for more info.'); 125 | } 126 | 127 | // Allows for regular messages to be sent to the server. 128 | } else { 129 | this.socket.emit('playerChat', { sender: this.player.name, message: text }); 130 | this.addChatLine(this.player.name, text, true); 131 | } 132 | 133 | // Resets input. 134 | input.value = ''; 135 | this.canvas.cv.focus(); 136 | } 137 | } 138 | } 139 | 140 | // Allows for addition of commands. 141 | registerCommand(name, description, callback) { 142 | this.commands[name] = { 143 | description: description, 144 | callback: callback 145 | }; 146 | } 147 | 148 | // Allows help to print the list of all the commands and their descriptions. 149 | printHelp() { 150 | var commands = this.commands; 151 | for (var cmd in commands) { 152 | if (commands.hasOwnProperty(cmd)) { 153 | this.addSystemLine('-' + cmd + ': ' + commands[cmd].description); 154 | } 155 | } 156 | } 157 | 158 | checkLatency() { 159 | // Ping. 160 | global.startPingTime = Date.now(); 161 | this.socket.emit('pingcheck'); 162 | } 163 | 164 | toggleDarkMode() { 165 | var LIGHT = '#f2fbff', 166 | DARK = '#181818'; 167 | var LINELIGHT = '#000000', 168 | LINEDARK = '#ffffff'; 169 | 170 | if (global.backgroundColor === LIGHT) { 171 | global.backgroundColor = DARK; 172 | global.lineColor = LINEDARK; 173 | this.addSystemLine('Dark mode enabled.'); 174 | } else { 175 | global.backgroundColor = LIGHT; 176 | global.lineColor = LINELIGHT; 177 | this.addSystemLine('Dark mode disabled.'); 178 | } 179 | } 180 | 181 | toggleBorder() { 182 | if (!global.borderDraw) { 183 | global.borderDraw = true; 184 | this.addSystemLine('Showing border.'); 185 | } else { 186 | global.borderDraw = false; 187 | this.addSystemLine('Hiding border.'); 188 | } 189 | } 190 | 191 | toggleMass() { 192 | if (global.toggleMassState === 0) { 193 | global.toggleMassState = 1; 194 | this.addSystemLine('Viewing mass enabled.'); 195 | } else { 196 | global.toggleMassState = 0; 197 | this.addSystemLine('Viewing mass disabled.'); 198 | } 199 | } 200 | 201 | toggleContinuity() { 202 | if (!global.continuity) { 203 | global.continuity = true; 204 | this.addSystemLine('Continuity enabled.'); 205 | } else { 206 | global.continuity = false; 207 | this.addSystemLine('Continuity disabled.'); 208 | } 209 | } 210 | 211 | toggleRoundFood(args) { 212 | if (args || global.foodSides < 10) { 213 | global.foodSides = (args && !isNaN(args[0]) && +args[0] >= 3) ? +args[0] : 10; 214 | this.addSystemLine('Food is now rounded!'); 215 | } else { 216 | global.foodSides = 5; 217 | this.addSystemLine('Food is no longer rounded!'); 218 | } 219 | } 220 | } 221 | 222 | module.exports = ChatClient; 223 | -------------------------------------------------------------------------------- /src/client/js/global.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Keys and other mathematical constants 3 | KEY_ESC: 27, 4 | KEY_ENTER: 13, 5 | KEY_CHAT: 13, 6 | KEY_FIREFOOD: 119, 7 | KEY_SPLIT: 32, 8 | KEY_LEFT: 37, 9 | KEY_UP: 38, 10 | KEY_RIGHT: 39, 11 | KEY_DOWN: 40, 12 | borderDraw: false, 13 | mobile: false, 14 | // Canvas 15 | screen: { 16 | width: window.innerWidth, 17 | height: window.innerHeight 18 | }, 19 | game: { 20 | width: 0, 21 | height: 0 22 | }, 23 | gameStart: false, 24 | disconnected: false, 25 | kicked: false, 26 | continuity: false, 27 | startPingTime: 0, 28 | toggleMassState: 0, 29 | backgroundColor: '#f2fbff', 30 | lineColor: '#000000', 31 | }; 32 | -------------------------------------------------------------------------------- /src/client/js/render.js: -------------------------------------------------------------------------------- 1 | const FULL_ANGLE = 2 * Math.PI; 2 | 3 | const drawRoundObject = (position, radius, graph) => { 4 | graph.beginPath(); 5 | graph.arc(position.x, position.y, radius, 0, FULL_ANGLE); 6 | graph.closePath(); 7 | graph.fill(); 8 | graph.stroke(); 9 | } 10 | 11 | const drawFood = (position, food, graph) => { 12 | graph.fillStyle = 'hsl(' + food.hue + ', 100%, 50%)'; 13 | graph.strokeStyle = 'hsl(' + food.hue + ', 100%, 45%)'; 14 | graph.lineWidth = 0; 15 | drawRoundObject(position, food.radius, graph); 16 | }; 17 | 18 | const drawVirus = (position, virus, graph) => { 19 | graph.strokeStyle = virus.stroke; 20 | graph.fillStyle = virus.fill; 21 | graph.lineWidth = virus.strokeWidth; 22 | let theta = 0; 23 | let sides = 20; 24 | 25 | graph.beginPath(); 26 | for (let theta = 0; theta < FULL_ANGLE; theta += FULL_ANGLE / sides) { 27 | let point = circlePoint(position, virus.radius, theta); 28 | graph.lineTo(point.x, point.y); 29 | } 30 | graph.closePath(); 31 | graph.stroke(); 32 | graph.fill(); 33 | }; 34 | 35 | const drawFireFood = (position, mass, playerConfig, graph) => { 36 | graph.strokeStyle = 'hsl(' + mass.hue + ', 100%, 45%)'; 37 | graph.fillStyle = 'hsl(' + mass.hue + ', 100%, 50%)'; 38 | graph.lineWidth = playerConfig.border + 2; 39 | drawRoundObject(position, mass.radius - 1, graph); 40 | }; 41 | 42 | const valueInRange = (min, max, value) => Math.min(max, Math.max(min, value)) 43 | 44 | const circlePoint = (origo, radius, theta) => ({ 45 | x: origo.x + radius * Math.cos(theta), 46 | y: origo.y + radius * Math.sin(theta) 47 | }); 48 | 49 | const cellTouchingBorders = (cell, borders) => 50 | cell.x - cell.radius <= borders.left || 51 | cell.x + cell.radius >= borders.right || 52 | cell.y - cell.radius <= borders.top || 53 | cell.y + cell.radius >= borders.bottom 54 | 55 | const regulatePoint = (point, borders) => ({ 56 | x: valueInRange(borders.left, borders.right, point.x), 57 | y: valueInRange(borders.top, borders.bottom, point.y) 58 | }); 59 | 60 | const drawCellWithLines = (cell, borders, graph) => { 61 | let pointCount = 30 + ~~(cell.mass / 5); 62 | let points = []; 63 | for (let theta = 0; theta < FULL_ANGLE; theta += FULL_ANGLE / pointCount) { 64 | let point = circlePoint(cell, cell.radius, theta); 65 | points.push(regulatePoint(point, borders)); 66 | } 67 | graph.beginPath(); 68 | graph.moveTo(points[0].x, points[0].y); 69 | for (let i = 1; i < points.length; i++) { 70 | graph.lineTo(points[i].x, points[i].y); 71 | } 72 | graph.closePath(); 73 | graph.fill(); 74 | graph.stroke(); 75 | } 76 | 77 | const drawCells = (cells, playerConfig, toggleMassState, borders, graph) => { 78 | for (let cell of cells) { 79 | // Draw the cell itself 80 | graph.fillStyle = cell.color; 81 | graph.strokeStyle = cell.borderColor; 82 | graph.lineWidth = 6; 83 | if (cellTouchingBorders(cell, borders)) { 84 | // Asssemble the cell from lines 85 | drawCellWithLines(cell, borders, graph); 86 | } else { 87 | // Border corrections are not needed, the cell can be drawn as a circle 88 | drawRoundObject(cell, cell.radius, graph); 89 | } 90 | 91 | // Draw the name of the player 92 | let fontSize = Math.max(cell.radius / 3, 12); 93 | graph.lineWidth = playerConfig.textBorderSize; 94 | graph.fillStyle = playerConfig.textColor; 95 | graph.strokeStyle = playerConfig.textBorder; 96 | graph.miterLimit = 1; 97 | graph.lineJoin = 'round'; 98 | graph.textAlign = 'center'; 99 | graph.textBaseline = 'middle'; 100 | graph.font = 'bold ' + fontSize + 'px sans-serif'; 101 | graph.strokeText(cell.name, cell.x, cell.y); 102 | graph.fillText(cell.name, cell.x, cell.y); 103 | 104 | // Draw the mass (if enabled) 105 | if (toggleMassState === 1) { 106 | graph.font = 'bold ' + Math.max(fontSize / 3 * 2, 10) + 'px sans-serif'; 107 | if (cell.name.length === 0) fontSize = 0; 108 | graph.strokeText(Math.round(cell.mass), cell.x, cell.y + fontSize); 109 | graph.fillText(Math.round(cell.mass), cell.x, cell.y + fontSize); 110 | } 111 | } 112 | }; 113 | 114 | const drawGrid = (global, player, screen, graph) => { 115 | graph.lineWidth = 1; 116 | graph.strokeStyle = global.lineColor; 117 | graph.globalAlpha = 0.15; 118 | graph.beginPath(); 119 | 120 | for (let x = -player.x; x < screen.width; x += screen.height / 18) { 121 | graph.moveTo(x, 0); 122 | graph.lineTo(x, screen.height); 123 | } 124 | 125 | for (let y = -player.y; y < screen.height; y += screen.height / 18) { 126 | graph.moveTo(0, y); 127 | graph.lineTo(screen.width, y); 128 | } 129 | 130 | graph.stroke(); 131 | graph.globalAlpha = 1; 132 | }; 133 | 134 | const drawBorder = (borders, graph) => { 135 | graph.lineWidth = 1; 136 | graph.strokeStyle = '#000000' 137 | graph.beginPath() 138 | graph.moveTo(borders.left, borders.top); 139 | graph.lineTo(borders.right, borders.top); 140 | graph.lineTo(borders.right, borders.bottom); 141 | graph.lineTo(borders.left, borders.bottom); 142 | graph.closePath() 143 | graph.stroke(); 144 | }; 145 | 146 | const drawErrorMessage = (message, graph, screen) => { 147 | graph.fillStyle = '#333333'; 148 | graph.fillRect(0, 0, screen.width, screen.height); 149 | graph.textAlign = 'center'; 150 | graph.fillStyle = '#FFFFFF'; 151 | graph.font = 'bold 30px sans-serif'; 152 | graph.fillText(message, screen.width / 2, screen.height / 2); 153 | } 154 | 155 | module.exports = { 156 | drawFood, 157 | drawVirus, 158 | drawFireFood, 159 | drawCells, 160 | drawErrorMessage, 161 | drawGrid, 162 | drawBorder 163 | }; -------------------------------------------------------------------------------- /src/server/game-logic.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config'); 2 | const adjustForBoundaries = (position, radius, borderOffset, gameWidth, gameHeight) => { 3 | const borderCalc = radius + borderOffset; 4 | if (position.x > gameWidth - borderCalc) { 5 | position.x = gameWidth - borderCalc; 6 | } 7 | if (position.y > gameHeight - borderCalc) { 8 | position.y = gameHeight - borderCalc; 9 | } 10 | if (position.x < borderCalc) { 11 | position.x = borderCalc; 12 | } 13 | if (position.y < borderCalc) { 14 | position.y = borderCalc; 15 | } 16 | }; 17 | 18 | module.exports = { 19 | adjustForBoundaries 20 | }; -------------------------------------------------------------------------------- /src/server/lib/entityUtils.js: -------------------------------------------------------------------------------- 1 | const util = require("./util"); 2 | 3 | function getPosition(isUniform, radius, uniformPositions) { 4 | return isUniform ? util.uniformPosition(uniformPositions, radius) : util.randomPosition(radius); 5 | } 6 | 7 | function isVisibleEntity(entity, player, addThreshold = true) { 8 | const entityHalfSize = entity.radius + (addThreshold ? entity.radius * 0.1 : 0); 9 | return util.testRectangleRectangle( 10 | entity.x, entity.y, entityHalfSize, entityHalfSize, 11 | player.x, player.y, player.screenWidth / 2, player.screenHeight / 2); 12 | } 13 | 14 | module.exports = { 15 | getPosition, 16 | isVisibleEntity 17 | } 18 | -------------------------------------------------------------------------------- /src/server/lib/util.js: -------------------------------------------------------------------------------- 1 | /* jslint node: true */ 2 | 3 | 'use strict'; 4 | 5 | const cfg = require('../../../config'); 6 | 7 | exports.validNick = function (nickname) { 8 | var regex = /^\w*$/; 9 | return regex.exec(nickname) !== null; 10 | }; 11 | 12 | // determine mass from radius of circle 13 | exports.massToRadius = function (mass) { 14 | return 4 + Math.sqrt(mass) * 6; 15 | }; 16 | 17 | 18 | // overwrite Math.log function 19 | exports.mathLog = (function () { 20 | var log = Math.log; 21 | return function (n, base) { 22 | return log(n) / (base ? log(base) : 1); 23 | }; 24 | })(); 25 | 26 | // get the Euclidean distance between the edges of two shapes 27 | exports.getDistance = function (p1, p2) { 28 | return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)) - p1.radius - p2.radius; 29 | }; 30 | 31 | exports.randomInRange = function (from, to) { 32 | return Math.floor(Math.random() * (to - from)) + from; 33 | }; 34 | 35 | // generate a random position within the field of play 36 | exports.randomPosition = function (radius) { 37 | return { 38 | x: exports.randomInRange(radius, cfg.gameWidth - radius), 39 | y: exports.randomInRange(radius, cfg.gameHeight - radius) 40 | }; 41 | }; 42 | 43 | exports.uniformPosition = function (points, radius) { 44 | var bestCandidate, maxDistance = 0; 45 | var numberOfCandidates = 10; 46 | 47 | if (points.length === 0) { 48 | return exports.randomPosition(radius); 49 | } 50 | 51 | // Generate the candidates 52 | for (var ci = 0; ci < numberOfCandidates; ci++) { 53 | var minDistance = Infinity; 54 | var candidate = exports.randomPosition(radius); 55 | candidate.radius = radius; 56 | 57 | for (var pi = 0; pi < points.length; pi++) { 58 | var distance = exports.getDistance(candidate, points[pi]); 59 | if (distance < minDistance) { 60 | minDistance = distance; 61 | } 62 | } 63 | 64 | if (minDistance > maxDistance) { 65 | bestCandidate = candidate; 66 | maxDistance = minDistance; 67 | } else { 68 | return exports.randomPosition(radius); 69 | } 70 | } 71 | 72 | return bestCandidate; 73 | }; 74 | 75 | exports.findIndex = function (arr, id) { 76 | var len = arr.length; 77 | 78 | while (len--) { 79 | if (arr[len].id === id) { 80 | return len; 81 | } 82 | } 83 | 84 | return -1; 85 | }; 86 | 87 | exports.randomColor = function () { 88 | var color = '#' + ('00000' + (Math.random() * (1 << 24) | 0).toString(16)).slice(-6); 89 | var c = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color); 90 | var r = (parseInt(c[1], 16) - 32) > 0 ? (parseInt(c[1], 16) - 32) : 0; 91 | var g = (parseInt(c[2], 16) - 32) > 0 ? (parseInt(c[2], 16) - 32) : 0; 92 | var b = (parseInt(c[3], 16) - 32) > 0 ? (parseInt(c[3], 16) - 32) : 0; 93 | 94 | return { 95 | fill: color, 96 | border: '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1) 97 | }; 98 | }; 99 | 100 | exports.removeNulls = function (inputArray) { 101 | let result = []; 102 | for (let element of inputArray) { 103 | if (element != null) { 104 | result.push(element); 105 | } 106 | } 107 | 108 | return result; 109 | } 110 | 111 | // Removes elements from `inputArray` whose indexes are in the `indexes` array. 112 | // Leaves the original array unchanged, and returns the result. 113 | exports.removeIndexes = function (inputArray, indexes) { 114 | let nullified = inputArray; 115 | for (let index of indexes) { 116 | nullified[index] = null; 117 | } 118 | 119 | return exports.removeNulls(nullified); 120 | } 121 | 122 | // Checks if the two rectangles are colliding 123 | // width and height is for half values (WTF??) 124 | exports.testRectangleRectangle = 125 | function (centerXA, centerYA, widthA, heightA, centerXB, centerYB, widthB, heightB) { 126 | return centerXA + widthA > centerXB - widthB 127 | && centerXA - widthA < centerXB + widthB 128 | && centerYA + heightA > centerYB - heightB 129 | && centerYA - heightA < centerYB + heightB; 130 | } 131 | 132 | // Checks if the square (first 3 arguments) and the rectangle (last 4 arguments) are colliding 133 | // length, width and height is for half values (WTF??) 134 | exports.testSquareRectangle = 135 | function (centerXA, centerYA, edgeLengthA, centerXB, centerYB, widthB, heightB) { 136 | return exports.testRectangleRectangle( 137 | centerXA, centerYA, edgeLengthA, edgeLengthA, 138 | centerXB, centerYB, widthB, heightB); 139 | } 140 | 141 | exports.getIndexes = (array, predicate) => { 142 | return array.reduce((acc, value, index) => { 143 | if (predicate(value)) { 144 | acc.push(index) 145 | } 146 | return acc; 147 | }, []); 148 | } 149 | -------------------------------------------------------------------------------- /src/server/map/food.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const util = require('../lib/util'); 4 | const { v4: uuidv4 } = require('uuid'); 5 | const {getPosition} = require("../lib/entityUtils"); 6 | 7 | class Food { 8 | constructor(position, radius) { 9 | this.id = uuidv4(); 10 | this.x = position.x; 11 | this.y = position.y; 12 | this.radius = radius; 13 | this.mass = Math.random() + 2; 14 | this.hue = Math.round(Math.random() * 360); 15 | } 16 | } 17 | 18 | exports.FoodManager = class { 19 | constructor(foodMass, foodUniformDisposition) { 20 | this.data = []; 21 | this.foodMass = foodMass; 22 | this.foodUniformDisposition = foodUniformDisposition; 23 | } 24 | 25 | addNew(number) { 26 | const radius = util.massToRadius(this.foodMass); 27 | while (number--) { 28 | const position = getPosition(this.foodUniformDisposition, radius, this.data) 29 | this.data.push(new Food(position, radius)); 30 | } 31 | } 32 | 33 | removeExcess(number) { 34 | while (number-- && this.data.length) { 35 | this.data.pop(); 36 | } 37 | } 38 | 39 | delete(foodsToDelete) { 40 | if (foodsToDelete.length > 0) { 41 | this.data = util.removeIndexes(this.data, foodsToDelete); 42 | } 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/server/map/map.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const {isVisibleEntity} = require("../lib/entityUtils"); 4 | 5 | exports.foodUtils = require('./food'); 6 | exports.virusUtils = require('./virus'); 7 | exports.massFoodUtils = require('./massFood'); 8 | exports.playerUtils = require('./player'); 9 | 10 | exports.Map = class { 11 | constructor(config) { 12 | this.food = new exports.foodUtils.FoodManager(config.foodMass, config.foodUniformDisposition); 13 | this.viruses = new exports.virusUtils.VirusManager(config.virus); 14 | this.massFood = new exports.massFoodUtils.MassFoodManager(); 15 | this.players = new exports.playerUtils.PlayerManager(); 16 | } 17 | 18 | balanceMass(foodMass, gameMass, maxFood, maxVirus) { 19 | const totalMass = this.food.data.length * foodMass + this.players.getTotalMass(); 20 | 21 | const massDiff = gameMass - totalMass; 22 | const foodFreeCapacity = maxFood - this.food.data.length; 23 | const foodDiff = Math.min(parseInt(massDiff / foodMass), foodFreeCapacity); 24 | if (foodDiff > 0) { 25 | console.debug('[DEBUG] Adding ' + foodDiff + ' food'); 26 | this.food.addNew(foodDiff); 27 | } else if (foodDiff && foodFreeCapacity !== maxFood) { 28 | console.debug('[DEBUG] Removing ' + -foodDiff + ' food'); 29 | this.food.removeExcess(-foodDiff); 30 | } 31 | //console.debug('[DEBUG] Mass rebalanced!'); 32 | 33 | const virusesToAdd = maxVirus - this.viruses.data.length; 34 | if (virusesToAdd > 0) { 35 | this.viruses.addNew(virusesToAdd); 36 | } 37 | } 38 | 39 | enumerateWhatPlayersSee(callback) { 40 | for (let currentPlayer of this.players.data) { 41 | var visibleFood = this.food.data.filter(entity => isVisibleEntity(entity, currentPlayer, false)); 42 | var visibleViruses = this.viruses.data.filter(entity => isVisibleEntity(entity, currentPlayer)); 43 | var visibleMass = this.massFood.data.filter(entity => isVisibleEntity(entity, currentPlayer)); 44 | 45 | const extractData = (player) => { 46 | return { 47 | x: player.x, 48 | y: player.y, 49 | cells: player.cells, 50 | massTotal: Math.round(player.massTotal), 51 | hue: player.hue, 52 | id: player.id, 53 | name: player.name 54 | }; 55 | } 56 | 57 | var visiblePlayers = []; 58 | for (let player of this.players.data) { 59 | for (let cell of player.cells) { 60 | if (isVisibleEntity(cell, currentPlayer)) { 61 | visiblePlayers.push(extractData(player)); 62 | break; 63 | } 64 | } 65 | } 66 | 67 | callback(extractData(currentPlayer), visiblePlayers, visibleFood, visibleMass, visibleViruses); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/server/map/massFood.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const util = require('../lib/util'); 4 | const gameLogic = require('../game-logic'); 5 | const sat = require('sat') 6 | 7 | exports.MassFood = class { 8 | 9 | constructor(playerFiring, cellIndex, mass) { 10 | this.id = playerFiring.id; 11 | this.num = cellIndex; 12 | this.mass = mass; 13 | this.hue = playerFiring.hue; 14 | this.direction = new sat.Vector( 15 | playerFiring.x - playerFiring.cells[cellIndex].x + playerFiring.target.x, 16 | playerFiring.y - playerFiring.cells[cellIndex].y + playerFiring.target.y 17 | ).normalize() 18 | this.x = playerFiring.cells[cellIndex].x; 19 | this.y = playerFiring.cells[cellIndex].y; 20 | this.radius = util.massToRadius(mass); 21 | this.speed = 25; 22 | } 23 | 24 | move(gameWidth, gameHeight) { 25 | var deltaX = this.speed * this.direction.x; 26 | var deltaY = this.speed * this.direction.y; 27 | 28 | this.speed -= 0.5; 29 | if (this.speed < 0) { 30 | this.speed = 0; 31 | } 32 | if (!isNaN(deltaY)) { 33 | this.y += deltaY; 34 | } 35 | if (!isNaN(deltaX)) { 36 | this.x += deltaX; 37 | } 38 | 39 | gameLogic.adjustForBoundaries(this, this.radius, 5, gameWidth, gameHeight); 40 | } 41 | } 42 | 43 | exports.MassFoodManager = class { 44 | constructor() { 45 | this.data = []; 46 | } 47 | 48 | addNew(playerFiring, cellIndex, mass) { 49 | this.data.push(new exports.MassFood(playerFiring, cellIndex, mass)); 50 | } 51 | 52 | move (gameWidth, gameHeight) { 53 | for (let currentFood of this.data) { 54 | if (currentFood.speed > 0) currentFood.move(gameWidth, gameHeight); 55 | } 56 | } 57 | 58 | remove(indexes) { 59 | if (indexes.length > 0) { 60 | this.data = util.removeIndexes(this.data, indexes); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/server/map/player.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const util = require('../lib/util'); 4 | const sat = require('sat'); 5 | const gameLogic = require('../game-logic'); 6 | 7 | const MIN_SPEED = 6.25; 8 | const SPLIT_CELL_SPEED = 20; 9 | const SPEED_DECREMENT = 0.5; 10 | const MIN_DISTANCE = 50; 11 | const PUSHING_AWAY_SPEED = 1.1; 12 | const MERGE_TIMER = 15; 13 | 14 | class Cell { 15 | constructor(x, y, mass, speed) { 16 | this.x = x; 17 | this.y = y; 18 | this.mass = mass; 19 | this.radius = util.massToRadius(mass); 20 | this.speed = speed; 21 | } 22 | 23 | setMass(mass) { 24 | this.mass = mass; 25 | this.recalculateRadius(); 26 | } 27 | 28 | addMass(mass) { 29 | this.setMass(this.mass + mass); 30 | } 31 | 32 | recalculateRadius() { 33 | this.radius = util.massToRadius(this.mass); 34 | } 35 | 36 | toCircle() { 37 | return new sat.Circle(new sat.Vector(this.x, this.y), this.radius); 38 | } 39 | 40 | move(playerX, playerY, playerTarget, slowBase, initMassLog) { 41 | var target = { 42 | x: playerX - this.x + playerTarget.x, 43 | y: playerY - this.y + playerTarget.y 44 | }; 45 | var dist = Math.hypot(target.y, target.x) 46 | var deg = Math.atan2(target.y, target.x); 47 | var slowDown = 1; 48 | if (this.speed <= MIN_SPEED) { 49 | slowDown = util.mathLog(this.mass, slowBase) - initMassLog + 1; 50 | } 51 | 52 | var deltaY = this.speed * Math.sin(deg) / slowDown; 53 | var deltaX = this.speed * Math.cos(deg) / slowDown; 54 | 55 | if (this.speed > MIN_SPEED) { 56 | this.speed -= SPEED_DECREMENT; 57 | } 58 | if (dist < (MIN_DISTANCE + this.radius)) { 59 | deltaY *= dist / (MIN_DISTANCE + this.radius); 60 | deltaX *= dist / (MIN_DISTANCE + this.radius); 61 | } 62 | 63 | if (!isNaN(deltaY)) { 64 | this.y += deltaY; 65 | } 66 | if (!isNaN(deltaX)) { 67 | this.x += deltaX; 68 | } 69 | } 70 | 71 | // 0: nothing happened 72 | // 1: A ate B 73 | // 2: B ate A 74 | static checkWhoAteWho(cellA, cellB) { 75 | if (!cellA || !cellB) return 0; 76 | let response = new sat.Response(); 77 | let colliding = sat.testCircleCircle(cellA.toCircle(), cellB.toCircle(), response); 78 | if (!colliding) return 0; 79 | if (response.bInA) return 1; 80 | if (response.aInB) return 2; 81 | return 0; 82 | } 83 | } 84 | 85 | exports.Player = class { 86 | constructor(id) { 87 | this.id = id; 88 | this.hue = Math.round(Math.random() * 360); 89 | this.name = null; 90 | this.admin = false; 91 | this.screenWidth = null; 92 | this.screenHeight = null; 93 | this.timeToMerge = null; 94 | this.setLastHeartbeat(); 95 | } 96 | 97 | /* Initalizes things that change with every respawn */ 98 | init(position, defaultPlayerMass) { 99 | this.cells = [new Cell(position.x, position.y, defaultPlayerMass, MIN_SPEED)]; 100 | this.massTotal = defaultPlayerMass; 101 | this.x = position.x; 102 | this.y = position.y; 103 | this.target = { 104 | x: 0, 105 | y: 0 106 | }; 107 | } 108 | 109 | clientProvidedData(playerData) { 110 | this.name = playerData.name; 111 | this.screenWidth = playerData.screenWidth; 112 | this.screenHeight = playerData.screenHeight; 113 | this.setLastHeartbeat(); 114 | } 115 | 116 | setLastHeartbeat() { 117 | this.lastHeartbeat = Date.now(); 118 | } 119 | 120 | setLastSplit() { 121 | this.timeToMerge = Date.now() + 1000 * MERGE_TIMER; 122 | } 123 | 124 | loseMassIfNeeded(massLossRate, defaultPlayerMass, minMassLoss) { 125 | for (let i in this.cells) { 126 | if (this.cells[i].mass * (1 - (massLossRate / 1000)) > defaultPlayerMass && this.massTotal > minMassLoss) { 127 | var massLoss = this.cells[i].mass * (massLossRate / 1000); 128 | this.changeCellMass(i, -massLoss); 129 | } 130 | } 131 | } 132 | 133 | changeCellMass(cellIndex, massDifference) { 134 | this.cells[cellIndex].addMass(massDifference) 135 | this.massTotal += massDifference; 136 | } 137 | 138 | removeCell(cellIndex) { 139 | this.massTotal -= this.cells[cellIndex].mass; 140 | this.cells.splice(cellIndex, 1); 141 | return this.cells.length === 0; 142 | } 143 | 144 | 145 | // Splits a cell into multiple cells with identical mass 146 | // Creates n-1 new cells, and lowers the mass of the original cell 147 | // If the resulting cells would be smaller than defaultPlayerMass, creates fewer and bigger cells. 148 | splitCell(cellIndex, maxRequestedPieces, defaultPlayerMass) { 149 | let cellToSplit = this.cells[cellIndex]; 150 | let maxAllowedPieces = Math.floor(cellToSplit.mass / defaultPlayerMass); // If we split the cell ino more pieces, they will be too small. 151 | let piecesToCreate = Math.min(maxAllowedPieces, maxRequestedPieces); 152 | if (piecesToCreate === 0) { 153 | return; 154 | } 155 | let newCellsMass = cellToSplit.mass / piecesToCreate; 156 | for (let i = 0; i < piecesToCreate - 1; i++) { 157 | this.cells.push(new Cell(cellToSplit.x, cellToSplit.y, newCellsMass, SPLIT_CELL_SPEED)); 158 | } 159 | cellToSplit.setMass(newCellsMass) 160 | this.setLastSplit(); 161 | } 162 | 163 | // Performs a split resulting from colliding with a virus. 164 | // The player will have the highest possible number of cells. 165 | virusSplit(cellIndexes, maxCells, defaultPlayerMass) { 166 | for (let cellIndex of cellIndexes) { 167 | this.splitCell(cellIndex, maxCells - this.cells.length + 1, defaultPlayerMass); 168 | } 169 | } 170 | 171 | // Performs a split initiated by the player. 172 | // Tries to split every cell in half. 173 | userSplit(maxCells, defaultPlayerMass) { 174 | let cellsToCreate; 175 | if (this.cells.length > maxCells / 2) { // Not every cell can be split 176 | cellsToCreate = maxCells - this.cells.length + 1; 177 | 178 | this.cells.sort(function (a, b) { // Sort the cells so the biggest ones will be split 179 | return b.mass - a.mass; 180 | }); 181 | } else { // Every cell can be split 182 | cellsToCreate = this.cells.length; 183 | } 184 | 185 | for (let i = 0; i < cellsToCreate; i++) { 186 | this.splitCell(i, 2, defaultPlayerMass); 187 | } 188 | } 189 | 190 | // Loops trough cells, and calls callback with colliding ones 191 | // Passes the colliding cells and their indexes to the callback 192 | // null values are skipped during the iteration and removed at the end 193 | enumerateCollidingCells(callback) { 194 | for (let cellAIndex = 0; cellAIndex < this.cells.length; cellAIndex++) { 195 | let cellA = this.cells[cellAIndex]; 196 | if (!cellA) continue; // cell has already been merged 197 | 198 | for (let cellBIndex = cellAIndex + 1; cellBIndex < this.cells.length; cellBIndex++) { 199 | let cellB = this.cells[cellBIndex]; 200 | if (!cellB) continue; 201 | let colliding = sat.testCircleCircle(cellA.toCircle(), cellB.toCircle()); 202 | if (colliding) { 203 | callback(this.cells, cellAIndex, cellBIndex); 204 | } 205 | } 206 | } 207 | 208 | this.cells = util.removeNulls(this.cells); 209 | } 210 | 211 | mergeCollidingCells() { 212 | this.enumerateCollidingCells(function (cells, cellAIndex, cellBIndex) { 213 | cells[cellAIndex].addMass(cells[cellBIndex].mass); 214 | cells[cellBIndex] = null; 215 | }); 216 | } 217 | 218 | pushAwayCollidingCells() { 219 | this.enumerateCollidingCells(function (cells, cellAIndex, cellBIndex) { 220 | let cellA = cells[cellAIndex], 221 | cellB = cells[cellBIndex], 222 | vector = new sat.Vector(cellB.x - cellA.x, cellB.y - cellA.y); // vector pointing from A to B 223 | vector = vector.normalize().scale(PUSHING_AWAY_SPEED, PUSHING_AWAY_SPEED); 224 | if (vector.len() == 0) { // The two cells are perfectly on the top of each other 225 | vector = new sat.Vector(0, 1); 226 | } 227 | 228 | cellA.x -= vector.x; 229 | cellA.y -= vector.y; 230 | 231 | cellB.x += vector.x; 232 | cellB.y += vector.y; 233 | }); 234 | } 235 | 236 | move(slowBase, gameWidth, gameHeight, initMassLog) { 237 | if (this.cells.length > 1) { 238 | if (this.timeToMerge < Date.now()) { 239 | this.mergeCollidingCells(); 240 | } else { 241 | this.pushAwayCollidingCells(); 242 | } 243 | } 244 | 245 | let xSum = 0, ySum = 0; 246 | for (let i = 0; i < this.cells.length; i++) { 247 | let cell = this.cells[i]; 248 | cell.move(this.x, this.y, this.target, slowBase, initMassLog); 249 | gameLogic.adjustForBoundaries(cell, cell.radius/3, 0, gameWidth, gameHeight); 250 | 251 | xSum += cell.x; 252 | ySum += cell.y; 253 | } 254 | this.x = xSum / this.cells.length; 255 | this.y = ySum / this.cells.length; 256 | } 257 | 258 | // Calls `callback` if any of the two cells ate the other. 259 | static checkForCollisions(playerA, playerB, playerAIndex, playerBIndex, callback) { 260 | for (let cellAIndex in playerA.cells) { 261 | for (let cellBIndex in playerB.cells) { 262 | let cellA = playerA.cells[cellAIndex]; 263 | let cellB = playerB.cells[cellBIndex]; 264 | 265 | let cellAData = { playerIndex: playerAIndex, cellIndex: cellAIndex }; 266 | let cellBData = { playerIndex: playerBIndex, cellIndex: cellBIndex }; 267 | 268 | let whoAteWho = Cell.checkWhoAteWho(cellA, cellB); 269 | 270 | if (whoAteWho == 1) { 271 | callback(cellBData, cellAData); 272 | } else if (whoAteWho == 2) { 273 | callback(cellAData, cellBData); 274 | } 275 | } 276 | } 277 | } 278 | } 279 | exports.PlayerManager = class { 280 | constructor() { 281 | this.data = []; 282 | } 283 | 284 | pushNew(player) { 285 | this.data.push(player); 286 | } 287 | 288 | findIndexByID(id) { 289 | return util.findIndex(this.data, id); 290 | } 291 | 292 | removePlayerByID(id) { 293 | let index = this.findIndexByID(id); 294 | if (index > -1) { 295 | this.removePlayerByIndex(index); 296 | } 297 | } 298 | 299 | removePlayerByIndex(index) { 300 | this.data.splice(index, 1); 301 | } 302 | 303 | shrinkCells(massLossRate, defaultPlayerMass, minMassLoss) { 304 | for (let player of this.data) { 305 | player.loseMassIfNeeded(massLossRate, defaultPlayerMass, minMassLoss); 306 | } 307 | } 308 | 309 | removeCell(playerIndex, cellIndex) { 310 | return this.data[playerIndex].removeCell(cellIndex); 311 | } 312 | 313 | getCell(playerIndex, cellIndex) { 314 | return this.data[playerIndex].cells[cellIndex] 315 | } 316 | 317 | handleCollisions(callback) { 318 | for (let playerAIndex = 0; playerAIndex < this.data.length; playerAIndex++) { 319 | for (let playerBIndex = playerAIndex + 1; playerBIndex < this.data.length; playerBIndex++) { 320 | exports.Player.checkForCollisions( 321 | this.data[playerAIndex], 322 | this.data[playerBIndex], 323 | playerAIndex, 324 | playerBIndex, 325 | callback 326 | ); 327 | } 328 | } 329 | } 330 | 331 | getTopPlayers() { 332 | this.data.sort(function (a, b) { return b.massTotal - a.massTotal; }); 333 | var topPlayers = []; 334 | for (var i = 0; i < Math.min(10, this.data.length); i++) { 335 | topPlayers.push({ 336 | id: this.data[i].id, 337 | name: this.data[i].name 338 | }); 339 | } 340 | return topPlayers; 341 | } 342 | 343 | getTotalMass() { 344 | let result = 0; 345 | for (let player of this.data) { 346 | result += player.massTotal; 347 | } 348 | return result; 349 | } 350 | } 351 | -------------------------------------------------------------------------------- /src/server/map/virus.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const util = require('../lib/util'); 4 | const { v4: uuidv4 } = require('uuid'); 5 | const {getPosition} = require("../lib/entityUtils"); 6 | 7 | class Virus { 8 | constructor(position, radius, mass, config) { 9 | this.id = uuidv4(); 10 | this.x = position.x; 11 | this.y = position.y; 12 | this.radius = radius; 13 | this.mass = mass; 14 | this.fill = config.fill; 15 | this.stroke = config.stroke; 16 | this.strokeWidth = config.strokeWidth; 17 | } 18 | } 19 | 20 | exports.VirusManager = class { 21 | constructor(virusConfig) { 22 | this.data = []; 23 | this.virusConfig = virusConfig; 24 | } 25 | 26 | pushNew(virus) { 27 | this.data.push(virus); 28 | } 29 | 30 | addNew(number) { 31 | while (number--) { 32 | var mass = util.randomInRange(this.virusConfig.defaultMass.from, this.virusConfig.defaultMass.to); 33 | var radius = util.massToRadius(mass); 34 | var position = getPosition(this.virusConfig.uniformDisposition, radius, this.data); 35 | var newVirus = new Virus(position, radius, mass, this.virusConfig); 36 | this.pushNew(newVirus); 37 | } 38 | } 39 | 40 | delete(virusCollision) { 41 | this.data.splice(virusCollision, 1); 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /src/server/repositories/chat-repository.js: -------------------------------------------------------------------------------- 1 | const db = require("../sql.js"); 2 | 3 | const logChatMessage = async (username, message, ipAddress) => { 4 | const timestamp = new Date().getTime(); 5 | 6 | return new Promise((resolve) => { 7 | db.run( 8 | "INSERT INTO chat_messages (username, message, ip_address, timestamp) VALUES (?, ?, ?, ?)", 9 | [username, message, ipAddress, timestamp], 10 | (err) => { 11 | if (err) console.error(err); 12 | resolve(); 13 | } 14 | ); 15 | }); 16 | }; 17 | 18 | module.exports = { 19 | logChatMessage, 20 | }; 21 | -------------------------------------------------------------------------------- /src/server/repositories/logging-repository.js: -------------------------------------------------------------------------------- 1 | const db = require("../sql.js"); 2 | 3 | const logFailedLoginAttempt = async (username, ipAddress) => { 4 | return new Promise((resolve) => { 5 | db.run( 6 | "INSERT INTO failed_login_attempts (username, ip_address) VALUES (?, ?)", 7 | [username, ipAddress], 8 | (err) => { 9 | if (err) console.error(err); 10 | resolve(); 11 | } 12 | ); 13 | }); 14 | }; 15 | 16 | module.exports = { 17 | logFailedLoginAttempt, 18 | }; 19 | -------------------------------------------------------------------------------- /src/server/server.js: -------------------------------------------------------------------------------- 1 | /*jslint bitwise: true, node: true */ 2 | 'use strict'; 3 | 4 | const express = require('express'); 5 | const app = express(); 6 | const http = require('http').Server(app); 7 | const io = require('socket.io')(http); 8 | const SAT = require('sat'); 9 | 10 | const gameLogic = require('./game-logic'); 11 | const loggingRepositry = require('./repositories/logging-repository'); 12 | const chatRepository = require('./repositories/chat-repository'); 13 | const config = require('../../config'); 14 | const util = require('./lib/util'); 15 | const mapUtils = require('./map/map'); 16 | const {getPosition} = require("./lib/entityUtils"); 17 | 18 | let map = new mapUtils.Map(config); 19 | 20 | let sockets = {}; 21 | let spectators = []; 22 | const INIT_MASS_LOG = util.mathLog(config.defaultPlayerMass, config.slowBase); 23 | 24 | let leaderboard = []; 25 | let leaderboardChanged = false; 26 | 27 | const Vector = SAT.Vector; 28 | 29 | app.use(express.static(__dirname + '/../client')); 30 | 31 | io.on('connection', function (socket) { 32 | let type = socket.handshake.query.type; 33 | console.log('User has connected: ', type); 34 | switch (type) { 35 | case 'player': 36 | addPlayer(socket); 37 | break; 38 | case 'spectator': 39 | addSpectator(socket); 40 | break; 41 | default: 42 | console.log('Unknown user type, not doing anything.'); 43 | } 44 | }); 45 | 46 | function generateSpawnpoint() { 47 | let radius = util.massToRadius(config.defaultPlayerMass); 48 | return getPosition(config.newPlayerInitialPosition === 'farthest', radius, map.players.data) 49 | } 50 | 51 | 52 | const addPlayer = (socket) => { 53 | var currentPlayer = new mapUtils.playerUtils.Player(socket.id); 54 | 55 | socket.on('gotit', function (clientPlayerData) { 56 | console.log('[INFO] Player ' + clientPlayerData.name + ' connecting!'); 57 | currentPlayer.init(generateSpawnpoint(), config.defaultPlayerMass); 58 | 59 | if (map.players.findIndexByID(socket.id) > -1) { 60 | console.log('[INFO] Player ID is already connected, kicking.'); 61 | socket.disconnect(); 62 | } else if (!util.validNick(clientPlayerData.name)) { 63 | socket.emit('kick', 'Invalid username.'); 64 | socket.disconnect(); 65 | } else { 66 | console.log('[INFO] Player ' + clientPlayerData.name + ' connected!'); 67 | sockets[socket.id] = socket; 68 | 69 | const sanitizedName = clientPlayerData.name.replace(/(<([^>]+)>)/ig, ''); 70 | clientPlayerData.name = sanitizedName; 71 | 72 | currentPlayer.clientProvidedData(clientPlayerData); 73 | map.players.pushNew(currentPlayer); 74 | io.emit('playerJoin', { name: currentPlayer.name }); 75 | console.log('Total players: ' + map.players.data.length); 76 | } 77 | 78 | }); 79 | 80 | socket.on('pingcheck', () => { 81 | socket.emit('pongcheck'); 82 | }); 83 | 84 | socket.on('windowResized', (data) => { 85 | currentPlayer.screenWidth = data.screenWidth; 86 | currentPlayer.screenHeight = data.screenHeight; 87 | }); 88 | 89 | socket.on('respawn', () => { 90 | map.players.removePlayerByID(currentPlayer.id); 91 | socket.emit('welcome', currentPlayer, { 92 | width: config.gameWidth, 93 | height: config.gameHeight 94 | }); 95 | console.log('[INFO] User ' + currentPlayer.name + ' has respawned'); 96 | }); 97 | 98 | socket.on('disconnect', () => { 99 | map.players.removePlayerByID(currentPlayer.id); 100 | console.log('[INFO] User ' + currentPlayer.name + ' has disconnected'); 101 | socket.broadcast.emit('playerDisconnect', { name: currentPlayer.name }); 102 | }); 103 | 104 | socket.on('playerChat', (data) => { 105 | var _sender = data.sender.replace(/(<([^>]+)>)/ig, ''); 106 | var _message = data.message.replace(/(<([^>]+)>)/ig, ''); 107 | 108 | if (config.logChat === 1) { 109 | console.log('[CHAT] [' + (new Date()).getHours() + ':' + (new Date()).getMinutes() + '] ' + _sender + ': ' + _message); 110 | } 111 | 112 | socket.broadcast.emit('serverSendPlayerChat', { 113 | sender: currentPlayer.name, 114 | message: _message.substring(0, 35) 115 | }); 116 | 117 | chatRepository.logChatMessage(_sender, _message, currentPlayer.ipAddress) 118 | .catch((err) => console.error("Error when attempting to log chat message", err)); 119 | }); 120 | 121 | socket.on('pass', async (data) => { 122 | const password = data[0]; 123 | if (password === config.adminPass) { 124 | console.log('[ADMIN] ' + currentPlayer.name + ' just logged in as an admin.'); 125 | socket.emit('serverMSG', 'Welcome back ' + currentPlayer.name); 126 | socket.broadcast.emit('serverMSG', currentPlayer.name + ' just logged in as an admin.'); 127 | currentPlayer.admin = true; 128 | } else { 129 | console.log('[ADMIN] ' + currentPlayer.name + ' attempted to log in with the incorrect password: ' + password); 130 | 131 | socket.emit('serverMSG', 'Password incorrect, attempt logged.'); 132 | 133 | loggingRepositry.logFailedLoginAttempt(currentPlayer.name, currentPlayer.ipAddress) 134 | .catch((err) => console.error("Error when attempting to log failed login attempt", err)); 135 | } 136 | }); 137 | 138 | socket.on('kick', (data) => { 139 | if (!currentPlayer.admin) { 140 | socket.emit('serverMSG', 'You are not permitted to use this command.'); 141 | return; 142 | } 143 | 144 | var reason = ''; 145 | var worked = false; 146 | for (let playerIndex in map.players.data) { 147 | let player = map.players.data[playerIndex]; 148 | if (player.name === data[0] && !player.admin && !worked) { 149 | if (data.length > 1) { 150 | for (var f = 1; f < data.length; f++) { 151 | if (f === data.length) { 152 | reason = reason + data[f]; 153 | } 154 | else { 155 | reason = reason + data[f] + ' '; 156 | } 157 | } 158 | } 159 | if (reason !== '') { 160 | console.log('[ADMIN] User ' + player.name + ' kicked successfully by ' + currentPlayer.name + ' for reason ' + reason); 161 | } 162 | else { 163 | console.log('[ADMIN] User ' + player.name + ' kicked successfully by ' + currentPlayer.name); 164 | } 165 | socket.emit('serverMSG', 'User ' + player.name + ' was kicked by ' + currentPlayer.name); 166 | sockets[player.id].emit('kick', reason); 167 | sockets[player.id].disconnect(); 168 | map.players.removePlayerByIndex(playerIndex); 169 | worked = true; 170 | } 171 | } 172 | if (!worked) { 173 | socket.emit('serverMSG', 'Could not locate user or user is an admin.'); 174 | } 175 | }); 176 | 177 | // Heartbeat function, update everytime. 178 | socket.on('0', (target) => { 179 | currentPlayer.lastHeartbeat = new Date().getTime(); 180 | if (target.x !== currentPlayer.x || target.y !== currentPlayer.y) { 181 | currentPlayer.target = target; 182 | } 183 | }); 184 | 185 | socket.on('1', function () { 186 | // Fire food. 187 | const minCellMass = config.defaultPlayerMass + config.fireFood; 188 | for (let i = 0; i < currentPlayer.cells.length; i++) { 189 | if (currentPlayer.cells[i].mass >= minCellMass) { 190 | currentPlayer.changeCellMass(i, -config.fireFood); 191 | map.massFood.addNew(currentPlayer, i, config.fireFood); 192 | } 193 | } 194 | }); 195 | 196 | socket.on('2', () => { 197 | currentPlayer.userSplit(config.limitSplit, config.defaultPlayerMass); 198 | }); 199 | } 200 | 201 | const addSpectator = (socket) => { 202 | socket.on('gotit', function () { 203 | sockets[socket.id] = socket; 204 | spectators.push(socket.id); 205 | io.emit('playerJoin', { name: '' }); 206 | }); 207 | 208 | socket.emit("welcome", {}, { 209 | width: config.gameWidth, 210 | height: config.gameHeight 211 | }); 212 | } 213 | 214 | const tickPlayer = (currentPlayer) => { 215 | if (currentPlayer.lastHeartbeat < new Date().getTime() - config.maxHeartbeatInterval) { 216 | sockets[currentPlayer.id].emit('kick', 'Last heartbeat received over ' + config.maxHeartbeatInterval + ' ago.'); 217 | sockets[currentPlayer.id].disconnect(); 218 | } 219 | 220 | currentPlayer.move(config.slowBase, config.gameWidth, config.gameHeight, INIT_MASS_LOG); 221 | 222 | const isEntityInsideCircle = (point, circle) => { 223 | return SAT.pointInCircle(new Vector(point.x, point.y), circle); 224 | }; 225 | 226 | const canEatMass = (cell, cellCircle, cellIndex, mass) => { 227 | if (isEntityInsideCircle(mass, cellCircle)) { 228 | if (mass.id === currentPlayer.id && mass.speed > 0 && cellIndex === mass.num) 229 | return false; 230 | if (cell.mass > mass.mass * 1.1) 231 | return true; 232 | } 233 | 234 | return false; 235 | }; 236 | 237 | const canEatVirus = (cell, cellCircle, virus) => { 238 | return virus.mass < cell.mass && isEntityInsideCircle(virus, cellCircle) 239 | } 240 | 241 | const cellsToSplit = []; 242 | for (let cellIndex = 0; cellIndex < currentPlayer.cells.length; cellIndex++) { 243 | const currentCell = currentPlayer.cells[cellIndex]; 244 | 245 | const cellCircle = currentCell.toCircle(); 246 | 247 | const eatenFoodIndexes = util.getIndexes(map.food.data, food => isEntityInsideCircle(food, cellCircle)); 248 | const eatenMassIndexes = util.getIndexes(map.massFood.data, mass => canEatMass(currentCell, cellCircle, cellIndex, mass)); 249 | const eatenVirusIndexes = util.getIndexes(map.viruses.data, virus => canEatVirus(currentCell, cellCircle, virus)); 250 | 251 | if (eatenVirusIndexes.length > 0) { 252 | cellsToSplit.push(cellIndex); 253 | map.viruses.delete(eatenVirusIndexes) 254 | } 255 | 256 | let massGained = eatenMassIndexes.reduce((acc, index) => acc + map.massFood.data[index].mass, 0); 257 | 258 | map.food.delete(eatenFoodIndexes); 259 | map.massFood.remove(eatenMassIndexes); 260 | massGained += (eatenFoodIndexes.length * config.foodMass); 261 | currentPlayer.changeCellMass(cellIndex, massGained); 262 | } 263 | currentPlayer.virusSplit(cellsToSplit, config.limitSplit, config.defaultPlayerMass); 264 | }; 265 | 266 | const tickGame = () => { 267 | map.players.data.forEach(tickPlayer); 268 | map.massFood.move(config.gameWidth, config.gameHeight); 269 | 270 | map.players.handleCollisions(function (gotEaten, eater) { 271 | const cellGotEaten = map.players.getCell(gotEaten.playerIndex, gotEaten.cellIndex); 272 | 273 | map.players.data[eater.playerIndex].changeCellMass(eater.cellIndex, cellGotEaten.mass); 274 | 275 | const playerDied = map.players.removeCell(gotEaten.playerIndex, gotEaten.cellIndex); 276 | if (playerDied) { 277 | let playerGotEaten = map.players.data[gotEaten.playerIndex]; 278 | io.emit('playerDied', { name: playerGotEaten.name }); //TODO: on client it is `playerEatenName` instead of `name` 279 | sockets[playerGotEaten.id].emit('RIP'); 280 | map.players.removePlayerByIndex(gotEaten.playerIndex); 281 | } 282 | }); 283 | 284 | }; 285 | 286 | const calculateLeaderboard = () => { 287 | const topPlayers = map.players.getTopPlayers(); 288 | 289 | if (leaderboard.length !== topPlayers.length) { 290 | leaderboard = topPlayers; 291 | leaderboardChanged = true; 292 | } else { 293 | for (let i = 0; i < leaderboard.length; i++) { 294 | if (leaderboard[i].id !== topPlayers[i].id) { 295 | leaderboard = topPlayers; 296 | leaderboardChanged = true; 297 | break; 298 | } 299 | } 300 | } 301 | } 302 | 303 | const gameloop = () => { 304 | if (map.players.data.length > 0) { 305 | calculateLeaderboard(); 306 | map.players.shrinkCells(config.massLossRate, config.defaultPlayerMass, config.minMassLoss); 307 | } 308 | 309 | map.balanceMass(config.foodMass, config.gameMass, config.maxFood, config.maxVirus); 310 | }; 311 | 312 | const sendUpdates = () => { 313 | spectators.forEach(updateSpectator); 314 | map.enumerateWhatPlayersSee(function (playerData, visiblePlayers, visibleFood, visibleMass, visibleViruses) { 315 | sockets[playerData.id].emit('serverTellPlayerMove', playerData, visiblePlayers, visibleFood, visibleMass, visibleViruses); 316 | if (leaderboardChanged) { 317 | sendLeaderboard(sockets[playerData.id]); 318 | } 319 | }); 320 | 321 | leaderboardChanged = false; 322 | }; 323 | 324 | const sendLeaderboard = (socket) => { 325 | socket.emit('leaderboard', { 326 | players: map.players.data.length, 327 | leaderboard 328 | }); 329 | } 330 | const updateSpectator = (socketID) => { 331 | let playerData = { 332 | x: config.gameWidth / 2, 333 | y: config.gameHeight / 2, 334 | cells: [], 335 | massTotal: 0, 336 | hue: 100, 337 | id: socketID, 338 | name: '' 339 | }; 340 | sockets[socketID].emit('serverTellPlayerMove', playerData, map.players.data, map.food.data, map.massFood.data, map.viruses.data); 341 | if (leaderboardChanged) { 342 | sendLeaderboard(sockets[socketID]); 343 | } 344 | } 345 | 346 | setInterval(tickGame, 1000 / 60); 347 | setInterval(gameloop, 1000); 348 | setInterval(sendUpdates, 1000 / config.networkUpdateFactor); 349 | 350 | // Don't touch, IP configurations. 351 | var ipaddress = process.env.OPENSHIFT_NODEJS_IP || process.env.IP || config.host; 352 | var serverport = process.env.OPENSHIFT_NODEJS_PORT || process.env.PORT || config.port; 353 | http.listen(serverport, ipaddress, () => console.log('[DEBUG] Listening on ' + ipaddress + ':' + serverport)); 354 | -------------------------------------------------------------------------------- /src/server/sql.js: -------------------------------------------------------------------------------- 1 | const sqlite3 = require('sqlite3').verbose(); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | const config = require('../../config'); 5 | 6 | const sqlInfo = config.sqlinfo; 7 | const dbPath = path.join(__dirname, 'db', sqlInfo.fileName); 8 | 9 | // Ensure the database folder exists 10 | const dbFolder = path.dirname(dbPath); 11 | if (!fs.existsSync(dbFolder)) { 12 | fs.mkdirSync(dbFolder, { recursive: true }); 13 | console.log(`Created the database folder: ${dbFolder}`); 14 | } 15 | 16 | // Create the database connection 17 | const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, (err) => { 18 | if (err) { 19 | console.error(err); 20 | } else { 21 | console.log('Connected to the SQLite database.'); 22 | 23 | // Perform any necessary table creations 24 | db.serialize(() => { 25 | db.run(`CREATE TABLE IF NOT EXISTS failed_login_attempts ( 26 | username TEXT, 27 | ip_address TEXT 28 | )`, (err) => { 29 | if (err) { 30 | console.error(err); 31 | } 32 | else { 33 | console.log("Created failed_login_attempts table"); 34 | } 35 | }); 36 | 37 | db.run(`CREATE TABLE IF NOT EXISTS chat_messages ( 38 | username TEXT, 39 | message TEXT, 40 | ip_address TEXT, 41 | timestamp INTEGER 42 | )`, (err) => { 43 | if (err) { 44 | console.error(err); 45 | } 46 | else { 47 | console.log("Created chat_messages table"); 48 | } 49 | }); 50 | }); 51 | } 52 | }); 53 | 54 | process.on('beforeExit', () => { 55 | db.close((err) => { 56 | if (err) { 57 | console.error('Error closing the database connection. ', err); 58 | } else { 59 | console.log('Closed the database connection.'); 60 | } 61 | }); 62 | }); 63 | 64 | module.exports = db; 65 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owenashurst/agar.io-clone/cb8c12173cd6ae07abbc300a8838de0ab1d358ad/test/server.js -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | /*jshint expr:true */ 2 | 3 | const expect = require('chai').expect; 4 | const util = require('../src/server/lib/util'); 5 | 6 | /** 7 | * Tests for server/lib/util.js 8 | * 9 | * This is mostly a regression suite, to make sure behavior 10 | * is preserved throughout changes to the server infrastructure. 11 | */ 12 | 13 | describe('util.js', () => { 14 | describe('massToRadius', () => { 15 | it('should return non-zero radius on zero input', () => { 16 | var r = util.massToRadius(0); 17 | expect(r).to.be.a('number'); 18 | expect(r).to.equal(4); 19 | }); 20 | 21 | it('should convert masses to a circle radius', () => { 22 | var r1 = util.massToRadius(4), 23 | r2 = util.massToRadius(16), 24 | r3 = util.massToRadius(1); 25 | 26 | expect(r1).to.equal(16); 27 | expect(r2).to.equal(28); 28 | expect(r3).to.equal(10); 29 | }); 30 | }); 31 | 32 | describe('validNick', () => { 33 | it.skip('should allow empty player nicknames', () => { 34 | var bool = util.validNick(''); 35 | expect(bool).to.be.true; 36 | }); 37 | 38 | it('should allow ascii character nicknames', () => { 39 | var n1 = util.validNick('Walter_White'), 40 | n2 = util.validNick('Jesse_Pinkman'), 41 | n3 = util.validNick('hank'), 42 | n4 = util.validNick('marie_schrader12'), 43 | n5 = util.validNick('p'); 44 | 45 | expect(n1).to.be.true; 46 | expect(n2).to.be.true; 47 | expect(n3).to.be.true; 48 | expect(n4).to.be.true; 49 | expect(n5).to.be.true; 50 | }); 51 | 52 | it('should disallow unicode-dependent alphabets', () => { 53 | var n1 = util.validNick('Йèæü'); 54 | 55 | expect(n1).to.be.false; 56 | }); 57 | 58 | it('should disallow spaces in nicknames', () => { 59 | var n1 = util.validNick('Walter White'); 60 | expect(n1).to.be.false; 61 | }); 62 | }); 63 | 64 | describe('log', () => { 65 | it('should compute the log_{base} of a number', () => { 66 | const base10 = util.mathLog(1, 10); 67 | const base2 = util.mathLog(1, 2); 68 | const identity = util.mathLog(10, 10); 69 | const logNineThree = Math.round(util.mathLog(9,3) * 1e5) / 1e5; // Tolerate rounding errors 70 | 71 | // log(1) should equal 0, no matter the base 72 | expect(base10).to.eql(base2); 73 | 74 | // log(n,n) === 1 75 | expect(identity).to.eql(1); 76 | 77 | // perform a trivial log calculation: 3^2 === 9 78 | expect(logNineThree).to.eql(2); 79 | }); 80 | 81 | }); 82 | 83 | describe('getDistance', () => { 84 | const Point = (x, y, r) => { 85 | return { 86 | x, 87 | y, 88 | radius: r 89 | }; 90 | } 91 | 92 | const p1 = Point(-100, 20, 1); 93 | const p2 = Point(0, 40, 5); 94 | 95 | it('should return a positive number', () => { 96 | var distance = util.getDistance(p1, p2); 97 | expect(distance).to.be.above(-1); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = (isProduction) => ({ 2 | entry: "./src/client/js/app.js", 3 | mode: isProduction ? 'production' : 'development', 4 | output: { 5 | library: "app", 6 | filename: "app.js" 7 | }, 8 | devtool: false, 9 | module: { 10 | rules: getRules(isProduction) 11 | }, 12 | }); 13 | 14 | function getRules(isProduction) { 15 | if (isProduction) { 16 | return [ 17 | { 18 | test: /\.(?:js|mjs|cjs)$/, 19 | exclude: /node_modules/, 20 | use: { 21 | loader: 'babel-loader', 22 | options: { 23 | presets: [ 24 | ['@babel/preset-env', { targets: "defaults" }] 25 | ] 26 | } 27 | } 28 | } 29 | ] 30 | } 31 | return []; 32 | } 33 | --------------------------------------------------------------------------------