From 044b01d9b06f0c90e1161c3f1239bc41a16bd553 Mon Sep 17 00:00:00 2001 From: Jose De Gouveia Date: Fri, 25 Oct 2019 12:34:25 +0200 Subject: [PATCH] Ported all code to Typescript to better organization All previous logic and gameplay reminds the same so probably all bugs still there --- .dockerignore | 4 + .eslintrc | 17 ++ .gitignore | 5 + .gitlab-ci.yml | 12 + CONTRIBUTING.md | 6 + Dockerfile | 20 ++ LICENSE | 21 ++ README.md | 69 ++++++ docker-compose.yml | 13 + index.html | 106 ++++++++ nodemon.json | 10 + package.json | 45 ++++ src/client/entities/Bullet.ts | 26 ++ src/client/entities/Entity.ts | 83 +++++++ src/client/entities/Player.ts | 197 +++++++++++++++ src/client/entities/StatsBox.ts | 57 +++++ src/client/index.ts | 11 + src/client/lib/Game.ts | 257 ++++++++++++++++++++ src/client/lib/WebSocketHandler.ts | 283 ++++++++++++++++++++++ src/client/webpack.config.js | 19 ++ src/common/IPlayer.ts | 7 + src/common/PlayersHandler.ts | 101 ++++++++ src/common/constants.ts | 32 +++ src/common/helpers.ts | 87 +++++++ src/server/dashboard/index.ts | 78 ++++++ src/server/dashboard/views/error.ejs | 3 + src/server/dashboard/views/home.ejs | 1 + src/server/dashboard/views/layout.ejs | 25 ++ src/server/dashboard/views/playerList.ejs | 56 +++++ src/server/events/IEventHandler.ts | 3 + src/server/events/PlayerEventsHandler.ts | 202 +++++++++++++++ src/server/events/ServerEventsHandler.ts | 48 ++++ src/server/index.ts | 22 ++ src/server/lib/Player.ts | 141 +++++++++++ src/server/lib/WebSocketHandler.ts | 97 ++++++++ tsconfig.json | 20 ++ tslint.json | 14 ++ 37 files changed, 2198 insertions(+) create mode 100644 .dockerignore create mode 100644 .eslintrc create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 index.html create mode 100644 nodemon.json create mode 100644 package.json create mode 100644 src/client/entities/Bullet.ts create mode 100644 src/client/entities/Entity.ts create mode 100644 src/client/entities/Player.ts create mode 100644 src/client/entities/StatsBox.ts create mode 100644 src/client/index.ts create mode 100644 src/client/lib/Game.ts create mode 100644 src/client/lib/WebSocketHandler.ts create mode 100644 src/client/webpack.config.js create mode 100644 src/common/IPlayer.ts create mode 100644 src/common/PlayersHandler.ts create mode 100644 src/common/constants.ts create mode 100644 src/common/helpers.ts create mode 100644 src/server/dashboard/index.ts create mode 100644 src/server/dashboard/views/error.ejs create mode 100644 src/server/dashboard/views/home.ejs create mode 100644 src/server/dashboard/views/layout.ejs create mode 100644 src/server/dashboard/views/playerList.ejs create mode 100644 src/server/events/IEventHandler.ts create mode 100644 src/server/events/PlayerEventsHandler.ts create mode 100644 src/server/events/ServerEventsHandler.ts create mode 100644 src/server/index.ts create mode 100644 src/server/lib/Player.ts create mode 100644 src/server/lib/WebSocketHandler.ts create mode 100644 tsconfig.json create mode 100644 tslint.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cbfe8a3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +build +node_modules +package-lock.json +*.log* \ No newline at end of file diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..aad801b --- /dev/null +++ b/.eslintrc @@ -0,0 +1,17 @@ +{ + "parserOptions": { + "sourceType": "module" + }, + "env": { + "node": true, + "es6": true + }, + "rules": { + "no-console": [true, "log", "debug", "error"], + "quotes": [2, "single", {"avoidEscape": true}], + "strict": [2, "never"] + }, + "extends": [ + "eslint:recommended" + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00bd086 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.vscode +build +node_modules +*.log +package-lock.json \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..a4c185d --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,12 @@ +image: node:10.16.3 + +cache: + paths: + - node_modules/ + +before_script: + - npm install --no-optional + +build: + script: + - npm test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..28cffdc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,6 @@ +## Contributing + +* If you find solution to an [issue/improvements](https://github.com/hgouveia/html5multiplayer/issues) would be helpful to everyone, feel free to send us a pull request. +* The ideal approach to create a fix would be to fork the repository, create a branch in your repository, and make a pull request out of it. +* It is desirable if there is enough comments/documentation and Tests included in the pull request. +* For general idea of contribution, please follow the guidelines mentioned [here](https://guides.github.com/activities/contributing-to-open-source/). \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0f22531 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Compiles webpack and typescript +FROM node:10 as BUILD +WORKDIR /tmp/app +COPY . . +RUN npm i --no-optional +ENV NODE_ENV=production +RUN npm run build + +# Deploy final app +FROM node:10 +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PORT=3478 +WORKDIR /usr/src/app +COPY --from=BUILD /tmp/app/package*.json /usr/src/app/ +COPY --from=BUILD /tmp/app/build/ /usr/src/app/build +COPY --from=BUILD /tmp/app/index.html /usr/src/app/ +RUN npm ci +EXPOSE $PORT +CMD [ "npm", "start" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8475206 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Jose De Gouveia + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3553da --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# Multiplayer Game + +A very basic multiplayer implementations using HTML5 Canvas and socket.io for learning purpose + +[Demo](https://game.joyalstudios.com/html5multiplayer) + +## Usage + +```bash +npm install +npm run build +``` + +Access via http://localhost:3478/play to play the game + +Access stats info via http://localhost:3478/list + +Running using **docker-compose** +```bash +docker-compose up -d +``` + +or plain docker + +Build: +```bash +docker build -t hgouveia/html-mp:latest . +``` +Run: +``` +docker run -d --name mp-game -e PORT=3478 -e DEBUG=ts-mp:* -p 3478:3478 hgouveia/html-mp:latest +``` + +if you want to hide the port, you could use Nginx Proxy + +```conf +server { + listen 80; + root /www/game; + server_name mydomain.com; + + location /mygame/ { + proxy_pass http://127.0.0.1:3478/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + } + + location / { + try_files $uri $uri/ =404; + } +} +``` + +## TODO +- Move HIT/DIE/BULLETS events to be processed on the server +- Add basic predictions +- Add security aspects (for ex: any player data could be altered via client) + +## License + +Read [License](LICENSE) for more licensing information. + +## Contributing + +Read [here](CONTRIBUTING.md) for more information. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3afec16 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3' +services: + node: + build: . + container_name: mp-server + restart: always + environment: + - NODE_ENV=production + - DEBUG=ts-mp:* + - HOST=0.0.0.0 + - PORT=3478 + ports: + - "3478:3478" \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..e47edb9 --- /dev/null +++ b/index.html @@ -0,0 +1,106 @@ + + + + + + + + + +
+

Block Multiplayer

+
+ Your browser does not support the canvas element. +
+
+
+ + + + + + + \ No newline at end of file diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..6c88b25 --- /dev/null +++ b/nodemon.json @@ -0,0 +1,10 @@ +{ + "watch": [ + "src" + ], + "ext": "ts", + "ignore": [ + "src/**/*.spec.ts" + ], + "exec": "ts-node ./src/server/index.ts" +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..2e31e24 --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "ts-mp", + "version": "1.0.0", + "description": "A simple multiplayer game using HTML5 Canvas in conjuction with nodejs", + "main": "build/server/index.js", + "scripts": { + "start": "npm run server:start", + "build": "npm run server:build && npm run client:build", + "server:start": "node build/server/index.js", + "server:start:dev": "npx cross-env DEBUG=ts-mp:* npx nodemon", + "server:build": "npx tsc && npm run server:build:cp:tpl", + "server:build:cp:tpl": "cp -R ./src/server/dashboard/views ./build/server/dashboard", + "client:build": "node_modules\\.bin\\webpack --config ./src/client/webpack.config.js", + "client:watch": "node_modules\\.bin\\webpack --config ./src/client/webpack.config.js -w", + "lint": "tslint -c tslint.json 'src/**/*.ts'", + "lint:fix": "tslint --fix -c tslint.json 'src/**/*.ts'", + "test": "npm run lint" + }, + "author": "Jose De Gouveia", + "engines": { + "node": "10.x" + }, + "license": "MIT", + "devDependencies": { + "@types/debug": "^4.1.4", + "@types/express": "^4.17.0", + "@types/node": "^12.6.8", + "@types/socket.io": "^2.1.2", + "@types/socket.io-client": "^1.4.32", + "crossenv": "0.0.2-security", + "nodemon": "^1.19.1", + "ts-loader": "^6.0.4", + "ts-node": "^8.3.0", + "tslint": "^5.20.0", + "typescript": "^3.5.3", + "webpack": "^4.36.1", + "webpack-cli": "^3.3.6" + }, + "dependencies": { + "debug": "^4.1.1", + "ejs": "^2.6.2", + "express": "^4.17.1", + "socket.io": "^2.2.0" + } +} diff --git a/src/client/entities/Bullet.ts b/src/client/entities/Bullet.ts new file mode 100644 index 0000000..0f1faa6 --- /dev/null +++ b/src/client/entities/Bullet.ts @@ -0,0 +1,26 @@ +import { toRad } from '../../common/helpers'; +import { Entity } from './Entity'; + +export class Bullet extends Entity { + private speed: number = 300; + private color: string = '#FFA420'; + + constructor(id: string, x: number, y: number, angle: number) { + super(id, x, y, angle, 5, 1); + } + + public update(elapsedTime: number): void { + this.advance(this.speed * elapsedTime); + } + + public draw(ctx: CanvasRenderingContext2D): void { + ctx.save(); + + ctx.fillStyle = this.color; + ctx.translate(this.x, this.y); + ctx.rotate(toRad(this.angle)); + ctx.fillRect(-this.width / 2 + 15, -this.height / 2, this.width, this.height); + + ctx.restore(); + } +} diff --git a/src/client/entities/Entity.ts b/src/client/entities/Entity.ts new file mode 100644 index 0000000..f7644ca --- /dev/null +++ b/src/client/entities/Entity.ts @@ -0,0 +1,83 @@ +import { intersectRect, IRect, toRad } from '../../common/helpers'; +import Game from '../lib/Game'; + +export interface IEntity { + id: string; + x: number; + y: number; + width: number; + height: number; + angle: number; + gameInstance: Game; + getRect(): IRect; + isOutOfScreen(limitX: number, limitY: number): boolean; + advance(speed: number): void; + update(elapsedTime: number): void; + draw(ctx: CanvasRenderingContext2D): void; + setGameInstance(game: Game): void; + intersect(target: IEntity): boolean; +} + +export abstract class Entity implements IEntity { + public gameInstance: Game = null; + + constructor( + public id: string, + public x: number, + public y: number, + public angle: number, + public width: number, + public height: number, + ) { } + + public advance(speed: number): void { + const ang: number = (this.angle < 0) + ? (this.angle % 360) + 360 + : this.angle % 360; + + this.x += speed * Math.cos(toRad(ang)); + this.y += speed * Math.sin(toRad(ang)); + } + + public getRect(): IRect { + const hW: number = this.width / 2; + const hH: number = this.height / 2; + return { + top: this.y - hH, + right: this.x + hW, + bottom: this.y + hH, + left: this.x - hW, + } as IRect; + } + + public isOutOfScreen(limitX: number, limitY: number): boolean { + const hW: number = this.width / 2; + const hH: number = this.height / 2; + return ( + this.x + hW > limitX || + this.y - hH < 0 || + this.y + hH > limitY || + this.x - hW < 0 + ); + } + + public intersect(target: IEntity): boolean { + return intersectRect(this.getRect(), target.getRect()); + } + + public setGameInstance(game: Game): void { + this.gameInstance = game; + } + + public toNetPackage(): any { + return { + id: this.id, + x: this.x, + y: this.y, + angle: this.angle, + }; + } + + public abstract update(elapsedTime: number): void; + public abstract draw(ctx: CanvasRenderingContext2D): void; +} diff --git a/src/client/entities/Player.ts b/src/client/entities/Player.ts new file mode 100644 index 0000000..3f9d3a1 --- /dev/null +++ b/src/client/entities/Player.ts @@ -0,0 +1,197 @@ +import { INPUT_KEY, PLAYER_EVENTS } from '../../common/constants'; +import { toRad } from '../../common/helpers'; +import { IPlayer } from '../../common/IPlayer'; +import { Bullet } from './Bullet'; +import { Entity } from './Entity'; + +export class Player extends Entity implements IPlayer { + public isDead: boolean = false; + private alpha: number = 1; + private speed: number = 150; + private deadCount: number = 0; + private killCount: number = 0; + private lifeDamage: number = 5; + private respawnWait: number = 1500; + private fontWidth: number = 0; + private fontSize: number = 12; + private lastX: number = 0; + private lastY: number = 0; + private lastKeyState: boolean = false; + private flickeringToggle: boolean = false; + private time: number = 0; + + constructor( + public id: string, + public name: string, + public x: number, + public y: number, + public angle: number, + public life: number, + public color: string, + public isLocalPlayer: boolean, + ) { + super(id, x, y, angle, 20, 20); + this.fontWidth = (name.length / 2) * 5; + } + + public getDead(): number { + return this.deadCount; + } + + public getKill(): number { + return this.killCount; + } + + public addKill(): void { + this.killCount++; + } + + public update(elapsedTime: number): void { + + if (!this.gameInstance) { + return; + } + + if (this.isDead) { + this.time += elapsedTime; + + // flickering + if (this.time > 0.1) { + this.time = 0; + this.alpha = (this.flickeringToggle) ? 1 : 0.5; + this.flickeringToggle = !this.flickeringToggle; + } + } + + // Keyboard + if (this.isLocalPlayer) { + this.localPlayerUpdate(elapsedTime); + } + } + + public draw(ctx: CanvasRenderingContext2D): void { + + if (this.isDead) { + ctx.globalAlpha = this.alpha; + } + + ctx.save(); // Save State - saved to be able to rotate only the following draw + + // Body + ctx.fillStyle = this.color; + ctx.translate(this.x, this.y); + ctx.rotate(toRad(this.angle)); + ctx.fillRect(-this.width / 2, -this.height / 2, this.width, this.height); + + const cannon: any = { + width: 10, + height: 3, + }; + ctx.fillStyle = '#999'; + ctx.fillRect(this.width - cannon.width, -cannon.height / 2, cannon.width, cannon.height); + + ctx.restore(); // End state + + // Life bar + const lifeBar: any = { + width: 30, + height: 5, + maxWidth: 30, + color: '#00FF00', + }; + + if (this.life <= 100) { lifeBar.color = '#00FF00'; } // Green - 00FF00 + if (this.life <= 75) { lifeBar.color = '#FFFF00'; } // Yellow - FFFF00 + if (this.life <= 50) { lifeBar.color = '#FFA420'; } // Orange - FFA420 + if (this.life <= 25) { lifeBar.color = '#FF0000'; } // Red - FF0000 + + lifeBar.width = (this.life * lifeBar.maxWidth) / 100; + ctx.fillStyle = lifeBar.color; + ctx.fillRect(this.x - lifeBar.width / 2, this.y - 30, lifeBar.width, lifeBar.height); + + // Name + ctx.fillStyle = '#FFF'; + ctx.font = this.fontSize + 'px Verdana'; + ctx.fillText(this.name, this.x - this.fontWidth, this.y - 40); + + // Restore Alpha + ctx.globalAlpha = 1; + } + + public hitBy(playerId: string): void { + if (this.life > 0) { + this.life -= this.lifeDamage; + this.send(PLAYER_EVENTS.HIT, { id: this.id, hitBy: playerId }); + } + } + + public killedBy(playerId: string): void { + this.isDead = true; + this.deadCount++; + setTimeout(() => this.wakeup(), this.respawnWait); + } + + private wakeup(): void { + this.life = 100; + this.isDead = false; + } + + private localPlayerUpdate(elapsedTime: number): void { + + if (!this.isDead) { + // Space - FIRE + const currentKeyState: boolean = this.gameInstance.getKeyStatus(INPUT_KEY.SPACE); + if (currentKeyState !== this.lastKeyState) { + const bullet: Bullet = new Bullet(this.id, this.x, this.y, this.angle); + this.send(PLAYER_EVENTS.SHOT, bullet.toNetPackage()); + this.gameInstance.bullets.push(bullet); + } + this.lastKeyState = currentKeyState; + } + + let isMovementUpdated: boolean = false; + + // Right + if (this.gameInstance.getKeyStatus(INPUT_KEY.RIGHT)) { + this.angle += this.speed * elapsedTime; + isMovementUpdated = true; + } + // Left + if (this.gameInstance.getKeyStatus(INPUT_KEY.LEFT)) { + this.angle -= this.speed * elapsedTime; + isMovementUpdated = true; + } + // Up + if (this.gameInstance.getKeyStatus(INPUT_KEY.UP)) { + this.advance(this.speed * elapsedTime); + isMovementUpdated = true; + } + // Down + if (this.gameInstance.getKeyStatus(INPUT_KEY.DOWN)) { + this.advance(-this.speed * elapsedTime); + isMovementUpdated = true; + } + + // Send local player data to the game server if detect movement + if (isMovementUpdated) { + this.send(PLAYER_EVENTS.MOVE, this.toNetPackage()); + } + + if (this.isOutOfScreen(this.gameInstance.options.width, this.gameInstance.options.height)) { + this.x = this.lastX; + this.y = this.lastY; + this.send(PLAYER_EVENTS.MOVE, this.toNetPackage()); + } + + this.lastX = this.x; + this.lastY = this.y; + } + + private send(type: number, data: any): void { + if (!this.gameInstance || !this.gameInstance.getWS()) { + return; + } + // TODO: DIE, HIT event's should be processed by the server + this.gameInstance.getWS().emit(type, data); + } +} diff --git a/src/client/entities/StatsBox.ts b/src/client/entities/StatsBox.ts new file mode 100644 index 0000000..c2b7a95 --- /dev/null +++ b/src/client/entities/StatsBox.ts @@ -0,0 +1,57 @@ +import { INPUT_KEY, SERVER_EVENTS } from '../../common/constants'; +import { Entity } from './Entity'; + +export class StatsBox extends Entity { + private statsList: any[] = []; + private lastKeyState: boolean = false; + private isVisible: boolean = false; + + constructor(x: number, y: number, width: number, height: number) { + super('statsBox', x, y, 0, width, height); + } + + public update(elapsedTime: number): void { + const currentKeyState: boolean = this.gameInstance.getKeyStatus(INPUT_KEY.L); + if (currentKeyState !== this.lastKeyState) { + // Request the stats from the server and wait until the response + this.gameInstance.getWS() + .emit(SERVER_EVENTS.STATS, (data: any[]) => this.statsList = data); + } + this.lastKeyState = currentKeyState; + + // Show while is key hold down + this.isVisible = currentKeyState; + } + + public draw(ctx: CanvasRenderingContext2D): void { + + if (this.isVisible && this.statsList.length) { + const tablePadding: number = 20; + const columnGap: number = this.width / 3; + ctx.save(); + + // Blue Rect + ctx.fillStyle = 'blue'; + ctx.globalAlpha = 0.2; + ctx.fillRect(this.x, this.y, this.width, this.height); + + // Font + ctx.fillStyle = 'white'; + ctx.globalAlpha = 1; + + // Header + ctx.fillText('Player:', this.x + tablePadding, this.y + tablePadding); + ctx.fillText('Kill:', this.x + columnGap * 1, this.y + tablePadding); + ctx.fillText('Dead:', this.x + columnGap * 2, this.y + tablePadding); + + // Stats + this.statsList.forEach((playerStat: any, index: number) => { + const incrementY: number = (this.y + (tablePadding * 2)) + (tablePadding * index); + ctx.fillText(playerStat.name, this.x + tablePadding, incrementY); + ctx.fillText(playerStat.kill, this.x + columnGap * 1, incrementY); + ctx.fillText(playerStat.dead, this.x + columnGap * 2, incrementY); + }); + ctx.restore(); + } + } +} diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..1f1bf7f --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,11 @@ +import Game, { IGameOptions } from './lib/Game'; + +window.addEventListener('load', (): void => { + const game: Game = new Game({ + host: location.hostname, + port: location.port ? parseInt(location.port, 10) : -1, + width: 1024, + height: 650, + canvasId: 'game-canvas', + } as IGameOptions); +}, true); diff --git a/src/client/lib/Game.ts b/src/client/lib/Game.ts new file mode 100644 index 0000000..5447ab0 --- /dev/null +++ b/src/client/lib/Game.ts @@ -0,0 +1,257 @@ +import { rand } from '../../common/helpers'; +import { PlayersHandler } from '../../common/PlayersHandler'; +import { Bullet } from '../entities/Bullet'; +import { Player } from '../entities/Player'; +import { StatsBox } from '../entities/StatsBox'; +import { WebSocketHandler } from './WebSocketHandler'; + +export interface IGameOptions { + host: string; + port: number; + width: number; + height: number; + canvasId: string; +} + +export class Game { + public ctx: CanvasRenderingContext2D = null; + public isRunning: boolean = false; + public inputKeys: { [key: string]: boolean } = {}; + public player: Player = null; + public bullets: Bullet[] = []; + + private log: HTMLElement = null; + private lastTime: number = 0; + private statsBox: StatsBox = null; + private logTimer: number = 0; + private wsHandler: WebSocketHandler = null; + private playersHandler: PlayersHandler = new PlayersHandler(); + + constructor(public options: IGameOptions) { + this.initCanvas(); + this.initInput(); + this.initPlayer(); + this.initWebSocket(); + this.initLog(); + this.initGameLoop(); + } + + public getWS(): WebSocketHandler { + return this.wsHandler; + } + + public getPlayer() { + return this.player; + } + + public getPlayersHandler(): PlayersHandler { + return this.playersHandler; + } + + public getKeyStatus(keyCode: string): boolean { + keyCode = keyCode.toLowerCase(); + + if (!this.inputKeys) { + return false; + } + + if (keyCode in this.inputKeys) { + return this.inputKeys[keyCode]; + } + return false; + } + + public addLog(msg: string, color: string = 'white'): void { + if (!this.log) { + return; + } + + const span: HTMLElement = document.createElement('span'); + if (color) { + span.classList.add(color); + } + span.classList.add('message'); + span.innerHTML = msg; + + this.logTimer = 0; + this.log.appendChild(span); + this.log.scrollTop = this.log.scrollHeight; + } + + private initCanvas(): void { + const canvas: HTMLCanvasElement = document.getElementById(this.options.canvasId) as HTMLCanvasElement; + const onResize = () => { + const style: CSSStyleDeclaration = window.getComputedStyle(canvas); + canvas.width = parseInt(style.getPropertyValue('width'), 10); + canvas.height = parseInt(style.getPropertyValue('height'), 10); + }; + + canvas.width = this.options.width; + canvas.height = this.options.height; + this.ctx = canvas.getContext('2d'); + this.ctx.font = '14px Monospace'; + + window.addEventListener('resize', onResize, false); + } + + private initInput(): void { + const onKeyEvent = (value: boolean): any => { + return (event: KeyboardEvent) => { + const charCode: string = event.key.toLowerCase(); + this.inputKeys[charCode] = value; + }; + }; + document.addEventListener('keydown', onKeyEvent(true), false); + document.addEventListener('keyup', onKeyEvent(false), false); + } + + private initPlayer(): void { + let playerName: string = 'Guest' + rand(1000, 2000); + const playerColor: string = '#0000ff'; + const bounds: number = 100; + const startX: number = rand(bounds, this.options.width - bounds); + const startY: number = rand(bounds, this.options.height - bounds); + const startAngle: number = 0; + const startLife: number = 100; + + const playerNameInput = prompt('Please enter your name', playerName); + if (playerNameInput != null) { + playerName = playerNameInput; + } + + this.player = new Player( + '0', + playerName, + startX, + startY, + startAngle, + startLife, + playerColor, + true, + ); + this.player.setGameInstance(this); + } + + private initWebSocket(): void { + const ignoredPorts = [80, 443, -1]; + let socketURL: string = `${location.protocol}//${this.options.host}`; + if (ignoredPorts.indexOf(this.options.port) === -1) { + socketURL += `:${this.options.port}`; + } + this.wsHandler = new WebSocketHandler(socketURL, this); + this.wsHandler.connect(); + } + + private initLog(): void { + this.log = document.getElementById('log'); + + const sbWidth: number = 600; + const sbHeight: number = 400; + this.statsBox = new StatsBox( + (this.options.width - sbWidth) / 2, + (this.options.height - sbHeight) / 2, + sbWidth, + sbHeight, + ); + this.statsBox.setGameInstance(this); + } + + private initGameLoop(): void { + const animloop: any = () => { + this.gameLoop(); + requestAnimationFrame(animloop); + }; + this.lastTime = Date.now(); + this.isRunning = true; + animloop(); + } + + private gameLoop(): void { + if (this.isRunning && this.ctx) { + this.update(); + this.draw(); + } + } + + private update(): void { + const elapsedTime: number = (Date.now() - this.lastTime) / 1000; + + // Remove first element every 5secs from the Chatlog + if (this.log) { + this.logTimer += elapsedTime; + if (this.logTimer > 5) { + const span: ChildNode = this.log.firstChild; + if (span) { + this.log.removeChild(span); + } + this.logTimer = 0; + } + } + + if (!this.player || (this.wsHandler && !this.wsHandler.isConnected)) { + return; + } + + this.player.update(elapsedTime); + this.statsBox.update(elapsedTime); + this.playersHandler.getPlayers().forEach((remotePlayer: Player) => remotePlayer.update(elapsedTime)); + + // Update Bullets, if go off the screen, delete + this.bullets.forEach((bullet) => { + if (bullet.isOutOfScreen(this.options.width, this.options.height)) { + this.bullets.splice(this.bullets.indexOf(bullet), 1); + return; + } + bullet.update(elapsedTime); + + if (!this.player.isDead && bullet.id !== this.player.id && bullet.intersect(this.player)) { + this.bullets.splice(this.bullets.indexOf(bullet), 1); + this.player.hitBy(bullet.id); + return; + } + + // remove bullet on players hit + this.playersHandler.getPlayers().forEach((remotePlayer: Player) => { + if (!remotePlayer.isDead && bullet.id !== remotePlayer.id && bullet.intersect(remotePlayer)) { + this.bullets.splice(this.bullets.indexOf(bullet), 1); + } + }); + + }); + + this.lastTime = Date.now(); + } + + private draw(): void { + this.ctx.clearRect(0, 0, this.options.width, this.options.height); + + if (this.wsHandler && !this.wsHandler.isConnected) { + const text: string = 'Connecting...'; + const textWidth = (text.length * 8) / 2; + this.ctx.fillStyle = '#FFF'; + this.ctx.fillText(text, (this.options.width / 2) - textWidth, (this.options.height / 2) - 8); + return; + } + + if (this.player) { + this.player.draw(this.ctx); + } + + this.playersHandler.getPlayers() + .forEach((remotePlayer: Player) => remotePlayer.draw(this.ctx)); + this.bullets.forEach((bullet) => bullet.draw(this.ctx)); + + // UI + if (this.player) { + this.ctx.fillStyle = '#FFF'; + this.ctx.fillText('Dead: ' + this.player.getDead(), 10, 20); + this.ctx.fillText('Kill: ' + this.player.getKill(), 100, 20); + this.ctx.fillText("Press 'L' key to display stats", this.options.height - 220, 20); + } + + if (this.statsBox) { + this.statsBox.draw(this.ctx); + } + } +} +export default Game; diff --git a/src/client/lib/WebSocketHandler.ts b/src/client/lib/WebSocketHandler.ts new file mode 100644 index 0000000..e898df1 --- /dev/null +++ b/src/client/lib/WebSocketHandler.ts @@ -0,0 +1,283 @@ +import { connect } from 'socket.io-client'; +import { PLAYER_EVENTS, SERVER_EVENTS, WS_EVENTS } from '../../common/constants'; +import { lerp } from '../../common/helpers'; +import { IPlayer } from '../../common/IPlayer'; +import { PlayersHandler } from '../../common/PlayersHandler'; +import { Bullet } from '../entities/Bullet'; +import { Player } from '../entities/Player'; +import Game from './Game'; + +/** + * + * + * @export + * @class WebSockerHandler + */ +export class WebSocketHandler { + + public isConnected: boolean = false; + + /** + * + * + * @protected + * @type {SocketIOClient.Socket} + * @memberof WebSockerHandler + */ + protected socket: SocketIOClient.Socket; + + /** + * + * + * @protected + * @type {{ [id: string]: SocketIOClient.Socket }} + * @memberof WebSockerHandler + */ + protected clients: { [id: string]: SocketIOClient.Socket } = {}; + + protected player: Player; + + protected playersHandler: PlayersHandler; + + /** + * Creates an instance of WebSockerHandler. + * @param {string} socketURL + * @param {Game} gameInstance + * @memberof WebSockerHandler + */ + constructor( + protected socketURL: string, + protected gameInstance: Game, + ) { + + this.player = gameInstance.getPlayer(); + this.playersHandler = gameInstance.getPlayersHandler(); + } + + /** + * + * + * @memberof WebSockerHandler + */ + public connect(): void { + this.socket = connect(this.socketURL, { path: `${location.pathname}socket.io` }); + this.socket.on(WS_EVENTS.SOCKET_CONNECT, this.onSocketConnected.bind(this)); + this.socket.on(WS_EVENTS.SOCKET_DISCONNECT, this.onSocketDisconnect.bind(this)); + this.socket.on(`${SERVER_EVENTS.PLAYER_LIST}`, (data: any[]) => this.onPlayerList(data)); + this.socket.on(`${SERVER_EVENTS.MESSAGE}`, (data: any) => this.onServerMessage(data)); + this.socket.on(`${PLAYER_EVENTS.JOIN}`, (data: any) => this.onPlayerJoin(data)); + this.socket.on(`${PLAYER_EVENTS.MOVE}`, (data: any) => this.onPlayerMove(data)); + this.socket.on(`${PLAYER_EVENTS.SHOT}`, (data: any) => this.onPlayerShot(data)); + this.socket.on(`${PLAYER_EVENTS.HIT}`, (data: any) => this.onPlayerHit(data)); + this.socket.on(`${PLAYER_EVENTS.DIE}`, (data: any) => this.onPlayerDie(data)); + this.socket.on(`${PLAYER_EVENTS.REMOVE}`, (data: any) => this.onPlayerDisconnect(data)); + this.socket.on(`${PLAYER_EVENTS.KILLED_PLAYER}`, (data: any) => this.onPlayerKilledPlayer(data)); + } + + /** + * + * + * @param {string} type + * @param {*} data + * @memberof WebSocketHandler + */ + public emit(type: number, data: any): void { + this.socket.emit(`${type}`, data); + } + + /** + * + * + * @protected + * @memberof WebSockerHandler + */ + protected onSocketConnected(): void { + this.player.id = this.socket.id; + this.gameInstance.addLog(`Connected to '${this.socketURL}' server`); + this.socket.emit(`${PLAYER_EVENTS.JOIN}`, + { name: this.player.name, ...this.player.toNetPackage() }, + () => { + this.isConnected = true; + this.gameInstance.addLog(`Join Game as "${this.player.name}"`, 'green'); + }); + } + + /** + * + * + * @protected + * @memberof WebSockerHandler + */ + protected onSocketDisconnect(): void { + this.gameInstance.addLog('Disconnected from socket server', 'red'); + this.isConnected = false; + this.playersHandler.clear(); + } + + /** + * + * + * @protected + * @param {*} data + * @memberof WebSocketHandler + */ + protected onServerMessage(data: any): void { + this.gameInstance.addLog(data.msg, data.color); + } + + /** + * + * + * @protected + * @param {*} data + * @memberof WebSocketHandler + */ + protected onPlayerJoin(data: any): void { + this.gameInstance.addLog(`New player connected: ${data.name}`, 'blue'); + this.playersHandler.addPlayer( + new Player( + data.id, + data.name, + data.x, + data.y, + data.angle, + data.life, + '#FF0000', + false, + ), + ); + } + + /** + * + * + * @protected + * @param {any[]} data + * @memberof WebSocketHandler + */ + protected onPlayerList(data: any[]): void { + this.playersHandler.clear(); + + data.forEach((player: any) => this.playersHandler.addPlayer( + new Player( + player.id, + player.name, + player.x, + player.y, + player.angle, + player.life, + '#FF0000', + false, + ), + )); + } + + /** + * + * + * @protected + * @param {*} data + * @returns {void} + * @memberof WebSocketHandler + */ + protected onPlayerMove(data: any): void { + const player: Player = this.playersHandler.getPlayerById(data.id) as Player; + + if (!player) { + return; + } + + const amount = 0.5; + player.angle = lerp(player.angle, data.angle, amount); + player.x = lerp(player.x, data.x, amount); + player.y = lerp(player.y, data.y, amount); + } + + /** + * + * + * @protected + * @param {*} data + * @returns {void} + * @memberof WebSocketHandler + */ + protected onPlayerShot(data: any): void { + const player: Player = this.playersHandler.getPlayerById(data.id) as Player; + + if (!player) { + return; + } + + this.gameInstance.bullets.push(new Bullet(data.id, data.x, data.y, data.angle)); + } + + /** + * + * + * @protected + * @param {*} data + * @returns {void} + * @memberof WebSocketHandler + */ + protected onPlayerHit(data: any): void { + const player: Player = this.playersHandler.getPlayerById(data.id) as Player; + + if (!player) { + return; + } + + player.life = data.life; + } + + /** + * + * + * @protected + * @param {*} data + * @memberof WebSocketHandler + */ + protected onPlayerKilledPlayer(data: any): void { + this.player.addKill(); + this.gameInstance.addLog(`You killed ${data.name}`, 'orange'); + } + + /** + * + * + * @protected + * @param {*} data + * @returns {void} + * @memberof WebSocketHandler + */ + protected onPlayerDie(data: any): void { + const player: Player = (data.id === this.player.id) + ? this.player + : this.playersHandler.getPlayerById(data.id) as Player; + + if (!player) { + return; + } + + player.killedBy(data.killedBy); + const killedBy: IPlayer = data.killedBy === this.player.id + ? this.player + : this.playersHandler.getPlayerById(data.killedBy); + + this.gameInstance.addLog( + `${player.name} was killed by ${killedBy.name}`, + 'blue', + ); + } + + /** + * + * + * @protected + * @param {*} data + * @memberof WebSocketHandler + */ + protected onPlayerDisconnect(data: any): void { + const removedPlayer: IPlayer = this.playersHandler.removePlayerById(data.id); + this.gameInstance.addLog(`${removedPlayer.name || 'unknown'} has disconnected`, 'red'); + } +} diff --git a/src/client/webpack.config.js b/src/client/webpack.config.js new file mode 100644 index 0000000..b21c4fc --- /dev/null +++ b/src/client/webpack.config.js @@ -0,0 +1,19 @@ +const path = require('path'); + +module.exports = { + mode: process.env.NODE_ENV || "development", + devtool: "source-map", + entry: './src/client/index.ts', + output: { + filename: 'bundle.js', + path: path.resolve(__dirname, '../../build/client') + }, + resolve: { + extensions: ['.ts', '.js'] + }, + module: { + rules: [ + { test: /\.ts$/, loader: 'ts-loader' } + ] + } +}; diff --git a/src/common/IPlayer.ts b/src/common/IPlayer.ts new file mode 100644 index 0000000..2c3d922 --- /dev/null +++ b/src/common/IPlayer.ts @@ -0,0 +1,7 @@ +export interface IPlayer { + id: string; + name: string; + x: number; + y: number; + angle: number; +} diff --git a/src/common/PlayersHandler.ts b/src/common/PlayersHandler.ts new file mode 100644 index 0000000..da0b3f0 --- /dev/null +++ b/src/common/PlayersHandler.ts @@ -0,0 +1,101 @@ +import { IPlayer } from './IPlayer'; + +/** + * + * + * @export + * @class PlayersHandler + */ +export class PlayersHandler { + + /** + * + * + * @protected + * @type {IPlayer[]} + * @memberof PlayersHandler + */ + protected players: IPlayer[] = []; + protected removedPlayers: IPlayer[] = []; + + /** + * + * + * @returns {IPlayer[]} + * @memberof PlayersHandler + */ + public getPlayers(): IPlayer[] { + return this.players; + } + + /** + * + * + * @returns {IPlayer[]} + * @memberof PlayersHandler + */ + public getRemovedPlayers(): IPlayer[] { + return this.removedPlayers; + } + + /** + * + * + * @param {IPlayer} player + * @memberof PlayersHandler + */ + public addPlayer(player: IPlayer): void { + this.players.push(player); + } + + /** + * + * + * @param {IPlayer} player + * @memberof PlayersHandler + */ + public updatePlayer(player: IPlayer): void { + this.players = this.players.map( + (p) => p.id === player.id ? { p, ...player } : p, + ) as IPlayer[]; + } + + /** + * + * + * @param {string} id + * @returns {IPlayer} + * @memberof PlayersHandler + */ + public removePlayerById(id: string): IPlayer { + const removedPlayer = this.getPlayerById(id); + if (removedPlayer) { + this.removedPlayers.push(removedPlayer); + } + this.players = this.players.filter((p) => p.id !== id); + + return removedPlayer; + } + + /** + * + * + * @param {string} id + * @returns {IPlayer} + * @memberof PlayersHandler + */ + public getPlayerById(id: string): IPlayer { + return this.players.find((player) => player.id === id) || null; + } + + /** + * + * + * @memberof PlayersHandler + */ + public clear(): void { + this.players = []; + this.removedPlayers = []; + } + +} diff --git a/src/common/constants.ts b/src/common/constants.ts new file mode 100644 index 0000000..df2fdd0 --- /dev/null +++ b/src/common/constants.ts @@ -0,0 +1,32 @@ +export const WS_EVENTS = { + SOCKET_CONNECT: 'connect', + SOCKET_CONNECTION: 'connection', + SOCKET_DISCONNECT: 'disconnect', + CLIENT_DISCONNECT: 'disconnect', +}; + +export enum SERVER_EVENTS { + MESSAGE = 100, + PLAYER_LIST, + STATS, +} + +export enum PLAYER_EVENTS { + JOIN = 200, + MOVE, + SHOT, + HIT, + DIE, + REMOVE, + SENT_MESSAGE, + KILLED_PLAYER, +} + +export const INPUT_KEY = { + UP: 'ArrowUp', + DOWN: 'ArrowDown', + LEFT: 'ArrowLeft', + RIGHT: 'ArrowRight', + SPACE: ' ', + L: 'l', +}; diff --git a/src/common/helpers.ts b/src/common/helpers.ts new file mode 100644 index 0000000..ea5d4be --- /dev/null +++ b/src/common/helpers.ts @@ -0,0 +1,87 @@ +/** + * + * + * @export + * @interface IRect + */ +export interface IRect { left: number; right: number; bottom: number; top: number; } + +/** + * + * + * @export + * @param {Date} date + * @returns + */ +export function toTimestamp(date: Date): number { + const newDate = new Date( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate(), + 0, 0, 0, 0, + ); + return Math.ceil((newDate.getTime() + -(newDate.getTimezoneOffset() * 60000)) / 1000); +} + +/** + * + * + * @export + * @doc http://processingjs.org/reference/lerp_/ + * @param {number} start + * @param {number} end + * @param {number} amount - between 0.0 and 1.0 + * @returns + */ +export function lerp(start: number, end: number, amount: number): number { + return (1 - amount) * start + amount * end; +} + +/** + * random number + * + * @export + * @param {number} min + * @param {number} max + * @returns {number} + */ +export function rand(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1) + min); +} + +/** + * Degree to Radians + * + * @export + * @param {number} degree + * @returns {number} + */ +export function toRad(degree: number): number { + return degree * Math.PI / 180; +} + +/** + * Radians to Degree + * + * @export + * @param {number} rad + * @returns {number} + */ +export function toDegree(rad: number): number { + return rad * 180 / Math.PI; +} + +/** + * intersect between 2 rectangle + * + * @export + * @param {IRect} r1 + * @param {IRect} r2 + * @returns {boolean} + */ +export function intersectRect(r1: IRect, r2: IRect): boolean { + return !(r2.left > r1.right || + r2.right < r1.left || + r2.top > r1.bottom || + r2.bottom < r1.top); +} diff --git a/src/server/dashboard/index.ts b/src/server/dashboard/index.ts new file mode 100644 index 0000000..afd7279 --- /dev/null +++ b/src/server/dashboard/index.ts @@ -0,0 +1,78 @@ +import { Application, NextFunction, Request, Response, static as eStatic } from 'express'; +import { resolve as pathResolve } from 'path'; +import { PlayersHandler } from '../../common/PlayersHandler'; +/** + * + * + * @export + * @class Dashboard + */ +export class Dashboard { + private rootPath = pathResolve(__dirname, '../../../'); + + constructor( + protected app: Application, + protected playersHandler: PlayersHandler, + ) { } + + /** + * + * + * @memberof Dashboard + */ + public init(): void { + // express setup + this.app.set('views', __dirname + '/views'); + this.app.set('view engine', 'ejs'); + // this.app.use(eStatic(__dirname)); + this.app.use(eStatic(this.rootPath)); + this.app.use(this.errorHandler); + this.routes(); + } + + /** + * + * + * @private + * @memberof Dashboard + */ + private routes(): void { + // Home + this.app.get('/', (req: Request, res: Response) => { + res.render('layout', { section: 'home', body: '

Server

' }); + }); + + // Play + this.app.get('/play', (req: Request, res: Response) => { + res.sendFile(`${this.rootPath}/index.html`); + }); + + // List + this.app.get('/list', (req: Request, res: Response) => { + res.render('layout', { + section: 'playerList', + players: this.playersHandler.getPlayers(), + removedPlayers: this.playersHandler.getRemovedPlayers(), + }); + }); + } + + /** + * + * + * @private + * @param {*} err + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @memberof Dashboard + */ + private errorHandler(err: any, req: Request, res: Response, next: NextFunction): void { + res.status(err.status || 500); + res.render('layout', { + section: 'error', + message: err.message, + error: (this.app.get('env') === 'development') ? err : {}, + }); + } +} diff --git a/src/server/dashboard/views/error.ejs b/src/server/dashboard/views/error.ejs new file mode 100644 index 0000000..e5102d4 --- /dev/null +++ b/src/server/dashboard/views/error.ejs @@ -0,0 +1,3 @@ +

<%=message%>

+

<%=error.status%>

+
<%=error.stack%>
\ No newline at end of file diff --git a/src/server/dashboard/views/home.ejs b/src/server/dashboard/views/home.ejs new file mode 100644 index 0000000..59593a0 --- /dev/null +++ b/src/server/dashboard/views/home.ejs @@ -0,0 +1 @@ +<%- body %> \ No newline at end of file diff --git a/src/server/dashboard/views/layout.ejs b/src/server/dashboard/views/layout.ejs new file mode 100644 index 0000000..3090ce6 --- /dev/null +++ b/src/server/dashboard/views/layout.ejs @@ -0,0 +1,25 @@ + + + + + + Server + + + + + + + + + + + +
+ <%- include(section) %> +
+ + + \ No newline at end of file diff --git a/src/server/dashboard/views/playerList.ejs b/src/server/dashboard/views/playerList.ejs new file mode 100644 index 0000000..a323874 --- /dev/null +++ b/src/server/dashboard/views/playerList.ejs @@ -0,0 +1,56 @@ +
+

Active Players (<%=players.length%>)

+
+ + + + + + + + + + + + <% for (let player of players) {%> + + + + + + + + + + <% } %> + +
+ IDLifeDeadKillXY
<%= player.id %><%= player.name %><%= player.getLife() %><%= player.getDead() %><%= player.getKill() %><%= player.x %><%= player.y %>
+ +

History Players (<%=removedPlayers.length%>)

+
+ + + + + + + + + + + + <% for (let player of removedPlayers) {%> + + + + + + + + + + <% } %> + +
+ IDLifeDeadKillXY
<%= player.id %><%= player.name %><%= player.getLife() %><%= player.getDead() %><%= player.getKill() %><%= player.x %><%= player.y %>
\ No newline at end of file diff --git a/src/server/events/IEventHandler.ts b/src/server/events/IEventHandler.ts new file mode 100644 index 0000000..853fc48 --- /dev/null +++ b/src/server/events/IEventHandler.ts @@ -0,0 +1,3 @@ +export interface IEventHandler { + attachEvents(client: SocketIO.Socket): void; +} diff --git a/src/server/events/PlayerEventsHandler.ts b/src/server/events/PlayerEventsHandler.ts new file mode 100644 index 0000000..64949af --- /dev/null +++ b/src/server/events/PlayerEventsHandler.ts @@ -0,0 +1,202 @@ +import debug, { Debugger } from 'debug'; +import { PLAYER_EVENTS, SERVER_EVENTS, WS_EVENTS } from '../../common/constants'; +import { IPlayer } from '../../common/IPlayer'; +import { PlayersHandler } from '../../common/PlayersHandler'; +import { Player } from '../lib/Player'; +import { IEventHandler } from './IEventHandler'; + +const dinfo: Debugger = debug('ts-mp:server:ws:playerevents'); + +export class PlayerEventsHandler implements IEventHandler { + + constructor( + protected socket: SocketIO.Server, + protected clients: { [id: string]: SocketIO.Socket }, + protected playersHandler: PlayersHandler, + ) { } + + /** + * + * + * @param {SocketIO.Socket} client + * @memberof PlayerEventsHandler + */ + public attachEvents(client: SocketIO.Socket): void { + + // Default events from websocket + client.on(WS_EVENTS.CLIENT_DISCONNECT, () => this.onClientDisconnect(client)); + + // Listen for new player + client.on(`${PLAYER_EVENTS.JOIN}`, (data: any, successFn: any) => + this.onPlayerJoined(client, data, successFn)); + + // Listen for move player + client.on(`${PLAYER_EVENTS.MOVE}`, (data: any) => this.onMovePlayer(client, data)); + + // Listen for player fire + client.on(`${PLAYER_EVENTS.SHOT}`, (data: any) => this.onFireBullet(client, data)); + + // Listen for player hit + client.on(`${PLAYER_EVENTS.HIT}`, (data: any) => this.onPlayerHit(client, data)); + + } + + /** + * + * + * @protected + * @param {SocketIO.Socket} client + * @param {*} data + * @memberof PlayerEventsHandler + */ + protected onPlayerJoined(client: SocketIO.Socket, data: any, successFn: any): void { + dinfo(`player joined: ${data.name} - [${client.id}]`); + + const player: Player = new Player( + client.id, + data.name, + data.x || 0, + data.y || 0, + data.angle || 0, + client, + ); + + const playerList: IPlayer[] = this.playersHandler.getPlayers() + .map((p: Player) => p.toNetPackage()); + this.playersHandler.addPlayer(player); + + // Notifies to the user that succesfuly joined to the game + successFn(); + + // Broadcast new player to connected socket clients + client.broadcast.emit(`${PLAYER_EVENTS.JOIN}`, player.toNetPackage()); + // Sent all connected player to the player + client.emit(`${SERVER_EVENTS.PLAYER_LIST}`, playerList); + } + + /** + * + * + * @protected + * @param {SocketIO.Socket} client + * @memberof PlayerEventsHandler + */ + protected onClientDisconnect(client: SocketIO.Socket): void { + dinfo(`Player has disconnected: ${client.id}`); + + // Remove from the list + this.playersHandler.removePlayerById(client.id); + + // Broadcast removed player to remaning connected socket clients + client.broadcast.emit(`${PLAYER_EVENTS.REMOVE}`, { id: client.id }); + } + + /** + * + * + * @protected + * @param {SocketIO.Socket} client + * @param {*} data + * @returns + * @memberof PlayerEventsHandler + */ + protected onMovePlayer(client: SocketIO.Socket, data: any): void { + const player: Player = this.playersHandler.getPlayerById(client.id) as Player; + + if (!player) { + dinfo(`Player not found: ${client.id}`); + return; + } + + // Update player position + player.angle = data.angle; + player.x = data.x; + player.y = data.y; + + // Broadcast updated position to others players + client.broadcast.emit(`${PLAYER_EVENTS.MOVE}`, player.toNetPackage()); + } + + /** + * + * + * @protected + * @param {SocketIO.Socket} client + * @param {*} data + * @returns + * @memberof PlayerEventsHandler + */ + protected onFireBullet(client: SocketIO.Socket, data: any): void { + const player: Player = this.playersHandler.getPlayerById(client.id) as Player; + + if (!player) { + dinfo(`Player not found: ${client.id}`); + return; + } + + // Relay bullet spawn point from the player to all players + client.broadcast.emit(`${PLAYER_EVENTS.SHOT}`, { + id: player.id, + x: data.x, + y: data.y, + angle: data.angle, + }); + } + + /** + * + * + * @protected + * @param {SocketIO.Socket} client + * @param {*} data + * @memberof PlayerEventsHandler + */ + protected onPlayerHit(client: SocketIO.Socket, data: any): void { + const player: Player = this.playersHandler.getPlayerById(client.id) as Player; + + // Calculate the hit damage + player.hit(); + + // Relay to other players + client.broadcast.emit(`${PLAYER_EVENTS.HIT}`, { + id: player.id, + hitBy: data.hitBy, + life: player.getLife(), + }); + + // check if player is dead + if (player.getLife() <= 0) { + this.onPlayerDead(client, player, data); + } + } + + /** + * + * + * @private + * @param {SocketIO.Socket} client + * @param {Player} player + * @param {*} data + * @memberof PlayerEventsHandler + */ + private onPlayerDead(client: SocketIO.Socket, player: Player, data: any): void { + + // Reset player attributes + player.dead(); + + // Send to all players + this.socket.emit(`${PLAYER_EVENTS.DIE}`, { id: player.id, killedBy: data.hitBy }); + + let killedByName = 'unknown'; + const killerPlayer: Player = this.playersHandler.getPlayerById(data.hitBy) as Player; + + if (killerPlayer) { + killerPlayer.addKill(); + killedByName = killerPlayer.name; + this.clients[data.hitBy].emit(`${PLAYER_EVENTS.KILLED_PLAYER}`, { id: player.id, name: player.name }); + } + + dinfo(`Player ${player.name} was killed by ${killedByName}`); + } + +} diff --git a/src/server/events/ServerEventsHandler.ts b/src/server/events/ServerEventsHandler.ts new file mode 100644 index 0000000..261b462 --- /dev/null +++ b/src/server/events/ServerEventsHandler.ts @@ -0,0 +1,48 @@ +import debug, { Debugger } from 'debug'; +import { SERVER_EVENTS } from '../../common/constants'; +import { PlayersHandler } from '../../common/PlayersHandler'; +import { Player } from '../lib/Player'; +import { IEventHandler } from './IEventHandler'; + +const dinfo: Debugger = debug('ts-mp:server:ws:playerevents'); + +export class ServerEventsHandler implements IEventHandler { + + constructor( + protected socket: SocketIO.Server, + protected clients: { [id: string]: SocketIO.Socket }, + protected playersHandler: PlayersHandler, + ) { } + + /** + * + * + * @param {SocketIO.Socket} client + * @memberof PlayerEventsHandler + */ + public attachEvents(client: SocketIO.Socket): void { + // Listen for player stats request + client.on(`${SERVER_EVENTS.STATS}`, (responseFn: any) => this.onPlayersStats(client, responseFn)); + } + + /** + * + * + * @protected + * @param {SocketIO.Socket} client + * @memberof PlayerEventsHandler + */ + protected onPlayersStats(client: SocketIO.Socket, responseFn: any): void { + const playersStats = this.playersHandler.getPlayers() + .reduce((acc: any[], player: Player) => { + acc.push({ + name: player.name, + kill: player.getKill(), + dead: player.getDead(), + }); + return acc; + }, []); + responseFn(playersStats); + dinfo(`Players Stats[${playersStats.length}]`, playersStats); + } +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..6a8521f --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,22 @@ +import debug, { Debugger } from 'debug'; +import express, { Application } from 'express'; +import { Server as httpServer } from 'http'; +import { PlayersHandler } from '../common/PlayersHandler'; +import { Dashboard } from './dashboard'; +import { WebSocketHandler } from './lib/WebSocketHandler'; + +const PORT: number = parseInt(process.env.PORT, 10) || 3478; +const HOST: string = process.env.HOST || '0.0.0.0'; +const dinfo: Debugger = debug('ts-mp:server'); + +const app: Application = express(); +const server: httpServer = app.listen(PORT, HOST); +const playersHandler: PlayersHandler = new PlayersHandler(); +const wsHandler: WebSocketHandler = new WebSocketHandler(server, playersHandler); +const dashboard: Dashboard = new Dashboard(app, playersHandler); + +wsHandler.init(); +dashboard.init(); + +dinfo(`Server Initialized ${HOST}:${PORT}`); +dinfo(`Dashboard Initialized http://${HOST}:${PORT}/list`); diff --git a/src/server/lib/Player.ts b/src/server/lib/Player.ts new file mode 100644 index 0000000..a639c94 --- /dev/null +++ b/src/server/lib/Player.ts @@ -0,0 +1,141 @@ +import { IPlayer } from '../../common/IPlayer'; + +/** + * + * + * @export + * @class Player + */ +export class Player implements IPlayer { + /** + * + * + * @protected + * @type {number} + * @memberof Player + */ + protected life: number = 100; + + /** + * + * + * @protected + * @type {number} + * @memberof Player + */ + protected lifeDamage: number = 5; + + /** + * + * + * @protected + * @type {number} + * @memberof Player + */ + protected deadCount: number = 0; + + /** + * + * + * @protected + * @type {number} + * @memberof Player + */ + protected killCount: number = 0; + + /** + * Creates an instance of Player. + * @param {string} id + * @param {string} name + * @param {number} x + * @param {number} y + * @param {number} angle + * @param {SocketIO.Socket} client + * @memberof Player + */ + constructor( + public id: string, + public name: string, + public x: number, + public y: number, + public angle: number, + public client: SocketIO.Socket, + ) { } + + /** + * + * + * @returns {number} + * @memberof Player + */ + public getLife(): number { + return this.life; + } + + /** + * + * + * @returns {number} + * @memberof Player + */ + public getDead(): number { + return this.deadCount; + } + + /** + * + * + * @returns {number} + * @memberof Player + */ + public getKill(): number { + return this.killCount; + } + + /** + * + * + * @memberof Player + */ + public addKill(): void { + this.killCount++; + } + + /** + * + * + * @memberof Player + */ + public hit(): void { + if (this.life > 0) { + this.life -= this.lifeDamage; + } + } + + /** + * + * + * @memberof Player + */ + public dead(): void { + this.life = 100; + this.deadCount++; + } + + /** + * + * + * @returns {*} + * @memberof Player + */ + public toNetPackage(): any { + return { + id: this.id, + name: this.name, + x: this.x, + y: this.y, + angle: this.angle, + life: this.life, + }; + } +} diff --git a/src/server/lib/WebSocketHandler.ts b/src/server/lib/WebSocketHandler.ts new file mode 100644 index 0000000..e267df2 --- /dev/null +++ b/src/server/lib/WebSocketHandler.ts @@ -0,0 +1,97 @@ +import debug, { Debugger } from 'debug'; +import { Server as httpServer } from 'http'; +import { Server as httpsServer } from 'https'; +import { listen } from 'socket.io'; +import { WS_EVENTS } from '../../common/constants'; +import { PlayersHandler } from '../../common/PlayersHandler'; +import { PlayerEventsHandler } from '../events/PlayerEventsHandler'; +import { ServerEventsHandler } from '../events/ServerEventsHandler'; +const dinfo: Debugger = debug('ts-mp:server:ws'); + +/** + * + * + * @export + * @class WebSockerHandler + */ +export class WebSocketHandler { + /** + * + * + * @protected + * @type {SocketIO.Server} + * @memberof WebSockerHandler + */ + protected socket: SocketIO.Server; + + /** + * + * + * @protected + * @type {ServerEventsHandler} + * @memberof WebSocketHandler + */ + protected serverEventsHandler: ServerEventsHandler; + + /** + * + * + * @protected + * @type {PlayerEventsHandler} + * @memberof WebSocketHandler + */ + protected playerEventsHandler: PlayerEventsHandler; + + /** + * + * + * @protected + * @type {{ [id: string]: SocketIO.Socket }} + * @memberof WebSockerHandler + */ + protected clients: { [id: string]: SocketIO.Socket } = {}; + + /** + * Creates an instance of WebSockerHandler. + * @param {(httpServer | httpsServer)} server + * @param {PlayersHandler} playersHandler + * @memberof WebSockerHandler + */ + constructor( + protected server: httpServer | httpsServer, + protected playersHandler: PlayersHandler, + ) { } + + /** + * + * + * @memberof WebSockerHandler + */ + public init(): void { + this.socket = listen(this.server); + this.socket.sockets.on(WS_EVENTS.SOCKET_CONNECTION, this.attachClientEventsOnConnection.bind(this)); + this.serverEventsHandler = new ServerEventsHandler(this.socket, this.clients, this.playersHandler); + this.playerEventsHandler = new PlayerEventsHandler(this.socket, this.clients, this.playersHandler); + + dinfo('WS Initialized, is secure:', this.server instanceof httpsServer); + } + + /** + * + * + * @protected + * @param {SocketIO.Socket} client + * @memberof WebSockerHandler + */ + protected attachClientEventsOnConnection(client: SocketIO.Socket): void { + dinfo('New player has connected: ', client.id); + + // store client + this.clients[client.id] = client; + + // Attach Events + this.serverEventsHandler.attachEvents(client); + this.playerEventsHandler.attachEvents(client); + } + +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b11da3d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "esModuleInterop": true, + "noImplicitAny": true, + "target": "es5", + "outDir": "./build", + "allowJs": true, + "sourceMap": true + }, + "include": [ + "./src/**/*" + ], + "exclude": [ + "node_modules", + "**/*.spec.ts", + "./src/client/*" + ] +} \ No newline at end of file diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..dc32a8b --- /dev/null +++ b/tslint.json @@ -0,0 +1,14 @@ +{ + "defaultSeverity": "error", + "extends": [ + "tslint:recommended" + ], + "jsRules": {}, + "rules": { + "no-console": [true, "log", "debug", "error"], + "object-literal-sort-keys": false, + "only-arrow-functions": false, + "quotemark": [true, "single", "avoid-escape"] + }, + "rulesDirectory": [] +} \ No newline at end of file